gax-cli 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. gax_cli-0.4.0/PKG-INFO +164 -0
  2. gax_cli-0.4.0/README.md +129 -0
  3. gax_cli-0.4.0/gax/__init__.py +3 -0
  4. gax_cli-0.4.0/gax/adapters/__init__.py +3 -0
  5. gax_cli-0.4.0/gax/adapters/base.py +25 -0
  6. gax_cli-0.4.0/gax/adapters/exec_adapter.py +261 -0
  7. gax_cli-0.4.0/gax/adapters/http_adapter.py +33 -0
  8. gax_cli-0.4.0/gax/adapters/k8s_adapter.py +338 -0
  9. gax_cli-0.4.0/gax/adapters/mcp_bridge.py +108 -0
  10. gax_cli-0.4.0/gax/adapters/mock_adapter.py +49 -0
  11. gax_cli-0.4.0/gax/audit.py +54 -0
  12. gax_cli-0.4.0/gax/caps.py +91 -0
  13. gax_cli-0.4.0/gax/cli.py +722 -0
  14. gax_cli-0.4.0/gax/client.py +58 -0
  15. gax_cli-0.4.0/gax/compliance.py +71 -0
  16. gax_cli-0.4.0/gax/config/oauth_providers.yaml +21 -0
  17. gax_cli-0.4.0/gax/config/policy.rego +20 -0
  18. gax_cli-0.4.0/gax/config/policy.yaml +59 -0
  19. gax_cli-0.4.0/gax/daemon.py +187 -0
  20. gax_cli-0.4.0/gax/envelope.py +64 -0
  21. gax_cli-0.4.0/gax/executor.py +151 -0
  22. gax_cli-0.4.0/gax/macaroons_cap.py +80 -0
  23. gax_cli-0.4.0/gax/manifests/aws.s3.list.yaml +20 -0
  24. gax_cli-0.4.0/gax/manifests/demo.echo.yaml +20 -0
  25. gax_cli-0.4.0/gax/manifests/gh.pr.list.yaml +41 -0
  26. gax_cli-0.4.0/gax/manifests/gh.pr.view.yaml +30 -0
  27. gax_cli-0.4.0/gax/manifests/jira.issue.get.yaml +18 -0
  28. gax_cli-0.4.0/gax/manifests/k8s.deployment.scale.yaml +35 -0
  29. gax_cli-0.4.0/gax/manifests/k8s.pod.delete.yaml +30 -0
  30. gax_cli-0.4.0/gax/manifests/kubectl.get.pods.yaml +20 -0
  31. gax_cli-0.4.0/gax/manifests/mcp.github.list_pulls.yaml +29 -0
  32. gax_cli-0.4.0/gax/manifests/pet_findpetsbystatus.yaml +19 -0
  33. gax_cli-0.4.0/gax/manifests/pet_getpetbyid.yaml +19 -0
  34. gax_cli-0.4.0/gax/mcp_client.py +110 -0
  35. gax_cli-0.4.0/gax/mcp_server.py +299 -0
  36. gax_cli-0.4.0/gax/oauth.py +184 -0
  37. gax_cli-0.4.0/gax/onboarding.py +397 -0
  38. gax_cli-0.4.0/gax/opa_policy.py +46 -0
  39. gax_cli-0.4.0/gax/openapi_gen.py +96 -0
  40. gax_cli-0.4.0/gax/otel_audit.py +50 -0
  41. gax_cli-0.4.0/gax/paths.py +46 -0
  42. gax_cli-0.4.0/gax/plan.py +151 -0
  43. gax_cli-0.4.0/gax/policy.py +33 -0
  44. gax_cli-0.4.0/gax/policy_bundle.py +64 -0
  45. gax_cli-0.4.0/gax/profiles/github/gh.issue.list.yaml +25 -0
  46. gax_cli-0.4.0/gax/profiles/github/gh.issue.view.yaml +23 -0
  47. gax_cli-0.4.0/gax/profiles/github/gh.pr.comment.yaml +30 -0
  48. gax_cli-0.4.0/gax/profiles/github/gh.pr.merge.yaml +29 -0
  49. gax_cli-0.4.0/gax/profiles/github/gh.run.list.yaml +22 -0
  50. gax_cli-0.4.0/gax/profiles/k8s/k8s.deployment.list.yaml +17 -0
  51. gax_cli-0.4.0/gax/profiles/k8s/k8s.deployment.restart.yaml +25 -0
  52. gax_cli-0.4.0/gax/profiles/k8s/k8s.namespace.delete.yaml +22 -0
  53. gax_cli-0.4.0/gax/profiles/k8s/k8s.pod.describe.yaml +22 -0
  54. gax_cli-0.4.0/gax/profiles/k8s/k8s.pod.logs.yaml +28 -0
  55. gax_cli-0.4.0/gax/profiles/k8s/k8s.service.list.yaml +17 -0
  56. gax_cli-0.4.0/gax/profiles.py +143 -0
  57. gax_cli-0.4.0/gax/projection.py +42 -0
  58. gax_cli-0.4.0/gax/registry.py +130 -0
  59. gax_cli-0.4.0/gax/schemas/envelope.v1.json +46 -0
  60. gax_cli-0.4.0/gax/secrets.py +49 -0
  61. gax_cli-0.4.0/gax/side_effects.py +67 -0
  62. gax_cli-0.4.0/gax/spiffe.py +32 -0
  63. gax_cli-0.4.0/gax/vault.py +69 -0
  64. gax_cli-0.4.0/gax_cli.egg-info/PKG-INFO +164 -0
  65. gax_cli-0.4.0/gax_cli.egg-info/SOURCES.txt +82 -0
  66. gax_cli-0.4.0/gax_cli.egg-info/dependency_links.txt +1 -0
  67. gax_cli-0.4.0/gax_cli.egg-info/entry_points.txt +4 -0
  68. gax_cli-0.4.0/gax_cli.egg-info/requires.txt +11 -0
  69. gax_cli-0.4.0/gax_cli.egg-info/top_level.txt +1 -0
  70. gax_cli-0.4.0/pyproject.toml +83 -0
  71. gax_cli-0.4.0/setup.cfg +4 -0
  72. gax_cli-0.4.0/tests/test_acsp_envelope_conformance.py +89 -0
  73. gax_cli-0.4.0/tests/test_agent_governance.py +66 -0
  74. gax_cli-0.4.0/tests/test_eval_extended.py +82 -0
  75. gax_cli-0.4.0/tests/test_eval_scoring.py +256 -0
  76. gax_cli-0.4.0/tests/test_gax.py +81 -0
  77. gax_cli-0.4.0/tests/test_macaroon_policy.py +67 -0
  78. gax_cli-0.4.0/tests/test_mcp_bridge_mock.py +40 -0
  79. gax_cli-0.4.0/tests/test_mcp_server.py +204 -0
  80. gax_cli-0.4.0/tests/test_onboarding.py +237 -0
  81. gax_cli-0.4.0/tests/test_phase34.py +82 -0
  82. gax_cli-0.4.0/tests/test_plan.py +38 -0
  83. gax_cli-0.4.0/tests/test_profiles.py +237 -0
  84. gax_cli-0.4.0/tests/test_side_effect_ceiling.py +324 -0
gax_cli-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: gax-cli
3
+ Version: 0.4.0
4
+ Summary: Governed execution layer for agent shell commands — capability-gated, audited, MCP-compatible
5
+ Author: Sparsh Nagpal
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/0sparsh2/GAX
8
+ Project-URL: Repository, https://github.com/0sparsh2/GAX
9
+ Project-URL: Documentation, https://github.com/0sparsh2/GAX/blob/main/docs/QUICKSTART.md
10
+ Project-URL: Issues, https://github.com/0sparsh2/GAX/issues
11
+ Keywords: ai-agents,mcp,model-context-protocol,agent-governance,capability-security,audit,llm-tools
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Topic :: System :: Systems Administration
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ Requires-Dist: click>=8.1
27
+ Requires-Dist: pyyaml>=6.0
28
+ Requires-Dist: jsonschema>=4.20
29
+ Requires-Dist: PyJWT>=2.8
30
+ Requires-Dist: httpx>=0.27
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=8.0; extra == "dev"
33
+ Provides-Extra: keyring
34
+ Requires-Dist: keyring>=25.0; extra == "keyring"
35
+
36
+ # GAX — Governed Agent eXecution
37
+
38
+ **Your MCP gateway can't see `bash`. GAX governs both.**
39
+
40
+ GAX is a governed execution layer for agent shell commands. Agents get a
41
+ command-line-shaped surface; OAuth, policy, audit, and tenancy live in a sidecar the
42
+ model never sees.
43
+
44
+ The guarantee: **an agent can only run commands you registered, every invoke is checked
45
+ against a capability token before any backend runs, and every invoke produces an
46
+ `audit_id`.**
47
+
48
+ It **complements MCP** rather than competing with it — MCP servers become adapters behind
49
+ stable command names, so one policy file and one audit trail cover both your MCP calls
50
+ and your shell calls.
51
+
52
+ Full docs, research, and evaluation: **https://github.com/0sparsh2/GAX**
53
+
54
+ ---
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pip install gax-cli
60
+ gax init --profile k8s --profile github
61
+ ```
62
+
63
+ That creates `~/.gax`, mints a 30-day **read-only** capability, starts the sidecar, and
64
+ registers 22 real commands. Measured on a clean machine: about a second.
65
+
66
+ ```bash
67
+ gax k8s.pod.logs --pod web-1 # works immediately, no exports needed
68
+ gax doctor # diagnose config, capability, sidecar, backends
69
+ ```
70
+
71
+ ## See it stop something dangerous
72
+
73
+ ```bash
74
+ export GAX_K8S_MOCK=1 # no cluster needed
75
+ gax k8s.namespace.delete --namespace prod
76
+ ```
77
+
78
+ ```json
79
+ {
80
+ "ok": false,
81
+ "error": {
82
+ "kind": "policy_denied",
83
+ "message": "side_effects 'destructive' exceeds capability ceiling 'read'"
84
+ },
85
+ "audit_id": "aud_cef808f0fadd41e7"
86
+ }
87
+ ```
88
+
89
+ Refused before `kubectl` was ever spawned, and the denial is audited with its arguments.
90
+
91
+ Even if the command sits on the token's allowlist it is *still* refused, because the
92
+ danger ceiling is a separate control — **two independent things must be wrong before
93
+ something gets deleted.**
94
+
95
+ To run it, raise the ceiling deliberately, with `--dry-run` available first:
96
+
97
+ ```bash
98
+ export GAX_CAP="$(gax auth cap-mint --command k8s.namespace.delete \
99
+ --scope k8s:namespaces:write --max-side-effect destructive --raw)"
100
+
101
+ gax k8s.namespace.delete --namespace staging --dry-run
102
+ ```
103
+
104
+ ## Use it from any MCP client
105
+
106
+ ```bash
107
+ claude mcp add gax -- gax-mcp
108
+ ```
109
+
110
+ Publishes exactly three tools — `gax_search`, `gax_doc`, `gax_invoke` — keeping the
111
+ registry behind them, so schema cost stays constant no matter how many commands you
112
+ register.
113
+
114
+ ## Add your own command
115
+
116
+ One YAML file in `~/.gax/manifests/`:
117
+
118
+ ```yaml
119
+ command: db.migration.run
120
+ version: "1.0.0"
121
+ description: Apply pending migrations
122
+ adapter: exec
123
+ required_scopes: [db:schema:write]
124
+ side_effects: destructive # sets the ceiling required to invoke it
125
+ input_schema:
126
+ type: object
127
+ properties:
128
+ env: { type: string, description: Target environment }
129
+ dry_run: { type: boolean, description: Validate without applying }
130
+ required: [env]
131
+ ```
132
+
133
+ CLI flags are generated from the schema. Omit `side_effects` and GAX fails closed —
134
+ undeclared commands are treated as `destructive`.
135
+
136
+ Generate manifests instead of writing them: `gax openapi generate spec.json`.
137
+
138
+ ## Commands
139
+
140
+ | | |
141
+ |---|---|
142
+ | `gax init` / `gax doctor` | Setup and diagnostics |
143
+ | `gax profile list` / `add` | Bundled command sets (`k8s`, `github`) |
144
+ | `gax search` / `doc` / `schema` | Lazy discovery |
145
+ | `gax <command>` | Invoke a registered command |
146
+ | `gax auth cap-mint` | Mint a capability (`--max-side-effect read\|write\|destructive`) |
147
+ | `gax auth login` | OAuth 2.0 device flow |
148
+ | `gax plan run <file>` | Multi-step / parallel workflows |
149
+ | `gax vault put` / `get` | Tenant secrets (file or HashiCorp) |
150
+ | `gax compliance export` | SOC2-aligned audit export |
151
+ | `gaxd start` / `stop` / `status` | Sidecar lifecycle |
152
+ | `gax-mcp` | Run GAX as an MCP server (stdio) |
153
+
154
+ Audit log: `~/.gax/audit.jsonl`, one JSON object per invocation, including denials.
155
+
156
+ ## Honest scope
157
+
158
+ - Raw CLI costs ~3.5× fewer tokens than GAX on like-for-like tasks. Governance is not
159
+ free, and we publish that number rather than a flattering one.
160
+ - Vault, SPIFFE, and OPA integrations are hooks and stubs, not production features.
161
+ - The evaluation is a self-assessment by the author; methodology and known defects are
162
+ documented in the repository.
163
+
164
+ MIT licensed. Issues and contributions: https://github.com/0sparsh2/GAX
@@ -0,0 +1,129 @@
1
+ # GAX — Governed Agent eXecution
2
+
3
+ **Your MCP gateway can't see `bash`. GAX governs both.**
4
+
5
+ GAX is a governed execution layer for agent shell commands. Agents get a
6
+ command-line-shaped surface; OAuth, policy, audit, and tenancy live in a sidecar the
7
+ model never sees.
8
+
9
+ The guarantee: **an agent can only run commands you registered, every invoke is checked
10
+ against a capability token before any backend runs, and every invoke produces an
11
+ `audit_id`.**
12
+
13
+ It **complements MCP** rather than competing with it — MCP servers become adapters behind
14
+ stable command names, so one policy file and one audit trail cover both your MCP calls
15
+ and your shell calls.
16
+
17
+ Full docs, research, and evaluation: **https://github.com/0sparsh2/GAX**
18
+
19
+ ---
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install gax-cli
25
+ gax init --profile k8s --profile github
26
+ ```
27
+
28
+ That creates `~/.gax`, mints a 30-day **read-only** capability, starts the sidecar, and
29
+ registers 22 real commands. Measured on a clean machine: about a second.
30
+
31
+ ```bash
32
+ gax k8s.pod.logs --pod web-1 # works immediately, no exports needed
33
+ gax doctor # diagnose config, capability, sidecar, backends
34
+ ```
35
+
36
+ ## See it stop something dangerous
37
+
38
+ ```bash
39
+ export GAX_K8S_MOCK=1 # no cluster needed
40
+ gax k8s.namespace.delete --namespace prod
41
+ ```
42
+
43
+ ```json
44
+ {
45
+ "ok": false,
46
+ "error": {
47
+ "kind": "policy_denied",
48
+ "message": "side_effects 'destructive' exceeds capability ceiling 'read'"
49
+ },
50
+ "audit_id": "aud_cef808f0fadd41e7"
51
+ }
52
+ ```
53
+
54
+ Refused before `kubectl` was ever spawned, and the denial is audited with its arguments.
55
+
56
+ Even if the command sits on the token's allowlist it is *still* refused, because the
57
+ danger ceiling is a separate control — **two independent things must be wrong before
58
+ something gets deleted.**
59
+
60
+ To run it, raise the ceiling deliberately, with `--dry-run` available first:
61
+
62
+ ```bash
63
+ export GAX_CAP="$(gax auth cap-mint --command k8s.namespace.delete \
64
+ --scope k8s:namespaces:write --max-side-effect destructive --raw)"
65
+
66
+ gax k8s.namespace.delete --namespace staging --dry-run
67
+ ```
68
+
69
+ ## Use it from any MCP client
70
+
71
+ ```bash
72
+ claude mcp add gax -- gax-mcp
73
+ ```
74
+
75
+ Publishes exactly three tools — `gax_search`, `gax_doc`, `gax_invoke` — keeping the
76
+ registry behind them, so schema cost stays constant no matter how many commands you
77
+ register.
78
+
79
+ ## Add your own command
80
+
81
+ One YAML file in `~/.gax/manifests/`:
82
+
83
+ ```yaml
84
+ command: db.migration.run
85
+ version: "1.0.0"
86
+ description: Apply pending migrations
87
+ adapter: exec
88
+ required_scopes: [db:schema:write]
89
+ side_effects: destructive # sets the ceiling required to invoke it
90
+ input_schema:
91
+ type: object
92
+ properties:
93
+ env: { type: string, description: Target environment }
94
+ dry_run: { type: boolean, description: Validate without applying }
95
+ required: [env]
96
+ ```
97
+
98
+ CLI flags are generated from the schema. Omit `side_effects` and GAX fails closed —
99
+ undeclared commands are treated as `destructive`.
100
+
101
+ Generate manifests instead of writing them: `gax openapi generate spec.json`.
102
+
103
+ ## Commands
104
+
105
+ | | |
106
+ |---|---|
107
+ | `gax init` / `gax doctor` | Setup and diagnostics |
108
+ | `gax profile list` / `add` | Bundled command sets (`k8s`, `github`) |
109
+ | `gax search` / `doc` / `schema` | Lazy discovery |
110
+ | `gax <command>` | Invoke a registered command |
111
+ | `gax auth cap-mint` | Mint a capability (`--max-side-effect read\|write\|destructive`) |
112
+ | `gax auth login` | OAuth 2.0 device flow |
113
+ | `gax plan run <file>` | Multi-step / parallel workflows |
114
+ | `gax vault put` / `get` | Tenant secrets (file or HashiCorp) |
115
+ | `gax compliance export` | SOC2-aligned audit export |
116
+ | `gaxd start` / `stop` / `status` | Sidecar lifecycle |
117
+ | `gax-mcp` | Run GAX as an MCP server (stdio) |
118
+
119
+ Audit log: `~/.gax/audit.jsonl`, one JSON object per invocation, including denials.
120
+
121
+ ## Honest scope
122
+
123
+ - Raw CLI costs ~3.5× fewer tokens than GAX on like-for-like tasks. Governance is not
124
+ free, and we publish that number rather than a flattering one.
125
+ - Vault, SPIFFE, and OPA integrations are hooks and stubs, not production features.
126
+ - The evaluation is a self-assessment by the author; methodology and known defects are
127
+ documented in the repository.
128
+
129
+ MIT licensed. Issues and contributions: https://github.com/0sparsh2/GAX
@@ -0,0 +1,3 @@
1
+ """GAX — Governed Agent eXecution."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from gax.adapters.base import run_adapter
2
+
3
+ __all__ = ["run_adapter"]
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from gax.adapters import exec_adapter, http_adapter, k8s_adapter, mock_adapter, mcp_bridge
6
+ from gax.registry import CommandManifest
7
+
8
+
9
+ def run_adapter(
10
+ manifest: CommandManifest,
11
+ args: dict[str, Any],
12
+ *,
13
+ tenant_id: str | None = None,
14
+ ) -> dict[str, Any]:
15
+ if manifest.adapter == "k8s":
16
+ return k8s_adapter.run(manifest, args, tenant_id=tenant_id)
17
+ if manifest.adapter == "exec":
18
+ return exec_adapter.run(manifest, args, tenant_id=tenant_id)
19
+ if manifest.adapter == "mock":
20
+ return mock_adapter.run(manifest, args)
21
+ if manifest.adapter == "mcp":
22
+ return mcp_bridge.run(manifest, args, tenant_id=tenant_id)
23
+ if manifest.adapter == "http":
24
+ return http_adapter.run(manifest, args, tenant_id=tenant_id)
25
+ raise RuntimeError(f"unknown adapter: {manifest.adapter}")
@@ -0,0 +1,261 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ from typing import Any
9
+
10
+ from gax.adapters import mock_adapter
11
+ from gax.oauth import load_tokens
12
+ from gax.registry import CommandManifest
13
+
14
+
15
+ def _gh_available() -> bool:
16
+ return shutil.which("gh") is not None
17
+
18
+
19
+ def _gh_env(tenant_id: str | None) -> dict[str, str]:
20
+ env = os.environ.copy()
21
+ if tenant_id:
22
+ tokens = load_tokens(tenant_id, "github")
23
+ if tokens and tokens.get("access_token"):
24
+ env["GH_TOKEN"] = tokens["access_token"]
25
+ return env
26
+
27
+
28
+ def run(
29
+ manifest: CommandManifest,
30
+ args: dict[str, Any],
31
+ *,
32
+ tenant_id: str | None = None,
33
+ ) -> dict[str, Any]:
34
+ if not _gh_available():
35
+ return mock_adapter.run(manifest, args)
36
+
37
+ name = _HANDLERS.get(manifest.command)
38
+ if name is None:
39
+ raise RuntimeError(f"exec adapter has no handler for {manifest.command}")
40
+ # Resolve the handler by name at call time rather than capturing the function
41
+ # object in the table. Binding at import makes monkeypatching a handler
42
+ # silently ineffective — the table would keep calling the original.
43
+ handler = globals()[name]
44
+ if manifest.command in _MUTATING:
45
+ return handler(args, tenant_id=tenant_id, dry_run=bool(args.get("dry_run")))
46
+ return handler(args, tenant_id=tenant_id)
47
+
48
+
49
+ def _gh_pr_list(args: dict[str, Any], *, tenant_id: str | None = None) -> dict[str, Any]:
50
+ repo = args["repo"]
51
+ limit = int(args.get("limit", 30))
52
+ state = args.get("state", "open")
53
+ cmd = [
54
+ "gh",
55
+ "pr",
56
+ "list",
57
+ "--repo",
58
+ repo,
59
+ "--limit",
60
+ str(limit),
61
+ "--state",
62
+ state,
63
+ "--json",
64
+ "number,title,state,url,author,isDraft",
65
+ ]
66
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60, env=_gh_env(tenant_id))
67
+ if proc.returncode != 0:
68
+ raise RuntimeError(proc.stderr.strip() or "gh pr list failed")
69
+ raw = json.loads(proc.stdout or "[]")
70
+ items = []
71
+ for row in raw:
72
+ author = row.get("author") or {}
73
+ items.append(
74
+ {
75
+ "number": row["number"],
76
+ "title": row["title"],
77
+ "state": row["state"],
78
+ "url": row["url"],
79
+ "author": author.get("login", "unknown"),
80
+ "draft": row.get("isDraft", False),
81
+ }
82
+ )
83
+ return {"items": items}
84
+
85
+
86
+ def _gh_pr_view(args: dict[str, Any], *, tenant_id: str | None = None) -> dict[str, Any]:
87
+ repo = args["repo"]
88
+ number = int(args["number"])
89
+ cmd = [
90
+ "gh",
91
+ "pr",
92
+ "view",
93
+ str(number),
94
+ "--repo",
95
+ repo,
96
+ "--json",
97
+ "number,title,body,state,url,author",
98
+ ]
99
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60, env=_gh_env(tenant_id))
100
+ if proc.returncode != 0:
101
+ raise RuntimeError(proc.stderr.strip() or "gh pr view failed")
102
+ row = json.loads(proc.stdout or "{}")
103
+ author = row.get("author") or {}
104
+ return {
105
+ "number": row["number"],
106
+ "title": row["title"],
107
+ "body": (row.get("body") or "")[:2000],
108
+ "state": row["state"],
109
+ "url": row["url"],
110
+ "author": author.get("login", "unknown"),
111
+ }
112
+
113
+
114
+ def _repo(args: dict[str, Any]) -> str:
115
+ """Validate owner/name before it reaches the gh subprocess."""
116
+ repo = str(args.get("repo") or "").strip()
117
+ if not re.fullmatch(r"[\w.-]+/[\w.-]+", repo):
118
+ raise ValueError(f"invalid repo {repo!r}; expected owner/name")
119
+ return repo
120
+
121
+
122
+ def _gh(argv: list[str], tenant_id: str | None) -> str:
123
+ proc = subprocess.run(
124
+ ["gh", *argv], capture_output=True, text=True, timeout=60, env=_gh_env(tenant_id)
125
+ )
126
+ if proc.returncode != 0:
127
+ raise RuntimeError(proc.stderr.strip() or f"gh {' '.join(argv)} failed")
128
+ return proc.stdout
129
+
130
+
131
+ def _gh_issue_list(args: dict[str, Any], *, tenant_id: str | None = None) -> dict[str, Any]:
132
+ repo = _repo(args)
133
+ argv = [
134
+ "issue", "list", "--repo", repo,
135
+ "--limit", str(int(args.get("limit", 30))),
136
+ "--state", str(args.get("state", "open")),
137
+ "--json", "number,title,state,url,author",
138
+ ]
139
+ rows = json.loads(_gh(argv, tenant_id) or "[]")
140
+ return {
141
+ "items": [
142
+ {
143
+ "number": r["number"],
144
+ "title": r["title"],
145
+ "state": r["state"],
146
+ "url": r["url"],
147
+ "author": (r.get("author") or {}).get("login", "unknown"),
148
+ }
149
+ for r in rows
150
+ ]
151
+ }
152
+
153
+
154
+ def _gh_issue_view(args: dict[str, Any], *, tenant_id: str | None = None) -> dict[str, Any]:
155
+ repo = _repo(args)
156
+ number = int(args["number"])
157
+ row = json.loads(
158
+ _gh(
159
+ ["issue", "view", str(number), "--repo", repo,
160
+ "--json", "number,title,body,state,url,author"],
161
+ tenant_id,
162
+ )
163
+ or "{}"
164
+ )
165
+ return {
166
+ "number": row.get("number"),
167
+ "title": row.get("title"),
168
+ "body": (row.get("body") or "")[:2000],
169
+ "state": row.get("state"),
170
+ "url": row.get("url"),
171
+ "author": (row.get("author") or {}).get("login", "unknown"),
172
+ }
173
+
174
+
175
+ def _gh_run_list(args: dict[str, Any], *, tenant_id: str | None = None) -> dict[str, Any]:
176
+ repo = _repo(args)
177
+ rows = json.loads(
178
+ _gh(
179
+ ["run", "list", "--repo", repo,
180
+ "--limit", str(int(args.get("limit", 20))),
181
+ "--json", "databaseId,displayTitle,status,conclusion,workflowName"],
182
+ tenant_id,
183
+ )
184
+ or "[]"
185
+ )
186
+ return {
187
+ "items": [
188
+ {
189
+ "id": r.get("databaseId"),
190
+ "title": r.get("displayTitle"),
191
+ "workflow": r.get("workflowName"),
192
+ "status": r.get("status"),
193
+ "conclusion": r.get("conclusion"),
194
+ }
195
+ for r in rows
196
+ ]
197
+ }
198
+
199
+
200
+ def _gh_pr_comment(
201
+ args: dict[str, Any], *, tenant_id: str | None = None, dry_run: bool = False
202
+ ) -> dict[str, Any]:
203
+ repo = _repo(args)
204
+ number = int(args["number"])
205
+ body = str(args.get("body") or "").strip()
206
+ if not body:
207
+ raise ValueError("body is required")
208
+ if dry_run:
209
+ return {"posted": False, "repo": repo, "number": number, "dry_run": True}
210
+ out = _gh(["pr", "comment", str(number), "--repo", repo, "--body", body], tenant_id)
211
+ return {
212
+ "posted": True,
213
+ "repo": repo,
214
+ "number": number,
215
+ "dry_run": False,
216
+ "url": out.strip(),
217
+ }
218
+
219
+
220
+ def _gh_pr_merge(
221
+ args: dict[str, Any], *, tenant_id: str | None = None, dry_run: bool = False
222
+ ) -> dict[str, Any]:
223
+ repo = _repo(args)
224
+ number = int(args["number"])
225
+ method = str(args.get("method") or "squash").lower()
226
+ if method not in {"merge", "squash", "rebase"}:
227
+ raise ValueError(f"invalid merge method {method!r}; expected merge|squash|rebase")
228
+ if dry_run:
229
+ return {
230
+ "merged": False,
231
+ "repo": repo,
232
+ "number": number,
233
+ "method": method,
234
+ "dry_run": True,
235
+ }
236
+ out = _gh(["pr", "merge", str(number), "--repo", repo, f"--{method}"], tenant_id)
237
+ return {
238
+ "merged": True,
239
+ "repo": repo,
240
+ "number": number,
241
+ "method": method,
242
+ "dry_run": False,
243
+ "output": out.strip(),
244
+ }
245
+
246
+
247
+ # Table-driven so a new gh manifest needs one entry, not an if-branch.
248
+ # Values are function *names*, resolved at call time in run() — see the comment
249
+ # there. Mutating commands receive dry_run; read-only ones cannot accept one
250
+ # they would silently ignore.
251
+ _HANDLERS = {
252
+ "gh.pr.list": "_gh_pr_list",
253
+ "gh.pr.view": "_gh_pr_view",
254
+ "gh.issue.list": "_gh_issue_list",
255
+ "gh.issue.view": "_gh_issue_view",
256
+ "gh.run.list": "_gh_run_list",
257
+ "gh.pr.comment": "_gh_pr_comment",
258
+ "gh.pr.merge": "_gh_pr_merge",
259
+ }
260
+
261
+ _MUTATING = {"gh.pr.comment", "gh.pr.merge"}
@@ -0,0 +1,33 @@
1
+ """Generic HTTP adapter for OpenAPI-generated manifests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from gax.registry import CommandManifest
10
+
11
+
12
+ def run(manifest: CommandManifest, args: dict[str, Any], **_: Any) -> dict[str, Any]:
13
+ http = (manifest.raw or {}).get("http") or {}
14
+ method = str(http.get("method", "GET")).upper()
15
+ url_template = str(http.get("url", ""))
16
+ if not url_template:
17
+ raise RuntimeError(f"http.url missing for {manifest.command}")
18
+
19
+ url = url_template
20
+ path_params = http.get("path_params") or []
21
+ query_params = http.get("query_params") or []
22
+ for p in path_params:
23
+ url = url.replace("{" + p + "}", str(args[p]))
24
+ query = {p: args[p] for p in query_params if p in args}
25
+
26
+ headers = dict(http.get("headers") or {})
27
+ with httpx.Client(timeout=60.0) as client:
28
+ resp = client.request(method, url, params=query, headers=headers)
29
+ resp.raise_for_status()
30
+ try:
31
+ return resp.json()
32
+ except Exception:
33
+ return {"text": resp.text}