diff --git a/dokimi/changelog.d/20261021_130000_vchamarthi_repo-cli-output-parity.md b/dokimi/changelog.d/20261021_130000_vchamarthi_repo-cli-output-parity.md
new file mode 100644
index 00000000..8c7e7573
--- /dev/null
+++ b/dokimi/changelog.d/20261021_130000_vchamarthi_repo-cli-output-parity.md
@@ -0,0 +1,12 @@
+### Added
+
+- **`repo fetch --json`** prints `{"sha", "git_dir"}` as JSON, so a script reads the
+ pinned commit without splitting the tab-separated line.
+- **`repo validate --out FILE`** and **`repo budget --out FILE`** write the full
+ report as JSON, as `repo scan` and `repo inspect` already do. The summary (or
+ `--json`) still prints.
+- **`repo validate --fail-on-warning`** exits 1 when the report has any warning,
+ as `repo budget --fail-on-warning` does, so a CI job can gate on a guidance
+ glob that matches nothing or a task base SHA that is not 40 characters.
+
+The default output and exit codes of all three commands are unchanged.
diff --git a/dokimi/docs/repo-evaluation-contract.md b/dokimi/docs/repo-evaluation-contract.md
index d4bda8dc..de103473 100644
--- a/dokimi/docs/repo-evaluation-contract.md
+++ b/dokimi/docs/repo-evaluation-contract.md
@@ -15,8 +15,8 @@ SPDX-License-Identifier: Apache-2.0
> subject kind:
>
> ```bash
-> python cli.py repo validate
--manifest [--json]
-> python cli.py repo fetch # bare, blob-less clone cached per URL
+> python cli.py repo validate --manifest [--json] [--out FILE] [--fail-on-warning]
+> python cli.py repo fetch [--json] # bare, blob-less clone cached per URL
> ```
>
> Slice S3 adds harness profiles (§6) and the reach probe (§7):
@@ -45,7 +45,7 @@ SPDX-License-Identifier: Apache-2.0
> Usage:
>
> ```bash
-> python cli.py repo budget [--harness claude-code] [--json] [--fail-on-warning]
+> python cli.py repo budget [--harness claude-code] [--out FILE] [--json] [--fail-on-warning]
> ```
>
> `repo inspect` (§8.1) needs no manifest: a static look at what the tree offers an agent.
diff --git a/dokimi/dokimi/commands/repo.py b/dokimi/dokimi/commands/repo.py
index 13c6a11b..2b5e82b1 100644
--- a/dokimi/dokimi/commands/repo.py
+++ b/dokimi/dokimi/commands/repo.py
@@ -20,6 +20,15 @@ def _normalize_whitespace(text: str, max_len: int | None = 100) -> str:
return printable[:max_len]
+def _write_json(path: str, payload: dict) -> None:
+ """Write a --out report as JSON, or exit 1 with `cannot write`."""
+ try:
+ Path(path).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
+ except OSError as exc:
+ print(f"✗ cannot write {path}: {exc}", file=sys.stderr)
+ sys.exit(1)
+
+
def cmd_repo_validate(args: argparse.Namespace) -> None:
"""Validate a repository's repo-eval.yaml and its tasks against one snapshot."""
from dokimi.repos import ManifestError, RepoSource, SourceError, check_repo, open_source
@@ -37,6 +46,8 @@ def cmd_repo_validate(args: argparse.Namespace) -> None:
if open_base is None:
report.notes.append("task bases were not checked: the source has no git history")
+ if args.out:
+ _write_json(args.out, report.to_dict())
if args.json:
print(json.dumps(report.to_dict(), indent=2))
else:
@@ -58,7 +69,7 @@ def cmd_repo_validate(args: argparse.Namespace) -> None:
print(f" note: {note}")
for error in report.errors:
print(f" error: {error}", file=sys.stderr)
- if not report.ok:
+ if not report.ok or (args.fail_on_warning and report.warnings):
sys.exit(1)
@@ -167,7 +178,10 @@ def cmd_repo_fetch(args: argparse.Namespace) -> None:
print(f"✗ {exc}", file=sys.stderr)
sys.exit(1)
assert isinstance(tree, GitTree)
- print(f"{tree.sha}\t{tree.git_dir}")
+ if args.json:
+ print(json.dumps({"sha": tree.sha, "git_dir": str(tree.git_dir)}, indent=2))
+ else:
+ print(f"{tree.sha}\t{tree.git_dir}")
def cmd_repo_reach(args: argparse.Namespace) -> None:
@@ -240,6 +254,8 @@ def cmd_repo_budget(args: argparse.Namespace) -> None:
print(f"✗ {exc}", file=sys.stderr)
sys.exit(1)
payload = budget(tree, profile.guidance_names, profile.import_max_depth)
+ if args.out:
+ _write_json(args.out, payload)
if args.json:
print(json.dumps(payload, indent=2))
else:
@@ -339,7 +355,10 @@ def register(sub, positive_int) -> None:
help="Read the manifest from this local file instead of from the tree, as if it were "
"at --manifest (for a repository that has not added it yet)")
validate_p.add_argument("--cache-dir", default=None, dest="cache_dir", help=cache_help)
+ validate_p.add_argument("--out", default=None, help="Write the full report as JSON to this file")
validate_p.add_argument("--json", action="store_true", help="Print the full report as JSON")
+ validate_p.add_argument("--fail-on-warning", action="store_true", dest="fail_on_warning",
+ help="Exit 1 when the report has any warning (for a CI gate)")
validate_p.set_defaults(func=cmd_repo_validate)
scan_p = repo_sub.add_parser(
@@ -386,6 +405,7 @@ def register(sub, positive_int) -> None:
fetch_p = repo_sub.add_parser("fetch", help="Clone/fetch a pinned source and print its commit SHA")
fetch_p.add_argument("source", help=source_help)
+ fetch_p.add_argument("--json", action="store_true", help="Print {sha, git_dir} as JSON instead of tab-separated")
fetch_p.add_argument("--cache-dir", default=None, dest="cache_dir", help=cache_help)
fetch_p.set_defaults(func=cmd_repo_fetch)
@@ -439,6 +459,7 @@ def register(sub, positive_int) -> None:
help="Built-in profile name or a harness_profile.v1 YAML path; its guidance names "
"supply the file names, but the classification rules (root vs nested, off rule) are "
"hardcoded to Claude Code until other harnesses are measured (default: claude-code)")
+ budget_p.add_argument("--out", default=None, help="Write the full inventory as JSON to this file")
budget_p.add_argument("--json", action="store_true", help="Print the full inventory as JSON")
budget_p.add_argument("--fail-on-warning", action="store_true", dest="fail_on_warning",
help="Exit 1 when any warning is printed (for a CI gate)")
diff --git a/dokimi/tests/test_repo_budget.py b/dokimi/tests/test_repo_budget.py
index 9717219a..4a733c64 100644
--- a/dokimi/tests/test_repo_budget.py
+++ b/dokimi/tests/test_repo_budget.py
@@ -319,6 +319,17 @@ def test_cli_json_and_bad_source(tmp_path, capsys):
assert exc.value.code == 1
+def test_cli_out_writes_the_inventory(tmp_path, capsys):
+ _tree(tmp_path, {"AGENTS.md": "a"})
+ dest = tmp_path / "budget.json"
+ out = _run(["repo", "budget", str(tmp_path), "--out", str(dest)], capsys).out
+ assert json.loads(dest.read_text(encoding="utf-8"))["totals"]["start"] == 1
+ assert "| start | guidance | AGENTS.md | 1 |" in out # the table still prints
+ with pytest.raises(SystemExit) as exc:
+ _run(["repo", "budget", str(tmp_path), "--out", str(tmp_path / "no" / "dir" / "b.json")], capsys)
+ assert exc.value.code == 1
+
+
def test_imports_must_exist_to_be_counted(tmp_path):
"""Real imports are counted; missing files and imports in code are not."""
tree = _tree(tmp_path, {
diff --git a/dokimi/tests/test_repo_manifest.py b/dokimi/tests/test_repo_manifest.py
index bdd81c26..cfe4da69 100644
--- a/dokimi/tests/test_repo_manifest.py
+++ b/dokimi/tests/test_repo_manifest.py
@@ -421,6 +421,36 @@ def test_repo_validate_cli(tmp_path, capsys):
assert "does not equal the directory name" in capsys.readouterr().err
+def test_repo_validate_fail_on_warning(tmp_path, capsys):
+ guidance = {**MANIFEST["guidance"], "also_scan": ["no-such-file-*.md"]}
+ root = _repo(tmp_path, {**MANIFEST, "guidance": guidance})
+ argv = ["repo", "validate", str(root), "--manifest", "dev/evals/repo-eval.yaml"]
+ _run_cli(argv) # a warning alone still passes by default
+ assert "warning: guidance/also_scan matches no file" in capsys.readouterr().out
+ with pytest.raises(SystemExit) as exc:
+ _run_cli([*argv, "--fail-on-warning"])
+ assert exc.value.code == 1
+ assert "warning:" in capsys.readouterr().out
+
+
+def test_repo_validate_out_writes_the_report(tmp_path, capsys):
+ root = _repo(tmp_path)
+ dest = tmp_path / "validate.json"
+ argv = ["repo", "validate", str(root), "--manifest", "dev/evals/repo-eval.yaml", "--out", str(dest)]
+ _run_cli(argv)
+ written = json.loads(dest.read_text(encoding="utf-8"))
+ assert written["ok"] is True and written["tasks"]["count"] == 2
+ assert "tasks:" in capsys.readouterr().out # the summary still prints
+
+ _run_cli([*argv, "--json"])
+ assert json.loads(capsys.readouterr().out) == written
+
+ with pytest.raises(SystemExit) as exc:
+ _run_cli([*argv[:-1], str(tmp_path / "no" / "dir" / "v.json")])
+ assert exc.value.code == 1
+ assert "cannot write" in capsys.readouterr().err
+
+
def test_repo_fetch_cli(tmp_path, capsys):
root = _repo(tmp_path)
sha = _git_commit(root)
@@ -430,6 +460,18 @@ def test_repo_fetch_cli(tmp_path, capsys):
_run_cli(["repo", "fetch", str(root)])
+def test_repo_fetch_cli_json(tmp_path, capsys, monkeypatch):
+ from dokimi.repos.source import GitTree
+
+ sha = "0123456789abcdef0123456789abcdef01234567"
+ git_dir = tmp_path / "bare.git"
+ monkeypatch.setattr("dokimi.repos.open_source", lambda source, cache: GitTree(git_dir, sha))
+ _run_cli(["repo", "fetch", "https://example.invalid/r.git@main", "--json"])
+ assert json.loads(capsys.readouterr().out) == {"sha": sha, "git_dir": str(git_dir)}
+ _run_cli(["repo", "fetch", "https://example.invalid/r.git@main"])
+ assert capsys.readouterr().out == f"{sha}\t{git_dir}\n"
+
+
def test_repo_subject_kind(tmp_path):
path = tmp_path / "subject.toml"
path.write_text(