prism-code-review 2.0.0__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.
prism/ai_test.py ADDED
@@ -0,0 +1,65 @@
1
+ """
2
+ AI Test Agent - Uses Gemini for test coverage review.
3
+
4
+ Catches: missing test coverage, weak test cases, untested code paths,
5
+ and test quality issues.
6
+
7
+ Rule IDs follow SonarQube convention: TST-XXX
8
+ """
9
+
10
+ import logging
11
+ from typing import List
12
+ from prism.agents import BaseAgent, Finding, register_agent
13
+ from prism.ai_common import review_with_agent
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ SYSTEM_PROMPT = """
18
+ You are a software testing expert with 10+ years of experience in test automation.
19
+ You review code for test coverage gaps and test quality issues.
20
+
21
+ Focus on:
22
+ 1. Untested code paths (functions without corresponding tests)
23
+ 2. Weak test cases (tests that don't actually verify behavior)
24
+ 3. Missing edge cases (null inputs, empty inputs, boundary values)
25
+ 4. Missing error handling tests (what happens when things go wrong?)
26
+ 5. Test isolation issues (tests that depend on shared state)
27
+ 6. Slow tests (tests that perform I/O, network calls, or database access)
28
+ 7. Missing assertions (tests that don't verify expected outcomes)
29
+ 8. Untested public APIs (exported functions with no test coverage)
30
+
31
+ Rules:
32
+ 1. Return ONLY valid JSON. No markdown, no extra text.
33
+ 2. Be specific. Each finding must include: severity, category, message, file, line, suggestion, suggested_code.
34
+ 3. For suggested_code, provide the actual test code that fixes the issue.
35
+ 4. Focus on actual coverage gaps, not test style preferences.
36
+ 5. If you find no issues, return empty findings array.
37
+
38
+ Schema:
39
+ {
40
+ "findings": [
41
+ {
42
+ "severity": "critical" | "high" | "medium" | "low",
43
+ "category": "string",
44
+ "message": "string",
45
+ "file": "string",
46
+ "line": number,
47
+ "suggestion": "string",
48
+ "suggested_code": "string or null"
49
+ }
50
+ ],
51
+ "summary": "2-3 sentence overall test coverage assessment"
52
+ }
53
+ """
54
+
55
+
56
+ @register_agent
57
+ class AITestAgent(BaseAgent):
58
+ name = "ai_test"
59
+ description = "AI-powered test coverage reviewer using Gemini"
60
+
61
+ def _check(self, file_path: str, content: str, diff: str,
62
+ skeleton: str = "", line_count: int = None,
63
+ fast: bool = False) -> List[Finding]:
64
+ return review_with_agent(SYSTEM_PROMPT, file_path, content,
65
+ self.name, skeleton, line_count, diff, fast)
prism/autofix.py ADDED
@@ -0,0 +1,360 @@
1
+ """
2
+ Auto-fix engine - Generates corrected code for auto-fixable findings.
3
+
4
+ Based on GitHub Copilot's "suggested changes" and CodeRabbit's "One-Click Fix":
5
+ - AI generates corrected code snippets
6
+ - Developer reviews before applying
7
+ - Changes are attributed to the developer, not committed automatically
8
+ """
9
+
10
+ import re
11
+ from typing import List, Optional, Tuple
12
+ from prism.agents import Finding
13
+ from prism.langs import lang_for
14
+ from prism.logging_config import logger
15
+
16
+ # A declaration is a name preceded by modifiers, a type, or a qualifier
17
+ # (`const token`, `String apiKey`, `self.password`). Taking the name from the
18
+ # start of the line got the boundary wrong: `String apiKey` produced
19
+ # `StringapiKey` and `\tapiKey :=` produced the single character `y`.
20
+ _SECRET_ASSIGN = re.compile(
21
+ r'^(?P<head>\s*(?:[\w$]+(?:[\s*&.]*|::))*?)'
22
+ r'(?P<name>\$?[A-Za-z_]\w*)'
23
+ r'(?P<op>\s*(?::=|(?<![=!<>:])=(?!=)|:(?!=))\s*)'
24
+ r'(?P<q>["\'])(?P<lit>.*?)(?P=q)(?P<tail>.*)$')
25
+
26
+
27
+ def _is_valid_python(source: str) -> bool:
28
+ """True when source parses as a complete Python module."""
29
+ try:
30
+ compile(source, "<prism-autofix>", "exec")
31
+ except (SyntaxError, ValueError):
32
+ return False
33
+ return True
34
+
35
+
36
+ def generate_fix(finding: Finding, original_content: str) -> Optional[str]:
37
+ """
38
+ Generate a corrected version of the line(s) for a finding.
39
+
40
+ Returns the fixed code string, or None if no fix is possible.
41
+ """
42
+ lines = original_content.split("\n")
43
+ line_idx = (finding.line or 1) - 1
44
+
45
+ if line_idx < 0 or line_idx >= len(lines):
46
+ return None
47
+
48
+ original_line = lines[line_idx]
49
+ fix_type = _classify_fix(finding)
50
+ spec = _spec_for(finding)
51
+
52
+ if fix_type == "remove_line":
53
+ return _remove_line(lines, line_idx)
54
+
55
+ if fix_type == "replace_secret":
56
+ return _replace_secret(original_line, finding, spec)
57
+
58
+ if fix_type == "remove_eval":
59
+ return _remove_eval(original_line, finding, spec)
60
+
61
+ if fix_type == "remove_exec":
62
+ return _remove_exec(original_line, finding, spec)
63
+
64
+ if fix_type == "add_logging":
65
+ return _replace_print_with_logging(original_line, finding, spec)
66
+
67
+ if fix_type == "remove_debugger":
68
+ return _remove_debugger(original_line, finding, spec)
69
+
70
+ if fix_type == "remove_console_log":
71
+ return _comment_out(original_line, finding, spec)
72
+
73
+ if fix_type == "add_test_docstring":
74
+ return _add_test_docstring(lines, line_idx, finding)
75
+
76
+ return None
77
+
78
+
79
+ def _spec_for(finding):
80
+ """The language of the file a finding came from, or None if it is not source."""
81
+ return lang_for(getattr(finding, "file", "") or "")
82
+
83
+
84
+ def _comment_prefix(spec):
85
+ """How a language starts a comment, for fixes that must disable a line."""
86
+ if spec is None or not spec.line_comments:
87
+ return "#"
88
+ return spec.line_comments[0]
89
+
90
+
91
+ def _is_python(spec):
92
+ return spec is None or spec.name == "python"
93
+
94
+
95
+ def _classify_fix(finding: Finding) -> str:
96
+ """Classify what kind of fix is needed.
97
+
98
+ Rule IDs are authoritative when present. The message fallbacks use word
99
+ boundaries so "evaluation" no longer looks like eval() and "keyword" no
100
+ longer looks like an API key.
101
+ """
102
+ rule_id = finding.rule_id or ""
103
+ msg = finding.message.lower()
104
+
105
+ if rule_id == "SECU-201":
106
+ return "remove_eval"
107
+ if rule_id == "SECU-202":
108
+ return "remove_exec"
109
+ if re.match(r"^SECU-1\d\d$", rule_id):
110
+ return "replace_secret"
111
+ if rule_id == "POL-203":
112
+ return "remove_debugger"
113
+ if rule_id == "POL-202":
114
+ return "add_logging"
115
+ # QUAL-110 is "missing docstring": a real docstring needs the function's
116
+ # purpose, which cannot be invented from the name. `add_test_docstring`
117
+ # only ever applied to TST-102, and routing QUAL-110 here produced a
118
+ # suggestion that just echoed the def line back.
119
+ if rule_id == "TST-102":
120
+ return "add_test_docstring"
121
+
122
+ if re.search(r"\beval\s*\(", msg):
123
+ return "remove_eval"
124
+ if re.search(r"\bexec\s*\(", msg):
125
+ return "remove_exec"
126
+ if re.search(r"\bdebugger\b", msg):
127
+ return "remove_debugger"
128
+ if re.search(r"console\.log", msg):
129
+ return "remove_console_log"
130
+ if re.search(r"\bprint\s*\(", msg):
131
+ return "add_logging"
132
+ if re.search(r"\b(api[_-]?secret|secret|password|passwd|pwd|token)\b", msg):
133
+ return "replace_secret"
134
+ # "docstring" alone is not enough: only test docstrings can be generated.
135
+ if re.search(r"\bdocstring\b", msg) and "test" in msg:
136
+ return "add_test_docstring"
137
+
138
+ return "unknown"
139
+
140
+
141
+ def _remove_line(lines: list, idx: int) -> str:
142
+ """Remove a line entirely."""
143
+ return ""
144
+
145
+
146
+ def _replace_secret(line: str, finding: Finding, spec=None) -> str:
147
+ """Replace a hardcoded secret with a read from the environment.
148
+
149
+ The declaration is left as it is and only the literal is swapped, so
150
+ ``apiKey := "..."`` keeps its ``:=`` and its trailing ``;`` while the value
151
+ becomes the language's own lookup (``os.Getenv("APIKEY")`` in Go,
152
+ ``process.env.APIKEY`` in JS). Emitting ``os.getenv`` into a Go file would
153
+ not compile, which is worse than offering no fix at all.
154
+ """
155
+ env_read = spec.env_read if spec is not None else 'os.getenv("{name}")'
156
+ match = _SECRET_ASSIGN.match(line)
157
+ if not match:
158
+ return line
159
+ # PHP declares `$key`; the sigil is not part of the variable's name.
160
+ name = re.sub(r"[^A-Za-z0-9]", "_", match.group("name").lstrip("$")).upper()
161
+ return (match.group("head") + match.group("name") + match.group("op")
162
+ + env_read.format(name=name) + match.group("tail"))
163
+
164
+
165
+ def _remove_dangerous_call(line: str, name: str, spec=None) -> str:
166
+ """Replace a dangerous call with a syntactically valid, inert statement.
167
+
168
+ Commenting out only the call left fragments such as
169
+ ``return # eval() removed`` followed by ``# x)``, which is confusing and
170
+ silently changes control flow. Emit a complete statement instead.
171
+
172
+ ``pass`` and ``return None`` are Python, so elsewhere the line is disabled
173
+ with the language's own comment marker: it cannot be wrong about syntax
174
+ because nothing new is executed.
175
+ """
176
+ indent = re.match(r"^(\s*)", line).group(1)
177
+ stripped = line.strip()
178
+ if not _is_python(spec):
179
+ return "{}{} {}() removed - security risk: {}".format(
180
+ indent, _comment_prefix(spec), name, stripped)
181
+ if re.match(r"^return\s+" + name + r"\s*\(", stripped):
182
+ return "{}return None # {}() removed - security risk".format(indent, name)
183
+ match = re.match(r"^([A-Za-z_]\w*)\s*=\s*" + name + r"\s*\(", stripped)
184
+ if match:
185
+ return "{}{} = None # {}() removed - security risk".format(
186
+ indent, match.group(1), name)
187
+ if re.match(r"^" + name + r"\s*\(", stripped):
188
+ return "{}pass # {}() removed - security risk".format(indent, name)
189
+ return "{}# {}() removed - security risk: {}".format(indent, name, stripped)
190
+
191
+
192
+ def _remove_eval(line: str, finding: Finding, spec=None) -> str:
193
+ """Replace eval() with an inert statement."""
194
+ return _remove_dangerous_call(line, "eval", spec)
195
+
196
+
197
+ def _remove_exec(line: str, finding: Finding, spec=None) -> str:
198
+ """Replace exec() with an inert statement."""
199
+ return _remove_dangerous_call(line, "exec", spec)
200
+
201
+
202
+ def _replace_print_with_logging(line: str, finding: Finding, spec=None) -> str:
203
+ """Replace print() with logging.info() (import is ensured later).
204
+
205
+ ``logging`` is Python's module; in another language the line is disabled and
206
+ the developer is pointed at the language's own logger.
207
+ """
208
+ match = re.match(r'^(\s*)print\((.*)\)', line)
209
+ if match and _is_python(spec):
210
+ indent, content = match.groups()
211
+ return '{}logging.info({})'.format(indent, content)
212
+ if match:
213
+ indent, content = match.groups()
214
+ return "{}{} print() removed - use a logging framework: {}".format(
215
+ indent, _comment_prefix(spec), content)
216
+ return line
217
+
218
+
219
+ def _remove_debugger(line: str, finding: Finding, spec=None) -> str:
220
+ """Comment out a debugger statement.
221
+
222
+ Only matches `debugger;` when it is the statement itself, not when the
223
+ text appears inside a string or regex literal (e.g. a rule definition
224
+ containing the word "debugger"), which would corrupt the line.
225
+ """
226
+ # A quoted literal before the keyword means it is data, not code.
227
+ stripped = line.lstrip()
228
+ if stripped.startswith(('"', "'", "r'", 'r"', "b'", 'b"', "f'", 'f"')):
229
+ return line
230
+ prefix = _comment_prefix(spec)
231
+ return re.sub(r'^(\s*)debugger;', r'\g<1>' + prefix + ' debugger removed', line)
232
+
233
+
234
+ def _comment_out(line: str, finding=None, spec=None) -> str:
235
+ """Comment out a line, using the language's own comment marker."""
236
+ if spec is None:
237
+ spec = _spec_for(finding)
238
+ return re.sub(r'^', _comment_prefix(spec) + " ", line, count=1)
239
+
240
+
241
+ def _add_test_docstring(lines: list, idx: int, finding: Finding) -> str:
242
+ """Add a docstring to a test function."""
243
+ line = lines[idx]
244
+ # Match def test_name(...): optionally followed by content on same line
245
+ match = re.match(r'^(\s*def\s+test_\w+\(.*\):)', line)
246
+ if match:
247
+ rest = line[match.end():].strip()
248
+ indent = " "
249
+ docstring = '\n{}"""Add a description of what this test validates."""'.format(indent)
250
+ if rest:
251
+ return match.group(1) + " " + rest + docstring
252
+ return match.group(1) + docstring
253
+ return line
254
+
255
+
256
+ def apply_fixes(fixes: List[Tuple[Finding, str]], content: str) -> str:
257
+ """
258
+ Apply multiple fixes to content.
259
+
260
+ Args:
261
+ fixes: List of (finding, fixed_line) tuples
262
+ content: Original file content
263
+
264
+ Returns:
265
+ Modified content
266
+ """
267
+ lines = content.split("\n")
268
+ for finding, fixed_line in fixes:
269
+ line_idx = (finding.line or 1) - 1
270
+ if 0 <= line_idx < len(lines):
271
+ lines[line_idx] = fixed_line
272
+ # `import os` is Python's way of getting the lookup; adding it to a Go or
273
+ # JS file would be a syntax error in a language it does not belong to.
274
+ spec = _spec_for(fixes[0][0]) if fixes else None
275
+ if _is_python(spec):
276
+ fixed = _ensure_imports("\n".join(lines))
277
+ elif spec is not None and spec.name == "go":
278
+ # `os.Getenv` needs `import "os"`. A single import statement is legal
279
+ # anywhere after the package clause, so this cannot break the file.
280
+ fixed = _ensure_go_imports("\n".join(lines))
281
+ else:
282
+ fixed = "\n".join(lines)
283
+
284
+ # The heuristics are line-local, so a fix can still produce unparsable
285
+ # code (e.g. inside a multi-line expression). Never hand back a broken
286
+ # file: fall back to the original and let the developer fix it manually.
287
+ if _is_python(spec) and _is_valid_python(content) \
288
+ and not _is_valid_python(fixed):
289
+ logger.warning(
290
+ "Auto-fix rejected: generated code for %s would not compile",
291
+ ", ".join(str((f.line or 0)) for f, _ in fixes),
292
+ )
293
+ return content
294
+ return fixed
295
+
296
+
297
+ def _ensure_go_imports(content: str) -> str:
298
+ """Add ``import "os"`` when a generated fix calls ``os.Getenv``.
299
+
300
+ Go has no implicit imports, so a fix that calls ``os.Getenv`` in a file
301
+ that does not already import ``os`` would not build. Any ``import`` after
302
+ the package clause is legal, including a second one, so this is a plain
303
+ insertion rather than an edit of an existing import block.
304
+ """
305
+ if not re.search(r"\bos\.Getenv\b", content):
306
+ return content
307
+ # Either form: `import "os"`, or the spec on its own line inside `import (...)`.
308
+ # Importing the same package twice is a compile error in Go, so this guard
309
+ # has to see through the parenthesised form.
310
+ spec_line = r'^\s*(?:\w+\s+)?"os"\s*$'
311
+ if re.search(r'^\s*import\s+(?:\w+\s+)?"os"\s*$', content, re.MULTILINE) or \
312
+ re.search(spec_line, content, re.MULTILINE):
313
+ return content
314
+ lines = content.split("\n")
315
+ for i, line in enumerate(lines):
316
+ if re.match(r"^\s*package\s+\w+", line):
317
+ lines.insert(i + 1, "")
318
+ lines.insert(i + 2, 'import "os"')
319
+ return "\n".join(lines)
320
+ return content
321
+
322
+
323
+ def _ensure_imports(content: str) -> str:
324
+ """Add imports required by generated fixes (e.g. os for os.getenv)."""
325
+ needs = []
326
+ if re.search(r"\bos\.(?:getenv|environ)\b", content) and \
327
+ not re.search(r"^\s*import\s+os\b", content, re.MULTILINE):
328
+ needs.append("import os")
329
+ if re.search(r"\blogging\.\w+", content) and \
330
+ not re.search(r"^\s*import\s+logging\b", content, re.MULTILINE):
331
+ needs.append("import logging")
332
+ if not needs:
333
+ return content
334
+
335
+ lines = content.split("\n")
336
+ insert_at = 0
337
+ if lines and lines[0].startswith("#!"):
338
+ insert_at = 1
339
+ if insert_at < len(lines):
340
+ stripped = lines[insert_at].lstrip()
341
+ if stripped.startswith(('"""', "'''")):
342
+ quote = stripped[:3]
343
+ if stripped.count(quote) >= 2 and len(stripped) > 3:
344
+ insert_at += 1
345
+ else:
346
+ for j in range(insert_at + 1, len(lines)):
347
+ if quote in lines[j]:
348
+ insert_at = j + 1
349
+ break
350
+ while insert_at < len(lines) and (
351
+ lines[insert_at].strip() == "" or lines[insert_at].lstrip().startswith("#")
352
+ ):
353
+ insert_at += 1
354
+ lines[insert_at:insert_at] = needs
355
+ return "\n".join(lines)
356
+
357
+
358
+ def get_auto_fixable(findings: List[Finding]) -> List[Finding]:
359
+ """Filter findings that can be auto-fixed."""
360
+ return [f for f in findings if _classify_fix(f) != "unknown"]
prism/cli.py ADDED
@@ -0,0 +1,248 @@
1
+ """PRISM CLI entry point.
2
+
3
+ This module is a thin facade: it builds the argparse parser and dispatches to
4
+ the command handlers. The implementation is split across:
5
+ - cli_config.py : config, git helpers, deviation policy
6
+ - cli_review.py : review orchestration, report formatting, JSON output
7
+ - cli_commands.py : check / check-staged / check-diff, self-tests
8
+ - commands_common.py : helpers shared by the command handlers
9
+ - commands_reports.py: reports, cache, config, stats, dashboard
10
+ - commands_setup.py : key, init, hook, version, help, agents
11
+
12
+ Importing this module runs the @register_agent decorators for the
13
+ deterministic regex agents so they are available alongside the AI agents.
14
+ """
15
+
16
+ import argparse
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ # Add parent directory to path so prism imports work when run as executable
21
+ sys.path.insert(0, str(Path(__file__).parent.parent))
22
+
23
+ # Load .env file if it exists (for GEMINI_API_KEY and other settings).
24
+ # prism/__init__.py already does this at package import; the call is kept
25
+ # explicit (and idempotent) so this module works even if imported oddly.
26
+ from prism import load_env as _load_env
27
+ _load_env()
28
+
29
+ # Importing these modules runs their @register_agent decorators so the
30
+ # deterministic regex agents are available alongside the AI agents.
31
+ from prism import agents_policy, agents_quality, agents_security, agents_test
32
+
33
+ _registered_agents = (agents_policy, agents_quality, agents_security,
34
+ agents_test)
35
+
36
+ from prism.cli_commands import (
37
+ _run_self_tests, _run_benchmark, _cmd_check, _cmd_check_staged,
38
+ _cmd_check_diff, _cmd_scan,
39
+ )
40
+ from prism.ai_providers import PROVIDERS as _PROVIDERS
41
+ from prism.commands_reports import (
42
+ _cmd_reports, _cmd_report, _cmd_delete_report, _cmd_clear_reports,
43
+ _cmd_dashboard, _cmd_stats, _cmd_config, _cmd_policy, _cmd_feedback,
44
+ _cmd_suppressed, _cmd_cache,
45
+ )
46
+ from prism.commands_setup import (
47
+ _cmd_key, _cmd_init, _cmd_install_hook, _cmd_uninstall_hook, _cmd_version,
48
+ _cmd_help, _cmd_agents, _cmd_ignore, _cmd_mode, _cmd_doctor,
49
+ )
50
+
51
+
52
+ def build_parser():
53
+ """Build the argparse parser with all subcommands."""
54
+ parser = argparse.ArgumentParser(
55
+ prog="prism", description="PRISM - Pre-Commit Code Review"
56
+ )
57
+ parser.add_argument(
58
+ "--effort", choices=["lite", "balanced", "max"],
59
+ default="balanced", help="AI review effort level",
60
+ )
61
+ parser.add_argument(
62
+ "--version", action="store_true",
63
+ help="Show the PRISM version and exit (same as `prism version`)",
64
+ )
65
+ sub = parser.add_subparsers(dest="command")
66
+
67
+ check = sub.add_parser("check", help="Review a file")
68
+ check.add_argument("file", help="File to review")
69
+ check.add_argument("--fix", action="store_true", help="Apply auto-fixable changes")
70
+ check.add_argument("--yes", action="store_true",
71
+ help="Apply --fix changes without the confirmation prompt")
72
+ check.add_argument("--json", action="store_true", help="Output results as JSON")
73
+ check.add_argument("--prompt", action="store_true",
74
+ help="Ask developer about medium/low findings")
75
+ check.add_argument("--effort", choices=["lite", "balanced", "max"],
76
+ default="balanced", help="AI review effort level")
77
+ check.add_argument("--fast", action="store_true",
78
+ help="Use two-pass summarization for large files")
79
+ check.add_argument("--no-dashboard", action="store_true",
80
+ help="Do not open the HTML dashboard after review")
81
+
82
+ check_staged = sub.add_parser("check-staged", help="Review staged git changes")
83
+ check_staged.add_argument("--json", action="store_true", help="Output results as JSON")
84
+ check_staged.add_argument("--prompt", action="store_true",
85
+ help="Ask developer about medium/low findings")
86
+ check_staged.add_argument("--effort", choices=["lite", "balanced", "max"],
87
+ default="balanced", help="AI review effort level")
88
+ check_staged.add_argument("--fast", action="store_true",
89
+ help="Use two-pass summarization for large files")
90
+ check_staged.add_argument("--dashboard", action="store_true",
91
+ help="Open visual dashboard after review")
92
+ check_staged.add_argument("--test", action="store_true",
93
+ help="Run pytest on changed Python files before review")
94
+ check_staged.add_argument("--no-dashboard", action="store_true",
95
+ help="Do not open the HTML dashboard after review")
96
+
97
+ check_diff = sub.add_parser(
98
+ "check-diff", aliases=["diff"],
99
+ help="Review the diff against a base branch (PR review)")
100
+ check_diff.add_argument("--base", default="origin/main",
101
+ help="Base ref to diff against (default: origin/main)")
102
+ check_diff.add_argument("--json", action="store_true", help="Output results as JSON")
103
+ check_diff.add_argument("--effort", choices=["lite", "balanced", "max"],
104
+ default="balanced", help="AI review effort level")
105
+ check_diff.add_argument("--fast", action="store_true",
106
+ help="Use two-pass summarization for large files")
107
+ check_diff.add_argument("--no-dashboard", action="store_true",
108
+ help="Do not open the HTML dashboard after review")
109
+
110
+ sub.add_parser("init", help="Create prism.json config")
111
+ sub.add_parser("install-hook", help="Install pre-commit hook")
112
+ sub.add_parser("policy", help="Show deviation policy")
113
+ sub.add_parser("feedback", help="Show feedback loop stats")
114
+ sub.add_parser("suppressed", help="Show historically suppressed rules")
115
+ sub.add_parser("reports", help="Show review history")
116
+ sub.add_parser("report", help="Show a specific report by ID").add_argument("id")
117
+ sub.add_parser("dashboard", help="Open visual report dashboard in browser")
118
+ sub.add_parser("doctor", help="Check environment and provider setup")
119
+
120
+ scan_cmd = sub.add_parser(
121
+ "scan",
122
+ help="Review every file under a path (whole-project audit)")
123
+ scan_cmd.add_argument("path", nargs="?", default=".",
124
+ help="Root directory to scan (default: .)")
125
+ scan_cmd.add_argument("--json", action="store_true",
126
+ help="Output results as JSON")
127
+ scan_cmd.add_argument("--effort", choices=["lite", "balanced", "max"],
128
+ default="balanced", help="AI review effort level")
129
+ scan_cmd.add_argument("--fast", action="store_true",
130
+ help="Use two-pass summarization for large files")
131
+ scan_cmd.add_argument("--no-dashboard", action="store_true",
132
+ help="Do not open the HTML dashboard after review")
133
+
134
+ delete_cmd = sub.add_parser("delete-report", help="Delete a specific report")
135
+ delete_cmd.add_argument("id", help="Report ID to delete")
136
+ clear_cmd = sub.add_parser("clear-reports", help="Delete ALL reports")
137
+ clear_cmd.add_argument("--force", action="store_true", help="Skip confirmation")
138
+
139
+ agents_cmd = sub.add_parser("agents", help="List available agents")
140
+ agents_cmd.add_argument("--enable", nargs="+", metavar="NAME",
141
+ help="Enable one or more agents")
142
+ agents_cmd.add_argument("--disable", nargs="+", metavar="NAME",
143
+ help="Disable one or more agents")
144
+
145
+ sub.add_parser("help", help="Show all commands and their uses")
146
+ sub.add_parser("version", help="Show PRISM version")
147
+ sub.add_parser("uninstall-hook", help="Remove pre-commit hook")
148
+ sub.add_parser("stats", help="Show review statistics")
149
+
150
+ ignore_cmd = sub.add_parser(
151
+ "ignore",
152
+ help="Manage files skipped by pre-commit review (.prismignore)")
153
+ ignore_cmd.add_argument("action",
154
+ choices=["list", "add", "remove", "reset"],
155
+ nargs="?", default="list",
156
+ help="What to do (default: list)")
157
+ ignore_cmd.add_argument("patterns", nargs="*",
158
+ help="Pattern(s) to add or remove")
159
+
160
+ mode_cmd = sub.add_parser(
161
+ "mode",
162
+ help="Show or switch the agent mode (all / regex / none)")
163
+ mode_cmd.add_argument("mode", nargs="?",
164
+ choices=["all", "regex", "none"],
165
+ help="Mode to switch to (omit to show the current one)")
166
+
167
+ config_cmd = sub.add_parser("config", help="Show or manage configuration")
168
+ config_cmd.add_argument("--set", nargs=2, metavar=("KEY", "VALUE"),
169
+ help="Set a config value")
170
+ config_cmd.add_argument("--get", metavar="KEY", help="Get a config value")
171
+
172
+ test_cmd = sub.add_parser("test", help="Run self-tests")
173
+ test_cmd.add_argument("--quick", action="store_true",
174
+ help="Run only quick tests (skip API calls)")
175
+
176
+ benchmark_cmd = sub.add_parser("benchmark", help="Benchmark agent performance")
177
+ benchmark_cmd.add_argument("--file", help="File to benchmark against")
178
+ benchmark_cmd.add_argument("--iterations", type=int, default=3,
179
+ help="Number of iterations")
180
+
181
+ cache_cmd = sub.add_parser("cache", help="Manage the codebase cache")
182
+ cache_cmd.add_argument("action", choices=["stats", "clear", "status"],
183
+ help="Cache action to perform")
184
+
185
+ key_cmd = sub.add_parser("key", help="Manage API keys")
186
+ key_cmd.add_argument("action", choices=["set", "delete", "status"],
187
+ help="Key action to perform")
188
+ key_cmd.add_argument("--provider", choices=list(_PROVIDERS),
189
+ default=None,
190
+ help="AI provider (default: gemini for set)")
191
+ key_cmd.add_argument("--key", help="API key value (required for set)")
192
+ key_cmd.add_argument("--global", dest="global_", action="store_true",
193
+ help="Write the key to the install folder's .env "
194
+ "(tool-wide, visible from every project) "
195
+ "instead of the current project")
196
+
197
+ return parser
198
+
199
+
200
+ _COMMANDS = {
201
+ "agents": _cmd_agents,
202
+ "help": _cmd_help,
203
+ "version": _cmd_version,
204
+ "uninstall-hook": _cmd_uninstall_hook,
205
+ "stats": _cmd_stats,
206
+ "config": _cmd_config,
207
+ "init": _cmd_init,
208
+ "install-hook": _cmd_install_hook,
209
+ "policy": _cmd_policy,
210
+ "feedback": _cmd_feedback,
211
+ "suppressed": _cmd_suppressed,
212
+ "reports": _cmd_reports,
213
+ "dashboard": _cmd_dashboard,
214
+ "report": _cmd_report,
215
+ "delete-report": _cmd_delete_report,
216
+ "clear-reports": _cmd_clear_reports,
217
+ "cache": _cmd_cache,
218
+ "check": _cmd_check,
219
+ "check-staged": _cmd_check_staged,
220
+ "check-diff": _cmd_check_diff,
221
+ "diff": _cmd_check_diff, # documented alias for check-diff
222
+ "scan": _cmd_scan,
223
+ "doctor": _cmd_doctor,
224
+ "test": _run_self_tests,
225
+ "benchmark": _run_benchmark,
226
+ "key": _cmd_key,
227
+ "ignore": _cmd_ignore,
228
+ "mode": _cmd_mode,
229
+ }
230
+
231
+
232
+ def main():
233
+ parser = build_parser()
234
+ args = parser.parse_args()
235
+
236
+ if getattr(args, "version", False):
237
+ _cmd_version(args)
238
+ return
239
+
240
+ handler = _COMMANDS.get(args.command)
241
+ if handler is None:
242
+ parser.print_help()
243
+ return
244
+ handler(args)
245
+
246
+
247
+ if __name__ == "__main__":
248
+ main()