blog-cli 0.1.0__py3-none-any.whl
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.
- blog_cli-0.1.0.dist-info/METADATA +147 -0
- blog_cli-0.1.0.dist-info/RECORD +10 -0
- blog_cli-0.1.0.dist-info/WHEEL +5 -0
- blog_cli-0.1.0.dist-info/entry_points.txt +2 -0
- blog_cli-0.1.0.dist-info/top_level.txt +1 -0
- personal_cli/__init__.py +2 -0
- personal_cli/__main__.py +6 -0
- personal_cli/cli.py +243 -0
- personal_cli/client.py +151 -0
- personal_cli/formatting.py +38 -0
|
@@ -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
|
+

|
|
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,10 @@
|
|
|
1
|
+
personal_cli/__init__.py,sha256=zSLVFHJp7dRslRHiL0NBg999NSi0kBHsSDCe4Pko_TM,29
|
|
2
|
+
personal_cli/__main__.py,sha256=Q-h9Re4o1TQV5QtcL-r7JCJvuoU9rvdJs9HMqsdiZ6E,75
|
|
3
|
+
personal_cli/cli.py,sha256=QKGvWM0fzPH0ySIR3UZ5w19KWMMv85Eu_0HmF0D013M,9793
|
|
4
|
+
personal_cli/client.py,sha256=XKj_cuel7eXxkmiddU-GUkvkCq5ttY-DC2KJL89gynw,5410
|
|
5
|
+
personal_cli/formatting.py,sha256=6ExLa1unUKp0DM3WGj7Z3_OO1XAoo3eUMHOo2jDwM6o,972
|
|
6
|
+
blog_cli-0.1.0.dist-info/METADATA,sha256=ZN9VWYphkZJVAxtKH-GqFlJCaZjhNu8giGvDfiFdN38,3656
|
|
7
|
+
blog_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
8
|
+
blog_cli-0.1.0.dist-info/entry_points.txt,sha256=bwoGco668a8rk3HGGzzHAOeTa27RP1QPo_arD9pbbIA,51
|
|
9
|
+
blog_cli-0.1.0.dist-info/top_level.txt,sha256=3y2DqdgZGcLQJNm0sO-0kIWFLtAYP90FPffxql9gGGM,13
|
|
10
|
+
blog_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
personal_cli
|
personal_cli/__init__.py
ADDED
personal_cli/__main__.py
ADDED
personal_cli/cli.py
ADDED
|
@@ -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()
|
personal_cli/client.py
ADDED
|
@@ -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
|
+
|