Skip to content

Commit 647d712

Browse files
committed
feat(cli): --json output mode on read-only commands (SPEC C2)
`ai-config-kit --json <verb>` now produces a single JSON document for the four read-only verbs that scripts care about. Mutating verbs (install, uninstall, sync, decisions apply, …) stay text-only by design — their side effects are the value, not the report. Wired: - status -> {content_dir, target_base, hostname, tracked_files, untracked_candidates, git_clean, git_summary} - doctor -> {healthy, issues, [heal: {summary, findings}]} Exit code still reflects health. - validate -> {ok, issues, warnings}; exit code matches ok. - decisions list -> {packs: [{name, description, version, files, readme}]} - decisions show -> single pack object (same shape as a list entry) Library surface gains `.to_json_dict()` on DoctorReport, StatusReport, ValidationReport, DecisionPack, DecisionsListReport. Path values stringify to absolute paths. 4 new tests (264 total): valid JSON from each verb, doctor exit code matches healthy field, validate exit matches ok, packs array populated. SPEC §5 updated: C2, C3, C4 all marked _shipped 2026-05-16_ with brief notes on the actual ships. C1, C5, C6 remain.
1 parent 4bd6e49 commit 647d712

4 files changed

Lines changed: 169 additions & 16 deletions

File tree

docs/SPEC.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -301,9 +301,9 @@ Status matrix at v0.3.0:
301301
| # | Where | Issue |
302302
|---|---|---|
303303
| C1 | `src/ai_config_kit/manager.py` | The 1500+ line file is dense. Consider extracting `decisions_*` methods to a sibling `decisions.py` module while keeping `ClaudeConfig` as the orchestrator. |
304-
| C2 | All commands | We don't have a `--json` output mode anywhere. Adding one would let other tools script around `ai-config-kit status` etc. |
305-
| C3 | `cli.py` | The bootstrap command's `--remote URL` flag accepts arbitrary URLs. Validate it's https:// or git@ before passing to git. |
306-
| C4 | `decisions apply` | When `--force` is given, doesn't show a diff first. Add a confirm-with-diff for tty users. |
304+
| C2 | _shipped 2026-05-16_ | `--json` flag wired on status, doctor, validate, decisions list/show. Reports gained `.to_json_dict()`. Mutating verbs stay text-only by design (their side effects are the value, not the report). |
305+
| C3 | _shipped 2026-05-16_ | `bootstrap --remote URL` validated against a transport allowlist (https/http/ssh/git/git@/file) and shell-metachar blocklist. |
306+
| C4 | _shipped 2026-05-16_ | `decisions apply --force` on a TTY shows unified diff + y/N prompt before applying. `--yes` / `--dry-run` / non-tty bypass. |
307307
| C5 | Cross-project | Audit-checklist + agent-loop instructions are duplicated between this SPEC and `get-installer/SPEC.md`. If a third project adopts the pattern, factor into a shared template. |
308308
| C6 | `tests/` | We test the manager's surface but not the integration with the symlinks-on-real-FS edge cases (e.g., bind-mounted target). Add a few integration tests. |
309309

src/ai_config_kit/cli.py

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from __future__ import annotations
2222

2323
import argparse
24+
import json
2425
import sys
2526
from pathlib import Path
2627

@@ -98,6 +99,14 @@ def _build_parser() -> argparse.ArgumentParser:
9899
action="store_true",
99100
help="Print only essential output.",
100101
)
102+
p.add_argument(
103+
"--json",
104+
action="store_true",
105+
help=(
106+
"Output a single JSON document on read-only commands "
107+
"(status, doctor, validate, list, decisions list/show)."
108+
),
109+
)
101110
p.add_argument(
102111
"-V", "--version",
103112
action="version",
@@ -580,17 +589,31 @@ def main(argv: list[str] | None = None) -> int:
580589
return 0
581590

582591
if args.cmd == "status":
583-
print(cfg.status().to_text())
592+
status_report = cfg.status()
593+
if args.json:
594+
print(json.dumps(status_report.to_json_dict(), indent=2))
595+
else:
596+
print(status_report.to_text())
584597
return 0
585598

586599
if args.cmd == "doctor":
587600
doctor_report = cfg.doctor()
588-
print(doctor_report.summary())
589-
if getattr(args, "heal", False):
590-
heal_report = cfg.audit_permissions()
591-
print(heal_report.summary())
592-
for finding in heal_report.findings:
593-
_print_finding(finding)
601+
if args.json:
602+
payload = doctor_report.to_json_dict()
603+
if getattr(args, "heal", False):
604+
heal_report = cfg.audit_permissions()
605+
payload["heal"] = {
606+
"summary": heal_report.summary(),
607+
"findings": [str(f) for f in heal_report.findings],
608+
}
609+
print(json.dumps(payload, indent=2))
610+
else:
611+
print(doctor_report.summary())
612+
if getattr(args, "heal", False):
613+
heal_report = cfg.audit_permissions()
614+
print(heal_report.summary())
615+
for finding in heal_report.findings:
616+
_print_finding(finding)
594617
return 0 if doctor_report.healthy else 1
595618

596619
if args.cmd == "heal":
@@ -622,7 +645,10 @@ def main(argv: list[str] | None = None) -> int:
622645

623646
if args.cmd == "validate":
624647
val_report = cfg.validate()
625-
print(val_report.summary())
648+
if args.json:
649+
print(json.dumps(val_report.to_json_dict(), indent=2))
650+
else:
651+
print(val_report.summary())
626652
return 0 if val_report.ok else 1
627653

628654
if args.cmd == "cleanup":
@@ -646,14 +672,21 @@ def main(argv: list[str] | None = None) -> int:
646672

647673
if args.cmd == "decisions":
648674
if args.decisions_cmd == "list":
649-
print(cfg.decisions_list().summary())
675+
dec_list_report = cfg.decisions_list()
676+
if args.json:
677+
print(json.dumps(dec_list_report.to_json_dict(), indent=2))
678+
else:
679+
print(dec_list_report.summary())
650680
return 0
651681
if args.decisions_cmd == "show":
652682
pack = cfg.decisions_show(args.name)
653-
print(pack.summary())
654-
if pack.readme:
655-
print()
656-
print(pack.readme.rstrip())
683+
if args.json:
684+
print(json.dumps(pack.to_json_dict(), indent=2))
685+
else:
686+
print(pack.summary())
687+
if pack.readme:
688+
print()
689+
print(pack.readme.rstrip())
657690
return 0
658691
if args.decisions_cmd == "apply":
659692
# SPEC C4: when --force is requested on a TTY (and the

src/ai_config_kit/manager.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ def summary(self) -> str:
149149
return "all symlinks healthy"
150150
return f"{len(self.issues)} issue(s):\n " + "\n ".join(self.issues)
151151

152+
def to_json_dict(self) -> dict[str, Any]:
153+
return {"healthy": self.healthy, "issues": list(self.issues)}
154+
152155

153156
@dataclass(frozen=True)
154157
class StatusReport:
@@ -185,6 +188,17 @@ def to_text(self, max_listed: int = 30) -> str:
185188
lines.append(self.git_summary)
186189
return "\n".join(lines)
187190

191+
def to_json_dict(self) -> dict[str, Any]:
192+
return {
193+
"content_dir": str(self.content_dir),
194+
"target_base": str(self.target_base),
195+
"hostname": self.hostname,
196+
"tracked_files": list(self.tracked_files),
197+
"untracked_candidates": list(self.untracked_candidates),
198+
"git_clean": self.git_clean,
199+
"git_summary": self.git_summary,
200+
}
201+
188202

189203
@dataclass(frozen=True)
190204
class SyncReport:
@@ -302,6 +316,13 @@ def summary(self) -> str:
302316
lines.extend(f" - {w}" for w in self.warnings)
303317
return "\n".join(lines)
304318

319+
def to_json_dict(self) -> dict[str, Any]:
320+
return {
321+
"ok": self.ok,
322+
"issues": list(self.issues),
323+
"warnings": list(self.warnings),
324+
}
325+
305326

306327
@dataclass(frozen=True)
307328
class FetchReport:
@@ -487,11 +508,26 @@ def summary(self) -> str:
487508
f" files ({len(self.files)}):\n{files}"
488509
)
489510

511+
def to_json_dict(self) -> dict[str, Any]:
512+
return {
513+
"name": self.name,
514+
"description": self.description,
515+
"version": self.version,
516+
"files": [
517+
{"src": f.src, "dest": f.dest, "mode": f.mode}
518+
for f in self.files
519+
],
520+
"readme": self.readme,
521+
}
522+
490523

491524
@dataclass(frozen=True)
492525
class DecisionsListReport:
493526
packs: list[DecisionPack] = field(default_factory=list)
494527

528+
def to_json_dict(self) -> dict[str, Any]:
529+
return {"packs": [p.to_json_dict() for p in self.packs]}
530+
495531
def summary(self) -> str:
496532
if not self.packs:
497533
return "no bundled decision packs"

tests/test_cli.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,90 @@ def test_status_runs(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None
7676
assert "Tracked files" in out
7777

7878

79+
# --- --json output mode (SPEC C2) -----------------------------------------
80+
81+
82+
def test_status_json_outputs_valid_json(
83+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
84+
) -> None:
85+
import json as _json
86+
content = tmp_path / "content"
87+
target = tmp_path / "target"
88+
(content / "claude").mkdir(parents=True)
89+
target.mkdir()
90+
rc = main([
91+
"--content", str(content), "--target", str(target), "--json", "status",
92+
])
93+
assert rc == 0
94+
out = capsys.readouterr().out
95+
doc = _json.loads(out)
96+
assert "content_dir" in doc
97+
assert "tracked_files" in doc
98+
assert isinstance(doc["tracked_files"], list)
99+
100+
101+
def test_doctor_json_outputs_healthy_field(
102+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
103+
) -> None:
104+
import json as _json
105+
content = tmp_path / "content"
106+
target = tmp_path / "target"
107+
(content / "claude").mkdir(parents=True)
108+
(content / "claude" / "CLAUDE.md").write_text("x")
109+
target.mkdir()
110+
# No install -> doctor finds issue, exits 1, but JSON still valid
111+
rc = main([
112+
"--content", str(content), "--target", str(target), "--json", "doctor",
113+
])
114+
assert rc == 1
115+
out = capsys.readouterr().out
116+
doc = _json.loads(out)
117+
assert "healthy" in doc
118+
assert doc["healthy"] is False
119+
assert isinstance(doc["issues"], list)
120+
121+
122+
def test_validate_json_emits_ok_field(
123+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
124+
) -> None:
125+
import json as _json
126+
content = tmp_path / "content"
127+
target = tmp_path / "target"
128+
(content / "claude").mkdir(parents=True)
129+
target.mkdir()
130+
rc = main([
131+
"--content", str(content), "--target", str(target), "--json", "validate",
132+
])
133+
out = capsys.readouterr().out
134+
doc = _json.loads(out)
135+
assert "ok" in doc
136+
assert "issues" in doc
137+
assert "warnings" in doc
138+
# rc matches ok semantics:
139+
assert (rc == 0) == bool(doc["ok"])
140+
141+
142+
def test_decisions_list_json_outputs_packs_array(
143+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
144+
) -> None:
145+
import json as _json
146+
content = tmp_path / "content"
147+
target = tmp_path / "target"
148+
(content / "claude").mkdir(parents=True)
149+
target.mkdir()
150+
rc = main([
151+
"--content", str(content), "--target", str(target), "--json",
152+
"decisions", "list",
153+
])
154+
assert rc == 0
155+
out = capsys.readouterr().out
156+
doc = _json.loads(out)
157+
assert "packs" in doc
158+
assert isinstance(doc["packs"], list)
159+
assert doc["packs"] # at least one bundled pack
160+
assert "name" in doc["packs"][0]
161+
162+
79163
def test_invalid_json_config_exits_two(
80164
tmp_path: Path, capsys: pytest.CaptureFixture[str]
81165
) -> None:

0 commit comments

Comments
 (0)