diff --git a/pyproject.toml b/pyproject.toml index 4a00ccd..de8f3dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "twitter-cli" -version = "0.8.5" +version = "0.8.6" description = "A CLI for Twitter/X — feed, bookmarks, and user timeline in terminal" readme = "README.md" license = "Apache-2.0" diff --git a/tests/test_cli.py b/tests/test_cli.py index 8e00eb9..cdf74ce 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -228,6 +228,40 @@ def test_cli_article_markdown_overrides_auto_structured_output(monkeypatch) -> N assert result.output == article_to_markdown(article) +def test_cli_article_json_output_file_uses_structured_format(monkeypatch, tmp_path) -> None: + article = Tweet( + id="12345", + text="https://t.co/article", + author=Author(id="u1", name="Alice", screen_name="alice"), + metrics=Metrics(likes=1, retweets=2, replies=3, views=4, bookmarks=5), + created_at="2026-03-11", + article_title="Title", + article_text="Hello\n\n## Section", + ) + + class FakeClient: + def fetch_article(self, tweet_id: str) -> Tweet: + assert tweet_id == "12345" + return article + + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) + monkeypatch.setattr("twitter_cli.cli.load_config", lambda: {}) + output_path = tmp_path / "article.json" + runner = CliRunner() + + result = runner.invoke( + cli, + ["article", "12345", "--json", "--output", str(output_path)], + ) + + assert result.exit_code == 0 + stdout_payload = yaml.safe_load(result.output) + assert stdout_payload["ok"] is True + saved_payload = json.loads(output_path.read_text(encoding="utf-8")) + assert saved_payload["id"] == "12345" + assert saved_payload["articleTitle"] == "Title" + + def test_cli_article_rejects_compact_mode() -> None: runner = CliRunner() @@ -450,6 +484,26 @@ def test_cli_post_json_output(monkeypatch) -> None: assert payload["data"]["id"] == "999" +def test_cli_post_reply_to_accepts_status_url(monkeypatch) -> None: + calls = [] + + class FakeClient: + def create_tweet(self, text: str, reply_to_id=None, media_ids=None) -> str: + calls.append({"text": text, "reply_to_id": reply_to_id}) + return "999" + + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) + runner = CliRunner() + + result = runner.invoke( + cli, + ["post", "hello", "--reply-to", "https://x.com/alice/status/12345?s=20"], + ) + + assert result.exit_code == 0 + assert calls == [{"text": "hello", "reply_to_id": "12345"}] + + def test_cli_like_yaml_output(monkeypatch) -> None: class FakeClient: def like_tweet(self, tweet_id: str) -> bool: diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index 928b88d..88784ec 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -32,6 +32,7 @@ Write commands: from __future__ import annotations +import json import logging import re import sys @@ -42,6 +43,7 @@ from typing import Any, Callable, Dict, List, Optional import click from rich.console import Console +import yaml from . import __version__ from .auth import get_cookies @@ -935,7 +937,8 @@ def article(ctx, tweet_id, as_json, as_yaml, as_markdown, output_file): tweet_id = _normalize_tweet_id(tweet_id) config = load_config() - rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=False) and not as_markdown + mode = _structured_mode(as_json=as_json, as_yaml=as_yaml) + rich_output = (mode is None) and not as_markdown try: client = _get_client(config, quiet=not rich_output) if rich_output: @@ -948,16 +951,28 @@ def article(ctx, tweet_id, as_json, as_yaml, as_markdown, output_file): except (TwitterError, RuntimeError) as exc: _exit_with_error(exc) + article_data = tweet_to_dict(article_tweet) markdown = article_to_markdown(article_tweet) if output_file: - Path(output_file).write_text(markdown, encoding="utf-8") + if as_markdown or mode is None: + rendered_output = markdown + elif mode == "json": + rendered_output = json.dumps(article_data, ensure_ascii=False, indent=2) + else: + rendered_output = yaml.safe_dump( + article_data, + allow_unicode=True, + sort_keys=False, + default_flow_style=False, + ) + Path(output_file).write_text(rendered_output, encoding="utf-8") if rich_output: - console.print("💾 Saved article Markdown to %s\n" % output_file) + console.print("💾 Saved article output to %s\n" % output_file) if as_markdown: click.echo(markdown, nl=False) return - if emit_structured(tweet_to_dict(article_tweet), as_json=as_json, as_yaml=as_yaml): + if emit_structured(article_data, as_json=as_json, as_yaml=as_yaml): return print_article(article_tweet, console) @@ -1098,12 +1113,13 @@ def post(text, reply_to, images, as_json, as_yaml): twitter post "Hello!" --image photo.jpg twitter post "Gallery" -i a.png -i b.png -i c.jpg """ - action = "Replying to %s" % reply_to if reply_to else "Posting tweet" + normalized_reply_to = _normalize_tweet_id(reply_to) if reply_to else None + action = "Replying to %s" % normalized_reply_to if normalized_reply_to else "Posting tweet" rich_output = not _structured_mode(as_json=as_json, as_yaml=as_yaml) def operation(client: TwitterClient) -> WritePayload: media_ids = _upload_images(client, images, rich_output=rich_output) - tweet_id = client.create_tweet(text, reply_to_id=reply_to, media_ids=media_ids or None) + tweet_id = client.create_tweet(text, reply_to_id=normalized_reply_to, media_ids=media_ids or None) return {"success": True, "action": "post", "id": tweet_id, "url": "https://x.com/i/status/%s" % tweet_id} payload = _run_write_command( @@ -1112,7 +1128,7 @@ def post(text, reply_to, images, as_json, as_yaml): operation=operation, progress_lines=["✏️ %s..." % action], success_lines=["[green]✅ Tweet posted![/green]"], - error_details={"action": "post", "replyTo": reply_to}, + error_details={"action": "post", "replyTo": normalized_reply_to}, ) if payload and not _structured_mode(as_json=as_json, as_yaml=as_yaml): console.print("🔗 %s" % payload["url"])