agentgov-cli 0.1.1__tar.gz → 0.1.3__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.3
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,79 @@
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
+ if gp.is_healthy(url, timeout_sec=10.0):
43
+ console.print(f"[green]Gateway healthy[/] at {url}")
44
+ 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.[/]")
48
+ console.print(gp.log_tail())
49
+ raise typer.Exit(1)
50
+
51
+
52
+ @app.command("start")
53
+ def start(gateway_url: str = _GATEWAY_OPT) -> None:
54
+ """Start the gateway now, detached from this terminal."""
55
+ url = _url(gateway_url)
56
+ ok, detail = gp.ensure_running(url)
57
+ if not ok:
58
+ console.print(f"[red]Gateway did not start.[/] {detail}")
59
+ raise typer.Exit(1)
60
+ console.print(f"[green]Gateway running[/] at {url} ({detail})")
61
+
62
+
63
+ @app.command("status")
64
+ def status(gateway_url: str = _GATEWAY_OPT) -> None:
65
+ """Report whether the gateway is answering."""
66
+ url = _url(gateway_url)
67
+ if gp.is_healthy(url):
68
+ console.print(f"[green]RUNNING[/] — {url}/health answered.")
69
+ return
70
+ console.print(f"[red]NOT RUNNING[/] — nothing answering at {url}/health.")
71
+ console.print("Start it with: [cyan]agentgov gateway install[/]")
72
+ console.print(gp.log_tail())
73
+ raise typer.Exit(1)
74
+
75
+
76
+ @app.command("logs")
77
+ def logs(lines: int = typer.Option(40, "--lines", "-n")) -> None:
78
+ """Show the tail of the gateway log."""
79
+ console.print(gp.log_tail(lines))
@@ -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,261 @@
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`."""
46
+ try:
47
+ r = httpx.get(f"{gateway_url.rstrip('/')}/health", timeout=timeout_sec)
48
+ except httpx.HTTPError:
49
+ return False
50
+ return r.status_code == 200
51
+
52
+
53
+ def find_executable() -> str | None:
54
+ """Locate the `agentgov-gateway` entry point.
55
+
56
+ Checked in order:
57
+ 1. The same directory as the running `agentgov` CLI. This is the common
58
+ case — the setup instructions install both packages into the single
59
+ venv at ~/.agentgov/venv, so the gateway is our sibling even when that
60
+ venv is not on PATH (a fresh shell that never sourced the rc file).
61
+ 2. PATH.
62
+ """
63
+ sibling = Path(sys.argv[0]).resolve().parent / "agentgov-gateway"
64
+ if sibling.is_file() and os.access(sibling, os.X_OK):
65
+ return str(sibling)
66
+
67
+ from shutil import which
68
+
69
+ return which("agentgov-gateway")
70
+
71
+
72
+ def start_detached(gateway_url: str) -> tuple[bool, str]:
73
+ """Start the gateway fully detached from this terminal.
74
+
75
+ Returns (healthy, detail). Detached means it survives the parent shell:
76
+ a new session (setsid) so the terminal's SIGHUP on close never reaches it,
77
+ and stdio redirected to the log file rather than the inherited tty.
78
+ """
79
+ exe = find_executable()
80
+ if exe is None:
81
+ return False, (
82
+ "could not find the `agentgov-gateway` executable. Install it with:\n"
83
+ " ~/.agentgov/venv/bin/pip install --upgrade agentgov-gateway"
84
+ )
85
+
86
+ AGENTGOV_HOME.mkdir(parents=True, exist_ok=True)
87
+ try:
88
+ log_fh = GATEWAY_LOG.open("ab")
89
+ except OSError as e:
90
+ return False, f"could not open {GATEWAY_LOG}: {e}"
91
+
92
+ try:
93
+ # start_new_session=True == setsid(): the child leads its own process
94
+ # group and session, so closing this terminal does not signal it.
95
+ subprocess.Popen(
96
+ [exe],
97
+ stdin=subprocess.DEVNULL,
98
+ stdout=log_fh,
99
+ stderr=log_fh,
100
+ start_new_session=True,
101
+ # Run from home, not the caller's cwd: the gateway resolves its
102
+ # default .runtime/ paths relative to cwd, and we do not want a
103
+ # stray .runtime/ directory inside whatever repo the developer
104
+ # happened to launch from.
105
+ cwd=str(Path.home()),
106
+ )
107
+ except OSError as e:
108
+ return False, f"could not start {exe}: {e}"
109
+ finally:
110
+ log_fh.close()
111
+
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)
117
+
118
+ return False, (
119
+ f"started but did not answer /health within {int(_BOOT_TIMEOUT_SEC)}s.\n"
120
+ f"{log_tail()}"
121
+ )
122
+
123
+
124
+ def ensure_running(gateway_url: str) -> tuple[bool, str]:
125
+ """Guarantee a healthy gateway at `gateway_url`, starting one if needed.
126
+
127
+ Returns (healthy, detail). Callers MUST NOT launch Claude Code when this
128
+ returns False — a dead base URL surfaces as an opaque connection error.
129
+ """
130
+ if is_healthy(gateway_url):
131
+ return True, "already running"
132
+ return start_detached(gateway_url)
133
+
134
+
135
+ def log_tail(lines: int = 15) -> str:
136
+ """Last `lines` of the gateway log, formatted for terminal display."""
137
+ if not GATEWAY_LOG.exists():
138
+ return f"(no log at {GATEWAY_LOG})"
139
+ try:
140
+ content = GATEWAY_LOG.read_text(errors="replace").splitlines()
141
+ except OSError as e:
142
+ return f"(could not read {GATEWAY_LOG}: {e})"
143
+ tail = content[-lines:]
144
+ body = "\n".join(f" {ln}" for ln in tail)
145
+ return f"Last {len(tail)} line(s) of {GATEWAY_LOG}:\n{body}"
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Service installation
150
+ #
151
+ # A supervised service is the real fix for "my gateway died": it starts at
152
+ # login and restarts on crash, so the port is bound whenever the developer is
153
+ # using their machine. `ensure_running` is the belt to this pair of braces.
154
+ # ---------------------------------------------------------------------------
155
+
156
+ SYSTEMD_UNIT_PATH = Path.home() / ".config" / "systemd" / "user" / "agentgov-gateway.service"
157
+ LAUNCHD_PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / "ai.agentgov.gateway.plist"
158
+
159
+
160
+ def _systemd_unit(exe: str) -> str:
161
+ return f"""[Unit]
162
+ Description=AgentGov local governance gateway
163
+ After=network-online.target
164
+
165
+ [Service]
166
+ Type=simple
167
+ ExecStart={exe}
168
+ WorkingDirectory=%h
169
+ Restart=always
170
+ RestartSec=3
171
+ StandardOutput=append:%h/.agentgov/gateway.log
172
+ StandardError=append:%h/.agentgov/gateway.log
173
+
174
+ [Install]
175
+ WantedBy=default.target
176
+ """
177
+
178
+
179
+ def _launchd_plist(exe: str) -> str:
180
+ log = str(GATEWAY_LOG)
181
+ return f"""<?xml version="1.0" encoding="UTF-8"?>
182
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
183
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
184
+ <plist version="1.0">
185
+ <dict>
186
+ <key>Label</key> <string>ai.agentgov.gateway</string>
187
+ <key>ProgramArguments</key> <array><string>{exe}</string></array>
188
+ <key>RunAtLoad</key> <true/>
189
+ <key>KeepAlive</key> <true/>
190
+ <key>WorkingDirectory</key> <string>{Path.home()}</string>
191
+ <key>StandardOutPath</key> <string>{log}</string>
192
+ <key>StandardErrorPath</key><string>{log}</string>
193
+ </dict>
194
+ </plist>
195
+ """
196
+
197
+
198
+ def install_service() -> tuple[bool, str]:
199
+ """Install + start a supervised gateway service for the current user.
200
+
201
+ systemd --user on Linux/WSL, launchd on macOS. Returns (ok, detail).
202
+ """
203
+ exe = find_executable()
204
+ if exe is None:
205
+ return False, (
206
+ "could not find the `agentgov-gateway` executable. Install it with:\n"
207
+ " ~/.agentgov/venv/bin/pip install --upgrade agentgov-gateway"
208
+ )
209
+ AGENTGOV_HOME.mkdir(parents=True, exist_ok=True)
210
+ system = platform.system()
211
+
212
+ if system == "Darwin":
213
+ LAUNCHD_PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
214
+ LAUNCHD_PLIST_PATH.write_text(_launchd_plist(exe))
215
+ # `bootout` first so a re-install picks up an edited plist. It fails
216
+ # harmlessly when nothing is loaded, hence check=False.
217
+ uid = os.getuid()
218
+ subprocess.run(
219
+ ["launchctl", "bootout", f"gui/{uid}/ai.agentgov.gateway"],
220
+ check=False,
221
+ capture_output=True,
222
+ )
223
+ r = subprocess.run(
224
+ ["launchctl", "bootstrap", f"gui/{uid}", str(LAUNCHD_PLIST_PATH)],
225
+ check=False,
226
+ capture_output=True,
227
+ text=True,
228
+ )
229
+ if r.returncode != 0:
230
+ return False, f"launchctl bootstrap failed: {r.stderr.strip() or r.returncode}"
231
+ return True, f"launchd agent installed at {LAUNCHD_PLIST_PATH}"
232
+
233
+ if system == "Linux":
234
+ from shutil import which
235
+
236
+ if which("systemctl") is None:
237
+ return False, (
238
+ "systemctl not found. This environment has no systemd user session "
239
+ "(common on minimal WSL setups).\n"
240
+ "The gateway will still be auto-started on demand by `agentgov wrap`."
241
+ )
242
+ SYSTEMD_UNIT_PATH.parent.mkdir(parents=True, exist_ok=True)
243
+ SYSTEMD_UNIT_PATH.write_text(_systemd_unit(exe))
244
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=False, capture_output=True)
245
+ r = subprocess.run(
246
+ ["systemctl", "--user", "enable", "--now", "agentgov-gateway"],
247
+ check=False,
248
+ capture_output=True,
249
+ text=True,
250
+ )
251
+ if r.returncode != 0:
252
+ return False, (
253
+ f"systemctl --user enable --now failed: {r.stderr.strip() or r.returncode}\n"
254
+ "On WSL without systemd, `agentgov wrap` will auto-start the gateway instead."
255
+ )
256
+ return True, f"systemd user unit installed at {SYSTEMD_UNIT_PATH}"
257
+
258
+ return False, (
259
+ f"no service manager integration for {system}. "
260
+ "`agentgov wrap` will auto-start the gateway on demand."
261
+ )
@@ -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.3"
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