agent-rules-kit 0.2.1__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """agent-rules-kit package."""
2
+
3
+ __version__ = "0.2.1"
agent_rules_kit/cli.py ADDED
@@ -0,0 +1,345 @@
1
+ """Command line entry point for agent-rules-kit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+
11
+ from agent_rules_kit import __version__
12
+ from agent_rules_kit.discovery import InstructionFile, discover_instruction_files
13
+ from agent_rules_kit.findings import Finding
14
+ from agent_rules_kit.governance import find_governance_findings
15
+ from agent_rules_kit.init_plan import InitPlan, build_init_plan
16
+ from agent_rules_kit.init_write import InitWriteResult, write_init_files
17
+ from agent_rules_kit.redaction import redact_secret_like_values
18
+
19
+ OUTPUT_FORMATS = ("console", "json", "markdown")
20
+
21
+
22
+ def build_parser() -> argparse.ArgumentParser:
23
+ """Build the command line parser."""
24
+ parser = argparse.ArgumentParser(
25
+ prog="agent-rules-kit",
26
+ description="Diagnose baseline quality of AI agent instruction files in repositories.",
27
+ )
28
+ parser.add_argument(
29
+ "--version",
30
+ action="store_true",
31
+ help="Print the package version and exit.",
32
+ )
33
+
34
+ subparsers = parser.add_subparsers(dest="command")
35
+
36
+ check_parser = subparsers.add_parser(
37
+ "check",
38
+ help="Discover supported agent instruction files in a repository.",
39
+ )
40
+ check_parser.add_argument(
41
+ "repository",
42
+ nargs="?",
43
+ default=".",
44
+ help="Repository root to inspect. Defaults to the current directory.",
45
+ )
46
+ check_parser.add_argument(
47
+ "--format",
48
+ choices=OUTPUT_FORMATS,
49
+ default="console",
50
+ help="Output format. Defaults to console.",
51
+ )
52
+
53
+ init_parser = subparsers.add_parser(
54
+ "init",
55
+ help="Plan baseline agent instruction files without writing by default.",
56
+ )
57
+ init_parser.add_argument(
58
+ "repository",
59
+ nargs="?",
60
+ default=".",
61
+ help="Repository root to inspect. Defaults to the current directory.",
62
+ )
63
+ init_parser.add_argument(
64
+ "--dry-run",
65
+ action="store_true",
66
+ help="Preview planned file changes without modifying files.",
67
+ )
68
+ init_parser.add_argument(
69
+ "--write",
70
+ action="store_true",
71
+ help="Write baseline files, backing up existing files first.",
72
+ )
73
+
74
+ return parser
75
+
76
+
77
+ def main(argv: Sequence[str] | None = None) -> int:
78
+ """Run the CLI."""
79
+ parser = build_parser()
80
+ args = parser.parse_args(argv)
81
+
82
+ if args.version:
83
+ print(f"agent-rules-kit {__version__}")
84
+ return 0
85
+
86
+ if args.command == "check":
87
+ return _run_check(Path(args.repository), output_format=args.format)
88
+
89
+ if args.command == "init":
90
+ return _run_init(
91
+ Path(args.repository),
92
+ dry_run=args.dry_run,
93
+ write=args.write,
94
+ )
95
+
96
+ parser.print_help()
97
+ return 0
98
+
99
+
100
+ def _run_check(repository_root: Path, *, output_format: str = "console") -> int:
101
+ try:
102
+ instruction_files = discover_instruction_files(repository_root)
103
+ except ValueError as error:
104
+ payload = _build_check_error_payload(repository_root, error)
105
+
106
+ if output_format == "json":
107
+ _print_json(payload)
108
+ elif output_format == "markdown":
109
+ _print_markdown(payload)
110
+ else:
111
+ print(f"ERROR: {payload['error']['message']}", file=sys.stderr)
112
+
113
+ return 2
114
+
115
+ status = "ok" if instruction_files else "no_instruction_files"
116
+ findings = find_governance_findings(repository_root, instruction_files)
117
+ payload = _build_check_payload(
118
+ repository_root,
119
+ instruction_files,
120
+ findings=findings,
121
+ status=status,
122
+ )
123
+
124
+ if output_format == "json":
125
+ _print_json(payload)
126
+ elif output_format == "markdown":
127
+ _print_markdown(payload)
128
+ else:
129
+ return _print_console_check(repository_root, instruction_files, findings)
130
+
131
+ return 0 if instruction_files else 1
132
+
133
+
134
+ def _print_console_check(
135
+ repository_root: Path,
136
+ instruction_files: tuple[InstructionFile, ...],
137
+ findings: tuple[Finding, ...],
138
+ ) -> int:
139
+ print(f"agent-rules-kit check: {repository_root}")
140
+
141
+ if not instruction_files:
142
+ print("No supported agent instruction files found.")
143
+ return 1
144
+
145
+ print(f"Found {len(instruction_files)} supported instruction file(s):")
146
+ for instruction_file in instruction_files:
147
+ print(f"- {instruction_file.path} [{instruction_file.kind.value}]")
148
+
149
+ if findings:
150
+ print("Findings:")
151
+ for finding in findings:
152
+ location = _format_finding_location(finding)
153
+ print(
154
+ f"- {finding.rule_id} [{finding.severity.value}] "
155
+ f"{location} - {redact_secret_like_values(finding.message)}"
156
+ )
157
+
158
+ return 0
159
+
160
+
161
+ def _run_init(repository_root: Path, *, dry_run: bool, write: bool) -> int:
162
+ if dry_run and write:
163
+ print("ERROR: init accepts only one mode: --dry-run or --write.", file=sys.stderr)
164
+ return 2
165
+
166
+ if not dry_run and not write:
167
+ print("ERROR: init currently requires --dry-run or --write.", file=sys.stderr)
168
+ return 2
169
+
170
+ try:
171
+ if dry_run:
172
+ plan = build_init_plan(repository_root)
173
+ _print_init_dry_run(plan)
174
+ else:
175
+ result = write_init_files(repository_root)
176
+ _print_init_write(result)
177
+ except ValueError as error:
178
+ print(f"ERROR: {redact_secret_like_values(str(error))}", file=sys.stderr)
179
+ return 2
180
+
181
+ return 0
182
+
183
+
184
+ def _print_init_dry_run(plan: InitPlan) -> None:
185
+ print(f"agent-rules-kit init: {redact_secret_like_values(plan.repository)}")
186
+ print("Mode: dry-run")
187
+ print("No files will be modified.")
188
+ print("Planned file actions:")
189
+
190
+ for file_item in plan.files:
191
+ path = redact_secret_like_values(file_item.path)
192
+ reason = redact_secret_like_values(file_item.reason)
193
+ print(f"- {path} [{file_item.action.value}] - {reason}")
194
+
195
+
196
+ def _print_init_write(result: InitWriteResult) -> None:
197
+ print(f"agent-rules-kit init: {redact_secret_like_values(result.repository)}")
198
+ print("Mode: write")
199
+ print("Files modified:")
200
+
201
+ for file_item in result.files:
202
+ path = redact_secret_like_values(file_item.path)
203
+ if file_item.backup_path is None:
204
+ print(f"- {path} [{file_item.action.value}]")
205
+ else:
206
+ backup_path = redact_secret_like_values(file_item.backup_path)
207
+ print(f"- {path} [{file_item.action.value}] - backup: {backup_path}")
208
+
209
+
210
+ def _build_check_payload(
211
+ repository_root: Path,
212
+ instruction_files: tuple[InstructionFile, ...],
213
+ *,
214
+ findings: tuple[Finding, ...],
215
+ status: str,
216
+ ) -> dict[str, object]:
217
+ return {
218
+ "command": "check",
219
+ "status": status,
220
+ "repository": redact_secret_like_values(str(repository_root)),
221
+ "instruction_files": [
222
+ {
223
+ "path": redact_secret_like_values(instruction_file.path),
224
+ "kind": instruction_file.kind.value,
225
+ }
226
+ for instruction_file in instruction_files
227
+ ],
228
+ "summary": {
229
+ "supported_instruction_file_count": len(instruction_files),
230
+ "finding_count": len(findings),
231
+ },
232
+ "findings": [_build_finding_payload(finding) for finding in findings],
233
+ "error": None,
234
+ }
235
+
236
+
237
+ def _build_check_error_payload(
238
+ repository_root: Path,
239
+ error: ValueError,
240
+ ) -> dict[str, object]:
241
+ return {
242
+ "command": "check",
243
+ "status": "error",
244
+ "repository": redact_secret_like_values(str(repository_root)),
245
+ "instruction_files": [],
246
+ "summary": {
247
+ "supported_instruction_file_count": 0,
248
+ "finding_count": 0,
249
+ },
250
+ "findings": [],
251
+ "error": {
252
+ "message": redact_secret_like_values(str(error)),
253
+ },
254
+ }
255
+
256
+
257
+ def _print_json(payload: dict[str, object]) -> None:
258
+ print(json.dumps(payload, indent=2, sort_keys=True))
259
+
260
+
261
+ def _print_markdown(payload: dict[str, object]) -> None:
262
+ print("# agent-rules-kit check")
263
+ print()
264
+ print(f"- Repository: {_markdown_value(str(payload['repository']))}")
265
+ print(f"- Status: {_markdown_value(str(payload['status']))}")
266
+ print(
267
+ "- Supported instruction files: "
268
+ f"{payload['summary']['supported_instruction_file_count']}"
269
+ )
270
+ print(f"- Findings: {payload['summary']['finding_count']}")
271
+
272
+ error = payload["error"]
273
+ if error is not None:
274
+ print()
275
+ print(f"Error: {_markdown_value(str(error['message']))}")
276
+ return
277
+
278
+ instruction_files = payload["instruction_files"]
279
+ if not instruction_files:
280
+ print()
281
+ print("No supported agent instruction files found.")
282
+ return
283
+
284
+ print()
285
+ print("| Path | Kind |")
286
+ print("| --- | --- |")
287
+ for instruction_file in instruction_files:
288
+ path = _markdown_value(str(instruction_file["path"]))
289
+ kind = _markdown_value(str(instruction_file["kind"]))
290
+ print(f"| {path} | {kind} |")
291
+
292
+ findings = payload["findings"]
293
+ if findings:
294
+ print()
295
+ print("## Findings")
296
+ print()
297
+ print("| Rule | Severity | Location | Message |")
298
+ print("| --- | --- | --- | --- |")
299
+ for finding in findings:
300
+ rule_id = _markdown_value(str(finding["rule_id"]))
301
+ severity = _markdown_value(str(finding["severity"]))
302
+ location = _markdown_value(_format_finding_payload_location(finding))
303
+ message = _markdown_value(str(finding["message"]))
304
+ print(f"| {rule_id} | {severity} | {location} | {message} |")
305
+
306
+
307
+ def _build_finding_payload(finding: Finding) -> dict[str, str | int]:
308
+ payload = finding.to_dict()
309
+
310
+ if "message" in payload:
311
+ payload["message"] = redact_secret_like_values(str(payload["message"]))
312
+ if "path" in payload:
313
+ payload["path"] = redact_secret_like_values(str(payload["path"]))
314
+ if "evidence" in payload:
315
+ payload["evidence"] = redact_secret_like_values(str(payload["evidence"]))
316
+
317
+ return payload
318
+
319
+
320
+ def _format_finding_location(finding: Finding) -> str:
321
+ if finding.path is None:
322
+ return "repository"
323
+ if finding.line is None:
324
+ return redact_secret_like_values(finding.path)
325
+ return f"{redact_secret_like_values(finding.path)}:{finding.line}"
326
+
327
+
328
+ def _format_finding_payload_location(finding: dict[str, object]) -> str:
329
+ path_value = finding.get("path")
330
+ if path_value is None:
331
+ return "repository"
332
+
333
+ line_value = finding.get("line")
334
+ if line_value is None:
335
+ return str(path_value)
336
+
337
+ return f"{path_value}:{line_value}"
338
+
339
+
340
+ def _markdown_value(value: str) -> str:
341
+ return redact_secret_like_values(value).replace("|", "\\|").replace("\n", " ")
342
+
343
+
344
+ if __name__ == "__main__":
345
+ raise SystemExit(main())
@@ -0,0 +1,109 @@
1
+ """Instruction file discovery for supported agent rule files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+
9
+
10
+ class InstructionFileKind(StrEnum):
11
+ """Supported instruction file family."""
12
+
13
+ AGENTS = "agents"
14
+ CLAUDE = "claude"
15
+ GEMINI = "gemini"
16
+ CURSOR_RULE = "cursor-rule"
17
+ COPILOT = "copilot"
18
+ GITHUB_INSTRUCTION = "github-instruction"
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class InstructionFile:
23
+ """A discovered instruction file."""
24
+
25
+ path: str
26
+ kind: InstructionFileKind
27
+
28
+
29
+ def discover_instruction_files(root: Path | str) -> tuple[InstructionFile, ...]:
30
+ """Discover supported instruction files below a repository root.
31
+
32
+ Discovery is intentionally limited to known instruction file locations. It
33
+ does not execute repository commands, call the network, or inspect file
34
+ contents.
35
+ """
36
+ root_path = Path(root)
37
+
38
+ if not root_path.exists():
39
+ raise ValueError(f"repository root does not exist: {root_path}")
40
+ if not root_path.is_dir():
41
+ raise ValueError(f"repository root is not a directory: {root_path}")
42
+
43
+ discovered: list[InstructionFile] = []
44
+
45
+ for relative_path, kind in _exact_instruction_paths():
46
+ candidate = root_path / relative_path
47
+ if _is_supported_instruction_path(candidate):
48
+ discovered.append(InstructionFile(path=relative_path, kind=kind))
49
+
50
+ cursor_rules_dir = root_path / ".cursor" / "rules"
51
+ if _is_safe_instruction_directory(root_path, cursor_rules_dir):
52
+ for candidate in sorted(cursor_rules_dir.glob("*.mdc")):
53
+ if _is_supported_instruction_path(candidate):
54
+ discovered.append(
55
+ InstructionFile(
56
+ path=candidate.relative_to(root_path).as_posix(),
57
+ kind=InstructionFileKind.CURSOR_RULE,
58
+ )
59
+ )
60
+
61
+ github_instructions_dir = root_path / ".github" / "instructions"
62
+ if _is_safe_instruction_directory(root_path, github_instructions_dir):
63
+ for candidate in sorted(github_instructions_dir.glob("*.md")):
64
+ if _is_supported_instruction_path(candidate):
65
+ discovered.append(
66
+ InstructionFile(
67
+ path=candidate.relative_to(root_path).as_posix(),
68
+ kind=InstructionFileKind.GITHUB_INSTRUCTION,
69
+ )
70
+ )
71
+
72
+ return tuple(discovered)
73
+
74
+
75
+ def _is_supported_instruction_path(candidate: Path) -> bool:
76
+ return candidate.is_file() or candidate.is_symlink()
77
+
78
+
79
+ def _is_safe_instruction_directory(root_path: Path, candidate: Path) -> bool:
80
+ return candidate.is_dir() and not _has_symlink_component(root_path, candidate)
81
+
82
+
83
+ def _has_symlink_component(root_path: Path, candidate: Path) -> bool:
84
+ relative_path = candidate.relative_to(root_path)
85
+ current = root_path
86
+
87
+ for part in relative_path.parts:
88
+ current = current / part
89
+ if current.is_symlink():
90
+ return True
91
+
92
+ return False
93
+
94
+
95
+ def _exact_instruction_paths() -> tuple[tuple[str, InstructionFileKind], ...]:
96
+ return (
97
+ ("AGENTS.md", InstructionFileKind.AGENTS),
98
+ ("CLAUDE.md", InstructionFileKind.CLAUDE),
99
+ (".claude/CLAUDE.md", InstructionFileKind.CLAUDE),
100
+ ("GEMINI.md", InstructionFileKind.GEMINI),
101
+ (".github/copilot-instructions.md", InstructionFileKind.COPILOT),
102
+ )
103
+
104
+
105
+ __all__ = [
106
+ "InstructionFile",
107
+ "InstructionFileKind",
108
+ "discover_instruction_files",
109
+ ]
@@ -0,0 +1,85 @@
1
+ """Finding model for diagnostic results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+
8
+
9
+ class Severity(StrEnum):
10
+ """Diagnostic finding severity."""
11
+
12
+ INFO = "info"
13
+ WARNING = "warning"
14
+ ERROR = "error"
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class Finding:
19
+ """A single diagnostic finding.
20
+
21
+ The model is intentionally small and dependency-free so it can support
22
+ console, JSON, and Markdown output later without pulling in runtime tools.
23
+ """
24
+
25
+ rule_id: str
26
+ severity: Severity
27
+ message: str
28
+ path: str | None = None
29
+ line: int | None = None
30
+ column: int | None = None
31
+ evidence: str | None = None
32
+
33
+ def __post_init__(self) -> None:
34
+ if not isinstance(self.severity, Severity):
35
+ raise TypeError("severity must be a Severity value")
36
+
37
+ normalized_rule_id = self.rule_id.strip()
38
+ normalized_message = self.message.strip()
39
+
40
+ if not normalized_rule_id:
41
+ raise ValueError("rule_id must not be blank")
42
+ if not normalized_message:
43
+ raise ValueError("message must not be blank")
44
+
45
+ object.__setattr__(self, "rule_id", normalized_rule_id)
46
+ object.__setattr__(self, "message", normalized_message)
47
+
48
+ if self.path is not None:
49
+ normalized_path = self.path.strip()
50
+ if not normalized_path:
51
+ raise ValueError("path must not be blank when provided")
52
+ object.__setattr__(self, "path", normalized_path)
53
+
54
+ if self.evidence is not None:
55
+ normalized_evidence = self.evidence.strip()
56
+ if not normalized_evidence:
57
+ raise ValueError("evidence must not be blank when provided")
58
+ object.__setattr__(self, "evidence", normalized_evidence)
59
+
60
+ if self.line is not None and self.line < 1:
61
+ raise ValueError("line must be greater than or equal to 1")
62
+ if self.column is not None and self.column < 1:
63
+ raise ValueError("column must be greater than or equal to 1")
64
+
65
+ def to_dict(self) -> dict[str, str | int]:
66
+ """Return a stable dictionary representation for future reporters."""
67
+ data: dict[str, str | int] = {
68
+ "rule_id": self.rule_id,
69
+ "severity": self.severity.value,
70
+ "message": self.message,
71
+ }
72
+
73
+ if self.path is not None:
74
+ data["path"] = self.path
75
+ if self.line is not None:
76
+ data["line"] = self.line
77
+ if self.column is not None:
78
+ data["column"] = self.column
79
+ if self.evidence is not None:
80
+ data["evidence"] = self.evidence
81
+
82
+ return data
83
+
84
+
85
+ __all__ = ["Finding", "Severity"]