crier 2.0.0__tar.gz → 2.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {crier-2.0.0 → crier-2.0.2}/PKG-INFO +1 -1
  2. {crier-2.0.0 → crier-2.0.2}/pyproject.toml +1 -1
  3. {crier-2.0.0 → crier-2.0.2}/src/crier/cli.py +9 -22
  4. {crier-2.0.0 → crier-2.0.2}/src/crier/mcp_server.py +242 -164
  5. {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/base.py +10 -0
  6. {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/bluesky.py +1 -4
  7. {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/linkedin.py +1 -4
  8. {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/mastodon.py +2 -7
  9. {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/threads.py +1 -4
  10. {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/twitter.py +1 -4
  11. {crier-2.0.0 → crier-2.0.2}/src/crier/rewrite.py +17 -17
  12. {crier-2.0.0 → crier-2.0.2}/.gitignore +0 -0
  13. {crier-2.0.0 → crier-2.0.2}/LICENSE +0 -0
  14. {crier-2.0.0 → crier-2.0.2}/README.md +0 -0
  15. {crier-2.0.0 → crier-2.0.2}/src/crier/SKILL.md +0 -0
  16. {crier-2.0.0 → crier-2.0.2}/src/crier/__init__.py +0 -0
  17. {crier-2.0.0 → crier-2.0.2}/src/crier/checker.py +0 -0
  18. {crier-2.0.0 → crier-2.0.2}/src/crier/config.py +0 -0
  19. {crier-2.0.0 → crier-2.0.2}/src/crier/converters/__init__.py +0 -0
  20. {crier-2.0.0 → crier-2.0.2}/src/crier/converters/markdown.py +0 -0
  21. {crier-2.0.0 → crier-2.0.2}/src/crier/feed.py +0 -0
  22. {crier-2.0.0 → crier-2.0.2}/src/crier/llm/__init__.py +0 -0
  23. {crier-2.0.0 → crier-2.0.2}/src/crier/llm/openai_compat.py +0 -0
  24. {crier-2.0.0 → crier-2.0.2}/src/crier/llm/provider.py +0 -0
  25. {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/__init__.py +0 -0
  26. {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/buttondown.py +0 -0
  27. {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/devto.py +0 -0
  28. {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/discord.py +0 -0
  29. {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/ghost.py +0 -0
  30. {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/hashnode.py +0 -0
  31. {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/medium.py +0 -0
  32. {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/telegram.py +0 -0
  33. {crier-2.0.0 → crier-2.0.2}/src/crier/platforms/wordpress.py +0 -0
  34. {crier-2.0.0 → crier-2.0.2}/src/crier/registry.py +0 -0
  35. {crier-2.0.0 → crier-2.0.2}/src/crier/scheduler.py +0 -0
  36. {crier-2.0.0 → crier-2.0.2}/src/crier/skill.py +0 -0
  37. {crier-2.0.0 → crier-2.0.2}/src/crier/threading.py +0 -0
  38. {crier-2.0.0 → crier-2.0.2}/src/crier/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crier
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: Cross-post your content to dev.to, Hashnode, Medium, and more
5
5
  Project-URL: Homepage, https://github.com/queelius/crier
6
6
  Project-URL: Repository, https://github.com/queelius/crier
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "crier"
7
- version = "2.0.0"
7
+ version = "2.0.2"
8
8
  description = "Cross-post your content to dev.to, Hashnode, Medium, and more"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -20,7 +20,7 @@ from .config import (
20
20
  )
21
21
  from .converters import parse_markdown_file
22
22
  from .platforms import PLATFORMS, get_platform
23
- from .platforms.base import Article
23
+ from .rewrite import apply_rewrite
24
24
  from .utils import (
25
25
  is_in_content_paths as _is_in_content_paths,
26
26
  parse_date_filter as _parse_date_filter_impl,
@@ -740,17 +740,7 @@ def publish(file: str, platform_args: tuple[str, ...], profile_name: str | None,
740
740
  if rewrite_content:
741
741
  is_rewritten = True
742
742
  posted_content = rewrite_content
743
- # Create a modified article with the rewritten content
744
- article = Article(
745
- title=article.title,
746
- body=rewrite_content, # Use rewritten content as body
747
- description=article.description,
748
- tags=article.tags,
749
- canonical_url=article.canonical_url,
750
- published=article.published,
751
- cover_image=article.cover_image,
752
- is_rewrite=True,
753
- )
743
+ article = apply_rewrite(article, rewrite_content)
754
744
 
755
745
  # Require canonical_url for registry tracking
756
746
  if not article.canonical_url:
@@ -4135,17 +4125,12 @@ def schedule_run(dry_run: bool, json_output: bool):
4135
4125
  console.print(f"[red]✗[/red] {post.id}: No API key for {platform_name}")
4136
4126
  continue
4137
4127
 
4138
- # Handle rewrite if specified
4128
+ # Handle rewrite if specified.
4129
+ # Use apply_rewrite so is_rewrite=True is set; short-form platforms
4130
+ # branch on that flag and would otherwise discard the body.
4131
+ is_rewritten = bool(post.rewrite)
4139
4132
  if post.rewrite:
4140
- article = Article(
4141
- title=article.title,
4142
- body=post.rewrite,
4143
- description=article.description,
4144
- tags=article.tags,
4145
- canonical_url=article.canonical_url,
4146
- published=article.published,
4147
- cover_image=article.cover_image,
4148
- )
4133
+ article = apply_rewrite(article, post.rewrite)
4149
4134
 
4150
4135
  # Publish
4151
4136
  try:
@@ -4165,6 +4150,8 @@ def schedule_run(dry_run: bool, json_output: bool):
4165
4150
  url=result.url,
4166
4151
  title=article.title,
4167
4152
  source_file=str(file_path),
4153
+ rewritten=is_rewritten,
4154
+ posted_content=post.rewrite if is_rewritten else None,
4168
4155
  )
4169
4156
 
4170
4157
  results.append({
@@ -83,13 +83,15 @@ def _validate_platform(name: str) -> str | None:
83
83
 
84
84
 
85
85
  def _resolve_file(file_path: str) -> Path | None:
86
- """Resolve a file path against project root. Returns None if not found."""
86
+ """Resolve a file path against project root. Returns None if not found or not a file."""
87
+ if not file_path:
88
+ return None
87
89
  from .config import get_project_root
88
90
  p = Path(file_path)
89
- if p.is_absolute() and p.exists():
91
+ if p.is_absolute() and p.is_file():
90
92
  return p
91
93
  resolved = get_project_root() / p
92
- if resolved.exists():
94
+ if resolved.is_file():
93
95
  return resolved
94
96
  return None
95
97
 
@@ -581,36 +583,23 @@ def crier_check(
581
583
  # ============================================================================
582
584
 
583
585
 
584
- @mcp.tool()
585
- def crier_publish(
586
+ def _prepare_publish(
586
587
  file_path: str,
587
588
  platform: str,
588
- dry_run: bool = False,
589
- rewrite_content: str | None = None,
590
- rewrite_author: str | None = None,
591
- confirmation_token: str | None = None,
592
- ) -> dict:
593
- """Publish a content file to a platform.
589
+ rewrite_content: str | None,
590
+ ):
591
+ """Validate inputs and load the article for crier_publish.
594
592
 
595
- Two-step confirmation: call without token to get a preview, then
596
- call again with the returned token to execute.
597
-
598
- For short-form platforms (bluesky, mastodon), pass rewrite_content
599
- with a condensed version. Crier appends the canonical URL automatically.
593
+ Shared by step 1 (preview + token) and step 2 (execute). Returns either
594
+ an error dict (caller should return it as-is) or a tuple of
595
+ ``(resolved_path, article, api_key, is_rewritten, posted_content)``.
600
596
 
601
- Examples:
602
- # Step 1: preview
603
- crier_publish("content/post/my-article/index.md", "devto")
604
- # Step 2: execute
605
- crier_publish("content/post/my-article/index.md", "devto",
606
- confirmation_token="abc123")
607
- # Dry run (no confirmation needed)
608
- crier_publish("content/post/my-article/index.md", "devto", dry_run=True)
609
- # With rewrite for short-form
610
- crier_publish("content/post/my-article/index.md", "bluesky",
611
- rewrite_content="Interesting take on X...",
612
- rewrite_author="claude-code")
597
+ The rewrite, when provided, is applied here so both branches see the
598
+ final Article that will be posted.
613
599
  """
600
+ from .converters import parse_markdown_file
601
+ from .config import get_api_key, get_platform_mode
602
+
614
603
  err = _validate_platform(platform)
615
604
  if err:
616
605
  return {"error": err}
@@ -619,13 +608,6 @@ def crier_publish(
619
608
  if not resolved:
620
609
  return {"error": f"File not found: {file_path}"}
621
610
 
622
- from .converters import parse_markdown_file
623
- from .config import get_api_key, get_platform_mode
624
- from .platforms import get_platform
625
- from .platforms.base import Article
626
- from .registry import record_publication, record_failure, is_published
627
-
628
- # Parse article
629
611
  try:
630
612
  article = parse_markdown_file(str(resolved))
631
613
  except Exception as e:
@@ -634,7 +616,6 @@ def crier_publish(
634
616
  if not article.title:
635
617
  return {"error": "Article has no title"}
636
618
 
637
- # Check API key
638
619
  api_key = get_api_key(platform)
639
620
  if not api_key:
640
621
  return {"error": f"No API key configured for {platform}"}
@@ -643,106 +624,158 @@ def crier_publish(
643
624
  if mode != "api":
644
625
  return {"error": f"{platform} is in {mode} mode (not API). Use CLI for manual/import."}
645
626
 
646
- # Apply rewrite if provided
647
627
  is_rewritten = bool(rewrite_content)
648
628
  posted_content = rewrite_content
649
629
  if rewrite_content:
650
- article = Article(
630
+ from .rewrite import apply_rewrite
631
+ article = apply_rewrite(article, rewrite_content)
632
+
633
+ return resolved, article, api_key, is_rewritten, posted_content
634
+
635
+
636
+ def _execute_publish(
637
+ resolved: Path,
638
+ article,
639
+ platform: str,
640
+ api_key: str,
641
+ is_rewritten: bool,
642
+ posted_content: str | None,
643
+ rewrite_author: str | None,
644
+ ) -> dict:
645
+ """Run the platform publish call and record the outcome.
646
+
647
+ Records success via record_publication and any failure (exception or
648
+ PublishResult.success=False) via record_failure, mirroring CLI publish
649
+ semantics. Returns the MCP-shaped result dict.
650
+ """
651
+ from .platforms import get_platform
652
+ from .registry import record_failure, record_publication
653
+
654
+ canonical = article.canonical_url
655
+
656
+ try:
657
+ platform_obj = get_platform(platform)(api_key)
658
+ result = platform_obj.publish(article)
659
+ except Exception as e:
660
+ if canonical:
661
+ record_failure(canonical, platform, str(e), article.title, str(resolved))
662
+ return {"error": f"Publish failed: {e}"}
663
+
664
+ if not result.success:
665
+ if canonical and result.error:
666
+ record_failure(canonical, platform, result.error, article.title, str(resolved))
667
+ return {"error": f"Publish failed: {result.error}"}
668
+
669
+ if canonical:
670
+ record_publication(
671
+ canonical_url=canonical,
672
+ platform=platform,
673
+ article_id=result.article_id,
674
+ url=result.url,
651
675
  title=article.title,
652
- body=rewrite_content,
653
- description=article.description,
654
- tags=article.tags,
655
- canonical_url=article.canonical_url,
656
- published=article.published,
657
- cover_image=article.cover_image,
658
- is_rewrite=True,
676
+ source_file=str(resolved),
677
+ rewritten=is_rewritten,
678
+ rewrite_author=rewrite_author,
679
+ posted_content=posted_content,
659
680
  )
660
681
 
661
- # Build preview
682
+ return {
683
+ "success": True,
684
+ "platform": platform,
685
+ "title": article.title,
686
+ "article_id": result.article_id,
687
+ "url": result.url,
688
+ }
689
+
690
+
691
+ @mcp.tool()
692
+ def crier_publish(
693
+ file_path: str,
694
+ platform: str,
695
+ dry_run: bool = False,
696
+ rewrite_content: str | None = None,
697
+ rewrite_author: str | None = None,
698
+ confirmation_token: str | None = None,
699
+ ) -> dict:
700
+ """Publish a content file to a platform.
701
+
702
+ Two-step confirmation: call without token to get a preview, then
703
+ call again with the returned token to execute.
704
+
705
+ For short-form platforms (bluesky, mastodon), pass rewrite_content
706
+ with a condensed version. Crier appends the canonical URL automatically.
707
+
708
+ Examples:
709
+ # Step 1: preview
710
+ crier_publish("content/post/my-article/index.md", "devto")
711
+ # Step 2: execute
712
+ crier_publish("content/post/my-article/index.md", "devto",
713
+ confirmation_token="abc123")
714
+ # Dry run (no confirmation needed)
715
+ crier_publish("content/post/my-article/index.md", "devto", dry_run=True)
716
+ # With rewrite for short-form
717
+ crier_publish("content/post/my-article/index.md", "bluesky",
718
+ rewrite_content="Interesting take on X...",
719
+ rewrite_author="claude-code")
720
+ """
721
+ # Step 2: token is the source of truth. Caller args are ignored to prevent
722
+ # token-substitution bypass; see the test_*_step2_token_overrides_caller_args
723
+ # regressions in tests/test_mcp.py.
724
+ if confirmation_token:
725
+ details = _consume_token(confirmation_token, "publish")
726
+ if not details:
727
+ return {"error": "Invalid or expired confirmation token. Request a new one."}
728
+
729
+ prepared = _prepare_publish(
730
+ details["file"],
731
+ details["platform"],
732
+ details.get("rewrite_content"),
733
+ )
734
+ if isinstance(prepared, dict):
735
+ return prepared
736
+ resolved, article, api_key, is_rewritten, posted_content = prepared
737
+
738
+ return _execute_publish(
739
+ resolved, article, details["platform"], api_key,
740
+ is_rewritten, posted_content, details.get("rewrite_author"),
741
+ )
742
+
743
+ # Step 1 / dry_run: validate, build preview, optionally mint a token.
744
+ prepared = _prepare_publish(file_path, platform, rewrite_content)
745
+ if isinstance(prepared, dict):
746
+ return prepared
747
+ resolved, article, _api_key, is_rewritten, _posted = prepared
748
+
749
+ from .registry import is_published
750
+ canonical = article.canonical_url
662
751
  preview = {
663
752
  "file": str(resolved),
664
753
  "title": article.title,
665
754
  "platform": platform,
666
- "canonical_url": article.canonical_url,
755
+ "canonical_url": canonical,
667
756
  "is_rewrite": is_rewritten,
668
757
  "body_length": len(article.body) if article.body else 0,
669
- "already_published": is_published(
670
- article.canonical_url or "", platform
671
- ) if article.canonical_url else False,
758
+ "already_published": is_published(canonical, platform) if canonical else False,
672
759
  }
673
760
 
674
761
  if dry_run:
675
762
  return {"dry_run": True, "preview": preview}
676
763
 
677
- # Step 1: return preview + token
678
- if not confirmation_token:
679
- token = _create_token("publish", {
680
- "file": str(resolved), "platform": platform,
681
- "rewrite_content": rewrite_content, "rewrite_author": rewrite_author,
682
- })
683
- return {
684
- "confirmation_required": True,
685
- "preview": preview,
686
- "confirmation_token": token,
687
- "message": f"Call again with confirmation_token to publish '{article.title}' to {platform}",
688
- }
689
-
690
- # Step 2: consume token and execute
691
- details = _consume_token(confirmation_token, "publish")
692
- if not details:
693
- return {"error": "Invalid or expired confirmation token. Request a new one."}
694
-
695
- # Restore rewrite from token (the caller only passes it in step 1)
696
- if not rewrite_content and details.get("rewrite_content"):
697
- rewrite_content = details["rewrite_content"]
698
- rewrite_author = details.get("rewrite_author")
699
- is_rewritten = True
700
- posted_content = rewrite_content
701
- article = Article(
702
- title=article.title,
703
- body=rewrite_content,
704
- description=article.description,
705
- tags=article.tags,
706
- canonical_url=article.canonical_url,
707
- published=article.published,
708
- cover_image=article.cover_image,
709
- is_rewrite=True,
710
- )
711
-
712
- # Publish
713
- try:
714
- platform_cls = get_platform(platform)
715
- platform_obj = platform_cls(api_key)
716
- result = platform_obj.publish(article)
717
- except Exception as e:
718
- if article.canonical_url:
719
- record_failure(article.canonical_url, platform, str(e), article.title, str(resolved))
720
- return {"error": f"Publish failed: {e}"}
721
-
722
- if result.success:
723
- if article.canonical_url:
724
- record_publication(
725
- canonical_url=article.canonical_url,
726
- platform=platform,
727
- article_id=result.article_id,
728
- url=result.url,
729
- title=article.title,
730
- source_file=str(resolved),
731
- rewritten=is_rewritten,
732
- rewrite_author=rewrite_author,
733
- posted_content=posted_content,
734
- )
735
- return {
736
- "success": True,
737
- "platform": platform,
738
- "title": article.title,
739
- "article_id": result.article_id,
740
- "url": result.url,
741
- }
742
- else:
743
- if article.canonical_url and result.error:
744
- record_failure(article.canonical_url, platform, result.error, article.title, str(resolved))
745
- return {"error": f"Publish failed: {result.error}"}
764
+ token = _create_token("publish", {
765
+ "file": str(resolved),
766
+ "platform": platform,
767
+ "rewrite_content": rewrite_content,
768
+ "rewrite_author": rewrite_author,
769
+ })
770
+ return {
771
+ "confirmation_required": True,
772
+ "preview": preview,
773
+ "confirmation_token": token,
774
+ "message": (
775
+ f"Call again with confirmation_token to publish "
776
+ f"'{article.title}' to {platform}"
777
+ ),
778
+ }
746
779
 
747
780
 
748
781
  @mcp.tool()
@@ -766,6 +799,20 @@ def crier_delete(
766
799
  # Delete from all platforms
767
800
  crier_delete("code-without-purpose", delete_all=True)
768
801
  """
802
+ from .registry import get_article
803
+
804
+ # Step 2: token is the source of truth. Caller args are ignored to prevent
805
+ # token-substitution bypass; see test_delete_step2_token_overrides_caller_key.
806
+ if confirmation_token:
807
+ details = _consume_token(confirmation_token, "delete")
808
+ if not details:
809
+ return {"error": "Invalid or expired confirmation token."}
810
+ article = get_article(details["key"])
811
+ if not article:
812
+ return {"error": f"Article not found: {details['key']}"}
813
+ return {"deleted": _execute_deletes(article, details["platforms"])}
814
+
815
+ # Step 1: validate, resolve targets, return preview + token.
769
816
  if platform:
770
817
  err = _validate_platform(platform)
771
818
  if err:
@@ -774,77 +821,97 @@ def crier_delete(
774
821
  if not platform and not delete_all:
775
822
  return {"error": "Specify platform or delete_all=True"}
776
823
 
777
- from .registry import get_article, record_deletion, get_publication_id
778
- from .config import get_api_key, get_platform_mode
779
- from .platforms import get_platform
780
-
781
824
  article = get_article(key)
782
825
  if not article:
783
826
  return {"error": f"Article not found: {key}"}
784
827
 
785
- # Determine platforms to delete from
786
- target_platforms = []
828
+ target_platforms, err_msg = _resolve_delete_targets(article, platform, delete_all)
829
+ if err_msg:
830
+ return {"error": err_msg}
831
+
832
+ token = _create_token("delete", {"key": key, "platforms": target_platforms})
833
+ return {
834
+ "confirmation_required": True,
835
+ "preview": {
836
+ "title": article["title"],
837
+ "slug": article["slug"],
838
+ "platforms_to_delete": target_platforms,
839
+ },
840
+ "confirmation_token": token,
841
+ "message": f"Call again with confirmation_token to delete from {target_platforms}",
842
+ }
843
+
844
+
845
+ def _resolve_delete_targets(
846
+ article: dict,
847
+ platform: str | None,
848
+ delete_all: bool,
849
+ ) -> tuple[list[str], str | None]:
850
+ """Determine which platforms a delete should target.
851
+
852
+ Returns ``(target_platforms, error)``: at most one is non-empty.
853
+ """
854
+ # Filter out failure-only rows (platform_id IS NULL) and soft-deleted rows.
855
+ # _article_row_to_dict surfaces all publication rows; we only want real
856
+ # publications. See the failure-row convention in CLAUDE.md.
857
+ def _is_real_publication(data: dict) -> bool:
858
+ return "deleted_at" not in data and bool(data.get("id"))
859
+
787
860
  if delete_all:
788
- target_platforms = [
861
+ targets = [
789
862
  p for p, data in article.get("platforms", {}).items()
790
- if "deleted_at" not in data
863
+ if _is_real_publication(data)
791
864
  ]
792
- elif platform:
793
- if platform not in article.get("platforms", {}):
794
- return {"error": f"'{article['title']}' not published to {platform}"}
795
- target_platforms = [platform]
865
+ if not targets:
866
+ return [], "Nothing to delete"
867
+ return targets, None
796
868
 
797
- if not target_platforms:
798
- return {"error": "Nothing to delete"}
869
+ # Single platform path; caller ensured platform is set.
870
+ pdata = article.get("platforms", {}).get(platform)
871
+ if not pdata or not pdata.get("id"):
872
+ return [], f"'{article['title']}' not published to {platform}"
873
+ if "deleted_at" in pdata:
874
+ return [], f"'{article['title']}' on {platform} is already deleted"
875
+ return [platform], None
799
876
 
800
- preview = {
801
- "title": article["title"],
802
- "slug": article["slug"],
803
- "platforms_to_delete": target_platforms,
804
- }
805
877
 
806
- # Step 1: return preview + token
807
- if not confirmation_token:
808
- token = _create_token("delete", {"key": key, "platforms": target_platforms})
809
- return {
810
- "confirmation_required": True,
811
- "preview": preview,
812
- "confirmation_token": token,
813
- "message": f"Call again with confirmation_token to delete from {target_platforms}",
814
- }
878
+ def _execute_deletes(article: dict, target_platforms: list[str]) -> list[dict]:
879
+ """Run platform delete calls for the given article, recording each outcome.
815
880
 
816
- # Step 2: consume and execute
817
- details = _consume_token(confirmation_token, "delete")
818
- if not details:
819
- return {"error": "Invalid or expired confirmation token."}
881
+ Always records a deletion in the registry, even when the platform call
882
+ fails or the platform is in non-API mode -- the user has already consented
883
+ via the confirmation token, so the registry should reflect that intent.
884
+ """
885
+ from .config import get_api_key, get_platform_mode
886
+ from .platforms import get_platform
887
+ from .registry import get_publication_id, record_deletion
820
888
 
821
- results = []
822
- for plat in details["platforms"]:
823
- canonical = article.get("canonical_url") or article["slug"]
889
+ canonical = article.get("canonical_url") or article["slug"]
890
+ results: list[dict] = []
891
+
892
+ for plat in target_platforms:
824
893
  pub_id = get_publication_id(canonical, plat)
825
894
  api_key = get_api_key(plat)
826
- mode = get_platform_mode(plat)
827
895
 
828
- if mode != "api" or not api_key or not pub_id:
896
+ # Non-API mode, missing key, or missing platform_id -> only mark in registry.
897
+ if get_platform_mode(plat) != "api" or not api_key or not pub_id:
829
898
  record_deletion(canonical, plat)
830
899
  results.append({"platform": plat, "status": "marked_deleted"})
831
900
  continue
832
901
 
833
902
  try:
834
- platform_cls = get_platform(plat)
835
- platform_obj = platform_cls(api_key)
836
- del_result = platform_obj.delete(pub_id)
903
+ del_result = get_platform(plat)(api_key).delete(pub_id)
837
904
  record_deletion(canonical, plat)
838
905
  results.append({
839
906
  "platform": plat,
840
907
  "status": "deleted" if del_result.success else "marked_deleted",
841
- "error": del_result.error if not del_result.success else None,
908
+ "error": None if del_result.success else del_result.error,
842
909
  })
843
910
  except Exception as e:
844
911
  record_deletion(canonical, plat)
845
912
  results.append({"platform": plat, "status": "marked_deleted", "error": str(e)})
846
913
 
847
- return {"deleted": results}
914
+ return results
848
915
 
849
916
 
850
917
  @mcp.tool()
@@ -864,10 +931,11 @@ def crier_archive(key: str, archived: bool = True) -> dict:
864
931
  return {"error": f"Article not found: {key}"}
865
932
 
866
933
  success = set_archived(key, archived)
867
- action = "archived" if archived else "unarchived"
934
+ verb = "archive" if archived else "unarchive"
935
+ past = "archived" if archived else "unarchived"
868
936
  if success:
869
- return {"success": True, "message": f"'{article['title']}' {action}"}
870
- return {"error": f"Failed to {action[:-1]}e '{key}'"}
937
+ return {"success": True, "message": f"'{article['title']}' {past}"}
938
+ return {"error": f"Failed to {verb} '{key}'"}
871
939
 
872
940
 
873
941
  # ============================================================================
@@ -963,6 +1031,11 @@ def crier_stats(key: str, platform: str | None = None) -> dict:
963
1031
  crier_stats("code-without-purpose")
964
1032
  crier_stats("code-without-purpose", platform="devto")
965
1033
  """
1034
+ if platform:
1035
+ err = _validate_platform(platform)
1036
+ if err:
1037
+ return {"error": err}
1038
+
966
1039
  from .registry import get_cached_stats, get_article
967
1040
 
968
1041
  article = get_article(key)
@@ -998,6 +1071,11 @@ def crier_stats_refresh(
998
1071
  crier_stats_refresh("code-without-purpose")
999
1072
  crier_stats_refresh("code-without-purpose", platform="devto")
1000
1073
  """
1074
+ if platform:
1075
+ err = _validate_platform(platform)
1076
+ if err:
1077
+ return {"error": err}
1078
+
1001
1079
  from .registry import get_article, get_all_articles, save_stats
1002
1080
  from .config import get_api_key, is_manual_mode_key, is_import_mode_key
1003
1081
  from .platforms import get_platform
@@ -135,6 +135,16 @@ class Platform(ABC):
135
135
  )
136
136
  return None
137
137
 
138
+ def _append_canonical_url(self, text: str, article: Article) -> str:
139
+ """Append the canonical URL to text if not already present.
140
+
141
+ Used by short-form platforms when publishing a rewrite to ensure
142
+ the link back to the canonical source is included.
143
+ """
144
+ if article.canonical_url and article.canonical_url not in text:
145
+ return f"{text}\n\n{article.canonical_url}"
146
+ return text
147
+
138
148
  def retry_request(
139
149
  self,
140
150
  method: str,
@@ -75,10 +75,7 @@ class Bluesky(Platform):
75
75
  """
76
76
  if article.is_rewrite:
77
77
  # Body provided (e.g., via --rewrite) — use it directly
78
- text = article.body
79
- # Append canonical URL if not already present
80
- if article.canonical_url and article.canonical_url not in text:
81
- text = text + "\n\n" + article.canonical_url
78
+ text = self._append_canonical_url(article.body, article)
82
79
  else:
83
80
  # Auto-construct from metadata
84
81
  text_parts = [article.title]
@@ -50,10 +50,7 @@ class LinkedIn(Platform):
50
50
  Otherwise constructs from title + description + hashtags + URL.
51
51
  """
52
52
  if article.is_rewrite:
53
- text = article.body
54
- if article.canonical_url and article.canonical_url not in text:
55
- text = text + "\n\n" + article.canonical_url
56
- return text
53
+ return self._append_canonical_url(article.body, article)
57
54
 
58
55
  parts = [article.title]
59
56
 
@@ -52,10 +52,7 @@ class Mastodon(Platform):
52
52
  """
53
53
  if article.is_rewrite:
54
54
  # Body provided (e.g., via --rewrite) — use it directly
55
- text = article.body
56
- # Append canonical URL if not already present
57
- if article.canonical_url and article.canonical_url not in text:
58
- text = text + "\n\n" + article.canonical_url
55
+ text = self._append_canonical_url(article.body, article)
59
56
  else:
60
57
  # Auto-construct from metadata
61
58
  text_parts = [article.title]
@@ -106,9 +103,7 @@ class Mastodon(Platform):
106
103
  def update(self, article_id: str, article: Article) -> PublishResult:
107
104
  """Update an existing toot (Mastodon supports editing)."""
108
105
  if article.is_rewrite:
109
- text = article.body
110
- if article.canonical_url and article.canonical_url not in text:
111
- text = text + "\n\n" + article.canonical_url
106
+ text = self._append_canonical_url(article.body, article)
112
107
  else:
113
108
  text_parts = [article.title]
114
109
  if article.description:
@@ -170,10 +170,7 @@ class Threads(Platform):
170
170
  Otherwise constructs from title + description + URL + hashtags.
171
171
  """
172
172
  if article.is_rewrite:
173
- text = article.body
174
- if article.canonical_url and article.canonical_url not in text:
175
- text = text + "\n\n" + article.canonical_url
176
- return text
173
+ return self._append_canonical_url(article.body, article)
177
174
 
178
175
  parts = [article.title]
179
176
 
@@ -37,10 +37,7 @@ class Twitter(Platform):
37
37
  Otherwise constructs from title + hashtags + URL.
38
38
  """
39
39
  if article.is_rewrite:
40
- text = article.body
41
- if article.canonical_url and article.canonical_url not in text:
42
- text = text + "\n\n" + article.canonical_url
43
- return text
40
+ return self._append_canonical_url(article.body, article)
44
41
 
45
42
  parts = [article.title]
46
43
 
@@ -4,11 +4,25 @@ Handles the LLM retry loop, truncation fallback, and Article construction
4
4
  for auto-rewritten content. Used by both publish() and audit() in cli.py.
5
5
  """
6
6
 
7
- from dataclasses import dataclass
7
+ from dataclasses import dataclass, replace
8
8
 
9
9
  from .platforms.base import Article
10
10
  from .utils import truncate_at_sentence
11
11
 
12
+ __all__ = ["AutoRewriteResult", "apply_rewrite", "auto_rewrite_for_platform"]
13
+
14
+
15
+ def apply_rewrite(article: Article, new_body: str) -> Article:
16
+ """Return a copy of article with body replaced and is_rewrite=True.
17
+
18
+ Single source of truth for "the article has been rewritten for a
19
+ platform". Used by both the auto-rewrite retry loop and the MCP
20
+ server's manual rewrite path. Using ``dataclasses.replace`` ensures
21
+ new Article fields are picked up automatically without manual
22
+ constructor maintenance.
23
+ """
24
+ return replace(article, body=new_body, is_rewrite=True)
25
+
12
26
 
13
27
  @dataclass
14
28
  class AutoRewriteResult:
@@ -106,7 +120,7 @@ def auto_rewrite_for_platform(
106
120
  )
107
121
  return AutoRewriteResult(
108
122
  success=True,
109
- article=_rewritten_article(article, final_text),
123
+ article=apply_rewrite(article, final_text),
110
124
  rewrite_text=final_text,
111
125
  )
112
126
 
@@ -120,7 +134,7 @@ def auto_rewrite_for_platform(
120
134
  )
121
135
  return AutoRewriteResult(
122
136
  success=True,
123
- article=_rewritten_article(article, truncated),
137
+ article=apply_rewrite(article, truncated),
124
138
  rewrite_text=truncated,
125
139
  )
126
140
 
@@ -139,17 +153,3 @@ def auto_rewrite_for_platform(
139
153
  success=False,
140
154
  error=f"Auto-rewrite failed: {e}",
141
155
  )
142
-
143
-
144
- def _rewritten_article(original: Article, new_body: str) -> Article:
145
- """Create a new Article with rewritten body, preserving metadata."""
146
- return Article(
147
- title=original.title,
148
- body=new_body,
149
- description=original.description,
150
- tags=original.tags,
151
- canonical_url=original.canonical_url,
152
- published=original.published,
153
- cover_image=original.cover_image,
154
- is_rewrite=True,
155
- )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes