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.
- {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/.gitignore +3 -0
- {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/PKG-INFO +1 -1
- agentgov_cli-0.1.6/agentgov_cli/client_assets/commands/workitem.md +18 -0
- agentgov_cli-0.1.6/agentgov_cli/client_assets/hooks/pretooluse_pathguard.py +160 -0
- agentgov_cli-0.1.6/agentgov_cli/client_assets/hooks/sessionstart_register.py +114 -0
- agentgov_cli-0.1.6/agentgov_cli/client_assets/hooks/userpromptsubmit_workitem.py +171 -0
- agentgov_cli-0.1.6/agentgov_cli/client_assets/managed-settings.json.tmpl +41 -0
- agentgov_cli-0.1.6/agentgov_cli/client_assets/statusline/agentgov_statusline.sh +74 -0
- {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/commands/install.py +16 -2
- agentgov_cli-0.1.6/agentgov_cli/commands/uninstall.py +149 -0
- {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/main.py +15 -1
- agentgov_cli-0.1.6/hatch_build.py +72 -0
- {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/pyproject.toml +23 -9
- {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/README.md +0 -0
- {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/__init__.py +0 -0
- {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/commands/__init__.py +0 -0
- {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/commands/doctor.py +0 -0
- {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/commands/gateway.py +0 -0
- {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/commands/login.py +0 -0
- {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/commands/register_device.py +0 -0
- {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/commands/status.py +0 -0
- {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/commands/workitem.py +0 -0
- {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/commands/wrap.py +0 -0
- {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/gateway_process.py +0 -0
- {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/loopback.py +0 -0
- {agentgov_cli-0.1.4 → agentgov_cli-0.1.6}/agentgov_cli/port_resolver.py +0 -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(
|
|
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(
|
|
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
|
|
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.
|
|
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/ (
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
[
|
|
46
|
-
|
|
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
|
-
"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|