argus-code-review 0.1.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.
Files changed (49) hide show
  1. argus/__init__.py +1 -0
  2. argus/cli.py +673 -0
  3. argus/config.py +102 -0
  4. argus/coverage.py +11 -0
  5. argus/dotenv_utils.py +88 -0
  6. argus/github_client.py +1006 -0
  7. argus/graph.py +2788 -0
  8. argus/helpers.py +143 -0
  9. argus/llm/models.py +126 -0
  10. argus/llm/output_models.py +492 -0
  11. argus/models.py +197 -0
  12. argus/openai_client.py +286 -0
  13. argus/pipeline_models.py +291 -0
  14. argus/prompts/__init__.py +8 -0
  15. argus/prompts/pr-review-blocking-validator.md +53 -0
  16. argus/prompts/pr-review-coverage-check.md +15 -0
  17. argus/prompts/pr-review-cross-cutting.md +78 -0
  18. argus/prompts/pr-review-feedback-verifier.md +31 -0
  19. argus/prompts/pr-review-lite.md +50 -0
  20. argus/prompts/pr-review-planner.md +193 -0
  21. argus/prompts/pr-review-preflight-router.md +24 -0
  22. argus/prompts/pr-review-prior-art.md +25 -0
  23. argus/prompts/pr-review-specialist-deployment.md +52 -0
  24. argus/prompts/pr-review-specialist-frontend.md +90 -0
  25. argus/prompts/pr-review-specialist-infra.md +33 -0
  26. argus/prompts/pr-review-specialist-llm-patterns.md +53 -0
  27. argus/prompts/pr-review-specialist-observability.md +47 -0
  28. argus/prompts/pr-review-specialist-orchestration.md +62 -0
  29. argus/prompts/pr-review-specialist-security.md +41 -0
  30. argus/prompts/pr-review-specialist-slackbot.md +47 -0
  31. argus/prompts/pr-review-specialist-sql.md +34 -0
  32. argus/prompts/pr-review-subagent.md +120 -0
  33. argus/prompts/pr-review-tests-and-docs.md +34 -0
  34. argus/prompts/pr-review-writer.md +268 -0
  35. argus/prompts_runtime.py +237 -0
  36. argus/repo_provision.py +445 -0
  37. argus/runners.py +1136 -0
  38. argus/storage/__init__.py +51 -0
  39. argus/storage/http.py +243 -0
  40. argus/storage/models.py +115 -0
  41. argus/storage/resolver.py +379 -0
  42. argus/storage/session.py +73 -0
  43. argus/storage/sql.py +488 -0
  44. argus/storage/sqlite.py +588 -0
  45. argus_code_review-0.1.0.dist-info/METADATA +405 -0
  46. argus_code_review-0.1.0.dist-info/RECORD +49 -0
  47. argus_code_review-0.1.0.dist-info/WHEEL +4 -0
  48. argus_code_review-0.1.0.dist-info/entry_points.txt +2 -0
  49. argus_code_review-0.1.0.dist-info/licenses/LICENSE +193 -0
argus/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Orchestrated PR review service using Claude Agent SDK."""
argus/cli.py ADDED
@@ -0,0 +1,673 @@
1
+ #!/usr/bin/env python3
2
+ """Local runner for the Argus v3 review agent.
3
+
4
+ Configuration is env-var / ``.env`` only — no AWS, no SSM. If required
5
+ secrets are missing after the load, the runner exits with a clear error.
6
+
7
+ Usage:
8
+ # Review a PR (positional repo, or --repo — mutually exclusive, not both)
9
+ uv run argus review owner/repo --pr 3124
10
+ uv run argus review --repo owner/repo --pr 3124
11
+
12
+ # Review a specific SHA against main
13
+ uv run argus review owner/repo --sha abc123 --base-ref main
14
+
15
+ # Write review comment to a file instead of just printing
16
+ uv run argus review owner/repo --pr 3124 -o review.md
17
+
18
+ # Also post/update the review as a PR comment, and set a commit status
19
+ uv run argus review owner/repo --pr 3124 --post --commit-status
20
+
21
+ # Dismiss a finding before running the next round
22
+ uv run argus review owner/repo --pr 3124 --dismiss "B2 -- pre-existing, not from this PR"
23
+
24
+ # List / export the packaged prompts for customization
25
+ uv run argus prompts list
26
+ uv run argus prompts export ./my-prompts
27
+
28
+ # Also runnable as a flat legacy invocation (no subcommand) or via -m:
29
+ uv run python -m argus.cli --repo owner/repo --pr 3124
30
+
31
+ Environment variables (HTTP-mode opt-in):
32
+ ARGUS_STORAGE_READ_URL / ARGUS_STORAGE_WRITE_URL / ARGUS_STORAGE_AUTH
33
+ Defaults for the corresponding ``--storage-*`` flags. Both
34
+ READ_URL and WRITE_URL must be set together (AUTH is optional)
35
+ to arm the HTTP shim and let the runner work inside sandboxes
36
+ that can't reach Postgres on 5432.
37
+
38
+ ARGUS_SQLITE_CHECKPOINT_PATH
39
+ Applies whenever no Postgres URL is configured (the SQLite
40
+ checkpointer is the default; ``ARGUS_DB_URL`` opts into
41
+ Postgres). When unset (default), the runner writes the
42
+ LangGraph checkpoint to ``/tmp/argus-checkpoint-<PID>.db`` and
43
+ unlinks it on exit. Set to a fixed path to preserve the
44
+ checkpoint across runs (e.g. for post-mortem inspection of a
45
+ failed pipeline). The runner will not delete an operator-pinned
46
+ path.
47
+
48
+ No storage env vars are required: with neither ``ARGUS_DB_URL`` nor the
49
+ HTTP-shim URLs set, round history and checkpoints default to local SQLite.
50
+ Required secrets are only ANTHROPIC_API_KEY, GITHUB_TOKEN_RO,
51
+ and OPENAI_API_KEY.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import argparse
57
+ import asyncio
58
+ import contextlib
59
+ import json
60
+ import logging
61
+ import os
62
+ import sys
63
+ import threading
64
+ import time
65
+ from pathlib import Path
66
+ from typing import TYPE_CHECKING
67
+
68
+ if TYPE_CHECKING:
69
+ from argus.config import Settings
70
+ from argus.models import ReviewRequest, ReviewResponse
71
+
72
+ logging.basicConfig(
73
+ level=logging.INFO,
74
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
75
+ datefmt="%H:%M:%S",
76
+ )
77
+ logger = logging.getLogger("argus_review_local")
78
+
79
+ # Hard wall-clock backstop: if the entire review has not completed within this
80
+ # many seconds, the watchdog thread force-exits the process via os._exit. This
81
+ # is the only reliable cap locally, because a deadlocked event loop (e.g. the
82
+ # macOS child-watcher misreaping subprocess grandchildren) cannot be interrupted
83
+ # by asyncio-level timeouts. 60 minutes is well above a healthy review (8-20 min).
84
+ _WATCHDOG_TIMEOUT_S = 3600
85
+
86
+
87
+ def _sweep_stale_argus_tempdirs() -> None:
88
+ """Best-effort removal of Argus temp artifacts the forced exit would orphan."""
89
+ import glob
90
+ import shutil
91
+ import tempfile
92
+
93
+ tmp = tempfile.gettempdir()
94
+ for path in glob.glob(os.path.join(tmp, "argus-worktree-*")):
95
+ with contextlib.suppress(Exception):
96
+ shutil.rmtree(path, ignore_errors=True)
97
+ for path in glob.glob(os.path.join(tmp, "argus-gitcfg-*")):
98
+ with contextlib.suppress(Exception):
99
+ os.unlink(path)
100
+
101
+
102
+ def _start_watchdog(repo: str, pr: object) -> threading.Thread:
103
+ """Start the hard wall-clock backstop as a daemon thread.
104
+
105
+ If the review has not finished within ``_WATCHDOG_TIMEOUT_S``, the thread
106
+ sweeps stale Argus temp artifacts (best-effort) and force-exits via
107
+ ``os._exit`` - the only reliable cap when a deadlocked event loop cannot be
108
+ interrupted by asyncio-level timeouts. ``repo`` / ``pr`` are captured so the
109
+ log line names WHAT timed out.
110
+ """
111
+
112
+ def _watchdog() -> None:
113
+ time.sleep(_WATCHDOG_TIMEOUT_S)
114
+ logger.critical(
115
+ "Watchdog: review of %s PR #%s exceeded hard timeout of %ds; force-exiting",
116
+ repo,
117
+ pr,
118
+ _WATCHDOG_TIMEOUT_S,
119
+ )
120
+ try:
121
+ _sweep_stale_argus_tempdirs()
122
+ except Exception:
123
+ pass
124
+ os._exit(1)
125
+
126
+ thread = threading.Thread(target=_watchdog, daemon=True, name="argus-watchdog")
127
+ thread.start()
128
+ return thread
129
+
130
+
131
+ def _load_settings() -> "Settings":
132
+ """Load settings from .env / shell.
133
+
134
+ Clears the cached Settings singleton first so a fresh process env
135
+ (e.g. a `.env` loaded moments ago) is picked up.
136
+ """
137
+ from argus.dotenv_utils import load_dotenv_early
138
+
139
+ # Pin to '.env' explicitly — the docs reference '.env', and the default
140
+ # in load_dotenv_early would otherwise pick '.env.local' (or
141
+ # '.env.production') based on ENVIRONMENT, leading to silent no-op loads.
142
+ env_path = load_dotenv_early(start=Path.cwd(), env_filename=".env")
143
+ if env_path:
144
+ logger.info("Loaded local env vars from %s", env_path)
145
+
146
+ from argus.config import clear_cache, get_settings
147
+
148
+ # Clear the settings cache so it picks up any just-loaded .env values.
149
+ clear_cache()
150
+
151
+ settings = get_settings()
152
+
153
+ # Propagate any loaded keys back into os.environ so child code that reads
154
+ # them directly picks up the right values.
155
+ for attr in (
156
+ "ANTHROPIC_API_KEY",
157
+ "OPENAI_API_KEY",
158
+ "GITHUB_TOKEN_RO",
159
+ "LANGSMITH_API_KEY",
160
+ "LANGSMITH_PROJECT",
161
+ "CONTEXT7_API_KEY",
162
+ ):
163
+ val = getattr(settings, attr, None)
164
+ if val:
165
+ os.environ[attr] = val
166
+ if settings.db_url:
167
+ os.environ.setdefault("SUPABASE_DB_URL", settings.db_url)
168
+
169
+ return settings
170
+
171
+
172
+ def _check_settings(settings: "Settings") -> None:
173
+ """Validate that critical secrets were loaded, fail loudly otherwise.
174
+
175
+ Only the three API credentials are required: ``ANTHROPIC_API_KEY``
176
+ (Agent SDK + LangChain), ``GITHUB_TOKEN_RO`` (diff fetch + clone), and
177
+ ``OPENAI_API_KEY`` (plan-extraction path). No storage configuration is
178
+ required — with neither ``ARGUS_DB_URL`` nor the HTTP-shim URLs set,
179
+ history and checkpoints default to local SQLite.
180
+
181
+ Args:
182
+ settings: Resolved settings object.
183
+ """
184
+ missing = []
185
+ if not settings.ANTHROPIC_API_KEY:
186
+ missing.append("ANTHROPIC_API_KEY")
187
+ if not settings.GITHUB_TOKEN_RO:
188
+ missing.append("GITHUB_TOKEN_RO")
189
+ if not settings.OPENAI_API_KEY:
190
+ missing.append("OPENAI_API_KEY")
191
+ if missing:
192
+ logger.error(
193
+ "Missing required secrets: %s. Add them to your shell or .env file.",
194
+ ", ".join(missing),
195
+ )
196
+ sys.exit(1)
197
+
198
+
199
+ async def run(request: "ReviewRequest") -> "ReviewResponse":
200
+ """Run the review pipeline (storage backend resolved automatically:
201
+ Postgres if ``ARGUS_DB_URL`` is set, HTTP shim if the storage URLs are
202
+ set, else local SQLite)."""
203
+ from argus.graph import run_review
204
+
205
+ return await run_review(request, flow_run_id=None)
206
+
207
+
208
+ def _add_review_args(parser: argparse.ArgumentParser) -> None:
209
+ repo_group = parser.add_mutually_exclusive_group()
210
+ repo_group.add_argument(
211
+ "repo_positional",
212
+ nargs="?",
213
+ default=None,
214
+ metavar="repo",
215
+ help="GitHub repo (owner/repo), positional form: 'argus review owner/repo --pr N'",
216
+ )
217
+ repo_group.add_argument(
218
+ "--repo",
219
+ dest="repo_flag",
220
+ default=None,
221
+ help="GitHub repo (owner/repo). Mutually exclusive with the positional form.",
222
+ )
223
+ parser.add_argument("--pr", type=int, default=0, help="PR number to review")
224
+ parser.add_argument("--sha", default=None, help="Specific commit SHA to review")
225
+ parser.add_argument("--base-ref", default=None, help="Base ref for SHA mode (e.g. 'main')")
226
+ parser.add_argument(
227
+ "--dismiss",
228
+ action="append",
229
+ default=[],
230
+ help='Dismiss a prior finding: --dismiss "B2 -- pre-existing, not from this PR"',
231
+ )
232
+ parser.add_argument(
233
+ "--storage-read-url",
234
+ default=os.environ.get("ARGUS_STORAGE_READ_URL"),
235
+ help=(
236
+ "URL template for prior-rounds GET. When set (along "
237
+ "with --storage-write-url), Argus's storage I/O routes through "
238
+ "your own HTTP backend over HTTPS instead of direct Postgres — for "
239
+ "in-sandbox Argus runs that can't reach Postgres on port 5432. "
240
+ "Template supports {owner}, {repo}, {pr}."
241
+ ),
242
+ )
243
+ parser.add_argument(
244
+ "--storage-write-url",
245
+ default=os.environ.get("ARGUS_STORAGE_WRITE_URL"),
246
+ help="URL template for new-round POST. See --storage-read-url.",
247
+ )
248
+ parser.add_argument(
249
+ "--storage-auth",
250
+ default=os.environ.get("ARGUS_STORAGE_AUTH"),
251
+ help=(
252
+ "API key value for the ``X-API-Key`` header on storage requests "
253
+ "your HTTP backend can validate. Optional."
254
+ ),
255
+ )
256
+ parser.add_argument("-o", "--output", default=None, help="Write review markdown to file")
257
+ parser.add_argument(
258
+ "--post",
259
+ action="store_true",
260
+ help=(
261
+ "Upsert the finished review as a PR comment (updates Argus's prior "
262
+ "comment on re-run instead of stacking a new one). Requires --pr. "
263
+ "Uses GITHUB_TOKEN if set, else falls back to GITHUB_TOKEN_RO "
264
+ "(which may lack write scope)."
265
+ ),
266
+ )
267
+ parser.add_argument(
268
+ "--commit-status",
269
+ action="store_true",
270
+ dest="commit_status",
271
+ help=(
272
+ "Set a commit status on the head SHA (context 'argus/review', "
273
+ "success iff verdict is APPROVE). Same token rules as --post."
274
+ ),
275
+ )
276
+ parser.add_argument(
277
+ "--no-prompt-overrides",
278
+ action="store_true",
279
+ dest="no_prompt_overrides",
280
+ help=(
281
+ "Ignore every prompt override directory (ARGUS_PROMPTS_DIR, "
282
+ "./.argus/prompts/, ~/.config/argus/prompts/) and force the "
283
+ "packaged prompts only. For CI/official runs that must not pick "
284
+ "up a developer's local override by accident."
285
+ ),
286
+ )
287
+
288
+
289
+ def _package_version() -> str:
290
+ """Resolve the installed ``argus-code-review`` package version.
291
+
292
+ Falls back to ``"unknown"`` rather than raising — ``--version`` should
293
+ never be the thing that crashes, even in an unusual install (e.g. a
294
+ partially-built editable checkout with no dist-info yet). Called
295
+ eagerly from ``_build_parser()`` (the ``version=`` kwarg is an f-string,
296
+ evaluated at ``add_argument()`` time), so this dist-info lookup runs on
297
+ every ``argus`` invocation, not only when ``--version`` is passed.
298
+ """
299
+ import importlib.metadata
300
+
301
+ try:
302
+ return importlib.metadata.version("argus-code-review")
303
+ except importlib.metadata.PackageNotFoundError:
304
+ return "unknown"
305
+
306
+
307
+ def _build_parser() -> argparse.ArgumentParser:
308
+ parser = argparse.ArgumentParser(
309
+ prog="argus",
310
+ description="Run the Argus v3 review agent locally (no external orchestrator/CI needed)",
311
+ formatter_class=argparse.RawDescriptionHelpFormatter,
312
+ epilog=__doc__,
313
+ )
314
+ parser.add_argument(
315
+ "--version",
316
+ action="version",
317
+ version=f"%(prog)s {_package_version()}",
318
+ )
319
+ subparsers = parser.add_subparsers(dest="command")
320
+ review_parser = subparsers.add_parser(
321
+ "review",
322
+ help="Run a PR or SHA review",
323
+ formatter_class=argparse.RawDescriptionHelpFormatter,
324
+ )
325
+ _add_review_args(review_parser)
326
+
327
+ prompts_parser = subparsers.add_parser(
328
+ "prompts",
329
+ help="Inspect or export the packaged prompt files",
330
+ )
331
+ prompts_subparsers = prompts_parser.add_subparsers(dest="prompts_command")
332
+ prompts_subparsers.add_parser(
333
+ "list",
334
+ help="List prompt names and whether each resolves from the packaged "
335
+ "files or an ARGUS_PROMPTS_DIR override",
336
+ )
337
+ export_parser = prompts_subparsers.add_parser(
338
+ "export",
339
+ help="Copy the packaged prompt files into a directory for customization",
340
+ )
341
+ export_parser.add_argument("dir", help="Target directory")
342
+ export_parser.add_argument(
343
+ "--force",
344
+ action="store_true",
345
+ help="Overwrite files if the target directory is non-empty",
346
+ )
347
+
348
+ return parser
349
+
350
+
351
+ def _resolve_repo(parser: argparse.ArgumentParser, args: argparse.Namespace) -> str:
352
+ """Resolve the target repo from the positional or --repo form.
353
+
354
+ The mutually-exclusive group in ``_add_review_args`` already rejects
355
+ supplying both; this only needs to reject supplying neither.
356
+ """
357
+ repo: str | None = args.repo_positional or args.repo_flag
358
+ if not repo:
359
+ parser.error(
360
+ "Must provide repo as a positional argument (argus review owner/repo) or via --repo"
361
+ )
362
+ assert repo is not None # parser.error() above always raises SystemExit
363
+ return repo
364
+
365
+
366
+ def _validate_review_args(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
367
+ """Cross-flag validation that argparse's own declarative options can't express."""
368
+ if args.post and args.sha and not args.pr:
369
+ parser.error("--post requires --pr (a PR to comment on); --sha alone has nowhere to post")
370
+
371
+
372
+ def _check_prerequisites() -> None:
373
+ """Verify runtime prerequisites are on PATH, before any network call.
374
+
375
+ ``git`` is needed for repo provisioning and the ``claude`` CLI is spawned
376
+ as a subprocess by the Agent SDK — both must be resolvable on PATH before
377
+ Argus burns any API calls. Missing settings are checked separately by
378
+ ``_check_settings`` (which also fails loudly, after settings load).
379
+ """
380
+ import shutil
381
+
382
+ missing = [name for name in ("git", "claude") if shutil.which(name) is None]
383
+ if not missing:
384
+ return
385
+ for name in missing:
386
+ if name == "git":
387
+ logger.error(
388
+ "`git` was not found on PATH. Install git "
389
+ "(https://git-scm.com/downloads) and ensure it's on PATH."
390
+ )
391
+ else:
392
+ logger.error(
393
+ "`claude` CLI was not found on PATH. Install the Claude Code "
394
+ "CLI (https://docs.claude.com/en/docs/claude-code) — the Agent "
395
+ "SDK spawns it as a subprocess to run the review."
396
+ )
397
+ sys.exit(1)
398
+
399
+
400
+ def render_summary_block(response: "ReviewResponse", elapsed: float) -> str:
401
+ """Render the frozen stderr summary block (Round/Verdict/Risk/Findings/Cost/Elapsed).
402
+
403
+ This is a **frozen public contract**: the
404
+ ``argus-review-loop`` skill screen-parses this exact block. Do not change
405
+ the field order, labels, or spacing without updating the skill in lockstep.
406
+ """
407
+ blocking = sum(1 for f in response.findings if f.severity.value == "BLOCKING")
408
+ suggestion = sum(1 for f in response.findings if f.severity.value == "SUGGESTION")
409
+ round_label = (
410
+ f"{response.review_round} (Lite Mode)" if response.lite_mode else str(response.review_round)
411
+ )
412
+ lines = [
413
+ "=" * 60,
414
+ f"Round: {round_label}",
415
+ f"Verdict: {response.verdict.value}",
416
+ f"Risk: {response.risk_level.value}",
417
+ f"Findings: {blocking} blocking, {suggestion} suggestions",
418
+ f"Cost: ${response.usage.cost_usd:.2f}",
419
+ f"Elapsed: {elapsed:.0f}s",
420
+ "=" * 60,
421
+ ]
422
+ return "\n".join(lines)
423
+
424
+
425
+ def render_review_output(response: "ReviewResponse", elapsed: float) -> str:
426
+ """Render the full stdout block: review comment + summary.
427
+
428
+ Byte-identical to the pre-refactor sequence of ``print()`` calls (see the
429
+ output-contract freeze note above); kept as a pure function so it can be
430
+ golden-file tested without driving the whole pipeline.
431
+ """
432
+ return "\n\n" + response.review_comment + "\n\n" + render_summary_block(response, elapsed)
433
+
434
+
435
+ def _post_review(repo: str, args: argparse.Namespace, response: "ReviewResponse") -> None:
436
+ """Handle ``--post`` / ``--commit-status``: upsert PR comment and/or commit status."""
437
+ from argus.github_client import GitHubClient, GitHubClientError
438
+
439
+ try:
440
+ client = GitHubClient.for_writes()
441
+ except GitHubClientError as exc:
442
+ logger.error("Cannot post to GitHub: %s", exc)
443
+ sys.exit(1)
444
+
445
+ if args.post:
446
+ if not args.pr:
447
+ logger.error("--post requires --pr; nothing to comment on")
448
+ sys.exit(1)
449
+ try:
450
+ client.upsert_pr_comment(repo, args.pr, response.review_comment)
451
+ except Exception as exc: # noqa: BLE001 — surface any GitHub API error clearly
452
+ logger.error("Failed to post PR comment: %s", exc)
453
+ sys.exit(1)
454
+
455
+ if args.commit_status:
456
+ sha = args.sha
457
+ if not sha and args.pr:
458
+ pr = client.get_pull_request(repo, args.pr)
459
+ sha = pr["head_sha"]
460
+ if not sha:
461
+ logger.error("--commit-status could not resolve a head SHA (need --pr or --sha)")
462
+ sys.exit(1)
463
+ state = "success" if response.verdict.value == "APPROVE" else "failure"
464
+ description = f"Argus: {response.verdict.value} ({response.risk_level.value} risk)"
465
+ try:
466
+ client.set_commit_status(repo, sha, state, description)
467
+ except Exception as exc: # noqa: BLE001 — surface any GitHub API error clearly
468
+ logger.error("Failed to set commit status: %s", exc)
469
+ sys.exit(1)
470
+
471
+
472
+ def _run_review(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
473
+ repo = _resolve_repo(parser, args)
474
+
475
+ http_mode = bool(args.storage_read_url and args.storage_write_url)
476
+ if bool(args.storage_read_url) != bool(args.storage_write_url):
477
+ sys.exit(
478
+ "--storage-read-url and --storage-write-url must be set together "
479
+ "(or neither; storage then uses ARGUS_DB_URL if set, else local SQLite)"
480
+ )
481
+ if http_mode:
482
+ from argus.storage.http import install_http_storage
483
+
484
+ install_http_storage(
485
+ read_url=args.storage_read_url,
486
+ write_url=args.storage_write_url,
487
+ auth=args.storage_auth,
488
+ )
489
+
490
+ if not args.pr and not args.sha:
491
+ sys.exit("Must provide either --pr or --sha")
492
+ if args.pr and args.sha:
493
+ sys.exit("Provide either --pr or --sha, not both")
494
+
495
+ if args.no_prompt_overrides:
496
+ os.environ["ARGUS_NO_PROMPT_OVERRIDES"] = "1"
497
+
498
+ _check_prerequisites()
499
+
500
+ try:
501
+ settings = _load_settings()
502
+ except Exception as exc: # pydantic ValidationError for missing required vars
503
+ logger.error("Failed to load settings: %s", exc)
504
+ sys.exit(1)
505
+ _check_settings(settings)
506
+
507
+ from argus.models import ReviewRequest
508
+
509
+ request = ReviewRequest(
510
+ repo=repo,
511
+ pr_number=args.pr,
512
+ sha=args.sha,
513
+ base_ref=args.base_ref,
514
+ dismissals=args.dismiss,
515
+ )
516
+
517
+ target = f"SHA {args.sha[:12]}" if args.sha else f"PR #{args.pr}"
518
+ logger.info("Starting local review: %s %s", repo, target)
519
+
520
+ _start_watchdog(repo, args.pr)
521
+
522
+ start = time.monotonic()
523
+ response = asyncio.run(run(request))
524
+
525
+ elapsed = time.monotonic() - start
526
+
527
+ print(render_review_output(response, elapsed))
528
+
529
+ # Write to file if requested
530
+ if args.output:
531
+ Path(args.output).write_text(response.review_comment, encoding="utf-8")
532
+ json_path = str(Path(args.output).with_suffix(".json"))
533
+ Path(json_path).write_text(
534
+ json.dumps(response.model_dump(), indent=2, default=str),
535
+ encoding="utf-8",
536
+ )
537
+ logger.info("Written to %s and %s", args.output, json_path)
538
+
539
+ if args.post or args.commit_status:
540
+ _post_review(repo, args, response)
541
+
542
+
543
+ def _packaged_prompts_dir() -> Path:
544
+ """Directory containing the packaged (verbatim) prompt ``.md`` files."""
545
+ import importlib.resources
546
+
547
+ return Path(str(importlib.resources.files("argus").joinpath("prompts")))
548
+
549
+
550
+ def _packaged_prompt_names() -> list[str]:
551
+ return sorted(p.stem for p in _packaged_prompts_dir().glob("*.md"))
552
+
553
+
554
+ # The exact case-insensitive string set pydantic-settings v2 accepts for a
555
+ # ``bool`` field (verified against ``ARGUS_NO_PROMPT_OVERRIDES: bool`` in
556
+ # argus.config.Settings). Kept in sync here so `argus prompts list`/`export`
557
+ # (deliberately Settings-free, see below) treats the env var identically to
558
+ # `argus review`, which resolves it through Settings.
559
+ _PYDANTIC_TRUTHY_STRINGS = frozenset({"1", "true", "t", "yes", "y", "on"})
560
+
561
+
562
+ def _is_truthy_env_value(value: str) -> bool:
563
+ return value.strip().lower() in _PYDANTIC_TRUTHY_STRINGS
564
+
565
+
566
+ def _prompts_list_override_dirs() -> list[Path]:
567
+ """Same three-directory search chain as
568
+ ``argus.prompts_runtime.override_dirs``, computed directly from
569
+ ``os.environ`` rather than ``argus.config.Settings``.
570
+
571
+ ``argus prompts list``/``export`` are deliberately implemented without
572
+ going through ``Settings`` (see ``tests/test_cli_prompts.py``'s module
573
+ docstring) so they work before the three required API keys are set —
574
+ a first-time user should be able to inspect the packaged prompts
575
+ without any credentials configured yet.
576
+ """
577
+ if _is_truthy_env_value(os.environ.get("ARGUS_NO_PROMPT_OVERRIDES", "")):
578
+ return []
579
+
580
+ dirs: list[Path] = []
581
+ explicit = os.environ.get("ARGUS_PROMPTS_DIR")
582
+ if explicit:
583
+ dirs.append(Path(explicit))
584
+
585
+ dirs.append(Path.cwd() / ".argus" / "prompts")
586
+
587
+ xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
588
+ config_home = Path(xdg_config_home) if xdg_config_home else Path.home() / ".config"
589
+ dirs.append(config_home / "argus" / "prompts")
590
+
591
+ return dirs
592
+
593
+
594
+ def _cmd_prompts_list() -> None:
595
+ """``argus prompts list``: print each packaged prompt name and its source."""
596
+ override_dirs = _prompts_list_override_dirs()
597
+ for name in _packaged_prompt_names():
598
+ source = "packaged"
599
+ for override_dir in override_dirs:
600
+ candidate = override_dir / f"{name}.md"
601
+ if candidate.is_file():
602
+ source = f"override ({candidate})"
603
+ break
604
+ print(f"{name}\t{source}")
605
+
606
+
607
+ def _cmd_prompts_export(target_dir: str, force: bool) -> None:
608
+ """``argus prompts export <dir>``: copy packaged prompt files into ``target_dir``."""
609
+ import shutil as shutil_mod
610
+
611
+ target = Path(target_dir)
612
+ target.mkdir(parents=True, exist_ok=True)
613
+ if any(target.iterdir()) and not force:
614
+ sys.exit(
615
+ f"{target} is not empty. Use --force to export anyway "
616
+ "(existing files with matching names will be overwritten)."
617
+ )
618
+
619
+ packaged = _packaged_prompts_dir()
620
+ count = 0
621
+ for src in sorted(packaged.glob("*.md")):
622
+ shutil_mod.copyfile(src, target / src.name)
623
+ count += 1
624
+ logger.info("Exported %d prompt files to %s", count, target)
625
+ print(f"Exported {count} prompt files to {target}")
626
+
627
+
628
+ def _run_prompts(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
629
+ if args.prompts_command == "list":
630
+ _cmd_prompts_list()
631
+ elif args.prompts_command == "export":
632
+ _cmd_prompts_export(args.dir, args.force)
633
+ else:
634
+ parser.error("Usage: argus prompts {list,export}")
635
+
636
+
637
+ def main() -> None:
638
+ # Load .env BEFORE building the argparse parser so the
639
+ # ``default=os.environ.get(...)`` lookups for ARGUS_STORAGE_* below
640
+ # see values supplied via .env, not just the shell env. The full
641
+ # settings load still happens inside ``_load_settings`` after args
642
+ # are parsed — this is just to make .env-only env vars visible to
643
+ # argparse defaults.
644
+ from argus.dotenv_utils import load_dotenv_early
645
+
646
+ load_dotenv_early(start=Path.cwd(), env_filename=".env")
647
+
648
+ parser = _build_parser()
649
+
650
+ # Accept both the "argus review ..." subcommand form and the flat
651
+ # legacy form ("argus --repo ..." / "python -m argus.cli --repo ...")
652
+ # without a subcommand — but leave a bare "-h"/"--help"/"--version"
653
+ # (and the "prompts" subcommand) alone so they route correctly.
654
+ argv = sys.argv[1:]
655
+ if argv and argv[0] not in ("review", "prompts", "-h", "--help", "--version"):
656
+ argv = ["review", *argv]
657
+
658
+ args = parser.parse_args(argv)
659
+
660
+ if args.command == "prompts":
661
+ _run_prompts(parser, args)
662
+ return
663
+
664
+ if args.command != "review":
665
+ parser.print_help()
666
+ sys.exit(1)
667
+
668
+ _validate_review_args(parser, args)
669
+ _run_review(parser, args)
670
+
671
+
672
+ if __name__ == "__main__":
673
+ main()