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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentgov-cli
3
- Version: 0.1.3
3
+ Version: 0.1.4
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
@@ -39,12 +39,17 @@ def install(gateway_url: str = _GATEWAY_OPT) -> None:
39
39
  return
40
40
 
41
41
  console.print(f"[green]{detail}[/]")
42
- if gp.is_healthy(url, timeout_sec=10.0):
42
+ # Wait for boot rather than probing once. The service manager returns as
43
+ # soon as it has spawned the process, but the gateway only binds its port
44
+ # after rehydrating sessions and fetching the first policy snapshot — two
45
+ # round-trips to the control tower. A single probe here reports a false
46
+ # failure on a gateway that is simply still starting.
47
+ console.print(f"Waiting for {url}/health ...")
48
+ if gp.wait_until_healthy(url):
43
49
  console.print(f"[green]Gateway healthy[/] at {url}")
44
50
  else:
45
- # The service manager accepted the unit but the process is not
46
- # answering — surface the log rather than claiming success.
47
- console.print(f"[yellow]Service installed but {url}/health did not answer yet.[/]")
51
+ # Genuinely not answering — surface the log rather than claiming success.
52
+ console.print(f"[yellow]Service installed but {url}/health did not answer in time.[/]")
48
53
  console.print(gp.log_tail())
49
54
  raise typer.Exit(1)
50
55
 
@@ -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 — save that file at the OS-managed path
45
- (needs sudo/admin) to force ALL Claude Code processes on this machine —
46
- terminal AND VS Code extension — through the gateway.
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
- deadline = time.time() + _BOOT_TIMEOUT_SEC
113
- while time.time() < deadline:
114
- if is_healthy(gateway_url, timeout_sec=1.0):
115
- return True, f"started; logging to {GATEWAY_LOG}"
116
- time.sleep(_POLL_INTERVAL_SEC)
139
+ if wait_until_healthy(gateway_url):
140
+ return True, f"started; logging to {GATEWAY_LOG}"
117
141
 
118
142
  return False, (
119
143
  f"started but did not answer /health within {int(_BOOT_TIMEOUT_SEC)}s.\n"
@@ -141,6 +165,8 @@ def log_tail(lines: int = 15) -> str:
141
165
  except OSError as e:
142
166
  return f"(could not read {GATEWAY_LOG}: {e})"
143
167
  tail = content[-lines:]
168
+ if not tail:
169
+ return f"({GATEWAY_LOG} is empty — the gateway has not logged anything yet)"
144
170
  body = "\n".join(f" {ln}" for ln in tail)
145
171
  return f"Last {len(tail)} line(s) of {GATEWAY_LOG}:\n{body}"
146
172
 
@@ -2,7 +2,7 @@
2
2
  # PyPI: `agentgov` was rejected as too similar to existing `agent-gov`.
3
3
  # Package name is agentgov-cli; the console command it installs is still `agentgov`.
4
4
  name = "agentgov-cli"
5
- version = "0.1.3"
5
+ version = "0.1.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