blog-cli 0.4.2__tar.gz → 0.4.4__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.4.2
3
+ Version: 0.4.4
4
4
  Summary: Agent-friendly CLI for managing a personal site. Articles, projects, services, media, and a bundled ChatGPT/Codex skill.
5
5
  Author: tanaka-mambinge
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "blog-cli"
7
- version = "0.4.2"
7
+ version = "0.4.4"
8
8
  description = "Agent-friendly CLI for managing a personal site. Articles, projects, services, media, and a bundled ChatGPT/Codex skill."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blog-cli
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: Agent-friendly CLI for managing a personal site. Articles, projects, services, media, and a bundled ChatGPT/Codex skill.
5
5
  Author: tanaka-mambinge
6
6
  License: MIT
@@ -13,7 +13,7 @@ from personal_cli.credentials import (
13
13
  CredentialStore,
14
14
  MissingCredentialError,
15
15
  )
16
- from personal_cli.formatting import emit_result, read_markdown_from_source
16
+ from personal_cli.formatting import emit_error, emit_result, read_markdown_from_source
17
17
  from personal_cli.setup_server import run_setup
18
18
 
19
19
  app = typer.Typer(help="Agent-facing article CLI.")
@@ -73,7 +73,7 @@ def _with_article_url(article: dict) -> dict:
73
73
 
74
74
 
75
75
  def _service_url(service: dict) -> str:
76
- return f"{_site_url()}/services/{service['slug']}"
76
+ return f"{_site_url()}/services?service={service['slug']}"
77
77
 
78
78
 
79
79
  def _with_service_url(service: dict) -> dict:
@@ -127,26 +127,43 @@ def run(coro):
127
127
  return asyncio.run(coro)
128
128
 
129
129
 
130
- def _run(operation: Callable[[], Result]) -> Result:
131
- """Run an operation, opening the setup page when credentials are missing/rejected."""
132
- while True:
133
- try:
134
- return operation()
135
- except MissingCredentialError:
136
- run_setup(
137
- CredentialStore(),
138
- output=_emit,
139
- prompt="Credentials are missing. Open this link in your browser",
140
- )
141
- except CLIError as exc:
142
- if exc.status_code not in (401, 403):
143
- raise
144
- _emit("The server rejected the stored API key. Re-enter it in the setup page.")
145
- run_setup(
146
- CredentialStore(),
147
- output=_emit,
148
- prompt="Enter replacement credentials in your browser",
149
- )
130
+ _SETUP_COMMAND = "blog-cli keys setup"
131
+ _AUTH_MESSAGE = (
132
+ "Authentication required. Run `blog-cli keys setup` in an attached process. "
133
+ "Keep it running while the user enters and validates credentials in the browser, "
134
+ "then rerun the original command."
135
+ )
136
+
137
+
138
+ def _run(operation: Callable[[], Result], *, json_output: bool = False) -> Result:
139
+ """Run an operation and return machine-readable auth failures."""
140
+ try:
141
+ return operation()
142
+ except MissingCredentialError as exc:
143
+ emit_error(
144
+ "authentication_required",
145
+ _AUTH_MESSAGE,
146
+ json_output=True,
147
+ setup_command=_SETUP_COMMAND,
148
+ )
149
+ raise typer.Exit(code=2) from exc
150
+ except CredentialError as exc:
151
+ emit_error(
152
+ "credential_store_unavailable",
153
+ str(exc),
154
+ json_output=True,
155
+ )
156
+ raise typer.Exit(code=2) from exc
157
+ except CLIError as exc:
158
+ if exc.status_code not in (401, 403):
159
+ raise
160
+ emit_error(
161
+ "authentication_required",
162
+ "The stored API key was rejected. Run `blog-cli keys setup`, keep it attached until setup succeeds, then rerun the original command.",
163
+ json_output=True,
164
+ setup_command=_SETUP_COMMAND,
165
+ )
166
+ raise typer.Exit(code=2) from exc
150
167
 
151
168
 
152
169
  @article_app.command("list")
@@ -165,7 +182,7 @@ def article_list(
165
182
  emit_result(articles, json_output=json_output)
166
183
 
167
184
  try:
168
- _run(_op)
185
+ _run(_op, json_output=json_output)
169
186
  except CLIError as exc:
170
187
  typer.echo(str(exc), err=True)
171
188
  raise typer.Exit(code=1) from exc
@@ -185,7 +202,7 @@ def article_show(
185
202
  emit_result(article, json_output=json_output)
186
203
 
187
204
  try:
188
- _run(_op)
205
+ _run(_op, json_output=json_output)
189
206
  except CLIError as exc:
190
207
  typer.echo(str(exc), err=True)
191
208
  raise typer.Exit(code=1) from exc
@@ -224,7 +241,7 @@ def blog_create(
224
241
  emit_result(article, json_output=json_output)
225
242
 
226
243
  try:
227
- _run(_op)
244
+ _run(_op, json_output=json_output)
228
245
  except CLIError as exc:
229
246
  typer.echo(str(exc), err=True)
230
247
  raise typer.Exit(code=1) from exc
@@ -266,7 +283,7 @@ def project_create(
266
283
  emit_result(article, json_output=json_output)
267
284
 
268
285
  try:
269
- _run(_op)
286
+ _run(_op, json_output=json_output)
270
287
  except CLIError as exc:
271
288
  typer.echo(str(exc), err=True)
272
289
  raise typer.Exit(code=1) from exc
@@ -330,7 +347,7 @@ def article_update(
330
347
  emit_result(article, json_output=json_output)
331
348
 
332
349
  try:
333
- _run(_op)
350
+ _run(_op, json_output=json_output)
334
351
  except CLIError as exc:
335
352
  typer.echo(str(exc), err=True)
336
353
  raise typer.Exit(code=1) from exc
@@ -351,7 +368,7 @@ def article_publish(
351
368
  emit_result(article, json_output=json_output)
352
369
 
353
370
  try:
354
- _run(_op)
371
+ _run(_op, json_output=json_output)
355
372
  except CLIError as exc:
356
373
  typer.echo(str(exc), err=True)
357
374
  raise typer.Exit(code=1) from exc
@@ -370,7 +387,7 @@ def article_delete(
370
387
  emit_result(result, json_output=json_output)
371
388
 
372
389
  try:
373
- _run(_op)
390
+ _run(_op, json_output=json_output)
374
391
  except CLIError as exc:
375
392
  typer.echo(str(exc), err=True)
376
393
  raise typer.Exit(code=1) from exc
@@ -390,7 +407,7 @@ def article_unarchive(
390
407
  emit_result(article, json_output=json_output)
391
408
 
392
409
  try:
393
- _run(_op)
410
+ _run(_op, json_output=json_output)
394
411
  except CLIError as exc:
395
412
  typer.echo(str(exc), err=True)
396
413
  raise typer.Exit(code=1) from exc
@@ -413,7 +430,7 @@ def article_preview(
413
430
  emit_result(result, json_output=json_output)
414
431
 
415
432
  try:
416
- _run(_op)
433
+ _run(_op, json_output=json_output)
417
434
  except CLIError as exc:
418
435
  typer.echo(str(exc), err=True)
419
436
  raise typer.Exit(code=1) from exc
@@ -433,7 +450,7 @@ def tag_list(
433
450
  emit_result(result, json_output=json_output)
434
451
 
435
452
  try:
436
- _run(_op)
453
+ _run(_op, json_output=json_output)
437
454
  except CLIError as exc:
438
455
  typer.echo(str(exc), err=True)
439
456
  raise typer.Exit(code=1) from exc
@@ -453,7 +470,7 @@ def tag_add(
453
470
  emit_result(result, json_output=json_output)
454
471
 
455
472
  try:
456
- _run(_op)
473
+ _run(_op, json_output=json_output)
457
474
  except CLIError as exc:
458
475
  typer.echo(str(exc), err=True)
459
476
  raise typer.Exit(code=1) from exc
@@ -475,7 +492,7 @@ def tag_remove(
475
492
  emit_result(result, json_output=json_output)
476
493
 
477
494
  try:
478
- _run(_op)
495
+ _run(_op, json_output=json_output)
479
496
  except CLIError as exc:
480
497
  typer.echo(str(exc), err=True)
481
498
  raise typer.Exit(code=1) from exc
@@ -495,7 +512,7 @@ def media_upload(
495
512
  emit_result(result, json_output=json_output)
496
513
 
497
514
  try:
498
- _run(_op)
515
+ _run(_op, json_output=json_output)
499
516
  except CLIError as exc:
500
517
  typer.echo(str(exc), err=True)
501
518
  raise typer.Exit(code=1) from exc
@@ -515,7 +532,7 @@ def media_update(
515
532
  emit_result(result, json_output=json_output)
516
533
 
517
534
  try:
518
- _run(_op)
535
+ _run(_op, json_output=json_output)
519
536
  except CLIError as exc:
520
537
  typer.echo(str(exc), err=True)
521
538
  raise typer.Exit(code=1) from exc
@@ -534,12 +551,32 @@ def media_delete(
534
551
  emit_result(result, json_output=json_output)
535
552
 
536
553
  try:
537
- _run(_op)
554
+ _run(_op, json_output=json_output)
538
555
  except CLIError as exc:
539
556
  typer.echo(str(exc), err=True)
540
557
  raise typer.Exit(code=1) from exc
541
558
 
542
559
 
560
+ @keys_app.command("setup")
561
+ def keys_setup(
562
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output after setup succeeds."),
563
+ ) -> None:
564
+ """Start the browser-based credential setup server and wait for valid credentials."""
565
+ try:
566
+ run_setup(
567
+ CredentialStore(),
568
+ output=_emit,
569
+ prompt="Open this setup URL in your browser",
570
+ )
571
+ emit_result(
572
+ {"configured": True, "message": "Credentials saved."},
573
+ json_output=json_output,
574
+ )
575
+ except CredentialError as exc:
576
+ emit_error("credential_store_unavailable", str(exc), json_output=True)
577
+ raise typer.Exit(code=2) from exc
578
+
579
+
543
580
  @keys_app.command("revoke")
544
581
  def keys_revoke(
545
582
  json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
@@ -623,7 +660,7 @@ def service_create(
623
660
  emit_result(result, json_output=json_output)
624
661
 
625
662
  try:
626
- _run(_op)
663
+ _run(_op, json_output=json_output)
627
664
  except CLIError as exc:
628
665
  typer.echo(str(exc), err=True)
629
666
  raise typer.Exit(code=1) from exc
@@ -644,7 +681,7 @@ def service_list(
644
681
  emit_result(result, json_output=json_output)
645
682
 
646
683
  try:
647
- _run(_op)
684
+ _run(_op, json_output=json_output)
648
685
  except CLIError as exc:
649
686
  typer.echo(str(exc), err=True)
650
687
  raise typer.Exit(code=1) from exc
@@ -663,7 +700,7 @@ def service_show(
663
700
  emit_result(result, json_output=json_output)
664
701
 
665
702
  try:
666
- _run(_op)
703
+ _run(_op, json_output=json_output)
667
704
  except CLIError as exc:
668
705
  typer.echo(str(exc), err=True)
669
706
  raise typer.Exit(code=1) from exc
@@ -731,7 +768,7 @@ def service_update(
731
768
  emit_result(result, json_output=json_output)
732
769
 
733
770
  try:
734
- _run(_op)
771
+ _run(_op, json_output=json_output)
735
772
  except CLIError as exc:
736
773
  typer.echo(str(exc), err=True)
737
774
  raise typer.Exit(code=1) from exc
@@ -749,7 +786,7 @@ def service_delete(
749
786
  emit_result(run(client.delete_service(slug)), json_output=json_output)
750
787
 
751
788
  try:
752
- _run(_op)
789
+ _run(_op, json_output=json_output)
753
790
  except CLIError as exc:
754
791
  typer.echo(str(exc), err=True)
755
792
  raise typer.Exit(code=1) from exc
@@ -36,3 +36,17 @@ def emit_result(data: Any, *, json_output: bool = False) -> None:
36
36
 
37
37
  print(data)
38
38
 
39
+
40
+ def emit_error(
41
+ code: str,
42
+ message: str,
43
+ *,
44
+ json_output: bool = False,
45
+ **details: Any,
46
+ ) -> None:
47
+ if json_output:
48
+ error: dict[str, Any] = {"code": code, "message": message}
49
+ error.update(details)
50
+ print(json.dumps({"error": error}, ensure_ascii=False, default=str), file=sys.stderr)
51
+ return
52
+ print(message, file=sys.stderr)
@@ -345,7 +345,7 @@ def test_service_cli_lifecycle(monkeypatch, runner: CliRunner, client: FakeApiCl
345
345
  assert create_result.exit_code == 0
346
346
  created = json.loads(create_result.stdout)
347
347
  assert created["slug"] == "web"
348
- assert created["url"] == "http://testsite/services/web"
348
+ assert created["url"] == "http://testsite/services?service=web"
349
349
  assert created["types"] == ["Static websites", "Web apps with a database"]
350
350
  assert created["offerings"][0]["title"] == "Static websites"
351
351
 
@@ -355,7 +355,7 @@ def test_service_cli_lifecycle(monkeypatch, runner: CliRunner, client: FakeApiCl
355
355
 
356
356
  list_result = runner.invoke(app, ["service", "list", "--category", "Web", "--json"])
357
357
  assert list_result.exit_code == 0
358
- assert json.loads(list_result.stdout)[0]["url"] == "http://testsite/services/web"
358
+ assert json.loads(list_result.stdout)[0]["url"] == "http://testsite/services?service=web"
359
359
 
360
360
 
361
361
  def test_service_cli_does_not_expose_homepage_options(runner: CliRunner) -> None:
@@ -395,13 +395,33 @@ def test_media_cli_smoke(monkeypatch, runner: CliRunner, client: FakeApiClient,
395
395
  assert deleted["name"] == "hero-image"
396
396
 
397
397
 
398
- def test_missing_credentials_triggers_setup(
398
+ def test_authentication_required_is_structured_and_does_not_start_setup(
399
399
  monkeypatch: pytest.MonkeyPatch,
400
400
  runner: CliRunner,
401
401
  credential_store: FakeKeyringBackend,
402
402
  ) -> None:
403
403
  credential_store.delete_password("personal-cli", "default")
404
404
 
405
+ setup_calls: list[int] = []
406
+ monkeypatch.setattr("personal_cli.cli.run_setup", lambda *args, **kwargs: setup_calls.append(1))
407
+ monkeypatch.setattr("personal_cli.cli.build_client", lambda *args, **kwargs: (_ for _ in ()).throw(MissingCredentialError("missing")))
408
+
409
+ for args in (["article", "list"], ["article", "list", "--json"]):
410
+ result = runner.invoke(app, args)
411
+ assert result.exit_code == 2
412
+ error = json.loads(result.stderr.strip())
413
+ assert error["error"]["code"] == "authentication_required"
414
+ assert error["error"]["setup_command"] == "blog-cli keys setup"
415
+ assert "http" not in result.stderr
416
+
417
+ assert not setup_calls
418
+
419
+
420
+ def test_keys_setup_runs_attached_setup_server(
421
+ monkeypatch: pytest.MonkeyPatch,
422
+ runner: CliRunner,
423
+ credential_store: FakeKeyringBackend,
424
+ ) -> None:
405
425
  setup_calls: list[int] = []
406
426
 
407
427
  def _fake_run_setup(store, output=None, prompt=""):
@@ -415,17 +435,45 @@ def test_missing_credentials_triggers_setup(
415
435
 
416
436
  monkeypatch.setattr("personal_cli.cli.run_setup", _fake_run_setup)
417
437
 
418
- attempts: list[int] = []
438
+ result = runner.invoke(app, ["keys", "setup", "--json"])
419
439
 
420
- def _flaky_build_client(server_url=None, insecure=False):
421
- attempts.append(1)
422
- if len(attempts) == 1:
423
- raise MissingCredentialError("missing")
424
- return FakeApiClient()
440
+ assert result.exit_code == 0
441
+ assert setup_calls == [1]
442
+ assert "http://127.0.0.1:3233/setup?token=fake" in result.stderr
443
+ assert json.loads(result.stdout)["configured"] is True
425
444
 
426
- monkeypatch.setattr("personal_cli.cli.build_client", _flaky_build_client)
427
- monkeypatch.setattr("personal_cli.cli.run", lambda coro: __import__("asyncio").run(coro))
428
445
 
429
- result = runner.invoke(app, ["article", "list", "--json"])
430
- assert setup_calls, "run_setup should have been called"
446
+ def test_keys_revoke_removes_stored_credentials(
447
+ runner: CliRunner,
448
+ credential_store: FakeKeyringBackend,
449
+ ) -> None:
450
+ result = runner.invoke(app, ["keys", "revoke", "--json"])
451
+
431
452
  assert result.exit_code == 0
453
+ assert json.loads(result.stdout) == {
454
+ "revoked": True,
455
+ "message": "Credentials revoked.",
456
+ }
457
+ assert credential_store.get_password("personal-cli", "default") is None
458
+
459
+
460
+ def test_json_credential_store_error_is_structured(
461
+ monkeypatch: pytest.MonkeyPatch,
462
+ runner: CliRunner,
463
+ ) -> None:
464
+ def _credential_store_failure(server_url=None, insecure=False):
465
+ raise CredentialError("Could not access the operating system credential store.")
466
+
467
+ monkeypatch.setattr("personal_cli.cli.build_client", _credential_store_failure)
468
+
469
+ result = runner.invoke(app, ["article", "list", "--json"])
470
+
471
+ assert result.exit_code == 2
472
+ error = json.loads(result.stderr.strip())
473
+ assert error == {
474
+ "error": {
475
+ "code": "credential_store_unavailable",
476
+ "message": "Could not access the operating system credential store.",
477
+ }
478
+ }
479
+ assert "Traceback" not in result.output
File without changes
File without changes
File without changes