arga-cli 0.1.6__tar.gz → 0.1.7__tar.gz

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 (29) hide show
  1. {arga_cli-0.1.6 → arga_cli-0.1.7}/PKG-INFO +1 -23
  2. {arga_cli-0.1.6 → arga_cli-0.1.7}/README.md +0 -22
  3. {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/main.py +310 -182
  4. {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/wizard/provision.py +40 -12
  5. {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/wizard/summary.py +19 -20
  6. {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli.egg-info/PKG-INFO +1 -23
  7. {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli.egg-info/SOURCES.txt +0 -1
  8. {arga_cli-0.1.6 → arga_cli-0.1.7}/pyproject.toml +1 -1
  9. arga_cli-0.1.7/tests/test_cli_test_url.py +107 -0
  10. arga_cli-0.1.6/tests/test_cli_scan.py +0 -113
  11. arga_cli-0.1.6/tests/test_cli_test_url.py +0 -50
  12. {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/__init__.py +0 -0
  13. {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/mcp.py +0 -0
  14. {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/wizard/__init__.py +0 -0
  15. {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/wizard/constants.py +0 -0
  16. {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/wizard/env.py +0 -0
  17. {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/wizard/output.py +0 -0
  18. {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/wizard/prompts.py +0 -0
  19. {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli/wizard/session.py +0 -0
  20. {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli.egg-info/dependency_links.txt +0 -0
  21. {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli.egg-info/entry_points.txt +0 -0
  22. {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli.egg-info/requires.txt +0 -0
  23. {arga_cli-0.1.6 → arga_cli-0.1.7}/arga_cli.egg-info/top_level.txt +0 -0
  24. {arga_cli-0.1.6 → arga_cli-0.1.7}/setup.cfg +0 -0
  25. {arga_cli-0.1.6 → arga_cli-0.1.7}/tests/test_cli_git.py +0 -0
  26. {arga_cli-0.1.6 → arga_cli-0.1.7}/tests/test_cli_mcp.py +0 -0
  27. {arga_cli-0.1.6 → arga_cli-0.1.7}/tests/test_cli_runs.py +0 -0
  28. {arga_cli-0.1.6 → arga_cli-0.1.7}/tests/test_cli_validate_config.py +0 -0
  29. {arga_cli-0.1.6 → arga_cli-0.1.7}/tests/test_cli_validate_pr.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arga-cli
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: Command-line interface for Arga authentication, MCP installation, and browser validation
5
5
  Author: Arga Labs
6
6
  Project-URL: Homepage, https://github.com/ArgaLabs/arga-cli
@@ -33,7 +33,6 @@ Requires-Dist: rich>=13.0.0
33
33
  - Starts URL validation runs from the terminal.
34
34
  - Starts pull request validation runs from the terminal.
35
35
  - Wraps `git commit` and `git push` with Arga skip-validation helpers.
36
- - Starts and inspects Arga app scans.
37
36
 
38
37
  ## Installation
39
38
 
@@ -112,14 +111,6 @@ arga validate config arga-labs/validation-server
112
111
  arga validate config set arga-labs/validation-server --trigger branch --branch main --comments on
113
112
  ```
114
113
 
115
- Start an app scan and inspect it later:
116
-
117
- ```bash
118
- arga scan https://demo-app.com --budget 200
119
- arga scan status <run_id>
120
- arga scan report <run_id>
121
- ```
122
-
123
114
  List and inspect recent validation runs:
124
115
 
125
116
  ```bash
@@ -173,19 +164,6 @@ arga test url \
173
164
 
174
165
  Both `--email` and `--password` must be supplied together.
175
166
 
176
- ### App Scans
177
-
178
- ```bash
179
- arga scan https://demo-app.com --budget 200
180
- arga scan status <run_id>
181
- arga scan report <run_id>
182
- ```
183
-
184
- - `arga scan <url>` starts an app scan, waits for the generated scan plan to be ready, and auto-approves it so execution can begin.
185
- - `--budget` controls the red-team action budget and defaults to `200`.
186
- - `arga scan status <run_id>` prints the current run status and anomaly count.
187
- - `arga scan report <run_id>` prints the final JSON report once the scan has completed.
188
-
189
167
  ### Validation Runs
190
168
 
191
169
  ```bash
@@ -11,7 +11,6 @@
11
11
  - Starts URL validation runs from the terminal.
12
12
  - Starts pull request validation runs from the terminal.
13
13
  - Wraps `git commit` and `git push` with Arga skip-validation helpers.
14
- - Starts and inspects Arga app scans.
15
14
 
16
15
  ## Installation
17
16
 
@@ -90,14 +89,6 @@ arga validate config arga-labs/validation-server
90
89
  arga validate config set arga-labs/validation-server --trigger branch --branch main --comments on
91
90
  ```
92
91
 
93
- Start an app scan and inspect it later:
94
-
95
- ```bash
96
- arga scan https://demo-app.com --budget 200
97
- arga scan status <run_id>
98
- arga scan report <run_id>
99
- ```
100
-
101
92
  List and inspect recent validation runs:
102
93
 
103
94
  ```bash
@@ -151,19 +142,6 @@ arga test url \
151
142
 
152
143
  Both `--email` and `--password` must be supplied together.
153
144
 
154
- ### App Scans
155
-
156
- ```bash
157
- arga scan https://demo-app.com --budget 200
158
- arga scan status <run_id>
159
- arga scan report <run_id>
160
- ```
161
-
162
- - `arga scan <url>` starts an app scan, waits for the generated scan plan to be ready, and auto-approves it so execution can begin.
163
- - `--budget` controls the red-team action budget and defaults to `200`.
164
- - `arga scan status <run_id>` prints the current run status and anomaly count.
165
- - `arga scan report <run_id>` prints the final JSON report once the scan has completed.
166
-
167
145
  ### Validation Runs
168
146
 
169
147
  ```bash
@@ -25,6 +25,7 @@ WIZARD_SESSION_FILE = ".arga-session.json"
25
25
  WIZARD_SESSION_PATH = Path(WIZARD_SESSION_FILE)
26
26
  POLL_INTERVAL_SECONDS = 2.0
27
27
  POLL_TIMEOUT_SECONDS = 600.0
28
+ URL_VALIDATION_START_TIMEOUT_SECONDS = 60.0
28
29
  SKIP_TRAILER = "[skip arga]"
29
30
 
30
31
 
@@ -82,15 +83,17 @@ class ApiClient:
82
83
  self,
83
84
  *,
84
85
  url: str,
85
- prompt: str,
86
+ prompt: str | None = None,
86
87
  email: str | None = None,
87
88
  password: str | None = None,
88
89
  ttl_minutes: int | None = None,
90
+ scenario_id: str | None = None,
91
+ provision_id: str | None = None,
92
+ twins: list[str] | None = None,
89
93
  ) -> dict[str, str]:
90
- payload: dict[str, object] = {
91
- "url": url,
92
- "prompt": prompt,
93
- }
94
+ payload: dict[str, object] = {"url": url}
95
+ if prompt is not None:
96
+ payload["prompt"] = prompt
94
97
  if email or password:
95
98
  payload["credentials"] = {
96
99
  "email": email or "",
@@ -98,13 +101,67 @@ class ApiClient:
98
101
  }
99
102
  if ttl_minutes is not None:
100
103
  payload["ttl_minutes"] = ttl_minutes
104
+ if scenario_id:
105
+ payload["scenario_id"] = scenario_id
106
+ if provision_id:
107
+ payload["provision_id"] = provision_id
108
+ if twins:
109
+ payload["twins"] = twins
101
110
  response = self._client.post(
102
111
  f"{self._api_url}/validate/url-run",
103
112
  json=payload,
104
113
  headers=self._auth_headers(),
114
+ timeout=URL_VALIDATION_START_TIMEOUT_SECONDS,
105
115
  )
106
116
  return self._parse_json(response, "Failed to start URL validation")
107
117
 
118
+ def list_scenarios(self, *, include_presets: bool = False) -> list[dict[str, Any]]:
119
+ params: dict[str, str] = {}
120
+ if include_presets:
121
+ params["include_presets"] = "true"
122
+ response = self._client.get(
123
+ f"{self._api_url}/scenarios",
124
+ params=params,
125
+ headers=self._auth_headers(),
126
+ )
127
+ data = self._parse_json(response, "Failed to list scenarios")
128
+ if not isinstance(data, list):
129
+ raise CliError("Unexpected response from /scenarios")
130
+ return data
131
+
132
+ def create_scenario(
133
+ self,
134
+ *,
135
+ name: str,
136
+ prompt: str | None = None,
137
+ description: str | None = None,
138
+ twins: list[str] | None = None,
139
+ tags: list[str] | None = None,
140
+ ) -> dict[str, Any]:
141
+ payload: dict[str, Any] = {"name": name}
142
+ if prompt is not None:
143
+ payload["prompt"] = prompt
144
+ if description is not None:
145
+ payload["description"] = description
146
+ if twins:
147
+ payload["twins"] = twins
148
+ if tags:
149
+ payload["tags"] = tags
150
+ response = self._client.post(
151
+ f"{self._api_url}/scenarios",
152
+ json=payload,
153
+ headers=self._auth_headers(),
154
+ timeout=URL_VALIDATION_START_TIMEOUT_SECONDS,
155
+ )
156
+ return self._parse_json(response, "Failed to create scenario")
157
+
158
+ def delete_scenario(self, scenario_id: str) -> dict[str, str]:
159
+ response = self._client.delete(
160
+ f"{self._api_url}/scenarios/{scenario_id}",
161
+ headers=self._auth_headers(),
162
+ )
163
+ return self._parse_json(response, "Failed to delete scenario")
164
+
108
165
  def start_pr_validation(
109
166
  self,
110
167
  *,
@@ -122,22 +179,6 @@ class ApiClient:
122
179
  )
123
180
  return self._parse_json(response, "Failed to start PR validation")
124
181
 
125
- def start_redteam_scan(self, *, url: str, action_budget: int, focus: str | None = None) -> dict[str, Any]:
126
- response = self._client.post(
127
- f"{self._api_url}/validate/agent-run",
128
- json={"url": url, "action_budget": action_budget, "focus": focus},
129
- headers=self._auth_headers(),
130
- )
131
- return self._parse_json(response, "Failed to start agent run")
132
-
133
- def approve_redteam_scan(self, run_id: str) -> dict[str, Any]:
134
- response = self._client.post(
135
- f"{self._api_url}/redteam/{run_id}/approve",
136
- json={},
137
- headers=self._auth_headers(),
138
- )
139
- return self._parse_json(response, "Failed to approve agent run")
140
-
141
182
  def get_run(self, run_id: str) -> dict[str, Any]:
142
183
  response = self._client.get(
143
184
  f"{self._api_url}/runs/{run_id}",
@@ -152,13 +193,6 @@ class ApiClient:
152
193
  )
153
194
  return self._parse_json(response, "Failed to load run logs")
154
195
 
155
- def get_redteam_report(self, run_id: str) -> dict[str, Any]:
156
- response = self._client.get(
157
- f"{self._api_url}/redteam/{run_id}/report",
158
- headers=self._auth_headers(),
159
- )
160
- return self._parse_json(response, "Failed to load agent run report")
161
-
162
196
  def list_pr_validation_runs(
163
197
  self,
164
198
  *,
@@ -443,20 +477,87 @@ def _resolve_ttl(client: ApiClient, requested_ttl: int | None) -> int | None:
443
477
  return FREE_TTL
444
478
 
445
479
 
480
+ def _print_twin_env_vars(status: dict) -> None:
481
+ """Print twin URLs and env vars so the user can configure their app."""
482
+ from arga_cli.wizard.provision import with_proxy_token
483
+
484
+ proxy_token = status.get("proxy_token")
485
+ print("\nTwin environment variables — update your app's config to point at these:\n")
486
+ for name, info in status.get("twins", {}).items():
487
+ label = info.get("label", name)
488
+ base_url = info.get("base_url", "")
489
+ if proxy_token and base_url:
490
+ base_url = with_proxy_token(base_url, proxy_token)
491
+ print(f" {label}:")
492
+ print(f" Base URL: {base_url}")
493
+ env_vars = info.get("env_vars", {})
494
+ for key, val in env_vars.items():
495
+ print(f" {key}={val}")
496
+ print()
497
+
498
+
446
499
  def run_test_url(args: argparse.Namespace) -> int:
447
500
  if bool(args.email) != bool(args.password):
448
501
  raise CliError("Both --email and --password must be provided together.")
449
502
 
503
+ scenario_id = getattr(args, "scenario", None)
504
+ if not args.prompt and not scenario_id:
505
+ raise CliError("Either --prompt or --scenario must be provided.")
506
+
507
+ twins_arg: list[str] | None = None
508
+ raw_twins = getattr(args, "twins", None)
509
+ if raw_twins:
510
+ twins_arg = [t.strip() for t in raw_twins.split(",") if t.strip()]
511
+
512
+ if not args.url and not twins_arg:
513
+ raise CliError("--url is required (or use --twins to provision twins first).")
514
+
515
+ url: str = args.url or ""
516
+
450
517
  api_key = load_api_key()
451
518
  client = ApiClient(args.api_url, api_key=api_key)
452
519
  try:
453
520
  ttl_minutes = _resolve_ttl(client, getattr(args, "ttl", None))
521
+
522
+ provision_id: str | None = None
523
+ if twins_arg:
524
+ from arga_cli.wizard.provision import provision_twins
525
+
526
+ status = provision_twins(client, twins_arg, ttl_minutes=ttl_minutes or 30)
527
+ provision_id = status.get("run_id")
528
+ _print_twin_env_vars(status)
529
+
530
+ if not url:
531
+ print("Deploy your app with the environment variables above.")
532
+ print("Press Ctrl+C to cancel.\n")
533
+ try:
534
+ url = input("Enter your staging URL: ").strip()
535
+ except KeyboardInterrupt:
536
+ print("\nCancelled.")
537
+ return 1
538
+ if not url:
539
+ raise CliError("A URL is required to start the validation run.")
540
+ print()
541
+ else:
542
+ print(
543
+ "Deploy your app with the environment variables above, then press Enter to start the validation run."
544
+ )
545
+ print("Press Ctrl+C to cancel.\n")
546
+ try:
547
+ input()
548
+ except KeyboardInterrupt:
549
+ print("\nCancelled.")
550
+ return 1
551
+
454
552
  payload = client.start_url_validation(
455
- url=args.url,
553
+ url=url,
456
554
  prompt=args.prompt,
457
555
  email=args.email,
458
556
  password=args.password,
459
557
  ttl_minutes=ttl_minutes,
558
+ scenario_id=scenario_id,
559
+ provision_id=provision_id,
560
+ twins=twins_arg if not provision_id else None,
460
561
  )
461
562
  finally:
462
563
  client.close()
@@ -466,8 +567,11 @@ def run_test_url(args: argparse.Namespace) -> int:
466
567
  return 0
467
568
 
468
569
  print("Starting validation...\n")
469
- print(f"URL: {args.url}")
470
- print(f"Prompt: {args.prompt}")
570
+ print(f"URL: {url}")
571
+ if args.prompt:
572
+ print(f"Prompt: {args.prompt}")
573
+ if scenario_id:
574
+ print(f"Scenario: {scenario_id}")
471
575
  print(f"TTL: {ttl_minutes} minutes\n")
472
576
  print(f"Run ID: {payload.get('run_id', 'unknown')}")
473
577
  print(f"Status: {payload.get('status', 'unknown')}")
@@ -497,6 +601,77 @@ def run_validate_pr(args: argparse.Namespace) -> int:
497
601
  return 0
498
602
 
499
603
 
604
+ def run_scenarios_list(args: argparse.Namespace) -> int:
605
+ api_key = load_api_key()
606
+ client = ApiClient(args.api_url, api_key=api_key)
607
+ try:
608
+ scenarios = client.list_scenarios(include_presets=getattr(args, "include_presets", False))
609
+ finally:
610
+ client.close()
611
+
612
+ if getattr(args, "json", False):
613
+ print(json.dumps(scenarios, indent=2))
614
+ return 0
615
+
616
+ if not scenarios:
617
+ print("No scenarios found. Create one with `arga scenarios create`.")
618
+ return 0
619
+
620
+ for s in scenarios:
621
+ marker = " (preset)" if s.get("is_preset") else ""
622
+ twins = ", ".join(s.get("twins") or []) or "-"
623
+ tags = ", ".join(s.get("tags") or []) or "-"
624
+ print(f"{s.get('id')} {s.get('name')}{marker}")
625
+ if s.get("description"):
626
+ print(f" description: {s['description']}")
627
+ print(f" twins: {twins}")
628
+ print(f" tags: {tags}")
629
+ print()
630
+ return 0
631
+
632
+
633
+ def run_scenarios_create(args: argparse.Namespace) -> int:
634
+ api_key = load_api_key()
635
+ client = ApiClient(args.api_url, api_key=api_key)
636
+ try:
637
+ scenario = client.create_scenario(
638
+ name=args.name,
639
+ prompt=args.prompt,
640
+ description=getattr(args, "description", None),
641
+ twins=getattr(args, "twin", None),
642
+ tags=getattr(args, "tag", None),
643
+ )
644
+ finally:
645
+ client.close()
646
+
647
+ if getattr(args, "json", False):
648
+ print(json.dumps(scenario, indent=2))
649
+ return 0
650
+
651
+ print("Scenario created.")
652
+ print(f" id: {scenario.get('id')}")
653
+ print(f" name: {scenario.get('name')}")
654
+ if scenario.get("twins"):
655
+ print(f" twins: {', '.join(scenario['twins'])}")
656
+ return 0
657
+
658
+
659
+ def run_scenarios_delete(args: argparse.Namespace) -> int:
660
+ api_key = load_api_key()
661
+ client = ApiClient(args.api_url, api_key=api_key)
662
+ try:
663
+ result = client.delete_scenario(args.scenario_id)
664
+ finally:
665
+ client.close()
666
+
667
+ if getattr(args, "json", False):
668
+ print(json.dumps(result))
669
+ return 0
670
+
671
+ print(f"Deleted scenario {args.scenario_id}.")
672
+ return 0
673
+
674
+
500
675
  def _validate_help_text() -> str:
501
676
  return (
502
677
  "usage: arga validate pr --repo <owner/repo> --pr <number>\n"
@@ -642,150 +817,6 @@ def run_mcp_install(args: argparse.Namespace) -> int:
642
817
  return 1 if failures else 0
643
818
 
644
819
 
645
- def _scan_help_text() -> str:
646
- return (
647
- "usage: arga scan <url> [--budget 200]\n"
648
- " arga scan status <run_id>\n"
649
- " arga scan report <run_id>\n\n"
650
- "Start or inspect Arga agent runs.\n\n"
651
- "Note: scan requires a Team or Paid plan. Check your plan with `arga whoami`."
652
- )
653
-
654
-
655
- def _build_scan_start_parser() -> argparse.ArgumentParser:
656
- parser = argparse.ArgumentParser(
657
- prog="arga scan",
658
- description="Start an Arga agent run. Requires a Team or Paid plan.",
659
- allow_abbrev=False,
660
- )
661
- parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
662
- parser.add_argument("url", help="Public application URL to explore")
663
- parser.add_argument("--budget", type=int, default=200, help="Total action budget for the scan")
664
- return parser
665
-
666
-
667
- def _build_scan_status_parser() -> argparse.ArgumentParser:
668
- parser = argparse.ArgumentParser(
669
- prog="arga scan status",
670
- description="Check the status of an Arga agent run.",
671
- allow_abbrev=False,
672
- )
673
- parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
674
- parser.add_argument("run_id", help="App scan run ID")
675
- return parser
676
-
677
-
678
- def _build_scan_report_parser() -> argparse.ArgumentParser:
679
- parser = argparse.ArgumentParser(
680
- prog="arga scan report",
681
- description="View the final report for an Arga agent run.",
682
- allow_abbrev=False,
683
- )
684
- parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
685
- parser.add_argument("run_id", help="App scan run ID")
686
- return parser
687
-
688
-
689
- def _status_from_run(run: dict[str, Any]) -> str:
690
- return str(run.get("status") or "unknown")
691
-
692
-
693
- def _wait_for_scan_approval(client: ApiClient, run_id: str) -> dict[str, Any]:
694
- deadline = time.monotonic() + POLL_TIMEOUT_SECONDS
695
- last_run: dict[str, Any] = {"id": run_id, "status": "planning"}
696
- while time.monotonic() < deadline:
697
- run = client.get_run(run_id)
698
- last_run = run
699
- status = _status_from_run(run)
700
- if status in {"queued", "running", "completed", "failed", "cancelled"}:
701
- return run
702
-
703
- if status in {"planning", "awaiting_approval"}:
704
- try:
705
- approval = client.approve_redteam_scan(run_id)
706
- run["status"] = approval.get("status", run.get("status"))
707
- return run
708
- except CliError as exc:
709
- if str(exc) != "Scan plan is not ready yet":
710
- raise
711
-
712
- time.sleep(POLL_INTERVAL_SECONDS)
713
-
714
- raise CliError(f"Timed out waiting for the scan plan to be ready for run {last_run.get('id', run_id)}.")
715
-
716
-
717
- def _print_scan_summary(run_id: str, run: dict[str, Any]) -> None:
718
- report = run.get("redteam_report_json")
719
- anomaly_count = len(report.get("anomalies") or []) if isinstance(report, dict) else 0
720
- print(f"Run ID: {run_id}")
721
- print(f"Status: {_status_from_run(run)}")
722
- print(f"URL: {run.get('frontend_url') or run.get('pr_url') or 'unknown'}")
723
- print(f"Mode: {run.get('mode') or 'unknown'}")
724
- print(f"Anomalies: {anomaly_count}")
725
-
726
-
727
- def run_scan_start(args: argparse.Namespace) -> int:
728
- if args.budget <= 0:
729
- raise CliError("Budget must be a positive integer.")
730
-
731
- api_key = load_api_key()
732
- client = ApiClient(args.api_url, api_key=api_key)
733
- try:
734
- payload = client.start_redteam_scan(url=args.url, action_budget=args.budget)
735
- run_id = str(payload.get("run_id") or "")
736
- if not run_id:
737
- raise CliError("Agent run started but no run ID was returned.")
738
- run = _wait_for_scan_approval(client, run_id)
739
- finally:
740
- client.close()
741
-
742
- print("Starting agent run...\n")
743
- print(f"URL: {args.url}")
744
- print(f"Budget: {args.budget}")
745
- print(f"Run ID: {run_id}")
746
- print(f"Status: {_status_from_run(run)}")
747
- return 0
748
-
749
-
750
- def run_scan_status(args: argparse.Namespace) -> int:
751
- api_key = load_api_key()
752
- client = ApiClient(args.api_url, api_key=api_key)
753
- try:
754
- run = client.get_run(args.run_id)
755
- finally:
756
- client.close()
757
-
758
- _print_scan_summary(args.run_id, run)
759
- return 0
760
-
761
-
762
- def run_scan_report(args: argparse.Namespace) -> int:
763
- api_key = load_api_key()
764
- client = ApiClient(args.api_url, api_key=api_key)
765
- try:
766
- report = client.get_redteam_report(args.run_id)
767
- finally:
768
- client.close()
769
-
770
- if not report:
771
- raise CliError("Scan report is not ready yet.")
772
-
773
- print(json.dumps(report, indent=2))
774
- return 0
775
-
776
-
777
- def run_scan_cli(argv: list[str]) -> int:
778
- if not argv or argv[0] in {"-h", "--help"}:
779
- print(_scan_help_text())
780
- return 0
781
-
782
- if argv[0] == "status":
783
- return run_scan_status(_build_scan_status_parser().parse_args(argv[1:]))
784
- if argv[0] == "report":
785
- return run_scan_report(_build_scan_report_parser().parse_args(argv[1:]))
786
- return run_scan_start(_build_scan_start_parser().parse_args(argv))
787
-
788
-
789
820
  def _format_timestamp(value: str | None) -> str:
790
821
  if not value:
791
822
  return "-"
@@ -904,6 +935,40 @@ def _print_worker_logs(worker_logs: list[dict[str, Any]]) -> None:
904
935
  print()
905
936
 
906
937
 
938
+ def _print_timeline_events(timeline_events: list[dict[str, Any]], *, limit: int = 50) -> None:
939
+ print("Timeline:")
940
+ if not timeline_events:
941
+ print("None")
942
+ return
943
+
944
+ total = len(timeline_events)
945
+ events = timeline_events[-limit:] if total > limit else timeline_events
946
+ if total > limit:
947
+ print(f"(showing last {len(events)} of {total} events)")
948
+
949
+ for index, event in enumerate(events):
950
+ if not isinstance(event, dict):
951
+ continue
952
+ timestamp = _format_timestamp(event.get("timestamp") or event.get("created_at"))
953
+ event_type = str(event.get("type") or event.get("event_type") or "").strip()
954
+ header_parts = [part for part in (timestamp, event_type) if part and part != "-"]
955
+ header = " | ".join(header_parts) if header_parts else "event"
956
+ print(header)
957
+
958
+ message = str(event.get("message") or "").strip()
959
+ if message:
960
+ print(message)
961
+
962
+ details = event.get("details")
963
+ if isinstance(details, dict) and details:
964
+ error_message = str(details.get("error_message") or details.get("error") or "").strip()
965
+ if error_message and error_message != message:
966
+ print(f"Error: {error_message}")
967
+
968
+ if index < len(events) - 1:
969
+ print()
970
+
971
+
907
972
  def _print_runtime_logs(runtime_logs: list[dict[str, Any]]) -> None:
908
973
  print("Runtime Logs:")
909
974
  if not runtime_logs:
@@ -996,10 +1061,18 @@ def _print_run_logs(payload: dict[str, Any], fallback_run_id: str) -> None:
996
1061
  if isinstance(timeline_events, list):
997
1062
  print(f"Timeline Events: {len(timeline_events)}")
998
1063
 
1064
+ worker_logs_list = worker_logs if isinstance(worker_logs, list) else []
1065
+ runtime_logs_list = runtime_logs if isinstance(runtime_logs, list) else []
1066
+ timeline_events_list = timeline_events if isinstance(timeline_events, list) else []
1067
+
999
1068
  print()
1000
- _print_worker_logs(worker_logs if isinstance(worker_logs, list) else [])
1069
+ _print_worker_logs(worker_logs_list)
1001
1070
  print()
1002
- _print_runtime_logs(runtime_logs if isinstance(runtime_logs, list) else [])
1071
+ _print_runtime_logs(runtime_logs_list)
1072
+
1073
+ if not worker_logs_list and not runtime_logs_list and timeline_events_list:
1074
+ print()
1075
+ _print_timeline_events(timeline_events_list)
1003
1076
 
1004
1077
  if isinstance(warnings, list) and warnings:
1005
1078
  print()
@@ -1493,8 +1566,25 @@ def build_parser() -> argparse.ArgumentParser:
1493
1566
 
1494
1567
  test_url_parser = test_subparsers.add_parser("url", help="Run a browser validation against a deployed URL")
1495
1568
  test_url_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1496
- test_url_parser.add_argument("--url", required=True, help="Deployed application URL")
1497
- test_url_parser.add_argument("--prompt", required=True, help="Natural language instructions for the agent")
1569
+ test_url_parser.add_argument(
1570
+ "--url", default=None, help="Deployed application URL (prompted after twin provisioning when --twins is used)"
1571
+ )
1572
+ test_url_parser.add_argument(
1573
+ "--prompt",
1574
+ default=None,
1575
+ help="Natural language instructions for the agent (optional when --scenario is provided)",
1576
+ )
1577
+ test_url_parser.add_argument(
1578
+ "--scenario",
1579
+ default=None,
1580
+ help="Scenario ID to seed twins from (obtain via `arga scenarios list` or the web app)",
1581
+ )
1582
+ test_url_parser.add_argument(
1583
+ "--twins",
1584
+ default=None,
1585
+ help="Comma-separated list of digital twins to provision before the run (e.g. slack,stripe). "
1586
+ "Twins are provisioned first, then you deploy your app against them before the validation starts.",
1587
+ )
1498
1588
  test_url_parser.add_argument("--email", help="Optional login email")
1499
1589
  test_url_parser.add_argument("--password", help="Optional login password")
1500
1590
  test_url_parser.add_argument(
@@ -1506,6 +1596,47 @@ def build_parser() -> argparse.ArgumentParser:
1506
1596
  test_url_parser.add_argument("--json", action="store_true", default=False, help="Output result as JSON")
1507
1597
  test_url_parser.set_defaults(func=run_test_url)
1508
1598
 
1599
+ scenarios_parser = subparsers.add_parser("scenarios", help="Manage twin seed scenarios")
1600
+ scenarios_subparsers = scenarios_parser.add_subparsers(dest="scenarios_command", required=True)
1601
+
1602
+ scenarios_list_parser = scenarios_subparsers.add_parser("list", help="List your scenarios")
1603
+ scenarios_list_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1604
+ scenarios_list_parser.add_argument(
1605
+ "--include-presets",
1606
+ action="store_true",
1607
+ default=False,
1608
+ help="Also include built-in preset scenarios",
1609
+ )
1610
+ scenarios_list_parser.add_argument("--json", action="store_true", default=False, help="Output as JSON")
1611
+ scenarios_list_parser.set_defaults(func=run_scenarios_list)
1612
+
1613
+ scenarios_create_parser = scenarios_subparsers.add_parser(
1614
+ "create", help="Create a scenario from a natural-language prompt"
1615
+ )
1616
+ scenarios_create_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1617
+ scenarios_create_parser.add_argument("--name", required=True, help="Scenario name")
1618
+ scenarios_create_parser.add_argument(
1619
+ "--prompt",
1620
+ required=True,
1621
+ help="Natural-language description of the desired twin state (LLM generates seed_config)",
1622
+ )
1623
+ scenarios_create_parser.add_argument("--description", default=None, help="Optional description")
1624
+ scenarios_create_parser.add_argument(
1625
+ "--twin",
1626
+ action="append",
1627
+ default=None,
1628
+ help="Restrict to specific twin(s) (repeatable). Inferred from prompt if omitted.",
1629
+ )
1630
+ scenarios_create_parser.add_argument("--tag", action="append", default=None, help="Tag the scenario (repeatable)")
1631
+ scenarios_create_parser.add_argument("--json", action="store_true", default=False, help="Output as JSON")
1632
+ scenarios_create_parser.set_defaults(func=run_scenarios_create)
1633
+
1634
+ scenarios_delete_parser = scenarios_subparsers.add_parser("delete", help="Delete a scenario")
1635
+ scenarios_delete_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1636
+ scenarios_delete_parser.add_argument("scenario_id", help="Scenario ID to delete")
1637
+ scenarios_delete_parser.add_argument("--json", action="store_true", default=False, help="Output as JSON")
1638
+ scenarios_delete_parser.set_defaults(func=run_scenarios_delete)
1639
+
1509
1640
  validate_parser = subparsers.add_parser("validate", help="Start PR or URL validation runs")
1510
1641
  validate_subparsers = validate_parser.add_subparsers(dest="validate_command", required=True)
1511
1642
 
@@ -1574,7 +1705,6 @@ def build_parser() -> argparse.ArgumentParser:
1574
1705
 
1575
1706
  subparsers.add_parser("commit", help="Wrap git commit and optionally mark it to skip Arga validation")
1576
1707
  subparsers.add_parser("push", help="Wrap git push and verify skip state when requested")
1577
- subparsers.add_parser("scan", help="Start an agent run or inspect a scan run")
1578
1708
  return parser
1579
1709
 
1580
1710
 
@@ -1586,8 +1716,6 @@ def main() -> None:
1586
1716
  exit_code = run_push_cli(sys.argv[2:])
1587
1717
  elif len(sys.argv) > 1 and sys.argv[1] == "validate":
1588
1718
  exit_code = run_validate_cli(sys.argv[2:])
1589
- elif len(sys.argv) > 1 and sys.argv[1] == "scan":
1590
- exit_code = run_scan_cli(sys.argv[2:])
1591
1719
  elif len(sys.argv) > 1 and sys.argv[1] == "wizard":
1592
1720
  exit_code = run_wizard_cli(sys.argv[2:])
1593
1721
  else:
@@ -47,6 +47,7 @@ def provision_twins(
47
47
  )
48
48
  data = client._parse_json(response, "Failed to provision twins")
49
49
  run_id = data["run_id"]
50
+ dim(f" Run ID: {run_id}")
50
51
 
51
52
  # Poll until ready
52
53
  ready_set: set[str] = set()
@@ -106,6 +107,23 @@ def provision_twins(
106
107
  raise RuntimeError(f"Timed out waiting for twins to provision for run {run_id}.")
107
108
 
108
109
 
110
+ def _format_seed_summary(seed_info: dict) -> list[str]:
111
+ """Render a server-reported seed result as a list of display lines."""
112
+ status_value = seed_info.get("status")
113
+ if status_value == "seeded":
114
+ counts = [
115
+ f"{key.replace('_', ' ')}: {value}"
116
+ for key, value in seed_info.items()
117
+ if key not in {"status", "twin"} and isinstance(value, (int, str))
118
+ ]
119
+ return [f"Seeded — {', '.join(counts)}"] if counts else ["Seeded."]
120
+ if status_value == "skipped":
121
+ return [f"Skipped ({seed_info.get('reason', 'unknown')})"]
122
+ if status_value == "error":
123
+ return [f"Seed error: {seed_info.get('error', 'unknown')}"]
124
+ return ["Default configuration loaded."]
125
+
126
+
109
127
  def seed_and_report(client: Any, status: dict) -> None:
110
128
  """Seed quickstart data for UI twins, print default config for backend-only twins."""
111
129
  header("Quickstart setup...")
@@ -120,23 +138,29 @@ def seed_and_report(client: Any, status: dict) -> None:
120
138
  backend_twins.append(name)
121
139
 
122
140
  proxy_token = status.get("proxy_token")
141
+ seed_results = status.get("seed_results") or {}
123
142
 
124
143
  # Seed and report UI twins
125
144
  for name in ui_twins:
126
145
  info = status["twins"][name]
127
146
  label = TWIN_CATALOG.get(name, {}).get("label", name)
128
147
 
129
- # Reset to seed state
130
- try:
131
- admin_url = info.get("admin_url", "")
132
- reset_url = with_proxy_token(f"{admin_url}/admin/reset", proxy_token)
133
- client._client.post(reset_url, json={}, headers={"Content-Type": "application/json"})
134
- except Exception:
135
- pass # Twin may already be in clean state
148
+ seed_info = seed_results.get(name)
149
+ if seed_info is None:
150
+ # No server-side seeding happened — fall back to the legacy client-side reset.
151
+ try:
152
+ admin_url = info.get("admin_url", "")
153
+ reset_url = with_proxy_token(f"{admin_url}/admin/reset", proxy_token)
154
+ client._client.post(reset_url, json={}, headers={"Content-Type": "application/json"})
155
+ except Exception:
156
+ pass
136
157
 
137
158
  console.print(f" [bold cyan]{label}[/bold cyan]:")
138
- summary = QUICKSTART_SUMMARIES.get(name, ["Default configuration loaded."])
139
- for line in summary:
159
+ if seed_info is not None:
160
+ summary_lines = _format_seed_summary(seed_info)
161
+ else:
162
+ summary_lines = QUICKSTART_SUMMARIES.get(name, ["Default configuration loaded."])
163
+ for line in summary_lines:
140
164
  console.print(f" {line}")
141
165
 
142
166
  # Print env vars
@@ -151,12 +175,16 @@ def seed_and_report(client: Any, status: dict) -> None:
151
175
  label = TWIN_CATALOG.get(name, {}).get("label", name)
152
176
 
153
177
  console.print(f" [bold cyan]{label}[/bold cyan] [dim](backend-only)[/dim]:")
154
- base_url = with_proxy_token(info.get("base_url", ""), proxy_token)
155
- console.print(f" Base URL: [underline]{base_url}[/underline]")
178
+ base_url = info.get("base_url", "")
179
+ console.print(f" Base URL: [underline]{base_url}[/underline]", soft_wrap=True, overflow="ignore")
180
+ seed_info = seed_results.get(name)
181
+ if seed_info is not None:
182
+ for line in _format_seed_summary(seed_info):
183
+ console.print(f" {line}")
156
184
  env_vars = info.get("env_vars", {})
157
185
  if env_vars:
158
186
  for key, val in env_vars.items():
159
187
  console.print(f" [dim]{key}[/dim]: {val}")
160
- else:
188
+ elif seed_info is None:
161
189
  dim(" No UI dashboard. Use API calls directly.")
162
190
  console.print()
@@ -5,8 +5,7 @@ from __future__ import annotations
5
5
  from datetime import datetime
6
6
 
7
7
  from arga_cli.wizard.constants import DASHBOARD_BASE_URL, SESSION_FILE, TWIN_CATALOG
8
- from arga_cli.wizard.output import dim, print_summary_box
9
- from arga_cli.wizard.provision import with_proxy_token
8
+ from arga_cli.wizard.output import console, dim, print_summary_box
10
9
  from arga_cli.wizard.session import save_session
11
10
 
12
11
 
@@ -18,31 +17,31 @@ def print_summary(cwd: str, status: dict, api_url: str, api_key: str) -> None:
18
17
  lines = [
19
18
  "[bold green]Arga Twins \u2014 Ready![/bold green]",
20
19
  "",
21
- f"Dashboard: [underline]{dashboard_url}[/underline]",
22
- "",
20
+ "Commands:",
21
+ " [cyan]arga wizard status[/cyan] Check health",
22
+ " [cyan]arga wizard reset[/cyan] Reset twin state",
23
+ " [cyan]arga wizard extend[/cyan] Extend by 10 min",
24
+ " [cyan]arga wizard teardown[/cyan] Destroy session",
23
25
  ]
24
26
 
25
- # Twin URLs
26
- for name, info in status.get("twins", {}).items():
27
- label = TWIN_CATALOG.get(name, {}).get("label", name).ljust(16)
28
- url = with_proxy_token(info.get("base_url", ""), proxy_token)
29
- lines.append(f"{label} [underline]{url}[/underline]")
27
+ print_summary_box(lines)
30
28
 
31
- lines.append("")
29
+ console.print(f"Dashboard: [underline]{dashboard_url}[/underline]", soft_wrap=True, overflow="ignore")
30
+ console.print()
32
31
 
33
- if status.get("expires_at"):
34
- lines.append(f"Session expires: {status['expires_at']}")
35
- lines.append("")
32
+ for name, info in status.get("twins", {}).items():
33
+ label = TWIN_CATALOG.get(name, {}).get("label", name)
34
+ url = info.get("base_url", "")
35
+ console.print(
36
+ f" [bold cyan]{label}[/bold cyan]: [underline]{url}[/underline]", soft_wrap=True, overflow="ignore"
37
+ )
36
38
 
37
- lines.append("Commands:")
38
- lines.append(" [cyan]arga wizard status[/cyan] Check health")
39
- lines.append(" [cyan]arga wizard reset[/cyan] Reset twin state")
40
- lines.append(" [cyan]arga wizard extend[/cyan] Extend by 10 min")
41
- lines.append(" [cyan]arga wizard teardown[/cyan] Destroy session")
39
+ if status.get("expires_at"):
40
+ console.print()
41
+ dim(f"Session expires: {status['expires_at']}")
42
42
 
43
- print_summary_box(lines)
43
+ console.print()
44
44
 
45
- # Write session state
46
45
  session = {
47
46
  "run_id": status["run_id"],
48
47
  "created_at": datetime.now().isoformat(),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arga-cli
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: Command-line interface for Arga authentication, MCP installation, and browser validation
5
5
  Author: Arga Labs
6
6
  Project-URL: Homepage, https://github.com/ArgaLabs/arga-cli
@@ -33,7 +33,6 @@ Requires-Dist: rich>=13.0.0
33
33
  - Starts URL validation runs from the terminal.
34
34
  - Starts pull request validation runs from the terminal.
35
35
  - Wraps `git commit` and `git push` with Arga skip-validation helpers.
36
- - Starts and inspects Arga app scans.
37
36
 
38
37
  ## Installation
39
38
 
@@ -112,14 +111,6 @@ arga validate config arga-labs/validation-server
112
111
  arga validate config set arga-labs/validation-server --trigger branch --branch main --comments on
113
112
  ```
114
113
 
115
- Start an app scan and inspect it later:
116
-
117
- ```bash
118
- arga scan https://demo-app.com --budget 200
119
- arga scan status <run_id>
120
- arga scan report <run_id>
121
- ```
122
-
123
114
  List and inspect recent validation runs:
124
115
 
125
116
  ```bash
@@ -173,19 +164,6 @@ arga test url \
173
164
 
174
165
  Both `--email` and `--password` must be supplied together.
175
166
 
176
- ### App Scans
177
-
178
- ```bash
179
- arga scan https://demo-app.com --budget 200
180
- arga scan status <run_id>
181
- arga scan report <run_id>
182
- ```
183
-
184
- - `arga scan <url>` starts an app scan, waits for the generated scan plan to be ready, and auto-approves it so execution can begin.
185
- - `--budget` controls the red-team action budget and defaults to `200`.
186
- - `arga scan status <run_id>` prints the current run status and anomaly count.
187
- - `arga scan report <run_id>` prints the final JSON report once the scan has completed.
188
-
189
167
  ### Validation Runs
190
168
 
191
169
  ```bash
@@ -20,7 +20,6 @@ arga_cli/wizard/summary.py
20
20
  tests/test_cli_git.py
21
21
  tests/test_cli_mcp.py
22
22
  tests/test_cli_runs.py
23
- tests/test_cli_scan.py
24
23
  tests/test_cli_test_url.py
25
24
  tests/test_cli_validate_config.py
26
25
  tests/test_cli_validate_pr.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "arga-cli"
7
- version = "0.1.6"
7
+ version = "0.1.7"
8
8
  description = "Command-line interface for Arga authentication, MCP installation, and browser validation"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from arga_cli import main
6
+
7
+
8
+ def test_start_url_validation_uses_longer_timeout(monkeypatch) -> None:
9
+ client = main.ApiClient("https://api.argalabs.com", api_key="arga_api_key")
10
+ captured: dict[str, object] = {}
11
+
12
+ def fake_post(url: str, *, json: dict[str, object], headers: dict[str, str], timeout: float):
13
+ captured["url"] = url
14
+ captured["json"] = json
15
+ captured["headers"] = headers
16
+ captured["timeout"] = timeout
17
+
18
+ class FakeResponse:
19
+ is_success = True
20
+ status_code = 200
21
+
22
+ def json(self) -> dict[str, str]:
23
+ return {"run_id": "run_3421", "status": "queued", "session_id": "session_1"}
24
+
25
+ return FakeResponse()
26
+
27
+ monkeypatch.setattr(client._client, "post", fake_post)
28
+ try:
29
+ payload = client.start_url_validation(url="https://demo-app.com", prompt="test login flow")
30
+ finally:
31
+ client.close()
32
+
33
+ assert captured["url"] == "https://api.argalabs.com/validate/url-run"
34
+ assert captured["json"] == {"url": "https://demo-app.com", "prompt": "test login flow"}
35
+ assert captured["timeout"] == main.URL_VALIDATION_START_TIMEOUT_SECONDS
36
+ assert payload == {"run_id": "run_3421", "status": "queued", "session_id": "session_1"}
37
+
38
+
39
+ def test_test_url_command_prints_run_id(monkeypatch, capsys) -> None:
40
+ monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
41
+ monkeypatch.setattr(main.ApiClient, "get_me", lambda self: {"billing_plan": "free"})
42
+
43
+ def fake_start(
44
+ self,
45
+ *,
46
+ url: str,
47
+ prompt: str | None = None,
48
+ email: str | None = None,
49
+ password: str | None = None,
50
+ ttl_minutes: int | None = None,
51
+ scenario_id: str | None = None,
52
+ provision_id: str | None = None,
53
+ twins: list[str] | None = None,
54
+ ):
55
+ assert url == "https://demo-app.com"
56
+ assert prompt == "test login flow"
57
+ assert email is None
58
+ assert password is None
59
+ assert ttl_minutes == 10
60
+ return {"run_id": "run_3421", "status": "queued", "session_id": "session_1"}
61
+
62
+ monkeypatch.setattr(main.ApiClient, "start_url_validation", fake_start)
63
+ monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
64
+
65
+ args = main.build_parser().parse_args(
66
+ ["test", "url", "--url", "https://demo-app.com", "--prompt", "test login flow"]
67
+ )
68
+ exit_code = args.func(args)
69
+ output = capsys.readouterr().out
70
+
71
+ assert exit_code == 0
72
+ assert "Starting validation..." in output
73
+ assert "Run ID: run_3421" in output
74
+ assert "Status: queued" in output
75
+
76
+
77
+ def test_test_url_json_flag(monkeypatch, capsys) -> None:
78
+ monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
79
+ monkeypatch.setattr(main.ApiClient, "get_me", lambda self: {"billing_plan": "free"})
80
+
81
+ def fake_start(
82
+ self,
83
+ *,
84
+ url: str,
85
+ prompt: str | None = None,
86
+ email: str | None = None,
87
+ password: str | None = None,
88
+ ttl_minutes: int | None = None,
89
+ scenario_id: str | None = None,
90
+ provision_id: str | None = None,
91
+ twins: list[str] | None = None,
92
+ ):
93
+ assert ttl_minutes == 10
94
+ return {"run_id": "run_3421", "status": "queued", "session_id": "session_1"}
95
+
96
+ monkeypatch.setattr(main.ApiClient, "start_url_validation", fake_start)
97
+ monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
98
+
99
+ args = main.build_parser().parse_args(
100
+ ["test", "url", "--url", "https://demo-app.com", "--prompt", "test login flow", "--json"]
101
+ )
102
+ exit_code = args.func(args)
103
+ output = capsys.readouterr().out
104
+
105
+ assert exit_code == 0
106
+ parsed = json.loads(output)
107
+ assert parsed == {"run_id": "run_3421", "status": "queued"}
@@ -1,113 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import sys
5
-
6
- import pytest
7
-
8
- from arga_cli import main
9
-
10
-
11
- def test_scan_start_polls_until_plan_ready_and_auto_approves(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
12
- monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
13
- monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
14
- monkeypatch.setattr(main.time, "sleep", lambda _: None)
15
-
16
- statuses = iter(
17
- [
18
- {"status": "planning"},
19
- {"status": "awaiting_approval"},
20
- ]
21
- )
22
-
23
- def fake_start(self, *, url: str, action_budget: int):
24
- assert url == "https://demo-app.com"
25
- assert action_budget == 200
26
- return {"run_id": "run_scan_123", "status": "planning"}
27
-
28
- def fake_get_run(self, run_id: str):
29
- assert run_id == "run_scan_123"
30
- status = next(statuses)
31
- return {
32
- "id": run_id,
33
- "status": status["status"],
34
- "frontend_url": "https://demo-app.com",
35
- "mode": "redteam",
36
- }
37
-
38
- def fake_approve(self, run_id: str):
39
- assert run_id == "run_scan_123"
40
- return {"run_id": run_id, "status": "queued"}
41
-
42
- monkeypatch.setattr(main.ApiClient, "start_redteam_scan", fake_start)
43
- monkeypatch.setattr(main.ApiClient, "get_run", fake_get_run)
44
- monkeypatch.setattr(main.ApiClient, "approve_redteam_scan", fake_approve)
45
-
46
- exit_code = main.run_scan_cli(["https://demo-app.com"])
47
- output = capsys.readouterr().out
48
-
49
- assert exit_code == 0
50
- assert "Starting agent run..." in output
51
- assert "URL: https://demo-app.com" in output
52
- assert "Budget: 200" in output
53
- assert "Run ID: run_scan_123" in output
54
- assert "Status: queued" in output
55
-
56
-
57
- def test_scan_status_prints_summary(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
58
- monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
59
- monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
60
-
61
- def fake_get_run(self, run_id: str):
62
- assert run_id == "run_scan_123"
63
- return {
64
- "id": run_id,
65
- "status": "running",
66
- "frontend_url": "https://demo-app.com",
67
- "mode": "redteam",
68
- "redteam_report_json": {"anomalies": [{"title": "Issue 1"}, {"title": "Issue 2"}]},
69
- }
70
-
71
- monkeypatch.setattr(main.ApiClient, "get_run", fake_get_run)
72
-
73
- exit_code = main.run_scan_cli(["status", "run_scan_123"])
74
- output = capsys.readouterr().out
75
-
76
- assert exit_code == 0
77
- assert "Run ID: run_scan_123" in output
78
- assert "Status: running" in output
79
- assert "Anomalies: 2" in output
80
-
81
-
82
- def test_scan_report_prints_json(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
83
- monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
84
- monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
85
-
86
- def fake_report(self, run_id: str):
87
- assert run_id == "run_scan_123"
88
- return {"summary": "done", "anomalies": [{"title": "Issue 1"}]}
89
-
90
- monkeypatch.setattr(main.ApiClient, "get_redteam_report", fake_report)
91
-
92
- exit_code = main.run_scan_cli(["report", "run_scan_123"])
93
- output = capsys.readouterr().out
94
-
95
- assert exit_code == 0
96
- assert json.loads(output) == {"summary": "done", "anomalies": [{"title": "Issue 1"}]}
97
-
98
-
99
- def test_main_dispatches_scan_wrapper_before_argparse(monkeypatch: pytest.MonkeyPatch) -> None:
100
- captured: dict[str, object] = {}
101
-
102
- def fake_scan(argv: list[str]) -> int:
103
- captured["argv"] = argv
104
- return 0
105
-
106
- monkeypatch.setattr(main, "run_scan_cli", fake_scan)
107
- monkeypatch.setattr(sys, "argv", ["arga", "scan", "status", "run_scan_123"])
108
-
109
- with pytest.raises(SystemExit) as exc_info:
110
- main.main()
111
-
112
- assert exc_info.value.code == 0
113
- assert captured["argv"] == ["status", "run_scan_123"]
@@ -1,50 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
-
5
- from arga_cli import main
6
-
7
-
8
- def test_test_url_command_prints_run_id(monkeypatch, capsys) -> None:
9
- monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
10
-
11
- def fake_start(self, *, url: str, prompt: str, email: str | None = None, password: str | None = None):
12
- assert url == "https://demo-app.com"
13
- assert prompt == "test login flow"
14
- assert email is None
15
- assert password is None
16
- return {"run_id": "run_3421", "status": "queued", "session_id": "session_1"}
17
-
18
- monkeypatch.setattr(main.ApiClient, "start_url_validation", fake_start)
19
- monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
20
-
21
- args = main.build_parser().parse_args(
22
- ["test", "url", "--url", "https://demo-app.com", "--prompt", "test login flow"]
23
- )
24
- exit_code = args.func(args)
25
- output = capsys.readouterr().out
26
-
27
- assert exit_code == 0
28
- assert "Starting validation..." in output
29
- assert "Run ID: run_3421" in output
30
- assert "Status: queued" in output
31
-
32
-
33
- def test_test_url_json_flag(monkeypatch, capsys) -> None:
34
- monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
35
-
36
- def fake_start(self, *, url: str, prompt: str, email: str | None = None, password: str | None = None):
37
- return {"run_id": "run_3421", "status": "queued", "session_id": "session_1"}
38
-
39
- monkeypatch.setattr(main.ApiClient, "start_url_validation", fake_start)
40
- monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
41
-
42
- args = main.build_parser().parse_args(
43
- ["test", "url", "--url", "https://demo-app.com", "--prompt", "test login flow", "--json"]
44
- )
45
- exit_code = args.func(args)
46
- output = capsys.readouterr().out
47
-
48
- assert exit_code == 0
49
- parsed = json.loads(output)
50
- assert parsed == {"run_id": "run_3421", "status": "queued"}
File without changes
File without changes
File without changes
File without changes
File without changes