agentgov-cli 0.1.4__tar.gz → 0.1.6__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.4 → agentgov_cli-0.1.6}/.gitignore +3 -0
  2. {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/PKG-INFO +1 -1
  3. agentgov_cli-0.1.6/agentgov_cli/client_assets/commands/workitem.md +18 -0
  4. agentgov_cli-0.1.6/agentgov_cli/client_assets/hooks/pretooluse_pathguard.py +160 -0
  5. agentgov_cli-0.1.6/agentgov_cli/client_assets/hooks/sessionstart_register.py +114 -0
  6. agentgov_cli-0.1.6/agentgov_cli/client_assets/hooks/userpromptsubmit_workitem.py +171 -0
  7. agentgov_cli-0.1.6/agentgov_cli/client_assets/managed-settings.json.tmpl +41 -0
  8. agentgov_cli-0.1.6/agentgov_cli/client_assets/statusline/agentgov_statusline.sh +74 -0
  9. {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/commands/install.py +16 -2
  10. agentgov_cli-0.1.6/agentgov_cli/commands/uninstall.py +149 -0
  11. {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/main.py +15 -1
  12. agentgov_cli-0.1.6/hatch_build.py +72 -0
  13. {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/pyproject.toml +23 -9
  14. {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/README.md +0 -0
  15. {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/__init__.py +0 -0
  16. {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/commands/__init__.py +0 -0
  17. {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/commands/doctor.py +0 -0
  18. {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/commands/gateway.py +0 -0
  19. {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/commands/login.py +0 -0
  20. {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/commands/register_device.py +0 -0
  21. {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/commands/status.py +0 -0
  22. {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/commands/workitem.py +0 -0
  23. {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/commands/wrap.py +0 -0
  24. {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/gateway_process.py +0 -0
  25. {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/loopback.py +0 -0
  26. {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/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.4
3
+ Version: 0.1.6
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,171 @@
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 subprocess
33
+ import sys
34
+ import urllib.request
35
+ from typing import NoReturn
36
+
37
+ GATEWAY_URL = os.environ.get("AGENTGOV_GATEWAY_URL", "http://127.0.0.1:8000")
38
+ TIMEOUT_SEC = 4.0
39
+
40
+ # Prompts that exist to FIX an unbound session must not be blocked by the
41
+ # unbound session — otherwise the developer is deadlocked with no way out.
42
+ ESCAPE_PREFIXES = ("/workitem", "/agentgov", "/help", "/status", "/doctor")
43
+
44
+
45
+ def _allow() -> NoReturn:
46
+ sys.exit(0)
47
+
48
+
49
+ def _block(message: str) -> NoReturn:
50
+ sys.stderr.write(message)
51
+ sys.exit(2)
52
+
53
+
54
+ def _binding(session_id: str) -> dict | None:
55
+ """Ask the gateway about this session. None = gateway unreachable."""
56
+ try:
57
+ with urllib.request.urlopen( # noqa: S310 — loopback only
58
+ f"{GATEWAY_URL.rstrip('/')}/admin/session/{session_id}/binding",
59
+ timeout=TIMEOUT_SEC,
60
+ ) as resp:
61
+ result = json.loads(resp.read() or b"{}")
62
+ return result if isinstance(result, dict) else {}
63
+ except Exception:
64
+ return None
65
+
66
+
67
+ def _register(session_id: str, cwd: str) -> bool:
68
+ """Re-register a session the gateway has forgotten (e.g. after a restart).
69
+
70
+ The gateway holds sessions in memory, so any restart drops every editor
71
+ window that is already open. Re-registering here means the developer never
72
+ has to know that happened.
73
+ """
74
+ if not cwd:
75
+ return False
76
+ try:
77
+ repo_root = subprocess.run(
78
+ ["git", "rev-parse", "--show-toplevel"],
79
+ cwd=cwd,
80
+ capture_output=True,
81
+ text=True,
82
+ timeout=5,
83
+ check=False,
84
+ )
85
+ if repo_root.returncode != 0:
86
+ return False
87
+ root = repo_root.stdout.strip()
88
+ remote = subprocess.run(
89
+ ["git", "remote", "get-url", "origin"],
90
+ cwd=cwd,
91
+ capture_output=True,
92
+ text=True,
93
+ timeout=5,
94
+ check=False,
95
+ )
96
+ body = json.dumps(
97
+ {
98
+ "session_id": session_id,
99
+ "repo": remote.stdout.strip() if remote.returncode == 0 else "",
100
+ "repo_root": root,
101
+ }
102
+ ).encode()
103
+ req = urllib.request.Request(
104
+ f"{GATEWAY_URL.rstrip('/')}/admin/session/register",
105
+ data=body,
106
+ headers={"Content-Type": "application/json"},
107
+ )
108
+ urllib.request.urlopen(req, timeout=TIMEOUT_SEC).read() # noqa: S310 — loopback only
109
+ return True
110
+ except Exception:
111
+ return False
112
+
113
+
114
+ def main() -> None:
115
+ try:
116
+ payload = json.load(sys.stdin)
117
+ except Exception:
118
+ _allow() # Fail open: the gateway is the real control.
119
+
120
+ session_id = str(payload.get("session_id") or "")
121
+ user_input = str(payload.get("user_input") or "").strip()
122
+ cwd = str(payload.get("cwd") or "")
123
+
124
+ if not session_id:
125
+ _allow()
126
+
127
+ # Let the developer run the command that fixes the problem.
128
+ if user_input.startswith(ESCAPE_PREFIXES):
129
+ _allow()
130
+
131
+ data = _binding(session_id)
132
+ if data is None:
133
+ # Gateway down or unreachable. Do NOT block here: Claude Code cannot
134
+ # reach the model either way, and blocking would bury the real error
135
+ # ("connection refused") under a confusing governance message.
136
+ _allow()
137
+
138
+ if data.get("bound"):
139
+ _allow()
140
+
141
+ if not data.get("known"):
142
+ # The gateway does not recognise this session. This is almost always
143
+ # TRANSIENT, not an attack: the gateway keeps sessions in memory, so a
144
+ # restart or reinstall forgets every editor window that is already open.
145
+ #
146
+ # Blocking here was a serious mistake. It bricked every open Claude Code
147
+ # window after a gateway restart, with a message telling the developer to
148
+ # start a new session — while the API plane would have enforced anyway.
149
+ # Self-heal instead: re-register, then re-check.
150
+ if not _register(session_id, cwd):
151
+ _allow() # Could not re-register — let the gateway be the judge.
152
+ data = _binding(session_id)
153
+ if data is None or data.get("bound"):
154
+ _allow()
155
+
156
+ repo = data.get("repo") or "this repository"
157
+ _block(
158
+ "📌 AgentGov: bind this session to a work item before continuing.\n"
159
+ f" Repository: {repo}\n"
160
+ "\n"
161
+ " Run one of:\n"
162
+ " /workitem PROJ-1234 (JIRA issue)\n"
163
+ " /workitem PRJ-0042 (internal project)\n"
164
+ " /workitem https://notion.so/... (Notion page)\n"
165
+ "\n"
166
+ " Your prompt was not sent and no tokens were used.\n"
167
+ )
168
+
169
+
170
+ if __name__ == "__main__":
171
+ 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
@@ -22,8 +22,18 @@ console = Console()
22
22
  # Asset source is discovered relative to the installed package (client_assets is
23
23
  # a sibling directory of the cli/). In wheel installs we ship it via
24
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
+
25
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).
26
35
  Path(__file__).resolve().parent.parent.parent.parent / "client_assets",
36
+ # Legacy shared-data location, kept so older installs keep working.
27
37
  Path(sys.prefix) / "share" / "agentgov" / "client_assets",
28
38
  ]
29
39
 
@@ -66,7 +76,9 @@ def run(
66
76
  if hooks_dest.exists() and not force:
67
77
  console.print(f"[yellow]Skipping[/] hooks — {hooks_dest} exists (use --force)")
68
78
  else:
69
- shutil.copytree(hooks_src, hooks_dest, dirs_exist_ok=True)
79
+ shutil.copytree(
80
+ hooks_src, hooks_dest, dirs_exist_ok=True, ignore=_IGNORE_BUILD_JUNK
81
+ )
70
82
  for h in hooks_dest.glob("*.py"):
71
83
  h.chmod(0o755)
72
84
  console.print(f"[green]Copied[/] hooks → {hooks_dest}")
@@ -76,7 +88,9 @@ def run(
76
88
  if statusline_dest.exists() and not force:
77
89
  console.print(f"[yellow]Skipping[/] statusline — {statusline_dest} exists")
78
90
  else:
79
- shutil.copytree(statusline_src, statusline_dest, dirs_exist_ok=True)
91
+ shutil.copytree(
92
+ statusline_src, statusline_dest, dirs_exist_ok=True, ignore=_IGNORE_BUILD_JUNK
93
+ )
80
94
  for h in statusline_dest.glob("*.sh"):
81
95
  h.chmod(0o755)
82
96
  console.print(f"[green]Copied[/] statusline → {statusline_dest}")
@@ -0,0 +1,149 @@
1
+ """`agentgov uninstall` — remove AgentGov from this machine, safely.
2
+
3
+ WHY THIS EXISTS
4
+ ---------------
5
+ `agentgov install` writes hook registrations into ~/.claude/settings.json that
6
+ point at ~/.claude/agentgov-hooks/*.py. Nothing removed them, so the documented
7
+ teardown —
8
+
9
+ rm -rf ~/.agentgov ~/.claude/agentgov-hooks
10
+
11
+ — left Claude Code registering three hooks whose files no longer existed. Every
12
+ session and every prompt then tried to execute a missing command. Deleting the
13
+ files made Claude Code WORSE, not neutral, and no amount of reinstalling fixed
14
+ it because the stale registrations were never the thing being reinstalled.
15
+
16
+ Order matters and is the whole point of this command: **deregister first, delete
17
+ second**. A half-removed install must never be able to break the editor.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import shutil
24
+ import subprocess
25
+ from pathlib import Path
26
+
27
+ import typer
28
+ from rich.console import Console
29
+
30
+ console = Console()
31
+
32
+ CLAUDE_DIR = Path.home() / ".claude"
33
+ SETTINGS = CLAUDE_DIR / "settings.json"
34
+ AGENTGOV_DIR = Path.home() / ".agentgov"
35
+
36
+
37
+ def run(
38
+ keep_credentials: bool = typer.Option(
39
+ False,
40
+ "--keep-credentials",
41
+ help="Keep ~/.agentgov (device key, identity, logs). Only remove Claude Code wiring.",
42
+ ),
43
+ yes: bool = typer.Option(False, "--yes", "-y", help="Do not prompt for confirmation."),
44
+ ) -> None:
45
+ """Remove AgentGov's Claude Code hooks, statusline, and local state."""
46
+ if not yes:
47
+ console.print("This will remove AgentGov's hooks and statusline from Claude Code.")
48
+ if not keep_credentials:
49
+ console.print(
50
+ f"It will also delete [cyan]{AGENTGOV_DIR}[/] "
51
+ "(device key, identity, gateway logs)."
52
+ )
53
+ if not typer.confirm("Continue?", default=False):
54
+ raise typer.Abort()
55
+
56
+ # 1. STOP the gateway service first, so nothing restarts mid-teardown.
57
+ _stop_service()
58
+
59
+ # 2. DEREGISTER before deleting. This is the step whose absence broke
60
+ # Claude Code: settings.json outlives the files it points at.
61
+ _deregister()
62
+
63
+ # 3. Now the files are safe to delete — nothing references them.
64
+ for path in (CLAUDE_DIR / "agentgov-hooks", CLAUDE_DIR / "agentgov-statusline"):
65
+ if path.exists():
66
+ shutil.rmtree(path, ignore_errors=True)
67
+ console.print(f"[green]Removed[/] {path}")
68
+ workitem = CLAUDE_DIR / "commands" / "workitem.md"
69
+ if workitem.exists():
70
+ workitem.unlink()
71
+ console.print(f"[green]Removed[/] {workitem}")
72
+
73
+ if not keep_credentials and AGENTGOV_DIR.exists():
74
+ shutil.rmtree(AGENTGOV_DIR, ignore_errors=True)
75
+ console.print(f"[green]Removed[/] {AGENTGOV_DIR}")
76
+
77
+ console.print("\n[bold green]AgentGov removed.[/]")
78
+ console.print("Claude Code is unaffected — restart it to clear the old session.")
79
+ console.print(
80
+ "\n[dim]Machine-wide managed-settings.json (if you installed it) needs sudo:[/]\n"
81
+ " sudo rm -f /etc/claude-code/managed-settings.json"
82
+ )
83
+
84
+
85
+ def _stop_service() -> None:
86
+ """Stop + disable the gateway service, ignoring 'not installed'."""
87
+ unit = Path.home() / ".config" / "systemd" / "user" / "agentgov-gateway.service"
88
+ if not unit.exists():
89
+ return
90
+ for args in (["stop", "agentgov-gateway"], ["disable", "agentgov-gateway"]):
91
+ subprocess.run(
92
+ ["systemctl", "--user", *args], capture_output=True, check=False, timeout=30
93
+ )
94
+ unit.unlink(missing_ok=True)
95
+ subprocess.run(
96
+ ["systemctl", "--user", "daemon-reload"], capture_output=True, check=False, timeout=30
97
+ )
98
+ console.print(f"[green]Stopped[/] gateway service ({unit.name})")
99
+
100
+
101
+ def _deregister() -> None:
102
+ """Strip AgentGov's entries from ~/.claude/settings.json, leaving the rest."""
103
+ if not SETTINGS.exists():
104
+ return
105
+ try:
106
+ data = json.loads(SETTINGS.read_text() or "{}")
107
+ except json.JSONDecodeError:
108
+ console.print(
109
+ f"[yellow]Could not parse {SETTINGS}[/] — leaving it alone. "
110
+ "Remove any 'agentgov-hooks' entries by hand."
111
+ )
112
+ return
113
+ if not isinstance(data, dict):
114
+ return
115
+
116
+ removed = 0
117
+ hooks = data.get("hooks")
118
+ if isinstance(hooks, dict):
119
+ for event in list(hooks):
120
+ entries = hooks[event]
121
+ if not isinstance(entries, list):
122
+ continue
123
+ kept = [e for e in entries if not _is_agentgov(e)]
124
+ removed += len(entries) - len(kept)
125
+ if kept:
126
+ hooks[event] = kept
127
+ else:
128
+ del hooks[event]
129
+ if hooks:
130
+ data["hooks"] = hooks
131
+ else:
132
+ data.pop("hooks", None)
133
+
134
+ if "agentgov" in str(data.get("statusLine", "")):
135
+ data.pop("statusLine", None)
136
+ removed += 1
137
+
138
+ if removed:
139
+ SETTINGS.write_text(json.dumps(data, indent=2) + "\n")
140
+ console.print(f"[green]Deregistered[/] {removed} entr(ies) from {SETTINGS}")
141
+
142
+
143
+ def _is_agentgov(entry: object) -> bool:
144
+ if not isinstance(entry, dict):
145
+ return False
146
+ return any(
147
+ isinstance(h, dict) and "agentgov-hooks" in str(h.get("command", ""))
148
+ for h in (entry.get("hooks") or [])
149
+ )
@@ -4,7 +4,17 @@ from __future__ import annotations
4
4
 
5
5
  import typer
6
6
 
7
- from .commands import doctor, gateway, install, login, register_device, status, workitem, wrap
7
+ from .commands import (
8
+ doctor,
9
+ gateway,
10
+ install,
11
+ login,
12
+ register_device,
13
+ status,
14
+ uninstall,
15
+ workitem,
16
+ wrap,
17
+ )
8
18
 
9
19
  app = typer.Typer(
10
20
  name="agentgov",
@@ -19,6 +29,10 @@ app.command(
19
29
  app.command(
20
30
  "install", help="Install client assets into ~/.claude/ and print managed-settings.json."
21
31
  )(install.run)
32
+ app.command(
33
+ "uninstall",
34
+ help="Remove AgentGov hooks, statusline and local state (deregisters before deleting).",
35
+ )(uninstall.run)
22
36
  app.add_typer(gateway.app, name="gateway")
23
37
  app.command("wrap", help="Wrap `claude` — start a governed Claude Code session.")(wrap.run)
24
38
  app.command("workitem", help="Bind the current session to a work item (JIRA/Notion/PRJ-...).")(
@@ -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.4"
5
+ version = "0.1.6"
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,24 +37,35 @@ 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
  ]
55
65
 
56
66
  [tool.ruff]
57
67
  line-length = 100
68
+ extend-exclude = ["agentgov_cli/client_assets"]
58
69
  target-version = "py310"
59
70
  [tool.ruff.lint]
60
71
  select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"]
@@ -63,6 +74,9 @@ select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"]
63
74
  ignore = ["B008"]
64
75
 
65
76
  [tool.mypy]
77
+ # agentgov_cli/client_assets is a build artifact vendored by hatch_build.py;
78
+ # the real source lives at the repo root and is checked there.
79
+ exclude = ['agentgov_cli/client_assets/']
66
80
  python_version = "3.10"
67
81
  strict = true
68
82
  disallow_untyped_defs = true
File without changes