Skip to content

Commit 57d6ad2

Browse files
feat(cli): add cache --json output and clean confirmation
Machine-readable --json for list/clean/verify/outdated/doctor, plus an interactive confirm before wiping the full cache (CNA parity). Closes #228 Closes #229 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6012c89 commit 57d6ad2

2 files changed

Lines changed: 146 additions & 4 deletions

File tree

packages/create-awesome-python-app/src/create_awesome_python_app/cli.py

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import asyncio
66
import os
7+
import sys
78
from pathlib import Path
89
from typing import Any
910

@@ -444,8 +445,29 @@ def cache_dir_cmd() -> None:
444445
console.print(str(default_cache_dir()))
445446

446447

448+
def _cache_json_print(payload: Any) -> None:
449+
"""Print machine-readable JSON to stdout (not the stderr console)."""
450+
import json
451+
from dataclasses import asdict, is_dataclass
452+
453+
def _to_jsonable(value: Any) -> Any:
454+
if isinstance(value, Path):
455+
return str(value)
456+
if is_dataclass(value) and not isinstance(value, type):
457+
return {k: _to_jsonable(v) for k, v in asdict(value).items()}
458+
if isinstance(value, list):
459+
return [_to_jsonable(item) for item in value]
460+
if isinstance(value, dict):
461+
return {k: _to_jsonable(v) for k, v in value.items()}
462+
return value
463+
464+
typer.echo(json.dumps(_to_jsonable(payload), indent=2))
465+
466+
447467
@cache_app.command("list")
448-
def cache_list_cmd() -> None:
468+
def cache_list_cmd(
469+
json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
470+
) -> None:
449471
from create_awesome_python_app.cache import (
450472
format_age,
451473
format_bytes,
@@ -454,6 +476,9 @@ def cache_list_cmd() -> None:
454476
)
455477

456478
entries = list_cache_entries()
479+
if json_out:
480+
_cache_json_print(entries)
481+
return
457482
if not entries:
458483
console.print("[dim]No cached templates or extensions.[/dim]")
459484
console.print(
@@ -488,10 +513,38 @@ def cache_list_cmd() -> None:
488513
def cache_clean_cmd(
489514
id: str | None = typer.Argument(None),
490515
catalog: bool = typer.Option(False, "--catalog"),
516+
json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
517+
force: bool = typer.Option(
518+
False, "--force", "-f", help="Skip interactive confirmation"
519+
),
491520
) -> None:
492521
from create_awesome_python_app.cache import clean_cache
493522

523+
# Targeted cleans (id / --catalog) never prompt.
524+
if not catalog and id is None and not json_out and not force:
525+
if not sys.stdin.isatty():
526+
console.print(
527+
"[yellow]Non-interactive shell — use --json or --force to skip "
528+
"the prompt, or specify an id to target a specific entry.[/yellow]"
529+
)
530+
return
531+
import questionary
532+
533+
from create_awesome_python_app.prompt_style import CPA_PROMPT_STYLE
534+
535+
confirmed = questionary.confirm(
536+
"Remove ALL cached templates and extensions?",
537+
default=False,
538+
style=CPA_PROMPT_STYLE,
539+
).ask()
540+
if not confirmed:
541+
console.print("[dim]Clean cancelled.[/dim]")
542+
return
543+
494544
result = clean_cache(id, catalog=catalog)
545+
if json_out:
546+
_cache_json_print(result)
547+
return
495548
if result.not_found:
496549
console.print(f"[yellow]No cache entry found for id: {id}[/yellow]")
497550
return
@@ -503,10 +556,18 @@ def cache_clean_cmd(
503556

504557

505558
@cache_app.command("verify")
506-
def cache_verify_cmd(id: str | None = typer.Argument(None)) -> None:
559+
def cache_verify_cmd(
560+
id: str | None = typer.Argument(None),
561+
json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
562+
) -> None:
507563
from create_awesome_python_app.cache import verify_cache
508564

509565
results = verify_cache(id)
566+
if json_out:
567+
_cache_json_print(results)
568+
if any(not bool(entry.fsck_ok) for entry in results):
569+
raise typer.Exit(1)
570+
raise typer.Exit(0)
510571
if not results:
511572
console.print("[dim]No cached entries.[/dim]")
512573
raise typer.Exit(0)
@@ -527,10 +588,15 @@ def cache_verify_cmd(id: str | None = typer.Argument(None)) -> None:
527588

528589

529590
@cache_app.command("outdated")
530-
def cache_outdated_cmd() -> None:
591+
def cache_outdated_cmd(
592+
json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
593+
) -> None:
531594
from create_awesome_python_app.cache import check_outdated
532595

533596
results = check_outdated()
597+
if json_out:
598+
_cache_json_print(results)
599+
return
534600
if not results:
535601
console.print("[dim]No cached entries to check.[/dim]")
536602
return
@@ -590,10 +656,17 @@ def cache_update_cmd(id: str | None = typer.Argument(None)) -> None:
590656

591657

592658
@cache_app.command("doctor")
593-
def cache_doctor_cmd() -> None:
659+
def cache_doctor_cmd(
660+
json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
661+
) -> None:
594662
from create_awesome_python_app.cache import run_doctor
595663

596664
results = run_doctor()
665+
if json_out:
666+
_cache_json_print(results)
667+
if any(not row.ok for row in results):
668+
raise typer.Exit(1)
669+
raise typer.Exit(0)
597670
all_ok = True
598671
for row in results:
599672
mark = "[green]✓[/green]" if row.ok else "[red]✗[/red]"

packages/create-awesome-python-app/tests/test_cache_cmds.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,72 @@ def test_list_cli_shows_table(cache_root: Path) -> None:
198198
text = _out(result)
199199
assert "demo" in text
200200
assert "SHA" in text
201+
202+
203+
def test_cache_list_verify_doctor_json(
204+
cache_root: Path, monkeypatch: pytest.MonkeyPatch
205+
) -> None:
206+
import json
207+
208+
entry = cache_root / "repos" / "demo"
209+
sha = _init_git_repo(entry)
210+
write_cache_meta(
211+
entry,
212+
CacheMeta(
213+
url="https://example.com/repo.git",
214+
ref="main",
215+
fetched_at=time.time(),
216+
commit=sha,
217+
),
218+
)
219+
monkeypatch.setattr(
220+
"create_awesome_python_app.cache._probe_network",
221+
lambda: __import__(
222+
"create_awesome_python_app.cache", fromlist=["DoctorResult"]
223+
).DoctorResult(check="network", ok=True, detail="mocked"),
224+
)
225+
226+
listed = runner.invoke(cache_app, ["list", "--json"])
227+
assert listed.exit_code == 0, _out(listed)
228+
payload = json.loads(listed.stdout)
229+
assert payload[0]["id"] == "demo"
230+
assert payload[0]["commit"] == sha
231+
232+
verified = runner.invoke(cache_app, ["verify", "--json"])
233+
assert verified.exit_code == 0, _out(verified)
234+
assert json.loads(verified.stdout)[0]["fsck_ok"] is True
235+
236+
doctor = runner.invoke(cache_app, ["doctor", "--json"])
237+
assert doctor.exit_code == 0, _out(doctor)
238+
assert any(row["check"] == "git" for row in json.loads(doctor.stdout))
239+
240+
outdated = runner.invoke(cache_app, ["outdated", "--json"])
241+
assert outdated.exit_code == 0, _out(outdated)
242+
assert isinstance(json.loads(outdated.stdout), list)
243+
244+
245+
def test_cache_clean_json_and_force(cache_root: Path) -> None:
246+
import json
247+
248+
entry = cache_root / "repos" / "demo"
249+
_init_git_repo(entry)
250+
251+
blocked = runner.invoke(cache_app, ["clean"])
252+
assert blocked.exit_code == 0
253+
assert "Non-interactive" in _out(blocked)
254+
assert entry.exists()
255+
256+
forced = runner.invoke(cache_app, ["clean", "--json"])
257+
assert forced.exit_code == 0, _out(forced)
258+
payload = json.loads(forced.stdout)
259+
assert str(entry) in payload["removed"]
260+
assert not entry.exists()
261+
262+
263+
def test_cache_clean_force_without_json(cache_root: Path) -> None:
264+
entry = cache_root / "repos" / "demo"
265+
_init_git_repo(entry)
266+
result = runner.invoke(cache_app, ["clean", "--force"])
267+
assert result.exit_code == 0, _out(result)
268+
assert "Removed" in _out(result)
269+
assert not entry.exists()

0 commit comments

Comments
 (0)