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.
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.3}/PKG-INFO +1 -1
- agentgov_cli-0.1.3/agentgov_cli/commands/gateway.py +79 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.3}/agentgov_cli/commands/register_device.py +28 -9
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.3}/agentgov_cli/commands/wrap.py +58 -0
- agentgov_cli-0.1.3/agentgov_cli/gateway_process.py +261 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.3}/agentgov_cli/main.py +2 -1
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.3}/pyproject.toml +1 -1
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.3}/.gitignore +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.3}/README.md +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.3}/agentgov_cli/__init__.py +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.3}/agentgov_cli/commands/__init__.py +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.3}/agentgov_cli/commands/doctor.py +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.3}/agentgov_cli/commands/install.py +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.3}/agentgov_cli/commands/login.py +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.3}/agentgov_cli/commands/status.py +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.3}/agentgov_cli/commands/workitem.py +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.3}/agentgov_cli/loopback.py +0 -0
- {agentgov_cli-0.1.1 → agentgov_cli-0.1.3}/agentgov_cli/port_resolver.py +0 -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
|
|
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,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.
|
|
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
|
|
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
|