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.
- gax/__init__.py +3 -0
- gax/adapters/__init__.py +3 -0
- gax/adapters/base.py +25 -0
- gax/adapters/exec_adapter.py +261 -0
- gax/adapters/http_adapter.py +33 -0
- gax/adapters/k8s_adapter.py +338 -0
- gax/adapters/mcp_bridge.py +108 -0
- gax/adapters/mock_adapter.py +49 -0
- gax/audit.py +54 -0
- gax/caps.py +91 -0
- gax/cli.py +722 -0
- gax/client.py +58 -0
- gax/compliance.py +71 -0
- gax/config/oauth_providers.yaml +21 -0
- gax/config/policy.rego +20 -0
- gax/config/policy.yaml +59 -0
- gax/daemon.py +187 -0
- gax/envelope.py +64 -0
- gax/executor.py +151 -0
- gax/macaroons_cap.py +80 -0
- gax/manifests/aws.s3.list.yaml +20 -0
- gax/manifests/demo.echo.yaml +20 -0
- gax/manifests/gh.pr.list.yaml +41 -0
- gax/manifests/gh.pr.view.yaml +30 -0
- gax/manifests/jira.issue.get.yaml +18 -0
- gax/manifests/k8s.deployment.scale.yaml +35 -0
- gax/manifests/k8s.pod.delete.yaml +30 -0
- gax/manifests/kubectl.get.pods.yaml +20 -0
- gax/manifests/mcp.github.list_pulls.yaml +29 -0
- gax/manifests/pet_findpetsbystatus.yaml +19 -0
- gax/manifests/pet_getpetbyid.yaml +19 -0
- gax/mcp_client.py +110 -0
- gax/mcp_server.py +299 -0
- gax/oauth.py +184 -0
- gax/onboarding.py +397 -0
- gax/opa_policy.py +46 -0
- gax/openapi_gen.py +96 -0
- gax/otel_audit.py +50 -0
- gax/paths.py +46 -0
- gax/plan.py +151 -0
- gax/policy.py +33 -0
- gax/policy_bundle.py +64 -0
- gax/profiles/github/gh.issue.list.yaml +25 -0
- gax/profiles/github/gh.issue.view.yaml +23 -0
- gax/profiles/github/gh.pr.comment.yaml +30 -0
- gax/profiles/github/gh.pr.merge.yaml +29 -0
- gax/profiles/github/gh.run.list.yaml +22 -0
- gax/profiles/k8s/k8s.deployment.list.yaml +17 -0
- gax/profiles/k8s/k8s.deployment.restart.yaml +25 -0
- gax/profiles/k8s/k8s.namespace.delete.yaml +22 -0
- gax/profiles/k8s/k8s.pod.describe.yaml +22 -0
- gax/profiles/k8s/k8s.pod.logs.yaml +28 -0
- gax/profiles/k8s/k8s.service.list.yaml +17 -0
- gax/profiles.py +143 -0
- gax/projection.py +42 -0
- gax/registry.py +130 -0
- gax/schemas/envelope.v1.json +46 -0
- gax/secrets.py +49 -0
- gax/side_effects.py +67 -0
- gax/spiffe.py +32 -0
- gax/vault.py +69 -0
- gax_cli-0.4.0.dist-info/METADATA +164 -0
- gax_cli-0.4.0.dist-info/RECORD +66 -0
- gax_cli-0.4.0.dist-info/WHEEL +5 -0
- gax_cli-0.4.0.dist-info/entry_points.txt +4 -0
- 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={})
|