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.
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.4}/PKG-INFO +1 -1
- agentgov_cli-0.1.4/agentgov_cli/commands/gateway.py +84 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.4}/agentgov_cli/commands/install.py +115 -20
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.4}/agentgov_cli/commands/register_device.py +28 -9
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.4}/agentgov_cli/commands/wrap.py +58 -0
- agentgov_cli-0.1.4/agentgov_cli/gateway_process.py +287 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.4}/agentgov_cli/main.py +2 -1
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.4}/pyproject.toml +1 -1
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.4}/.gitignore +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.4}/README.md +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.4}/agentgov_cli/__init__.py +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.4}/agentgov_cli/commands/__init__.py +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.4}/agentgov_cli/commands/doctor.py +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.4}/agentgov_cli/commands/login.py +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.4}/agentgov_cli/commands/status.py +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.4}/agentgov_cli/commands/workitem.py +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.4}/agentgov_cli/loopback.py +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.4}/agentgov_cli/port_resolver.py +0 -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
|
|
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"),
|
|
@@ -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
|
|
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"
|
|
153
|
+
console.print(f"Gateway config saved to {creds_path}")
|
|
133
154
|
console.print()
|
|
134
|
-
console.print("
|
|
135
|
-
console.print(
|
|
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.
|
|
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
|