arga-cli 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.
arga_cli/main.py ADDED
@@ -0,0 +1,1102 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import socket
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import time
11
+ import webbrowser
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from urllib.parse import urlencode
16
+
17
+ import httpx
18
+
19
+ from arga_cli.mcp import install_mcp_configuration
20
+
21
+ DEFAULT_API_URL = os.environ.get("ARGA_API_URL", "https://api.argalabs.com")
22
+ CONFIG_PATH = Path.home() / ".config" / "arga" / "config.json"
23
+ POLL_INTERVAL_SECONDS = 2.0
24
+ POLL_TIMEOUT_SECONDS = 600.0
25
+ SKIP_TRAILER = "[skip arga]"
26
+
27
+
28
+ class CliError(Exception):
29
+ """Base CLI error."""
30
+
31
+
32
+ class NotAuthenticatedError(CliError):
33
+ """Raised when no local API key is available."""
34
+
35
+
36
+ class ApiClient:
37
+ def __init__(self, api_url: str, api_key: str | None = None) -> None:
38
+ self._api_url = api_url.rstrip("/")
39
+ self._api_key = api_key
40
+ self._client = httpx.Client(timeout=10.0)
41
+
42
+ def close(self) -> None:
43
+ self._client.close()
44
+
45
+ def start_device_authorization(self, device_name: str | None = None) -> dict[str, str]:
46
+ payload = {"device_name": device_name} if device_name else {}
47
+ response = self._client.post(f"{self._api_url}/auth/device/start", json=payload)
48
+ return self._parse_json(response, "Failed to start device authorization")
49
+
50
+ def poll_device_authorization(self, device_code: str) -> dict[str, str]:
51
+ response = self._client.post(
52
+ f"{self._api_url}/auth/device/poll",
53
+ json={"device_code": device_code},
54
+ )
55
+ return self._parse_json(response, "Failed to poll device authorization")
56
+
57
+ def get_me(self) -> dict[str, str]:
58
+ response = self._client.get(
59
+ f"{self._api_url}/auth/me",
60
+ headers=self._auth_headers(),
61
+ )
62
+ return self._parse_json(response, "Failed to load current user")
63
+
64
+ def revoke_cli_device(self, cli_api_key_id: str) -> dict[str, str]:
65
+ response = self._client.post(
66
+ f"{self._api_url}/auth/cli/devices/{cli_api_key_id}/revoke",
67
+ headers=self._auth_headers(),
68
+ )
69
+ return self._parse_json(response, "Failed to revoke CLI device")
70
+
71
+ def start_url_validation(
72
+ self,
73
+ *,
74
+ url: str,
75
+ prompt: str,
76
+ email: str | None = None,
77
+ password: str | None = None,
78
+ ) -> dict[str, str]:
79
+ payload: dict[str, object] = {
80
+ "url": url,
81
+ "prompt": prompt,
82
+ }
83
+ if email or password:
84
+ payload["credentials"] = {
85
+ "email": email or "",
86
+ "password": password or "",
87
+ }
88
+ response = self._client.post(
89
+ f"{self._api_url}/validate/url",
90
+ json=payload,
91
+ headers=self._auth_headers(),
92
+ )
93
+ return self._parse_json(response, "Failed to start URL validation")
94
+
95
+ def start_pr_validation(
96
+ self,
97
+ *,
98
+ repo: str,
99
+ pr_number: int,
100
+ ) -> dict[str, str]:
101
+ response = self._client.post(
102
+ f"{self._api_url}/validation/pr",
103
+ json={"repo": repo, "pr_number": pr_number},
104
+ headers=self._auth_headers(),
105
+ )
106
+ return self._parse_json(response, "Failed to start PR validation")
107
+
108
+ def start_redteam_scan(self, *, url: str, action_budget: int) -> dict[str, Any]:
109
+ response = self._client.post(
110
+ f"{self._api_url}/redteam/start",
111
+ json={"url": url, "action_budget": action_budget},
112
+ headers=self._auth_headers(),
113
+ )
114
+ return self._parse_json(response, "Failed to start app scan")
115
+
116
+ def approve_redteam_scan(self, run_id: str) -> dict[str, Any]:
117
+ response = self._client.post(
118
+ f"{self._api_url}/redteam/{run_id}/approve",
119
+ json={},
120
+ headers=self._auth_headers(),
121
+ )
122
+ return self._parse_json(response, "Failed to approve app scan")
123
+
124
+ def get_run(self, run_id: str) -> dict[str, Any]:
125
+ response = self._client.get(
126
+ f"{self._api_url}/runs/{run_id}",
127
+ headers=self._auth_headers(),
128
+ )
129
+ return self._parse_json(response, "Failed to load run details")
130
+
131
+ def get_redteam_report(self, run_id: str) -> dict[str, Any]:
132
+ response = self._client.get(
133
+ f"{self._api_url}/redteam/{run_id}/report",
134
+ headers=self._auth_headers(),
135
+ )
136
+ return self._parse_json(response, "Failed to load app scan report")
137
+
138
+ def list_pr_validation_runs(
139
+ self,
140
+ *,
141
+ repo: str | None = None,
142
+ limit: int = 20,
143
+ offset: int = 0,
144
+ ) -> dict[str, Any]:
145
+ params: dict[str, object] = {"limit": limit, "offset": offset}
146
+ if repo:
147
+ params["repo"] = repo
148
+ response = self._client.get(
149
+ f"{self._api_url}/validation/runs",
150
+ params=params,
151
+ headers=self._auth_headers(),
152
+ )
153
+ return self._parse_json(response, "Failed to load validation runs")
154
+
155
+ def cancel_validation_run(self, run_id: str) -> dict[str, Any]:
156
+ response = self._client.post(
157
+ f"{self._api_url}/validate/{run_id}/cancel",
158
+ headers=self._auth_headers(),
159
+ )
160
+ return self._parse_json(response, "Failed to cancel validation run")
161
+
162
+ def install_github_validation(self, *, repo: str) -> dict[str, Any]:
163
+ response = self._client.post(
164
+ f"{self._api_url}/validation/github/install",
165
+ json={"repo": repo},
166
+ headers=self._auth_headers(),
167
+ )
168
+ return self._parse_json(response, "Failed to install validation webhook")
169
+
170
+ def get_github_validation_config(self, *, repo: str) -> dict[str, Any]:
171
+ response = self._client.get(
172
+ f"{self._api_url}/validation/github/config",
173
+ params={"repo": repo},
174
+ headers=self._auth_headers(),
175
+ )
176
+ return self._parse_json(response, "Failed to load validation config")
177
+
178
+ def save_github_validation_config(
179
+ self,
180
+ *,
181
+ repo: str,
182
+ trigger_mode: str,
183
+ branch: str | None,
184
+ comment_on_pr: bool,
185
+ ) -> dict[str, Any]:
186
+ payload: dict[str, Any] = {
187
+ "repo": repo,
188
+ "trigger_mode": trigger_mode,
189
+ "comment_on_pr": comment_on_pr,
190
+ }
191
+ if branch is not None:
192
+ payload["branch"] = branch
193
+ response = self._client.post(
194
+ f"{self._api_url}/validation/github/config",
195
+ json=payload,
196
+ headers=self._auth_headers(),
197
+ )
198
+ return self._parse_json(response, "Failed to save validation config")
199
+
200
+ def _auth_headers(self) -> dict[str, str]:
201
+ if not self._api_key:
202
+ raise NotAuthenticatedError("Error: Not authenticated. Run `arga login`.")
203
+ return {"Authorization": f"Bearer {self._api_key}"}
204
+
205
+ @staticmethod
206
+ def _parse_json(response: httpx.Response, fallback: str) -> dict[str, str]:
207
+ try:
208
+ payload = response.json()
209
+ except ValueError as exc:
210
+ raise CliError(fallback) from exc
211
+
212
+ if response.is_success:
213
+ return payload
214
+
215
+ detail = payload.get("detail") if isinstance(payload, dict) else None
216
+ if response.status_code == 401:
217
+ raise NotAuthenticatedError("Error: Not authenticated. Run `arga login`.")
218
+ raise CliError(str(detail or fallback))
219
+
220
+
221
+ def load_config() -> dict[str, str]:
222
+ try:
223
+ data = json.loads(CONFIG_PATH.read_text())
224
+ except FileNotFoundError as exc:
225
+ raise NotAuthenticatedError("Error: Not authenticated. Run `arga login`.") from exc
226
+ except json.JSONDecodeError as exc:
227
+ raise CliError(f"Invalid config file: {CONFIG_PATH}") from exc
228
+
229
+ if not isinstance(data, dict):
230
+ raise CliError(f"Invalid config file: {CONFIG_PATH}")
231
+ return {str(key): str(value) for key, value in data.items() if isinstance(value, str)}
232
+
233
+
234
+ def load_api_key() -> str:
235
+ data = load_config()
236
+ api_key = data.get("api_key")
237
+ if not api_key:
238
+ raise NotAuthenticatedError("Error: Not authenticated. Run `arga login`.")
239
+ return api_key
240
+
241
+
242
+ def save_config(config: dict[str, str]) -> None:
243
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
244
+ CONFIG_PATH.write_text(json.dumps(config, indent=2) + "\n")
245
+
246
+
247
+ def delete_api_key() -> bool:
248
+ if not CONFIG_PATH.exists():
249
+ return False
250
+ CONFIG_PATH.unlink()
251
+ return True
252
+
253
+
254
+ def build_verification_url(start_payload: dict[str, str]) -> str:
255
+ verification_url = start_payload["verification_url"]
256
+ device_code = start_payload["device_code"]
257
+ return f"{verification_url}?{urlencode({'device_code': device_code})}"
258
+
259
+
260
+ def run_login(args: argparse.Namespace) -> int:
261
+ client = ApiClient(args.api_url)
262
+ try:
263
+ device_name = socket.gethostname()
264
+ start_payload = client.start_device_authorization(device_name=device_name)
265
+ verification_url = build_verification_url(start_payload)
266
+
267
+ print("Opening browser for authentication...\n")
268
+ print("If it does not open automatically, visit:\n")
269
+ print(verification_url)
270
+
271
+ try:
272
+ webbrowser.open(verification_url)
273
+ except webbrowser.Error:
274
+ pass
275
+
276
+ deadline = time.monotonic() + POLL_TIMEOUT_SECONDS
277
+ while time.monotonic() < deadline:
278
+ payload = client.poll_device_authorization(start_payload["device_code"])
279
+ api_key = payload.get("api_key")
280
+ cli_api_key_id = payload.get("cli_api_key_id")
281
+ if api_key and cli_api_key_id:
282
+ save_config(
283
+ {
284
+ "api_key": api_key,
285
+ "cli_api_key_id": cli_api_key_id,
286
+ "device_name": payload.get("device_name", device_name),
287
+ }
288
+ )
289
+ print("\nAuthentication complete.")
290
+ return 0
291
+ time.sleep(POLL_INTERVAL_SECONDS)
292
+
293
+ raise CliError("Timed out waiting for authentication approval.")
294
+ finally:
295
+ client.close()
296
+
297
+
298
+ def run_logout(args: argparse.Namespace) -> int:
299
+ config: dict[str, str] | None = None
300
+ try:
301
+ config = load_config()
302
+ except (NotAuthenticatedError, CliError):
303
+ config = None
304
+
305
+ if config and config.get("api_key") and config.get("cli_api_key_id"):
306
+ client = ApiClient(args.api_url, api_key=config["api_key"])
307
+ try:
308
+ client.revoke_cli_device(config["cli_api_key_id"])
309
+ except (CliError, httpx.HTTPError):
310
+ # Always remove the local credential even if server-side revoke fails.
311
+ pass
312
+ finally:
313
+ client.close()
314
+
315
+ removed = delete_api_key()
316
+ if removed:
317
+ print("Logged out.")
318
+ else:
319
+ print("No saved login found.")
320
+ return 0
321
+
322
+
323
+ def run_whoami(args: argparse.Namespace) -> int:
324
+ api_key = load_api_key()
325
+ client = ApiClient(args.api_url, api_key=api_key)
326
+ try:
327
+ payload = client.get_me()
328
+ finally:
329
+ client.close()
330
+
331
+ print(f"Logged in as: {payload.get('github_login', 'unknown')}")
332
+ print(f"Workspace: {payload.get('workspace', 'Unknown')}")
333
+ return 0
334
+
335
+
336
+ def run_test_url(args: argparse.Namespace) -> int:
337
+ if bool(args.email) != bool(args.password):
338
+ raise CliError("Both --email and --password must be provided together.")
339
+
340
+ api_key = load_api_key()
341
+ client = ApiClient(args.api_url, api_key=api_key)
342
+ try:
343
+ payload = client.start_url_validation(
344
+ url=args.url,
345
+ prompt=args.prompt,
346
+ email=args.email,
347
+ password=args.password,
348
+ )
349
+ finally:
350
+ client.close()
351
+
352
+ print("Starting validation...\n")
353
+ print(f"URL: {args.url}")
354
+ print(f"Prompt: {args.prompt}\n")
355
+ print(f"Run ID: {payload.get('run_id', 'unknown')}")
356
+ print(f"Status: {payload.get('status', 'unknown')}")
357
+ return 0
358
+
359
+
360
+ def run_validate_pr(args: argparse.Namespace) -> int:
361
+ api_key = load_api_key()
362
+ client = ApiClient(args.api_url, api_key=api_key)
363
+ try:
364
+ payload = client.start_pr_validation(repo=args.repo, pr_number=args.pr)
365
+ finally:
366
+ client.close()
367
+
368
+ print("Starting PR validation...\n")
369
+ print(f"Repository: {args.repo}")
370
+ print(f"PR: #{args.pr}\n")
371
+ print("Validation run started.")
372
+ print(f"Run ID: {payload.get('run_id', 'unknown')}")
373
+ print(f"Status: {payload.get('status', 'unknown')}")
374
+ return 0
375
+
376
+
377
+ def _validate_help_text() -> str:
378
+ return (
379
+ "usage: arga validate pr --repo <owner/repo> --pr <number>\n"
380
+ " arga validate url --url <url> --prompt <prompt>\n"
381
+ " arga validate install <repo>\n"
382
+ " arga validate config <repo>\n"
383
+ " arga validate config set <repo> [--trigger pr|branch] [--branch <name>] [--comments on|off]\n\n"
384
+ "Start validations or manage automatic validation settings."
385
+ )
386
+
387
+
388
+ def _build_validate_pr_parser() -> argparse.ArgumentParser:
389
+ parser = argparse.ArgumentParser(
390
+ prog="arga validate pr",
391
+ description="Run PR validation.",
392
+ allow_abbrev=False,
393
+ )
394
+ parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
395
+ parser.add_argument("--repo", required=True, help="Repository in owner/repo format")
396
+ parser.add_argument("--pr", required=True, type=int, help="Pull request number")
397
+ return parser
398
+
399
+
400
+ def _build_validate_url_parser() -> argparse.ArgumentParser:
401
+ parser = argparse.ArgumentParser(
402
+ prog="arga validate url",
403
+ description="Run a browser validation against a deployed URL.",
404
+ allow_abbrev=False,
405
+ )
406
+ parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
407
+ parser.add_argument("--url", required=True, help="Deployed application URL")
408
+ parser.add_argument("--prompt", required=True, help="Natural language instructions for the agent")
409
+ parser.add_argument("--email", help="Optional login email")
410
+ parser.add_argument("--password", help="Optional login password")
411
+ return parser
412
+
413
+
414
+ def _build_validate_install_parser() -> argparse.ArgumentParser:
415
+ parser = argparse.ArgumentParser(
416
+ prog="arga validate install",
417
+ description="Install the GitHub webhook for automatic validation.",
418
+ allow_abbrev=False,
419
+ )
420
+ parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
421
+ parser.add_argument("repo", help="Repository in owner/repo format")
422
+ return parser
423
+
424
+
425
+ def _build_validate_config_parser() -> argparse.ArgumentParser:
426
+ parser = argparse.ArgumentParser(
427
+ prog="arga validate config",
428
+ description="Show validation config for a repository.",
429
+ allow_abbrev=False,
430
+ )
431
+ parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
432
+ parser.add_argument("repo", help="Repository in owner/repo format")
433
+ return parser
434
+
435
+
436
+ def _build_validate_config_set_parser() -> argparse.ArgumentParser:
437
+ parser = argparse.ArgumentParser(
438
+ prog="arga validate config set",
439
+ description="Save validation config for a repository.",
440
+ allow_abbrev=False,
441
+ )
442
+ parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
443
+ parser.add_argument("repo", help="Repository in owner/repo format")
444
+ parser.add_argument("--trigger", choices=("pr", "branch"), help="Validation trigger mode")
445
+ parser.add_argument("--branch", help="Branch to monitor when using branch trigger mode")
446
+ parser.add_argument("--comments", choices=("on", "off"), help="Whether PR comments are enabled")
447
+ return parser
448
+
449
+
450
+ def _bool_label(value: bool) -> str:
451
+ return "yes" if value else "no"
452
+
453
+
454
+ def _comments_label(value: bool) -> str:
455
+ return "on" if value else "off"
456
+
457
+
458
+ def _print_validation_config(payload: dict[str, Any]) -> None:
459
+ print(f"Repository: {payload.get('repo', '-')}")
460
+ print(f"Installed: {_bool_label(bool(payload.get('installed')))}")
461
+ print(f"Enabled: {_bool_label(bool(payload.get('enabled')))}")
462
+ print(f"Installation ID: {payload.get('installation_id') or '-'}")
463
+ print(f"Trigger Mode: {payload.get('trigger_mode') or '-'}")
464
+ print(f"Branch: {payload.get('branch') or '-'}")
465
+ print(f"Default Branch: {payload.get('default_branch') or '-'}")
466
+ print(f"PR Comments: {_comments_label(bool(payload.get('comment_on_pr', True)))}")
467
+
468
+
469
+ def run_validate_install(args: argparse.Namespace) -> int:
470
+ api_key = load_api_key()
471
+ client = ApiClient(args.api_url, api_key=api_key)
472
+ try:
473
+ payload = client.install_github_validation(repo=args.repo)
474
+ finally:
475
+ client.close()
476
+
477
+ print("Installed validation webhook.\n")
478
+ print(f"Repository: {payload.get('repo', args.repo)}")
479
+ print(f"Webhook ID: {payload.get('webhook_id', '-')}")
480
+ print(f"Enabled: {_bool_label(bool(payload.get('enabled')))}")
481
+ return 0
482
+
483
+
484
+ def run_validate_config(args: argparse.Namespace) -> int:
485
+ api_key = load_api_key()
486
+ client = ApiClient(args.api_url, api_key=api_key)
487
+ try:
488
+ payload = client.get_github_validation_config(repo=args.repo)
489
+ finally:
490
+ client.close()
491
+
492
+ _print_validation_config(payload)
493
+ return 0
494
+
495
+
496
+ def run_validate_config_set(args: argparse.Namespace) -> int:
497
+ api_key = load_api_key()
498
+ client = ApiClient(args.api_url, api_key=api_key)
499
+ try:
500
+ current = client.get_github_validation_config(repo=args.repo)
501
+ trigger_mode = args.trigger or str(current.get("trigger_mode") or "pr")
502
+ comment_on_pr = (
503
+ current.get("comment_on_pr", True)
504
+ if args.comments is None
505
+ else args.comments == "on"
506
+ )
507
+ branch: str | None = None
508
+ if trigger_mode == "branch":
509
+ branch = args.branch or str(current.get("branch") or current.get("default_branch") or "").strip() or None
510
+ payload = client.save_github_validation_config(
511
+ repo=args.repo,
512
+ trigger_mode=trigger_mode,
513
+ branch=branch if trigger_mode == "branch" else None,
514
+ comment_on_pr=bool(comment_on_pr),
515
+ )
516
+ finally:
517
+ client.close()
518
+
519
+ print("Saved validation config.\n")
520
+ _print_validation_config(payload)
521
+ return 0
522
+
523
+
524
+ def run_mcp_install(args: argparse.Namespace) -> int:
525
+ api_key = load_api_key()
526
+ installed, failures = install_mcp_configuration(
527
+ api_url=args.api_url,
528
+ api_key=api_key,
529
+ )
530
+ if installed == 0 and failures == 0:
531
+ print("\nInstall Arga MCP manually by adding the generated config to your IDE.")
532
+ return 1
533
+ return 1 if failures else 0
534
+
535
+
536
+ def _scan_help_text() -> str:
537
+ return (
538
+ "usage: arga scan <url> [--budget 200]\n"
539
+ " arga scan status <run_id>\n"
540
+ " arga scan report <run_id>\n\n"
541
+ "Start or inspect Arga app scans."
542
+ )
543
+
544
+
545
+ def _build_scan_start_parser() -> argparse.ArgumentParser:
546
+ parser = argparse.ArgumentParser(
547
+ prog="arga scan",
548
+ description="Start an Arga app scan.",
549
+ allow_abbrev=False,
550
+ )
551
+ parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
552
+ parser.add_argument("url", help="Public application URL to scan")
553
+ parser.add_argument("--budget", type=int, default=200, help="Total action budget for the scan")
554
+ return parser
555
+
556
+
557
+ def _build_scan_status_parser() -> argparse.ArgumentParser:
558
+ parser = argparse.ArgumentParser(
559
+ prog="arga scan status",
560
+ description="Check the status of an Arga app scan.",
561
+ allow_abbrev=False,
562
+ )
563
+ parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
564
+ parser.add_argument("run_id", help="App scan run ID")
565
+ return parser
566
+
567
+
568
+ def _build_scan_report_parser() -> argparse.ArgumentParser:
569
+ parser = argparse.ArgumentParser(
570
+ prog="arga scan report",
571
+ description="View the final report for an Arga app scan.",
572
+ allow_abbrev=False,
573
+ )
574
+ parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
575
+ parser.add_argument("run_id", help="App scan run ID")
576
+ return parser
577
+
578
+
579
+ def _status_from_run(run: dict[str, Any]) -> str:
580
+ return str(run.get("status") or "unknown")
581
+
582
+
583
+ def _wait_for_scan_approval(client: ApiClient, run_id: str) -> dict[str, Any]:
584
+ deadline = time.monotonic() + POLL_TIMEOUT_SECONDS
585
+ last_run: dict[str, Any] = {"id": run_id, "status": "planning"}
586
+ while time.monotonic() < deadline:
587
+ run = client.get_run(run_id)
588
+ last_run = run
589
+ status = _status_from_run(run)
590
+ if status in {"queued", "running", "completed", "failed", "cancelled"}:
591
+ return run
592
+
593
+ if status in {"planning", "awaiting_approval"}:
594
+ try:
595
+ approval = client.approve_redteam_scan(run_id)
596
+ run["status"] = approval.get("status", run.get("status"))
597
+ return run
598
+ except CliError as exc:
599
+ if str(exc) != "Scan plan is not ready yet":
600
+ raise
601
+
602
+ time.sleep(POLL_INTERVAL_SECONDS)
603
+
604
+ raise CliError(
605
+ f"Timed out waiting for the scan plan to be ready for run {last_run.get('id', run_id)}."
606
+ )
607
+
608
+
609
+ def _print_scan_summary(run_id: str, run: dict[str, Any]) -> None:
610
+ report = run.get("redteam_report_json")
611
+ anomaly_count = (
612
+ len(report.get("anomalies") or []) if isinstance(report, dict) else 0
613
+ )
614
+ print(f"Run ID: {run_id}")
615
+ print(f"Status: {_status_from_run(run)}")
616
+ print(f"URL: {run.get('frontend_url') or run.get('pr_url') or 'unknown'}")
617
+ print(f"Mode: {run.get('mode') or 'unknown'}")
618
+ print(f"Anomalies: {anomaly_count}")
619
+
620
+
621
+ def run_scan_start(args: argparse.Namespace) -> int:
622
+ if args.budget <= 0:
623
+ raise CliError("Budget must be a positive integer.")
624
+
625
+ api_key = load_api_key()
626
+ client = ApiClient(args.api_url, api_key=api_key)
627
+ try:
628
+ payload = client.start_redteam_scan(url=args.url, action_budget=args.budget)
629
+ run_id = str(payload.get("run_id") or "")
630
+ if not run_id:
631
+ raise CliError("App scan started but no run ID was returned.")
632
+ run = _wait_for_scan_approval(client, run_id)
633
+ finally:
634
+ client.close()
635
+
636
+ print("Starting app scan...\n")
637
+ print(f"URL: {args.url}")
638
+ print(f"Budget: {args.budget}")
639
+ print(f"Run ID: {run_id}")
640
+ print(f"Status: {_status_from_run(run)}")
641
+ return 0
642
+
643
+
644
+ def run_scan_status(args: argparse.Namespace) -> int:
645
+ api_key = load_api_key()
646
+ client = ApiClient(args.api_url, api_key=api_key)
647
+ try:
648
+ run = client.get_run(args.run_id)
649
+ finally:
650
+ client.close()
651
+
652
+ _print_scan_summary(args.run_id, run)
653
+ return 0
654
+
655
+
656
+ def run_scan_report(args: argparse.Namespace) -> int:
657
+ api_key = load_api_key()
658
+ client = ApiClient(args.api_url, api_key=api_key)
659
+ try:
660
+ report = client.get_redteam_report(args.run_id)
661
+ finally:
662
+ client.close()
663
+
664
+ if not report:
665
+ raise CliError("Scan report is not ready yet.")
666
+
667
+ print(json.dumps(report, indent=2))
668
+ return 0
669
+
670
+
671
+ def run_scan_cli(argv: list[str]) -> int:
672
+ if not argv or argv[0] in {"-h", "--help"}:
673
+ print(_scan_help_text())
674
+ return 0
675
+
676
+ if argv[0] == "status":
677
+ return run_scan_status(_build_scan_status_parser().parse_args(argv[1:]))
678
+ if argv[0] == "report":
679
+ return run_scan_report(_build_scan_report_parser().parse_args(argv[1:]))
680
+ return run_scan_start(_build_scan_start_parser().parse_args(argv))
681
+
682
+
683
+ def _format_timestamp(value: str | None) -> str:
684
+ if not value:
685
+ return "-"
686
+ normalized = value.replace("Z", "+00:00")
687
+ try:
688
+ parsed = datetime.fromisoformat(normalized)
689
+ except ValueError:
690
+ return value
691
+ return parsed.strftime("%Y-%m-%d %H:%M")
692
+
693
+
694
+ def _run_ref_label(run: dict[str, Any]) -> str:
695
+ pr_number = run.get("pr_number") or run.get("github_pr_number")
696
+ if pr_number is not None:
697
+ return f"PR #{pr_number}"
698
+ branch = run.get("branch") or run.get("git_branch")
699
+ if branch:
700
+ return str(branch)
701
+ return "-"
702
+
703
+
704
+ def _matches_runs_status_filter(run_status: str, requested_status: str | None) -> bool:
705
+ if requested_status is None:
706
+ return True
707
+ normalized = run_status.strip().lower()
708
+ if requested_status == "running":
709
+ return normalized not in {"completed", "failed", "cancelled"}
710
+ return normalized == requested_status
711
+
712
+
713
+ def _collect_runs_for_listing(
714
+ client: ApiClient,
715
+ *,
716
+ repo: str | None,
717
+ requested_status: str | None,
718
+ limit: int,
719
+ ) -> list[dict[str, Any]]:
720
+ collected: list[dict[str, Any]] = []
721
+ offset = 0
722
+ page_size = min(max(limit, 10), 100)
723
+
724
+ while len(collected) < limit:
725
+ page = client.list_pr_validation_runs(repo=repo, limit=page_size, offset=offset)
726
+ items = page.get("items")
727
+ if not isinstance(items, list) or not items:
728
+ break
729
+
730
+ for item in items:
731
+ if not isinstance(item, dict):
732
+ continue
733
+ status = str(item.get("status") or "")
734
+ if _matches_runs_status_filter(status, requested_status):
735
+ collected.append(item)
736
+ if len(collected) >= limit:
737
+ break
738
+
739
+ has_more = bool(page.get("has_more"))
740
+ if not has_more:
741
+ break
742
+ offset += int(page.get("limit") or page_size)
743
+
744
+ return collected[:limit]
745
+
746
+
747
+ def _print_runs_table(runs: list[dict[str, Any]]) -> None:
748
+ headers = ["RUN_ID", "STATUS", "REPO", "PR/BRANCH", "CREATED"]
749
+ rows = [
750
+ [
751
+ str(run.get("run_id") or "-"),
752
+ str(run.get("status") or "-"),
753
+ str(run.get("repo") or "-"),
754
+ _run_ref_label(run),
755
+ _format_timestamp(run.get("created_at")),
756
+ ]
757
+ for run in runs
758
+ ]
759
+ widths = [
760
+ max(len(headers[index]), max((len(row[index]) for row in rows), default=0))
761
+ for index in range(len(headers))
762
+ ]
763
+
764
+ def format_row(values: list[str]) -> str:
765
+ return " | ".join(value.ljust(widths[index]) for index, value in enumerate(values))
766
+
767
+ print(format_row(headers))
768
+ print(" | ".join("-" * width for width in widths))
769
+ for row in rows:
770
+ print(format_row(row))
771
+
772
+
773
+ def run_runs_list(args: argparse.Namespace) -> int:
774
+ api_key = load_api_key()
775
+ client = ApiClient(args.api_url, api_key=api_key)
776
+ try:
777
+ runs = _collect_runs_for_listing(
778
+ client,
779
+ repo=args.repo,
780
+ requested_status=args.status,
781
+ limit=args.limit,
782
+ )
783
+ finally:
784
+ client.close()
785
+
786
+ if not runs:
787
+ print("No matching validation runs found.")
788
+ return 0
789
+
790
+ _print_runs_table(runs)
791
+ return 0
792
+
793
+
794
+ def run_runs_status(args: argparse.Namespace) -> int:
795
+ api_key = load_api_key()
796
+ client = ApiClient(args.api_url, api_key=api_key)
797
+ try:
798
+ run = client.get_run(args.run_id)
799
+ finally:
800
+ client.close()
801
+
802
+ print(f"Run ID: {run.get('id', args.run_id)}")
803
+ print(f"Status: {run.get('status', 'unknown')}")
804
+ print(f"Type: {run.get('run_type', 'unknown')}")
805
+ print(f"Mode: {run.get('mode', 'unknown')}")
806
+ print(f"Repository: {run.get('repo_full_name') or '-'}")
807
+ print(f"PR/Branch: {_run_ref_label(run)}")
808
+ print(f"Commit: {run.get('commit_sha') or '-'}")
809
+ print(f"Created: {_format_timestamp(run.get('created_at'))}")
810
+ print(f"Environment URL: {run.get('environment_url') or '-'}")
811
+ print(f"Session ID: {run.get('session_id') or '-'}")
812
+ return 0
813
+
814
+
815
+ def run_runs_cancel(args: argparse.Namespace) -> int:
816
+ api_key = load_api_key()
817
+ client = ApiClient(args.api_url, api_key=api_key)
818
+ try:
819
+ payload = client.cancel_validation_run(args.run_id)
820
+ finally:
821
+ client.close()
822
+
823
+ print(f"Run ID: {args.run_id}")
824
+ print(f"Status: {payload.get('status', 'cancelled')}")
825
+ return 0
826
+
827
+
828
+ def run_validate_cli(argv: list[str]) -> int:
829
+ if not argv or argv[0] in {"-h", "--help"}:
830
+ print(_validate_help_text())
831
+ return 0
832
+
833
+ if argv[0] == "pr":
834
+ return run_validate_pr(_build_validate_pr_parser().parse_args(argv[1:]))
835
+ if argv[0] == "url":
836
+ return run_test_url(_build_validate_url_parser().parse_args(argv[1:]))
837
+ if argv[0] == "install":
838
+ return run_validate_install(_build_validate_install_parser().parse_args(argv[1:]))
839
+ if argv[0] == "config":
840
+ if len(argv) > 1 and argv[1] == "set":
841
+ return run_validate_config_set(_build_validate_config_set_parser().parse_args(argv[2:]))
842
+ return run_validate_config(_build_validate_config_parser().parse_args(argv[1:]))
843
+
844
+ raise CliError(f"Unknown validate subcommand: {argv[0]}")
845
+
846
+
847
+ def _parse_git_wrapper_args(command: str, argv: list[str]) -> tuple[argparse.Namespace, list[str]]:
848
+ parser = argparse.ArgumentParser(
849
+ prog=f"arga {command}",
850
+ description=f"Wrap `git {command}` with optional Arga-specific behavior.",
851
+ allow_abbrev=False,
852
+ )
853
+ parser.add_argument(
854
+ "--skip",
855
+ action="store_true",
856
+ help="Mark the head commit to skip Arga validation.",
857
+ )
858
+ return parser.parse_known_args(argv)
859
+
860
+
861
+ def _run_git_command(args: list[str], *, input_text: str | None = None) -> int:
862
+ try:
863
+ completed = subprocess.run(["git", *args], text=True, input=input_text, check=False)
864
+ except FileNotFoundError as exc:
865
+ raise CliError("Error: `git` is not installed or not available on PATH.") from exc
866
+ return int(completed.returncode)
867
+
868
+
869
+ def _get_head_commit_message() -> str:
870
+ try:
871
+ completed = subprocess.run(
872
+ ["git", "log", "-1", "--pretty=%B"],
873
+ capture_output=True,
874
+ text=True,
875
+ check=False,
876
+ )
877
+ except FileNotFoundError as exc:
878
+ raise CliError("Error: `git` is not installed or not available on PATH.") from exc
879
+
880
+ if completed.returncode != 0:
881
+ stderr = (completed.stderr or "").strip()
882
+ raise CliError(stderr or "Error: Failed to read the HEAD commit message.")
883
+ return completed.stdout
884
+
885
+
886
+ def _commit_args_contain_message_flag(git_args: list[str]) -> bool:
887
+ for arg in git_args:
888
+ if arg in {"-m", "--message"}:
889
+ return True
890
+ if arg.startswith("-m") and arg != "-m":
891
+ return True
892
+ if arg.startswith("--message="):
893
+ return True
894
+ return False
895
+
896
+
897
+ def _extract_commit_file_path(git_args: list[str]) -> str | None:
898
+ i = 0
899
+ while i < len(git_args):
900
+ arg = git_args[i]
901
+ if arg in {"-F", "--file"}:
902
+ if i + 1 >= len(git_args):
903
+ raise CliError("Error: Missing value for git commit message file.")
904
+ return git_args[i + 1]
905
+ if arg.startswith("-F") and arg != "-F":
906
+ return arg[2:]
907
+ if arg.startswith("--file="):
908
+ return arg.split("=", 1)[1]
909
+ i += 1
910
+ return None
911
+
912
+
913
+ def _build_skip_commit_args(git_args: list[str]) -> tuple[list[str], str | None, list[Path]]:
914
+ if _commit_args_contain_message_flag(git_args):
915
+ return [*git_args, "-m", SKIP_TRAILER], None, []
916
+
917
+ file_path = _extract_commit_file_path(git_args)
918
+ if file_path is None:
919
+ raise CliError(
920
+ "Error: `arga commit --skip` requires a commit message via `-m/--message` or `-F/--file`."
921
+ )
922
+
923
+ if file_path == "-":
924
+ stdin_message = sys.stdin.read()
925
+ stripped = stdin_message.rstrip()
926
+ trailer = SKIP_TRAILER if not stripped else f"{stripped}\n\n{SKIP_TRAILER}"
927
+ return git_args, trailer, []
928
+
929
+ source = Path(file_path)
930
+ message = source.read_text()
931
+ stripped = message.rstrip()
932
+ trailer = SKIP_TRAILER if not stripped else f"{stripped}\n\n{SKIP_TRAILER}"
933
+
934
+ temp_file = tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8")
935
+ try:
936
+ temp_file.write(trailer)
937
+ finally:
938
+ temp_file.close()
939
+
940
+ rewritten_args: list[str] = []
941
+ i = 0
942
+ while i < len(git_args):
943
+ arg = git_args[i]
944
+ if arg in {"-F", "--file"}:
945
+ rewritten_args.extend([arg, temp_file.name])
946
+ i += 2
947
+ continue
948
+ if arg.startswith("-F") and arg != "-F":
949
+ rewritten_args.append(f"-F{temp_file.name}")
950
+ i += 1
951
+ continue
952
+ if arg.startswith("--file="):
953
+ rewritten_args.append(f"--file={temp_file.name}")
954
+ i += 1
955
+ continue
956
+ rewritten_args.append(arg)
957
+ i += 1
958
+
959
+ return rewritten_args, None, [Path(temp_file.name)]
960
+
961
+
962
+ def run_commit_cli(argv: list[str]) -> int:
963
+ args, git_args = _parse_git_wrapper_args("commit", argv)
964
+ input_text: str | None = None
965
+ temp_paths: list[Path] = []
966
+ commit_args = git_args
967
+
968
+ if args.skip:
969
+ commit_args, input_text, temp_paths = _build_skip_commit_args(git_args)
970
+
971
+ try:
972
+ return _run_git_command(["commit", *commit_args], input_text=input_text)
973
+ finally:
974
+ for path in temp_paths:
975
+ path.unlink(missing_ok=True)
976
+
977
+
978
+ def run_push_cli(argv: list[str]) -> int:
979
+ args, git_args = _parse_git_wrapper_args("push", argv)
980
+
981
+ if args.skip:
982
+ commit_message = _get_head_commit_message()
983
+ if SKIP_TRAILER not in commit_message.lower():
984
+ raise CliError(
985
+ "Error: HEAD is not marked to skip Arga validation. Create the commit with `arga commit --skip` first."
986
+ )
987
+
988
+ return _run_git_command(["push", *git_args])
989
+
990
+
991
+ def build_parser() -> argparse.ArgumentParser:
992
+ parser = argparse.ArgumentParser(prog="arga")
993
+ subparsers = parser.add_subparsers(dest="command", required=True)
994
+
995
+ login_parser = subparsers.add_parser("login", help="Authenticate the CLI")
996
+ login_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
997
+ login_parser.set_defaults(func=run_login)
998
+
999
+ logout_parser = subparsers.add_parser("logout", help="Remove the saved API key")
1000
+ logout_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1001
+ logout_parser.set_defaults(func=run_logout)
1002
+
1003
+ whoami_parser = subparsers.add_parser("whoami", help="Show the authenticated user")
1004
+ whoami_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1005
+ whoami_parser.set_defaults(func=run_whoami)
1006
+
1007
+ test_parser = subparsers.add_parser("test", help="Start validation runs")
1008
+ test_subparsers = test_parser.add_subparsers(dest="test_command", required=True)
1009
+
1010
+ test_url_parser = test_subparsers.add_parser("url", help="Run a browser validation against a deployed URL")
1011
+ test_url_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1012
+ test_url_parser.add_argument("--url", required=True, help="Deployed application URL")
1013
+ test_url_parser.add_argument("--prompt", required=True, help="Natural language instructions for the agent")
1014
+ test_url_parser.add_argument("--email", help="Optional login email")
1015
+ test_url_parser.add_argument("--password", help="Optional login password")
1016
+ test_url_parser.set_defaults(func=run_test_url)
1017
+
1018
+ validate_parser = subparsers.add_parser("validate", help="Start PR or URL validation runs")
1019
+ validate_subparsers = validate_parser.add_subparsers(dest="validate_command", required=True)
1020
+
1021
+ validate_pr_parser = validate_subparsers.add_parser("pr", help="Run PR validation")
1022
+ validate_pr_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1023
+ validate_pr_parser.add_argument("--repo", required=True, help="Repository in owner/repo format")
1024
+ validate_pr_parser.add_argument("--pr", required=True, type=int, help="Pull request number")
1025
+ validate_pr_parser.set_defaults(func=run_validate_pr)
1026
+
1027
+ validate_url_parser = validate_subparsers.add_parser(
1028
+ "url",
1029
+ help="Run a browser validation against a deployed URL",
1030
+ )
1031
+ validate_url_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1032
+ validate_url_parser.add_argument("--url", required=True, help="Deployed application URL")
1033
+ validate_url_parser.add_argument("--prompt", required=True, help="Natural language instructions for the agent")
1034
+ validate_url_parser.add_argument("--email", help="Optional login email")
1035
+ validate_url_parser.add_argument("--password", help="Optional login password")
1036
+ validate_url_parser.set_defaults(func=run_test_url)
1037
+
1038
+ mcp_parser = subparsers.add_parser("mcp", help="Manage MCP integrations")
1039
+ mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_command", required=True)
1040
+
1041
+ mcp_install_parser = mcp_subparsers.add_parser(
1042
+ "install",
1043
+ help="Install Arga MCP config into supported IDE agents",
1044
+ )
1045
+ mcp_install_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1046
+ mcp_install_parser.set_defaults(func=run_mcp_install)
1047
+
1048
+ runs_parser = subparsers.add_parser("runs", help="List, inspect, or cancel validation runs")
1049
+ runs_subparsers = runs_parser.add_subparsers(dest="runs_command", required=True)
1050
+
1051
+ runs_list_parser = runs_subparsers.add_parser("list", help="List recent validation runs")
1052
+ runs_list_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1053
+ runs_list_parser.add_argument("--repo", help="Filter by repository in owner/repo format")
1054
+ runs_list_parser.add_argument(
1055
+ "--status",
1056
+ choices=("completed", "failed", "running"),
1057
+ help="Filter by validation status",
1058
+ )
1059
+ runs_list_parser.add_argument("--limit", type=int, default=20, help="Maximum number of runs to show")
1060
+ runs_list_parser.set_defaults(func=run_runs_list)
1061
+
1062
+ runs_status_parser = runs_subparsers.add_parser("status", help="Show detailed status for a validation run")
1063
+ runs_status_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1064
+ runs_status_parser.add_argument("run_id", help="Validation run ID")
1065
+ runs_status_parser.set_defaults(func=run_runs_status)
1066
+
1067
+ runs_cancel_parser = runs_subparsers.add_parser("cancel", help="Cancel a validation run")
1068
+ runs_cancel_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1069
+ runs_cancel_parser.add_argument("run_id", help="Validation run ID")
1070
+ runs_cancel_parser.set_defaults(func=run_runs_cancel)
1071
+
1072
+ subparsers.add_parser("commit", help="Wrap git commit and optionally mark it to skip Arga validation")
1073
+ subparsers.add_parser("push", help="Wrap git push and verify skip state when requested")
1074
+ subparsers.add_parser("scan", help="Start an app scan or inspect a scan run")
1075
+ return parser
1076
+
1077
+
1078
+ def main() -> None:
1079
+ try:
1080
+ if len(sys.argv) > 1 and sys.argv[1] == "commit":
1081
+ exit_code = run_commit_cli(sys.argv[2:])
1082
+ elif len(sys.argv) > 1 and sys.argv[1] == "push":
1083
+ exit_code = run_push_cli(sys.argv[2:])
1084
+ elif len(sys.argv) > 1 and sys.argv[1] == "validate":
1085
+ exit_code = run_validate_cli(sys.argv[2:])
1086
+ elif len(sys.argv) > 1 and sys.argv[1] == "scan":
1087
+ exit_code = run_scan_cli(sys.argv[2:])
1088
+ else:
1089
+ parser = build_parser()
1090
+ args = parser.parse_args()
1091
+ exit_code = args.func(args)
1092
+ except CliError as exc:
1093
+ print(str(exc), file=sys.stderr)
1094
+ raise SystemExit(1) from exc
1095
+ except httpx.HTTPError as exc:
1096
+ print(f"Network error: {exc}", file=sys.stderr)
1097
+ raise SystemExit(1) from exc
1098
+ raise SystemExit(exit_code)
1099
+
1100
+
1101
+ if __name__ == "__main__":
1102
+ main()