blog-cli 0.1.0__tar.gz → 0.2.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.
@@ -1,17 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blog-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Agent-friendly CLI for managing a blog. Create drafts, upload media, generate time-limited preview links.
5
5
  Author: tanaka-mambinge
6
6
  License: MIT
7
- Project-URL: Repository, https://github.com/tanaka-mambinge/personal
8
- Project-URL: Issues, https://github.com/tanaka-mambinge/personal/issues
7
+ Project-URL: Repository, https://github.com/tanaka-mambinge/personal-cli
8
+ Project-URL: Issues, https://github.com/tanaka-mambinge/personal-cli/issues
9
9
  Keywords: blog,cli,agent,markdown,writing
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: OS Independent
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Topic :: Internet :: WWW/HTTP
14
- Requires-Python: >=3.14
14
+ Requires-Python: >=3.13
15
15
  Description-Content-Type: text/markdown
16
16
  Requires-Dist: httpx>=0.28
17
17
  Requires-Dist: typer>=0.16
@@ -35,32 +35,35 @@ Designed for use by both humans and AI agents.
35
35
 
36
36
  ## Install
37
37
 
38
+ ```bash
39
+ pip install blog-cli
40
+ ```
41
+
42
+ Or via pipx/uv:
43
+
38
44
  ```bash
39
45
  pipx install blog-cli
40
- # or
41
46
  uv tool install blog-cli
42
47
  ```
43
48
 
44
49
  For development:
45
50
 
46
51
  ```bash
47
- git clone https://github.com/tanaka-mambinge/personal
48
- cd personal/blog-cli
52
+ git clone https://github.com/tanaka-mambinge/personal-cli
53
+ cd personal-cli
49
54
  uv sync --extra dev
50
55
  ```
51
56
 
52
57
  ## Configure
53
58
 
54
- Set these env vars (e.g. in `~/.zshrc`):
59
+ All three are required:
55
60
 
56
61
  ```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"
62
+ export PERSONAL_SERVER_URL="<your-server-url>"
63
+ export PERSONAL_API_KEY="<your-api-key>"
64
+ export PERSONAL_SITE_URL="<your-site-url>"
60
65
  ```
61
66
 
62
- Empty `PERSONAL_API_KEY` disables auth if your server has no key configured.
63
-
64
67
  ## Usage
65
68
 
66
69
  ### Articles
@@ -6,32 +6,35 @@ Designed for use by both humans and AI agents.
6
6
 
7
7
  ## Install
8
8
 
9
+ ```bash
10
+ pip install blog-cli
11
+ ```
12
+
13
+ Or via pipx/uv:
14
+
9
15
  ```bash
10
16
  pipx install blog-cli
11
- # or
12
17
  uv tool install blog-cli
13
18
  ```
14
19
 
15
20
  For development:
16
21
 
17
22
  ```bash
18
- git clone https://github.com/tanaka-mambinge/personal
19
- cd personal/blog-cli
23
+ git clone https://github.com/tanaka-mambinge/personal-cli
24
+ cd personal-cli
20
25
  uv sync --extra dev
21
26
  ```
22
27
 
23
28
  ## Configure
24
29
 
25
- Set these env vars (e.g. in `~/.zshrc`):
30
+ All three are required:
26
31
 
27
32
  ```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"
33
+ export PERSONAL_SERVER_URL="<your-server-url>"
34
+ export PERSONAL_API_KEY="<your-api-key>"
35
+ export PERSONAL_SITE_URL="<your-site-url>"
31
36
  ```
32
37
 
33
- Empty `PERSONAL_API_KEY` disables auth if your server has no key configured.
34
-
35
38
  ## Usage
36
39
 
37
40
  ### Articles
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "blog-cli"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Agent-friendly CLI for managing a blog. Create drafts, upload media, generate time-limited preview links."
9
9
  readme = "README.md"
10
- requires-python = ">=3.14"
10
+ requires-python = ">=3.13"
11
11
  license = {text = "MIT"}
12
12
  authors = [{name = "tanaka-mambinge"}]
13
13
  keywords = ["blog", "cli", "agent", "markdown", "writing"]
@@ -23,8 +23,8 @@ dependencies = [
23
23
  ]
24
24
 
25
25
  [project.urls]
26
- Repository = "https://github.com/tanaka-mambinge/personal"
27
- Issues = "https://github.com/tanaka-mambinge/personal/issues"
26
+ Repository = "https://github.com/tanaka-mambinge/personal-cli"
27
+ Issues = "https://github.com/tanaka-mambinge/personal-cli/issues"
28
28
 
29
29
  [project.scripts]
30
30
  blog-cli = "personal_cli.cli:main"
@@ -1,17 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blog-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Agent-friendly CLI for managing a blog. Create drafts, upload media, generate time-limited preview links.
5
5
  Author: tanaka-mambinge
6
6
  License: MIT
7
- Project-URL: Repository, https://github.com/tanaka-mambinge/personal
8
- Project-URL: Issues, https://github.com/tanaka-mambinge/personal/issues
7
+ Project-URL: Repository, https://github.com/tanaka-mambinge/personal-cli
8
+ Project-URL: Issues, https://github.com/tanaka-mambinge/personal-cli/issues
9
9
  Keywords: blog,cli,agent,markdown,writing
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: OS Independent
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Topic :: Internet :: WWW/HTTP
14
- Requires-Python: >=3.14
14
+ Requires-Python: >=3.13
15
15
  Description-Content-Type: text/markdown
16
16
  Requires-Dist: httpx>=0.28
17
17
  Requires-Dist: typer>=0.16
@@ -35,32 +35,35 @@ Designed for use by both humans and AI agents.
35
35
 
36
36
  ## Install
37
37
 
38
+ ```bash
39
+ pip install blog-cli
40
+ ```
41
+
42
+ Or via pipx/uv:
43
+
38
44
  ```bash
39
45
  pipx install blog-cli
40
- # or
41
46
  uv tool install blog-cli
42
47
  ```
43
48
 
44
49
  For development:
45
50
 
46
51
  ```bash
47
- git clone https://github.com/tanaka-mambinge/personal
48
- cd personal/blog-cli
52
+ git clone https://github.com/tanaka-mambinge/personal-cli
53
+ cd personal-cli
49
54
  uv sync --extra dev
50
55
  ```
51
56
 
52
57
  ## Configure
53
58
 
54
- Set these env vars (e.g. in `~/.zshrc`):
59
+ All three are required:
55
60
 
56
61
  ```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"
62
+ export PERSONAL_SERVER_URL="<your-server-url>"
63
+ export PERSONAL_API_KEY="<your-api-key>"
64
+ export PERSONAL_SITE_URL="<your-site-url>"
60
65
  ```
61
66
 
62
- Empty `PERSONAL_API_KEY` disables auth if your server has no key configured.
63
-
64
67
  ## Usage
65
68
 
66
69
  ### Articles
@@ -16,9 +16,9 @@ app.add_typer(article_app, name="article")
16
16
  app.add_typer(media_app, name="media")
17
17
 
18
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)
19
+ def build_client(server_url: str | None = None, insecure: bool = False) -> ArticleApiClient:
20
+ url, api_key, _ = get_default_config()
21
+ return ArticleApiClient(server_url or url, api_key=api_key, verify=not insecure)
22
22
 
23
23
 
24
24
  def run(coro):
@@ -30,10 +30,11 @@ def article_list(
30
30
  type_filter: str = typer.Option("all", "--type", help="all, blog, or project."),
31
31
  status: str | None = typer.Option(None, "--status", help="Filter by article status."),
32
32
  json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
33
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
33
34
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
34
35
  ) -> None:
35
36
  try:
36
- client = build_client(server_url)
37
+ client = build_client(server_url, insecure=insecure)
37
38
  articles = run(client.list_articles(status=status, type_filter=type_filter))
38
39
  emit_result(articles, json_output=json_output)
39
40
  except CLIError as exc:
@@ -45,10 +46,11 @@ def article_list(
45
46
  def article_show(
46
47
  slug: str,
47
48
  json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
49
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
48
50
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
49
51
  ) -> None:
50
52
  try:
51
- client = build_client(server_url)
53
+ client = build_client(server_url, insecure=insecure)
52
54
  article = run(client.get_article(slug))
53
55
  emit_result(article, json_output=json_output)
54
56
  except CLIError as exc:
@@ -67,11 +69,12 @@ def article_create(
67
69
  markdown: str | None = typer.Option(None, "--markdown", help="Inline markdown body."),
68
70
  markdown_file: Path | None = typer.Option(None, "--markdown-file", exists=True, readable=True, dir_okay=False),
69
71
  json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
72
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
70
73
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
71
74
  ) -> None:
72
75
  try:
73
76
  body = read_markdown_from_source(markdown=markdown, markdown_file=markdown_file)
74
- client = build_client(server_url)
77
+ client = build_client(server_url, insecure=insecure)
75
78
  payload = {
76
79
  "title": title,
77
80
  "description": description,
@@ -99,10 +102,11 @@ def article_update(
99
102
  markdown: str | None = typer.Option(None, "--markdown", help="Inline markdown body."),
100
103
  markdown_file: Path | None = typer.Option(None, "--markdown-file", exists=True, readable=True, dir_okay=False),
101
104
  json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
105
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
102
106
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
103
107
  ) -> None:
104
108
  try:
105
- client = build_client(server_url)
109
+ client = build_client(server_url, insecure=insecure)
106
110
  payload: dict[str, object] = {}
107
111
  if title is not None:
108
112
  payload["title"] = title
@@ -128,10 +132,11 @@ def article_publish(
128
132
  slug: str,
129
133
  published_by: str | None = typer.Option(None, "--published-by", help="Who published the article."),
130
134
  json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
135
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
131
136
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
132
137
  ) -> None:
133
138
  try:
134
- client = build_client(server_url)
139
+ client = build_client(server_url, insecure=insecure)
135
140
  article = run(client.publish_article(slug, {"published_by": published_by} if published_by else None))
136
141
  emit_result(article, json_output=json_output)
137
142
  except CLIError as exc:
@@ -143,10 +148,11 @@ def article_publish(
143
148
  def article_delete(
144
149
  slug: str,
145
150
  json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
151
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
146
152
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
147
153
  ) -> None:
148
154
  try:
149
- client = build_client(server_url)
155
+ client = build_client(server_url, insecure=insecure)
150
156
  result = run(client.delete_article(slug))
151
157
  emit_result(result, json_output=json_output)
152
158
  except CLIError as exc:
@@ -158,10 +164,11 @@ def article_delete(
158
164
  def article_unarchive(
159
165
  slug: str,
160
166
  json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
167
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
161
168
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
162
169
  ) -> None:
163
170
  try:
164
- client = build_client(server_url)
171
+ client = build_client(server_url, insecure=insecure)
165
172
  article = run(client.unarchive_article(slug))
166
173
  emit_result(article, json_output=json_output)
167
174
  except CLIError as exc:
@@ -175,12 +182,13 @@ def article_preview(
175
182
  ttl_hours: int = typer.Option(24, "--ttl-hours", help="Hours until the preview link expires."),
176
183
  site_url: str | None = typer.Option(None, "--site-url", help="Base URL of the personal site."),
177
184
  json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
185
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
178
186
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
179
187
  ) -> None:
180
188
  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)
189
+ _, _, default_site_url = get_default_config()
190
+ resolved_site_url = site_url or default_site_url
191
+ client = build_client(server_url, insecure=insecure)
184
192
  result = run(client.generate_preview(slug, ttl_hours=ttl_hours, base_url=resolved_site_url))
185
193
  emit_result(result, json_output=json_output)
186
194
  except CLIError as exc:
@@ -188,15 +196,69 @@ def article_preview(
188
196
  raise typer.Exit(code=1) from exc
189
197
 
190
198
 
199
+ @article_app.command("tag-list")
200
+ @article_app.command("tags")
201
+ def tag_list(
202
+ slug: str,
203
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
204
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
205
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
206
+ ) -> None:
207
+ try:
208
+ client = build_client(server_url, insecure=insecure)
209
+ result = run(client.list_tags(slug))
210
+ emit_result(result, json_output=json_output)
211
+ except CLIError as exc:
212
+ typer.echo(str(exc), err=True)
213
+ raise typer.Exit(code=1) from exc
214
+
215
+
216
+ @article_app.command("tag-add")
217
+ def tag_add(
218
+ slug: str,
219
+ tag: list[str] = typer.Option(..., "--tag", help="Tag to attach. Repeat for multiple."),
220
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
221
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
222
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
223
+ ) -> None:
224
+ try:
225
+ client = build_client(server_url, insecure=insecure)
226
+ result = run(client.attach_tags(slug, tag))
227
+ emit_result(result, json_output=json_output)
228
+ except CLIError as exc:
229
+ typer.echo(str(exc), err=True)
230
+ raise typer.Exit(code=1) from exc
231
+
232
+
233
+ @article_app.command("tag-remove")
234
+ def tag_remove(
235
+ slug: str,
236
+ tag: list[str] = typer.Option(..., "--tag", help="Tag to remove. Repeat for multiple."),
237
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
238
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
239
+ server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
240
+ ) -> None:
241
+ try:
242
+ client = build_client(server_url, insecure=insecure)
243
+ for t in tag:
244
+ run(client.remove_tag(slug, t))
245
+ result = run(client.list_tags(slug))
246
+ emit_result(result, json_output=json_output)
247
+ except CLIError as exc:
248
+ typer.echo(str(exc), err=True)
249
+ raise typer.Exit(code=1) from exc
250
+
251
+
191
252
  @media_app.command("upload")
192
253
  def media_upload(
193
254
  name: str = typer.Option(..., "--name", help="Unique name for the media (e.g. hero-image)."),
194
255
  path: Path = typer.Argument(..., exists=True, file_okay=True, dir_okay=False, readable=True),
195
256
  json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
257
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
196
258
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
197
259
  ) -> None:
198
260
  try:
199
- client = build_client(server_url)
261
+ client = build_client(server_url, insecure=insecure)
200
262
  result = run(client.upload_media(name, path))
201
263
  emit_result(result, json_output=json_output)
202
264
  except CLIError as exc:
@@ -209,10 +271,11 @@ def media_update(
209
271
  name: str = typer.Option(..., "--name", help="Name of the media to replace."),
210
272
  path: Path = typer.Argument(..., exists=True, file_okay=True, dir_okay=False, readable=True),
211
273
  json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
274
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
212
275
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
213
276
  ) -> None:
214
277
  try:
215
- client = build_client(server_url)
278
+ client = build_client(server_url, insecure=insecure)
216
279
  result = run(client.update_media(name, path))
217
280
  emit_result(result, json_output=json_output)
218
281
  except CLIError as exc:
@@ -224,10 +287,11 @@ def media_update(
224
287
  def media_delete(
225
288
  name: str = typer.Option(..., "--name", help="Name of the media to delete."),
226
289
  json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
290
+ insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
227
291
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
228
292
  ) -> None:
229
293
  try:
230
- client = build_client(server_url)
294
+ client = build_client(server_url, insecure=insecure)
231
295
  result = run(client.delete_media(name))
232
296
  emit_result(result, json_output=json_output)
233
297
  except CLIError as exc:
@@ -20,11 +20,13 @@ class ArticleApiClient:
20
20
  api_key: str | None = None,
21
21
  transport: httpx.AsyncBaseTransport | None = None,
22
22
  timeout: float = 30.0,
23
+ verify: bool = True,
23
24
  ) -> None:
24
25
  self.base_url = base_url.rstrip("/")
25
26
  self.api_key = api_key
26
27
  self.transport = transport
27
28
  self.timeout = timeout
29
+ self.verify = verify
28
30
 
29
31
  def _headers(self) -> dict[str, str]:
30
32
  if not self.api_key:
@@ -45,6 +47,7 @@ class ArticleApiClient:
45
47
  transport=self.transport,
46
48
  timeout=self.timeout,
47
49
  headers=self._headers(),
50
+ verify=self.verify,
48
51
  ) as client:
49
52
  response = await client.request(method, path, json=json, params=params, files=files)
50
53
  if response.status_code >= 400:
@@ -108,7 +111,7 @@ class ArticleApiClient:
108
111
  result = await self._request_json("DELETE", f"/api/v1/media/{name}")
109
112
  return dict(result or {})
110
113
 
111
- async def generate_preview(self, slug: str, *, ttl_hours: int = 24, base_url: str = "http://localhost:3000") -> dict[str, Any]:
114
+ async def generate_preview(self, slug: str, *, ttl_hours: int = 24, base_url: str) -> dict[str, Any]:
112
115
  result = await self._request_json(
113
116
  "POST",
114
117
  f"/api/v1/articles/{slug}/preview",
@@ -116,14 +119,30 @@ class ArticleApiClient:
116
119
  )
117
120
  return dict(result)
118
121
 
122
+ async def list_tags(self, slug: str) -> dict[str, Any]:
123
+ result = await self._request_json("GET", f"/api/v1/articles/{slug}/tags")
124
+ return dict(result)
125
+
126
+ async def attach_tags(self, slug: str, tags: list[str]) -> dict[str, Any]:
127
+ result = await self._request_json("POST", f"/api/v1/articles/{slug}/tags", json={"tags": tags})
128
+ return dict(result)
129
+
130
+ async def remove_tag(self, slug: str, tag: str) -> dict[str, Any]:
131
+ result = await self._request_json("DELETE", f"/api/v1/articles/{slug}/tags", params={"tag": tag})
132
+ return dict(result)
133
+
119
134
 
120
- def get_default_config() -> tuple[str, str | None]:
135
+ def get_default_config() -> tuple[str, str | None, str]:
121
136
  import os
122
137
  _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
- )
138
+ server_url = os.environ.get("PERSONAL_SERVER_URL")
139
+ if not server_url:
140
+ raise CLIError("PERSONAL_SERVER_URL is not set.")
141
+ api_key = os.environ.get("PERSONAL_API_KEY")
142
+ if not api_key:
143
+ raise CLIError("PERSONAL_API_KEY is not set.")
144
+ site_url = os.environ.get("PERSONAL_SITE_URL", "")
145
+ return (server_url, api_key, site_url)
127
146
 
128
147
 
129
148
  def _load_dotenv() -> None:
@@ -15,7 +15,7 @@ def runner() -> CliRunner:
15
15
 
16
16
 
17
17
  def _build_client_mock(live_server: str):
18
- return lambda server_url=None: ArticleApiClient(live_server)
18
+ return lambda server_url=None, insecure=False: ArticleApiClient(live_server)
19
19
 
20
20
 
21
21
  def test_article_cli_smoke(monkeypatch, runner: CliRunner, live_server: str) -> None:
File without changes
File without changes