agentgov-cli 0.1.3__tar.gz → 0.1.4__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.3 → agentgov_cli-0.1.4}/PKG-INFO +1 -1
- {agentgov_cli-0.1.3 → agentgov_cli-0.1.4}/agentgov_cli/commands/gateway.py +9 -4
- {agentgov_cli-0.1.3 → agentgov_cli-0.1.4}/agentgov_cli/commands/install.py +115 -20
- {agentgov_cli-0.1.3 → agentgov_cli-0.1.4}/agentgov_cli/gateway_process.py +32 -6
- {agentgov_cli-0.1.3 → agentgov_cli-0.1.4}/pyproject.toml +1 -1
- {agentgov_cli-0.1.3 → agentgov_cli-0.1.4}/.gitignore +0 -0
- {agentgov_cli-0.1.3 → agentgov_cli-0.1.4}/README.md +0 -0
- {agentgov_cli-0.1.3 → agentgov_cli-0.1.4}/agentgov_cli/__init__.py +0 -0
- {agentgov_cli-0.1.3 → agentgov_cli-0.1.4}/agentgov_cli/commands/__init__.py +0 -0
- {agentgov_cli-0.1.3 → agentgov_cli-0.1.4}/agentgov_cli/commands/doctor.py +0 -0
- {agentgov_cli-0.1.3 → agentgov_cli-0.1.4}/agentgov_cli/commands/login.py +0 -0
- {agentgov_cli-0.1.3 → agentgov_cli-0.1.4}/agentgov_cli/commands/register_device.py +0 -0
- {agentgov_cli-0.1.3 → agentgov_cli-0.1.4}/agentgov_cli/commands/status.py +0 -0
- {agentgov_cli-0.1.3 → agentgov_cli-0.1.4}/agentgov_cli/commands/workitem.py +0 -0
- {agentgov_cli-0.1.3 → agentgov_cli-0.1.4}/agentgov_cli/commands/wrap.py +0 -0
- {agentgov_cli-0.1.3 → agentgov_cli-0.1.4}/agentgov_cli/loopback.py +0 -0
- {agentgov_cli-0.1.3 → agentgov_cli-0.1.4}/agentgov_cli/main.py +0 -0
- {agentgov_cli-0.1.3 → agentgov_cli-0.1.4}/agentgov_cli/port_resolver.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
#
|
|
46
|
-
|
|
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
|
|
|
@@ -37,13 +37,25 @@ def run(
|
|
|
37
37
|
help="URL of the local AgentGov gateway (usually http://localhost:8000).",
|
|
38
38
|
),
|
|
39
39
|
force: bool = typer.Option(False, "--force", help="Overwrite existing hooks/statusline."),
|
|
40
|
+
user_settings: bool = typer.Option(
|
|
41
|
+
True,
|
|
42
|
+
"--user-settings/--no-user-settings",
|
|
43
|
+
help=(
|
|
44
|
+
"Register the hooks + statusline in ~/.claude/settings.json so they take "
|
|
45
|
+
"effect immediately, with no sudo. Disable for admin-managed installs."
|
|
46
|
+
),
|
|
47
|
+
),
|
|
40
48
|
) -> None:
|
|
41
49
|
"""Install hooks + statusline + slash commands into ~/.claude/.
|
|
42
50
|
|
|
51
|
+
By default this also REGISTERS them in ~/.claude/settings.json, so the
|
|
52
|
+
governance hooks run in every Claude Code session on this machine — the VS
|
|
53
|
+
Code / Cursor extension included — without sudo and without launching
|
|
54
|
+
anything through a wrapper.
|
|
55
|
+
|
|
43
56
|
Also writes a ready-to-copy managed-settings.json to
|
|
44
|
-
~/.agentgov/managed-settings.json
|
|
45
|
-
|
|
46
|
-
terminal AND VS Code extension — through the gateway.
|
|
57
|
+
~/.agentgov/managed-settings.json. Installing THAT (needs sudo/admin) is
|
|
58
|
+
what makes governance non-overridable, for team/enterprise deployments.
|
|
47
59
|
"""
|
|
48
60
|
src = _find_assets()
|
|
49
61
|
dest = Path.home() / ".claude"
|
|
@@ -77,6 +89,16 @@ def run(
|
|
|
77
89
|
settings = _build_managed_settings(gateway_url, hooks_dest, statusline_dest)
|
|
78
90
|
settings_json = json.dumps(settings, indent=2)
|
|
79
91
|
|
|
92
|
+
# Activate the hooks for THIS user, right now, without sudo.
|
|
93
|
+
#
|
|
94
|
+
# Copying the files is not enough — Claude Code only runs hooks that a
|
|
95
|
+
# settings file registers. Previously the only place that happened was
|
|
96
|
+
# managed-settings.json, which needs root, so a developer who followed setup
|
|
97
|
+
# to the letter ended up with hook files on disk and nothing running them:
|
|
98
|
+
# no statusline, no path guard, and no work-item prompt.
|
|
99
|
+
if user_settings:
|
|
100
|
+
_merge_user_settings(gateway_url, hooks_dest, statusline_dest)
|
|
101
|
+
|
|
80
102
|
# Write to a stable local path so the admin can `sudo cp` it into place
|
|
81
103
|
# without having to redirect stdout carefully.
|
|
82
104
|
local_settings_path = Path.home() / ".agentgov" / "managed-settings.json"
|
|
@@ -105,6 +127,95 @@ def run(
|
|
|
105
127
|
console.print(settings_json)
|
|
106
128
|
|
|
107
129
|
|
|
130
|
+
def _agentgov_hook_entries(hooks_dest: Path) -> dict[str, list[dict[str, object]]]:
|
|
131
|
+
"""The hook registrations AgentGov owns, keyed by Claude Code event name."""
|
|
132
|
+
def cmd(script: str) -> dict[str, object]:
|
|
133
|
+
return {"type": "command", "command": str(hooks_dest / script)}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
"SessionStart": [{"hooks": [cmd("sessionstart_register.py")]}],
|
|
137
|
+
"UserPromptSubmit": [{"hooks": [cmd("userpromptsubmit_workitem.py")]}],
|
|
138
|
+
"PreToolUse": [
|
|
139
|
+
{
|
|
140
|
+
"matcher": "Read|Write|Edit|Glob|Grep|Bash|NotebookEdit",
|
|
141
|
+
"hooks": [cmd("pretooluse_pathguard.py")],
|
|
142
|
+
}
|
|
143
|
+
],
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _is_agentgov_entry(entry: object) -> bool:
|
|
148
|
+
"""True if a hook registration belongs to AgentGov.
|
|
149
|
+
|
|
150
|
+
Matched on the command path so re-running `install` replaces our own entries
|
|
151
|
+
instead of stacking duplicates, while leaving the user's other hooks alone.
|
|
152
|
+
"""
|
|
153
|
+
if not isinstance(entry, dict):
|
|
154
|
+
return False
|
|
155
|
+
for h in entry.get("hooks") or []:
|
|
156
|
+
if isinstance(h, dict) and "agentgov-hooks" in str(h.get("command", "")):
|
|
157
|
+
return True
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _merge_user_settings(gateway_url: str, hooks_dest: Path, statusline_dest: Path) -> None:
|
|
162
|
+
"""Register AgentGov's hooks + statusline in ~/.claude/settings.json.
|
|
163
|
+
|
|
164
|
+
MERGES. The file usually already holds the developer's own preferences and
|
|
165
|
+
hooks; clobbering it would be a hostile way to install a governance tool.
|
|
166
|
+
We replace only entries we previously wrote (identified by their path) and
|
|
167
|
+
leave everything else untouched. A corrupt file is left alone entirely —
|
|
168
|
+
reporting it beats silently overwriting whatever was in there.
|
|
169
|
+
"""
|
|
170
|
+
path = Path.home() / ".claude" / "settings.json"
|
|
171
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
|
|
173
|
+
data: dict[str, object] = {}
|
|
174
|
+
if path.exists():
|
|
175
|
+
try:
|
|
176
|
+
loaded = json.loads(path.read_text() or "{}")
|
|
177
|
+
if isinstance(loaded, dict):
|
|
178
|
+
data = loaded
|
|
179
|
+
else:
|
|
180
|
+
console.print(f"[yellow]Skipping[/] {path} — not a JSON object.")
|
|
181
|
+
return
|
|
182
|
+
except json.JSONDecodeError as e:
|
|
183
|
+
console.print(
|
|
184
|
+
f"[red]Skipping[/] {path} — it is not valid JSON ({e}).\n"
|
|
185
|
+
"Fix or remove it, then re-run `agentgov install`."
|
|
186
|
+
)
|
|
187
|
+
return
|
|
188
|
+
# Back the file up once before the first modification.
|
|
189
|
+
backup = path.with_suffix(".json.agentgov-backup")
|
|
190
|
+
if not backup.exists():
|
|
191
|
+
backup.write_text(path.read_text())
|
|
192
|
+
|
|
193
|
+
hooks_raw = data.get("hooks")
|
|
194
|
+
hooks: dict[str, object] = hooks_raw if isinstance(hooks_raw, dict) else {}
|
|
195
|
+
for event, entries in _agentgov_hook_entries(hooks_dest).items():
|
|
196
|
+
existing_raw = hooks.get(event)
|
|
197
|
+
existing = existing_raw if isinstance(existing_raw, list) else []
|
|
198
|
+
kept = [e for e in existing if not _is_agentgov_entry(e)]
|
|
199
|
+
hooks[event] = kept + entries
|
|
200
|
+
data["hooks"] = hooks
|
|
201
|
+
|
|
202
|
+
data["statusLine"] = {
|
|
203
|
+
"type": "command",
|
|
204
|
+
"command": str(statusline_dest / "agentgov_statusline.sh"),
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# NOTE: deliberately NOT setting env.ANTHROPIC_BASE_URL here. User settings
|
|
208
|
+
# are the lowest-precedence scope and the developer can edit them, so
|
|
209
|
+
# pinning the base URL here would look like enforcement while being trivial
|
|
210
|
+
# to undo. Routing is the job of managed-settings.json (highest precedence).
|
|
211
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
212
|
+
console.print(f"[green]Registered[/] hooks + statusline → {path}")
|
|
213
|
+
console.print(
|
|
214
|
+
" [dim]These run in every Claude Code session on this machine, including the "
|
|
215
|
+
"VS Code extension. Restart Claude Code to pick them up.[/]"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
108
219
|
def _find_assets() -> Path:
|
|
109
220
|
for root in ASSET_ROOTS:
|
|
110
221
|
if root.exists():
|
|
@@ -122,23 +233,7 @@ def _build_managed_settings(
|
|
|
122
233
|
},
|
|
123
234
|
# apiKeyHelper disabled — Claude Code must use the agk_ key from the wrapper env.
|
|
124
235
|
"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
|
-
},
|
|
236
|
+
"hooks": _agentgov_hook_entries(hooks_dest),
|
|
142
237
|
"statusLine": {
|
|
143
238
|
"type": "command",
|
|
144
239
|
"command": str(statusline_dest / "agentgov_statusline.sh"),
|
|
@@ -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
|
-
|
|
113
|
-
|
|
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
|
|
|
@@ -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.4"
|
|
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" }
|
|
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
|