agentgov-cli 0.1.3__tar.gz → 0.1.5__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 (26) hide show
  1. {agentgov_cli-0.1.3 → agentgov_cli-0.1.5}/.gitignore +3 -0
  2. {agentgov_cli-0.1.3 → agentgov_cli-0.1.5}/PKG-INFO +1 -1
  3. agentgov_cli-0.1.5/agentgov_cli/client_assets/commands/workitem.md +18 -0
  4. agentgov_cli-0.1.5/agentgov_cli/client_assets/hooks/pretooluse_pathguard.py +160 -0
  5. agentgov_cli-0.1.5/agentgov_cli/client_assets/hooks/sessionstart_register.py +114 -0
  6. agentgov_cli-0.1.5/agentgov_cli/client_assets/hooks/userpromptsubmit_workitem.py +106 -0
  7. agentgov_cli-0.1.5/agentgov_cli/client_assets/managed-settings.json.tmpl +41 -0
  8. agentgov_cli-0.1.5/agentgov_cli/client_assets/statusline/agentgov_statusline.sh +74 -0
  9. {agentgov_cli-0.1.3 → agentgov_cli-0.1.5}/agentgov_cli/commands/gateway.py +9 -4
  10. agentgov_cli-0.1.5/agentgov_cli/commands/install.py +269 -0
  11. {agentgov_cli-0.1.3 → agentgov_cli-0.1.5}/agentgov_cli/gateway_process.py +32 -6
  12. agentgov_cli-0.1.5/hatch_build.py +72 -0
  13. {agentgov_cli-0.1.3 → agentgov_cli-0.1.5}/pyproject.toml +19 -9
  14. agentgov_cli-0.1.3/agentgov_cli/commands/install.py +0 -160
  15. {agentgov_cli-0.1.3 → agentgov_cli-0.1.5}/README.md +0 -0
  16. {agentgov_cli-0.1.3 → agentgov_cli-0.1.5}/agentgov_cli/__init__.py +0 -0
  17. {agentgov_cli-0.1.3 → agentgov_cli-0.1.5}/agentgov_cli/commands/__init__.py +0 -0
  18. {agentgov_cli-0.1.3 → agentgov_cli-0.1.5}/agentgov_cli/commands/doctor.py +0 -0
  19. {agentgov_cli-0.1.3 → agentgov_cli-0.1.5}/agentgov_cli/commands/login.py +0 -0
  20. {agentgov_cli-0.1.3 → agentgov_cli-0.1.5}/agentgov_cli/commands/register_device.py +0 -0
  21. {agentgov_cli-0.1.3 → agentgov_cli-0.1.5}/agentgov_cli/commands/status.py +0 -0
  22. {agentgov_cli-0.1.3 → agentgov_cli-0.1.5}/agentgov_cli/commands/workitem.py +0 -0
  23. {agentgov_cli-0.1.3 → agentgov_cli-0.1.5}/agentgov_cli/commands/wrap.py +0 -0
  24. {agentgov_cli-0.1.3 → agentgov_cli-0.1.5}/agentgov_cli/loopback.py +0 -0
  25. {agentgov_cli-0.1.3 → agentgov_cli-0.1.5}/agentgov_cli/main.py +0 -0
  26. {agentgov_cli-0.1.3 → agentgov_cli-0.1.5}/agentgov_cli/port_resolver.py +0 -0
@@ -79,3 +79,6 @@ Thumbs.db
79
79
  # ---- Local overrides ----
80
80
  *.local
81
81
  CLAUDE.local.md
82
+
83
+ # Build artifact: vendored by cli/hatch_build.py from the repo-root copy.
84
+ cli/agentgov_cli/client_assets/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentgov-cli
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: AgentGov CLI — wrap Claude Code, bind work items, check gateway health.
5
5
  Author: AgentGov Contributors
6
6
  License: Apache-2.0
@@ -0,0 +1,18 @@
1
+ ---
2
+ description: Bind this session to a work item (JIRA / Notion / PRJ-XXXX)
3
+ allowed-tools: Bash(agentgov workitem:*)
4
+ argument-hint: <PROJ-1234 | https://notion.so/... | PRJ-0042>
5
+ ---
6
+
7
+ Bind the current session to a validated work item so token spend is
8
+ attributed to the right story.
9
+
10
+ Run:
11
+
12
+ !`agentgov workitem $ARGUMENTS`
13
+
14
+ If the CLI prints an error (JIRA not reachable, item DONE, etc.), relay it
15
+ verbatim to the user and stop — do not attempt to run other tools until they
16
+ provide a valid work item. If the CLI prints a success line
17
+ (`✅ Bound to PROJ-1234 …`), acknowledge the binding and continue with whatever
18
+ the user was doing.
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env python3
2
+ """AgentGov PreToolUse path-guard hook (§R-CC1).
3
+
4
+ Registered for Read | Write | Edit | Glob | Grep | Bash | NotebookEdit.
5
+
6
+ Reads Claude Code's hook JSON on stdin; extracts candidate paths from
7
+ tool_input; resolves realpath (symlink-safe); exits 2 with a stderr
8
+ explanation if any path escapes $AGENTGOV_REPO_ROOT.
9
+
10
+ Fail-closed: unparsable input → exit 2 (block).
11
+ Allow: paths inside $AGENTGOV_REPO_ROOT, or under /tmp/claude-* scratch.
12
+
13
+ Every block is appended to ~/.agentgov/toolguard.log for post-hoc audit.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ import shlex
21
+ import subprocess
22
+ import sys
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+
26
+ # The authorized repository root. Resolved in main() from the hook payload's
27
+ # `cwd`, because this hook now runs in ANY Claude Code session — including the
28
+ # VS Code extension, where no `agentgov wrap` process exists to export
29
+ # AGENTGOV_REPO_ROOT. The env var is still honoured first so wrapper-launched
30
+ # sessions and the test-suite behave exactly as before.
31
+ REPO_ROOT = os.environ.get("AGENTGOV_REPO_ROOT", "")
32
+ LOG_PATH = Path.home() / ".agentgov" / "toolguard.log"
33
+
34
+
35
+ def _resolve_repo_root(cwd: str) -> str:
36
+ """Repository root for this session: env first, else `git rev-parse` in cwd.
37
+
38
+ Returning "" makes _allowed() fail closed, which is the correct outcome for
39
+ a directory that is not a git repository — there is no authorized root, so
40
+ nothing is authorized.
41
+ """
42
+ if REPO_ROOT:
43
+ return REPO_ROOT
44
+ if not cwd:
45
+ return ""
46
+ try:
47
+ out = subprocess.run(
48
+ ["git", "rev-parse", "--show-toplevel"],
49
+ cwd=cwd,
50
+ capture_output=True,
51
+ text=True,
52
+ timeout=5,
53
+ check=False,
54
+ )
55
+ except (OSError, subprocess.SubprocessError):
56
+ return ""
57
+ return out.stdout.strip() if out.returncode == 0 else ""
58
+
59
+
60
+ def _log_block(reason: str, detail: str) -> None:
61
+ try:
62
+ LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
63
+ with LOG_PATH.open("a", encoding="utf-8") as f:
64
+ f.write(
65
+ json.dumps(
66
+ {
67
+ "at": datetime.now(timezone.utc).isoformat(),
68
+ "reason": reason,
69
+ "detail": detail,
70
+ "repo_root": REPO_ROOT,
71
+ }
72
+ )
73
+ + "\n"
74
+ )
75
+ except Exception:
76
+ pass # never let logging break enforcement
77
+
78
+
79
+ def _bail(reason: str, detail: str) -> None:
80
+ _log_block(reason, detail)
81
+ sys.stderr.write(f"AgentGov: blocked — {detail}\n")
82
+ sys.exit(2)
83
+
84
+
85
+ def _is_inside(candidate: Path, root: Path) -> bool:
86
+ try:
87
+ candidate.relative_to(root)
88
+ return True
89
+ except ValueError:
90
+ return False
91
+
92
+
93
+ def _allowed(candidate: str) -> bool:
94
+ if not REPO_ROOT:
95
+ return False # fail-closed if the env wasn't set
96
+ # /tmp/claude-* scratch is explicitly allowed.
97
+ if candidate.startswith("/tmp/claude-") or candidate.startswith("/tmp/agentgov-"):
98
+ return True
99
+ p = Path(candidate)
100
+ if not p.is_absolute():
101
+ # Relative path — treat as inside cwd (which the wrapper set to repo root).
102
+ p = Path.cwd() / p
103
+ try:
104
+ resolved = p.resolve(strict=False)
105
+ except Exception:
106
+ return False
107
+ root = Path(REPO_ROOT).resolve(strict=False)
108
+ return _is_inside(resolved, root)
109
+
110
+
111
+ def _extract_paths(tool_name: str, tool_input: dict) -> list[str]:
112
+ if tool_name == "Bash":
113
+ # For Bash, scan the command string for absolute paths and `cd` targets.
114
+ cmd = str(tool_input.get("command", ""))
115
+ paths: list[str] = []
116
+ # `cd <path>`
117
+ for m in re.finditer(r"\bcd\s+([^\s;&|<>]+)", cmd):
118
+ paths.append(m.group(1))
119
+ # Absolute paths appearing as tokens.
120
+ try:
121
+ for tok in shlex.split(cmd):
122
+ if tok.startswith("/") or tok.startswith("~/"):
123
+ paths.append(tok)
124
+ except ValueError:
125
+ paths.append(cmd) # unparsable → be conservative
126
+ return paths
127
+ # Standard file-touching tools.
128
+ for k in ("file_path", "path", "pattern"):
129
+ v = tool_input.get(k)
130
+ if v:
131
+ return [str(v)]
132
+ return []
133
+
134
+
135
+ def main() -> None:
136
+ try:
137
+ payload = json.loads(sys.stdin.read())
138
+ except Exception:
139
+ _bail("bad_input", "unparsable hook JSON")
140
+
141
+ tool_name = payload.get("tool_name") or payload.get("toolName") or ""
142
+ tool_input = payload.get("tool_input") or payload.get("toolInput") or {}
143
+ if not isinstance(tool_input, dict):
144
+ _bail("bad_input", "tool_input is not an object")
145
+
146
+ # Resolve the root for THIS session before checking anything against it.
147
+ global REPO_ROOT
148
+ REPO_ROOT = _resolve_repo_root(str(payload.get("cwd") or ""))
149
+
150
+ candidates = _extract_paths(tool_name, tool_input)
151
+ for c in candidates:
152
+ if not _allowed(c):
153
+ _bail("outside_repo_root", f"'{c}' is outside the authorized repository root")
154
+
155
+ # No offending path; allow.
156
+ sys.exit(0)
157
+
158
+
159
+ if __name__ == "__main__":
160
+ main()
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env python3
2
+ """AgentGov SessionStart hook — registers this Claude Code session (§R-CC2).
3
+
4
+ Runs inside WHATEVER Claude Code the developer actually uses: the VS Code /
5
+ Cursor / JetBrains extension, or the terminal CLI. That is the point. The old
6
+ version of this hook required `agentgov wrap` to have exported AGENTGOV_SESSION,
7
+ which meant only a wrapper-launched terminal agent could ever be governed — the
8
+ editor extension the developer really works in was left out.
9
+
10
+ It needs nothing from a wrapper. Claude Code hands the hook everything on stdin:
11
+
12
+ {"hook_event_name": "SessionStart",
13
+ "session_id": "40a9b75f-55f6-42bb-b839-a4d668a47b45",
14
+ "cwd": "/home/dev/payment-api"}
15
+
16
+ and sends that SAME session id to the API on every request as
17
+ `x-claude-code-session-id`. So registering `session_id -> repo` here is what lets
18
+ the gateway authorize every later request. See readme/ARCHITECTURE_v1.md §4.
19
+
20
+ Exit code is always 0: a governance hook must never stop a developer from
21
+ opening their editor. If registration fails, the API plane still denies —
22
+ enforcement does not depend on this hook succeeding.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import subprocess
30
+ import sys
31
+ import urllib.request
32
+
33
+ GATEWAY_URL = os.environ.get("AGENTGOV_GATEWAY_URL", "http://127.0.0.1:8000")
34
+ TIMEOUT_SEC = 5.0
35
+
36
+
37
+ def _git(cwd: str, *args: str) -> str:
38
+ """Run a git command in `cwd`, returning stripped stdout ('' on failure)."""
39
+ try:
40
+ out = subprocess.run(
41
+ ["git", *args],
42
+ cwd=cwd,
43
+ capture_output=True,
44
+ text=True,
45
+ timeout=5,
46
+ check=False,
47
+ )
48
+ except (OSError, subprocess.SubprocessError):
49
+ return ""
50
+ return out.stdout.strip() if out.returncode == 0 else ""
51
+
52
+
53
+ def main() -> None:
54
+ try:
55
+ payload = json.load(sys.stdin)
56
+ except Exception:
57
+ sys.exit(0) # Never block the session on a parse problem.
58
+
59
+ session_id = str(payload.get("session_id") or "")
60
+ cwd = str(payload.get("cwd") or os.getcwd())
61
+ if not session_id:
62
+ sys.exit(0)
63
+
64
+ repo_root = _git(cwd, "rev-parse", "--show-toplevel")
65
+ remote = _git(cwd, "remote", "get-url", "origin") if repo_root else ""
66
+
67
+ if not repo_root:
68
+ # Outside a git repo there is nothing to authorize against. Say so now,
69
+ # in the session banner, rather than letting the first prompt fail.
70
+ sys.stdout.write(
71
+ "AgentGov: this directory is not a git repository, so it cannot be "
72
+ "authorized. Open a granted repository to use Claude Code here.\n"
73
+ )
74
+ sys.exit(0)
75
+
76
+ body = json.dumps(
77
+ {
78
+ "session_id": session_id,
79
+ "repo": remote,
80
+ "repo_root": repo_root,
81
+ "client_version": os.environ.get("CLAUDE_CODE_VERSION", ""),
82
+ }
83
+ ).encode()
84
+
85
+ try:
86
+ req = urllib.request.Request(
87
+ f"{GATEWAY_URL.rstrip('/')}/admin/session/register",
88
+ data=body,
89
+ headers={"Content-Type": "application/json"},
90
+ )
91
+ with urllib.request.urlopen(req, timeout=TIMEOUT_SEC) as resp: # noqa: S310 — loopback only
92
+ data = json.loads(resp.read() or b"{}")
93
+ except Exception:
94
+ sys.stdout.write(
95
+ "AgentGov: could not reach the governance gateway at "
96
+ f"{GATEWAY_URL}. Claude Code will not be able to send requests until it is "
97
+ "running. Start it with: agentgov gateway install\n"
98
+ )
99
+ sys.exit(0)
100
+
101
+ # Surface anything the developer needs to act on, in the session banner.
102
+ for warning in data.get("warnings") or []:
103
+ sys.stdout.write(f"AgentGov: {warning}\n")
104
+ if not remote:
105
+ sys.stdout.write(
106
+ "AgentGov: this repository has no `origin` remote, so it cannot be "
107
+ "matched to a grant.\n"
108
+ )
109
+
110
+ sys.exit(0)
111
+
112
+
113
+ if __name__ == "__main__":
114
+ main()
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env python3
2
+ """AgentGov UserPromptSubmit hook — the work-item gate.
3
+
4
+ WHY THIS EXISTS
5
+ ---------------
6
+ Work-item binding used to be enforced only at the gateway, which replied with a
7
+ synthetic "bind a work item" assistant message. That has two problems:
8
+
9
+ 1. The developer only discovers the requirement AFTER sending a prompt, and
10
+ it arrives looking like a strange answer from the model.
11
+ 2. It cannot prompt at all until something is sent — so a developer who opens
12
+ their editor and starts typing gets no guidance.
13
+
14
+ `UserPromptSubmit` fires before Claude Code sends anything, and exit code 2
15
+ blocks the prompt and shows stderr to the developer. So the ask happens in the
16
+ window they are already working in, before any network call, and costs nothing.
17
+
18
+ The gateway still enforces the same rule independently (§3.1, two-plane model).
19
+ This hook is the good experience; the gateway is the actual control. A developer
20
+ who deletes this hook does not gain ungoverned access — they just go back to
21
+ being told by the gateway instead.
22
+
23
+ Exit codes:
24
+ 0 -> allow the prompt
25
+ 2 -> block it, and show stderr to the developer
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import sys
33
+ import urllib.request
34
+
35
+ GATEWAY_URL = os.environ.get("AGENTGOV_GATEWAY_URL", "http://127.0.0.1:8000")
36
+ TIMEOUT_SEC = 4.0
37
+
38
+ # Prompts that exist to FIX an unbound session must not be blocked by the
39
+ # unbound session — otherwise the developer is deadlocked with no way out.
40
+ ESCAPE_PREFIXES = ("/workitem", "/agentgov", "/help", "/status", "/doctor")
41
+
42
+
43
+ def _allow() -> None:
44
+ sys.exit(0)
45
+
46
+
47
+ def _block(message: str) -> None:
48
+ sys.stderr.write(message)
49
+ sys.exit(2)
50
+
51
+
52
+ def main() -> None:
53
+ try:
54
+ payload = json.load(sys.stdin)
55
+ except Exception:
56
+ _allow() # Fail open: the gateway is the real control.
57
+
58
+ session_id = str(payload.get("session_id") or "")
59
+ user_input = str(payload.get("user_input") or "").strip()
60
+
61
+ if not session_id:
62
+ _allow()
63
+
64
+ # Let the developer run the command that fixes the problem.
65
+ if user_input.startswith(ESCAPE_PREFIXES):
66
+ _allow()
67
+
68
+ try:
69
+ with urllib.request.urlopen( # noqa: S310 — loopback only
70
+ f"{GATEWAY_URL.rstrip('/')}/admin/session/{session_id}/binding",
71
+ timeout=TIMEOUT_SEC,
72
+ ) as resp:
73
+ data = json.loads(resp.read() or b"{}")
74
+ except Exception:
75
+ # Gateway down or unreachable. Do NOT block here: Claude Code cannot
76
+ # reach the model either way, and blocking would bury the real error
77
+ # ("connection refused") under a confusing governance message.
78
+ _allow()
79
+
80
+ if data.get("bound"):
81
+ _allow()
82
+
83
+ if not data.get("known"):
84
+ _block(
85
+ "AgentGov: this session is not registered with the gateway, so it cannot "
86
+ "be governed.\n"
87
+ "Start a new Claude Code session so the SessionStart hook can run. If this "
88
+ "persists, run: agentgov doctor\n"
89
+ )
90
+
91
+ repo = data.get("repo") or "this repository"
92
+ _block(
93
+ "📌 AgentGov: bind this session to a work item before continuing.\n"
94
+ f" Repository: {repo}\n"
95
+ "\n"
96
+ " Run one of:\n"
97
+ " /workitem PROJ-1234 (JIRA issue)\n"
98
+ " /workitem PRJ-0042 (internal project)\n"
99
+ " /workitem https://notion.so/... (Notion page)\n"
100
+ "\n"
101
+ " Your prompt was not sent and no tokens were used.\n"
102
+ )
103
+
104
+
105
+ if __name__ == "__main__":
106
+ main()
@@ -0,0 +1,41 @@
1
+ {
2
+ "$comment": "AgentGov managed-settings.json template. Rendered by `agentgov install`. The final file MUST live in the OS-managed path (see docs/DEPLOYMENT.md §4) and be root/Administrator-owned. Developers cannot override.",
3
+ "env": {
4
+ "ANTHROPIC_BASE_URL": "{{GATEWAY_URL}}",
5
+ "AGENTGOV_LOOPBACK_PORT": "8788"
6
+ },
7
+ "apiKeyHelper": "",
8
+ "hooks": {
9
+ "PreToolUse": [
10
+ {
11
+ "matcher": "Read|Write|Edit|Glob|Grep|Bash|NotebookEdit",
12
+ "hooks": [
13
+ { "type": "command", "command": "{{HOME}}/.claude/agentgov-hooks/pretooluse_pathguard.py" }
14
+ ]
15
+ }
16
+ ],
17
+ "SessionStart": [
18
+ {
19
+ "hooks": [
20
+ { "type": "command", "command": "{{HOME}}/.claude/agentgov-hooks/sessionstart_register.py" }
21
+ ]
22
+ }
23
+ ],
24
+ "UserPromptSubmit": [
25
+ {
26
+ "$comment": "Work-item gate. Blocks the prompt (exit 2) in the developer's own window when the session has no work item bound, so they are told before anything is sent rather than after.",
27
+ "hooks": [
28
+ { "type": "command", "command": "{{HOME}}/.claude/agentgov-hooks/userpromptsubmit_workitem.py" }
29
+ ]
30
+ }
31
+ ]
32
+ },
33
+ "statusLine": {
34
+ "type": "command",
35
+ "command": "{{HOME}}/.claude/agentgov-statusline/agentgov_statusline.sh"
36
+ },
37
+ "permissions": {
38
+ "allow": [],
39
+ "deny": []
40
+ }
41
+ }
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bash
2
+ # AgentGov statusline (§R-CC3, §13.6).
3
+ # Renders one of:
4
+ # 🛡 <repo> │ 📌 <work-item> │ N% weekly · resets <when> (personal mode w/ rate-limit)
5
+ # 🛡 <repo> │ 📌 <work-item> │ N.NNk tok / $C.CC (vault mode w/ cost)
6
+ # 🛡 <repo> │ 📌 UNBOUND — /workitem required (no binding)
7
+
8
+ set -u
9
+
10
+ session="${AGENTGOV_SESSION:-}"
11
+ state_file="${HOME}/.agentgov/state/${session}.json"
12
+
13
+ if [[ -z "${session}" ]]; then
14
+ printf "\033[31m📌 UNGOVERNED — run agentgov wrap claude\033[0m"
15
+ exit 0
16
+ fi
17
+
18
+ if [[ ! -f "${state_file}" ]]; then
19
+ printf "\033[33m🛡 %s\033[0m │ \033[31m📌 UNBOUND — /workitem required\033[0m" "${AGENTGOV_REPO:-unknown}"
20
+ exit 0
21
+ fi
22
+
23
+ # Parse fields — jq preferred; python fallback.
24
+ if command -v jq >/dev/null 2>&1; then
25
+ repo=$(jq -r '.repo // env.AGENTGOV_REPO // "unknown"' "${state_file}")
26
+ wi=$(jq -r '.work_item.external_id // "UNBOUND"' "${state_file}")
27
+ tokens=$(jq -r '.tokens // 0' "${state_file}")
28
+ cost=$(jq -r '.cost_usd // 0' "${state_file}")
29
+ percent=$(jq -r '.rate_limit_percent // ""' "${state_file}")
30
+ reset=$(jq -r '.rate_limit_reset // ""' "${state_file}")
31
+ else
32
+ read -r repo wi tokens cost percent reset < <(python3 - "${state_file}" <<'PY'
33
+ import json, os, sys
34
+ d = json.load(open(sys.argv[1]))
35
+ print(
36
+ d.get("repo") or os.environ.get("AGENTGOV_REPO", "unknown"),
37
+ (d.get("work_item") or {}).get("external_id") or "UNBOUND",
38
+ d.get("tokens") or 0,
39
+ d.get("cost_usd") or 0,
40
+ d.get("rate_limit_percent") or "",
41
+ d.get("rate_limit_reset") or "",
42
+ )
43
+ PY
44
+ )
45
+ fi
46
+
47
+ # Format tokens as N.Nk if >= 1000.
48
+ if [[ "$tokens" -ge 1000 ]] 2>/dev/null; then
49
+ tokens_h=$(awk "BEGIN {printf \"%.1fk\", $tokens/1000}")
50
+ else
51
+ tokens_h="${tokens}"
52
+ fi
53
+
54
+ if [[ "$wi" == "UNBOUND" ]]; then
55
+ wi_display="\033[31m📌 UNBOUND — /workitem required\033[0m"
56
+ else
57
+ wi_display="📌 ${wi}"
58
+ fi
59
+
60
+ # Prefer rate-limit view (personal mode) when we have it; fall back to cost.
61
+ if [[ -n "${percent}" && "${percent}" != "null" && "${percent}" != "None" ]]; then
62
+ # Color the % red under 15%, yellow under 40%, green otherwise.
63
+ if awk "BEGIN {exit !($percent < 15)}"; then color=31
64
+ elif awk "BEGIN {exit !($percent < 40)}"; then color=33
65
+ else color=32; fi
66
+ if [[ -n "${reset}" && "${reset}" != "null" ]]; then
67
+ reset_short=$(printf "%s" "$reset" | cut -c 6-16 | tr T " ")
68
+ printf "🛡 %s │ %b │ \033[%sm%s%% weekly\033[0m · resets %s" "$repo" "$wi_display" "$color" "$percent" "$reset_short"
69
+ else
70
+ printf "🛡 %s │ %b │ \033[%sm%s%% weekly\033[0m" "$repo" "$wi_display" "$color" "$percent"
71
+ fi
72
+ else
73
+ printf "🛡 %s │ %b │ %s tok / \$%.2f" "$repo" "$wi_display" "$tokens_h" "$cost"
74
+ fi
@@ -39,12 +39,17 @@ def install(gateway_url: str = _GATEWAY_OPT) -> None:
39
39
  return
40
40
 
41
41
  console.print(f"[green]{detail}[/]")
42
- if gp.is_healthy(url, timeout_sec=10.0):
42
+ # Wait for boot rather than probing once. The service manager returns as
43
+ # soon as it has spawned the process, but the gateway only binds its port
44
+ # after rehydrating sessions and fetching the first policy snapshot — two
45
+ # round-trips to the control tower. A single probe here reports a false
46
+ # failure on a gateway that is simply still starting.
47
+ console.print(f"Waiting for {url}/health ...")
48
+ if gp.wait_until_healthy(url):
43
49
  console.print(f"[green]Gateway healthy[/] at {url}")
44
50
  else:
45
- # The service manager accepted the unit but the process is not
46
- # answering — surface the log rather than claiming success.
47
- console.print(f"[yellow]Service installed but {url}/health did not answer yet.[/]")
51
+ # Genuinely not answering — surface the log rather than claiming success.
52
+ console.print(f"[yellow]Service installed but {url}/health did not answer in time.[/]")
48
53
  console.print(gp.log_tail())
49
54
  raise typer.Exit(1)
50
55
 
@@ -0,0 +1,269 @@
1
+ """`agentgov install` — copies client assets to ~/.claude/, prints managed-settings.json.
2
+
3
+ The managed-settings.json is printed to stdout (with the admin-visible path per OS)
4
+ so an administrator can drop it into the OS-managed path. That path is ROOT-owned;
5
+ we do NOT try to write it — the CLI runs as the developer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import platform
13
+ import shutil
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import typer
18
+ from rich.console import Console
19
+
20
+ console = Console()
21
+
22
+ # Asset source is discovered relative to the installed package (client_assets is
23
+ # a sibling directory of the cli/). In wheel installs we ship it via
24
+ # package data; in local dev we look up-tree.
25
+ # pip byte-compiles the vendored hooks inside site-packages, so the source
26
+ # tree carries __pycache__. Never copy that into the developer's ~/.claude.
27
+ _IGNORE_BUILD_JUNK = shutil.ignore_patterns("__pycache__", "*.pyc")
28
+
29
+ ASSET_ROOTS = [
30
+ # Vendored into the package at build time by cli/hatch_build.py. This is the
31
+ # path that works for pip/pipx installs: it is relative to __file__, so it
32
+ # survives relocated venvs and `pip install --user`, unlike sys.prefix.
33
+ Path(__file__).resolve().parent.parent / "client_assets",
34
+ # Source checkout: client_assets/ sits at the repo root (CLAUDE.md §4).
35
+ Path(__file__).resolve().parent.parent.parent.parent / "client_assets",
36
+ # Legacy shared-data location, kept so older installs keep working.
37
+ Path(sys.prefix) / "share" / "agentgov" / "client_assets",
38
+ ]
39
+
40
+
41
+ def run(
42
+ gateway_url: str = typer.Option(
43
+ "http://localhost:8000",
44
+ "--gateway-url",
45
+ "--gateway", # alias — earlier docs called it --gateway
46
+ envvar="AGENTGOV_GATEWAY_URL",
47
+ help="URL of the local AgentGov gateway (usually http://localhost:8000).",
48
+ ),
49
+ force: bool = typer.Option(False, "--force", help="Overwrite existing hooks/statusline."),
50
+ user_settings: bool = typer.Option(
51
+ True,
52
+ "--user-settings/--no-user-settings",
53
+ help=(
54
+ "Register the hooks + statusline in ~/.claude/settings.json so they take "
55
+ "effect immediately, with no sudo. Disable for admin-managed installs."
56
+ ),
57
+ ),
58
+ ) -> None:
59
+ """Install hooks + statusline + slash commands into ~/.claude/.
60
+
61
+ By default this also REGISTERS them in ~/.claude/settings.json, so the
62
+ governance hooks run in every Claude Code session on this machine — the VS
63
+ Code / Cursor extension included — without sudo and without launching
64
+ anything through a wrapper.
65
+
66
+ Also writes a ready-to-copy managed-settings.json to
67
+ ~/.agentgov/managed-settings.json. Installing THAT (needs sudo/admin) is
68
+ what makes governance non-overridable, for team/enterprise deployments.
69
+ """
70
+ src = _find_assets()
71
+ dest = Path.home() / ".claude"
72
+ dest.mkdir(exist_ok=True)
73
+
74
+ hooks_src = src / "hooks"
75
+ hooks_dest = dest / "agentgov-hooks"
76
+ if hooks_dest.exists() and not force:
77
+ console.print(f"[yellow]Skipping[/] hooks — {hooks_dest} exists (use --force)")
78
+ else:
79
+ shutil.copytree(
80
+ hooks_src, hooks_dest, dirs_exist_ok=True, ignore=_IGNORE_BUILD_JUNK
81
+ )
82
+ for h in hooks_dest.glob("*.py"):
83
+ h.chmod(0o755)
84
+ console.print(f"[green]Copied[/] hooks → {hooks_dest}")
85
+
86
+ statusline_src = src / "statusline"
87
+ statusline_dest = dest / "agentgov-statusline"
88
+ if statusline_dest.exists() and not force:
89
+ console.print(f"[yellow]Skipping[/] statusline — {statusline_dest} exists")
90
+ else:
91
+ shutil.copytree(
92
+ statusline_src, statusline_dest, dirs_exist_ok=True, ignore=_IGNORE_BUILD_JUNK
93
+ )
94
+ for h in statusline_dest.glob("*.sh"):
95
+ h.chmod(0o755)
96
+ console.print(f"[green]Copied[/] statusline → {statusline_dest}")
97
+
98
+ commands_dest = dest / "commands"
99
+ commands_dest.mkdir(exist_ok=True)
100
+ shutil.copy2(src / "commands" / "workitem.md", commands_dest / "workitem.md")
101
+ console.print(f"[green]Copied[/] /workitem slash command → {commands_dest / 'workitem.md'}")
102
+
103
+ settings = _build_managed_settings(gateway_url, hooks_dest, statusline_dest)
104
+ settings_json = json.dumps(settings, indent=2)
105
+
106
+ # Activate the hooks for THIS user, right now, without sudo.
107
+ #
108
+ # Copying the files is not enough — Claude Code only runs hooks that a
109
+ # settings file registers. Previously the only place that happened was
110
+ # managed-settings.json, which needs root, so a developer who followed setup
111
+ # to the letter ended up with hook files on disk and nothing running them:
112
+ # no statusline, no path guard, and no work-item prompt.
113
+ if user_settings:
114
+ _merge_user_settings(gateway_url, hooks_dest, statusline_dest)
115
+
116
+ # Write to a stable local path so the admin can `sudo cp` it into place
117
+ # without having to redirect stdout carefully.
118
+ local_settings_path = Path.home() / ".agentgov" / "managed-settings.json"
119
+ local_settings_path.parent.mkdir(parents=True, exist_ok=True)
120
+ local_settings_path.write_text(settings_json + "\n")
121
+ local_settings_path.chmod(0o644)
122
+
123
+ target_path = _managed_settings_path()
124
+ console.rule("[bold]managed-settings.json[/]")
125
+ console.print(f"Written locally to: [cyan]{local_settings_path}[/]")
126
+ console.print(f"Target install path: [cyan]{target_path}[/]\n")
127
+ console.print("[bold]To govern the VS Code extension too, run:[/]")
128
+ if platform.system() == "Windows":
129
+ console.print(
130
+ f' Copy-Item "{local_settings_path}" "{target_path}" -Force'
131
+ )
132
+ else:
133
+ parent = str(Path(target_path).parent)
134
+ console.print(
135
+ f" sudo mkdir -p {parent} && sudo cp {local_settings_path} {target_path}"
136
+ )
137
+ console.print(
138
+ "\nThen fully quit VS Code and reopen — the extension now routes through the gateway."
139
+ )
140
+ console.print("\n[dim]Contents (for reference):[/]")
141
+ console.print(settings_json)
142
+
143
+
144
+ def _agentgov_hook_entries(hooks_dest: Path) -> dict[str, list[dict[str, object]]]:
145
+ """The hook registrations AgentGov owns, keyed by Claude Code event name."""
146
+ def cmd(script: str) -> dict[str, object]:
147
+ return {"type": "command", "command": str(hooks_dest / script)}
148
+
149
+ return {
150
+ "SessionStart": [{"hooks": [cmd("sessionstart_register.py")]}],
151
+ "UserPromptSubmit": [{"hooks": [cmd("userpromptsubmit_workitem.py")]}],
152
+ "PreToolUse": [
153
+ {
154
+ "matcher": "Read|Write|Edit|Glob|Grep|Bash|NotebookEdit",
155
+ "hooks": [cmd("pretooluse_pathguard.py")],
156
+ }
157
+ ],
158
+ }
159
+
160
+
161
+ def _is_agentgov_entry(entry: object) -> bool:
162
+ """True if a hook registration belongs to AgentGov.
163
+
164
+ Matched on the command path so re-running `install` replaces our own entries
165
+ instead of stacking duplicates, while leaving the user's other hooks alone.
166
+ """
167
+ if not isinstance(entry, dict):
168
+ return False
169
+ for h in entry.get("hooks") or []:
170
+ if isinstance(h, dict) and "agentgov-hooks" in str(h.get("command", "")):
171
+ return True
172
+ return False
173
+
174
+
175
+ def _merge_user_settings(gateway_url: str, hooks_dest: Path, statusline_dest: Path) -> None:
176
+ """Register AgentGov's hooks + statusline in ~/.claude/settings.json.
177
+
178
+ MERGES. The file usually already holds the developer's own preferences and
179
+ hooks; clobbering it would be a hostile way to install a governance tool.
180
+ We replace only entries we previously wrote (identified by their path) and
181
+ leave everything else untouched. A corrupt file is left alone entirely —
182
+ reporting it beats silently overwriting whatever was in there.
183
+ """
184
+ path = Path.home() / ".claude" / "settings.json"
185
+ path.parent.mkdir(parents=True, exist_ok=True)
186
+
187
+ data: dict[str, object] = {}
188
+ if path.exists():
189
+ try:
190
+ loaded = json.loads(path.read_text() or "{}")
191
+ if isinstance(loaded, dict):
192
+ data = loaded
193
+ else:
194
+ console.print(f"[yellow]Skipping[/] {path} — not a JSON object.")
195
+ return
196
+ except json.JSONDecodeError as e:
197
+ console.print(
198
+ f"[red]Skipping[/] {path} — it is not valid JSON ({e}).\n"
199
+ "Fix or remove it, then re-run `agentgov install`."
200
+ )
201
+ return
202
+ # Back the file up once before the first modification.
203
+ backup = path.with_suffix(".json.agentgov-backup")
204
+ if not backup.exists():
205
+ backup.write_text(path.read_text())
206
+
207
+ hooks_raw = data.get("hooks")
208
+ hooks: dict[str, object] = hooks_raw if isinstance(hooks_raw, dict) else {}
209
+ for event, entries in _agentgov_hook_entries(hooks_dest).items():
210
+ existing_raw = hooks.get(event)
211
+ existing = existing_raw if isinstance(existing_raw, list) else []
212
+ kept = [e for e in existing if not _is_agentgov_entry(e)]
213
+ hooks[event] = kept + entries
214
+ data["hooks"] = hooks
215
+
216
+ data["statusLine"] = {
217
+ "type": "command",
218
+ "command": str(statusline_dest / "agentgov_statusline.sh"),
219
+ }
220
+
221
+ # NOTE: deliberately NOT setting env.ANTHROPIC_BASE_URL here. User settings
222
+ # are the lowest-precedence scope and the developer can edit them, so
223
+ # pinning the base URL here would look like enforcement while being trivial
224
+ # to undo. Routing is the job of managed-settings.json (highest precedence).
225
+ path.write_text(json.dumps(data, indent=2) + "\n")
226
+ console.print(f"[green]Registered[/] hooks + statusline → {path}")
227
+ console.print(
228
+ " [dim]These run in every Claude Code session on this machine, including the "
229
+ "VS Code extension. Restart Claude Code to pick them up.[/]"
230
+ )
231
+
232
+
233
+ def _find_assets() -> Path:
234
+ for root in ASSET_ROOTS:
235
+ if root.exists():
236
+ return root
237
+ raise FileNotFoundError(f"client_assets/ not found in any of: {ASSET_ROOTS}")
238
+
239
+
240
+ def _build_managed_settings(
241
+ gateway_url: str, hooks_dest: Path, statusline_dest: Path
242
+ ) -> dict[str, object]:
243
+ return {
244
+ "env": {
245
+ "ANTHROPIC_BASE_URL": gateway_url,
246
+ "AGENTGOV_LOOPBACK_PORT": os.environ.get("AGENTGOV_LOOPBACK_PORT", "8788"),
247
+ },
248
+ # apiKeyHelper disabled — Claude Code must use the agk_ key from the wrapper env.
249
+ "apiKeyHelper": "",
250
+ "hooks": _agentgov_hook_entries(hooks_dest),
251
+ "statusLine": {
252
+ "type": "command",
253
+ "command": str(statusline_dest / "agentgov_statusline.sh"),
254
+ },
255
+ # Permissions baseline: developers may NOT override the base URL or hooks.
256
+ "permissions": {
257
+ "allow": [],
258
+ "deny": [],
259
+ },
260
+ }
261
+
262
+
263
+ def _managed_settings_path() -> str:
264
+ sysname = platform.system()
265
+ if sysname == "Darwin":
266
+ return "/Library/Application Support/ClaudeCode/managed-settings.json"
267
+ if sysname == "Windows":
268
+ return r"C:\ProgramData\ClaudeCode\managed-settings.json"
269
+ return "/etc/claude-code/managed-settings.json"
@@ -42,7 +42,12 @@ _POLL_INTERVAL_SEC = 0.25
42
42
 
43
43
 
44
44
  def is_healthy(gateway_url: str, timeout_sec: float = 2.0) -> bool:
45
- """True if a gateway answers /health at `gateway_url`."""
45
+ """True if a gateway answers /health at `gateway_url` RIGHT NOW.
46
+
47
+ Single probe, no retry. `timeout_sec` bounds a slow *response*; it does
48
+ NOT wait for a port to appear — a closed port refuses the connection
49
+ immediately. To wait for a booting gateway, use `wait_until_healthy`.
50
+ """
46
51
  try:
47
52
  r = httpx.get(f"{gateway_url.rstrip('/')}/health", timeout=timeout_sec)
48
53
  except httpx.HTTPError:
@@ -50,6 +55,28 @@ def is_healthy(gateway_url: str, timeout_sec: float = 2.0) -> bool:
50
55
  return r.status_code == 200
51
56
 
52
57
 
58
+ def wait_until_healthy(gateway_url: str, timeout_sec: float | None = None) -> bool:
59
+ """Poll /health until it answers or `timeout_sec` elapses.
60
+
61
+ Startup is not instant: the gateway's lifespan awaits session rehydration
62
+ and the first policy-snapshot fetch, both round-trips to the control tower,
63
+ before uvicorn binds. Probing once right after launching it reports a false
64
+ "NOT RUNNING" on a gateway that is merely still booting.
65
+
66
+ `timeout_sec=None` resolves `_BOOT_TIMEOUT_SEC` at CALL time, not at import
67
+ time — a default argument would freeze the module constant into the
68
+ signature and ignore any later override.
69
+ """
70
+ if timeout_sec is None:
71
+ timeout_sec = _BOOT_TIMEOUT_SEC
72
+ deadline = time.time() + timeout_sec
73
+ while time.time() < deadline:
74
+ if is_healthy(gateway_url, timeout_sec=1.0):
75
+ return True
76
+ time.sleep(_POLL_INTERVAL_SEC)
77
+ return False
78
+
79
+
53
80
  def find_executable() -> str | None:
54
81
  """Locate the `agentgov-gateway` entry point.
55
82
 
@@ -109,11 +136,8 @@ def start_detached(gateway_url: str) -> tuple[bool, str]:
109
136
  finally:
110
137
  log_fh.close()
111
138
 
112
- deadline = time.time() + _BOOT_TIMEOUT_SEC
113
- while time.time() < deadline:
114
- if is_healthy(gateway_url, timeout_sec=1.0):
115
- return True, f"started; logging to {GATEWAY_LOG}"
116
- time.sleep(_POLL_INTERVAL_SEC)
139
+ if wait_until_healthy(gateway_url):
140
+ return True, f"started; logging to {GATEWAY_LOG}"
117
141
 
118
142
  return False, (
119
143
  f"started but did not answer /health within {int(_BOOT_TIMEOUT_SEC)}s.\n"
@@ -141,6 +165,8 @@ def log_tail(lines: int = 15) -> str:
141
165
  except OSError as e:
142
166
  return f"(could not read {GATEWAY_LOG}: {e})"
143
167
  tail = content[-lines:]
168
+ if not tail:
169
+ return f"({GATEWAY_LOG} is empty — the gateway has not logged anything yet)"
144
170
  body = "\n".join(f" {ln}" for ln in tail)
145
171
  return f"Last {len(tail)} line(s) of {GATEWAY_LOG}:\n{body}"
146
172
 
@@ -0,0 +1,72 @@
1
+ """Vendor client_assets/ into the wheel at build time.
2
+
3
+ WHY THIS FILE EXISTS
4
+ --------------------
5
+ `agentgov install` copies hooks, the statusline and the /workitem command out of
6
+ `client_assets/`, which CLAUDE.md §4 places at the REPO ROOT — a sibling of
7
+ `cli/`, i.e. outside this package's build root.
8
+
9
+ Two earlier attempts failed:
10
+
11
+ * `[tool.hatch.build.targets.wheel.shared-data]` with "../client_assets"
12
+ produced NOTHING, silently. The wheel shipped without any assets, so
13
+ `agentgov install` worked only from a source checkout and raised
14
+ FileNotFoundError for every pip-installed user.
15
+ * `force-include` with "../client_assets" builds fine in the repo but breaks
16
+ `uv build`, which builds the wheel FROM the sdist — and inside the unpacked
17
+ sdist there is no parent directory to reach into.
18
+
19
+ So the assets are copied INTO the package during `initialize()`, which runs for
20
+ both the sdist and the wheel:
21
+
22
+ * building in the repo -> found at <root>/../client_assets
23
+ * building from an sdist -> already vendored at agentgov_cli/client_assets
24
+
25
+ Being inside the package also means `agentgov install` resolves them relative to
26
+ __file__ instead of sys.prefix, which is what makes it work under pipx, `pip
27
+ install --user`, and relocated virtualenvs.
28
+
29
+ The vendored copy is a build artifact and is git-ignored; the repo-root copy
30
+ stays the single source of truth.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import shutil
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
40
+
41
+ VENDORED_SUBPATH = "agentgov_cli/client_assets"
42
+
43
+
44
+ class CustomBuildHook(BuildHookInterface):
45
+ """Copies <repo>/client_assets into agentgov_cli/client_assets before build."""
46
+
47
+ def initialize(self, version: str, build_data: dict[str, Any]) -> None:
48
+ root = Path(self.root)
49
+ vendored = root / VENDORED_SUBPATH
50
+ source = root.parent / "client_assets"
51
+
52
+ if source.is_dir():
53
+ # Building from the repo: refresh the vendored copy so a stale one
54
+ # can never ship instead of the real assets. __pycache__ is excluded
55
+ # because running a hook in the repo leaves .pyc files behind, and a
56
+ # stale one shipped next to an edited hook is a nasty way to debug.
57
+ if vendored.exists():
58
+ shutil.rmtree(vendored)
59
+ shutil.copytree(source, vendored, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
60
+ elif not vendored.is_dir():
61
+ raise FileNotFoundError(
62
+ f"client_assets not found at {source} and not vendored at {vendored}. "
63
+ "The CLI cannot ship without the hooks and statusline it installs."
64
+ )
65
+
66
+ # Be explicit rather than relying on default file collection: these are
67
+ # untracked build artifacts, and a wheel that silently omits them is the
68
+ # exact failure this hook exists to prevent.
69
+ force_include = build_data.setdefault("force_include", {})
70
+ for path in sorted(vendored.rglob("*")):
71
+ if path.is_file() and "__pycache__" not in path.parts and path.suffix != ".pyc":
72
+ force_include[str(path)] = str(path.relative_to(root))
@@ -2,7 +2,7 @@
2
2
  # PyPI: `agentgov` was rejected as too similar to existing `agent-gov`.
3
3
  # Package name is agentgov-cli; the console command it installs is still `agentgov`.
4
4
  name = "agentgov-cli"
5
- version = "0.1.3"
5
+ version = "0.1.5"
6
6
  description = "AgentGov CLI — wrap Claude Code, bind work items, check gateway health."
7
7
  readme = "README.md"
8
8
  license = { text = "Apache-2.0" }
@@ -37,18 +37,28 @@ build-backend = "hatchling.build"
37
37
  [tool.hatch.build.targets.wheel]
38
38
  packages = ["agentgov_cli"]
39
39
 
40
- # Ship client_assets/ (sibling dir of cli/) as shared data. Installs to
41
- # <venv>/share/agentgov/client_assets/, which is exactly where
42
- # commands/install.py:ASSET_ROOTS[1] looks. Without this, a pip-installed CLI
43
- # can't find hooks/statusline/slash-command and `agentgov install` fails with
44
- # FileNotFoundError.
45
- [tool.hatch.build.targets.wheel.shared-data]
46
- "../client_assets" = "share/agentgov/client_assets"
40
+ # Ship client_assets/ (a SIBLING of cli/) inside the package itself.
41
+ #
42
+ # `shared-data` was tried here first and silently produced nothing: the build
43
+ # root is cli/, and hatchling will not collect a `../` path for shared-data. The
44
+ # wheel therefore contained no assets at all, so `agentgov install` worked only
45
+ # from a source checkout (where ASSET_ROOTS[0] resolves) and died with
46
+ # FileNotFoundError for every pip-installed user — the exact opposite of who
47
+ # needs it.
48
+ #
49
+ # `force-include` DOES accept paths outside the project root, and placing the
50
+ # assets inside the package means they are found relative to __file__ — no
51
+ # dependence on sys.prefix, which breaks under pipx, --user, and relocated venvs.
52
+ # hatch_build.py vendors <repo>/client_assets into agentgov_cli/client_assets
53
+ # for BOTH targets. See that file for why shared-data and a bare force-include
54
+ # of "../client_assets" both fail.
55
+ [tool.hatch.build.hooks.custom]
56
+ path = "hatch_build.py"
47
57
 
48
58
  [tool.hatch.build.targets.sdist]
49
59
  include = [
50
60
  "agentgov_cli/**",
51
- "../client_assets/**",
61
+ "hatch_build.py",
52
62
  "README.md",
53
63
  "pyproject.toml",
54
64
  ]
@@ -1,160 +0,0 @@
1
- """`agentgov install` — copies client assets to ~/.claude/, prints managed-settings.json.
2
-
3
- The managed-settings.json is printed to stdout (with the admin-visible path per OS)
4
- so an administrator can drop it into the OS-managed path. That path is ROOT-owned;
5
- we do NOT try to write it — the CLI runs as the developer.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import json
11
- import os
12
- import platform
13
- import shutil
14
- import sys
15
- from pathlib import Path
16
-
17
- import typer
18
- from rich.console import Console
19
-
20
- console = Console()
21
-
22
- # Asset source is discovered relative to the installed package (client_assets is
23
- # a sibling directory of the cli/). In wheel installs we ship it via
24
- # package data; in local dev we look up-tree.
25
- ASSET_ROOTS = [
26
- Path(__file__).resolve().parent.parent.parent.parent / "client_assets",
27
- Path(sys.prefix) / "share" / "agentgov" / "client_assets",
28
- ]
29
-
30
-
31
- def run(
32
- gateway_url: str = typer.Option(
33
- "http://localhost:8000",
34
- "--gateway-url",
35
- "--gateway", # alias — earlier docs called it --gateway
36
- envvar="AGENTGOV_GATEWAY_URL",
37
- help="URL of the local AgentGov gateway (usually http://localhost:8000).",
38
- ),
39
- force: bool = typer.Option(False, "--force", help="Overwrite existing hooks/statusline."),
40
- ) -> None:
41
- """Install hooks + statusline + slash commands into ~/.claude/.
42
-
43
- Also writes a ready-to-copy managed-settings.json to
44
- ~/.agentgov/managed-settings.json — save that file at the OS-managed path
45
- (needs sudo/admin) to force ALL Claude Code processes on this machine —
46
- terminal AND VS Code extension — through the gateway.
47
- """
48
- src = _find_assets()
49
- dest = Path.home() / ".claude"
50
- dest.mkdir(exist_ok=True)
51
-
52
- hooks_src = src / "hooks"
53
- hooks_dest = dest / "agentgov-hooks"
54
- if hooks_dest.exists() and not force:
55
- console.print(f"[yellow]Skipping[/] hooks — {hooks_dest} exists (use --force)")
56
- else:
57
- shutil.copytree(hooks_src, hooks_dest, dirs_exist_ok=True)
58
- for h in hooks_dest.glob("*.py"):
59
- h.chmod(0o755)
60
- console.print(f"[green]Copied[/] hooks → {hooks_dest}")
61
-
62
- statusline_src = src / "statusline"
63
- statusline_dest = dest / "agentgov-statusline"
64
- if statusline_dest.exists() and not force:
65
- console.print(f"[yellow]Skipping[/] statusline — {statusline_dest} exists")
66
- else:
67
- shutil.copytree(statusline_src, statusline_dest, dirs_exist_ok=True)
68
- for h in statusline_dest.glob("*.sh"):
69
- h.chmod(0o755)
70
- console.print(f"[green]Copied[/] statusline → {statusline_dest}")
71
-
72
- commands_dest = dest / "commands"
73
- commands_dest.mkdir(exist_ok=True)
74
- shutil.copy2(src / "commands" / "workitem.md", commands_dest / "workitem.md")
75
- console.print(f"[green]Copied[/] /workitem slash command → {commands_dest / 'workitem.md'}")
76
-
77
- settings = _build_managed_settings(gateway_url, hooks_dest, statusline_dest)
78
- settings_json = json.dumps(settings, indent=2)
79
-
80
- # Write to a stable local path so the admin can `sudo cp` it into place
81
- # without having to redirect stdout carefully.
82
- local_settings_path = Path.home() / ".agentgov" / "managed-settings.json"
83
- local_settings_path.parent.mkdir(parents=True, exist_ok=True)
84
- local_settings_path.write_text(settings_json + "\n")
85
- local_settings_path.chmod(0o644)
86
-
87
- target_path = _managed_settings_path()
88
- console.rule("[bold]managed-settings.json[/]")
89
- console.print(f"Written locally to: [cyan]{local_settings_path}[/]")
90
- console.print(f"Target install path: [cyan]{target_path}[/]\n")
91
- console.print("[bold]To govern the VS Code extension too, run:[/]")
92
- if platform.system() == "Windows":
93
- console.print(
94
- f' Copy-Item "{local_settings_path}" "{target_path}" -Force'
95
- )
96
- else:
97
- parent = str(Path(target_path).parent)
98
- console.print(
99
- f" sudo mkdir -p {parent} && sudo cp {local_settings_path} {target_path}"
100
- )
101
- console.print(
102
- "\nThen fully quit VS Code and reopen — the extension now routes through the gateway."
103
- )
104
- console.print("\n[dim]Contents (for reference):[/]")
105
- console.print(settings_json)
106
-
107
-
108
- def _find_assets() -> Path:
109
- for root in ASSET_ROOTS:
110
- if root.exists():
111
- return root
112
- raise FileNotFoundError(f"client_assets/ not found in any of: {ASSET_ROOTS}")
113
-
114
-
115
- def _build_managed_settings(
116
- gateway_url: str, hooks_dest: Path, statusline_dest: Path
117
- ) -> dict[str, object]:
118
- return {
119
- "env": {
120
- "ANTHROPIC_BASE_URL": gateway_url,
121
- "AGENTGOV_LOOPBACK_PORT": os.environ.get("AGENTGOV_LOOPBACK_PORT", "8788"),
122
- },
123
- # apiKeyHelper disabled — Claude Code must use the agk_ key from the wrapper env.
124
- "apiKeyHelper": "",
125
- "hooks": {
126
- "PreToolUse": [
127
- {
128
- "matcher": "Read|Write|Edit|Glob|Grep|Bash|NotebookEdit",
129
- "hooks": [
130
- {"type": "command", "command": str(hooks_dest / "pretooluse_pathguard.py")}
131
- ],
132
- }
133
- ],
134
- "SessionStart": [
135
- {
136
- "hooks": [
137
- {"type": "command", "command": str(hooks_dest / "sessionstart_register.py")}
138
- ]
139
- }
140
- ],
141
- },
142
- "statusLine": {
143
- "type": "command",
144
- "command": str(statusline_dest / "agentgov_statusline.sh"),
145
- },
146
- # Permissions baseline: developers may NOT override the base URL or hooks.
147
- "permissions": {
148
- "allow": [],
149
- "deny": [],
150
- },
151
- }
152
-
153
-
154
- def _managed_settings_path() -> str:
155
- sysname = platform.system()
156
- if sysname == "Darwin":
157
- return "/Library/Application Support/ClaudeCode/managed-settings.json"
158
- if sysname == "Windows":
159
- return r"C:\ProgramData\ClaudeCode\managed-settings.json"
160
- return "/etc/claude-code/managed-settings.json"
File without changes