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/plan.py ADDED
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import time
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+ from typing import Any, Callable
7
+
8
+ import yaml
9
+
10
+ from gax.envelope import make_envelope, timed_meta
11
+ from gax.executor import invoke
12
+ from gax.registry import Registry
13
+
14
+ _TEMPLATE = re.compile(r"\{\{\s*([^}]+)\s*\}\}")
15
+
16
+
17
+ def load_plan(path: str) -> dict[str, Any]:
18
+ with open(path, encoding="utf-8") as f:
19
+ return yaml.safe_load(f) or {}
20
+
21
+
22
+ def _resolve_path(expr: str, ctx: dict[str, Any]) -> Any:
23
+ parts = expr.strip().split(".")
24
+ cur: Any = ctx
25
+ for p in parts:
26
+ if p.endswith("]"):
27
+ key, idx = p[:-1].split("[")
28
+ cur = cur[key][int(idx)]
29
+ elif "[" in p:
30
+ key, rest = p.split("[", 1)
31
+ idx = int(rest.rstrip("]"))
32
+ cur = cur[key][idx]
33
+ else:
34
+ cur = cur[p]
35
+ return cur
36
+
37
+
38
+ def render_value(value: Any, ctx: dict[str, Any]) -> Any:
39
+ if isinstance(value, str):
40
+ stripped = value.strip()
41
+ m = re.fullmatch(r"\{\{\s*([^}]+)\s*\}\}", stripped)
42
+ if m:
43
+ return _resolve_path(m.group(1), ctx)
44
+
45
+ def repl(match: re.Match[str]) -> str:
46
+ resolved = _resolve_path(match.group(1), ctx)
47
+ return str(resolved)
48
+
49
+ return _TEMPLATE.sub(repl, value)
50
+ if isinstance(value, dict):
51
+ return {k: render_value(v, ctx) for k, v in value.items()}
52
+ if isinstance(value, list):
53
+ return [render_value(v, ctx) for v in value]
54
+ return value
55
+
56
+
57
+ def _run_step(
58
+ step: dict[str, Any],
59
+ ctx: dict[str, Any],
60
+ surface: str,
61
+ invoke_fn: Callable[..., tuple[dict[str, Any], int]],
62
+ ) -> tuple[str, dict[str, Any], int]:
63
+ step_id = step.get("id") or step.get("command")
64
+ command = step["command"]
65
+ args = render_value(step.get("args") or {}, ctx)
66
+ env, code = invoke_fn(command=command, args=args, surface=surface)
67
+ ctx["steps"][step_id] = {
68
+ "ok": env.get("ok"),
69
+ "data": env.get("data"),
70
+ "meta": env.get("meta"),
71
+ "audit_id": env.get("audit_id"),
72
+ }
73
+ summary = {
74
+ "id": step_id,
75
+ "command": command,
76
+ "ok": env.get("ok"),
77
+ "audit_id": env.get("audit_id"),
78
+ }
79
+ return step_id, summary, code
80
+
81
+
82
+ def run_plan(
83
+ registry: Registry,
84
+ plan: dict[str, Any],
85
+ *,
86
+ surface: str = "model",
87
+ capability: str | None = None,
88
+ invoke_fn: Callable[..., tuple[dict[str, Any], int]] | None = None,
89
+ ) -> tuple[dict[str, Any], int]:
90
+ """Execute plan steps (sequential or parallel groups)."""
91
+ start = time.perf_counter()
92
+ steps_def = plan.get("steps") or []
93
+ if not steps_def:
94
+ return make_envelope(
95
+ ok=False,
96
+ cmd="plan@1",
97
+ surface=surface,
98
+ data={},
99
+ error={"kind": "invalid_plan", "message": "no steps", "retryable": False},
100
+ ), 1
101
+
102
+ invoke_fn = invoke_fn or (lambda **kw: invoke(registry, capability=capability, **kw))
103
+ ctx: dict[str, Any] = {"steps": {}}
104
+ step_results: list[dict[str, Any]] = []
105
+ last_env: dict[str, Any] | None = None
106
+ exit_code = 0
107
+
108
+ for block in steps_def:
109
+ if "parallel" in block:
110
+ branches = block["parallel"]
111
+ with ThreadPoolExecutor(max_workers=min(8, len(branches))) as pool:
112
+ futures = {
113
+ pool.submit(_run_step, branch, ctx, surface, invoke_fn): branch
114
+ for branch in branches
115
+ }
116
+ for fut in as_completed(futures):
117
+ _sid, summary, code = fut.result()
118
+ step_results.append(summary)
119
+ if code != 0 or not summary.get("ok"):
120
+ exit_code = code or 1
121
+ if exit_code:
122
+ break
123
+ continue
124
+
125
+ step_id, summary, code = _run_step(block, ctx, surface, invoke_fn)
126
+ step_results.append(summary)
127
+ last_env = ctx["steps"][step_id]
128
+ if code != 0 or not summary.get("ok"):
129
+ exit_code = code or 1
130
+ break
131
+
132
+ combined = make_envelope(
133
+ ok=exit_code == 0,
134
+ cmd=f"plan:{plan.get('name', 'unnamed')}@1",
135
+ surface=surface,
136
+ data={
137
+ "steps": ctx["steps"],
138
+ "summary": step_results,
139
+ "final": (
140
+ ctx["steps"][step_results[-1]["id"]].get("data", {})
141
+ if step_results and step_results[-1]["id"] in ctx["steps"]
142
+ else {}
143
+ ),
144
+ },
145
+ schema="https://schemas.gax.dev/plan/result/v1",
146
+ meta=timed_meta(start, step_count=len(step_results)),
147
+ error={"kind": "plan_failed", "message": "one or more steps failed", "retryable": True}
148
+ if exit_code
149
+ else None,
150
+ )
151
+ return combined, exit_code
gax/policy.py ADDED
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from gax.caps import cap_allows_command, cap_allows_scopes
6
+ from gax.opa_policy import check_opa_or_yaml
7
+ from gax.policy_bundle import PolicyDenied
8
+ from gax.registry import CommandManifest
9
+ from gax.side_effects import capability_ceiling, exceeds_ceiling, normalize
10
+
11
+ __all__ = ["PolicyDenied", "check_invoke"]
12
+
13
+
14
+ def check_invoke(
15
+ claims: dict[str, Any],
16
+ manifest: CommandManifest,
17
+ args: dict[str, Any] | None = None,
18
+ ) -> None:
19
+ if not cap_allows_command(claims, manifest.command):
20
+ raise PolicyDenied(f"capability does not allow command: {manifest.command}")
21
+ if not cap_allows_scopes(claims, manifest.required_scopes):
22
+ raise PolicyDenied(
23
+ f"missing scopes: {manifest.required_scopes}; held: {claims.get('scopes')}"
24
+ )
25
+ # Danger ceiling. Deliberately checked even when the command is on the
26
+ # capability's allowlist: an allowlist edit should not be able to silently
27
+ # promote a read-only token into one that can delete things.
28
+ if exceeds_ceiling(claims, manifest.side_effects):
29
+ raise PolicyDenied(
30
+ f"side_effects '{normalize(manifest.side_effects)}' exceeds capability "
31
+ f"ceiling '{capability_ceiling(claims)}': {manifest.command}"
32
+ )
33
+ check_opa_or_yaml(claims, manifest, args or {})
gax/policy_bundle.py ADDED
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+ from gax.paths import CONFIG_DIR
9
+ from gax.registry import CommandManifest
10
+
11
+ POLICY_PATH = CONFIG_DIR / "policy.yaml"
12
+
13
+
14
+ class PolicyDenied(Exception):
15
+ def __init__(self, message: str) -> None:
16
+ super().__init__(message)
17
+ self.message = message
18
+
19
+
20
+ def load_policy(path: Path | None = None) -> dict[str, Any]:
21
+ p = path or POLICY_PATH
22
+ if not p.exists():
23
+ return {}
24
+ return yaml.safe_load(p.read_text()) or {}
25
+
26
+
27
+ def check_policy_bundle(
28
+ claims: dict[str, Any],
29
+ manifest: CommandManifest,
30
+ args: dict[str, Any],
31
+ *,
32
+ policy: dict[str, Any] | None = None,
33
+ ) -> None:
34
+ policy = policy or load_policy()
35
+ defaults = policy.get("defaults") or {}
36
+ tenant_id = str(claims.get("tenant_id", "default"))
37
+ tenant_rules = (policy.get("tenants") or {}).get(tenant_id) or (policy.get("tenants") or {}).get(
38
+ "default", {}
39
+ )
40
+
41
+ # Tenant-level kill switch for destructive commands. The per-capability ceiling
42
+ # (gax.side_effects) is the primary control; this is the blunt org-wide override
43
+ # for tenants that must never run destructive commands regardless of who asks.
44
+ # Tenant setting wins over the global default so a tenant can opt in explicitly.
45
+ deny_destructive = tenant_rules.get(
46
+ "deny_destructive", defaults.get("deny_destructive")
47
+ )
48
+ if deny_destructive and manifest.side_effects == "destructive":
49
+ raise PolicyDenied(
50
+ f"destructive commands disabled for tenant '{tenant_id}': {manifest.command}"
51
+ )
52
+
53
+ denied = set(tenant_rules.get("denied_commands") or [])
54
+ if manifest.command in denied:
55
+ raise PolicyDenied(f"command denied by policy: {manifest.command}")
56
+
57
+ allowed = tenant_rules.get("allowed_commands")
58
+ if allowed and manifest.command not in allowed and "*" not in allowed:
59
+ raise PolicyDenied(f"command not in tenant allowlist: {manifest.command}")
60
+
61
+ allowlist = tenant_rules.get("repo_allowlist") or []
62
+ repo = args.get("repo")
63
+ if allowlist and repo and repo not in allowlist:
64
+ raise PolicyDenied(f"repo not in allowlist: {repo}")
@@ -0,0 +1,25 @@
1
+ command: gh.issue.list
2
+ version: "1.0.0"
3
+ description: List issues in a repository
4
+ category: github
5
+ adapter: exec
6
+ required_scopes:
7
+ - github:issues:read
8
+ side_effects: read
9
+ idempotent: true
10
+ input_schema:
11
+ type: object
12
+ properties:
13
+ repo:
14
+ type: string
15
+ description: owner/name
16
+ limit:
17
+ type: integer
18
+ description: Max results (default 30)
19
+ state:
20
+ type: string
21
+ description: open | closed | all
22
+ required:
23
+ - repo
24
+ output_schema:
25
+ type: object
@@ -0,0 +1,23 @@
1
+ command: gh.issue.view
2
+ version: "1.0.0"
3
+ description: View a single issue
4
+ category: github
5
+ adapter: exec
6
+ required_scopes:
7
+ - github:issues:read
8
+ side_effects: read
9
+ idempotent: true
10
+ input_schema:
11
+ type: object
12
+ properties:
13
+ repo:
14
+ type: string
15
+ description: owner/name
16
+ number:
17
+ type: integer
18
+ description: Issue number
19
+ required:
20
+ - repo
21
+ - number
22
+ output_schema:
23
+ type: object
@@ -0,0 +1,30 @@
1
+ command: gh.pr.comment
2
+ version: "1.0.0"
3
+ description: Post a comment on a pull request
4
+ category: github
5
+ adapter: exec
6
+ required_scopes:
7
+ - github:pull_request:write
8
+ side_effects: write
9
+ idempotent: false
10
+ input_schema:
11
+ type: object
12
+ properties:
13
+ repo:
14
+ type: string
15
+ description: owner/name
16
+ number:
17
+ type: integer
18
+ description: PR number
19
+ body:
20
+ type: string
21
+ description: Comment body
22
+ dry_run:
23
+ type: boolean
24
+ description: Validate without posting
25
+ required:
26
+ - repo
27
+ - number
28
+ - body
29
+ output_schema:
30
+ type: object
@@ -0,0 +1,29 @@
1
+ command: gh.pr.merge
2
+ version: "1.0.0"
3
+ description: Merge a pull request. Irreversible on a shared branch.
4
+ category: github
5
+ adapter: exec
6
+ required_scopes:
7
+ - github:pull_request:write
8
+ side_effects: destructive
9
+ idempotent: false
10
+ input_schema:
11
+ type: object
12
+ properties:
13
+ repo:
14
+ type: string
15
+ description: owner/name
16
+ number:
17
+ type: integer
18
+ description: PR number
19
+ method:
20
+ type: string
21
+ description: merge | squash | rebase (default squash)
22
+ dry_run:
23
+ type: boolean
24
+ description: Validate without merging
25
+ required:
26
+ - repo
27
+ - number
28
+ output_schema:
29
+ type: object
@@ -0,0 +1,22 @@
1
+ command: gh.run.list
2
+ version: "1.0.0"
3
+ description: List recent GitHub Actions runs
4
+ category: github
5
+ adapter: exec
6
+ required_scopes:
7
+ - github:actions:read
8
+ side_effects: read
9
+ idempotent: true
10
+ input_schema:
11
+ type: object
12
+ properties:
13
+ repo:
14
+ type: string
15
+ description: owner/name
16
+ limit:
17
+ type: integer
18
+ description: Max results (default 20)
19
+ required:
20
+ - repo
21
+ output_schema:
22
+ type: object
@@ -0,0 +1,17 @@
1
+ command: k8s.deployment.list
2
+ version: "1.0.0"
3
+ description: List deployments in a namespace
4
+ category: kubernetes
5
+ adapter: k8s
6
+ required_scopes:
7
+ - k8s:deployments:read
8
+ side_effects: read
9
+ idempotent: true
10
+ input_schema:
11
+ type: object
12
+ properties:
13
+ namespace:
14
+ type: string
15
+ description: Kubernetes namespace
16
+ output_schema:
17
+ type: object
@@ -0,0 +1,25 @@
1
+ command: k8s.deployment.restart
2
+ version: "1.0.0"
3
+ description: Roll a deployment (restarts every pod)
4
+ category: kubernetes
5
+ adapter: k8s
6
+ required_scopes:
7
+ - k8s:deployments:write
8
+ side_effects: write
9
+ idempotent: true
10
+ input_schema:
11
+ type: object
12
+ properties:
13
+ namespace:
14
+ type: string
15
+ description: Kubernetes namespace
16
+ deployment:
17
+ type: string
18
+ description: Deployment name
19
+ dry_run:
20
+ type: boolean
21
+ description: Validate server-side without restarting
22
+ required:
23
+ - deployment
24
+ output_schema:
25
+ type: object
@@ -0,0 +1,22 @@
1
+ command: k8s.namespace.delete
2
+ version: "1.0.0"
3
+ description: Delete a namespace and everything in it. Irreversible.
4
+ category: kubernetes
5
+ adapter: k8s
6
+ required_scopes:
7
+ - k8s:namespaces:write
8
+ side_effects: destructive
9
+ idempotent: false
10
+ input_schema:
11
+ type: object
12
+ properties:
13
+ namespace:
14
+ type: string
15
+ description: Namespace to delete
16
+ dry_run:
17
+ type: boolean
18
+ description: Validate server-side without deleting
19
+ required:
20
+ - namespace
21
+ output_schema:
22
+ type: object
@@ -0,0 +1,22 @@
1
+ command: k8s.pod.describe
2
+ version: "1.0.0"
3
+ description: Describe a pod (status, events, conditions)
4
+ category: kubernetes
5
+ adapter: k8s
6
+ required_scopes:
7
+ - k8s:pods:read
8
+ side_effects: read
9
+ idempotent: true
10
+ input_schema:
11
+ type: object
12
+ properties:
13
+ namespace:
14
+ type: string
15
+ description: Kubernetes namespace
16
+ pod:
17
+ type: string
18
+ description: Pod name
19
+ required:
20
+ - pod
21
+ output_schema:
22
+ type: object
@@ -0,0 +1,28 @@
1
+ command: k8s.pod.logs
2
+ version: "1.0.0"
3
+ description: Fetch recent logs for a pod
4
+ category: kubernetes
5
+ adapter: k8s
6
+ required_scopes:
7
+ - k8s:pods:read
8
+ side_effects: read
9
+ idempotent: true
10
+ input_schema:
11
+ type: object
12
+ properties:
13
+ namespace:
14
+ type: string
15
+ description: Kubernetes namespace
16
+ pod:
17
+ type: string
18
+ description: Pod name
19
+ tail:
20
+ type: integer
21
+ description: Number of lines from the end (default 100)
22
+ container:
23
+ type: string
24
+ description: Container name when the pod has several
25
+ required:
26
+ - pod
27
+ output_schema:
28
+ type: object
@@ -0,0 +1,17 @@
1
+ command: k8s.service.list
2
+ version: "1.0.0"
3
+ description: List services in a namespace
4
+ category: kubernetes
5
+ adapter: k8s
6
+ required_scopes:
7
+ - k8s:services:read
8
+ side_effects: read
9
+ idempotent: true
10
+ input_schema:
11
+ type: object
12
+ properties:
13
+ namespace:
14
+ type: string
15
+ description: Kubernetes namespace
16
+ output_schema:
17
+ type: object
gax/profiles.py ADDED
@@ -0,0 +1,143 @@
1
+ """
2
+ Profiles — bundled sets of ready-to-use commands.
3
+
4
+ The adoption problem this solves: GAX shipped 11 commands, mostly mocks, so a new
5
+ user's first task was authoring YAML for everything they actually cared about.
6
+ A framework that ships empty doesn't get adopted.
7
+
8
+ A profile is a directory of manifests under `gax/profiles/<name>/`. Installing one
9
+ copies them into `~/.gax/manifests/`, which the registry loads *after* the packaged
10
+ set — so profile commands survive package upgrades and can be edited in place.
11
+
12
+ Profiles deliberately span the danger ladder (`read` → `write` → `destructive`).
13
+ A profile of only read commands would make the side-effect ceiling untestable in
14
+ practice, and the ceiling is the thing worth demonstrating.
15
+
16
+ Installing a profile **grants nothing**. It registers commands; invoking them still
17
+ requires a capability naming the command, holding the scope, and reaching the
18
+ ceiling. `gax init --profile k8s` still hands out a read-only capability.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import shutil
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ import yaml
29
+
30
+ from gax.paths import PROFILES_DIR, USER_MANIFESTS_DIR
31
+
32
+
33
+ @dataclass
34
+ class ProfileCommand:
35
+ command: str
36
+ description: str
37
+ side_effects: str
38
+ required_scopes: list[str] = field(default_factory=list)
39
+
40
+
41
+ @dataclass
42
+ class Profile:
43
+ name: str
44
+ path: Path
45
+ commands: list[ProfileCommand]
46
+
47
+ @property
48
+ def scopes(self) -> list[str]:
49
+ seen: list[str] = []
50
+ for c in self.commands:
51
+ for s in c.required_scopes:
52
+ if s not in seen:
53
+ seen.append(s)
54
+ return sorted(seen)
55
+
56
+ def by_level(self, level: str) -> list[ProfileCommand]:
57
+ return [c for c in self.commands if c.side_effects == level]
58
+
59
+ @property
60
+ def summary(self) -> str:
61
+ parts = [
62
+ f"{len(self.by_level(lvl))} {lvl}"
63
+ for lvl in ("read", "write", "destructive")
64
+ if self.by_level(lvl)
65
+ ]
66
+ return ", ".join(parts)
67
+
68
+
69
+ def _read_profile(directory: Path) -> Profile:
70
+ commands: list[ProfileCommand] = []
71
+ for path in sorted(directory.glob("*.yaml")):
72
+ data = yaml.safe_load(path.read_text()) or {}
73
+ if not data.get("command"):
74
+ continue
75
+ commands.append(
76
+ ProfileCommand(
77
+ command=str(data["command"]),
78
+ description=str(data.get("description", "")),
79
+ side_effects=str(data.get("side_effects", "destructive")),
80
+ required_scopes=list(data.get("required_scopes") or []),
81
+ )
82
+ )
83
+ return Profile(name=directory.name, path=directory, commands=commands)
84
+
85
+
86
+ def available_profiles() -> dict[str, Profile]:
87
+ if not PROFILES_DIR.exists():
88
+ return {}
89
+ return {
90
+ d.name: _read_profile(d)
91
+ for d in sorted(PROFILES_DIR.iterdir())
92
+ if d.is_dir() and not d.name.startswith(("_", "."))
93
+ }
94
+
95
+
96
+ def get_profile(name: str) -> Profile | None:
97
+ return available_profiles().get(name)
98
+
99
+
100
+ def installed_commands() -> set[str]:
101
+ """Commands currently present in the user manifests directory."""
102
+ out: set[str] = set()
103
+ if not USER_MANIFESTS_DIR.exists():
104
+ return out
105
+ for path in USER_MANIFESTS_DIR.glob("*.yaml"):
106
+ data = yaml.safe_load(path.read_text()) or {}
107
+ if data.get("command"):
108
+ out.add(str(data["command"]))
109
+ return out
110
+
111
+
112
+ def install_profile(name: str, *, force: bool = False) -> dict[str, Any]:
113
+ """
114
+ Copy a profile's manifests into `~/.gax/manifests/`.
115
+
116
+ Existing files are skipped unless `force`, so a locally edited command is never
117
+ silently overwritten by a reinstall.
118
+ """
119
+ profile = get_profile(name)
120
+ if profile is None:
121
+ raise KeyError(name)
122
+
123
+ USER_MANIFESTS_DIR.mkdir(parents=True, exist_ok=True)
124
+ installed: list[str] = []
125
+ skipped: list[str] = []
126
+
127
+ for src in sorted(profile.path.glob("*.yaml")):
128
+ dst = USER_MANIFESTS_DIR / src.name
129
+ if dst.exists() and not force:
130
+ skipped.append(src.stem)
131
+ continue
132
+ shutil.copy2(src, dst)
133
+ installed.append(src.stem)
134
+
135
+ return {
136
+ "profile": name,
137
+ "installed": installed,
138
+ "skipped": skipped,
139
+ "target": str(USER_MANIFESTS_DIR),
140
+ "scopes": profile.scopes,
141
+ "commands": [c.command for c in profile.commands],
142
+ "summary": profile.summary,
143
+ }