blog-cli 0.1.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.
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: blog-cli
3
+ Version: 0.1.0
4
+ Summary: Agent-friendly CLI for managing a blog. Create drafts, upload media, generate time-limited preview links.
5
+ Author: tanaka-mambinge
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/tanaka-mambinge/personal
8
+ Project-URL: Issues, https://github.com/tanaka-mambinge/personal/issues
9
+ Keywords: blog,cli,agent,markdown,writing
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Internet :: WWW/HTTP
14
+ Requires-Python: >=3.14
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: httpx>=0.28
17
+ Requires-Dist: typer>=0.16
18
+ Provides-Extra: dev
19
+ Requires-Dist: asgi-lifespan>=2.1; extra == "dev"
20
+ Requires-Dist: fastapi>=0.115; extra == "dev"
21
+ Requires-Dist: motor>=3.7; extra == "dev"
22
+ Requires-Dist: pytest>=8.3; extra == "dev"
23
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
24
+ Requires-Dist: pydantic>=2.10; extra == "dev"
25
+ Requires-Dist: pydantic-settings>=2.6; extra == "dev"
26
+ Requires-Dist: pymongo>=4.9; extra == "dev"
27
+ Requires-Dist: python-multipart>=0.0.20; extra == "dev"
28
+ Requires-Dist: uvicorn>=0.49; extra == "dev"
29
+
30
+ # blog-cli
31
+
32
+ Agent-friendly CLI for managing a personal blog. Create drafts, upload media, generate time-limited preview links — all from the command line.
33
+
34
+ Designed for use by both humans and AI agents.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pipx install blog-cli
40
+ # or
41
+ uv tool install blog-cli
42
+ ```
43
+
44
+ For development:
45
+
46
+ ```bash
47
+ git clone https://github.com/tanaka-mambinge/personal
48
+ cd personal/blog-cli
49
+ uv sync --extra dev
50
+ ```
51
+
52
+ ## Configure
53
+
54
+ Set these env vars (e.g. in `~/.zshrc`):
55
+
56
+ ```bash
57
+ export PERSONAL_SERVER_URL="https://api.personal.localhost:1355"
58
+ export PERSONAL_API_KEY="your-api-key"
59
+ export PERSONAL_SITE_URL="https://personal.localhost:1355"
60
+ ```
61
+
62
+ Empty `PERSONAL_API_KEY` disables auth if your server has no key configured.
63
+
64
+ ## Usage
65
+
66
+ ### Articles
67
+
68
+ ```bash
69
+ # Create a draft
70
+ blog-cli article create \
71
+ --title "My Post" \
72
+ --description "A short summary" \
73
+ --type blog \
74
+ --markdown "# My Post\n\nHello."
75
+
76
+ # List published blog posts
77
+ blog-cli article list --type blog
78
+
79
+ # Show an article
80
+ blog-cli article show my-post
81
+
82
+ # Update an article
83
+ blog-cli article update my-post --title "Better Title"
84
+
85
+ # Generate a 24h preview link for a draft
86
+ blog-cli article preview my-post
87
+
88
+ # Custom TTL (1–168 hours)
89
+ blog-cli article preview my-post --ttl-hours 4
90
+
91
+ # Publish
92
+ blog-cli article publish my-post --published-by agent
93
+
94
+ # Delete (soft)
95
+ blog-cli article delete my-post
96
+
97
+ # Unarchive (restore deleted)
98
+ blog-cli article unarchive my-post
99
+ ```
100
+
101
+ All commands support `--json` for machine-readable output and `--server-url` to override the server.
102
+
103
+ ### Media
104
+
105
+ ```bash
106
+ # Upload (name is the unique key used in markdown)
107
+ blog-cli media upload --name hero-image ./hero.jpg
108
+
109
+ # Update (replace the file, keep the name)
110
+ blog-cli media update --name hero-image ./new-hero.jpg
111
+
112
+ # Delete (soft)
113
+ blog-cli media delete --name hero-image
114
+ ```
115
+
116
+ ### Markdown Image References
117
+
118
+ Upload media with a name, then reference it in article markdown:
119
+
120
+ ```markdown
121
+ ![Hero image](hero-image)
122
+
123
+ <video controls width="100%" src="ambulance-video"></video>
124
+ ```
125
+
126
+ The site resolves these to full URLs automatically.
127
+
128
+ ## Testing
129
+
130
+ ```bash
131
+ uv run pytest -v
132
+ ```
133
+
134
+ Spins up a temp MongoDB + server via test fixtures. No external deps needed.
135
+
136
+ ## Architecture
137
+
138
+ ```
139
+ CLI (httpx) → FastAPI server → Motor/GridFS → MongoDB
140
+ ```
141
+
142
+ - **Public endpoints** (GET) — list and read published articles, download media
143
+ - **Protected endpoints** (POST/PATCH/PUT/DELETE) — require `Authorization: Bearer <key>`
144
+
145
+ ## License
146
+
147
+ MIT
@@ -0,0 +1,118 @@
1
+ # blog-cli
2
+
3
+ Agent-friendly CLI for managing a personal blog. Create drafts, upload media, generate time-limited preview links — all from the command line.
4
+
5
+ Designed for use by both humans and AI agents.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pipx install blog-cli
11
+ # or
12
+ uv tool install blog-cli
13
+ ```
14
+
15
+ For development:
16
+
17
+ ```bash
18
+ git clone https://github.com/tanaka-mambinge/personal
19
+ cd personal/blog-cli
20
+ uv sync --extra dev
21
+ ```
22
+
23
+ ## Configure
24
+
25
+ Set these env vars (e.g. in `~/.zshrc`):
26
+
27
+ ```bash
28
+ export PERSONAL_SERVER_URL="https://api.personal.localhost:1355"
29
+ export PERSONAL_API_KEY="your-api-key"
30
+ export PERSONAL_SITE_URL="https://personal.localhost:1355"
31
+ ```
32
+
33
+ Empty `PERSONAL_API_KEY` disables auth if your server has no key configured.
34
+
35
+ ## Usage
36
+
37
+ ### Articles
38
+
39
+ ```bash
40
+ # Create a draft
41
+ blog-cli article create \
42
+ --title "My Post" \
43
+ --description "A short summary" \
44
+ --type blog \
45
+ --markdown "# My Post\n\nHello."
46
+
47
+ # List published blog posts
48
+ blog-cli article list --type blog
49
+
50
+ # Show an article
51
+ blog-cli article show my-post
52
+
53
+ # Update an article
54
+ blog-cli article update my-post --title "Better Title"
55
+
56
+ # Generate a 24h preview link for a draft
57
+ blog-cli article preview my-post
58
+
59
+ # Custom TTL (1–168 hours)
60
+ blog-cli article preview my-post --ttl-hours 4
61
+
62
+ # Publish
63
+ blog-cli article publish my-post --published-by agent
64
+
65
+ # Delete (soft)
66
+ blog-cli article delete my-post
67
+
68
+ # Unarchive (restore deleted)
69
+ blog-cli article unarchive my-post
70
+ ```
71
+
72
+ All commands support `--json` for machine-readable output and `--server-url` to override the server.
73
+
74
+ ### Media
75
+
76
+ ```bash
77
+ # Upload (name is the unique key used in markdown)
78
+ blog-cli media upload --name hero-image ./hero.jpg
79
+
80
+ # Update (replace the file, keep the name)
81
+ blog-cli media update --name hero-image ./new-hero.jpg
82
+
83
+ # Delete (soft)
84
+ blog-cli media delete --name hero-image
85
+ ```
86
+
87
+ ### Markdown Image References
88
+
89
+ Upload media with a name, then reference it in article markdown:
90
+
91
+ ```markdown
92
+ ![Hero image](hero-image)
93
+
94
+ <video controls width="100%" src="ambulance-video"></video>
95
+ ```
96
+
97
+ The site resolves these to full URLs automatically.
98
+
99
+ ## Testing
100
+
101
+ ```bash
102
+ uv run pytest -v
103
+ ```
104
+
105
+ Spins up a temp MongoDB + server via test fixtures. No external deps needed.
106
+
107
+ ## Architecture
108
+
109
+ ```
110
+ CLI (httpx) → FastAPI server → Motor/GridFS → MongoDB
111
+ ```
112
+
113
+ - **Public endpoints** (GET) — list and read published articles, download media
114
+ - **Protected endpoints** (POST/PATCH/PUT/DELETE) — require `Authorization: Bearer <key>`
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "blog-cli"
7
+ version = "0.1.0"
8
+ description = "Agent-friendly CLI for managing a blog. Create drafts, upload media, generate time-limited preview links."
9
+ readme = "README.md"
10
+ requires-python = ">=3.14"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "tanaka-mambinge"}]
13
+ keywords = ["blog", "cli", "agent", "markdown", "writing"]
14
+ classifiers = [
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Internet :: WWW/HTTP",
19
+ ]
20
+ dependencies = [
21
+ "httpx>=0.28",
22
+ "typer>=0.16",
23
+ ]
24
+
25
+ [project.urls]
26
+ Repository = "https://github.com/tanaka-mambinge/personal"
27
+ Issues = "https://github.com/tanaka-mambinge/personal/issues"
28
+
29
+ [project.scripts]
30
+ blog-cli = "personal_cli.cli:main"
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "asgi-lifespan>=2.1",
35
+ "fastapi>=0.115",
36
+ "motor>=3.7",
37
+ "pytest>=8.3",
38
+ "pytest-asyncio>=0.24",
39
+ "pydantic>=2.10",
40
+ "pydantic-settings>=2.6",
41
+ "pymongo>=4.9",
42
+ "python-multipart>=0.0.20",
43
+ "uvicorn>=0.49",
44
+ ]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+ python_files = ["test_*.py"]
49
+ asyncio_mode = "auto"
50
+
51
+ [tool.uv]
52
+ package = true
53
+
54
+ [tool.setuptools]
55
+ package-dir = {"" = "src"}
56
+
57
+ [tool.setuptools.packages.find]
58
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: blog-cli
3
+ Version: 0.1.0
4
+ Summary: Agent-friendly CLI for managing a blog. Create drafts, upload media, generate time-limited preview links.
5
+ Author: tanaka-mambinge
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/tanaka-mambinge/personal
8
+ Project-URL: Issues, https://github.com/tanaka-mambinge/personal/issues
9
+ Keywords: blog,cli,agent,markdown,writing
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Internet :: WWW/HTTP
14
+ Requires-Python: >=3.14
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: httpx>=0.28
17
+ Requires-Dist: typer>=0.16
18
+ Provides-Extra: dev
19
+ Requires-Dist: asgi-lifespan>=2.1; extra == "dev"
20
+ Requires-Dist: fastapi>=0.115; extra == "dev"
21
+ Requires-Dist: motor>=3.7; extra == "dev"
22
+ Requires-Dist: pytest>=8.3; extra == "dev"
23
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
24
+ Requires-Dist: pydantic>=2.10; extra == "dev"
25
+ Requires-Dist: pydantic-settings>=2.6; extra == "dev"
26
+ Requires-Dist: pymongo>=4.9; extra == "dev"
27
+ Requires-Dist: python-multipart>=0.0.20; extra == "dev"
28
+ Requires-Dist: uvicorn>=0.49; extra == "dev"
29
+
30
+ # blog-cli
31
+
32
+ Agent-friendly CLI for managing a personal blog. Create drafts, upload media, generate time-limited preview links — all from the command line.
33
+
34
+ Designed for use by both humans and AI agents.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pipx install blog-cli
40
+ # or
41
+ uv tool install blog-cli
42
+ ```
43
+
44
+ For development:
45
+
46
+ ```bash
47
+ git clone https://github.com/tanaka-mambinge/personal
48
+ cd personal/blog-cli
49
+ uv sync --extra dev
50
+ ```
51
+
52
+ ## Configure
53
+
54
+ Set these env vars (e.g. in `~/.zshrc`):
55
+
56
+ ```bash
57
+ export PERSONAL_SERVER_URL="https://api.personal.localhost:1355"
58
+ export PERSONAL_API_KEY="your-api-key"
59
+ export PERSONAL_SITE_URL="https://personal.localhost:1355"
60
+ ```
61
+
62
+ Empty `PERSONAL_API_KEY` disables auth if your server has no key configured.
63
+
64
+ ## Usage
65
+
66
+ ### Articles
67
+
68
+ ```bash
69
+ # Create a draft
70
+ blog-cli article create \
71
+ --title "My Post" \
72
+ --description "A short summary" \
73
+ --type blog \
74
+ --markdown "# My Post\n\nHello."
75
+
76
+ # List published blog posts
77
+ blog-cli article list --type blog
78
+
79
+ # Show an article
80
+ blog-cli article show my-post
81
+
82
+ # Update an article
83
+ blog-cli article update my-post --title "Better Title"
84
+
85
+ # Generate a 24h preview link for a draft
86
+ blog-cli article preview my-post
87
+
88
+ # Custom TTL (1–168 hours)
89
+ blog-cli article preview my-post --ttl-hours 4
90
+
91
+ # Publish
92
+ blog-cli article publish my-post --published-by agent
93
+
94
+ # Delete (soft)
95
+ blog-cli article delete my-post
96
+
97
+ # Unarchive (restore deleted)
98
+ blog-cli article unarchive my-post
99
+ ```
100
+
101
+ All commands support `--json` for machine-readable output and `--server-url` to override the server.
102
+
103
+ ### Media
104
+
105
+ ```bash
106
+ # Upload (name is the unique key used in markdown)
107
+ blog-cli media upload --name hero-image ./hero.jpg
108
+
109
+ # Update (replace the file, keep the name)
110
+ blog-cli media update --name hero-image ./new-hero.jpg
111
+
112
+ # Delete (soft)
113
+ blog-cli media delete --name hero-image
114
+ ```
115
+
116
+ ### Markdown Image References
117
+
118
+ Upload media with a name, then reference it in article markdown:
119
+
120
+ ```markdown
121
+ ![Hero image](hero-image)
122
+
123
+ <video controls width="100%" src="ambulance-video"></video>
124
+ ```
125
+
126
+ The site resolves these to full URLs automatically.
127
+
128
+ ## Testing
129
+
130
+ ```bash
131
+ uv run pytest -v
132
+ ```
133
+
134
+ Spins up a temp MongoDB + server via test fixtures. No external deps needed.
135
+
136
+ ## Architecture
137
+
138
+ ```
139
+ CLI (httpx) → FastAPI server → Motor/GridFS → MongoDB
140
+ ```
141
+
142
+ - **Public endpoints** (GET) — list and read published articles, download media
143
+ - **Protected endpoints** (POST/PATCH/PUT/DELETE) — require `Authorization: Bearer <key>`
144
+
145
+ ## License
146
+
147
+ MIT
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/blog_cli.egg-info/PKG-INFO
4
+ src/blog_cli.egg-info/SOURCES.txt
5
+ src/blog_cli.egg-info/dependency_links.txt
6
+ src/blog_cli.egg-info/entry_points.txt
7
+ src/blog_cli.egg-info/requires.txt
8
+ src/blog_cli.egg-info/top_level.txt
9
+ src/personal_cli/__init__.py
10
+ src/personal_cli/__main__.py
11
+ src/personal_cli/cli.py
12
+ src/personal_cli/client.py
13
+ src/personal_cli/formatting.py
14
+ tests/test_cli.py
15
+ tests/test_client.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ blog-cli = personal_cli.cli:main
@@ -0,0 +1,14 @@
1
+ httpx>=0.28
2
+ typer>=0.16
3
+
4
+ [dev]
5
+ asgi-lifespan>=2.1
6
+ fastapi>=0.115
7
+ motor>=3.7
8
+ pytest>=8.3
9
+ pytest-asyncio>=0.24
10
+ pydantic>=2.10
11
+ pydantic-settings>=2.6
12
+ pymongo>=4.9
13
+ python-multipart>=0.0.20
14
+ uvicorn>=0.49
@@ -0,0 +1 @@
1
+ personal_cli
@@ -0,0 +1,2 @@
1
+ """Personal article CLI."""
2
+
@@ -0,0 +1,6 @@
1
+ from personal_cli.cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
6
+
@@ -0,0 +1,243 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from personal_cli.client import ArticleApiClient, CLIError, get_default_config
9
+ from personal_cli.formatting import emit_result, read_markdown_from_source
10
+
11
+ app = typer.Typer(help="Agent-facing article CLI.")
12
+ article_app = typer.Typer(help="Manage articles.")
13
+ media_app = typer.Typer(help="Manage media uploads.")
14
+
15
+ app.add_typer(article_app, name="article")
16
+ app.add_typer(media_app, name="media")
17
+
18
+
19
+ def build_client(server_url: str | None = None) -> ArticleApiClient:
20
+ url, api_key = get_default_config()
21
+ return ArticleApiClient(server_url or url, api_key=api_key)
22
+
23
+
24
+ def run(coro):
25
+ return asyncio.run(coro)
26
+
27
+
28
+ @article_app.command("list")
29
+ def article_list(
30
+ type_filter: str = typer.Option("all", "--type", help="all, blog, or project."),
31
+ status: str | None = typer.Option(None, "--status", help="Filter by article status."),
32
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
33
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
34
+ ) -> None:
35
+ try:
36
+ client = build_client(server_url)
37
+ articles = run(client.list_articles(status=status, type_filter=type_filter))
38
+ emit_result(articles, json_output=json_output)
39
+ except CLIError as exc:
40
+ typer.echo(str(exc), err=True)
41
+ raise typer.Exit(code=1) from exc
42
+
43
+
44
+ @article_app.command("show")
45
+ def article_show(
46
+ slug: str,
47
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
48
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
49
+ ) -> None:
50
+ try:
51
+ client = build_client(server_url)
52
+ article = run(client.get_article(slug))
53
+ emit_result(article, json_output=json_output)
54
+ except CLIError as exc:
55
+ typer.echo(str(exc), err=True)
56
+ raise typer.Exit(code=1) from exc
57
+
58
+
59
+ @article_app.command("create")
60
+ def article_create(
61
+ title: str = typer.Option(..., "--title", help="Article title."),
62
+ description: str = typer.Option(..., "--description", help="Short summary."),
63
+ slug: str | None = typer.Option(None, "--slug", help="Optional slug override."),
64
+ tag: list[str] = typer.Option([], "--tag", help="Repeat for each tag."),
65
+ article_type: str = typer.Option(..., "--type", help="blog or project."),
66
+ status: str = typer.Option("draft", "--status", help="draft or published."),
67
+ markdown: str | None = typer.Option(None, "--markdown", help="Inline markdown body."),
68
+ markdown_file: Path | None = typer.Option(None, "--markdown-file", exists=True, readable=True, dir_okay=False),
69
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
70
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
71
+ ) -> None:
72
+ try:
73
+ body = read_markdown_from_source(markdown=markdown, markdown_file=markdown_file)
74
+ client = build_client(server_url)
75
+ payload = {
76
+ "title": title,
77
+ "description": description,
78
+ "slug": slug,
79
+ "tags": tag,
80
+ "type": article_type,
81
+ "status": status,
82
+ "markdown": body,
83
+ }
84
+ article = run(client.create_article(payload))
85
+ emit_result(article, json_output=json_output)
86
+ except CLIError as exc:
87
+ typer.echo(str(exc), err=True)
88
+ raise typer.Exit(code=1) from exc
89
+
90
+
91
+ @article_app.command("update")
92
+ def article_update(
93
+ slug: str,
94
+ title: str | None = typer.Option(None, "--title", help="New title."),
95
+ description: str | None = typer.Option(None, "--description", help="New summary."),
96
+ tag: list[str] | None = typer.Option(None, "--tag", help="Repeat for each tag."),
97
+ article_type: str | None = typer.Option(None, "--type", help="blog or project."),
98
+ status: str | None = typer.Option(None, "--status", help="draft or published."),
99
+ markdown: str | None = typer.Option(None, "--markdown", help="Inline markdown body."),
100
+ markdown_file: Path | None = typer.Option(None, "--markdown-file", exists=True, readable=True, dir_okay=False),
101
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
102
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
103
+ ) -> None:
104
+ try:
105
+ client = build_client(server_url)
106
+ payload: dict[str, object] = {}
107
+ if title is not None:
108
+ payload["title"] = title
109
+ if description is not None:
110
+ payload["description"] = description
111
+ if tag is not None:
112
+ payload["tags"] = tag
113
+ if article_type is not None:
114
+ payload["type"] = article_type
115
+ if status is not None:
116
+ payload["status"] = status
117
+ if markdown is not None or markdown_file is not None:
118
+ payload["markdown"] = read_markdown_from_source(markdown=markdown, markdown_file=markdown_file)
119
+ article = run(client.update_article(slug, payload))
120
+ emit_result(article, json_output=json_output)
121
+ except CLIError as exc:
122
+ typer.echo(str(exc), err=True)
123
+ raise typer.Exit(code=1) from exc
124
+
125
+
126
+ @article_app.command("publish")
127
+ def article_publish(
128
+ slug: str,
129
+ published_by: str | None = typer.Option(None, "--published-by", help="Who published the article."),
130
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
131
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
132
+ ) -> None:
133
+ try:
134
+ client = build_client(server_url)
135
+ article = run(client.publish_article(slug, {"published_by": published_by} if published_by else None))
136
+ emit_result(article, json_output=json_output)
137
+ except CLIError as exc:
138
+ typer.echo(str(exc), err=True)
139
+ raise typer.Exit(code=1) from exc
140
+
141
+
142
+ @article_app.command("delete")
143
+ def article_delete(
144
+ slug: str,
145
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
146
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
147
+ ) -> None:
148
+ try:
149
+ client = build_client(server_url)
150
+ result = run(client.delete_article(slug))
151
+ emit_result(result, json_output=json_output)
152
+ except CLIError as exc:
153
+ typer.echo(str(exc), err=True)
154
+ raise typer.Exit(code=1) from exc
155
+
156
+
157
+ @article_app.command("unarchive")
158
+ def article_unarchive(
159
+ slug: str,
160
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
161
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
162
+ ) -> None:
163
+ try:
164
+ client = build_client(server_url)
165
+ article = run(client.unarchive_article(slug))
166
+ emit_result(article, json_output=json_output)
167
+ except CLIError as exc:
168
+ typer.echo(str(exc), err=True)
169
+ raise typer.Exit(code=1) from exc
170
+
171
+
172
+ @article_app.command("preview")
173
+ def article_preview(
174
+ slug: str,
175
+ ttl_hours: int = typer.Option(24, "--ttl-hours", help="Hours until the preview link expires."),
176
+ site_url: str | None = typer.Option(None, "--site-url", help="Base URL of the personal site."),
177
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
178
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
179
+ ) -> None:
180
+ try:
181
+ import os
182
+ resolved_site_url = site_url or os.environ.get("PERSONAL_SITE_URL", "http://localhost:3000")
183
+ client = build_client(server_url)
184
+ result = run(client.generate_preview(slug, ttl_hours=ttl_hours, base_url=resolved_site_url))
185
+ emit_result(result, json_output=json_output)
186
+ except CLIError as exc:
187
+ typer.echo(str(exc), err=True)
188
+ raise typer.Exit(code=1) from exc
189
+
190
+
191
+ @media_app.command("upload")
192
+ def media_upload(
193
+ name: str = typer.Option(..., "--name", help="Unique name for the media (e.g. hero-image)."),
194
+ path: Path = typer.Argument(..., exists=True, file_okay=True, dir_okay=False, readable=True),
195
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
196
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
197
+ ) -> None:
198
+ try:
199
+ client = build_client(server_url)
200
+ result = run(client.upload_media(name, path))
201
+ emit_result(result, json_output=json_output)
202
+ except CLIError as exc:
203
+ typer.echo(str(exc), err=True)
204
+ raise typer.Exit(code=1) from exc
205
+
206
+
207
+ @media_app.command("update")
208
+ def media_update(
209
+ name: str = typer.Option(..., "--name", help="Name of the media to replace."),
210
+ path: Path = typer.Argument(..., exists=True, file_okay=True, dir_okay=False, readable=True),
211
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
212
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
213
+ ) -> None:
214
+ try:
215
+ client = build_client(server_url)
216
+ result = run(client.update_media(name, path))
217
+ emit_result(result, json_output=json_output)
218
+ except CLIError as exc:
219
+ typer.echo(str(exc), err=True)
220
+ raise typer.Exit(code=1) from exc
221
+
222
+
223
+ @media_app.command("delete")
224
+ def media_delete(
225
+ name: str = typer.Option(..., "--name", help="Name of the media to delete."),
226
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
227
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
228
+ ) -> None:
229
+ try:
230
+ client = build_client(server_url)
231
+ result = run(client.delete_media(name))
232
+ emit_result(result, json_output=json_output)
233
+ except CLIError as exc:
234
+ typer.echo(str(exc), err=True)
235
+ raise typer.Exit(code=1) from exc
236
+
237
+
238
+ def main() -> None:
239
+ app()
240
+
241
+
242
+ if __name__ == "__main__":
243
+ main()
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+
9
+ class CLIError(RuntimeError):
10
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
11
+ super().__init__(message)
12
+ self.status_code = status_code
13
+
14
+
15
+ class ArticleApiClient:
16
+ def __init__(
17
+ self,
18
+ base_url: str,
19
+ *,
20
+ api_key: str | None = None,
21
+ transport: httpx.AsyncBaseTransport | None = None,
22
+ timeout: float = 30.0,
23
+ ) -> None:
24
+ self.base_url = base_url.rstrip("/")
25
+ self.api_key = api_key
26
+ self.transport = transport
27
+ self.timeout = timeout
28
+
29
+ def _headers(self) -> dict[str, str]:
30
+ if not self.api_key:
31
+ return {}
32
+ return {"Authorization": f"Bearer {self.api_key}"}
33
+
34
+ async def _request_json(
35
+ self,
36
+ method: str,
37
+ path: str,
38
+ *,
39
+ json: dict[str, Any] | list[Any] | None = None,
40
+ params: dict[str, Any] | None = None,
41
+ files: Any = None,
42
+ ) -> Any:
43
+ async with httpx.AsyncClient(
44
+ base_url=self.base_url,
45
+ transport=self.transport,
46
+ timeout=self.timeout,
47
+ headers=self._headers(),
48
+ ) as client:
49
+ response = await client.request(method, path, json=json, params=params, files=files)
50
+ if response.status_code >= 400:
51
+ message = response.text.strip() or response.reason_phrase
52
+ raise CLIError(f"{method} {path} failed: {response.status_code} {message}", status_code=response.status_code)
53
+ if response.content:
54
+ return response.json()
55
+ return None
56
+
57
+ async def list_articles(self, *, status: str | None = None, type_filter: str = "all") -> list[dict[str, Any]]:
58
+ params: dict[str, Any] = {"type": type_filter}
59
+ if status is not None:
60
+ params["status_filter"] = status
61
+ result = await self._request_json("GET", "/api/v1/articles", params=params)
62
+ return list(result or [])
63
+
64
+ async def get_article(self, slug: str) -> dict[str, Any]:
65
+ result = await self._request_json("GET", f"/api/v1/articles/{slug}")
66
+ return dict(result)
67
+
68
+ async def create_article(self, payload: dict[str, Any]) -> dict[str, Any]:
69
+ result = await self._request_json("POST", "/api/v1/articles", json=payload)
70
+ return dict(result)
71
+
72
+ async def update_article(self, slug: str, payload: dict[str, Any]) -> dict[str, Any]:
73
+ result = await self._request_json("PATCH", f"/api/v1/articles/{slug}", json=payload)
74
+ return dict(result)
75
+
76
+ async def publish_article(self, slug: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
77
+ result = await self._request_json("POST", f"/api/v1/articles/{slug}/publish", json=payload)
78
+ return dict(result)
79
+
80
+ async def unarchive_article(self, slug: str) -> dict[str, Any]:
81
+ result = await self._request_json("POST", f"/api/v1/articles/{slug}/unarchive")
82
+ return dict(result)
83
+
84
+ async def delete_article(self, slug: str) -> dict[str, Any]:
85
+ result = await self._request_json("DELETE", f"/api/v1/articles/{slug}")
86
+ return dict(result or {})
87
+
88
+ async def upload_media(self, name: str, path: Path) -> dict[str, Any]:
89
+ with path.open("rb") as handle:
90
+ result = await self._request_json(
91
+ "POST",
92
+ "/api/v1/media",
93
+ params={"name": name},
94
+ files={"file": (path.name, handle, "application/octet-stream")},
95
+ )
96
+ return dict(result)
97
+
98
+ async def update_media(self, name: str, path: Path) -> dict[str, Any]:
99
+ with path.open("rb") as handle:
100
+ result = await self._request_json(
101
+ "PUT",
102
+ f"/api/v1/media/{name}",
103
+ files={"file": (path.name, handle, "application/octet-stream")},
104
+ )
105
+ return dict(result)
106
+
107
+ async def delete_media(self, name: str) -> dict[str, Any]:
108
+ result = await self._request_json("DELETE", f"/api/v1/media/{name}")
109
+ return dict(result or {})
110
+
111
+ async def generate_preview(self, slug: str, *, ttl_hours: int = 24, base_url: str = "http://localhost:3000") -> dict[str, Any]:
112
+ result = await self._request_json(
113
+ "POST",
114
+ f"/api/v1/articles/{slug}/preview",
115
+ params={"ttl_hours": ttl_hours, "base_url": base_url},
116
+ )
117
+ return dict(result)
118
+
119
+
120
+ def get_default_config() -> tuple[str, str | None]:
121
+ import os
122
+ _load_dotenv()
123
+ return (
124
+ os.environ.get("PERSONAL_SERVER_URL", "http://127.0.0.1:8000"),
125
+ os.environ.get("PERSONAL_API_KEY"),
126
+ )
127
+
128
+
129
+ def _load_dotenv() -> None:
130
+ import os
131
+ from pathlib import Path
132
+
133
+ candidates = [
134
+ Path.cwd() / ".env",
135
+ Path(__file__).resolve().parents[3] / ".env",
136
+ ]
137
+ for env_path in candidates:
138
+ if not env_path.exists():
139
+ continue
140
+ with env_path.open() as f:
141
+ for line in f:
142
+ line = line.strip()
143
+ if not line or line.startswith("#"):
144
+ continue
145
+ if "=" not in line:
146
+ continue
147
+ key, _, value = line.partition("=")
148
+ key = key.strip()
149
+ value = value.strip().strip("'\"")
150
+ os.environ[key] = value
151
+ break
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from personal_cli.client import CLIError
9
+
10
+
11
+ def read_markdown_from_source(
12
+ *,
13
+ markdown: str | None,
14
+ markdown_file: Path | None,
15
+ ) -> str:
16
+ if markdown is not None and markdown_file is not None:
17
+ raise CLIError("Use either --markdown or --markdown-file, not both.")
18
+ if markdown is not None:
19
+ return markdown
20
+ if markdown_file is not None:
21
+ return markdown_file.read_text(encoding="utf-8")
22
+ if sys.stdin.isatty():
23
+ raise CLIError("Provide markdown via --markdown, --markdown-file, or stdin.")
24
+ return sys.stdin.read()
25
+
26
+
27
+ def emit_result(data: Any, *, json_output: bool = False) -> None:
28
+ if json_output:
29
+ print(json.dumps(data, indent=2, ensure_ascii=False, default=str))
30
+ return
31
+
32
+ if isinstance(data, list):
33
+ for item in data:
34
+ print(item)
35
+ return
36
+
37
+ print(data)
38
+
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import pytest
6
+ from typer.testing import CliRunner
7
+
8
+ from personal_cli.client import ArticleApiClient
9
+ from personal_cli.cli import app
10
+
11
+
12
+ @pytest.fixture()
13
+ def runner() -> CliRunner:
14
+ return CliRunner()
15
+
16
+
17
+ def _build_client_mock(live_server: str):
18
+ return lambda server_url=None: ArticleApiClient(live_server)
19
+
20
+
21
+ def test_article_cli_smoke(monkeypatch, runner: CliRunner, live_server: str) -> None:
22
+ monkeypatch.setattr("personal_cli.cli.build_client", _build_client_mock(live_server))
23
+
24
+ create_result = runner.invoke(
25
+ app,
26
+ [
27
+ "article",
28
+ "create",
29
+ "--title",
30
+ "CLI Article",
31
+ "--description",
32
+ "Created from the CLI",
33
+ "--type",
34
+ "blog",
35
+ "--markdown",
36
+ "# CLI Article\n\nBody.",
37
+ "--tag",
38
+ "build",
39
+ "--tag",
40
+ "indie",
41
+ "--json",
42
+ ],
43
+ )
44
+ assert create_result.exit_code == 0
45
+ created = json.loads(create_result.stdout)
46
+ assert created["slug"] == "cli-article"
47
+ assert created["type"] == "blog"
48
+
49
+ list_result = runner.invoke(app, ["article", "list", "--type", "blog", "--json"])
50
+ assert list_result.exit_code == 0
51
+ listed = json.loads(list_result.stdout)
52
+ assert listed[0]["slug"] == "cli-article"
53
+
54
+ publish_result = runner.invoke(app, ["article", "publish", "cli-article", "--published-by", "agent", "--json"])
55
+ assert publish_result.exit_code == 0
56
+ published = json.loads(publish_result.stdout)
57
+ assert published["status"] == "published"
58
+
59
+ delete_result = runner.invoke(app, ["article", "delete", "cli-article", "--json"])
60
+ assert delete_result.exit_code == 0
61
+ deleted = json.loads(delete_result.stdout)
62
+ assert deleted["deleted"] is True
63
+ assert deleted["slug"] == "cli-article"
64
+ assert deleted["deleted_at"]
65
+
66
+ unarchive_result = runner.invoke(app, ["article", "unarchive", "cli-article", "--json"])
67
+ assert unarchive_result.exit_code == 0
68
+ unarchived = json.loads(unarchive_result.stdout)
69
+ assert unarchived["slug"] == "cli-article"
70
+ assert unarchived["status"] == "published"
71
+
72
+ empty_list = runner.invoke(app, ["article", "list", "--json"])
73
+ assert empty_list.exit_code == 0
74
+ assert json.loads(empty_list.stdout)[0]["slug"] == "cli-article"
75
+
76
+
77
+ def test_media_cli_smoke(monkeypatch, runner: CliRunner, live_server: str, tmp_path) -> None:
78
+ monkeypatch.setattr("personal_cli.cli.build_client", _build_client_mock(live_server))
79
+
80
+ media_file = tmp_path / "test-image.png"
81
+ media_file.write_bytes(b"fake-image-data")
82
+
83
+ upload_result = runner.invoke(
84
+ app,
85
+ [
86
+ "media",
87
+ "upload",
88
+ "--name",
89
+ "hero-image",
90
+ str(media_file),
91
+ "--json",
92
+ ],
93
+ )
94
+ assert upload_result.exit_code == 0
95
+ uploaded = json.loads(upload_result.stdout)
96
+ assert uploaded["name"] == "hero-image"
97
+ assert uploaded["url"] == "/api/v1/media/hero-image"
98
+
99
+ updated_file = tmp_path / "test-image-v2.png"
100
+ updated_file.write_bytes(b"fake-image-data-v2")
101
+
102
+ update_result = runner.invoke(
103
+ app,
104
+ [
105
+ "media",
106
+ "update",
107
+ "--name",
108
+ "hero-image",
109
+ str(updated_file),
110
+ "--json",
111
+ ],
112
+ )
113
+ assert update_result.exit_code == 0
114
+ updated = json.loads(update_result.stdout)
115
+ assert updated["name"] == "hero-image"
116
+
117
+ delete_result = runner.invoke(
118
+ app,
119
+ [
120
+ "media",
121
+ "delete",
122
+ "--name",
123
+ "hero-image",
124
+ "--json",
125
+ ],
126
+ )
127
+ assert delete_result.exit_code == 0
128
+ deleted = json.loads(delete_result.stdout)
129
+ assert deleted["deleted"] is True
130
+ assert deleted["name"] == "hero-image"
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from personal_cli.formatting import read_markdown_from_source
4
+
5
+
6
+ def test_read_markdown_from_source_prefers_inline_text() -> None:
7
+ assert read_markdown_from_source(markdown="hello", markdown_file=None) == "hello"
8
+