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.
- gax_cli-0.4.0/PKG-INFO +164 -0
- gax_cli-0.4.0/README.md +129 -0
- gax_cli-0.4.0/gax/__init__.py +3 -0
- gax_cli-0.4.0/gax/adapters/__init__.py +3 -0
- gax_cli-0.4.0/gax/adapters/base.py +25 -0
- gax_cli-0.4.0/gax/adapters/exec_adapter.py +261 -0
- gax_cli-0.4.0/gax/adapters/http_adapter.py +33 -0
- gax_cli-0.4.0/gax/adapters/k8s_adapter.py +338 -0
- gax_cli-0.4.0/gax/adapters/mcp_bridge.py +108 -0
- gax_cli-0.4.0/gax/adapters/mock_adapter.py +49 -0
- gax_cli-0.4.0/gax/audit.py +54 -0
- gax_cli-0.4.0/gax/caps.py +91 -0
- gax_cli-0.4.0/gax/cli.py +722 -0
- gax_cli-0.4.0/gax/client.py +58 -0
- gax_cli-0.4.0/gax/compliance.py +71 -0
- gax_cli-0.4.0/gax/config/oauth_providers.yaml +21 -0
- gax_cli-0.4.0/gax/config/policy.rego +20 -0
- gax_cli-0.4.0/gax/config/policy.yaml +59 -0
- gax_cli-0.4.0/gax/daemon.py +187 -0
- gax_cli-0.4.0/gax/envelope.py +64 -0
- gax_cli-0.4.0/gax/executor.py +151 -0
- gax_cli-0.4.0/gax/macaroons_cap.py +80 -0
- gax_cli-0.4.0/gax/manifests/aws.s3.list.yaml +20 -0
- gax_cli-0.4.0/gax/manifests/demo.echo.yaml +20 -0
- gax_cli-0.4.0/gax/manifests/gh.pr.list.yaml +41 -0
- gax_cli-0.4.0/gax/manifests/gh.pr.view.yaml +30 -0
- gax_cli-0.4.0/gax/manifests/jira.issue.get.yaml +18 -0
- gax_cli-0.4.0/gax/manifests/k8s.deployment.scale.yaml +35 -0
- gax_cli-0.4.0/gax/manifests/k8s.pod.delete.yaml +30 -0
- gax_cli-0.4.0/gax/manifests/kubectl.get.pods.yaml +20 -0
- gax_cli-0.4.0/gax/manifests/mcp.github.list_pulls.yaml +29 -0
- gax_cli-0.4.0/gax/manifests/pet_findpetsbystatus.yaml +19 -0
- gax_cli-0.4.0/gax/manifests/pet_getpetbyid.yaml +19 -0
- gax_cli-0.4.0/gax/mcp_client.py +110 -0
- gax_cli-0.4.0/gax/mcp_server.py +299 -0
- gax_cli-0.4.0/gax/oauth.py +184 -0
- gax_cli-0.4.0/gax/onboarding.py +397 -0
- gax_cli-0.4.0/gax/opa_policy.py +46 -0
- gax_cli-0.4.0/gax/openapi_gen.py +96 -0
- gax_cli-0.4.0/gax/otel_audit.py +50 -0
- gax_cli-0.4.0/gax/paths.py +46 -0
- gax_cli-0.4.0/gax/plan.py +151 -0
- gax_cli-0.4.0/gax/policy.py +33 -0
- gax_cli-0.4.0/gax/policy_bundle.py +64 -0
- gax_cli-0.4.0/gax/profiles/github/gh.issue.list.yaml +25 -0
- gax_cli-0.4.0/gax/profiles/github/gh.issue.view.yaml +23 -0
- gax_cli-0.4.0/gax/profiles/github/gh.pr.comment.yaml +30 -0
- gax_cli-0.4.0/gax/profiles/github/gh.pr.merge.yaml +29 -0
- gax_cli-0.4.0/gax/profiles/github/gh.run.list.yaml +22 -0
- gax_cli-0.4.0/gax/profiles/k8s/k8s.deployment.list.yaml +17 -0
- gax_cli-0.4.0/gax/profiles/k8s/k8s.deployment.restart.yaml +25 -0
- gax_cli-0.4.0/gax/profiles/k8s/k8s.namespace.delete.yaml +22 -0
- gax_cli-0.4.0/gax/profiles/k8s/k8s.pod.describe.yaml +22 -0
- gax_cli-0.4.0/gax/profiles/k8s/k8s.pod.logs.yaml +28 -0
- gax_cli-0.4.0/gax/profiles/k8s/k8s.service.list.yaml +17 -0
- gax_cli-0.4.0/gax/profiles.py +143 -0
- gax_cli-0.4.0/gax/projection.py +42 -0
- gax_cli-0.4.0/gax/registry.py +130 -0
- gax_cli-0.4.0/gax/schemas/envelope.v1.json +46 -0
- gax_cli-0.4.0/gax/secrets.py +49 -0
- gax_cli-0.4.0/gax/side_effects.py +67 -0
- gax_cli-0.4.0/gax/spiffe.py +32 -0
- gax_cli-0.4.0/gax/vault.py +69 -0
- gax_cli-0.4.0/gax_cli.egg-info/PKG-INFO +164 -0
- gax_cli-0.4.0/gax_cli.egg-info/SOURCES.txt +82 -0
- gax_cli-0.4.0/gax_cli.egg-info/dependency_links.txt +1 -0
- gax_cli-0.4.0/gax_cli.egg-info/entry_points.txt +4 -0
- gax_cli-0.4.0/gax_cli.egg-info/requires.txt +11 -0
- gax_cli-0.4.0/gax_cli.egg-info/top_level.txt +1 -0
- gax_cli-0.4.0/pyproject.toml +83 -0
- gax_cli-0.4.0/setup.cfg +4 -0
- gax_cli-0.4.0/tests/test_acsp_envelope_conformance.py +89 -0
- gax_cli-0.4.0/tests/test_agent_governance.py +66 -0
- gax_cli-0.4.0/tests/test_eval_extended.py +82 -0
- gax_cli-0.4.0/tests/test_eval_scoring.py +256 -0
- gax_cli-0.4.0/tests/test_gax.py +81 -0
- gax_cli-0.4.0/tests/test_macaroon_policy.py +67 -0
- gax_cli-0.4.0/tests/test_mcp_bridge_mock.py +40 -0
- gax_cli-0.4.0/tests/test_mcp_server.py +204 -0
- gax_cli-0.4.0/tests/test_onboarding.py +237 -0
- gax_cli-0.4.0/tests/test_phase34.py +82 -0
- gax_cli-0.4.0/tests/test_plan.py +38 -0
- gax_cli-0.4.0/tests/test_profiles.py +237 -0
- 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
|
gax_cli-0.4.0/README.md
ADDED
|
@@ -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,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}
|