blog-cli 0.2.4__tar.gz → 0.3.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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blog-cli
3
- Version: 0.2.4
3
+ Version: 0.3.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
@@ -14,6 +14,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
14
14
  Requires-Python: >=3.13
15
15
  Description-Content-Type: text/markdown
16
16
  Requires-Dist: httpx>=0.28
17
+ Requires-Dist: keyring>=25
17
18
  Requires-Dist: typer>=0.16
18
19
  Provides-Extra: dev
19
20
  Requires-Dist: asgi-lifespan>=2.1; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "blog-cli"
7
- version = "0.2.4"
7
+ version = "0.3.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
10
  requires-python = ">=3.13"
@@ -19,6 +19,7 @@ classifiers = [
19
19
  ]
20
20
  dependencies = [
21
21
  "httpx>=0.28",
22
+ "keyring>=25",
22
23
  "typer>=0.16",
23
24
  ]
24
25
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blog-cli
3
- Version: 0.2.4
3
+ Version: 0.3.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
@@ -14,6 +14,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
14
14
  Requires-Python: >=3.13
15
15
  Description-Content-Type: text/markdown
16
16
  Requires-Dist: httpx>=0.28
17
+ Requires-Dist: keyring>=25
17
18
  Requires-Dist: typer>=0.16
18
19
  Provides-Extra: dev
19
20
  Requires-Dist: asgi-lifespan>=2.1; extra == "dev"
@@ -10,6 +10,8 @@ src/personal_cli/__init__.py
10
10
  src/personal_cli/__main__.py
11
11
  src/personal_cli/cli.py
12
12
  src/personal_cli/client.py
13
+ src/personal_cli/credentials.py
13
14
  src/personal_cli/formatting.py
15
+ src/personal_cli/setup_server.py
14
16
  tests/test_cli.py
15
17
  tests/test_client.py
@@ -1,4 +1,5 @@
1
1
  httpx>=0.28
2
+ keyring>=25
2
3
  typer>=0.16
3
4
 
4
5
  [dev]
@@ -3,22 +3,33 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import importlib.metadata
5
5
  from pathlib import Path
6
+ from typing import Callable, TypeVar
6
7
 
7
8
  import typer
8
9
 
9
- from personal_cli.client import ArticleApiClient, CLIError, get_default_config
10
+ from personal_cli.client import ArticleApiClient, CLIError, get_config
11
+ from personal_cli.credentials import (
12
+ CredentialError,
13
+ CredentialStore,
14
+ MissingCredentialError,
15
+ )
10
16
  from personal_cli.formatting import emit_result, read_markdown_from_source
17
+ from personal_cli.setup_server import run_setup
11
18
 
12
19
  app = typer.Typer(help="Agent-facing article CLI.")
13
20
  article_app = typer.Typer(help="Manage articles.")
14
21
  blog_app = typer.Typer(help="Manage blog posts.")
15
22
  project_app = typer.Typer(help="Manage projects.")
16
23
  media_app = typer.Typer(help="Manage media uploads.")
24
+ keys_app = typer.Typer(help="Manage stored credentials.")
17
25
 
18
26
  app.add_typer(article_app, name="article")
19
27
  article_app.add_typer(blog_app, name="blog")
20
28
  article_app.add_typer(project_app, name="project")
21
29
  app.add_typer(media_app, name="media")
30
+ app.add_typer(keys_app, name="keys")
31
+
32
+ Result = TypeVar("Result")
22
33
 
23
34
 
24
35
  def get_version() -> str:
@@ -33,8 +44,14 @@ def cli_version() -> None:
33
44
  typer.echo(get_version())
34
45
 
35
46
 
36
- def build_client(server_url: str | None = None, insecure: bool = False) -> ArticleApiClient:
37
- url, api_key, _ = get_default_config()
47
+ def _emit(message: str) -> None:
48
+ typer.echo(message, err=True)
49
+
50
+
51
+ def build_client(
52
+ server_url: str | None = None, insecure: bool = False
53
+ ) -> ArticleApiClient:
54
+ url, api_key, _ = get_config()
38
55
  return ArticleApiClient(server_url or url, api_key=api_key, verify=not insecure)
39
56
 
40
57
 
@@ -42,6 +59,28 @@ def run(coro):
42
59
  return asyncio.run(coro)
43
60
 
44
61
 
62
+ def _run(operation: Callable[[], Result]) -> Result:
63
+ """Run an operation, opening the setup page when credentials are missing/rejected."""
64
+ while True:
65
+ try:
66
+ return operation()
67
+ except MissingCredentialError:
68
+ run_setup(
69
+ CredentialStore(),
70
+ output=_emit,
71
+ prompt="Credentials are missing. Open this link in your browser",
72
+ )
73
+ except CLIError as exc:
74
+ if exc.status_code not in (401, 403):
75
+ raise
76
+ _emit("The server rejected the stored API key. Re-enter it in the setup page.")
77
+ run_setup(
78
+ CredentialStore(),
79
+ output=_emit,
80
+ prompt="Enter replacement credentials in your browser",
81
+ )
82
+
83
+
45
84
  @article_app.command("list")
46
85
  def article_list(
47
86
  type_filter: str = typer.Option("all", "--type", help="all, blog, or project."),
@@ -50,10 +89,13 @@ def article_list(
50
89
  insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
51
90
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
52
91
  ) -> None:
53
- try:
92
+ def _op() -> None:
54
93
  client = build_client(server_url, insecure=insecure)
55
94
  articles = run(client.list_articles(status=status, type_filter=type_filter))
56
95
  emit_result(articles, json_output=json_output)
96
+
97
+ try:
98
+ _run(_op)
57
99
  except CLIError as exc:
58
100
  typer.echo(str(exc), err=True)
59
101
  raise typer.Exit(code=1) from exc
@@ -66,10 +108,13 @@ def article_show(
66
108
  insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
67
109
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
68
110
  ) -> None:
69
- try:
111
+ def _op() -> None:
70
112
  client = build_client(server_url, insecure=insecure)
71
113
  article = run(client.get_article(slug))
72
114
  emit_result(article, json_output=json_output)
115
+
116
+ try:
117
+ _run(_op)
73
118
  except CLIError as exc:
74
119
  typer.echo(str(exc), err=True)
75
120
  raise typer.Exit(code=1) from exc
@@ -88,7 +133,7 @@ def blog_create(
88
133
  insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
89
134
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
90
135
  ) -> None:
91
- try:
136
+ def _op() -> None:
92
137
  body = read_markdown_from_source(markdown=markdown, markdown_file=markdown_file)
93
138
  client = build_client(server_url, insecure=insecure)
94
139
  payload = {
@@ -105,6 +150,9 @@ def blog_create(
105
150
  }
106
151
  article = run(client.create_article(payload))
107
152
  emit_result(article, json_output=json_output)
153
+
154
+ try:
155
+ _run(_op)
108
156
  except CLIError as exc:
109
157
  typer.echo(str(exc), err=True)
110
158
  raise typer.Exit(code=1) from exc
@@ -126,7 +174,7 @@ def project_create(
126
174
  insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
127
175
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
128
176
  ) -> None:
129
- try:
177
+ def _op() -> None:
130
178
  body = read_markdown_from_source(markdown=markdown, markdown_file=markdown_file)
131
179
  client = build_client(server_url, insecure=insecure)
132
180
  payload = {
@@ -143,6 +191,9 @@ def project_create(
143
191
  }
144
192
  article = run(client.create_article(payload))
145
193
  emit_result(article, json_output=json_output)
194
+
195
+ try:
196
+ _run(_op)
146
197
  except CLIError as exc:
147
198
  typer.echo(str(exc), err=True)
148
199
  raise typer.Exit(code=1) from exc
@@ -166,7 +217,7 @@ def article_update(
166
217
  insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
167
218
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
168
219
  ) -> None:
169
- try:
220
+ def _op() -> None:
170
221
  if cover_image is not None and clear_cover_image:
171
222
  raise CLIError("Use either --cover-image or --clear-cover-image, not both.")
172
223
  client = build_client(server_url, insecure=insecure)
@@ -193,6 +244,9 @@ def article_update(
193
244
  payload["markdown"] = read_markdown_from_source(markdown=markdown, markdown_file=markdown_file)
194
245
  article = run(client.update_article(slug, payload))
195
246
  emit_result(article, json_output=json_output)
247
+
248
+ try:
249
+ _run(_op)
196
250
  except CLIError as exc:
197
251
  typer.echo(str(exc), err=True)
198
252
  raise typer.Exit(code=1) from exc
@@ -206,10 +260,13 @@ def article_publish(
206
260
  insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
207
261
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
208
262
  ) -> None:
209
- try:
263
+ def _op() -> None:
210
264
  client = build_client(server_url, insecure=insecure)
211
265
  article = run(client.publish_article(slug, {"published_by": published_by} if published_by else None))
212
266
  emit_result(article, json_output=json_output)
267
+
268
+ try:
269
+ _run(_op)
213
270
  except CLIError as exc:
214
271
  typer.echo(str(exc), err=True)
215
272
  raise typer.Exit(code=1) from exc
@@ -222,10 +279,13 @@ def article_delete(
222
279
  insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
223
280
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
224
281
  ) -> None:
225
- try:
282
+ def _op() -> None:
226
283
  client = build_client(server_url, insecure=insecure)
227
284
  result = run(client.delete_article(slug))
228
285
  emit_result(result, json_output=json_output)
286
+
287
+ try:
288
+ _run(_op)
229
289
  except CLIError as exc:
230
290
  typer.echo(str(exc), err=True)
231
291
  raise typer.Exit(code=1) from exc
@@ -238,10 +298,13 @@ def article_unarchive(
238
298
  insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
239
299
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
240
300
  ) -> None:
241
- try:
301
+ def _op() -> None:
242
302
  client = build_client(server_url, insecure=insecure)
243
303
  article = run(client.unarchive_article(slug))
244
304
  emit_result(article, json_output=json_output)
305
+
306
+ try:
307
+ _run(_op)
245
308
  except CLIError as exc:
246
309
  typer.echo(str(exc), err=True)
247
310
  raise typer.Exit(code=1) from exc
@@ -256,12 +319,15 @@ def article_preview(
256
319
  insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
257
320
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
258
321
  ) -> None:
259
- try:
260
- _, _, default_site_url = get_default_config()
322
+ def _op() -> None:
323
+ _, _, default_site_url = get_config()
261
324
  resolved_site_url = site_url or default_site_url
262
325
  client = build_client(server_url, insecure=insecure)
263
326
  result = run(client.generate_preview(slug, ttl_hours=ttl_hours, base_url=resolved_site_url))
264
327
  emit_result(result, json_output=json_output)
328
+
329
+ try:
330
+ _run(_op)
265
331
  except CLIError as exc:
266
332
  typer.echo(str(exc), err=True)
267
333
  raise typer.Exit(code=1) from exc
@@ -274,10 +340,13 @@ def article_revoke_preview(
274
340
  insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
275
341
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
276
342
  ) -> None:
277
- try:
343
+ def _op() -> None:
278
344
  client = build_client(server_url, insecure=insecure)
279
345
  result = run(client.revoke_preview(slug))
280
346
  emit_result(result, json_output=json_output)
347
+
348
+ try:
349
+ _run(_op)
281
350
  except CLIError as exc:
282
351
  typer.echo(str(exc), err=True)
283
352
  raise typer.Exit(code=1) from exc
@@ -291,10 +360,13 @@ def tag_list(
291
360
  insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
292
361
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
293
362
  ) -> None:
294
- try:
363
+ def _op() -> None:
295
364
  client = build_client(server_url, insecure=insecure)
296
365
  result = run(client.list_tags(slug))
297
366
  emit_result(result, json_output=json_output)
367
+
368
+ try:
369
+ _run(_op)
298
370
  except CLIError as exc:
299
371
  typer.echo(str(exc), err=True)
300
372
  raise typer.Exit(code=1) from exc
@@ -308,10 +380,13 @@ def tag_add(
308
380
  insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
309
381
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
310
382
  ) -> None:
311
- try:
383
+ def _op() -> None:
312
384
  client = build_client(server_url, insecure=insecure)
313
385
  result = run(client.attach_tags(slug, tag))
314
386
  emit_result(result, json_output=json_output)
387
+
388
+ try:
389
+ _run(_op)
315
390
  except CLIError as exc:
316
391
  typer.echo(str(exc), err=True)
317
392
  raise typer.Exit(code=1) from exc
@@ -325,12 +400,15 @@ def tag_remove(
325
400
  insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
326
401
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
327
402
  ) -> None:
328
- try:
403
+ def _op() -> None:
329
404
  client = build_client(server_url, insecure=insecure)
330
405
  for t in tag:
331
406
  run(client.remove_tag(slug, t))
332
407
  result = run(client.list_tags(slug))
333
408
  emit_result(result, json_output=json_output)
409
+
410
+ try:
411
+ _run(_op)
334
412
  except CLIError as exc:
335
413
  typer.echo(str(exc), err=True)
336
414
  raise typer.Exit(code=1) from exc
@@ -344,10 +422,13 @@ def media_upload(
344
422
  insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
345
423
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
346
424
  ) -> None:
347
- try:
425
+ def _op() -> None:
348
426
  client = build_client(server_url, insecure=insecure)
349
427
  result = run(client.upload_media(name, path))
350
428
  emit_result(result, json_output=json_output)
429
+
430
+ try:
431
+ _run(_op)
351
432
  except CLIError as exc:
352
433
  typer.echo(str(exc), err=True)
353
434
  raise typer.Exit(code=1) from exc
@@ -361,10 +442,13 @@ def media_update(
361
442
  insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
362
443
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
363
444
  ) -> None:
364
- try:
445
+ def _op() -> None:
365
446
  client = build_client(server_url, insecure=insecure)
366
447
  result = run(client.update_media(name, path))
367
448
  emit_result(result, json_output=json_output)
449
+
450
+ try:
451
+ _run(_op)
368
452
  except CLIError as exc:
369
453
  typer.echo(str(exc), err=True)
370
454
  raise typer.Exit(code=1) from exc
@@ -377,15 +461,56 @@ def media_delete(
377
461
  insecure: bool = typer.Option(False, "--insecure", help="Skip SSL verification."),
378
462
  server_url: str | None = typer.Option(None, "--server-url", help="FastAPI base URL."),
379
463
  ) -> None:
380
- try:
464
+ def _op() -> None:
381
465
  client = build_client(server_url, insecure=insecure)
382
466
  result = run(client.delete_media(name))
383
467
  emit_result(result, json_output=json_output)
468
+
469
+ try:
470
+ _run(_op)
384
471
  except CLIError as exc:
385
472
  typer.echo(str(exc), err=True)
386
473
  raise typer.Exit(code=1) from exc
387
474
 
388
475
 
476
+ @keys_app.command("revoke")
477
+ def keys_revoke(
478
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
479
+ ) -> None:
480
+ """Revoke the stored personal-cli credentials."""
481
+ try:
482
+ revoked = CredentialStore().remove()
483
+ emit_result(
484
+ {"revoked": revoked, "message": "Credentials revoked." if revoked else "No credentials were stored."},
485
+ json_output=json_output,
486
+ )
487
+ except CredentialError as exc:
488
+ typer.echo(str(exc), err=True)
489
+ raise typer.Exit(code=1) from exc
490
+
491
+
492
+ @keys_app.command("show")
493
+ def keys_show(
494
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
495
+ ) -> None:
496
+ """Show whether credentials are stored (API key is masked)."""
497
+ try:
498
+ server_url, api_key, site_url = get_config()
499
+ emit_result(
500
+ {
501
+ "server_url": server_url,
502
+ "api_key": f"{api_key[:4]}...{api_key[-4:]}" if len(api_key) > 8 else "****",
503
+ "site_url": site_url,
504
+ },
505
+ json_output=json_output,
506
+ )
507
+ except MissingCredentialError:
508
+ emit_result({"stored": False}, json_output=json_output)
509
+ except CredentialError as exc:
510
+ typer.echo(str(exc), err=True)
511
+ raise typer.Exit(code=1) from exc
512
+
513
+
389
514
  def main() -> None:
390
515
  app()
391
516
 
@@ -136,39 +136,8 @@ class ArticleApiClient:
136
136
  return dict(result)
137
137
 
138
138
 
139
- def get_default_config() -> tuple[str, str | None, str]:
140
- import os
141
- _load_dotenv()
142
- server_url = os.environ.get("PERSONAL_SERVER_URL")
143
- if not server_url:
144
- raise CLIError("PERSONAL_SERVER_URL is not set.")
145
- api_key = os.environ.get("PERSONAL_API_KEY")
146
- if not api_key:
147
- raise CLIError("PERSONAL_API_KEY is not set.")
148
- site_url = os.environ.get("PERSONAL_SITE_URL", "")
149
- return (server_url, api_key, site_url)
150
-
151
-
152
- def _load_dotenv() -> None:
153
- import os
154
- from pathlib import Path
155
-
156
- candidates = [
157
- Path.cwd() / ".env",
158
- Path(__file__).resolve().parents[3] / ".env",
159
- ]
160
- for env_path in candidates:
161
- if not env_path.exists():
162
- continue
163
- with env_path.open() as f:
164
- for line in f:
165
- line = line.strip()
166
- if not line or line.startswith("#"):
167
- continue
168
- if "=" not in line:
169
- continue
170
- key, _, value = line.partition("=")
171
- key = key.strip()
172
- value = value.strip().strip("'\"")
173
- os.environ[key] = value
174
- break
139
+ def get_config() -> tuple[str, str, str]:
140
+ """Read stored credentials from the OS credential store."""
141
+ from .credentials import CredentialStore
142
+
143
+ return CredentialStore().get()
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from typing import Any
6
+
7
+ import keyring
8
+ from keyring.errors import PasswordDeleteError
9
+
10
+
11
+ SERVICE_NAME = "personal-cli"
12
+ ACCOUNT_NAME = "default"
13
+ _REQUIRED_FIELDS = ("server_url", "api_key", "site_url")
14
+
15
+
16
+ class CredentialError(RuntimeError):
17
+ """An actionable error accessing the operating system credential store."""
18
+
19
+
20
+ class MissingCredentialError(CredentialError):
21
+ """No credentials have been stored yet."""
22
+
23
+
24
+ def _service_name() -> str:
25
+ if os.environ.get("PERSONAL_CLI_ENV", "").strip().lower() == "dev":
26
+ return f"{SERVICE_NAME}-dev"
27
+ return SERVICE_NAME
28
+
29
+
30
+ def _normalize(value: str) -> str:
31
+ return value.strip().strip("\"'").strip()
32
+
33
+
34
+ class CredentialStore:
35
+ """Store the personal-cli server credentials in the OS credential store.
36
+
37
+ The three values (server_url, api_key, site_url) are serialized as JSON
38
+ under a single keyring entry so they stay atomic. The service name is
39
+ suffixed with ``-dev`` when ``PERSONAL_CLI_ENV=dev`` so local testing
40
+ never touches production credentials.
41
+ """
42
+
43
+ def __init__(self, backend: Any | None = None) -> None:
44
+ self._backend = backend or keyring
45
+
46
+ def add(self, *, server_url: str, api_key: str, site_url: str) -> None:
47
+ server_url = _normalize(server_url)
48
+ api_key = _normalize(api_key)
49
+ site_url = _normalize(site_url)
50
+ if not server_url:
51
+ raise CredentialError("Server URL cannot be empty.")
52
+ if not api_key:
53
+ raise CredentialError("API key cannot be empty.")
54
+ if not site_url:
55
+ raise CredentialError("Site URL cannot be empty.")
56
+ payload = json.dumps(
57
+ {"server_url": server_url, "api_key": api_key, "site_url": site_url}
58
+ )
59
+ try:
60
+ self._backend.set_password(_service_name(), ACCOUNT_NAME, payload)
61
+ except Exception as exc:
62
+ raise CredentialError(
63
+ "Could not access the operating system credential store. "
64
+ "Configure a native keyring backend and try again."
65
+ ) from exc
66
+
67
+ def get(self) -> tuple[str, str, str]:
68
+ try:
69
+ raw = self._backend.get_password(_service_name(), ACCOUNT_NAME)
70
+ except Exception as exc:
71
+ raise CredentialError(
72
+ "Could not access the operating system credential store. "
73
+ "Configure a native keyring backend and try again."
74
+ ) from exc
75
+ if not raw:
76
+ raise MissingCredentialError(
77
+ "No credentials are stored. Run a command to trigger the setup page."
78
+ )
79
+ try:
80
+ values = json.loads(raw)
81
+ except json.JSONDecodeError as exc:
82
+ raise CredentialError("Stored credentials are corrupted.") from exc
83
+ missing = [field for field in _REQUIRED_FIELDS if not values.get(field)]
84
+ if missing:
85
+ raise MissingCredentialError(
86
+ f"Stored credentials are incomplete (missing: {', '.join(missing)})."
87
+ )
88
+ return values["server_url"], values["api_key"], values["site_url"]
89
+
90
+ def remove(self) -> bool:
91
+ try:
92
+ self._backend.delete_password(_service_name(), ACCOUNT_NAME)
93
+ except PasswordDeleteError:
94
+ return False
95
+ except Exception as exc:
96
+ raise CredentialError(
97
+ "Could not access the operating system credential store. "
98
+ "Configure a native keyring backend and try again."
99
+ ) from exc
100
+ return True
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import os
5
+ import secrets
6
+ import threading
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+ from typing import Callable
9
+ from urllib.parse import parse_qs, urlencode, urlparse
10
+
11
+ import httpx
12
+
13
+ from .credentials import CredentialError, CredentialStore
14
+
15
+
16
+ SETUP_PATH = "/setup"
17
+ SETUP_PORT = 3234
18
+
19
+
20
+ def _page(
21
+ message: str = "",
22
+ *,
23
+ error: bool = False,
24
+ token: str = "",
25
+ server_url: str = "",
26
+ site_url: str = "",
27
+ success: bool = False,
28
+ ) -> bytes:
29
+ action = f"{SETUP_PATH}?{urlencode({'token': token})}"
30
+ if success:
31
+ return f"""<!doctype html>
32
+ <html lang="en">
33
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
34
+ <title>Personal CLI setup</title>
35
+ <style>
36
+ :root {{ color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }}
37
+ * {{ box-sizing: border-box; }}
38
+ body {{ margin: 0; min-height: 100vh; background: linear-gradient(135deg,#f4f7ff,#fff 55%,#eefbf7); color: #172033; }}
39
+ dialog {{ border: 0; border-radius: 1.25rem; padding: 0; max-width: 26rem; width: calc(100% - 2rem); box-shadow: 0 24px 60px rgba(24,39,75,.18); overflow: hidden; top: 50%; transform: translateY(-50%); margin: 0 auto; }}
40
+ dialog::backdrop {{ background: rgba(23,32,51,.45); backdrop-filter: blur(4px); }}
41
+ .modal {{ padding: 2.75rem 2rem 2.25rem; text-align: center; }}
42
+ .check {{ display: grid; place-items: center; width: 4rem; height: 4rem; margin: 0 auto 1.25rem; border-radius: 50%; background: #edfff6; color: #087443; }}
43
+ .check svg {{ width: 2.25rem; height: 2.25rem; }}
44
+ h2 {{ margin: 0 0 .5rem; font-size: 1.5rem; letter-spacing: -.02em; }}
45
+ .modal p {{ margin: 0; color: #667085; line-height: 1.55; font-size: .95rem; }}
46
+ </style></head>
47
+ <body>
48
+ <dialog open>
49
+ <div class="modal">
50
+ <div class="check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></div>
51
+ <h2>Credentials saved</h2>
52
+ <p>Your server URL, API key, and site URL are stored in your operating system credential store. You can close this browser window.</p>
53
+ </div>
54
+ </dialog>
55
+ </body></html>""".encode("utf-8")
56
+
57
+ escaped_message = html.escape(message)
58
+ message_html = (
59
+ f'<p class="{"error" if error else "success"}">{escaped_message}</p>'
60
+ if message
61
+ else ""
62
+ )
63
+ return f"""<!doctype html>
64
+ <html lang="en">
65
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
66
+ <title>Personal CLI setup</title>
67
+ <style>
68
+ :root {{ color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }}
69
+ * {{ box-sizing: border-box; }}
70
+ body {{ margin: 0; min-height: 100vh; background: linear-gradient(135deg,#f4f7ff,#fff 55%,#eefbf7); color: #172033; }}
71
+ .shell {{ max-width: 42rem; margin: 0 auto; padding: 4rem 1.25rem; }}
72
+ .eyebrow {{ color: #536dfe; font-size: .78rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }}
73
+ h1 {{ margin: .5rem 0 .75rem; font-size: clamp(2rem,5vw,2.75rem); letter-spacing: -.04em; }}
74
+ .intro {{ color: #667085; line-height: 1.6; margin: 0 0 2rem; }}
75
+ .card {{ padding: 1.5rem; border: 1px solid #e4e7ec; border-radius: 1rem; background: rgba(255,255,255,.86); box-shadow: 0 10px 30px rgba(24,39,75,.07); }}
76
+ label {{ display: block; margin: 1rem 0 .4rem; font-size: .85rem; font-weight: 700; }}
77
+ input {{ width: 100%; padding: .85rem 1rem; border: 1px solid #d0d5dd; border-radius: .65rem; font: inherit; background: #fff; }}
78
+ input:focus {{ outline: 3px solid #dfe3ff; border-color: #536dfe; }}
79
+ button {{ margin-top: 1.5rem; width: 100%; padding: .9rem 1rem; border: 0; border-radius: .65rem; background: #536dfe; color: white; font-weight: 750; cursor: pointer; box-shadow: 0 8px 18px rgba(83,109,254,.25); font: inherit; }}
80
+ button:hover {{ background: #4256d6; }}
81
+ .notice {{ margin: 1rem 0; padding: .85rem 1rem; border-radius: .65rem; }}
82
+ .error {{ color: #a4262c; background: #fff0f0; }} .success {{ color: #087443; background: #edfff6; }}
83
+ .footnote {{ color: #667085; font-size: .8rem; text-align: center; margin-top: 1.5rem; }}
84
+ </style></head>
85
+ <body><main class="shell"><div class="eyebrow">Personal CLI</div><h1>Connect your backend</h1>
86
+ <p class="intro">Enter your personal server URL, API key, and site URL. They are saved to your operating system credential store and never sent to the agent chat.</p>
87
+ {message_html}
88
+ <form method="post" action="{html.escape(action)}" class="card">
89
+ <label for="server-url">Server URL</label>
90
+ <input id="server-url" name="server_url" type="url" required placeholder="https://api.example.com" value="{html.escape(server_url)}">
91
+ <label for="api-key">API key</label>
92
+ <input id="api-key" name="api_key" type="password" autocomplete="off" required placeholder="Enter your API key">
93
+ <label for="site-url">Site URL</label>
94
+ <input id="site-url" name="site_url" type="url" required placeholder="https://example.com" value="{html.escape(site_url)}">
95
+ <button type="submit">Save credentials</button>
96
+ </form>
97
+ <div class="footnote">You can revoke later with <code>blog-cli keys revoke</code>.</div>
98
+ </main></body></html>""".encode("utf-8")
99
+
100
+
101
+ def _validate(server_url: str, api_key: str, site_url: str) -> str | None:
102
+ """Hit the server to confirm the URL + key work. Returns error message or None."""
103
+ try:
104
+ with httpx.Client(
105
+ base_url=server_url.rstrip("/"),
106
+ headers={"Authorization": f"Bearer {api_key}"},
107
+ timeout=10.0,
108
+ verify=True,
109
+ ) as client:
110
+ response = client.get("/api/v1/articles", params={"type": "all"})
111
+ except httpx.HTTPError as exc:
112
+ return f"Could not reach the server: {exc}"
113
+ if response.status_code in (401, 403):
114
+ return "The server rejected that API key."
115
+ if response.status_code >= 500:
116
+ return f"The server returned an error ({response.status_code})."
117
+ return None
118
+
119
+
120
+ class _SetupServer(ThreadingHTTPServer):
121
+ allow_reuse_address = True
122
+
123
+ def __init__(
124
+ self,
125
+ store: CredentialStore,
126
+ token: str,
127
+ port: int = SETUP_PORT,
128
+ bind_host: str = "127.0.0.1",
129
+ ) -> None:
130
+ try:
131
+ super().__init__((bind_host, port), _SetupHandler)
132
+ except OSError:
133
+ super().__init__((bind_host, 0), _SetupHandler)
134
+ self.store = store
135
+ self.token = token
136
+ self.consumed = False
137
+
138
+
139
+ class _SetupHandler(BaseHTTPRequestHandler):
140
+ server: _SetupServer
141
+
142
+ def log_message(self, format: str, *args: object) -> None:
143
+ return
144
+
145
+ def _authorized(self) -> bool:
146
+ query = parse_qs(urlparse(self.path).query)
147
+ return urlparse(self.path).path == SETUP_PATH and query.get("token") == [
148
+ self.server.token
149
+ ]
150
+
151
+ def _send(self, status: int, body: bytes) -> None:
152
+ self.send_response(status)
153
+ self.send_header("Content-Type", "text/html; charset=utf-8")
154
+ self.send_header("Content-Length", str(len(body)))
155
+ self.send_header("Cache-Control", "no-store")
156
+ self.end_headers()
157
+ self.wfile.write(body)
158
+
159
+ def do_GET(self) -> None:
160
+ if not self._authorized():
161
+ self._send(404, _page("Setup page not found.", error=True))
162
+ return
163
+ if self.server.consumed:
164
+ self._send(410, _page("This setup link has already been used.", error=True))
165
+ return
166
+ self._send(200, _page(token=self.server.token))
167
+
168
+ def do_POST(self) -> None:
169
+ if not self._authorized():
170
+ self._send(404, _page("Setup page not found.", error=True))
171
+ return
172
+ if self.server.consumed:
173
+ self._send(410, _page("This setup link has already been used.", error=True))
174
+ return
175
+ try:
176
+ length = min(int(self.headers.get("Content-Length", "0")), 64 * 1024)
177
+ values = parse_qs(
178
+ self.rfile.read(length).decode("utf-8"), keep_blank_values=True
179
+ )
180
+ server_url = values.get("server_url", [""])[0].strip().strip("\"'").strip()
181
+ api_key = values.get("api_key", [""])[0].strip().strip("\"'").strip()
182
+ site_url = values.get("site_url", [""])[0].strip().strip("\"'").strip()
183
+ if not (server_url and api_key and site_url):
184
+ raise CredentialError("All three fields are required.")
185
+
186
+ error = _validate(server_url, api_key, site_url)
187
+ if error:
188
+ self._send(
189
+ 400,
190
+ _page(
191
+ error,
192
+ error=True,
193
+ token=self.server.token,
194
+ server_url=server_url,
195
+ site_url=site_url,
196
+ ),
197
+ )
198
+ return
199
+ self.server.store.add(
200
+ server_url=server_url, api_key=api_key, site_url=site_url
201
+ )
202
+ except (ValueError, UnicodeDecodeError, CredentialError) as exc:
203
+ self._send(400, _page(str(exc), error=True, token=self.server.token))
204
+ return
205
+
206
+ self.server.consumed = True
207
+ self._send(200, _page(success=True))
208
+ threading.Thread(target=self.server.shutdown, daemon=True).start()
209
+
210
+
211
+ def run_setup(
212
+ store: CredentialStore,
213
+ output: Callable[[str], None] = print,
214
+ prompt: str = "Credentials are missing. Open this link in your browser",
215
+ ) -> None:
216
+ """Serve the one-time local setup page until credentials are saved."""
217
+ token = secrets.token_urlsafe(32)
218
+ server = _SetupServer(
219
+ store,
220
+ token,
221
+ bind_host=os.environ.get("PERSONAL_CLI_SETUP_BIND_HOST", "127.0.0.1"),
222
+ )
223
+ url = f"http://127.0.0.1:{server.server_port}{SETUP_PATH}?{urlencode({'token': token})}"
224
+ output(f"{prompt}: {url}")
225
+ try:
226
+ server.serve_forever()
227
+ finally:
228
+ server.server_close()
@@ -7,6 +7,29 @@ import pytest
7
7
  from typer.testing import CliRunner
8
8
 
9
9
  from personal_cli.cli import app
10
+ from personal_cli.credentials import (
11
+ CredentialError,
12
+ CredentialStore,
13
+ MissingCredentialError,
14
+ )
15
+
16
+
17
+ class FakeKeyringBackend:
18
+ def __init__(self) -> None:
19
+ self._store: dict[str, dict[str, str]] = {}
20
+
21
+ def set_password(self, service: str, account: str, password: str) -> None:
22
+ self._store.setdefault(service, {})[account] = password
23
+
24
+ def get_password(self, service: str, account: str) -> str | None:
25
+ return self._store.get(service, {}).get(account)
26
+
27
+ def delete_password(self, service: str, account: str) -> None:
28
+ if account not in self._store.get(service, {}):
29
+ from keyring.errors import PasswordDeleteError
30
+
31
+ raise PasswordDeleteError("not found")
32
+ del self._store[service][account]
10
33
 
11
34
 
12
35
  class FakeApiClient:
@@ -77,16 +100,52 @@ class FakeApiClient:
77
100
  return {"deleted": True, "name": name}
78
101
 
79
102
 
103
+ VALID_CREDS = {
104
+ "server_url": "http://testserver",
105
+ "api_key": "test-key",
106
+ "site_url": "http://testsite",
107
+ }
108
+
109
+
80
110
  @pytest.fixture()
81
111
  def runner() -> CliRunner:
82
112
  return CliRunner()
83
113
 
84
114
 
115
+ @pytest.fixture()
116
+ def fake_backend() -> FakeKeyringBackend:
117
+ return FakeKeyringBackend()
118
+
119
+
120
+ @pytest.fixture(autouse=True)
121
+ def credential_store(monkeypatch: pytest.MonkeyPatch, fake_backend: FakeKeyringBackend) -> FakeKeyringBackend:
122
+ """Every CredentialStore() instance uses the fake in-memory backend."""
123
+ real_init = CredentialStore.__init__
124
+
125
+ def _init(self, backend=None):
126
+ real_init(self, backend=fake_backend)
127
+
128
+ monkeypatch.setattr(CredentialStore, "__init__", _init)
129
+ return fake_backend
130
+
131
+
132
+ @pytest.fixture(autouse=True)
133
+ def seeded_credentials(credential_store: FakeKeyringBackend) -> None:
134
+ """Pre-seed valid credentials so commands can build a client."""
135
+ credential_store.set_password(
136
+ "personal-cli",
137
+ "default",
138
+ json.dumps(VALID_CREDS),
139
+ )
140
+
141
+
85
142
  @pytest.fixture(autouse=True)
86
- def config_env(monkeypatch: pytest.MonkeyPatch) -> None:
87
- monkeypatch.setenv("PERSONAL_SERVER_URL", "http://testserver")
88
- monkeypatch.setenv("PERSONAL_API_KEY", "test-key")
89
- monkeypatch.setenv("PERSONAL_SITE_URL", "http://testsite")
143
+ def stub_run_setup(monkeypatch: pytest.MonkeyPatch) -> None:
144
+ """Never start the real setup server during tests."""
145
+ def _noop_run_setup(*args, **kwargs):
146
+ raise AssertionError("run_setup was called during a test that pre-seeded credentials.")
147
+
148
+ monkeypatch.setattr("personal_cli.cli.run_setup", _noop_run_setup)
90
149
 
91
150
 
92
151
  @pytest.fixture()
@@ -204,3 +263,71 @@ def test_media_cli_smoke(monkeypatch, runner: CliRunner, client: FakeApiClient,
204
263
  deleted = json.loads(delete_result.stdout)
205
264
  assert deleted["deleted"] is True
206
265
  assert deleted["name"] == "hero-image"
266
+
267
+
268
+ def test_keys_show_reports_stored_credentials(runner: CliRunner) -> None:
269
+ result = runner.invoke(app, ["keys", "show", "--json"])
270
+ assert result.exit_code == 0
271
+ shown = json.loads(result.stdout)
272
+ assert shown["server_url"] == "http://testserver"
273
+ assert shown["site_url"] == "http://testsite"
274
+ assert "api_key" in shown
275
+ assert shown["api_key"] != "test-key"
276
+
277
+
278
+ def test_keys_revoke_clears_credentials(
279
+ runner: CliRunner, credential_store: FakeKeyringBackend
280
+ ) -> None:
281
+ revoke_result = runner.invoke(app, ["keys", "revoke", "--json"])
282
+ assert revoke_result.exit_code == 0
283
+ revoked = json.loads(revoke_result.stdout)
284
+ assert revoked["revoked"] is True
285
+
286
+ assert credential_store.get_password("personal-cli", "default") is None
287
+
288
+ second_revoke = runner.invoke(app, ["keys", "revoke", "--json"])
289
+ assert second_revoke.exit_code == 0
290
+ assert json.loads(second_revoke.stdout)["revoked"] is False
291
+
292
+
293
+ def test_keys_show_when_empty(runner: CliRunner, credential_store: FakeKeyringBackend) -> None:
294
+ credential_store.delete_password("personal-cli", "default")
295
+ result = runner.invoke(app, ["keys", "show", "--json"])
296
+ assert result.exit_code == 0
297
+ assert json.loads(result.stdout) == {"stored": False}
298
+
299
+
300
+ def test_missing_credentials_triggers_setup(
301
+ monkeypatch: pytest.MonkeyPatch,
302
+ runner: CliRunner,
303
+ credential_store: FakeKeyringBackend,
304
+ ) -> None:
305
+ credential_store.delete_password("personal-cli", "default")
306
+
307
+ setup_calls: list[int] = []
308
+
309
+ def _fake_run_setup(store, output=None, prompt=""):
310
+ output(f"{prompt}: http://127.0.0.1:3233/setup?token=fake")
311
+ setup_calls.append(1)
312
+ store.add(
313
+ server_url=VALID_CREDS["server_url"],
314
+ api_key=VALID_CREDS["api_key"],
315
+ site_url=VALID_CREDS["site_url"],
316
+ )
317
+
318
+ monkeypatch.setattr("personal_cli.cli.run_setup", _fake_run_setup)
319
+
320
+ attempts: list[int] = []
321
+
322
+ def _flaky_build_client(server_url=None, insecure=False):
323
+ attempts.append(1)
324
+ if len(attempts) == 1:
325
+ raise MissingCredentialError("missing")
326
+ return FakeApiClient()
327
+
328
+ monkeypatch.setattr("personal_cli.cli.build_client", _flaky_build_client)
329
+ monkeypatch.setattr("personal_cli.cli.run", lambda coro: __import__("asyncio").run(coro))
330
+
331
+ result = runner.invoke(app, ["article", "list", "--json"])
332
+ assert setup_calls, "run_setup should have been called"
333
+ assert result.exit_code == 0
File without changes
File without changes
File without changes