From 28f97dd28edaa5a4ad4d601b2c230e2e8f8f8fd5 Mon Sep 17 00:00:00 2001 From: CoderDeltaLAN Date: Tue, 9 Jun 2026 05:55:14 +0100 Subject: [PATCH] feat: add json check output --- src/agent_rules_kit/cli.py | 77 ++++++++++++++++++++++++-- tests/test_cli.py | 109 +++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 4 deletions(-) diff --git a/src/agent_rules_kit/cli.py b/src/agent_rules_kit/cli.py index a01e57e..22904fc 100644 --- a/src/agent_rules_kit/cli.py +++ b/src/agent_rules_kit/cli.py @@ -3,12 +3,16 @@ from __future__ import annotations import argparse +import json import sys from collections.abc import Sequence from pathlib import Path from agent_rules_kit import __version__ -from agent_rules_kit.discovery import discover_instruction_files +from agent_rules_kit.discovery import InstructionFile, discover_instruction_files +from agent_rules_kit.redaction import redact_secret_like_values + +OUTPUT_FORMATS = ("console", "json") def build_parser() -> argparse.ArgumentParser: @@ -35,6 +39,12 @@ def build_parser() -> argparse.ArgumentParser: default=".", help="Repository root to inspect. Defaults to the current directory.", ) + check_parser.add_argument( + "--format", + choices=OUTPUT_FORMATS, + default="console", + help="Output format. Defaults to console.", + ) return parser @@ -49,19 +59,50 @@ def main(argv: Sequence[str] | None = None) -> int: return 0 if args.command == "check": - return _run_check(Path(args.repository)) + return _run_check(Path(args.repository), output_format=args.format) parser.print_help() return 0 -def _run_check(repository_root: Path) -> int: +def _run_check(repository_root: Path, *, output_format: str = "console") -> int: try: instruction_files = discover_instruction_files(repository_root) except ValueError as error: - print(f"ERROR: {error}", file=sys.stderr) + message = redact_secret_like_values(str(error)) + + if output_format == "json": + _print_json( + { + "command": "check", + "status": "error", + "repository": redact_secret_like_values(str(repository_root)), + "instruction_files": [], + "summary": { + "supported_instruction_file_count": 0, + }, + "error": { + "message": message, + }, + } + ) + else: + print(f"ERROR: {message}", file=sys.stderr) + return 2 + if output_format == "json": + status = "ok" if instruction_files else "no_instruction_files" + _print_json(_build_check_payload(repository_root, instruction_files, status=status)) + return 0 if instruction_files else 1 + + return _print_console_check(repository_root, instruction_files) + + +def _print_console_check( + repository_root: Path, + instruction_files: tuple[InstructionFile, ...], +) -> int: print(f"agent-rules-kit check: {repository_root}") if not instruction_files: @@ -75,5 +116,33 @@ def _run_check(repository_root: Path) -> int: return 0 +def _build_check_payload( + repository_root: Path, + instruction_files: tuple[InstructionFile, ...], + *, + status: str, +) -> dict[str, object]: + return { + "command": "check", + "status": status, + "repository": redact_secret_like_values(str(repository_root)), + "instruction_files": [ + { + "path": redact_secret_like_values(instruction_file.path), + "kind": instruction_file.kind.value, + } + for instruction_file in instruction_files + ], + "summary": { + "supported_instruction_file_count": len(instruction_files), + }, + "error": None, + } + + +def _print_json(payload: dict[str, object]) -> None: + print(json.dumps(payload, indent=2, sort_keys=True)) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/tests/test_cli.py b/tests/test_cli.py index bfe2518..f43b508 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import io +import json import unittest from contextlib import redirect_stderr, redirect_stdout from pathlib import Path @@ -68,6 +69,114 @@ def test_check_returns_two_for_invalid_repository_root(self) -> None: self.assertEqual(exit_code, 2) self.assertIn("ERROR: repository root does not exist:", output.getvalue()) + def test_check_reports_discovered_instruction_files_as_json(self) -> None: + output = io.StringIO() + + with redirect_stdout(output): + exit_code = main( + [ + "check", + str(FIXTURE_ROOT / "multi-agent-overlap"), + "--format", + "json", + ] + ) + + payload = json.loads(output.getvalue()) + + self.assertEqual(exit_code, 0) + self.assertEqual(payload["command"], "check") + self.assertEqual(payload["status"], "ok") + self.assertEqual(payload["error"], None) + self.assertEqual(payload["summary"]["supported_instruction_file_count"], 6) + self.assertEqual( + payload["instruction_files"], + [ + {"path": "AGENTS.md", "kind": "agents"}, + {"path": "CLAUDE.md", "kind": "claude"}, + {"path": "GEMINI.md", "kind": "gemini"}, + { + "path": ".github/copilot-instructions.md", + "kind": "copilot", + }, + { + "path": ".cursor/rules/agent-rules.mdc", + "kind": "cursor-rule", + }, + { + "path": ".github/instructions/agents.instructions.md", + "kind": "github-instruction", + }, + ], + ) + + def test_check_json_returns_one_when_no_instruction_files_are_found(self) -> None: + output = io.StringIO() + + with redirect_stdout(output): + exit_code = main( + [ + "check", + str(FIXTURE_ROOT / "empty-repo"), + "--format", + "json", + ] + ) + + payload = json.loads(output.getvalue()) + + self.assertEqual(exit_code, 1) + self.assertEqual(payload["status"], "no_instruction_files") + self.assertEqual(payload["instruction_files"], []) + self.assertEqual(payload["summary"]["supported_instruction_file_count"], 0) + self.assertEqual(payload["error"], None) + + def test_check_json_returns_two_for_invalid_repository_root(self) -> None: + output = io.StringIO() + + with redirect_stdout(output): + exit_code = main( + [ + "check", + str(FIXTURE_ROOT / "missing-repo"), + "--format", + "json", + ] + ) + + payload = json.loads(output.getvalue()) + + self.assertEqual(exit_code, 2) + self.assertEqual(payload["status"], "error") + self.assertEqual(payload["instruction_files"], []) + self.assertEqual(payload["summary"]["supported_instruction_file_count"], 0) + self.assertIn( + "repository root does not exist:", + payload["error"]["message"], + ) + + def test_check_json_redacts_secret_like_repository_values(self) -> None: + output = io.StringIO() + secret_like_path = FIXTURE_ROOT / ("sk-" + ("A" * 24)) + + with redirect_stdout(output): + exit_code = main( + [ + "check", + str(secret_like_path), + "--format", + "json", + ] + ) + + payload = json.loads(output.getvalue()) + text = output.getvalue() + + self.assertEqual(exit_code, 2) + self.assertIn("[REDACTED]", text) + self.assertNotIn(secret_like_path.name, text) + self.assertEqual(payload["status"], "error") + if __name__ == "__main__": unittest.main()