arga-cli 0.1.0__tar.gz → 0.1.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arga-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
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
@@ -115,6 +115,7 @@ List and inspect recent validation runs:
115
115
  ```bash
116
116
  arga runs list --repo arga-labs/validation-server --limit 20
117
117
  arga runs status <run_id>
118
+ arga runs logs <run_id>
118
119
  arga runs cancel <run_id>
119
120
  ```
120
121
 
@@ -186,6 +187,7 @@ arga scan report <run_id>
186
187
  ```bash
187
188
  arga runs list --repo arga-labs/validation-server --status running --limit 20
188
189
  arga runs status <run_id>
190
+ arga runs logs <run_id>
189
191
  arga runs cancel <run_id>
190
192
  ```
191
193
 
@@ -193,6 +195,9 @@ arga runs cancel <run_id>
193
195
  - `--repo` narrows the list to a single repository.
194
196
  - `--status` accepts `completed`, `failed`, or `running`. The `running` filter includes non-terminal states such as `queued`.
195
197
  - `arga runs status <run_id>` prints a detailed summary for a specific run.
198
+ - `arga runs logs <run_id>` prints worker logs plus recent runtime logs for a run you own.
199
+ - When you omit `<run_id>`, `arga runs logs` falls back to `./.arga-session.json` when present, which makes wizard-created twin sessions easy to inspect from the same directory.
200
+ - Add `--json` to `arga runs logs` for a machine-readable response.
196
201
  - `arga runs cancel <run_id>` cancels the run through the validation API.
197
202
 
198
203
  ### Git Wrappers
@@ -287,6 +292,34 @@ To install the current checkout as a shell command:
287
292
  uv tool install -e .
288
293
  ```
289
294
 
295
+ ## Releasing
296
+
297
+ This repo includes a GitHub Actions workflow at `.github/workflows/publish.yml` for trusted publishing.
298
+
299
+ One-time setup:
300
+
301
+ - Create GitHub Environments named `testpypi` and `pypi` in the repository settings.
302
+ - In TestPyPI, add a Trusted Publisher for this GitHub repository and workflow:
303
+ - owner: `ArgaLabs`
304
+ - repository: `arga-cli`
305
+ - workflow: `publish.yml`
306
+ - environment: `testpypi`
307
+ - In PyPI, add a Trusted Publisher with the same repository and workflow, but environment `pypi`.
308
+
309
+ Publishing flow:
310
+
311
+ - Manual TestPyPI publish: run the `Publish Package` workflow with `repository=testpypi`.
312
+ - Automatic PyPI publish: push a tag like `v0.1.0`.
313
+ - The workflow verifies that the tag version matches `project.version` in `pyproject.toml`, builds the package with `uv build`, and publishes with `uv publish`.
314
+
315
+ Typical release steps:
316
+
317
+ ```bash
318
+ uv run pytest
319
+ git tag v0.1.0
320
+ git push origin v0.1.0
321
+ ```
322
+
290
323
  ## Config Storage
291
324
 
292
325
  The CLI stores its local auth state in:
@@ -95,6 +95,7 @@ List and inspect recent validation runs:
95
95
  ```bash
96
96
  arga runs list --repo arga-labs/validation-server --limit 20
97
97
  arga runs status <run_id>
98
+ arga runs logs <run_id>
98
99
  arga runs cancel <run_id>
99
100
  ```
100
101
 
@@ -166,6 +167,7 @@ arga scan report <run_id>
166
167
  ```bash
167
168
  arga runs list --repo arga-labs/validation-server --status running --limit 20
168
169
  arga runs status <run_id>
170
+ arga runs logs <run_id>
169
171
  arga runs cancel <run_id>
170
172
  ```
171
173
 
@@ -173,6 +175,9 @@ arga runs cancel <run_id>
173
175
  - `--repo` narrows the list to a single repository.
174
176
  - `--status` accepts `completed`, `failed`, or `running`. The `running` filter includes non-terminal states such as `queued`.
175
177
  - `arga runs status <run_id>` prints a detailed summary for a specific run.
178
+ - `arga runs logs <run_id>` prints worker logs plus recent runtime logs for a run you own.
179
+ - When you omit `<run_id>`, `arga runs logs` falls back to `./.arga-session.json` when present, which makes wizard-created twin sessions easy to inspect from the same directory.
180
+ - Add `--json` to `arga runs logs` for a machine-readable response.
176
181
  - `arga runs cancel <run_id>` cancels the run through the validation API.
177
182
 
178
183
  ### Git Wrappers
@@ -267,6 +272,34 @@ To install the current checkout as a shell command:
267
272
  uv tool install -e .
268
273
  ```
269
274
 
275
+ ## Releasing
276
+
277
+ This repo includes a GitHub Actions workflow at `.github/workflows/publish.yml` for trusted publishing.
278
+
279
+ One-time setup:
280
+
281
+ - Create GitHub Environments named `testpypi` and `pypi` in the repository settings.
282
+ - In TestPyPI, add a Trusted Publisher for this GitHub repository and workflow:
283
+ - owner: `ArgaLabs`
284
+ - repository: `arga-cli`
285
+ - workflow: `publish.yml`
286
+ - environment: `testpypi`
287
+ - In PyPI, add a Trusted Publisher with the same repository and workflow, but environment `pypi`.
288
+
289
+ Publishing flow:
290
+
291
+ - Manual TestPyPI publish: run the `Publish Package` workflow with `repository=testpypi`.
292
+ - Automatic PyPI publish: push a tag like `v0.1.0`.
293
+ - The workflow verifies that the tag version matches `project.version` in `pyproject.toml`, builds the package with `uv build`, and publishes with `uv publish`.
294
+
295
+ Typical release steps:
296
+
297
+ ```bash
298
+ uv run pytest
299
+ git tag v0.1.0
300
+ git push origin v0.1.0
301
+ ```
302
+
270
303
  ## Config Storage
271
304
 
272
305
  The CLI stores its local auth state in:
@@ -20,6 +20,8 @@ from arga_cli.mcp import install_mcp_configuration
20
20
 
21
21
  DEFAULT_API_URL = os.environ.get("ARGA_API_URL", "https://api.argalabs.com")
22
22
  CONFIG_PATH = Path.home() / ".config" / "arga" / "config.json"
23
+ WIZARD_SESSION_FILE = ".arga-session.json"
24
+ WIZARD_SESSION_PATH = Path(WIZARD_SESSION_FILE)
23
25
  POLL_INTERVAL_SECONDS = 2.0
24
26
  POLL_TIMEOUT_SECONDS = 600.0
25
27
  SKIP_TRAILER = "[skip arga]"
@@ -128,6 +130,13 @@ class ApiClient:
128
130
  )
129
131
  return self._parse_json(response, "Failed to load run details")
130
132
 
133
+ def get_run_logs(self, run_id: str) -> dict[str, Any]:
134
+ response = self._client.get(
135
+ f"{self._api_url}/runs/{run_id}/logs",
136
+ headers=self._auth_headers(),
137
+ )
138
+ return self._parse_json(response, "Failed to load run logs")
139
+
131
140
  def get_redteam_report(self, run_id: str) -> dict[str, Any]:
132
141
  response = self._client.get(
133
142
  f"{self._api_url}/redteam/{run_id}/report",
@@ -251,6 +260,31 @@ def delete_api_key() -> bool:
251
260
  return True
252
261
 
253
262
 
263
+ def load_wizard_session(path: Path = WIZARD_SESSION_PATH) -> dict[str, Any] | None:
264
+ try:
265
+ data = json.loads(path.read_text())
266
+ except FileNotFoundError:
267
+ return None
268
+ except json.JSONDecodeError as exc:
269
+ raise CliError(f"Invalid wizard session file: {path}") from exc
270
+
271
+ if not isinstance(data, dict):
272
+ raise CliError(f"Invalid wizard session file: {path}")
273
+ return data
274
+
275
+
276
+ def resolve_logs_run_id(run_id: str | None, *, session_path: Path = WIZARD_SESSION_PATH) -> str:
277
+ if run_id:
278
+ return run_id
279
+ session = load_wizard_session(session_path)
280
+ session_run_id = str((session or {}).get("run_id") or "").strip()
281
+ if session_run_id:
282
+ return session_run_id
283
+ raise CliError(
284
+ f"Run ID is required. Pass one explicitly or run this command from a directory containing {WIZARD_SESSION_FILE}."
285
+ )
286
+
287
+
254
288
  def build_verification_url(start_payload: dict[str, str]) -> str:
255
289
  verification_url = start_payload["verification_url"]
256
290
  device_code = start_payload["device_code"]
@@ -330,6 +364,32 @@ def run_whoami(args: argparse.Namespace) -> int:
330
364
 
331
365
  print(f"Logged in as: {payload.get('github_login', 'unknown')}")
332
366
  print(f"Workspace: {payload.get('workspace', 'Unknown')}")
367
+
368
+ billing_plan = payload.get("billing_plan", "free")
369
+ print(f"Plan: {billing_plan}")
370
+
371
+ plan_limits = payload.get("plan_limits")
372
+ if plan_limits:
373
+ runs_remaining = plan_limits.get("validation_runs_remaining")
374
+ runs_limit = plan_limits.get("validation_runs_limit")
375
+ if runs_limit is not None:
376
+ print(f"Validation runs: {runs_remaining}/{runs_limit} remaining this month")
377
+ else:
378
+ print("Validation runs: unlimited")
379
+
380
+ ci_limit = plan_limits.get("ci_checks_limit")
381
+ ci_remaining = plan_limits.get("ci_checks_remaining")
382
+ if ci_limit is not None:
383
+ print(f"CI checks: {ci_remaining}/{ci_limit} remaining this month")
384
+ elif billing_plan in ("team", "paid"):
385
+ print("CI checks: unlimited")
386
+
387
+ max_twins = plan_limits.get("max_twins_per_run")
388
+ if max_twins is not None:
389
+ print(f"Twins per run: {max_twins}")
390
+ elif billing_plan in ("team", "paid"):
391
+ print("Twins per run: unlimited")
392
+
333
393
  return 0
334
394
 
335
395
 
@@ -349,6 +409,10 @@ def run_test_url(args: argparse.Namespace) -> int:
349
409
  finally:
350
410
  client.close()
351
411
 
412
+ if getattr(args, "json", False):
413
+ print(json.dumps({"run_id": payload.get("run_id"), "status": payload.get("status")}))
414
+ return 0
415
+
352
416
  print("Starting validation...\n")
353
417
  print(f"URL: {args.url}")
354
418
  print(f"Prompt: {args.prompt}\n")
@@ -365,6 +429,10 @@ def run_validate_pr(args: argparse.Namespace) -> int:
365
429
  finally:
366
430
  client.close()
367
431
 
432
+ if args.json:
433
+ print(json.dumps({"run_id": payload.get("run_id"), "status": payload.get("status")}))
434
+ return 0
435
+
368
436
  print("Starting PR validation...\n")
369
437
  print(f"Repository: {args.repo}")
370
438
  print(f"PR: #{args.pr}\n")
@@ -770,6 +838,105 @@ def _print_runs_table(runs: list[dict[str, Any]]) -> None:
770
838
  print(format_row(row))
771
839
 
772
840
 
841
+ def _print_worker_logs(worker_logs: list[dict[str, Any]]) -> None:
842
+ print("Worker Logs:")
843
+ if not worker_logs:
844
+ print("None")
845
+ return
846
+
847
+ for index, worker_log in enumerate(worker_logs):
848
+ job_id = str(worker_log.get("job_id") or "-")
849
+ metadata = [
850
+ str(worker_log.get("job_type") or "").strip(),
851
+ str(worker_log.get("target_role") or "").strip(),
852
+ str(worker_log.get("status") or "").strip(),
853
+ ]
854
+ metadata_label = " / ".join(value for value in metadata if value)
855
+ print(f"{job_id}: {metadata_label or 'worker log'}")
856
+
857
+ error = str(worker_log.get("error") or "").strip()
858
+ content = str(worker_log.get("content") or "").rstrip()
859
+ if error:
860
+ print(f"Error: {error}")
861
+ elif content:
862
+ print(content)
863
+ else:
864
+ print("No log content available.")
865
+
866
+ if index < len(worker_logs) - 1:
867
+ print()
868
+
869
+
870
+ def _print_runtime_logs(runtime_logs: list[dict[str, Any]]) -> None:
871
+ print("Runtime Logs:")
872
+ if not runtime_logs:
873
+ print("None")
874
+ return
875
+
876
+ for index, runtime_log in enumerate(runtime_logs):
877
+ header_parts = [
878
+ _format_timestamp(runtime_log.get("timestamp")),
879
+ str(runtime_log.get("severity") or "").strip(),
880
+ str(runtime_log.get("service_name") or "").strip(),
881
+ str(runtime_log.get("event") or "").strip(),
882
+ str(runtime_log.get("code") or "").strip(),
883
+ ]
884
+ header = " | ".join(part for part in header_parts if part and part != "-")
885
+ print(header or "Log entry")
886
+
887
+ message = str(runtime_log.get("message") or "").strip()
888
+ if message:
889
+ print(message)
890
+
891
+ metadata: list[str] = []
892
+ request_id = str(runtime_log.get("request_id") or "").strip()
893
+ if request_id:
894
+ metadata.append(f"request_id={request_id}")
895
+ job_id = str(runtime_log.get("job_id") or "").strip()
896
+ if job_id:
897
+ metadata.append(f"job_id={job_id}")
898
+ surface_name = str(runtime_log.get("surface_name") or "").strip()
899
+ if surface_name:
900
+ metadata.append(f"surface={surface_name}")
901
+ if metadata:
902
+ print(" ".join(metadata))
903
+
904
+ if index < len(runtime_logs) - 1:
905
+ print()
906
+
907
+
908
+ def _print_run_logs(payload: dict[str, Any], fallback_run_id: str) -> None:
909
+ run = payload.get("run")
910
+ run_data = run if isinstance(run, dict) else {}
911
+ worker_logs = payload.get("worker_logs")
912
+ runtime_logs = payload.get("runtime_logs")
913
+ warnings = payload.get("warnings")
914
+ timeline_events = run_data.get("event_log_json")
915
+
916
+ print(f"Run ID: {run_data.get('id', fallback_run_id)}")
917
+ print(f"Status: {run_data.get('status', 'unknown')}")
918
+ print(f"Type: {run_data.get('run_type', 'unknown')}")
919
+ print(f"Mode: {run_data.get('mode', 'unknown')}")
920
+ print(f"Repository: {run_data.get('repo_full_name') or '-'}")
921
+ print(f"PR/Branch: {_run_ref_label(run_data)}")
922
+ print(f"Commit: {run_data.get('commit_sha') or '-'}")
923
+ print(f"Created: {_format_timestamp(run_data.get('created_at'))}")
924
+ print(f"Environment URL: {run_data.get('environment_url') or '-'}")
925
+ if isinstance(timeline_events, list):
926
+ print(f"Timeline Events: {len(timeline_events)}")
927
+
928
+ print()
929
+ _print_worker_logs(worker_logs if isinstance(worker_logs, list) else [])
930
+ print()
931
+ _print_runtime_logs(runtime_logs if isinstance(runtime_logs, list) else [])
932
+
933
+ if isinstance(warnings, list) and warnings:
934
+ print()
935
+ print("Warnings:")
936
+ for warning in warnings:
937
+ print(f"- {warning}")
938
+
939
+
773
940
  def run_runs_list(args: argparse.Namespace) -> int:
774
941
  api_key = load_api_key()
775
942
  client = ApiClient(args.api_url, api_key=api_key)
@@ -783,6 +950,10 @@ def run_runs_list(args: argparse.Namespace) -> int:
783
950
  finally:
784
951
  client.close()
785
952
 
953
+ if args.json:
954
+ print(json.dumps(runs))
955
+ return 0
956
+
786
957
  if not runs:
787
958
  print("No matching validation runs found.")
788
959
  return 0
@@ -799,6 +970,10 @@ def run_runs_status(args: argparse.Namespace) -> int:
799
970
  finally:
800
971
  client.close()
801
972
 
973
+ if args.json:
974
+ print(json.dumps(run))
975
+ return 0
976
+
802
977
  print(f"Run ID: {run.get('id', args.run_id)}")
803
978
  print(f"Status: {run.get('status', 'unknown')}")
804
979
  print(f"Type: {run.get('run_type', 'unknown')}")
@@ -812,6 +987,23 @@ def run_runs_status(args: argparse.Namespace) -> int:
812
987
  return 0
813
988
 
814
989
 
990
+ def run_runs_logs(args: argparse.Namespace) -> int:
991
+ run_id = resolve_logs_run_id(args.run_id)
992
+ api_key = load_api_key()
993
+ client = ApiClient(args.api_url, api_key=api_key)
994
+ try:
995
+ payload = client.get_run_logs(run_id)
996
+ finally:
997
+ client.close()
998
+
999
+ if args.json:
1000
+ print(json.dumps(payload, indent=2))
1001
+ return 0
1002
+
1003
+ _print_run_logs(payload, run_id)
1004
+ return 0
1005
+
1006
+
815
1007
  def run_runs_cancel(args: argparse.Namespace) -> int:
816
1008
  api_key = load_api_key()
817
1009
  client = ApiClient(args.api_url, api_key=api_key)
@@ -988,6 +1180,28 @@ def run_push_cli(argv: list[str]) -> int:
988
1180
  return _run_git_command(["push", *git_args])
989
1181
 
990
1182
 
1183
+ def run_wizard(args: argparse.Namespace) -> int:
1184
+ """Launch the arga-wizard npm package, passing the stored API key."""
1185
+ try:
1186
+ api_key = load_api_key()
1187
+ except (NotAuthenticatedError, CliError):
1188
+ print("Not logged in. Run `arga login` first, or use `npx arga-wizard` directly.")
1189
+ return 1
1190
+
1191
+ cmd: list[str] = ["npx", "arga-wizard"]
1192
+ cmd.extend(["--api-key", api_key])
1193
+ if args.api_url != DEFAULT_API_URL:
1194
+ cmd.extend(["--api-url", args.api_url])
1195
+
1196
+ try:
1197
+ result = subprocess.run(cmd, check=False)
1198
+ return result.returncode
1199
+ except FileNotFoundError:
1200
+ print("Node.js is required for the wizard. Install it from https://nodejs.org")
1201
+ print("Or run the wizard directly: npx arga-wizard")
1202
+ return 1
1203
+
1204
+
991
1205
  def build_parser() -> argparse.ArgumentParser:
992
1206
  parser = argparse.ArgumentParser(prog="arga")
993
1207
  subparsers = parser.add_subparsers(dest="command", required=True)
@@ -1013,6 +1227,7 @@ def build_parser() -> argparse.ArgumentParser:
1013
1227
  test_url_parser.add_argument("--prompt", required=True, help="Natural language instructions for the agent")
1014
1228
  test_url_parser.add_argument("--email", help="Optional login email")
1015
1229
  test_url_parser.add_argument("--password", help="Optional login password")
1230
+ test_url_parser.add_argument("--json", action="store_true", default=False, help="Output result as JSON")
1016
1231
  test_url_parser.set_defaults(func=run_test_url)
1017
1232
 
1018
1233
  validate_parser = subparsers.add_parser("validate", help="Start PR or URL validation runs")
@@ -1022,6 +1237,7 @@ def build_parser() -> argparse.ArgumentParser:
1022
1237
  validate_pr_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1023
1238
  validate_pr_parser.add_argument("--repo", required=True, help="Repository in owner/repo format")
1024
1239
  validate_pr_parser.add_argument("--pr", required=True, type=int, help="Pull request number")
1240
+ validate_pr_parser.add_argument("--json", action="store_true", default=False, help="Output result as JSON")
1025
1241
  validate_pr_parser.set_defaults(func=run_validate_pr)
1026
1242
 
1027
1243
  validate_url_parser = validate_subparsers.add_parser(
@@ -1033,6 +1249,7 @@ def build_parser() -> argparse.ArgumentParser:
1033
1249
  validate_url_parser.add_argument("--prompt", required=True, help="Natural language instructions for the agent")
1034
1250
  validate_url_parser.add_argument("--email", help="Optional login email")
1035
1251
  validate_url_parser.add_argument("--password", help="Optional login password")
1252
+ validate_url_parser.add_argument("--json", action="store_true", default=False, help="Output result as JSON")
1036
1253
  validate_url_parser.set_defaults(func=run_test_url)
1037
1254
 
1038
1255
  mcp_parser = subparsers.add_parser("mcp", help="Manage MCP integrations")
@@ -1045,7 +1262,7 @@ def build_parser() -> argparse.ArgumentParser:
1045
1262
  mcp_install_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1046
1263
  mcp_install_parser.set_defaults(func=run_mcp_install)
1047
1264
 
1048
- runs_parser = subparsers.add_parser("runs", help="List, inspect, or cancel validation runs")
1265
+ runs_parser = subparsers.add_parser("runs", help="List, inspect, cancel, or read validation run logs")
1049
1266
  runs_subparsers = runs_parser.add_subparsers(dest="runs_command", required=True)
1050
1267
 
1051
1268
  runs_list_parser = runs_subparsers.add_parser("list", help="List recent validation runs")
@@ -1057,18 +1274,34 @@ def build_parser() -> argparse.ArgumentParser:
1057
1274
  help="Filter by validation status",
1058
1275
  )
1059
1276
  runs_list_parser.add_argument("--limit", type=int, default=20, help="Maximum number of runs to show")
1277
+ runs_list_parser.add_argument("--json", action="store_true", default=False, help="Output result as JSON")
1060
1278
  runs_list_parser.set_defaults(func=run_runs_list)
1061
1279
 
1062
1280
  runs_status_parser = runs_subparsers.add_parser("status", help="Show detailed status for a validation run")
1063
1281
  runs_status_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1064
1282
  runs_status_parser.add_argument("run_id", help="Validation run ID")
1283
+ runs_status_parser.add_argument("--json", action="store_true", default=False, help="Output result as JSON")
1065
1284
  runs_status_parser.set_defaults(func=run_runs_status)
1066
1285
 
1286
+ runs_logs_parser = runs_subparsers.add_parser("logs", help="Show worker and runtime logs for a validation run")
1287
+ runs_logs_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1288
+ runs_logs_parser.add_argument(
1289
+ "run_id",
1290
+ nargs="?",
1291
+ help=f"Validation run ID. Defaults to {WIZARD_SESSION_FILE} in the current directory when available.",
1292
+ )
1293
+ runs_logs_parser.add_argument("--json", action="store_true", help="Print the raw JSON response")
1294
+ runs_logs_parser.set_defaults(func=run_runs_logs)
1295
+
1067
1296
  runs_cancel_parser = runs_subparsers.add_parser("cancel", help="Cancel a validation run")
1068
1297
  runs_cancel_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1069
1298
  runs_cancel_parser.add_argument("run_id", help="Validation run ID")
1070
1299
  runs_cancel_parser.set_defaults(func=run_runs_cancel)
1071
1300
 
1301
+ wizard_parser = subparsers.add_parser("wizard", help="Launch the twins quickstart wizard")
1302
+ wizard_parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Arga API base URL")
1303
+ wizard_parser.set_defaults(func=run_wizard)
1304
+
1072
1305
  subparsers.add_parser("commit", help="Wrap git commit and optionally mark it to skip Arga validation")
1073
1306
  subparsers.add_parser("push", help="Wrap git push and verify skip state when requested")
1074
1307
  subparsers.add_parser("scan", help="Start an app scan or inspect a scan run")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arga-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
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
@@ -115,6 +115,7 @@ List and inspect recent validation runs:
115
115
  ```bash
116
116
  arga runs list --repo arga-labs/validation-server --limit 20
117
117
  arga runs status <run_id>
118
+ arga runs logs <run_id>
118
119
  arga runs cancel <run_id>
119
120
  ```
120
121
 
@@ -186,6 +187,7 @@ arga scan report <run_id>
186
187
  ```bash
187
188
  arga runs list --repo arga-labs/validation-server --status running --limit 20
188
189
  arga runs status <run_id>
190
+ arga runs logs <run_id>
189
191
  arga runs cancel <run_id>
190
192
  ```
191
193
 
@@ -193,6 +195,9 @@ arga runs cancel <run_id>
193
195
  - `--repo` narrows the list to a single repository.
194
196
  - `--status` accepts `completed`, `failed`, or `running`. The `running` filter includes non-terminal states such as `queued`.
195
197
  - `arga runs status <run_id>` prints a detailed summary for a specific run.
198
+ - `arga runs logs <run_id>` prints worker logs plus recent runtime logs for a run you own.
199
+ - When you omit `<run_id>`, `arga runs logs` falls back to `./.arga-session.json` when present, which makes wizard-created twin sessions easy to inspect from the same directory.
200
+ - Add `--json` to `arga runs logs` for a machine-readable response.
196
201
  - `arga runs cancel <run_id>` cancels the run through the validation API.
197
202
 
198
203
  ### Git Wrappers
@@ -287,6 +292,34 @@ To install the current checkout as a shell command:
287
292
  uv tool install -e .
288
293
  ```
289
294
 
295
+ ## Releasing
296
+
297
+ This repo includes a GitHub Actions workflow at `.github/workflows/publish.yml` for trusted publishing.
298
+
299
+ One-time setup:
300
+
301
+ - Create GitHub Environments named `testpypi` and `pypi` in the repository settings.
302
+ - In TestPyPI, add a Trusted Publisher for this GitHub repository and workflow:
303
+ - owner: `ArgaLabs`
304
+ - repository: `arga-cli`
305
+ - workflow: `publish.yml`
306
+ - environment: `testpypi`
307
+ - In PyPI, add a Trusted Publisher with the same repository and workflow, but environment `pypi`.
308
+
309
+ Publishing flow:
310
+
311
+ - Manual TestPyPI publish: run the `Publish Package` workflow with `repository=testpypi`.
312
+ - Automatic PyPI publish: push a tag like `v0.1.0`.
313
+ - The workflow verifies that the tag version matches `project.version` in `pyproject.toml`, builds the package with `uv build`, and publishes with `uv publish`.
314
+
315
+ Typical release steps:
316
+
317
+ ```bash
318
+ uv run pytest
319
+ git tag v0.1.0
320
+ git push origin v0.1.0
321
+ ```
322
+
290
323
  ## Config Storage
291
324
 
292
325
  The CLI stores its local auth state in:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "arga-cli"
7
- version = "0.1.0"
7
+ version = "0.1.2"
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,337 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from arga_cli import main
6
+
7
+
8
+ def test_runs_list_prints_filtered_table(monkeypatch, capsys) -> None:
9
+ monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
10
+ monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
11
+
12
+ def fake_list(self, *, repo: str | None = None, limit: int = 20, offset: int = 0):
13
+ assert repo == "arga-labs/validation-server"
14
+ if offset == 0:
15
+ return {
16
+ "items": [
17
+ {
18
+ "run_id": "run_completed",
19
+ "status": "completed",
20
+ "repo": "arga-labs/validation-server",
21
+ "pr_number": 182,
22
+ "created_at": "2026-03-25T12:30:00Z",
23
+ },
24
+ {
25
+ "run_id": "run_failed",
26
+ "status": "failed",
27
+ "repo": "arga-labs/validation-server",
28
+ "branch": "main",
29
+ "created_at": "2026-03-25T12:00:00Z",
30
+ },
31
+ ],
32
+ "limit": limit,
33
+ "offset": offset,
34
+ "has_more": False,
35
+ "total": 2,
36
+ }
37
+ raise AssertionError("Unexpected extra page request")
38
+
39
+ monkeypatch.setattr(main.ApiClient, "list_pr_validation_runs", fake_list)
40
+
41
+ args = main.build_parser().parse_args(
42
+ ["runs", "list", "--repo", "arga-labs/validation-server", "--status", "failed", "--limit", "20"]
43
+ )
44
+ exit_code = args.func(args)
45
+ output = capsys.readouterr().out
46
+
47
+ assert exit_code == 0
48
+ assert "RUN_ID" in output
49
+ assert "run_failed" in output
50
+ assert "main" in output
51
+ assert "run_completed" not in output
52
+
53
+
54
+ def test_runs_list_running_filter_includes_non_terminal_statuses(monkeypatch, capsys) -> None:
55
+ monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
56
+ monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
57
+
58
+ def fake_list(self, *, repo: str | None = None, limit: int = 20, offset: int = 0):
59
+ return {
60
+ "items": [
61
+ {
62
+ "run_id": "run_queued",
63
+ "status": "queued",
64
+ "repo": "arga-labs/validation-server",
65
+ "branch": "feature/arga",
66
+ "created_at": "2026-03-25T13:00:00Z",
67
+ },
68
+ {
69
+ "run_id": "run_completed",
70
+ "status": "completed",
71
+ "repo": "arga-labs/validation-server",
72
+ "branch": "main",
73
+ "created_at": "2026-03-25T12:00:00Z",
74
+ },
75
+ ],
76
+ "limit": limit,
77
+ "offset": offset,
78
+ "has_more": False,
79
+ "total": 2,
80
+ }
81
+
82
+ monkeypatch.setattr(main.ApiClient, "list_pr_validation_runs", fake_list)
83
+
84
+ args = main.build_parser().parse_args(["runs", "list", "--status", "running"])
85
+ exit_code = args.func(args)
86
+ output = capsys.readouterr().out
87
+
88
+ assert exit_code == 0
89
+ assert "run_queued" in output
90
+ assert "run_completed" not in output
91
+
92
+
93
+ def test_runs_status_prints_detail_summary(monkeypatch, capsys) -> None:
94
+ monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
95
+ monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
96
+
97
+ def fake_get_run(self, run_id: str):
98
+ assert run_id == "run_123"
99
+ return {
100
+ "id": run_id,
101
+ "status": "running",
102
+ "run_type": "pr",
103
+ "mode": "auto_visual",
104
+ "repo_full_name": "arga-labs/validation-server",
105
+ "github_pr_number": 182,
106
+ "commit_sha": "abc123",
107
+ "created_at": "2026-03-25T12:30:00Z",
108
+ "environment_url": "https://preview.example.com",
109
+ "session_id": "session_1",
110
+ }
111
+
112
+ monkeypatch.setattr(main.ApiClient, "get_run", fake_get_run)
113
+
114
+ args = main.build_parser().parse_args(["runs", "status", "run_123"])
115
+ exit_code = args.func(args)
116
+ output = capsys.readouterr().out
117
+
118
+ assert exit_code == 0
119
+ assert "Run ID: run_123" in output
120
+ assert "Status: running" in output
121
+ assert "Repository: arga-labs/validation-server" in output
122
+ assert "PR/Branch: PR #182" in output
123
+
124
+
125
+ def test_runs_list_json_flag(monkeypatch, capsys) -> None:
126
+ monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
127
+ monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
128
+
129
+ def fake_list(self, *, repo: str | None = None, limit: int = 20, offset: int = 0):
130
+ return {
131
+ "items": [
132
+ {
133
+ "run_id": "run_1",
134
+ "status": "completed",
135
+ "repo": "arga-labs/validation-server",
136
+ "pr_number": 182,
137
+ "created_at": "2026-03-25T12:30:00Z",
138
+ },
139
+ {
140
+ "run_id": "run_2",
141
+ "status": "failed",
142
+ "repo": "arga-labs/validation-server",
143
+ "branch": "main",
144
+ "created_at": "2026-03-25T12:00:00Z",
145
+ },
146
+ ],
147
+ "limit": limit,
148
+ "offset": offset,
149
+ "has_more": False,
150
+ "total": 2,
151
+ }
152
+
153
+ monkeypatch.setattr(main.ApiClient, "list_pr_validation_runs", fake_list)
154
+
155
+ args = main.build_parser().parse_args(["runs", "list", "--json"])
156
+ exit_code = args.func(args)
157
+ output = capsys.readouterr().out
158
+
159
+ assert exit_code == 0
160
+ parsed = json.loads(output)
161
+ assert isinstance(parsed, list)
162
+ assert len(parsed) == 2
163
+ assert parsed[0]["run_id"] == "run_1"
164
+ assert parsed[1]["run_id"] == "run_2"
165
+
166
+
167
+ def test_runs_list_json_flag_empty(monkeypatch, capsys) -> None:
168
+ monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
169
+ monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
170
+
171
+ def fake_list(self, *, repo: str | None = None, limit: int = 20, offset: int = 0):
172
+ return {"items": [], "limit": limit, "offset": offset, "has_more": False, "total": 0}
173
+
174
+ monkeypatch.setattr(main.ApiClient, "list_pr_validation_runs", fake_list)
175
+
176
+ args = main.build_parser().parse_args(["runs", "list", "--json"])
177
+ exit_code = args.func(args)
178
+ output = capsys.readouterr().out
179
+
180
+ assert exit_code == 0
181
+ assert json.loads(output) == []
182
+
183
+
184
+ def test_runs_status_json_flag(monkeypatch, capsys) -> None:
185
+ monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
186
+ monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
187
+
188
+ run_data = {
189
+ "id": "run_123",
190
+ "status": "running",
191
+ "run_type": "pr",
192
+ "mode": "auto_visual",
193
+ "repo_full_name": "arga-labs/validation-server",
194
+ "github_pr_number": 182,
195
+ "commit_sha": "abc123",
196
+ "created_at": "2026-03-25T12:30:00Z",
197
+ "environment_url": "https://preview.example.com",
198
+ "session_id": "session_1",
199
+ }
200
+
201
+ def fake_get_run(self, run_id: str):
202
+ return run_data
203
+
204
+ monkeypatch.setattr(main.ApiClient, "get_run", fake_get_run)
205
+
206
+ args = main.build_parser().parse_args(["runs", "status", "run_123", "--json"])
207
+ exit_code = args.func(args)
208
+ output = capsys.readouterr().out
209
+
210
+ assert exit_code == 0
211
+ parsed = json.loads(output)
212
+ assert parsed == run_data
213
+
214
+
215
+ def test_runs_logs_prints_worker_and_runtime_logs(monkeypatch, capsys) -> None:
216
+ monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
217
+ monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
218
+
219
+ def fake_get_logs(self, run_id: str):
220
+ assert run_id == "run_123"
221
+ return {
222
+ "run": {
223
+ "id": run_id,
224
+ "status": "ready",
225
+ "run_type": "twin_quickstart",
226
+ "mode": "staging",
227
+ "repo_full_name": None,
228
+ "commit_sha": None,
229
+ "created_at": "2026-03-25T12:30:00Z",
230
+ "environment_url": "https://preview.example.com",
231
+ "event_log_json": [{"type": "environment_ready"}],
232
+ },
233
+ "worker_logs": [
234
+ {
235
+ "job_id": "job_1",
236
+ "job_type": "deploy",
237
+ "target_role": "warm-vm",
238
+ "status": "succeeded",
239
+ "content": "deploy output",
240
+ "truncated": False,
241
+ "error": None,
242
+ }
243
+ ],
244
+ "runtime_logs": [
245
+ {
246
+ "timestamp": "2026-03-25T12:31:00Z",
247
+ "service_name": "arga-api",
248
+ "severity": "INFO",
249
+ "event": "environment_ready",
250
+ "code": "ok",
251
+ "request_id": "req_1",
252
+ "job_id": "job_1",
253
+ "surface_name": "app",
254
+ "message": "Environment ready.",
255
+ }
256
+ ],
257
+ "warnings": ["Cloud Logging query was partially truncated."],
258
+ }
259
+
260
+ monkeypatch.setattr(main.ApiClient, "get_run_logs", fake_get_logs)
261
+
262
+ args = main.build_parser().parse_args(["runs", "logs", "run_123"])
263
+ exit_code = args.func(args)
264
+ output = capsys.readouterr().out
265
+
266
+ assert exit_code == 0
267
+ assert "Run ID: run_123" in output
268
+ assert "Worker Logs:" in output
269
+ assert "deploy output" in output
270
+ assert "Runtime Logs:" in output
271
+ assert "Environment ready." in output
272
+ assert "Warnings:" in output
273
+
274
+
275
+ def test_runs_logs_prints_json(monkeypatch, capsys) -> None:
276
+ monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
277
+ monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
278
+
279
+ payload = {
280
+ "run": {"id": "run_123", "status": "ready"},
281
+ "worker_logs": [],
282
+ "runtime_logs": [],
283
+ "warnings": [],
284
+ }
285
+
286
+ monkeypatch.setattr(main.ApiClient, "get_run_logs", lambda self, run_id: payload)
287
+
288
+ args = main.build_parser().parse_args(["runs", "logs", "run_123", "--json"])
289
+ exit_code = args.func(args)
290
+ output = capsys.readouterr().out
291
+
292
+ assert exit_code == 0
293
+ assert json.loads(output) == payload
294
+
295
+
296
+ def test_runs_logs_uses_wizard_session_file_when_run_id_missing(monkeypatch, capsys, tmp_path) -> None:
297
+ monkeypatch.chdir(tmp_path)
298
+ (tmp_path / main.WIZARD_SESSION_FILE).write_text(json.dumps({"run_id": "run_from_session"}) + "\n")
299
+ monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
300
+ monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
301
+
302
+ def fake_get_logs(self, run_id: str):
303
+ assert run_id == "run_from_session"
304
+ return {
305
+ "run": {"id": run_id, "status": "ready"},
306
+ "worker_logs": [],
307
+ "runtime_logs": [],
308
+ "warnings": [],
309
+ }
310
+
311
+ monkeypatch.setattr(main.ApiClient, "get_run_logs", fake_get_logs)
312
+
313
+ args = main.build_parser().parse_args(["runs", "logs"])
314
+ exit_code = args.func(args)
315
+ output = capsys.readouterr().out
316
+
317
+ assert exit_code == 0
318
+ assert "Run ID: run_from_session" in output
319
+
320
+
321
+ def test_runs_cancel_prints_cancelled_status(monkeypatch, capsys) -> None:
322
+ monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
323
+ monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
324
+
325
+ def fake_cancel(self, run_id: str):
326
+ assert run_id == "run_123"
327
+ return {"status": "cancelled"}
328
+
329
+ monkeypatch.setattr(main.ApiClient, "cancel_validation_run", fake_cancel)
330
+
331
+ args = main.build_parser().parse_args(["runs", "cancel", "run_123"])
332
+ exit_code = args.func(args)
333
+ output = capsys.readouterr().out
334
+
335
+ assert exit_code == 0
336
+ assert "Run ID: run_123" in output
337
+ assert "Status: cancelled" in output
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
4
+
3
5
  from arga_cli import main
4
6
 
5
7
 
@@ -26,3 +28,23 @@ def test_test_url_command_prints_run_id(monkeypatch, capsys) -> None:
26
28
  assert "Starting validation..." in output
27
29
  assert "Run ID: run_3421" in output
28
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"}
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
4
+
3
5
  from arga_cli import main
4
6
 
5
7
 
@@ -51,3 +53,23 @@ def test_validate_url_alias_reuses_url_validation(monkeypatch, capsys) -> None:
51
53
  assert exit_code == 0
52
54
  assert "Starting validation..." in output
53
55
  assert "Run ID: run_123" in output
56
+
57
+
58
+ def test_validate_pr_json_flag(monkeypatch, capsys) -> None:
59
+ monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
60
+
61
+ def fake_start(self, *, repo: str, pr_number: int):
62
+ return {"run_id": "run_83921", "status": "queued"}
63
+
64
+ monkeypatch.setattr(main.ApiClient, "start_pr_validation", fake_start)
65
+ monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
66
+
67
+ args = main.build_parser().parse_args(
68
+ ["validate", "pr", "--repo", "arga-labs/validation-server", "--pr", "182", "--json"]
69
+ )
70
+ exit_code = args.func(args)
71
+ output = capsys.readouterr().out
72
+
73
+ assert exit_code == 0
74
+ parsed = json.loads(output)
75
+ assert parsed == {"run_id": "run_83921", "status": "queued"}
@@ -1,139 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from arga_cli import main
4
-
5
-
6
- def test_runs_list_prints_filtered_table(monkeypatch, capsys) -> None:
7
- monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
8
- monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
9
-
10
- def fake_list(self, *, repo: str | None = None, limit: int = 20, offset: int = 0):
11
- assert repo == "arga-labs/validation-server"
12
- if offset == 0:
13
- return {
14
- "items": [
15
- {
16
- "run_id": "run_completed",
17
- "status": "completed",
18
- "repo": "arga-labs/validation-server",
19
- "pr_number": 182,
20
- "created_at": "2026-03-25T12:30:00Z",
21
- },
22
- {
23
- "run_id": "run_failed",
24
- "status": "failed",
25
- "repo": "arga-labs/validation-server",
26
- "branch": "main",
27
- "created_at": "2026-03-25T12:00:00Z",
28
- },
29
- ],
30
- "limit": limit,
31
- "offset": offset,
32
- "has_more": False,
33
- "total": 2,
34
- }
35
- raise AssertionError("Unexpected extra page request")
36
-
37
- monkeypatch.setattr(main.ApiClient, "list_pr_validation_runs", fake_list)
38
-
39
- args = main.build_parser().parse_args(
40
- ["runs", "list", "--repo", "arga-labs/validation-server", "--status", "failed", "--limit", "20"]
41
- )
42
- exit_code = args.func(args)
43
- output = capsys.readouterr().out
44
-
45
- assert exit_code == 0
46
- assert "RUN_ID" in output
47
- assert "run_failed" in output
48
- assert "main" in output
49
- assert "run_completed" not in output
50
-
51
-
52
- def test_runs_list_running_filter_includes_non_terminal_statuses(monkeypatch, capsys) -> None:
53
- monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
54
- monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
55
-
56
- def fake_list(self, *, repo: str | None = None, limit: int = 20, offset: int = 0):
57
- return {
58
- "items": [
59
- {
60
- "run_id": "run_queued",
61
- "status": "queued",
62
- "repo": "arga-labs/validation-server",
63
- "branch": "feature/arga",
64
- "created_at": "2026-03-25T13:00:00Z",
65
- },
66
- {
67
- "run_id": "run_completed",
68
- "status": "completed",
69
- "repo": "arga-labs/validation-server",
70
- "branch": "main",
71
- "created_at": "2026-03-25T12:00:00Z",
72
- },
73
- ],
74
- "limit": limit,
75
- "offset": offset,
76
- "has_more": False,
77
- "total": 2,
78
- }
79
-
80
- monkeypatch.setattr(main.ApiClient, "list_pr_validation_runs", fake_list)
81
-
82
- args = main.build_parser().parse_args(["runs", "list", "--status", "running"])
83
- exit_code = args.func(args)
84
- output = capsys.readouterr().out
85
-
86
- assert exit_code == 0
87
- assert "run_queued" in output
88
- assert "run_completed" not in output
89
-
90
-
91
- def test_runs_status_prints_detail_summary(monkeypatch, capsys) -> None:
92
- monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
93
- monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
94
-
95
- def fake_get_run(self, run_id: str):
96
- assert run_id == "run_123"
97
- return {
98
- "id": run_id,
99
- "status": "running",
100
- "run_type": "pr",
101
- "mode": "auto_visual",
102
- "repo_full_name": "arga-labs/validation-server",
103
- "github_pr_number": 182,
104
- "commit_sha": "abc123",
105
- "created_at": "2026-03-25T12:30:00Z",
106
- "environment_url": "https://preview.example.com",
107
- "session_id": "session_1",
108
- }
109
-
110
- monkeypatch.setattr(main.ApiClient, "get_run", fake_get_run)
111
-
112
- args = main.build_parser().parse_args(["runs", "status", "run_123"])
113
- exit_code = args.func(args)
114
- output = capsys.readouterr().out
115
-
116
- assert exit_code == 0
117
- assert "Run ID: run_123" in output
118
- assert "Status: running" in output
119
- assert "Repository: arga-labs/validation-server" in output
120
- assert "PR/Branch: PR #182" in output
121
-
122
-
123
- def test_runs_cancel_prints_cancelled_status(monkeypatch, capsys) -> None:
124
- monkeypatch.setattr(main, "load_api_key", lambda: "arga_api_key")
125
- monkeypatch.setattr(main.ApiClient, "close", lambda self: None)
126
-
127
- def fake_cancel(self, run_id: str):
128
- assert run_id == "run_123"
129
- return {"status": "cancelled"}
130
-
131
- monkeypatch.setattr(main.ApiClient, "cancel_validation_run", fake_cancel)
132
-
133
- args = main.build_parser().parse_args(["runs", "cancel", "run_123"])
134
- exit_code = args.func(args)
135
- output = capsys.readouterr().out
136
-
137
- assert exit_code == 0
138
- assert "Run ID: run_123" in output
139
- assert "Status: cancelled" in output
File without changes
File without changes
File without changes
File without changes
File without changes