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/client.py ADDED
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from gax.paths import DEFAULT_HOST, DEFAULT_PORT
10
+
11
+
12
+ def base_url(host: str | None = None, port: int | None = None) -> str:
13
+ h = host or os.environ.get("GAX_HOST", DEFAULT_HOST)
14
+ p = port or int(os.environ.get("GAX_PORT", DEFAULT_PORT))
15
+ return f"http://{h}:{p}"
16
+
17
+
18
+ def capability_header(capability: str | None = None) -> dict[str, str]:
19
+ cap = (capability or os.environ.get("GAX_CAP", "")).strip()
20
+ if not cap:
21
+ return {}
22
+ return {"GAX-Capability": cap}
23
+
24
+
25
+ def remote_invoke(
26
+ command: str,
27
+ args: dict[str, Any],
28
+ *,
29
+ surface: str = "model",
30
+ host: str | None = None,
31
+ port: int | None = None,
32
+ capability: str | None = None,
33
+ ) -> tuple[dict[str, Any], int]:
34
+ url = base_url(host, port) + "/invoke"
35
+ body = {"command": command, "args": args, "surface": surface}
36
+ r = httpx.post(url, json=body, headers=capability_header(capability), timeout=120.0)
37
+ data = r.json()
38
+ env = data.get("envelope", data)
39
+ exit_code = int(data.get("exit_code", 1 if not env.get("ok") else 0))
40
+ return env, exit_code
41
+
42
+
43
+ def remote_doc(command: str, host: str | None = None, port: int | None = None) -> dict[str, Any]:
44
+ r = httpx.get(f"{base_url(host, port)}/commands/{command}/doc", timeout=10.0)
45
+ r.raise_for_status()
46
+ return r.json()
47
+
48
+
49
+ def remote_search(query: str, host: str | None = None, port: int | None = None) -> dict[str, Any]:
50
+ r = httpx.get(f"{base_url(host, port)}/search", params={"q": query}, timeout=10.0)
51
+ r.raise_for_status()
52
+ return r.json()
53
+
54
+
55
+ def remote_schema(command: str, host: str | None = None, port: int | None = None) -> dict[str, Any]:
56
+ r = httpx.get(f"{base_url(host, port)}/schema/{command}", timeout=10.0)
57
+ r.raise_for_status()
58
+ return r.json()
gax/compliance.py ADDED
@@ -0,0 +1,71 @@
1
+ """SOC2-friendly audit exports from ~/.gax/audit.jsonl."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from gax.paths import AUDIT_PATH, GAX_HOME, ensure_gax_home
12
+
13
+ SOC2_FIELDS = [
14
+ "timestamp",
15
+ "audit_id",
16
+ "tenant_id",
17
+ "subject",
18
+ "command",
19
+ "ok",
20
+ "error_kind",
21
+ "duration_ms",
22
+ "args_json",
23
+ ]
24
+
25
+
26
+ def load_audit_lines(path: Path | None = None) -> list[dict[str, Any]]:
27
+ p = path or AUDIT_PATH
28
+ if not p.exists():
29
+ return []
30
+ rows = []
31
+ for line in p.read_text().splitlines():
32
+ if line.strip():
33
+ rows.append(json.loads(line))
34
+ return rows
35
+
36
+
37
+ def export_soc2_csv(out_path: Path, *, since: str | None = None) -> int:
38
+ rows = load_audit_lines()
39
+ if since:
40
+ rows = [r for r in rows if r.get("ts", "") >= since]
41
+ out_path.parent.mkdir(parents=True, exist_ok=True)
42
+ with out_path.open("w", newline="") as f:
43
+ w = csv.DictWriter(f, fieldnames=SOC2_FIELDS)
44
+ w.writeheader()
45
+ for r in rows:
46
+ w.writerow(
47
+ {
48
+ "timestamp": r.get("ts"),
49
+ "audit_id": r.get("audit_id"),
50
+ "tenant_id": r.get("tenant_id"),
51
+ "subject": r.get("subject"),
52
+ "command": r.get("command"),
53
+ "ok": r.get("ok"),
54
+ "error_kind": r.get("error_kind") or "",
55
+ "duration_ms": r.get("duration_ms") or "",
56
+ "args_json": json.dumps(r.get("args") or {}),
57
+ }
58
+ )
59
+ return len(rows)
60
+
61
+
62
+ def export_soc2_json(out_path: Path) -> dict[str, Any]:
63
+ rows = load_audit_lines()
64
+ bundle = {
65
+ "exported_at": datetime.now(timezone.utc).isoformat(),
66
+ "standard": "SOC2-aligned access log (MVP)",
67
+ "record_count": len(rows),
68
+ "records": rows,
69
+ }
70
+ out_path.write_text(json.dumps(bundle, indent=2))
71
+ return bundle
@@ -0,0 +1,21 @@
1
+ # OAuth 2.0 device authorization (RFC 8628) providers for `gax auth login`
2
+ #
3
+ # GitHub: create an OAuth App at https://github.com/settings/developers
4
+ # - Enable Device Flow
5
+ # - Set GAX_GITHUB_CLIENT_ID to the Client ID
6
+
7
+ providers:
8
+ github:
9
+ device_authorization_url: https://github.com/login/device/code
10
+ token_url: https://github.com/login/oauth/access_token
11
+ client_id_env: GAX_GITHUB_CLIENT_ID
12
+ scopes:
13
+ - repo
14
+ - read:org
15
+
16
+ # Generic template (OIDC/OAuth provider with RFC 8628 endpoints)
17
+ # custom:
18
+ # device_authorization_url: https://auth.example.com/oauth/device
19
+ # token_url: https://auth.example.com/oauth/token
20
+ # client_id_env: GAX_CUSTOM_CLIENT_ID
21
+ # scopes: [openid, profile]
gax/config/policy.rego ADDED
@@ -0,0 +1,20 @@
1
+ package gax
2
+
3
+ default allow = false
4
+
5
+ allow {
6
+ input.side_effects == "read"
7
+ not denied_command
8
+ not denied_repo
9
+ }
10
+
11
+ denied_command {
12
+ input.command == "destructive.example"
13
+ }
14
+
15
+ denied_repo {
16
+ some repo
17
+ repo := input.args.repo
18
+ input.claims.tenant_id == "restricted"
19
+ repo != "allowed/repo"
20
+ }
gax/config/policy.yaml ADDED
@@ -0,0 +1,59 @@
1
+ # GAX policy bundle (MVP — YAML rules; OPA/Rego compatible path later)
2
+ version: 1
3
+
4
+ defaults:
5
+ # Org-wide kill switch for destructive commands. A tenant may override it.
6
+ # This is the blunt control; the per-capability `max_side_effect` ceiling
7
+ # (gax/side_effects.py) is the primary, fine-grained one.
8
+ deny_destructive: true
9
+ max_rows_per_invoke: 500
10
+
11
+ tenants:
12
+ default:
13
+ # Local/dev tenant. Opt in to destructive commands here; each invoke still
14
+ # needs a capability minted with `--max-side-effect destructive`.
15
+ deny_destructive: false
16
+ # "*" means "any registered command", NOT "any action" — the registry is
17
+ # still a closed set, and the per-capability allowlist + scopes + ceiling are
18
+ # the real controls. A wildcard here keeps installed profiles usable without
19
+ # editing policy; production tenants should enumerate instead (see below).
20
+ allowed_commands:
21
+ - "*"
22
+ denied_commands: []
23
+ repo_allowlist: [] # empty = allow all repos
24
+
25
+ # Template for a production tenant: enumerate exactly what may run, and keep
26
+ # the destructive kill switch on unless a specific need is documented.
27
+ production:
28
+ deny_destructive: true
29
+ allowed_commands:
30
+ - gh.pr.list
31
+ - gh.pr.view
32
+ - k8s.pod.list
33
+ - k8s.pod.logs
34
+ denied_commands: []
35
+ repo_allowlist: []
36
+
37
+ # Example: a tenant that must never run destructive commands, regardless of
38
+ # what capability a caller presents.
39
+ readonly-tenant:
40
+ deny_destructive: true
41
+ allowed_commands:
42
+ - "*"
43
+ denied_commands: []
44
+ repo_allowlist: []
45
+ capguard:
46
+ allowed_commands:
47
+ - demo.echo
48
+ - gitlab.mr.search
49
+ - gitlab.mr.get
50
+ - gitlab.mr.diffs
51
+ - gitlab.mr.pipelines
52
+ - gitlab.note.draft
53
+ denied_commands: []
54
+ repo_allowlist: [] # empty = allow all repos
55
+
56
+ rules:
57
+ - id: block_private_repos
58
+ description: Deny gh commands on repos marked private (future)
59
+ enabled: false
gax/daemon.py ADDED
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import signal
7
+ import sys
8
+ import time
9
+ from http.server import BaseHTTPRequestHandler, HTTPServer
10
+ from typing import Any
11
+ from urllib.parse import parse_qs, urlparse
12
+
13
+ import click
14
+
15
+ from gax.executor import invoke
16
+ from gax.paths import DEFAULT_HOST, DEFAULT_PORT, PID_PATH, ensure_gax_home
17
+ from gax.registry import Registry
18
+
19
+ _REGISTRY = Registry()
20
+
21
+
22
+ class GaxdHandler(BaseHTTPRequestHandler):
23
+ def log_message(self, format: str, *args: Any) -> None:
24
+ return # quiet by default
25
+
26
+ def _json_response(self, code: int, body: dict[str, Any]) -> None:
27
+ payload = json.dumps(body, indent=2).encode()
28
+ self.send_response(code)
29
+ self.send_header("Content-Type", "application/json")
30
+ self.send_header("Content-Length", str(len(payload)))
31
+ self.end_headers()
32
+ self.wfile.write(payload)
33
+
34
+ def _read_json(self) -> dict[str, Any]:
35
+ length = int(self.headers.get("Content-Length", 0))
36
+ raw = self.rfile.read(length) if length else b"{}"
37
+ return json.loads(raw.decode() or "{}")
38
+
39
+ def do_GET(self) -> None:
40
+ parsed = urlparse(self.path)
41
+ path = parsed.path
42
+ qs = parse_qs(parsed.query)
43
+
44
+ if path == "/health":
45
+ # Expose a fingerprint of the signing secret (never the secret) so a
46
+ # client can tell it is talking to a daemon that shares its config.
47
+ # A daemon started against a different ~/.gax will reject every
48
+ # capability with "Signature verification failed", which is otherwise
49
+ # very hard to diagnose.
50
+ from gax.caps import jwt_secret
51
+
52
+ fp = hashlib.sha256(jwt_secret().encode()).hexdigest()[:12]
53
+ self._json_response(
54
+ 200,
55
+ {
56
+ "ok": True,
57
+ "service": "gaxd",
58
+ "commands": len(_REGISTRY.list_commands()),
59
+ "config_fingerprint": fp,
60
+ },
61
+ )
62
+ return
63
+
64
+ if path == "/commands":
65
+ items = [
66
+ {"command": m.command, "version": m.version, "description": m.description}
67
+ for m in _REGISTRY.list_commands()
68
+ ]
69
+ self._json_response(200, {"commands": items})
70
+ return
71
+
72
+ if path.startswith("/commands/") and path.endswith("/doc"):
73
+ cmd = path.split("/")[2]
74
+ doc = _REGISTRY.doc_stub(cmd)
75
+ if not doc:
76
+ self._json_response(404, {"error": "not_found"})
77
+ return
78
+ self._json_response(200, doc)
79
+ return
80
+
81
+ if path == "/search":
82
+ q = (qs.get("q") or [""])[0]
83
+ hits = _REGISTRY.search(q)
84
+ self._json_response(
85
+ 200,
86
+ {
87
+ "query": q,
88
+ "results": [
89
+ {"command": m.command, "description": m.description, "category": m.category}
90
+ for m in hits
91
+ ],
92
+ },
93
+ )
94
+ return
95
+
96
+ if path.startswith("/schema/"):
97
+ cmd = path.split("/schema/", 1)[1]
98
+ m = _REGISTRY.get(cmd)
99
+ if not m:
100
+ self._json_response(404, {"error": "not_found"})
101
+ return
102
+ self._json_response(
103
+ 200,
104
+ {"command": cmd, "input_schema": m.input_schema, "output_schema": m.output_schema},
105
+ )
106
+ return
107
+
108
+ self._json_response(404, {"error": "not_found"})
109
+
110
+ def do_POST(self) -> None:
111
+ if self.path != "/invoke":
112
+ self._json_response(404, {"error": "not_found"})
113
+ return
114
+ body = self._read_json()
115
+ cap = self.headers.get("GAX-Capability") or body.get("capability")
116
+ env, exit_code = invoke(
117
+ _REGISTRY,
118
+ command=str(body.get("command", "")),
119
+ args=dict(body.get("args") or {}),
120
+ surface=str(body.get("surface") or "model"),
121
+ capability=cap,
122
+ )
123
+ http_code = 200 if env.get("ok") else 400
124
+ self._json_response(http_code, {"envelope": env, "exit_code": exit_code})
125
+
126
+
127
+ def run_server(host: str, port: int) -> None:
128
+ server = HTTPServer((host, port), GaxdHandler)
129
+ click.echo(f"gaxd listening on http://{host}:{port}")
130
+ server.serve_forever()
131
+
132
+
133
+ @click.group()
134
+ def main() -> None:
135
+ """GAX daemon (gaxd)."""
136
+
137
+
138
+ @main.command("start")
139
+ @click.option("--host", default=DEFAULT_HOST)
140
+ @click.option("--port", default=DEFAULT_PORT, type=int)
141
+ @click.option("--background", is_flag=True, help="Daemonize (write pid file)")
142
+ def start(host: str, port: int, background: bool) -> None:
143
+ ensure_gax_home()
144
+ if background:
145
+ pid = os.fork()
146
+ if pid > 0:
147
+ PID_PATH.write_text(str(pid))
148
+ click.echo(f"gaxd started pid={pid} http://{host}:{port}")
149
+ return
150
+ os.setsid()
151
+ sys.stdout = open(os.devnull, "w")
152
+ sys.stderr = open(os.devnull, "w")
153
+ else:
154
+ PID_PATH.write_text(str(os.getpid()))
155
+ run_server(host, port)
156
+
157
+
158
+ @main.command("stop")
159
+ def stop() -> None:
160
+ if not PID_PATH.exists():
161
+ click.echo("gaxd not running (no pid file)")
162
+ return
163
+ pid = int(PID_PATH.read_text().strip())
164
+ try:
165
+ os.kill(pid, signal.SIGTERM)
166
+ PID_PATH.unlink(missing_ok=True)
167
+ click.echo(f"stopped gaxd pid={pid}")
168
+ except ProcessLookupError:
169
+ PID_PATH.unlink(missing_ok=True)
170
+ click.echo("stale pid file removed")
171
+
172
+
173
+ @main.command("status")
174
+ @click.option("--host", default=DEFAULT_HOST)
175
+ @click.option("--port", default=DEFAULT_PORT, type=int)
176
+ def status(host: str, port: int) -> None:
177
+ import httpx
178
+
179
+ try:
180
+ r = httpx.get(f"http://{host}:{port}/health", timeout=2.0)
181
+ click.echo(r.text)
182
+ except Exception as e:
183
+ click.echo(f"gaxd unreachable: {e}")
184
+
185
+
186
+ if __name__ == "__main__":
187
+ main()
gax/envelope.py ADDED
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import uuid
5
+ from typing import Any
6
+
7
+
8
+ def new_audit_id() -> str:
9
+ return f"aud_{uuid.uuid4().hex[:16]}"
10
+
11
+
12
+ def make_envelope(
13
+ *,
14
+ ok: bool,
15
+ cmd: str,
16
+ surface: str,
17
+ data: Any,
18
+ audit_id: str | None = None,
19
+ schema: str | None = None,
20
+ meta: dict[str, Any] | None = None,
21
+ error: dict[str, Any] | None = None,
22
+ next_actions: list[dict[str, Any]] | None = None,
23
+ ) -> dict[str, Any]:
24
+ env: dict[str, Any] = {
25
+ "v": 1,
26
+ "ok": ok,
27
+ "cmd": cmd,
28
+ "audit_id": audit_id or new_audit_id(),
29
+ "surface": surface,
30
+ "data": data if data is not None else {},
31
+ "meta": meta or {},
32
+ "error": error,
33
+ }
34
+ if schema:
35
+ env["schema"] = schema
36
+ if next_actions:
37
+ env["next"] = next_actions
38
+ return env
39
+
40
+
41
+ def fail_envelope(
42
+ *,
43
+ cmd: str,
44
+ surface: str,
45
+ kind: str,
46
+ message: str,
47
+ retryable: bool = False,
48
+ audit_id: str | None = None,
49
+ ) -> dict[str, Any]:
50
+ return make_envelope(
51
+ ok=False,
52
+ cmd=cmd,
53
+ surface=surface,
54
+ data={},
55
+ audit_id=audit_id,
56
+ error={"kind": kind, "message": message, "retryable": retryable},
57
+ meta={"duration_ms": 0},
58
+ )
59
+
60
+
61
+ def timed_meta(start: float, **extra: Any) -> dict[str, Any]:
62
+ meta = {"duration_ms": round((time.perf_counter() - start) * 1000, 2)}
63
+ meta.update(extra)
64
+ return meta
gax/executor.py ADDED
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+ from gax.adapters.base import run_adapter
7
+ from gax.audit import log_event
8
+ from gax.caps import decode_capability
9
+ from gax.envelope import fail_envelope, make_envelope, timed_meta
10
+ from gax.policy import PolicyDenied, check_invoke
11
+ from gax.projection import project_data
12
+ from gax.registry import Registry
13
+
14
+
15
+ def invoke(
16
+ registry: Registry,
17
+ *,
18
+ command: str,
19
+ args: dict[str, Any],
20
+ surface: str = "model",
21
+ capability: str | None = None,
22
+ ) -> tuple[dict[str, Any], int]:
23
+ start = time.perf_counter()
24
+ manifest = registry.get(command)
25
+ cmd_label = f"{command}@?" if not manifest else manifest.cmd_id
26
+
27
+ try:
28
+ claims = decode_capability(capability)
29
+ except Exception as e:
30
+ env = fail_envelope(
31
+ cmd=cmd_label,
32
+ surface=surface,
33
+ kind="capability_invalid",
34
+ message=str(e),
35
+ )
36
+ log_event(
37
+ audit_id=env["audit_id"],
38
+ tenant_id="unknown",
39
+ subject="unknown",
40
+ command=command,
41
+ args=args,
42
+ ok=False,
43
+ error_kind="capability_invalid",
44
+ )
45
+ return env, 3
46
+
47
+ tenant_id = str(claims.get("tenant_id", "unknown"))
48
+ subject = str(claims.get("sub", "unknown"))
49
+
50
+ if not manifest:
51
+ env = fail_envelope(
52
+ cmd=cmd_label,
53
+ surface=surface,
54
+ kind="not_found",
55
+ message=f"unknown command: {command}",
56
+ )
57
+ log_event(
58
+ audit_id=env["audit_id"],
59
+ tenant_id=tenant_id,
60
+ subject=subject,
61
+ command=command,
62
+ args=args,
63
+ ok=False,
64
+ error_kind="not_found",
65
+ )
66
+ return env, 4
67
+
68
+ cmd_label = manifest.cmd_id
69
+ try:
70
+ check_invoke(claims, manifest, args)
71
+ except PolicyDenied as e:
72
+ env = fail_envelope(
73
+ cmd=cmd_label,
74
+ surface=surface,
75
+ kind="policy_denied",
76
+ message=e.message,
77
+ retryable=False,
78
+ )
79
+ log_event(
80
+ audit_id=env["audit_id"],
81
+ tenant_id=tenant_id,
82
+ subject=subject,
83
+ command=command,
84
+ args=args,
85
+ ok=False,
86
+ error_kind="policy_denied",
87
+ )
88
+ return env, 2
89
+
90
+ audit_id = make_envelope(ok=True, cmd=cmd_label, surface=surface, data={})["audit_id"]
91
+
92
+ try:
93
+ raw = run_adapter(manifest, args, tenant_id=tenant_id)
94
+ except Exception as e:
95
+ env = fail_envelope(
96
+ cmd=cmd_label,
97
+ surface=surface,
98
+ kind="adapter_error",
99
+ message=str(e),
100
+ retryable=True,
101
+ audit_id=audit_id,
102
+ )
103
+ log_event(
104
+ audit_id=audit_id,
105
+ tenant_id=tenant_id,
106
+ subject=subject,
107
+ command=command,
108
+ args=args,
109
+ ok=False,
110
+ error_kind="adapter_error",
111
+ duration_ms=timed_meta(start)["duration_ms"],
112
+ )
113
+ return env, 5
114
+
115
+ projected, proj_meta = project_data(raw, surface)
116
+ meta = timed_meta(start, **proj_meta)
117
+
118
+ next_actions = None
119
+ if manifest.command == "gh.pr.list" and isinstance(projected, dict):
120
+ items = projected.get("items") or []
121
+ if items:
122
+ first = items[0]
123
+ next_actions = [
124
+ {
125
+ "cmd": "gh.pr.view",
126
+ "args": {"repo": args.get("repo"), "number": first.get("number")},
127
+ "reason": "inspect first PR in list",
128
+ }
129
+ ]
130
+
131
+ env = make_envelope(
132
+ ok=True,
133
+ cmd=cmd_label,
134
+ surface=surface,
135
+ data=projected,
136
+ audit_id=audit_id,
137
+ schema=manifest.schema_uri,
138
+ meta=meta,
139
+ next_actions=next_actions,
140
+ )
141
+
142
+ log_event(
143
+ audit_id=audit_id,
144
+ tenant_id=tenant_id,
145
+ subject=subject,
146
+ command=command,
147
+ args=args,
148
+ ok=True,
149
+ duration_ms=meta.get("duration_ms"),
150
+ )
151
+ return env, 0