agentgov-cli 0.1.0__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.0
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))
@@ -14,6 +14,7 @@ import shutil
14
14
  import sys
15
15
  from pathlib import Path
16
16
 
17
+ import typer
17
18
  from rich.console import Console
18
19
 
19
20
  console = Console()
@@ -28,10 +29,22 @@ ASSET_ROOTS = [
28
29
 
29
30
 
30
31
  def run(
31
- gateway_url: str = "http://localhost:8000",
32
- force: bool = False,
32
+ gateway_url: str = typer.Option(
33
+ "http://localhost:8000",
34
+ "--gateway-url",
35
+ "--gateway", # alias — earlier docs called it --gateway
36
+ envvar="AGENTGOV_GATEWAY_URL",
37
+ help="URL of the local AgentGov gateway (usually http://localhost:8000).",
38
+ ),
39
+ force: bool = typer.Option(False, "--force", help="Overwrite existing hooks/statusline."),
33
40
  ) -> None:
34
- """Install hooks + statusline + slash commands into ~/.claude/."""
41
+ """Install hooks + statusline + slash commands into ~/.claude/.
42
+
43
+ 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.
47
+ """
35
48
  src = _find_assets()
36
49
  dest = Path.home() / ".claude"
37
50
  dest.mkdir(exist_ok=True)
@@ -62,9 +75,34 @@ def run(
62
75
  console.print(f"[green]Copied[/] /workitem slash command → {commands_dest / 'workitem.md'}")
63
76
 
64
77
  settings = _build_managed_settings(gateway_url, hooks_dest, statusline_dest)
78
+ settings_json = json.dumps(settings, indent=2)
79
+
80
+ # Write to a stable local path so the admin can `sudo cp` it into place
81
+ # without having to redirect stdout carefully.
82
+ local_settings_path = Path.home() / ".agentgov" / "managed-settings.json"
83
+ local_settings_path.parent.mkdir(parents=True, exist_ok=True)
84
+ local_settings_path.write_text(settings_json + "\n")
85
+ local_settings_path.chmod(0o644)
86
+
87
+ target_path = _managed_settings_path()
65
88
  console.rule("[bold]managed-settings.json[/]")
66
- console.print(f"Ask your admin to save this file at:\n {_managed_settings_path()}\n")
67
- console.print(json.dumps(settings, indent=2))
89
+ console.print(f"Written locally to: [cyan]{local_settings_path}[/]")
90
+ console.print(f"Target install path: [cyan]{target_path}[/]\n")
91
+ console.print("[bold]To govern the VS Code extension too, run:[/]")
92
+ if platform.system() == "Windows":
93
+ console.print(
94
+ f' Copy-Item "{local_settings_path}" "{target_path}" -Force'
95
+ )
96
+ else:
97
+ parent = str(Path(target_path).parent)
98
+ console.print(
99
+ f" sudo mkdir -p {parent} && sudo cp {local_settings_path} {target_path}"
100
+ )
101
+ console.print(
102
+ "\nThen fully quit VS Code and reopen — the extension now routes through the gateway."
103
+ )
104
+ console.print("\n[dim]Contents (for reference):[/]")
105
+ console.print(settings_json)
68
106
 
69
107
 
70
108
  def _find_assets() -> Path:
@@ -0,0 +1,159 @@
1
+ """`agentgov login` — obtain an AgentGov dashboard session (CLAUDE.md §13.5).
2
+
3
+ Two paths, mutually exclusive:
4
+
5
+ 1. --invite <inv_...> Accept an emailed invite: prompts for a username,
6
+ POSTs /api/invites/<token>, and stores the returned
7
+ sess_ token as the identity.
8
+
9
+ 2. --token <sess_...|agentgov-dev-admin>
10
+ Paste-in a pre-obtained token. Verified by hitting
11
+ /api/devices with it (any authenticated GET works).
12
+
13
+ The Auth0 device-code flow lands in Phase 5 and slots into path (2).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ from pathlib import Path
21
+
22
+ import httpx
23
+ import typer
24
+ from rich.console import Console
25
+
26
+ console = Console()
27
+
28
+
29
+ def run(
30
+ tower_url: str = typer.Option(
31
+ None, "--tower", envvar="CONTROL_TOWER_URL", help="Control tower URL"
32
+ ),
33
+ token: str = typer.Option(
34
+ None,
35
+ "--token",
36
+ help="Paste a session token (sess_...) or 'agentgov-dev-admin' in dev.",
37
+ ),
38
+ invite: str = typer.Option(
39
+ None,
40
+ "--invite",
41
+ help="Accept an invite token (inv_...) from an email link.",
42
+ ),
43
+ username: str = typer.Option(
44
+ None,
45
+ "--username",
46
+ help="Username to claim when accepting an invite. Prompted if omitted.",
47
+ ),
48
+ display_name: str = typer.Option(
49
+ None, "--display-name", help="Optional display name when accepting an invite."
50
+ ),
51
+ ) -> None:
52
+ """Attach this machine to a user in the AgentGov control tower."""
53
+ tower = (tower_url or os.environ.get("CONTROL_TOWER_URL") or "http://localhost:3000").rstrip("/")
54
+
55
+ if invite and token:
56
+ console.print("[red]--invite and --token are mutually exclusive.[/]")
57
+ raise typer.Exit(2)
58
+
59
+ if invite:
60
+ resolved = _accept_invite(tower, invite.strip(), username, display_name)
61
+ else:
62
+ resolved = _paste_token(tower, token)
63
+
64
+ identity_path = Path.home() / ".agentgov" / "identity.json"
65
+ identity_path.parent.mkdir(parents=True, exist_ok=True)
66
+ identity_path.write_text(json.dumps(resolved, indent=2))
67
+ identity_path.chmod(0o600)
68
+ console.print(f"[green]✅ Logged in.[/] Identity saved to {identity_path}")
69
+ console.print("Next: [bold]agentgov register-device --name 'my-laptop'[/]")
70
+
71
+
72
+ def _paste_token(tower: str, token: str | None) -> dict[str, object]:
73
+ if not token:
74
+ console.print(
75
+ "[bold]Paste your session token[/] (sess_... from signup/invite, or 'agentgov-dev-admin' in dev)."
76
+ )
77
+ token = typer.prompt("token", hide_input=True).strip()
78
+ else:
79
+ token = token.strip()
80
+
81
+ # Verify.
82
+ try:
83
+ r = httpx.get(
84
+ f"{tower}/api/devices",
85
+ headers={"Authorization": f"Bearer {token}"},
86
+ timeout=10.0,
87
+ )
88
+ except Exception as e:
89
+ console.print(f"[red]Cannot reach control tower at {tower}: {e}[/]")
90
+ raise typer.Exit(2) from e
91
+ if r.status_code == 401:
92
+ console.print("[red]Token rejected by control tower.[/]")
93
+ raise typer.Exit(2)
94
+ if r.status_code >= 400:
95
+ console.print(f"[red]Unexpected response: {r.status_code} {r.text}[/]")
96
+ raise typer.Exit(2)
97
+
98
+ return {"tower_url": tower, "auth0_token": token}
99
+
100
+
101
+ def _accept_invite(
102
+ tower: str,
103
+ invite_token: str,
104
+ username: str | None,
105
+ display_name: str | None,
106
+ ) -> dict[str, object]:
107
+ # Peek so the user sees what they're joining.
108
+ try:
109
+ peek = httpx.get(f"{tower}/api/invites/{invite_token}", timeout=10.0)
110
+ except Exception as e:
111
+ console.print(f"[red]Cannot reach control tower at {tower}: {e}[/]")
112
+ raise typer.Exit(2) from e
113
+ if peek.status_code != 200:
114
+ console.print(f"[red]Invite not valid: {peek.status_code} {peek.text}[/]")
115
+ raise typer.Exit(2)
116
+ preview = peek.json()
117
+ console.print(
118
+ f"Invite for [bold]{preview.get('email')}[/] to join "
119
+ f"[bold]{preview.get('tenant_name')}[/] as {preview.get('role')}."
120
+ )
121
+
122
+ if not username:
123
+ username = typer.prompt("Choose a username").strip()
124
+ if not display_name:
125
+ display_name = typer.prompt("Display name (optional)", default="").strip() or None
126
+
127
+ body: dict[str, object] = {"username": username}
128
+ if display_name:
129
+ body["display_name"] = display_name
130
+
131
+ try:
132
+ r = httpx.post(
133
+ f"{tower}/api/invites/{invite_token}",
134
+ json=body,
135
+ timeout=15.0,
136
+ )
137
+ except Exception as e:
138
+ console.print(f"[red]Accept failed: {e}[/]")
139
+ raise typer.Exit(2) from e
140
+ if r.status_code >= 400:
141
+ console.print(f"[red]Accept rejected: {r.status_code} {r.text}[/]")
142
+ raise typer.Exit(2)
143
+
144
+ resp = r.json()
145
+ session_token = resp.get("session_token")
146
+ if not session_token:
147
+ console.print("[red]Server did not return a session token.[/]")
148
+ raise typer.Exit(2)
149
+
150
+ console.print(
151
+ f"[green]Joined {resp.get('tenant_name')} as {resp.get('user', {}).get('username')}.[/]"
152
+ )
153
+ return {
154
+ "tower_url": tower,
155
+ "auth0_token": session_token,
156
+ "tenant_id": resp.get("tenant_id"),
157
+ "tenant_slug": resp.get("tenant_slug"),
158
+ "username": resp.get("user", {}).get("username"),
159
+ }
@@ -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()