gax-cli 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. gax/__init__.py +3 -0
  2. gax/adapters/__init__.py +3 -0
  3. gax/adapters/base.py +25 -0
  4. gax/adapters/exec_adapter.py +261 -0
  5. gax/adapters/http_adapter.py +33 -0
  6. gax/adapters/k8s_adapter.py +338 -0
  7. gax/adapters/mcp_bridge.py +108 -0
  8. gax/adapters/mock_adapter.py +49 -0
  9. gax/audit.py +54 -0
  10. gax/caps.py +91 -0
  11. gax/cli.py +722 -0
  12. gax/client.py +58 -0
  13. gax/compliance.py +71 -0
  14. gax/config/oauth_providers.yaml +21 -0
  15. gax/config/policy.rego +20 -0
  16. gax/config/policy.yaml +59 -0
  17. gax/daemon.py +187 -0
  18. gax/envelope.py +64 -0
  19. gax/executor.py +151 -0
  20. gax/macaroons_cap.py +80 -0
  21. gax/manifests/aws.s3.list.yaml +20 -0
  22. gax/manifests/demo.echo.yaml +20 -0
  23. gax/manifests/gh.pr.list.yaml +41 -0
  24. gax/manifests/gh.pr.view.yaml +30 -0
  25. gax/manifests/jira.issue.get.yaml +18 -0
  26. gax/manifests/k8s.deployment.scale.yaml +35 -0
  27. gax/manifests/k8s.pod.delete.yaml +30 -0
  28. gax/manifests/kubectl.get.pods.yaml +20 -0
  29. gax/manifests/mcp.github.list_pulls.yaml +29 -0
  30. gax/manifests/pet_findpetsbystatus.yaml +19 -0
  31. gax/manifests/pet_getpetbyid.yaml +19 -0
  32. gax/mcp_client.py +110 -0
  33. gax/mcp_server.py +299 -0
  34. gax/oauth.py +184 -0
  35. gax/onboarding.py +397 -0
  36. gax/opa_policy.py +46 -0
  37. gax/openapi_gen.py +96 -0
  38. gax/otel_audit.py +50 -0
  39. gax/paths.py +46 -0
  40. gax/plan.py +151 -0
  41. gax/policy.py +33 -0
  42. gax/policy_bundle.py +64 -0
  43. gax/profiles/github/gh.issue.list.yaml +25 -0
  44. gax/profiles/github/gh.issue.view.yaml +23 -0
  45. gax/profiles/github/gh.pr.comment.yaml +30 -0
  46. gax/profiles/github/gh.pr.merge.yaml +29 -0
  47. gax/profiles/github/gh.run.list.yaml +22 -0
  48. gax/profiles/k8s/k8s.deployment.list.yaml +17 -0
  49. gax/profiles/k8s/k8s.deployment.restart.yaml +25 -0
  50. gax/profiles/k8s/k8s.namespace.delete.yaml +22 -0
  51. gax/profiles/k8s/k8s.pod.describe.yaml +22 -0
  52. gax/profiles/k8s/k8s.pod.logs.yaml +28 -0
  53. gax/profiles/k8s/k8s.service.list.yaml +17 -0
  54. gax/profiles.py +143 -0
  55. gax/projection.py +42 -0
  56. gax/registry.py +130 -0
  57. gax/schemas/envelope.v1.json +46 -0
  58. gax/secrets.py +49 -0
  59. gax/side_effects.py +67 -0
  60. gax/spiffe.py +32 -0
  61. gax/vault.py +69 -0
  62. gax_cli-0.4.0.dist-info/METADATA +164 -0
  63. gax_cli-0.4.0.dist-info/RECORD +66 -0
  64. gax_cli-0.4.0.dist-info/WHEEL +5 -0
  65. gax_cli-0.4.0.dist-info/entry_points.txt +4 -0
  66. gax_cli-0.4.0.dist-info/top_level.txt +1 -0
gax/cli.py ADDED
@@ -0,0 +1,722 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from gax.caps import mint_capability
12
+ from gax.client import remote_doc, remote_invoke, remote_schema, remote_search
13
+ from gax.executor import invoke
14
+ from gax.paths import DEFAULT_HOST, DEFAULT_PORT, PID_PATH
15
+ from gax.registry import Registry
16
+
17
+ _REGISTRY = Registry()
18
+
19
+
20
+ def _print_json(obj: object) -> None:
21
+ click.echo(json.dumps(obj, indent=2))
22
+
23
+
24
+ def _capability() -> str | None:
25
+ """
26
+ Capability for this invocation: explicit env wins, else the one `gax init`
27
+ saved. Falling back to the saved file is what makes the first run work
28
+ without the user having to export anything.
29
+ """
30
+ from gax.onboarding import read_saved_cap
31
+
32
+ return os.environ.get("GAX_CAP", "").strip() or read_saved_cap()
33
+
34
+
35
+ def _hint(message: str, fix: str) -> None:
36
+ """First-run errors should say what to do, not just what failed."""
37
+ click.echo(f"\n {message}\n → {fix}\n", err=True)
38
+
39
+
40
+ def _run_local(command: str, args: dict, surface: str) -> int:
41
+ cap = _capability()
42
+ if not cap:
43
+ _print_json(
44
+ {
45
+ "ok": False,
46
+ "error": {
47
+ "kind": "capability_invalid",
48
+ "message": "no capability token found",
49
+ },
50
+ }
51
+ )
52
+ _hint(
53
+ "No capability token.",
54
+ 'Run "gax init", then: eval "$(gax init --print-export)"',
55
+ )
56
+ return 3
57
+ env, code = invoke(
58
+ _REGISTRY, command=command, args=args, surface=surface, capability=cap
59
+ )
60
+ _print_json(env)
61
+ return code
62
+
63
+
64
+ def _run_remote(command: str, args: dict, surface: str, host: str, port: int) -> int:
65
+ try:
66
+ env, code = remote_invoke(
67
+ command, args, surface=surface, host=host, port=port, capability=_capability()
68
+ )
69
+ except Exception as e:
70
+ click.echo(json.dumps({"ok": False, "error": str(e)}), err=True)
71
+ if _is_connection_error(e):
72
+ _hint(
73
+ f"gaxd is not running on {host}:{port}.",
74
+ 'Run "gax init" (starts it), or "gaxd start --background", '
75
+ 'or use "gax --local <command>" to run without the sidecar.',
76
+ )
77
+ return 1
78
+ _print_json(env)
79
+ return code
80
+
81
+
82
+ def _is_connection_error(e: Exception) -> bool:
83
+ text = str(e).lower()
84
+ return "connect" in text or "refused" in text or "errno 61" in text
85
+
86
+
87
+ @click.group()
88
+ @click.option("--local", is_flag=True, help="Run in-process without gaxd")
89
+ @click.option("--host", default=DEFAULT_HOST, envvar="GAX_HOST")
90
+ @click.option("--port", default=DEFAULT_PORT, type=int, envvar="GAX_PORT")
91
+ @click.pass_context
92
+ def main(ctx: click.Context, local: bool, host: str, port: int) -> None:
93
+ ctx.ensure_object(dict)
94
+ ctx.obj["local"] = local
95
+ ctx.obj["host"] = host
96
+ ctx.obj["port"] = port
97
+
98
+
99
+ @main.command("init")
100
+ @click.option("--force", is_flag=True, help="Re-mint the dev capability even if valid")
101
+ @click.option("--no-daemon", is_flag=True, help="Set up but do not start gaxd")
102
+ @click.option(
103
+ "--profile",
104
+ "profiles_",
105
+ multiple=True,
106
+ help="Install a command profile (repeatable): k8s, github",
107
+ )
108
+ @click.option(
109
+ "--print-export",
110
+ is_flag=True,
111
+ help='Print only: export GAX_CAP="..." (use with eval)',
112
+ )
113
+ @click.pass_context
114
+ def init_cmd(
115
+ ctx: click.Context,
116
+ force: bool,
117
+ no_daemon: bool,
118
+ profiles_: tuple[str, ...],
119
+ print_export: bool,
120
+ ) -> None:
121
+ """Set up ~/.gax, mint a dev capability, and start the sidecar."""
122
+ from gax.onboarding import run_init
123
+
124
+ host, port = ctx.obj["host"], ctx.obj["port"]
125
+
126
+ if print_export:
127
+ # Quiet path for eval "$(gax init --print-export)" — no daemon side effects.
128
+ report = run_init(host=host, port=port, start_daemon=False, force=force)
129
+ click.echo(f'export GAX_CAP="{report["capability"]}"')
130
+ return
131
+
132
+ report = run_init(
133
+ host=host,
134
+ port=port,
135
+ start_daemon=not no_daemon,
136
+ force=force,
137
+ profiles=list(profiles_),
138
+ )
139
+
140
+ click.echo("\nGAX is ready.\n")
141
+ for step in report["steps"]:
142
+ click.echo(f" ✓ {step}")
143
+
144
+ if not report["daemon_running"] and not no_daemon:
145
+ click.echo(
146
+ "\n ! gaxd did not start — commands still work with: gax --local <cmd>"
147
+ )
148
+
149
+ click.echo("\nTry it:\n")
150
+ click.echo(" gax demo.echo --message hello")
151
+ click.echo(" gax search 'pull requests'")
152
+ click.echo("\nConnect an agent (Claude Code, Cursor, any MCP client):\n")
153
+ click.echo(" claude mcp add gax -- gax-mcp")
154
+ click.echo("\nFor scripts and other shells:\n")
155
+ click.echo(' eval "$(gax init --print-export)"')
156
+ click.echo(
157
+ "\nThe dev capability is read-only. To run destructive commands, mint one"
158
+ "\nexplicitly:\n"
159
+ )
160
+ click.echo(
161
+ " gax auth cap-mint --command k8s.pod.delete --scope k8s:pods:write \\\n"
162
+ " --max-side-effect destructive --raw"
163
+ )
164
+ click.echo("\nCheck setup any time with: gax doctor\n")
165
+
166
+
167
+ @main.group("profile")
168
+ def profile_group() -> None:
169
+ """Bundled command sets (k8s, github)."""
170
+
171
+
172
+ @profile_group.command("list")
173
+ def profile_list() -> None:
174
+ """Show available profiles and what they register."""
175
+ from gax.profiles import available_profiles, installed_commands
176
+
177
+ profiles = available_profiles()
178
+ if not profiles:
179
+ click.echo("No profiles bundled with this install.")
180
+ return
181
+
182
+ present = installed_commands()
183
+ click.echo("")
184
+ for name, p in profiles.items():
185
+ have = sum(1 for c in p.commands if c.command in present)
186
+ mark = "installed" if have == len(p.commands) else (
187
+ f"{have}/{len(p.commands)} installed" if have else "available"
188
+ )
189
+ click.echo(f" {name:<10} {len(p.commands)} commands ({p.summary}) [{mark}]")
190
+ for c in p.commands:
191
+ flag = {"read": " ", "write": "~", "destructive": "!"}.get(c.side_effects, "?")
192
+ click.echo(f" {flag} {c.command:<28} {c.description[:44]}")
193
+ click.echo("")
194
+ click.echo(" Legend: read ~ write ! destructive\n")
195
+ click.echo(" Install: gax profile add <name>\n")
196
+
197
+
198
+ @profile_group.command("add")
199
+ @click.argument("name")
200
+ @click.option("--force", is_flag=True, help="Overwrite manifests already installed")
201
+ def profile_add(name: str, force: bool) -> None:
202
+ """Install a profile's commands into ~/.gax/manifests/."""
203
+ from gax.profiles import available_profiles, install_profile
204
+
205
+ try:
206
+ result = install_profile(name, force=force)
207
+ except KeyError:
208
+ names = ", ".join(available_profiles()) or "(none)"
209
+ raise click.ClickException(f"unknown profile '{name}'; available: {names}")
210
+
211
+ click.echo(f"\n Installed profile '{name}' → {result['target']}")
212
+ click.echo(f" {len(result['installed'])} command(s) added: {result['summary']}")
213
+ if result["skipped"]:
214
+ click.echo(
215
+ f" {len(result['skipped'])} already present (use --force to overwrite)"
216
+ )
217
+ click.echo(
218
+ "\n Installing a profile grants nothing on its own — invoking still needs a"
219
+ "\n capability naming the command, holding its scope, and reaching its ceiling."
220
+ )
221
+ click.echo("\n Mint one for the read-only commands:\n")
222
+ click.echo(f" gax init --profile {name} --force")
223
+ click.echo("\n Or for a specific destructive command:\n")
224
+ destructive = [
225
+ c for c in (available_profiles()[name].commands) if c.side_effects == "destructive"
226
+ ]
227
+ example = destructive[0] if destructive else available_profiles()[name].commands[0]
228
+ click.echo(
229
+ f" gax auth cap-mint --command {example.command} \\\n"
230
+ f" --scope {(example.required_scopes or ['<scope>'])[0]} \\\n"
231
+ f" --max-side-effect {example.side_effects} --raw"
232
+ )
233
+ click.echo("\n Restart gaxd to pick up new commands: gaxd stop && gaxd start --background\n")
234
+
235
+
236
+ @main.command("doctor")
237
+ @click.pass_context
238
+ def doctor_cmd(ctx: click.Context) -> None:
239
+ """Diagnose setup: config, capability, sidecar, backends."""
240
+ from gax.onboarding import run_doctor
241
+
242
+ checks = run_doctor(host=ctx.obj["host"], port=ctx.obj["port"])
243
+ click.echo("")
244
+ for c in checks:
245
+ click.echo(f" {c.symbol} {c.name:<18} {c.detail}")
246
+ if not c.ok and c.fix:
247
+ click.echo(f" → {c.fix}")
248
+ failures = [c for c in checks if not c.ok]
249
+ click.echo("")
250
+ if failures:
251
+ click.echo(f" {len(failures)} issue(s) found.\n")
252
+ sys.exit(1)
253
+ click.echo(" All checks passed.\n")
254
+
255
+
256
+ @main.group()
257
+ def auth() -> None:
258
+ """Authentication and capabilities."""
259
+
260
+
261
+ @auth.command("login")
262
+ @click.option("--tenant", default="default", help="Tenant id")
263
+ @click.option(
264
+ "--provider",
265
+ default="github",
266
+ help="OAuth provider from config/oauth_providers.yaml",
267
+ )
268
+ @click.option("--no-browser", is_flag=True, help="Do not open verification URL")
269
+ def auth_login(tenant: str, provider: str, no_browser: bool) -> None:
270
+ """OAuth 2.0 device flow (RFC 8628); stores tokens under ~/.gax/tokens/."""
271
+ from gax.oauth import device_flow_login, load_providers
272
+ from gax.paths import CONFIG_PATH, ensure_gax_home
273
+
274
+ providers = load_providers()
275
+ if provider not in providers:
276
+ names = ", ".join(providers) or "(none — check config/oauth_providers.yaml)"
277
+ raise click.ClickException(f"unknown provider '{provider}'; available: {names}")
278
+
279
+ ensure_gax_home()
280
+ cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
281
+ cfg["tenant_id"] = tenant
282
+ CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
283
+
284
+ click.echo(f"Starting device flow for provider={provider} tenant={tenant}")
285
+ tokens = device_flow_login(
286
+ providers[provider],
287
+ tenant=tenant,
288
+ open_browser=not no_browser,
289
+ )
290
+ scope = tokens.get("scope", "")
291
+ click.echo(f"Saved tokens to ~/.gax/tokens/{tenant}/{provider}.json")
292
+ if scope:
293
+ click.echo(f"Scopes: {scope}")
294
+ click.echo("Mint capability: gax auth cap-from-oauth --export")
295
+
296
+
297
+ @auth.command("cap-from-oauth")
298
+ @click.option("--tenant", default=None)
299
+ @click.option("--provider", default="github")
300
+ @click.option("--command", "commands", multiple=True)
301
+ @click.option("--ttl", default=3600, type=int)
302
+ @click.option("--export", is_flag=True)
303
+ def cap_from_oauth(
304
+ tenant: str | None,
305
+ provider: str,
306
+ commands: tuple[str, ...],
307
+ ttl: int,
308
+ export: bool,
309
+ ) -> None:
310
+ """Mint GAX_CAP JWT using scopes from stored OAuth tokens."""
311
+ from gax.oauth import mint_cap_from_oauth
312
+ from gax.paths import CONFIG_PATH
313
+
314
+ tid = tenant
315
+ if not tid and CONFIG_PATH.exists():
316
+ tid = json.loads(CONFIG_PATH.read_text()).get("tenant_id", "default")
317
+ tid = tid or "default"
318
+ cmd_list = list(commands) if commands else ["*"]
319
+ token = mint_cap_from_oauth(tid, provider, commands=cmd_list, ttl_seconds=ttl)
320
+ if export:
321
+ click.echo(f'export GAX_CAP="{token}"')
322
+ else:
323
+ click.echo(token)
324
+
325
+
326
+ @auth.command("status")
327
+ @click.option("--tenant", default=None)
328
+ def auth_status(tenant: str | None) -> None:
329
+ """Show stored OAuth providers for tenant."""
330
+ from gax.oauth import load_tokens
331
+ from gax.paths import CONFIG_PATH, GAX_HOME
332
+
333
+ tid = tenant
334
+ if not tid and CONFIG_PATH.exists():
335
+ tid = json.loads(CONFIG_PATH.read_text()).get("tenant_id", "default")
336
+ tid = tid or "default"
337
+ tok_dir = GAX_HOME / "tokens" / tid
338
+ if not tok_dir.exists():
339
+ click.echo(f"No OAuth tokens for tenant={tid}")
340
+ return
341
+ for p in sorted(tok_dir.glob("*.json")):
342
+ data = json.loads(p.read_text())
343
+ click.echo(
344
+ f"{p.stem}: access_token=*** scopes={data.get('scope', data.get('scopes', '?'))}"
345
+ )
346
+
347
+
348
+ @auth.command("cap-mint")
349
+ @click.option("--tenant", default=None)
350
+ @click.option("--command", "commands", multiple=True, help="Allowed commands (repeatable)")
351
+ @click.option("--scope", "scopes", multiple=True, help="Scopes (repeatable)")
352
+ @click.option("--ttl", default=3600, type=int, help="TTL seconds")
353
+ @click.option("--macaroon", is_flag=True, help="Emit macaroon-style cap instead of JWT")
354
+ @click.option("--export", is_flag=True, help="Print export GAX_CAP=... line (use with eval)")
355
+ @click.option("--raw", is_flag=True, help="Print the bare token, no quotes or prefix")
356
+ @click.option(
357
+ "--max-side-effect",
358
+ type=click.Choice(["read", "write", "destructive"]),
359
+ default="read",
360
+ show_default=True,
361
+ help="Danger ceiling. A command above this is refused even if allowlisted.",
362
+ )
363
+ def cap_mint(
364
+ tenant: str | None,
365
+ commands: tuple[str, ...],
366
+ scopes: tuple[str, ...],
367
+ ttl: int,
368
+ macaroon: bool,
369
+ export: bool,
370
+ raw: bool,
371
+ max_side_effect: str,
372
+ ) -> None:
373
+ from gax.paths import CONFIG_PATH
374
+
375
+ cmd_list = list(commands) if commands else ["*"]
376
+ scope_list = list(scopes) if scopes else ["*"]
377
+ tid = tenant
378
+ if not tid and CONFIG_PATH.exists():
379
+ tid = json.loads(CONFIG_PATH.read_text()).get("tenant_id", "default")
380
+ if macaroon:
381
+ from gax.macaroons_cap import mint_macaroon
382
+
383
+ token = mint_macaroon(
384
+ tenant_id=tid or "default",
385
+ subject="dev@local",
386
+ commands=cmd_list,
387
+ scopes=scope_list,
388
+ ttl_seconds=ttl,
389
+ )
390
+ else:
391
+ token = mint_capability(
392
+ tenant_id=tenant,
393
+ commands=cmd_list,
394
+ scopes=scope_list,
395
+ ttl_seconds=ttl,
396
+ max_side_effect=max_side_effect,
397
+ )
398
+ if export and not raw:
399
+ click.echo(f'export GAX_CAP="{token}"')
400
+ else:
401
+ click.echo(token)
402
+
403
+
404
+ @main.group()
405
+ def daemon() -> None:
406
+ """Manage gaxd sidecar."""
407
+
408
+
409
+ @daemon.command("start")
410
+ @click.option("--background", is_flag=True)
411
+ @click.option("--host", default=DEFAULT_HOST)
412
+ @click.option("--port", default=DEFAULT_PORT, type=int)
413
+ def daemon_start(background: bool, host: str, port: int) -> None:
414
+ args = [sys.executable, "-m", "gax.daemon", "start", "--host", host, "--port", str(port)]
415
+ if background:
416
+ args.append("--background")
417
+ subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
418
+ click.echo(f"gaxd starting on http://{host}:{port}")
419
+ else:
420
+ os.execv(sys.executable, [sys.executable, "-m", "gax.daemon", "start", "--host", host, "--port", str(port)])
421
+
422
+
423
+ @daemon.command("stop")
424
+ def daemon_stop() -> None:
425
+ subprocess.call([sys.executable, "-m", "gax.daemon", "stop"])
426
+
427
+
428
+ @daemon.command("status")
429
+ def daemon_status() -> None:
430
+ subprocess.call([sys.executable, "-m", "gax.daemon", "status"])
431
+
432
+
433
+ @main.command("search")
434
+ @click.argument("query")
435
+ @click.pass_context
436
+ def search_cmd(ctx: click.Context, query: str) -> None:
437
+ if ctx.obj["local"]:
438
+ hits = _REGISTRY.search(query)
439
+ _print_json(
440
+ {
441
+ "query": query,
442
+ "results": [
443
+ {"command": m.command, "description": m.description, "category": m.category}
444
+ for m in hits
445
+ ],
446
+ }
447
+ )
448
+ return
449
+ _print_json(remote_search(query, ctx.obj["host"], ctx.obj["port"]))
450
+
451
+
452
+ @main.command("doc")
453
+ @click.argument("command")
454
+ @click.pass_context
455
+ def doc_cmd(ctx: click.Context, command: str) -> None:
456
+ if ctx.obj["local"]:
457
+ stub = _REGISTRY.doc_stub(command)
458
+ if not stub:
459
+ click.echo(json.dumps({"error": "not_found"}), err=True)
460
+ sys.exit(4)
461
+ _print_json(stub)
462
+ return
463
+ try:
464
+ _print_json(remote_doc(command, ctx.obj["host"], ctx.obj["port"]))
465
+ except Exception:
466
+ sys.exit(4)
467
+
468
+
469
+ @main.command("schema")
470
+ @click.argument("command")
471
+ @click.pass_context
472
+ def schema_cmd(ctx: click.Context, command: str) -> None:
473
+ m = _REGISTRY.get(command)
474
+ if ctx.obj["local"]:
475
+ if not m:
476
+ sys.exit(4)
477
+ _print_json({"command": command, "input_schema": m.input_schema, "output_schema": m.output_schema})
478
+ return
479
+ try:
480
+ _print_json(remote_schema(command, ctx.obj["host"], ctx.obj["port"]))
481
+ except Exception:
482
+ sys.exit(4)
483
+
484
+
485
+ @main.group()
486
+ def plan() -> None:
487
+ """Multi-step DAG-style plans."""
488
+
489
+
490
+ @plan.command("run")
491
+ @click.argument("plan_file", type=click.Path(exists=True))
492
+ @click.option("--surface", default=None, help="Override plan surface")
493
+ @click.pass_context
494
+ def plan_run(ctx: click.Context, plan_file: str, surface: str | None) -> None:
495
+ """Run a YAML plan (sequential steps with {{ steps.* }} templates)."""
496
+ from gax.plan import load_plan, run_plan
497
+
498
+ spec = load_plan(plan_file)
499
+ surf = surface or spec.get("surface") or "model"
500
+
501
+ def _invoke(**kw: object) -> tuple[dict, int]:
502
+ if ctx.obj["local"]:
503
+ return invoke(_REGISTRY, capability=os.environ.get("GAX_CAP"), **kw) # type: ignore[arg-type]
504
+ env, code = remote_invoke(
505
+ str(kw["command"]),
506
+ dict(kw["args"]), # type: ignore[arg-type]
507
+ surface=str(kw["surface"]),
508
+ host=ctx.obj["host"],
509
+ port=ctx.obj["port"],
510
+ )
511
+ return env, code
512
+
513
+ env, code = run_plan(
514
+ _REGISTRY,
515
+ spec,
516
+ surface=surf,
517
+ capability=os.environ.get("GAX_CAP"),
518
+ invoke_fn=_invoke if not ctx.obj["local"] else None,
519
+ )
520
+ _print_json(env)
521
+ sys.exit(code)
522
+
523
+
524
+ @main.command("commands")
525
+ @click.pass_context
526
+ def commands_cmd(ctx: click.Context) -> None:
527
+ items = [
528
+ {"command": m.command, "version": m.version, "description": m.description}
529
+ for m in _REGISTRY.list_commands()
530
+ ]
531
+ _print_json({"commands": items})
532
+
533
+
534
+ @main.command("run")
535
+ @click.argument("command")
536
+ @click.option("--surface", default="model", type=click.Choice(["model", "human", "full"]))
537
+ @click.option("--repo", default=None, help="For gh.* commands")
538
+ @click.option("--number", type=int, default=None)
539
+ @click.option("--limit", type=int, default=None)
540
+ @click.option("--state", default=None)
541
+ @click.option("--message", default=None, help="For demo.echo")
542
+ @click.pass_context
543
+ def run_cmd(
544
+ ctx: click.Context,
545
+ command: str,
546
+ surface: str,
547
+ repo: str | None,
548
+ number: int | None,
549
+ limit: int | None,
550
+ state: str | None,
551
+ message: str | None,
552
+ ) -> None:
553
+ args: dict = {}
554
+ if repo:
555
+ args["repo"] = repo
556
+ if number is not None:
557
+ args["number"] = number
558
+ if limit is not None:
559
+ args["limit"] = limit
560
+ if state:
561
+ args["state"] = state
562
+ if message:
563
+ args["message"] = message
564
+ if ctx.obj["local"]:
565
+ sys.exit(_run_local(command, args, surface))
566
+ sys.exit(_run_remote(command, args, surface, ctx.obj["host"], ctx.obj["port"]))
567
+
568
+
569
+ @main.group()
570
+ def openapi() -> None:
571
+ """OpenAPI → GAX manifest generator."""
572
+
573
+
574
+ @openapi.command("generate")
575
+ @click.argument("spec_path", type=click.Path(exists=True))
576
+ @click.option("--out", "out_dir", default=None, help="Manifests dir (default: gax/manifests)")
577
+ @click.option("--prefix", default="api")
578
+ @click.option("--adapter", default="mock", type=click.Choice(["mock", "http"]))
579
+ def openapi_generate(spec_path: str, out_dir: str | None, prefix: str, adapter: str) -> None:
580
+ from gax.openapi_gen import generate_manifests, load_spec, write_manifests
581
+ from gax.paths import MANIFESTS_DIR
582
+
583
+ spec = load_spec(Path(spec_path))
584
+ manifests = generate_manifests(spec, prefix=prefix, adapter=adapter)
585
+ target = Path(out_dir) if out_dir else MANIFESTS_DIR
586
+ paths = write_manifests(manifests, target)
587
+ click.echo(f"Wrote {len(paths)} manifests to {target}")
588
+ click.echo("Restart gaxd or use --local to reload registry.")
589
+
590
+
591
+ @main.group()
592
+ def vault() -> None:
593
+ """Tenant secret vault (file or HashiCorp via GAX_HASHICORP_VAULT_ADDR)."""
594
+
595
+
596
+ @vault.command("put")
597
+ @click.argument("key")
598
+ @click.argument("value")
599
+ @click.option("--tenant", default=None)
600
+ def vault_put(key: str, value: str, tenant: str | None) -> None:
601
+ from gax.paths import CONFIG_PATH
602
+ from gax.vault import vault_put as vp
603
+
604
+ tid = tenant or "default"
605
+ if not tenant and CONFIG_PATH.exists():
606
+ tid = json.loads(CONFIG_PATH.read_text()).get("tenant_id", "default")
607
+ vp(tid, key, value)
608
+ click.echo(f"stored {key} for tenant={tid}")
609
+
610
+
611
+ @vault.command("get")
612
+ @click.argument("key")
613
+ @click.option("--tenant", default=None)
614
+ def vault_get(key: str, tenant: str | None) -> None:
615
+ from gax.paths import CONFIG_PATH
616
+ from gax.vault import vault_get as vg
617
+
618
+ tid = tenant or "default"
619
+ if not tenant and CONFIG_PATH.exists():
620
+ tid = json.loads(CONFIG_PATH.read_text()).get("tenant_id", "default")
621
+ val = vg(tid, key)
622
+ if val is None:
623
+ raise click.ClickException(f"key not found: {key}")
624
+ click.echo(val)
625
+
626
+
627
+ @main.group()
628
+ def compliance() -> None:
629
+ """Compliance exports (SOC2-aligned audit bundles)."""
630
+
631
+
632
+ @compliance.command("export")
633
+ @click.option("--format", "fmt", default="csv", type=click.Choice(["csv", "json"]))
634
+ @click.option("--out", default=None, help="Output path")
635
+ def compliance_export(fmt: str, out: str | None) -> None:
636
+ from gax.compliance import export_soc2_csv, export_soc2_json
637
+ from gax.paths import GAX_HOME
638
+
639
+ if fmt == "csv":
640
+ path = Path(out) if out else GAX_HOME / "exports" / "audit_soc2.csv"
641
+ n = export_soc2_csv(path)
642
+ click.echo(f"exported {n} records to {path}")
643
+ else:
644
+ path = Path(out) if out else GAX_HOME / "exports" / "audit_soc2.json"
645
+ bundle = export_soc2_json(path)
646
+ click.echo(f"exported {bundle['record_count']} records to {path}")
647
+
648
+
649
+ # Dynamic command aliases: gax gh.pr.list ...
650
+ _JSON_TO_CLICK = {
651
+ "integer": int,
652
+ "number": float,
653
+ "boolean": bool,
654
+ "string": str,
655
+ }
656
+
657
+
658
+ def _options_from_schema(schema: dict) -> list:
659
+ """
660
+ Build click options from a manifest's input_schema.
661
+
662
+ Derived rather than hardcoded: a new manifest gets working CLI flags with no
663
+ change here. Booleans become `--flag/--no-flag` so `--dry-run` reads naturally.
664
+ """
665
+ props = (schema or {}).get("properties") or {}
666
+ required = set((schema or {}).get("required") or [])
667
+ opts = []
668
+ for name, spec in props.items():
669
+ flag = "--" + name.replace("_", "-")
670
+ jtype = (spec or {}).get("type", "string")
671
+ help_text = (spec or {}).get("description", "")
672
+ if required:
673
+ help_text = (help_text + (" [required]" if name in required else "")).strip()
674
+ if jtype == "boolean":
675
+ opts.append(
676
+ click.option(
677
+ f"{flag}/--no-{name.replace('_', '-')}",
678
+ name,
679
+ default=None,
680
+ help=help_text,
681
+ )
682
+ )
683
+ else:
684
+ opts.append(
685
+ click.option(
686
+ flag,
687
+ name,
688
+ type=_JSON_TO_CLICK.get(jtype, str),
689
+ default=None,
690
+ help=help_text,
691
+ )
692
+ )
693
+ return opts
694
+
695
+
696
+ def _register_command_aliases() -> None:
697
+ for m in _REGISTRY.list_commands():
698
+
699
+ def make_callback(cmd_name: str, schema: dict, description: str):
700
+ @click.pass_context
701
+ def cmd(ctx: click.Context, surface: str, **kwargs) -> None:
702
+ # Drop unset options so adapters see only what the user passed.
703
+ args = {k: v for k, v in kwargs.items() if v is not None}
704
+ if ctx.obj.get("local"):
705
+ sys.exit(_run_local(cmd_name, args, surface))
706
+ sys.exit(_run_remote(cmd_name, args, surface, ctx.obj["host"], ctx.obj["port"]))
707
+
708
+ cmd = click.option(
709
+ "--surface", default="model", type=click.Choice(["model", "human", "full"])
710
+ )(cmd)
711
+ for opt in reversed(_options_from_schema(schema)):
712
+ cmd = opt(cmd)
713
+ return click.command(name=cmd_name, help=description or None)(cmd)
714
+
715
+ main.add_command(make_callback(m.command, m.input_schema, m.description))
716
+
717
+
718
+ _register_command_aliases()
719
+
720
+
721
+ if __name__ == "__main__":
722
+ main(obj={})