agentgov-cli 0.1.1__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.1
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
@@ -0,0 +1,84 @@
1
+ """`agentgov gateway` — manage the local gateway process.
2
+
3
+ `install` is the recommended path: a supervised service (systemd --user on
4
+ Linux/WSL, launchd on macOS) that starts at login and restarts on failure, so
5
+ the port Claude Code targets is bound whenever the developer is working.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from .. import gateway_process as gp
14
+
15
+ console = Console()
16
+
17
+ app = typer.Typer(name="gateway", help="Manage the local AgentGov gateway process.")
18
+
19
+ _GATEWAY_OPT = typer.Option(None, "--gateway", envvar="AGENTGOV_GATEWAY_URL")
20
+
21
+
22
+ def _url(gateway_url: str | None) -> str:
23
+ return gateway_url or "http://localhost:8000"
24
+
25
+
26
+ @app.command("install")
27
+ def install(gateway_url: str = _GATEWAY_OPT) -> None:
28
+ """Install + start the gateway as a supervised background service."""
29
+ url = _url(gateway_url)
30
+ ok, detail = gp.install_service()
31
+ if not ok:
32
+ console.print(f"[yellow]Could not install a service:[/] {detail}")
33
+ console.print("Falling back to a detached one-off start.")
34
+ ok, detail = gp.ensure_running(url)
35
+ if not ok:
36
+ console.print(f"[red]Gateway did not start.[/] {detail}")
37
+ raise typer.Exit(1)
38
+ console.print(f"[green]Gateway running[/] at {url} ({detail})")
39
+ return
40
+
41
+ console.print(f"[green]{detail}[/]")
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):
49
+ console.print(f"[green]Gateway healthy[/] at {url}")
50
+ else:
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.[/]")
53
+ console.print(gp.log_tail())
54
+ raise typer.Exit(1)
55
+
56
+
57
+ @app.command("start")
58
+ def start(gateway_url: str = _GATEWAY_OPT) -> None:
59
+ """Start the gateway now, detached from this terminal."""
60
+ url = _url(gateway_url)
61
+ ok, detail = gp.ensure_running(url)
62
+ if not ok:
63
+ console.print(f"[red]Gateway did not start.[/] {detail}")
64
+ raise typer.Exit(1)
65
+ console.print(f"[green]Gateway running[/] at {url} ({detail})")
66
+
67
+
68
+ @app.command("status")
69
+ def status(gateway_url: str = _GATEWAY_OPT) -> None:
70
+ """Report whether the gateway is answering."""
71
+ url = _url(gateway_url)
72
+ if gp.is_healthy(url):
73
+ console.print(f"[green]RUNNING[/] — {url}/health answered.")
74
+ return
75
+ console.print(f"[red]NOT RUNNING[/] — nothing answering at {url}/health.")
76
+ console.print("Start it with: [cyan]agentgov gateway install[/]")
77
+ console.print(gp.log_tail())
78
+ raise typer.Exit(1)
79
+
80
+
81
+ @app.command("logs")
82
+ def logs(lines: int = typer.Option(40, "--lines", "-n")) -> None:
83
+ """Show the tail of the gateway log."""
84
+ console.print(gp.log_tail(lines))
@@ -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"),
@@ -57,14 +57,27 @@ def run(
57
57
  console.print(f"[red]Invalid --mode {upstream_mode}[/]")
58
58
  raise typer.Exit(2)
59
59
 
60
- tower = tower_url or os.environ.get("CONTROL_TOWER_URL") or "http://localhost:3000"
61
-
62
60
  identity_path = Path.home() / ".agentgov" / "identity.json"
63
61
  if not identity_path.exists():
64
62
  console.print("[red]No AgentGov identity found.[/] Run [bold]agentgov login[/] first.")
65
63
  raise typer.Exit(2)
66
64
  identity = json.loads(identity_path.read_text())
67
65
  token = identity.get("auth0_token") or ""
66
+
67
+ # Tower URL precedence: explicit --tower > CONTROL_TOWER_URL env >
68
+ # the tower we logged in against (identity.json) > localhost dev default.
69
+ #
70
+ # The identity.json fallback matters: `agentgov login --tower https://...`
71
+ # persists the tower, but a NEW shell won't have CONTROL_TOWER_URL set.
72
+ # Without this fallback register-device silently tried localhost:3000 and
73
+ # died with "Connection refused" even though login had just succeeded.
74
+ tower = (
75
+ tower_url
76
+ or os.environ.get("CONTROL_TOWER_URL")
77
+ or identity.get("tower_url")
78
+ or "http://localhost:3000"
79
+ )
80
+ console.print(f"[dim]Registering against control tower:[/] {tower}")
68
81
  if not token:
69
82
  console.print("[red]Identity file is missing auth0_token.[/] Re-run `agentgov login`.")
70
83
  raise typer.Exit(2)
@@ -110,16 +123,24 @@ def run(
110
123
  device = payload.get("device", {})
111
124
  gtw_token = payload.get("gateway_service_token") or ""
112
125
 
113
- # Persist gateway creds.
126
+ # Persist a COMPLETE gateway config so `agentgov-gateway` boots with zero
127
+ # environment variables. config.py reads this file when the corresponding
128
+ # env vars are absent; env still wins if explicitly set.
114
129
  creds_path = Path.home() / ".agentgov" / "gateway_creds.json"
115
130
  creds_path.write_text(
116
131
  json.dumps(
117
132
  {
118
133
  "tower_url": tower,
134
+ "control_tower_url": tower,
119
135
  "device_id": device.get("id"),
120
136
  "device_name": device.get("name"),
121
- "upstream_mode": device.get("upstream_mode"),
137
+ "upstream_mode": device.get("upstream_mode") or upstream_mode,
122
138
  "gateway_service_token": gtw_token,
139
+ # Public verify-only key; the tower returns it at registration.
140
+ "policy_signing_public_key_b64": payload.get(
141
+ "policy_signing_public_key_b64", ""
142
+ ),
143
+ "device_key_path": str(priv_path),
123
144
  },
124
145
  indent=2,
125
146
  )
@@ -129,9 +150,7 @@ def run(
129
150
  console.print(
130
151
  f"[green]✅ Device registered.[/] id={device.get('id')} mode={device.get('upstream_mode')}"
131
152
  )
132
- console.print(f"gateway_service_token saved to {creds_path}")
153
+ console.print(f"Gateway config saved to {creds_path}")
133
154
  console.print()
134
- console.print("Now start (or restart) the gateway with these env vars — see .env:")
135
- console.print(f" GATEWAY_SERVICE_TOKEN={gtw_token}")
136
- console.print(f" DEVICE_KEY_PATH={priv_path}")
137
- console.print(f" UPSTREAM_MODE={upstream_mode}")
155
+ console.print("[bold]Start the gateway — no environment variables needed:[/]")
156
+ console.print(" agentgov-gateway")
@@ -78,6 +78,13 @@ def run(
78
78
  target = list(cmd) if cmd else ["claude"]
79
79
  gateway = gateway_url or "http://localhost:8000"
80
80
 
81
+ # Never hand Claude Code a dead base URL. We are about to set
82
+ # ANTHROPIC_BASE_URL=<gateway>; if nothing is listening there, Claude Code
83
+ # fails with an opaque "API Error: Connection refused" from inside its own
84
+ # request path, with no mention of AgentGov. Verify first, and start a
85
+ # local gateway on demand.
86
+ _require_gateway(gateway)
87
+
81
88
  # Register the session with the gateway. Also send hook hashes so the
82
89
  # gateway can compare against expected values from the signed snapshot
83
90
  # (gap-P0-4 hook integrity check). Mismatch = warning + audit row;
@@ -130,6 +137,57 @@ def _wait_for_port(listener: LoopbackListener, timeout_sec: float = 2.0) -> None
130
137
  _time.sleep(0.01)
131
138
 
132
139
 
140
+ def _is_loopback(gateway: str) -> bool:
141
+ """True if `gateway` points at this machine (so we may start it ourselves)."""
142
+ from urllib.parse import urlparse
143
+
144
+ host = (urlparse(gateway).hostname or "").lower()
145
+ return host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
146
+
147
+
148
+ def _require_gateway(gateway: str) -> None:
149
+ """Ensure a healthy gateway at `gateway`, or exit with actionable guidance.
150
+
151
+ Local gateways are started on demand (detached, so they outlive this
152
+ terminal). Remote gateways (§12 enterprise-remote) are never started by us —
153
+ we only report that they are unreachable.
154
+ """
155
+ from .. import gateway_process as gp
156
+
157
+ if gp.is_healthy(gateway):
158
+ return
159
+
160
+ if not _is_loopback(gateway):
161
+ console.print(
162
+ f"[red]AgentGov gateway at {gateway} is not reachable.[/]\n"
163
+ "This is a remote gateway, so this machine cannot start it. "
164
+ "Check the URL and your network, or contact your administrator."
165
+ )
166
+ raise typer.Exit(2)
167
+
168
+ console.print(f"[yellow]Gateway not running at {gateway} — starting it...[/]")
169
+ ok, detail = gp.start_detached(gateway)
170
+ if ok:
171
+ console.print(f"[green]Gateway started.[/] {detail}")
172
+ console.print(
173
+ "Tip: run [cyan]agentgov gateway install[/] once to have it start "
174
+ "automatically at login and restart on failure."
175
+ )
176
+ return
177
+
178
+ # Refuse to launch rather than hand Claude Code a dead URL.
179
+ console.print(f"[red]Could not start the AgentGov gateway at {gateway}.[/]\n{detail}")
180
+ console.print(
181
+ "\nClaude Code was NOT launched, because it would have failed with an "
182
+ "opaque connection error.\n"
183
+ "Fix the gateway, then retry:\n"
184
+ " [cyan]agentgov gateway install[/] # install as a supervised service\n"
185
+ " [cyan]agentgov gateway logs[/] # see why it is failing\n"
186
+ " [cyan]agentgov doctor[/] # full diagnosis"
187
+ )
188
+ raise typer.Exit(2)
189
+
190
+
133
191
  def _resolve_repo() -> tuple[Path, str]:
134
192
  try:
135
193
  root = subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip()
@@ -0,0 +1,287 @@
1
+ """Local gateway process lifecycle.
2
+
3
+ Why this module exists
4
+ ----------------------
5
+ `agentgov wrap` points Claude Code's ANTHROPIC_BASE_URL at the local gateway.
6
+ If nothing is listening on that port, Claude Code fails with a bare
7
+ ``API Error: Connection refused`` from deep inside its own request path —
8
+ typically mid-compaction, with no mention of AgentGov. Users reasonably
9
+ conclude the governance layer is broken and reach for the uninstall recipe.
10
+
11
+ The old setup instructions made that outcome likely: the gateway was started as
12
+ a bare ``agentgov-gateway &`` job of an interactive shell, so it received
13
+ SIGHUP and died as soon as that terminal closed. Combined with a machine-wide
14
+ ``managed-settings.json`` forcing ANTHROPIC_BASE_URL, one closed terminal
15
+ bricked *every* Claude Code session on the machine.
16
+
17
+ So: never hand Claude Code a dead base URL. Callers use `ensure_running()`,
18
+ which probes /health, starts a detached gateway if needed, and reports honestly
19
+ if it could not.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import platform
26
+ import subprocess
27
+ import sys
28
+ import time
29
+ from pathlib import Path
30
+
31
+ import httpx
32
+
33
+ # Where a detached gateway writes its log. Kept next to the rest of the
34
+ # per-user state so `agentgov doctor` and the support bundle can find it.
35
+ AGENTGOV_HOME = Path.home() / ".agentgov"
36
+ GATEWAY_LOG = AGENTGOV_HOME / "gateway.log"
37
+
38
+ # Health probe budget. The gateway binds its port before the lifespan's first
39
+ # control-tower call, so a healthy boot answers well inside this.
40
+ _BOOT_TIMEOUT_SEC = 20.0
41
+ _POLL_INTERVAL_SEC = 0.25
42
+
43
+
44
+ def is_healthy(gateway_url: str, timeout_sec: float = 2.0) -> bool:
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
+ """
51
+ try:
52
+ r = httpx.get(f"{gateway_url.rstrip('/')}/health", timeout=timeout_sec)
53
+ except httpx.HTTPError:
54
+ return False
55
+ return r.status_code == 200
56
+
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
+
80
+ def find_executable() -> str | None:
81
+ """Locate the `agentgov-gateway` entry point.
82
+
83
+ Checked in order:
84
+ 1. The same directory as the running `agentgov` CLI. This is the common
85
+ case — the setup instructions install both packages into the single
86
+ venv at ~/.agentgov/venv, so the gateway is our sibling even when that
87
+ venv is not on PATH (a fresh shell that never sourced the rc file).
88
+ 2. PATH.
89
+ """
90
+ sibling = Path(sys.argv[0]).resolve().parent / "agentgov-gateway"
91
+ if sibling.is_file() and os.access(sibling, os.X_OK):
92
+ return str(sibling)
93
+
94
+ from shutil import which
95
+
96
+ return which("agentgov-gateway")
97
+
98
+
99
+ def start_detached(gateway_url: str) -> tuple[bool, str]:
100
+ """Start the gateway fully detached from this terminal.
101
+
102
+ Returns (healthy, detail). Detached means it survives the parent shell:
103
+ a new session (setsid) so the terminal's SIGHUP on close never reaches it,
104
+ and stdio redirected to the log file rather than the inherited tty.
105
+ """
106
+ exe = find_executable()
107
+ if exe is None:
108
+ return False, (
109
+ "could not find the `agentgov-gateway` executable. Install it with:\n"
110
+ " ~/.agentgov/venv/bin/pip install --upgrade agentgov-gateway"
111
+ )
112
+
113
+ AGENTGOV_HOME.mkdir(parents=True, exist_ok=True)
114
+ try:
115
+ log_fh = GATEWAY_LOG.open("ab")
116
+ except OSError as e:
117
+ return False, f"could not open {GATEWAY_LOG}: {e}"
118
+
119
+ try:
120
+ # start_new_session=True == setsid(): the child leads its own process
121
+ # group and session, so closing this terminal does not signal it.
122
+ subprocess.Popen(
123
+ [exe],
124
+ stdin=subprocess.DEVNULL,
125
+ stdout=log_fh,
126
+ stderr=log_fh,
127
+ start_new_session=True,
128
+ # Run from home, not the caller's cwd: the gateway resolves its
129
+ # default .runtime/ paths relative to cwd, and we do not want a
130
+ # stray .runtime/ directory inside whatever repo the developer
131
+ # happened to launch from.
132
+ cwd=str(Path.home()),
133
+ )
134
+ except OSError as e:
135
+ return False, f"could not start {exe}: {e}"
136
+ finally:
137
+ log_fh.close()
138
+
139
+ if wait_until_healthy(gateway_url):
140
+ return True, f"started; logging to {GATEWAY_LOG}"
141
+
142
+ return False, (
143
+ f"started but did not answer /health within {int(_BOOT_TIMEOUT_SEC)}s.\n"
144
+ f"{log_tail()}"
145
+ )
146
+
147
+
148
+ def ensure_running(gateway_url: str) -> tuple[bool, str]:
149
+ """Guarantee a healthy gateway at `gateway_url`, starting one if needed.
150
+
151
+ Returns (healthy, detail). Callers MUST NOT launch Claude Code when this
152
+ returns False — a dead base URL surfaces as an opaque connection error.
153
+ """
154
+ if is_healthy(gateway_url):
155
+ return True, "already running"
156
+ return start_detached(gateway_url)
157
+
158
+
159
+ def log_tail(lines: int = 15) -> str:
160
+ """Last `lines` of the gateway log, formatted for terminal display."""
161
+ if not GATEWAY_LOG.exists():
162
+ return f"(no log at {GATEWAY_LOG})"
163
+ try:
164
+ content = GATEWAY_LOG.read_text(errors="replace").splitlines()
165
+ except OSError as e:
166
+ return f"(could not read {GATEWAY_LOG}: {e})"
167
+ tail = content[-lines:]
168
+ if not tail:
169
+ return f"({GATEWAY_LOG} is empty — the gateway has not logged anything yet)"
170
+ body = "\n".join(f" {ln}" for ln in tail)
171
+ return f"Last {len(tail)} line(s) of {GATEWAY_LOG}:\n{body}"
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Service installation
176
+ #
177
+ # A supervised service is the real fix for "my gateway died": it starts at
178
+ # login and restarts on crash, so the port is bound whenever the developer is
179
+ # using their machine. `ensure_running` is the belt to this pair of braces.
180
+ # ---------------------------------------------------------------------------
181
+
182
+ SYSTEMD_UNIT_PATH = Path.home() / ".config" / "systemd" / "user" / "agentgov-gateway.service"
183
+ LAUNCHD_PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / "ai.agentgov.gateway.plist"
184
+
185
+
186
+ def _systemd_unit(exe: str) -> str:
187
+ return f"""[Unit]
188
+ Description=AgentGov local governance gateway
189
+ After=network-online.target
190
+
191
+ [Service]
192
+ Type=simple
193
+ ExecStart={exe}
194
+ WorkingDirectory=%h
195
+ Restart=always
196
+ RestartSec=3
197
+ StandardOutput=append:%h/.agentgov/gateway.log
198
+ StandardError=append:%h/.agentgov/gateway.log
199
+
200
+ [Install]
201
+ WantedBy=default.target
202
+ """
203
+
204
+
205
+ def _launchd_plist(exe: str) -> str:
206
+ log = str(GATEWAY_LOG)
207
+ return f"""<?xml version="1.0" encoding="UTF-8"?>
208
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
209
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
210
+ <plist version="1.0">
211
+ <dict>
212
+ <key>Label</key> <string>ai.agentgov.gateway</string>
213
+ <key>ProgramArguments</key> <array><string>{exe}</string></array>
214
+ <key>RunAtLoad</key> <true/>
215
+ <key>KeepAlive</key> <true/>
216
+ <key>WorkingDirectory</key> <string>{Path.home()}</string>
217
+ <key>StandardOutPath</key> <string>{log}</string>
218
+ <key>StandardErrorPath</key><string>{log}</string>
219
+ </dict>
220
+ </plist>
221
+ """
222
+
223
+
224
+ def install_service() -> tuple[bool, str]:
225
+ """Install + start a supervised gateway service for the current user.
226
+
227
+ systemd --user on Linux/WSL, launchd on macOS. Returns (ok, detail).
228
+ """
229
+ exe = find_executable()
230
+ if exe is None:
231
+ return False, (
232
+ "could not find the `agentgov-gateway` executable. Install it with:\n"
233
+ " ~/.agentgov/venv/bin/pip install --upgrade agentgov-gateway"
234
+ )
235
+ AGENTGOV_HOME.mkdir(parents=True, exist_ok=True)
236
+ system = platform.system()
237
+
238
+ if system == "Darwin":
239
+ LAUNCHD_PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
240
+ LAUNCHD_PLIST_PATH.write_text(_launchd_plist(exe))
241
+ # `bootout` first so a re-install picks up an edited plist. It fails
242
+ # harmlessly when nothing is loaded, hence check=False.
243
+ uid = os.getuid()
244
+ subprocess.run(
245
+ ["launchctl", "bootout", f"gui/{uid}/ai.agentgov.gateway"],
246
+ check=False,
247
+ capture_output=True,
248
+ )
249
+ r = subprocess.run(
250
+ ["launchctl", "bootstrap", f"gui/{uid}", str(LAUNCHD_PLIST_PATH)],
251
+ check=False,
252
+ capture_output=True,
253
+ text=True,
254
+ )
255
+ if r.returncode != 0:
256
+ return False, f"launchctl bootstrap failed: {r.stderr.strip() or r.returncode}"
257
+ return True, f"launchd agent installed at {LAUNCHD_PLIST_PATH}"
258
+
259
+ if system == "Linux":
260
+ from shutil import which
261
+
262
+ if which("systemctl") is None:
263
+ return False, (
264
+ "systemctl not found. This environment has no systemd user session "
265
+ "(common on minimal WSL setups).\n"
266
+ "The gateway will still be auto-started on demand by `agentgov wrap`."
267
+ )
268
+ SYSTEMD_UNIT_PATH.parent.mkdir(parents=True, exist_ok=True)
269
+ SYSTEMD_UNIT_PATH.write_text(_systemd_unit(exe))
270
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=False, capture_output=True)
271
+ r = subprocess.run(
272
+ ["systemctl", "--user", "enable", "--now", "agentgov-gateway"],
273
+ check=False,
274
+ capture_output=True,
275
+ text=True,
276
+ )
277
+ if r.returncode != 0:
278
+ return False, (
279
+ f"systemctl --user enable --now failed: {r.stderr.strip() or r.returncode}\n"
280
+ "On WSL without systemd, `agentgov wrap` will auto-start the gateway instead."
281
+ )
282
+ return True, f"systemd user unit installed at {SYSTEMD_UNIT_PATH}"
283
+
284
+ return False, (
285
+ f"no service manager integration for {system}. "
286
+ "`agentgov wrap` will auto-start the gateway on demand."
287
+ )
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import typer
6
6
 
7
- from .commands import doctor, install, login, register_device, status, workitem, wrap
7
+ from .commands import doctor, gateway, install, login, register_device, status, workitem, wrap
8
8
 
9
9
  app = typer.Typer(
10
10
  name="agentgov",
@@ -19,6 +19,7 @@ app.command(
19
19
  app.command(
20
20
  "install", help="Install client assets into ~/.claude/ and print managed-settings.json."
21
21
  )(install.run)
22
+ app.add_typer(gateway.app, name="gateway")
22
23
  app.command("wrap", help="Wrap `claude` — start a governed Claude Code session.")(wrap.run)
23
24
  app.command("workitem", help="Bind the current session to a work item (JIRA/Notion/PRJ-...).")(
24
25
  workitem.run
@@ -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.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