blog-cli 0.3.0__tar.gz → 0.4.0__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 (26) hide show
  1. {blog_cli-0.3.0 → blog_cli-0.4.0}/PKG-INFO +54 -3
  2. {blog_cli-0.3.0 → blog_cli-0.4.0}/README.md +51 -0
  3. {blog_cli-0.3.0 → blog_cli-0.4.0}/pyproject.toml +7 -3
  4. {blog_cli-0.3.0 → blog_cli-0.4.0}/src/blog_cli.egg-info/PKG-INFO +54 -3
  5. {blog_cli-0.3.0 → blog_cli-0.4.0}/src/blog_cli.egg-info/SOURCES.txt +8 -1
  6. {blog_cli-0.3.0 → blog_cli-0.4.0}/src/personal_cli/cli.py +351 -1
  7. {blog_cli-0.3.0 → blog_cli-0.4.0}/src/personal_cli/client.py +45 -0
  8. blog_cli-0.4.0/src/personal_cli/skill.py +34 -0
  9. blog_cli-0.4.0/src/personal_cli/skill_data/SKILL.md +30 -0
  10. blog_cli-0.4.0/src/personal_cli/skill_data/agents/openai.yaml +7 -0
  11. blog_cli-0.4.0/src/personal_cli/skill_data/references/articles.md +46 -0
  12. blog_cli-0.4.0/src/personal_cli/skill_data/references/pages.md +132 -0
  13. blog_cli-0.4.0/src/personal_cli/skill_data/references/projects.md +50 -0
  14. {blog_cli-0.3.0 → blog_cli-0.4.0}/tests/test_cli.py +122 -0
  15. blog_cli-0.4.0/tests/test_pages_categories.py +254 -0
  16. {blog_cli-0.3.0 → blog_cli-0.4.0}/setup.cfg +0 -0
  17. {blog_cli-0.3.0 → blog_cli-0.4.0}/src/blog_cli.egg-info/dependency_links.txt +0 -0
  18. {blog_cli-0.3.0 → blog_cli-0.4.0}/src/blog_cli.egg-info/entry_points.txt +0 -0
  19. {blog_cli-0.3.0 → blog_cli-0.4.0}/src/blog_cli.egg-info/requires.txt +0 -0
  20. {blog_cli-0.3.0 → blog_cli-0.4.0}/src/blog_cli.egg-info/top_level.txt +0 -0
  21. {blog_cli-0.3.0 → blog_cli-0.4.0}/src/personal_cli/__init__.py +0 -0
  22. {blog_cli-0.3.0 → blog_cli-0.4.0}/src/personal_cli/__main__.py +0 -0
  23. {blog_cli-0.3.0 → blog_cli-0.4.0}/src/personal_cli/credentials.py +0 -0
  24. {blog_cli-0.3.0 → blog_cli-0.4.0}/src/personal_cli/formatting.py +0 -0
  25. {blog_cli-0.3.0 → blog_cli-0.4.0}/src/personal_cli/setup_server.py +0 -0
  26. {blog_cli-0.3.0 → blog_cli-0.4.0}/tests/test_client.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blog-cli
3
- Version: 0.3.0
4
- Summary: Agent-friendly CLI for managing a blog. Create drafts, upload media, generate time-limited preview links.
3
+ Version: 0.4.0
4
+ Summary: Agent-friendly CLI for managing a personal site. Articles, projects, categories, private pages, media, and a bundled ChatGPT/Codex skill.
5
5
  Author: tanaka-mambinge
6
6
  License: MIT
7
7
  Project-URL: Repository, https://github.com/tanaka-mambinge/personal-cli
8
8
  Project-URL: Issues, https://github.com/tanaka-mambinge/personal-cli/issues
9
- Keywords: blog,cli,agent,markdown,writing
9
+ Keywords: blog,cli,agent,markdown,writing,mdx,cms,dashboard
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: OS Independent
12
12
  Classifier: Programming Language :: Python :: 3
@@ -194,6 +194,57 @@ blog-cli article tag-add my-project --tag python --tag agents
194
194
  blog-cli article tag-remove my-project --tag agents
195
195
  ```
196
196
 
197
+ ## Categories
198
+
199
+ Categories are first-class models that group private content pages. The slug is auto-derived from the name. A category cannot be deleted while pages still belong to it.
200
+
201
+ ```bash
202
+ # Create (slug auto from name, e.g. "YouTube Notes" -> "youtube-notes")
203
+ blog-cli category create --name "Ideas" --icon bulb --description "Captured ideas"
204
+
205
+ # List / show / update / delete
206
+ blog-cli category list
207
+ blog-cli category show ideas
208
+ blog-cli category update ideas --name "Idea Box" --icon lightbulb
209
+ blog-cli category delete ideas
210
+ ```
211
+
212
+ ## Pages
213
+
214
+ Pages are private content shown only on the dashboard at `/d/<slug>`. They are for your eyes only. Each page belongs to a category and supports MDX with prebuilt components (`<Callout>`, `<Steps>`, `<ImageGrid>`, `<Video>`, `<Figure>`).
215
+
216
+ ```bash
217
+ # Create a page
218
+ blog-cli page create \
219
+ --title "An idea" \
220
+ --description "Short summary" \
221
+ --category ideas \
222
+ --tag web --tag ai \
223
+ --markdown-file idea.mdx
224
+
225
+ # List (optionally filter by category)
226
+ blog-cli page list
227
+ blog-cli page list --category ideas
228
+
229
+ # Show / update / delete
230
+ blog-cli page show an-idea
231
+ blog-cli page update an-idea --title "A better title"
232
+ blog-cli page update an-idea --markdown-file updated.mdx
233
+ blog-cli page delete an-idea
234
+ ```
235
+
236
+ Page bodies are MDX. Referenced media uses names uploaded via `blog-cli media upload --name <name> <file>`; the site resolves names to media URLs at render time.
237
+
238
+ ## ChatGPT / Codex skill
239
+
240
+ The CLI ships a bundled `content-pipeline` skill that routes blog, project, and page tasks to the right reference doc. Install it into the user-level skills directory:
241
+
242
+ ```bash
243
+ blog-cli skill install
244
+ blog-cli skill uninstall
245
+ blog-cli skill path
246
+ ```
247
+
197
248
  ## Media
198
249
 
199
250
  Upload media using a stable name, then reference that name from article Markdown:
@@ -164,6 +164,57 @@ blog-cli article tag-add my-project --tag python --tag agents
164
164
  blog-cli article tag-remove my-project --tag agents
165
165
  ```
166
166
 
167
+ ## Categories
168
+
169
+ Categories are first-class models that group private content pages. The slug is auto-derived from the name. A category cannot be deleted while pages still belong to it.
170
+
171
+ ```bash
172
+ # Create (slug auto from name, e.g. "YouTube Notes" -> "youtube-notes")
173
+ blog-cli category create --name "Ideas" --icon bulb --description "Captured ideas"
174
+
175
+ # List / show / update / delete
176
+ blog-cli category list
177
+ blog-cli category show ideas
178
+ blog-cli category update ideas --name "Idea Box" --icon lightbulb
179
+ blog-cli category delete ideas
180
+ ```
181
+
182
+ ## Pages
183
+
184
+ Pages are private content shown only on the dashboard at `/d/<slug>`. They are for your eyes only. Each page belongs to a category and supports MDX with prebuilt components (`<Callout>`, `<Steps>`, `<ImageGrid>`, `<Video>`, `<Figure>`).
185
+
186
+ ```bash
187
+ # Create a page
188
+ blog-cli page create \
189
+ --title "An idea" \
190
+ --description "Short summary" \
191
+ --category ideas \
192
+ --tag web --tag ai \
193
+ --markdown-file idea.mdx
194
+
195
+ # List (optionally filter by category)
196
+ blog-cli page list
197
+ blog-cli page list --category ideas
198
+
199
+ # Show / update / delete
200
+ blog-cli page show an-idea
201
+ blog-cli page update an-idea --title "A better title"
202
+ blog-cli page update an-idea --markdown-file updated.mdx
203
+ blog-cli page delete an-idea
204
+ ```
205
+
206
+ Page bodies are MDX. Referenced media uses names uploaded via `blog-cli media upload --name <name> <file>`; the site resolves names to media URLs at render time.
207
+
208
+ ## ChatGPT / Codex skill
209
+
210
+ The CLI ships a bundled `content-pipeline` skill that routes blog, project, and page tasks to the right reference doc. Install it into the user-level skills directory:
211
+
212
+ ```bash
213
+ blog-cli skill install
214
+ blog-cli skill uninstall
215
+ blog-cli skill path
216
+ ```
217
+
167
218
  ## Media
168
219
 
169
220
  Upload media using a stable name, then reference that name from article Markdown:
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "blog-cli"
7
- version = "0.3.0"
8
- description = "Agent-friendly CLI for managing a blog. Create drafts, upload media, generate time-limited preview links."
7
+ version = "0.4.0"
8
+ description = "Agent-friendly CLI for managing a personal site. Articles, projects, categories, private pages, media, and a bundled ChatGPT/Codex skill."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
11
11
  license = {text = "MIT"}
12
12
  authors = [{name = "tanaka-mambinge"}]
13
- keywords = ["blog", "cli", "agent", "markdown", "writing"]
13
+ keywords = ["blog", "cli", "agent", "markdown", "writing", "mdx", "cms", "dashboard"]
14
14
  classifiers = [
15
15
  "License :: OSI Approved :: MIT License",
16
16
  "Operating System :: OS Independent",
@@ -54,6 +54,10 @@ package = true
54
54
 
55
55
  [tool.setuptools]
56
56
  package-dir = {"" = "src"}
57
+ include-package-data = true
57
58
 
58
59
  [tool.setuptools.packages.find]
59
60
  where = ["src"]
61
+
62
+ [tool.setuptools.package-data]
63
+ personal_cli = ["skill_data/**/*"]
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blog-cli
3
- Version: 0.3.0
4
- Summary: Agent-friendly CLI for managing a blog. Create drafts, upload media, generate time-limited preview links.
3
+ Version: 0.4.0
4
+ Summary: Agent-friendly CLI for managing a personal site. Articles, projects, categories, private pages, media, and a bundled ChatGPT/Codex skill.
5
5
  Author: tanaka-mambinge
6
6
  License: MIT
7
7
  Project-URL: Repository, https://github.com/tanaka-mambinge/personal-cli
8
8
  Project-URL: Issues, https://github.com/tanaka-mambinge/personal-cli/issues
9
- Keywords: blog,cli,agent,markdown,writing
9
+ Keywords: blog,cli,agent,markdown,writing,mdx,cms,dashboard
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: OS Independent
12
12
  Classifier: Programming Language :: Python :: 3
@@ -194,6 +194,57 @@ blog-cli article tag-add my-project --tag python --tag agents
194
194
  blog-cli article tag-remove my-project --tag agents
195
195
  ```
196
196
 
197
+ ## Categories
198
+
199
+ Categories are first-class models that group private content pages. The slug is auto-derived from the name. A category cannot be deleted while pages still belong to it.
200
+
201
+ ```bash
202
+ # Create (slug auto from name, e.g. "YouTube Notes" -> "youtube-notes")
203
+ blog-cli category create --name "Ideas" --icon bulb --description "Captured ideas"
204
+
205
+ # List / show / update / delete
206
+ blog-cli category list
207
+ blog-cli category show ideas
208
+ blog-cli category update ideas --name "Idea Box" --icon lightbulb
209
+ blog-cli category delete ideas
210
+ ```
211
+
212
+ ## Pages
213
+
214
+ Pages are private content shown only on the dashboard at `/d/<slug>`. They are for your eyes only. Each page belongs to a category and supports MDX with prebuilt components (`<Callout>`, `<Steps>`, `<ImageGrid>`, `<Video>`, `<Figure>`).
215
+
216
+ ```bash
217
+ # Create a page
218
+ blog-cli page create \
219
+ --title "An idea" \
220
+ --description "Short summary" \
221
+ --category ideas \
222
+ --tag web --tag ai \
223
+ --markdown-file idea.mdx
224
+
225
+ # List (optionally filter by category)
226
+ blog-cli page list
227
+ blog-cli page list --category ideas
228
+
229
+ # Show / update / delete
230
+ blog-cli page show an-idea
231
+ blog-cli page update an-idea --title "A better title"
232
+ blog-cli page update an-idea --markdown-file updated.mdx
233
+ blog-cli page delete an-idea
234
+ ```
235
+
236
+ Page bodies are MDX. Referenced media uses names uploaded via `blog-cli media upload --name <name> <file>`; the site resolves names to media URLs at render time.
237
+
238
+ ## ChatGPT / Codex skill
239
+
240
+ The CLI ships a bundled `content-pipeline` skill that routes blog, project, and page tasks to the right reference doc. Install it into the user-level skills directory:
241
+
242
+ ```bash
243
+ blog-cli skill install
244
+ blog-cli skill uninstall
245
+ blog-cli skill path
246
+ ```
247
+
197
248
  ## Media
198
249
 
199
250
  Upload media using a stable name, then reference that name from article Markdown:
@@ -13,5 +13,12 @@ src/personal_cli/client.py
13
13
  src/personal_cli/credentials.py
14
14
  src/personal_cli/formatting.py
15
15
  src/personal_cli/setup_server.py
16
+ src/personal_cli/skill.py
17
+ src/personal_cli/skill_data/SKILL.md
18
+ src/personal_cli/skill_data/agents/openai.yaml
19
+ src/personal_cli/skill_data/references/articles.md
20
+ src/personal_cli/skill_data/references/pages.md
21
+ src/personal_cli/skill_data/references/projects.md
16
22
  tests/test_cli.py
17
- tests/test_client.py
23
+ tests/test_client.py
24
+ tests/test_pages_categories.py
@@ -22,12 +22,18 @@ blog_app = typer.Typer(help="Manage blog posts.")
22
22
  project_app = typer.Typer(help="Manage projects.")
23
23
  media_app = typer.Typer(help="Manage media uploads.")
24
24
  keys_app = typer.Typer(help="Manage stored credentials.")
25
+ category_app = typer.Typer(help="Manage content categories.")
26
+ page_app = typer.Typer(help="Manage private content pages.")
27
+ skill_app = typer.Typer(help="Manage the bundled ChatGPT/Codex skill.")
25
28
 
26
29
  app.add_typer(article_app, name="article")
27
30
  article_app.add_typer(blog_app, name="blog")
28
31
  article_app.add_typer(project_app, name="project")
29
32
  app.add_typer(media_app, name="media")
30
33
  app.add_typer(keys_app, name="keys")
34
+ app.add_typer(category_app, name="category")
35
+ app.add_typer(page_app, name="page")
36
+ app.add_typer(skill_app, name="skill")
31
37
 
32
38
  Result = TypeVar("Result")
33
39
 
@@ -55,6 +61,19 @@ def build_client(
55
61
  return ArticleApiClient(server_url or url, api_key=api_key, verify=not insecure)
56
62
 
57
63
 
64
+ def _site_url() -> str:
65
+ _, _, site_url = get_config()
66
+ return site_url.rstrip("/")
67
+
68
+
69
+ def _dashboard_url(slug: str) -> str:
70
+ return f"{_site_url()}/d/{slug}"
71
+
72
+
73
+ def _category_dashboard_url(slug: str) -> str:
74
+ return f"{_site_url()}/d?category={slug}"
75
+
76
+
58
77
  def run(coro):
59
78
  return asyncio.run(coro)
60
79
 
@@ -164,7 +183,7 @@ def project_create(
164
183
  description: str = typer.Option(..., "--description", help="Short summary."),
165
184
  slug: str | None = typer.Option(None, "--slug", help="Optional slug override."),
166
185
  tag: list[str] = typer.Option([], "--tag", help="Repeat for each tag."),
167
- cover_image: str | None = typer.Option(None, "--cover-image", help="Uploaded media name for the project banner image."),
186
+ cover_image: str = typer.Option(..., "--cover-image", help="Uploaded media name for the project banner image."),
168
187
  status: str = typer.Option("draft", "--status", help="draft or published."),
169
188
  pinned: bool = typer.Option(False, "--pinned/--not-pinned", help="Pin to the home page."),
170
189
  sort_order: int = typer.Option(0, "--sort-order", help="Order among pinned projects (lower first)."),
@@ -221,6 +240,16 @@ def article_update(
221
240
  if cover_image is not None and clear_cover_image:
222
241
  raise CLIError("Use either --cover-image or --clear-cover-image, not both.")
223
242
  client = build_client(server_url, insecure=insecure)
243
+ current = next(
244
+ (article for article in run(client.list_articles(type_filter="all")) if article.get("slug") == slug),
245
+ None,
246
+ )
247
+ if current is None:
248
+ raise CLIError(f"Article not found: {slug}")
249
+ target_type = article_type or current.get("type")
250
+ resulting_cover = cover_image if cover_image is not None else current.get("cover_image")
251
+ if target_type == "project" and (clear_cover_image or not resulting_cover):
252
+ raise CLIError("Projects must have a cover image. Use --cover-image or keep the existing cover image.")
224
253
  payload: dict[str, object] = {}
225
254
  if title is not None:
226
255
  payload["title"] = title
@@ -511,6 +540,327 @@ def keys_show(
511
540
  raise typer.Exit(code=1) from exc
512
541
 
513
542
 
543
+ # ---------------------------------------------------------------------------
544
+ # Categories
545
+ # ---------------------------------------------------------------------------
546
+
547
+
548
+ @category_app.command("create")
549
+ def category_create(
550
+ name: str = typer.Option(..., "--name", help="Display name. Slug is derived from this."),
551
+ icon: str | None = typer.Option(None, "--icon", help="Tabler icon name, e.g. bulb."),
552
+ description: str | None = typer.Option(None, "--description", help="Short description."),
553
+ sort_order: int = typer.Option(0, "--sort-order", help="Lower sorts first."),
554
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
555
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
556
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
557
+ ) -> None:
558
+ def _op() -> None:
559
+ client = build_client(server_url, insecure=insecure)
560
+ payload: dict[str, object] = {"name": name, "sort_order": sort_order}
561
+ if icon is not None:
562
+ payload["icon"] = icon
563
+ if description is not None:
564
+ payload["description"] = description
565
+ result = run(client.create_category(payload))
566
+ result["dashboard_url"] = _category_dashboard_url(result["slug"])
567
+ emit_result(result, json_output=json_output)
568
+
569
+ try:
570
+ _run(_op)
571
+ except CLIError as exc:
572
+ typer.echo(str(exc), err=True)
573
+ raise typer.Exit(code=1) from exc
574
+
575
+
576
+ @category_app.command("list")
577
+ def category_list(
578
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
579
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
580
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
581
+ ) -> None:
582
+ def _op() -> None:
583
+ client = build_client(server_url, insecure=insecure)
584
+ result = run(client.list_categories())
585
+ emit_result(result, json_output=json_output)
586
+
587
+ try:
588
+ _run(_op)
589
+ except CLIError as exc:
590
+ typer.echo(str(exc), err=True)
591
+ raise typer.Exit(code=1) from exc
592
+
593
+
594
+ @category_app.command("show")
595
+ def category_show(
596
+ slug: str,
597
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
598
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
599
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
600
+ ) -> None:
601
+ def _op() -> None:
602
+ client = build_client(server_url, insecure=insecure)
603
+ result = run(client.get_category(slug))
604
+ result["dashboard_url"] = _category_dashboard_url(result["slug"])
605
+ emit_result(result, json_output=json_output)
606
+
607
+ try:
608
+ _run(_op)
609
+ except CLIError as exc:
610
+ typer.echo(str(exc), err=True)
611
+ raise typer.Exit(code=1) from exc
612
+
613
+
614
+ @category_app.command("update")
615
+ def category_update(
616
+ slug: str,
617
+ name: str | None = typer.Option(None, "--name", help="New display name."),
618
+ icon: str | None = typer.Option(None, "--icon", help="New tabler icon name."),
619
+ description: str | None = typer.Option(None, "--description", help="New description."),
620
+ sort_order: int | None = typer.Option(None, "--sort-order", help="Lower sorts first."),
621
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
622
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
623
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
624
+ ) -> None:
625
+ def _op() -> None:
626
+ client = build_client(server_url, insecure=insecure)
627
+ payload: dict[str, object] = {}
628
+ if name is not None:
629
+ payload["name"] = name
630
+ if icon is not None:
631
+ payload["icon"] = icon
632
+ if description is not None:
633
+ payload["description"] = description
634
+ if sort_order is not None:
635
+ payload["sort_order"] = sort_order
636
+ if not payload:
637
+ raise CLIError("No update fields provided.")
638
+ result = run(client.update_category(slug, payload))
639
+ emit_result(result, json_output=json_output)
640
+
641
+ try:
642
+ _run(_op)
643
+ except CLIError as exc:
644
+ typer.echo(str(exc), err=True)
645
+ raise typer.Exit(code=1) from exc
646
+
647
+
648
+ @category_app.command("delete")
649
+ def category_delete(
650
+ slug: str,
651
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
652
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
653
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
654
+ ) -> None:
655
+ def _op() -> None:
656
+ client = build_client(server_url, insecure=insecure)
657
+ result = run(client.delete_category(slug))
658
+ emit_result(result, json_output=json_output)
659
+
660
+ try:
661
+ _run(_op)
662
+ except CLIError as exc:
663
+ typer.echo(str(exc), err=True)
664
+ raise typer.Exit(code=1) from exc
665
+
666
+
667
+ # ---------------------------------------------------------------------------
668
+ # Pages
669
+ # ---------------------------------------------------------------------------
670
+
671
+
672
+ @page_app.command("create")
673
+ def page_create(
674
+ title: str = typer.Option(..., "--title", help="Page title."),
675
+ description: str = typer.Option(..., "--description", help="Short summary."),
676
+ category: str = typer.Option(..., "--category", help="Category slug the page belongs to."),
677
+ slug: str | None = typer.Option(None, "--slug", help="Optional slug override."),
678
+ sort_order: int = typer.Option(0, "--sort-order", help="Lower sorts first."),
679
+ markdown: str | None = typer.Option(None, "--markdown", help="Inline MDX body."),
680
+ markdown_file: Path | None = typer.Option(None, "--markdown-file", exists=True, readable=True, dir_okay=False),
681
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
682
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
683
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
684
+ ) -> None:
685
+ def _op() -> None:
686
+ body = read_markdown_from_source(markdown=markdown, markdown_file=markdown_file)
687
+ client = build_client(server_url, insecure=insecure)
688
+ payload = {
689
+ "title": title,
690
+ "description": description,
691
+ "category_slug": category,
692
+ "slug": slug,
693
+ "sort_order": sort_order,
694
+ "markdown": body,
695
+ }
696
+ result = run(client.create_page(payload))
697
+ result["dashboard_url"] = _dashboard_url(result["slug"])
698
+ emit_result(result, json_output=json_output)
699
+
700
+ try:
701
+ _run(_op)
702
+ except CLIError as exc:
703
+ typer.echo(str(exc), err=True)
704
+ raise typer.Exit(code=1) from exc
705
+
706
+
707
+ @page_app.command("list")
708
+ def page_list(
709
+ category: str | None = typer.Option(None, "--category", help="Filter by category slug."),
710
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
711
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
712
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
713
+ ) -> None:
714
+ def _op() -> None:
715
+ client = build_client(server_url, insecure=insecure)
716
+ result = run(client.list_pages(category=category))
717
+ emit_result(result, json_output=json_output)
718
+
719
+ try:
720
+ _run(_op)
721
+ except CLIError as exc:
722
+ typer.echo(str(exc), err=True)
723
+ raise typer.Exit(code=1) from exc
724
+
725
+
726
+ @page_app.command("show")
727
+ def page_show(
728
+ slug: str,
729
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
730
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
731
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
732
+ ) -> None:
733
+ def _op() -> None:
734
+ client = build_client(server_url, insecure=insecure)
735
+ result = run(client.get_page(slug))
736
+ result["dashboard_url"] = _dashboard_url(result["slug"])
737
+ emit_result(result, json_output=json_output)
738
+
739
+ try:
740
+ _run(_op)
741
+ except CLIError as exc:
742
+ typer.echo(str(exc), err=True)
743
+ raise typer.Exit(code=1) from exc
744
+
745
+
746
+ @page_app.command("update")
747
+ def page_update(
748
+ slug: str,
749
+ title: str | None = typer.Option(None, "--title", help="New title."),
750
+ description: str | None = typer.Option(None, "--description", help="New summary."),
751
+ category: str | None = typer.Option(None, "--category", help="New category slug."),
752
+ sort_order: int | None = typer.Option(None, "--sort-order", help="Lower sorts first."),
753
+ markdown: str | None = typer.Option(None, "--markdown", help="Inline MDX body."),
754
+ markdown_file: Path | None = typer.Option(None, "--markdown-file", exists=True, readable=True, dir_okay=False),
755
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
756
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
757
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
758
+ ) -> None:
759
+ def _op() -> None:
760
+ client = build_client(server_url, insecure=insecure)
761
+ payload: dict[str, object] = {}
762
+ if title is not None:
763
+ payload["title"] = title
764
+ if description is not None:
765
+ payload["description"] = description
766
+ if category is not None:
767
+ payload["category_slug"] = category
768
+ if sort_order is not None:
769
+ payload["sort_order"] = sort_order
770
+ if markdown is not None or markdown_file is not None:
771
+ payload["markdown"] = read_markdown_from_source(markdown=markdown, markdown_file=markdown_file)
772
+ if not payload:
773
+ raise CLIError("No update fields provided.")
774
+ result = run(client.update_page(slug, payload))
775
+ result["dashboard_url"] = _dashboard_url(result["slug"])
776
+ emit_result(result, json_output=json_output)
777
+
778
+ try:
779
+ _run(_op)
780
+ except CLIError as exc:
781
+ typer.echo(str(exc), err=True)
782
+ raise typer.Exit(code=1) from exc
783
+
784
+
785
+ @page_app.command("delete")
786
+ def page_delete(
787
+ slug: str,
788
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
789
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
790
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
791
+ ) -> None:
792
+ def _op() -> None:
793
+ client = build_client(server_url, insecure=insecure)
794
+ result = run(client.delete_page(slug))
795
+ emit_result(result, json_output=json_output)
796
+
797
+ try:
798
+ _run(_op)
799
+ except CLIError as exc:
800
+ typer.echo(str(exc), err=True)
801
+ raise typer.Exit(code=1) from exc
802
+
803
+
804
+ # ---------------------------------------------------------------------------
805
+ # Skill installation
806
+ # ---------------------------------------------------------------------------
807
+
808
+
809
+ @skill_app.command("install")
810
+ def skill_install(
811
+ target_dir: Path | None = typer.Option(
812
+ None,
813
+ "--dir",
814
+ help="Destination directory. Defaults to ~/.agents/skills/content-pipeline.",
815
+ ),
816
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
817
+ ) -> None:
818
+ """Install the bundled ChatGPT/Codex skill into the agents skills directory."""
819
+ from personal_cli.skill import install_skill
820
+
821
+ try:
822
+ destination = install_skill(target_dir)
823
+ emit_result(
824
+ {"installed": True, "path": str(destination)},
825
+ json_output=json_output,
826
+ )
827
+ except Exception as exc:
828
+ typer.echo(str(exc), err=True)
829
+ raise typer.Exit(code=1) from exc
830
+
831
+
832
+ @skill_app.command("uninstall")
833
+ def skill_uninstall(
834
+ target_dir: Path | None = typer.Option(
835
+ None,
836
+ "--dir",
837
+ help="Installed location. Defaults to ~/.agents/skills/content-pipeline.",
838
+ ),
839
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
840
+ ) -> None:
841
+ """Remove the installed skill."""
842
+ from personal_cli.skill import uninstall_skill
843
+
844
+ try:
845
+ removed = uninstall_skill(target_dir)
846
+ emit_result({"uninstalled": removed}, json_output=json_output)
847
+ except Exception as exc:
848
+ typer.echo(str(exc), err=True)
849
+ raise typer.Exit(code=1) from exc
850
+
851
+
852
+ @skill_app.command("path")
853
+ def skill_path(
854
+ target_dir: Path | None = typer.Option(None, "--dir", help="Custom install location."),
855
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
856
+ ) -> None:
857
+ """Print where the skill would be installed."""
858
+ from personal_cli.skill import skill_install_path
859
+
860
+ destination = skill_install_path(target_dir)
861
+ emit_result({"path": str(destination)}, json_output=json_output)
862
+
863
+
514
864
  def main() -> None:
515
865
  app()
516
866