agentgov-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """AgentGov CLI. Developer-laptop tool: wrap · workitem · status · doctor."""
2
+
3
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,168 @@
1
+ """`agentgov doctor` — diagnose the installation (CLAUDE.md §13, §14).
2
+
3
+ The old §12.1 blanket rule ("gateway must NEVER run on a governed developer's
4
+ machine") has been REPLACED by the §14.4 CONDITIONAL rule:
5
+
6
+ The gateway MAY run locally provided
7
+ (a) upstream_mode = oauth_passthrough (no vault to steal)
8
+ OR
9
+ (b) every credential is user-scoped + spending-capped + device-encrypted.
10
+
11
+ So this doctor:
12
+ - Confirms local gateway is reachable (green: it IS local).
13
+ - Reports the machine fingerprint + the upstream_mode the gateway is
14
+ running in.
15
+ - Flags a WARNING (not an error) only if apikey_vault is on AND we couldn't
16
+ confirm device-encrypted delivery (i.e. an env-provided key overrides the
17
+ sealed cred — legacy §12 path on a dev machine, which we discourage).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import platform
24
+ import socket
25
+ import uuid
26
+ from pathlib import Path
27
+
28
+ import httpx
29
+ import typer
30
+ from rich.console import Console
31
+ from rich.table import Table
32
+
33
+ console = Console()
34
+
35
+
36
+ def run(
37
+ gateway_url: str = typer.Option(None, "--gateway", envvar="AGENTGOV_GATEWAY_URL"),
38
+ ) -> None:
39
+ ok = 0
40
+ warn = 0
41
+ fail = 0
42
+ table = Table("check", "result", "detail", show_lines=False)
43
+
44
+ # 1. Managed settings present (Linux only; on macOS/Windows we just show the path)
45
+ settings_path = _managed_settings_path()
46
+ if settings_path.exists():
47
+ table.add_row("managed-settings.json", "[green]OK[/]", str(settings_path))
48
+ ok += 1
49
+ else:
50
+ table.add_row("managed-settings.json", "[yellow]MISSING[/]", str(settings_path))
51
+ warn += 1
52
+
53
+ # 2. Hooks executable
54
+ hooks_dir = Path.home() / ".claude" / "agentgov-hooks"
55
+ if not hooks_dir.exists():
56
+ table.add_row("hooks installed", "[red]MISSING[/]", str(hooks_dir))
57
+ fail += 1
58
+ else:
59
+ problems = [str(h) for h in hooks_dir.glob("*.py") if not h.stat().st_mode & 0o100]
60
+ if problems:
61
+ table.add_row("hooks executable", "[red]FAIL[/]", ", ".join(problems))
62
+ fail += 1
63
+ else:
64
+ table.add_row("hooks executable", "[green]OK[/]", str(hooks_dir))
65
+ ok += 1
66
+
67
+ # 3. Statusline installed
68
+ sl = Path.home() / ".claude" / "agentgov-statusline" / "agentgov_statusline.sh"
69
+ if sl.exists():
70
+ table.add_row("statusline installed", "[green]OK[/]", str(sl))
71
+ ok += 1
72
+ else:
73
+ table.add_row("statusline installed", "[yellow]MISSING[/]", str(sl))
74
+ warn += 1
75
+
76
+ # 4. Identity + device registration (§13/§14)
77
+ identity = Path.home() / ".agentgov" / "identity.json"
78
+ creds = Path.home() / ".agentgov" / "gateway_creds.json"
79
+ if identity.exists():
80
+ table.add_row("identity", "[green]OK[/]", str(identity))
81
+ ok += 1
82
+ else:
83
+ table.add_row("identity", "[yellow]MISSING[/]", "run `agentgov login`")
84
+ warn += 1
85
+ if creds.exists():
86
+ table.add_row("device registered", "[green]OK[/]", str(creds))
87
+ ok += 1
88
+ else:
89
+ table.add_row(
90
+ "device registered", "[yellow]MISSING[/]", "run `agentgov register-device --name ...`"
91
+ )
92
+ warn += 1
93
+
94
+ # 5. Gateway reachable
95
+ url = gateway_url or "http://localhost:8000"
96
+ upstream_mode = None
97
+ try:
98
+ r = httpx.get(f"{url}/health", timeout=3.0)
99
+ if r.status_code == 200:
100
+ table.add_row("gateway reachable", "[green]OK[/]", url)
101
+ ok += 1
102
+ # Try to read the mode from the /admin endpoint or fall back to
103
+ # env — for now we just show what env says.
104
+ import os as _os
105
+
106
+ upstream_mode = _os.environ.get("UPSTREAM_MODE", "oauth_passthrough")
107
+ else:
108
+ table.add_row("gateway reachable", "[red]FAIL[/]", f"{url} → HTTP {r.status_code}")
109
+ fail += 1
110
+ except Exception as e:
111
+ table.add_row("gateway reachable", "[red]FAIL[/]", f"{url}: {e}")
112
+ fail += 1
113
+
114
+ # 6. §14.4 CONDITIONAL rule check
115
+ fp = _machine_fingerprint()
116
+ if upstream_mode == "oauth_passthrough":
117
+ table.add_row(
118
+ "§14.4 rule",
119
+ "[green]OK[/]",
120
+ f"oauth_passthrough — no vault · fingerprint={fp[:16]}…",
121
+ )
122
+ ok += 1
123
+ elif upstream_mode == "apikey_vault":
124
+ # We can't introspect the running gateway's config remotely (yet).
125
+ # This is a soft warning until we add /admin/mode endpoint.
126
+ import os as _os
127
+
128
+ legacy_env_key = bool(_os.environ.get("ANTHROPIC_API_KEY", ""))
129
+ if legacy_env_key:
130
+ table.add_row(
131
+ "§14.4 rule",
132
+ "[yellow]WARN[/]",
133
+ (
134
+ "apikey_vault WITH env-provided ANTHROPIC_API_KEY — legacy §12 path. "
135
+ "Prefer per-device sealed credentials."
136
+ ),
137
+ )
138
+ warn += 1
139
+ else:
140
+ table.add_row(
141
+ "§14.4 rule",
142
+ "[green]OK[/]",
143
+ "apikey_vault via per-device sealed credential (recommended)",
144
+ )
145
+ ok += 1
146
+
147
+ console.print(table)
148
+ console.print(f"\n[bold]{ok} OK · {warn} WARN · {fail} FAIL[/]")
149
+ if fail:
150
+ raise typer.Exit(2)
151
+ if warn:
152
+ raise typer.Exit(1)
153
+
154
+
155
+ def _managed_settings_path() -> Path:
156
+ sysname = platform.system()
157
+ if sysname == "Darwin":
158
+ return Path("/Library/Application Support/ClaudeCode/managed-settings.json")
159
+ if sysname == "Windows":
160
+ return Path(r"C:\ProgramData\ClaudeCode\managed-settings.json")
161
+ return Path("/etc/claude-code/managed-settings.json")
162
+
163
+
164
+ def _machine_fingerprint() -> str:
165
+ """SHA-256 of hostname + a stable MAC — same recipe as the gateway."""
166
+ hostname = socket.gethostname()
167
+ mac = uuid.getnode()
168
+ return hashlib.sha256(f"{hostname}:{mac:012x}".encode()).hexdigest()
@@ -0,0 +1,122 @@
1
+ """`agentgov install` — copies client assets to ~/.claude/, prints managed-settings.json.
2
+
3
+ The managed-settings.json is printed to stdout (with the admin-visible path per OS)
4
+ so an administrator can drop it into the OS-managed path. That path is ROOT-owned;
5
+ we do NOT try to write it — the CLI runs as the developer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import platform
13
+ import shutil
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from rich.console import Console
18
+
19
+ console = Console()
20
+
21
+ # Asset source is discovered relative to the installed package (client_assets is
22
+ # a sibling directory of the cli/). In wheel installs we ship it via
23
+ # package data; in local dev we look up-tree.
24
+ ASSET_ROOTS = [
25
+ Path(__file__).resolve().parent.parent.parent.parent / "client_assets",
26
+ Path(sys.prefix) / "share" / "agentgov" / "client_assets",
27
+ ]
28
+
29
+
30
+ def run(
31
+ gateway_url: str = "http://localhost:8000",
32
+ force: bool = False,
33
+ ) -> None:
34
+ """Install hooks + statusline + slash commands into ~/.claude/."""
35
+ src = _find_assets()
36
+ dest = Path.home() / ".claude"
37
+ dest.mkdir(exist_ok=True)
38
+
39
+ hooks_src = src / "hooks"
40
+ hooks_dest = dest / "agentgov-hooks"
41
+ if hooks_dest.exists() and not force:
42
+ console.print(f"[yellow]Skipping[/] hooks — {hooks_dest} exists (use --force)")
43
+ else:
44
+ shutil.copytree(hooks_src, hooks_dest, dirs_exist_ok=True)
45
+ for h in hooks_dest.glob("*.py"):
46
+ h.chmod(0o755)
47
+ console.print(f"[green]Copied[/] hooks → {hooks_dest}")
48
+
49
+ statusline_src = src / "statusline"
50
+ statusline_dest = dest / "agentgov-statusline"
51
+ if statusline_dest.exists() and not force:
52
+ console.print(f"[yellow]Skipping[/] statusline — {statusline_dest} exists")
53
+ else:
54
+ shutil.copytree(statusline_src, statusline_dest, dirs_exist_ok=True)
55
+ for h in statusline_dest.glob("*.sh"):
56
+ h.chmod(0o755)
57
+ console.print(f"[green]Copied[/] statusline → {statusline_dest}")
58
+
59
+ commands_dest = dest / "commands"
60
+ commands_dest.mkdir(exist_ok=True)
61
+ shutil.copy2(src / "commands" / "workitem.md", commands_dest / "workitem.md")
62
+ console.print(f"[green]Copied[/] /workitem slash command → {commands_dest / 'workitem.md'}")
63
+
64
+ settings = _build_managed_settings(gateway_url, hooks_dest, statusline_dest)
65
+ 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))
68
+
69
+
70
+ def _find_assets() -> Path:
71
+ for root in ASSET_ROOTS:
72
+ if root.exists():
73
+ return root
74
+ raise FileNotFoundError(f"client_assets/ not found in any of: {ASSET_ROOTS}")
75
+
76
+
77
+ def _build_managed_settings(
78
+ gateway_url: str, hooks_dest: Path, statusline_dest: Path
79
+ ) -> dict[str, object]:
80
+ return {
81
+ "env": {
82
+ "ANTHROPIC_BASE_URL": gateway_url,
83
+ "AGENTGOV_LOOPBACK_PORT": os.environ.get("AGENTGOV_LOOPBACK_PORT", "8788"),
84
+ },
85
+ # apiKeyHelper disabled — Claude Code must use the agk_ key from the wrapper env.
86
+ "apiKeyHelper": "",
87
+ "hooks": {
88
+ "PreToolUse": [
89
+ {
90
+ "matcher": "Read|Write|Edit|Glob|Grep|Bash|NotebookEdit",
91
+ "hooks": [
92
+ {"type": "command", "command": str(hooks_dest / "pretooluse_pathguard.py")}
93
+ ],
94
+ }
95
+ ],
96
+ "SessionStart": [
97
+ {
98
+ "hooks": [
99
+ {"type": "command", "command": str(hooks_dest / "sessionstart_register.py")}
100
+ ]
101
+ }
102
+ ],
103
+ },
104
+ "statusLine": {
105
+ "type": "command",
106
+ "command": str(statusline_dest / "agentgov_statusline.sh"),
107
+ },
108
+ # Permissions baseline: developers may NOT override the base URL or hooks.
109
+ "permissions": {
110
+ "allow": [],
111
+ "deny": [],
112
+ },
113
+ }
114
+
115
+
116
+ def _managed_settings_path() -> str:
117
+ sysname = platform.system()
118
+ if sysname == "Darwin":
119
+ return "/Library/Application Support/ClaudeCode/managed-settings.json"
120
+ if sysname == "Windows":
121
+ return r"C:\ProgramData\ClaudeCode\managed-settings.json"
122
+ return "/etc/claude-code/managed-settings.json"
@@ -0,0 +1,74 @@
1
+ """`agentgov login` — obtain an AgentGov session token (CLAUDE.md §13.5).
2
+
3
+ v1 implements a DEV-ONLY stub: the CLI accepts a bearer token you paste
4
+ (from the dashboard) and stores it in ~/.agentgov/identity.json (0600).
5
+ The real OAuth device-code flow lands with Auth0 in Phase 5; the file
6
+ layout + tower endpoint are already device-code-shaped so the swap is
7
+ transparent to `wrap`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+
16
+ import httpx
17
+ import typer
18
+ from rich.console import Console
19
+
20
+ console = Console()
21
+
22
+
23
+ def run(
24
+ tower_url: str = typer.Option(
25
+ None, "--tower", envvar="CONTROL_TOWER_URL", help="Control tower URL"
26
+ ),
27
+ token: str = typer.Option(
28
+ None,
29
+ "--token",
30
+ help="Paste your dev-admin token (development mode only). Prompts if omitted.",
31
+ prompt=False,
32
+ ),
33
+ ) -> None:
34
+ """Attach this machine to a user in the AgentGov control tower."""
35
+ tower = tower_url or os.environ.get("CONTROL_TOWER_URL") or "http://localhost:3000"
36
+
37
+ if not token:
38
+ console.print(
39
+ "[bold]Dev-mode login[/] — paste a dev-admin token from the dashboard, or "
40
+ "the literal string 'agentgov-dev-admin' to use the seeded ADMIN.",
41
+ )
42
+ token = typer.prompt("token", hide_input=True).strip()
43
+ else:
44
+ token = token.strip()
45
+
46
+ # Verify the token can list /api/devices (a low-risk authenticated call).
47
+ try:
48
+ r = httpx.get(
49
+ f"{tower.rstrip('/')}/api/devices",
50
+ headers={"Authorization": f"Bearer {token}"},
51
+ timeout=10.0,
52
+ )
53
+ except Exception as e:
54
+ console.print(f"[red]Cannot reach control tower at {tower}: {e}[/]")
55
+ raise typer.Exit(2) from e
56
+ if r.status_code == 401:
57
+ console.print("[red]Token rejected by control tower.[/]")
58
+ raise typer.Exit(2)
59
+ if r.status_code >= 400:
60
+ console.print(f"[red]Unexpected response from control tower: {r.status_code} {r.text}[/]")
61
+ raise typer.Exit(2)
62
+
63
+ # Persist.
64
+ identity_path = Path.home() / ".agentgov" / "identity.json"
65
+ identity_path.parent.mkdir(parents=True, exist_ok=True)
66
+ payload = {
67
+ "tower_url": tower,
68
+ "auth0_token": token,
69
+ # user_id + tenant_id populated on register-device response.
70
+ }
71
+ identity_path.write_text(json.dumps(payload, indent=2))
72
+ identity_path.chmod(0o600)
73
+ console.print(f"[green]✅ Logged in.[/] Identity saved to {identity_path}")
74
+ console.print("Next: [bold]agentgov register-device --name 'my-laptop'[/]")
@@ -0,0 +1,137 @@
1
+ """`agentgov register-device` — first-run device registration (CLAUDE.md §14.1).
2
+
3
+ Generates an X25519 keypair, sends the public key to the control tower's
4
+ /api/devices route, receives back a device_id + gtw_ token, and persists
5
+ them locally so the gateway can boot in device mode.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import socket
15
+ import uuid
16
+ from pathlib import Path
17
+
18
+ import httpx
19
+ import typer
20
+ from rich.console import Console
21
+
22
+ console = Console()
23
+
24
+
25
+ def _machine_fingerprint() -> str:
26
+ """SHA-256 of hostname + best-effort MAC (uuid.getnode())."""
27
+ return hashlib.sha256(f"{socket.gethostname()}:{uuid.getnode():012x}".encode()).hexdigest()
28
+
29
+
30
+ def _generate_x25519_keypair() -> tuple[str, str]:
31
+ """Return (private_b64, public_b64)."""
32
+ from nacl.public import PrivateKey
33
+
34
+ priv = PrivateKey.generate()
35
+ return (
36
+ base64.b64encode(bytes(priv)).decode(),
37
+ base64.b64encode(bytes(priv.public_key)).decode(),
38
+ )
39
+
40
+
41
+ def run(
42
+ name: str = typer.Option(..., "--name", help="Human-readable device name, e.g. 'work-laptop'"),
43
+ upstream_mode: str = typer.Option(
44
+ "oauth_passthrough",
45
+ "--mode",
46
+ help="oauth_passthrough (default) or apikey_vault",
47
+ ),
48
+ tower_url: str = typer.Option(None, "--tower", envvar="CONTROL_TOWER_URL"),
49
+ gateway_runtime_dir: Path = typer.Option(
50
+ None,
51
+ "--runtime-dir",
52
+ envvar="AGENTGOV_GATEWAY_RUNTIME",
53
+ help="Where to write device_key.priv (must match gateway's POLICY_CACHE_PATH parent)",
54
+ ),
55
+ ) -> None:
56
+ if upstream_mode not in ("oauth_passthrough", "apikey_vault"):
57
+ console.print(f"[red]Invalid --mode {upstream_mode}[/]")
58
+ raise typer.Exit(2)
59
+
60
+ tower = tower_url or os.environ.get("CONTROL_TOWER_URL") or "http://localhost:3000"
61
+
62
+ identity_path = Path.home() / ".agentgov" / "identity.json"
63
+ if not identity_path.exists():
64
+ console.print("[red]No AgentGov identity found.[/] Run [bold]agentgov login[/] first.")
65
+ raise typer.Exit(2)
66
+ identity = json.loads(identity_path.read_text())
67
+ token = identity.get("auth0_token") or ""
68
+ if not token:
69
+ console.print("[red]Identity file is missing auth0_token.[/] Re-run `agentgov login`.")
70
+ raise typer.Exit(2)
71
+
72
+ # Generate keypair; write private key to the gateway runtime dir.
73
+ priv_b64, pub_b64 = _generate_x25519_keypair()
74
+ runtime_dir = gateway_runtime_dir or Path.home() / ".agentgov" / "gateway_runtime"
75
+ runtime_dir.mkdir(parents=True, exist_ok=True)
76
+ priv_path = runtime_dir / "device_key.priv"
77
+ priv_path.write_text(priv_b64 + "\n")
78
+ priv_path.chmod(0o600)
79
+ (runtime_dir / "device_key.priv.pub").write_text(pub_b64 + "\n")
80
+
81
+ fingerprint = _machine_fingerprint()
82
+
83
+ console.print(f"[green]Generated device keypair[/] → {priv_path}")
84
+ console.print(f" public: {pub_b64[:16]}... fingerprint: {fingerprint[:16]}...")
85
+
86
+ # POST to control tower.
87
+ try:
88
+ r = httpx.post(
89
+ f"{tower.rstrip('/')}/api/devices",
90
+ headers={
91
+ "Authorization": f"Bearer {token}",
92
+ "Content-Type": "application/json",
93
+ },
94
+ json={
95
+ "name": name,
96
+ "public_key_b64": pub_b64,
97
+ "machine_fingerprint": fingerprint,
98
+ "upstream_mode": upstream_mode,
99
+ },
100
+ timeout=15.0,
101
+ )
102
+ except Exception as e:
103
+ console.print(f"[red]Registration failed: {e}[/]")
104
+ raise typer.Exit(2) from e
105
+ if r.status_code >= 400:
106
+ console.print(f"[red]Control tower rejected registration: {r.status_code} {r.text}[/]")
107
+ raise typer.Exit(2)
108
+
109
+ payload = r.json()
110
+ device = payload.get("device", {})
111
+ gtw_token = payload.get("gateway_service_token") or ""
112
+
113
+ # Persist gateway creds.
114
+ creds_path = Path.home() / ".agentgov" / "gateway_creds.json"
115
+ creds_path.write_text(
116
+ json.dumps(
117
+ {
118
+ "tower_url": tower,
119
+ "device_id": device.get("id"),
120
+ "device_name": device.get("name"),
121
+ "upstream_mode": device.get("upstream_mode"),
122
+ "gateway_service_token": gtw_token,
123
+ },
124
+ indent=2,
125
+ )
126
+ )
127
+ creds_path.chmod(0o600)
128
+
129
+ console.print(
130
+ f"[green]✅ Device registered.[/] id={device.get('id')} mode={device.get('upstream_mode')}"
131
+ )
132
+ console.print(f"gateway_service_token saved to {creds_path}")
133
+ 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}")
@@ -0,0 +1,46 @@
1
+ """`agentgov status` — show current binding + session totals for cwd."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from ..port_resolver import list_active_sessions, resolve_port
10
+
11
+ console = Console()
12
+
13
+
14
+ def run(
15
+ session: str = typer.Option(None, "--session", envvar="AGENTGOV_SESSION"),
16
+ port: int = typer.Option(0, "--port", help="Override port; 0 = resolve"),
17
+ ) -> None:
18
+ resolved_port = port if port > 0 else resolve_port(session)
19
+ if resolved_port is None:
20
+ active = list_active_sessions()
21
+ if not active:
22
+ console.print("[yellow]No active AgentGov session.[/]")
23
+ console.print("Start one: [bold]agentgov wrap claude[/]")
24
+ raise typer.Exit(1)
25
+ console.print("[yellow]Multiple active sessions[/] — pass --session <id>:")
26
+ for sid, prt in active:
27
+ console.print(f" {sid} → :{prt}")
28
+ raise typer.Exit(1)
29
+
30
+ try:
31
+ r = httpx.post(f"http://127.0.0.1:{resolved_port}/status", timeout=5.0)
32
+ r.raise_for_status()
33
+ data = r.json()
34
+ except Exception:
35
+ console.print("[yellow]No active AgentGov session.[/]")
36
+ console.print("Start one: [bold]agentgov wrap claude[/]")
37
+ raise typer.Exit(1) from None
38
+
39
+ wi = data.get("work_item")
40
+ if not wi:
41
+ console.print("[red]📌 UNBOUND[/] — run `/workitem <ref>` in Claude Code.")
42
+ else:
43
+ console.print(
44
+ f"📌 {wi.get('external_id')} — [bold]{wi.get('title')}[/] ({wi.get('item_status')})"
45
+ )
46
+ console.print(f"tokens: {data.get('tokens', 0):,} cost: ${data.get('cost_usd', 0):.2f}")
@@ -0,0 +1,72 @@
1
+ """`agentgov workitem <ref>` — bind current session to a work item.
2
+
3
+ Posts to the loopback admin listener started by `agentgov wrap`. Port is
4
+ resolved dynamically (gap-P0-3): env AGENTGOV_LOOPBACK_PORT set by wrap →
5
+ per-session state file → single-session convenience.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import httpx
11
+ import typer
12
+ from rich.console import Console
13
+
14
+ from ..port_resolver import list_active_sessions, resolve_port
15
+
16
+ console = Console()
17
+
18
+
19
+ def run(
20
+ ref: str = typer.Argument(..., help="JIRA key, Notion URL, or PRJ-XXX id"),
21
+ session: str = typer.Option(
22
+ None,
23
+ "--session",
24
+ envvar="AGENTGOV_SESSION",
25
+ help="Session id (usually inherited from `agentgov wrap`).",
26
+ ),
27
+ port: int = typer.Option(
28
+ 0,
29
+ "--port",
30
+ help="Override port explicitly. 0 = resolve from env/state.",
31
+ ),
32
+ ) -> None:
33
+ """Bind the current Claude Code session to <ref>."""
34
+ resolved_port = port if port > 0 else resolve_port(session)
35
+ if resolved_port is None:
36
+ _no_listener_help()
37
+ raise typer.Exit(2)
38
+
39
+ try:
40
+ r = httpx.post(
41
+ f"http://127.0.0.1:{resolved_port}/workitem",
42
+ json={"ref": ref},
43
+ timeout=15.0,
44
+ )
45
+ except Exception as e:
46
+ console.print(f"[red]No AgentGov loopback listener on :{resolved_port}.[/]")
47
+ _no_listener_help()
48
+ raise typer.Exit(2) from e
49
+
50
+ if r.status_code >= 400:
51
+ console.print(f"[red]bind failed:[/] {r.text}")
52
+ raise typer.Exit(2)
53
+
54
+ data = r.json()
55
+ wi = data.get("work_item") or {}
56
+ console.print(
57
+ f"[green]✅ Bound to[/] {wi.get('external_id')} — [bold]{wi.get('title')}[/] "
58
+ f"({wi.get('item_status')})"
59
+ )
60
+
61
+
62
+ def _no_listener_help() -> None:
63
+ active = list_active_sessions()
64
+ if not active:
65
+ console.print(
66
+ "[red]No active AgentGov session.[/] Start one: [bold]agentgov wrap claude[/]"
67
+ )
68
+ return
69
+ if len(active) > 1:
70
+ console.print("[yellow]Multiple active sessions[/] — pass --session <id>:")
71
+ for sid, prt in active:
72
+ console.print(f" {sid} → :{prt}")
@@ -0,0 +1,271 @@
1
+ """`agentgov wrap claude` — the launcher.
2
+
3
+ 1. Resolve repo root + normalized `origin`. Fail if not a git repo.
4
+ 2. Generate a UUIDv7 session id.
5
+ 3. Start the loopback admin listener on 127.0.0.1:8788 in a thread.
6
+ 4. Register the session with the gateway admin endpoint.
7
+ 5. Exec `claude` with the env described in CLAUDE.md §R-CLI2.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ import threading
16
+ import time
17
+ from pathlib import Path
18
+
19
+ import httpx
20
+ import typer
21
+ from rich.console import Console
22
+
23
+ from ..loopback import LoopbackListener
24
+
25
+ console = Console()
26
+
27
+
28
+ def run(
29
+ cmd: list[str] = typer.Argument(None, help="Command to launch (default: claude)"),
30
+ gateway_url: str = typer.Option(None, "--gateway", envvar="AGENTGOV_GATEWAY_URL"),
31
+ # port=0 → OS-assigned free port. The listener writes the bound port to
32
+ # ~/.agentgov/state/<session>.port so concurrent wraps don't collide
33
+ # (gap-P0-3). Set AGENTGOV_LOOPBACK_PORT to a fixed port only for tests.
34
+ loopback_port: int = typer.Option(0, "--loopback-port", envvar="AGENTGOV_LOOPBACK_PORT"),
35
+ credentials_path: Path = typer.Option(
36
+ Path.home() / ".agentgov" / "credentials",
37
+ "--credentials",
38
+ help="Path to the file storing the agk_ governance key.",
39
+ ),
40
+ resume_session: str = typer.Option(
41
+ None,
42
+ "--resume-session",
43
+ help=(
44
+ "Resume a previous AgentGov session id instead of generating a new one. "
45
+ "The gateway rehydrates the previous work-item binding (§13/gap-P1-7). "
46
+ "Use this when Claude Code's own --resume is bringing back an old conversation."
47
+ ),
48
+ ),
49
+ ) -> None:
50
+ """Wrap `claude` (or another command) in a governed session.
51
+
52
+ Session-resume semantics (gap-P1-7):
53
+ - Default: generate a NEW session id. Fresh session, no binding, first
54
+ prompt triggers NO_WORK_ITEM until /workitem runs.
55
+ - --resume-session <id>: re-use the given id. The gateway's boot
56
+ rehydration (gap-P0-2) already loads open work_item_bindings from
57
+ Supabase; a resumed session picks up its previous binding
58
+ transparently. If the binding is closed (unbound), you'll hit
59
+ NO_WORK_ITEM as usual.
60
+ """
61
+ repo_root, remote = _resolve_repo()
62
+ gov_key = _read_credentials(credentials_path)
63
+ if resume_session:
64
+ # Basic sanity: UUID-shape or reject.
65
+ import re
66
+
67
+ if not re.fullmatch(
68
+ r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
69
+ resume_session,
70
+ ):
71
+ console.print(f"[red]--resume-session must be a UUID; got {resume_session!r}[/]")
72
+ raise typer.Exit(2)
73
+ session_id = resume_session
74
+ console.print(f"[cyan]Resuming session[/] {session_id}")
75
+ else:
76
+ session_id = _uuid7_hex()
77
+
78
+ target = list(cmd) if cmd else ["claude"]
79
+ gateway = gateway_url or "http://localhost:8000"
80
+
81
+ # Register the session with the gateway. Also send hook hashes so the
82
+ # gateway can compare against expected values from the signed snapshot
83
+ # (gap-P0-4 hook integrity check). Mismatch = warning + audit row;
84
+ # depending on tenant `strict_hook_verification`, may refuse the session.
85
+ hook_hashes = _hash_hooks_best_effort()
86
+ _register_session(gateway, session_id, remote, repo_root, hook_hashes)
87
+
88
+ # Start loopback listener for `/workitem`, `status`, etc.
89
+ listener = LoopbackListener(port=loopback_port, gateway_url=gateway, session_id=session_id)
90
+ t = threading.Thread(target=listener.serve_forever, daemon=True)
91
+ t.start()
92
+ # Wait briefly for the listener to bind + write its port file (usually ~ms).
93
+ _wait_for_port(listener)
94
+
95
+ env = os.environ.copy()
96
+ # Export the ACTUAL bound port so slash commands + `agentgov workitem`
97
+ # find this session's listener even under concurrent wraps.
98
+ if listener.port is not None:
99
+ env["AGENTGOV_LOOPBACK_PORT"] = str(listener.port)
100
+ # In personal (oauth_passthrough) mode gov_key is "" and we leave whatever
101
+ # ANTHROPIC_API_KEY (or Anthropic OAuth session) was already set intact.
102
+ if gov_key:
103
+ env["ANTHROPIC_API_KEY"] = gov_key
104
+ env["ANTHROPIC_BASE_URL"] = gateway
105
+ env["AGENTGOV_SESSION"] = session_id
106
+ env["AGENTGOV_REPO"] = remote
107
+ env["AGENTGOV_REPO_ROOT"] = str(repo_root)
108
+ # Claude Code forwards ANTHROPIC_CUSTOM_HEADERS as JSON.
109
+ env["ANTHROPIC_CUSTOM_HEADERS"] = _custom_headers_json(session_id, remote, repo_root)
110
+
111
+ console.print(f"[green]AgentGov[/] wrapping {' '.join(target)}")
112
+ console.print(f" session: {session_id}")
113
+ console.print(f" repo: {remote}")
114
+ console.print(f" gateway: {gateway}")
115
+
116
+ # Launch — subprocess.call to keep pipes correct + return exit code.
117
+ try:
118
+ rc = subprocess.call(target, env=env, cwd=str(repo_root))
119
+ finally:
120
+ _end_session(gateway, session_id)
121
+ sys.exit(rc)
122
+
123
+
124
+ def _wait_for_port(listener: LoopbackListener, timeout_sec: float = 2.0) -> None:
125
+ """Block until listener.port is set (i.e. socketserver bound) or timeout."""
126
+ import time as _time
127
+
128
+ deadline = _time.time() + timeout_sec
129
+ while listener.port is None and _time.time() < deadline:
130
+ _time.sleep(0.01)
131
+
132
+
133
+ def _resolve_repo() -> tuple[Path, str]:
134
+ try:
135
+ root = subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip()
136
+ except subprocess.CalledProcessError as e:
137
+ console.print(
138
+ "[red]Not a git repository.[/] Run inside a repo that has been granted to you."
139
+ )
140
+ raise typer.Exit(2) from e
141
+ try:
142
+ remote = subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip()
143
+ except subprocess.CalledProcessError as e:
144
+ console.print("[red]No `origin` remote configured on this repository.[/]")
145
+ raise typer.Exit(2) from e
146
+ return Path(root), remote
147
+
148
+
149
+ def _read_credentials(path: Path) -> str:
150
+ """Return the `agk_` governance key if present; empty string in personal mode.
151
+
152
+ Personal-mode devices (oauth_passthrough) don't need an agk_ key — Claude
153
+ Code uses its own OAuth. In that case we return "" and Claude Code sends
154
+ whatever `ANTHROPIC_API_KEY` was already set (or its OAuth bearer via
155
+ ANTHROPIC_BASE_URL). The gateway will identify the user by device.
156
+ """
157
+ if not path.exists():
158
+ # OK in personal mode. Warn once and continue.
159
+ console.print(
160
+ f"[yellow]No agk_ key at {path}[/]. Assuming personal (oauth_passthrough) mode."
161
+ )
162
+ return ""
163
+ if path.stat().st_mode & 0o077:
164
+ console.print(
165
+ f"[red]Credentials at {path} have loose permissions.[/] Run: chmod 600 {path}"
166
+ )
167
+ raise typer.Exit(2)
168
+ return path.read_text().strip()
169
+
170
+
171
+ def _uuid7_hex() -> str:
172
+ # Standard-library uuid doesn't provide v7 pre-3.13. We use time-based prefix
173
+ # plus urandom suffix for monotonicity; not RFC-perfect but sufficient here.
174
+ import os as _os
175
+ import uuid as _uuid
176
+
177
+ ms = int(time.time() * 1000)
178
+ prefix = ms.to_bytes(6, "big")
179
+ rand = _os.urandom(10)
180
+ b = bytearray(prefix + rand)
181
+ # version 7 + RFC 4122 variant bits
182
+ b[6] = (b[6] & 0x0F) | 0x70
183
+ b[8] = (b[8] & 0x3F) | 0x80
184
+ return str(_uuid.UUID(bytes=bytes(b)))
185
+
186
+
187
+ def _register_session(
188
+ gateway: str,
189
+ session_id: str,
190
+ remote: str,
191
+ repo_root: Path,
192
+ hook_hashes: dict[str, str] | None = None,
193
+ ) -> None:
194
+ try:
195
+ r = httpx.post(
196
+ f"{gateway.rstrip('/')}/admin/session/register",
197
+ headers={"content-type": "application/json"},
198
+ json={
199
+ "session_id": session_id,
200
+ "repo": remote,
201
+ "repo_root": str(repo_root),
202
+ "hook_hashes": hook_hashes or {},
203
+ },
204
+ timeout=5.0,
205
+ )
206
+ r.raise_for_status()
207
+ payload = r.json()
208
+ # Gateway may report hook mismatch here — surface immediately to the dev.
209
+ warnings = payload.get("warnings") or []
210
+ for w in warnings:
211
+ console.print(f"[yellow]⚠ {w}[/]")
212
+ if payload.get("state") == "REFUSED":
213
+ console.print(
214
+ f"[red]Gateway refused this session:[/] {payload.get('reason', 'unknown')}"
215
+ )
216
+ raise typer.Exit(2)
217
+ except typer.Exit:
218
+ raise
219
+ except Exception as e:
220
+ console.print(
221
+ f"[yellow]Warning:[/] could not register session with gateway ({e}). Continuing."
222
+ )
223
+
224
+
225
+ def _hash_hooks_best_effort() -> dict[str, str]:
226
+ """Return {filename: hex_sha256} for each installed AgentGov hook.
227
+
228
+ Best-effort: missing hooks return an empty dict. The gateway decides
229
+ whether that's a failure (`strict_hook_verification`) or just a warning.
230
+ """
231
+ import hashlib
232
+
233
+ hooks_dir = Path.home() / ".claude" / "agentgov-hooks"
234
+ out: dict[str, str] = {}
235
+ if not hooks_dir.exists():
236
+ return out
237
+ for p in hooks_dir.glob("*.py"):
238
+ try:
239
+ h = hashlib.sha256()
240
+ with p.open("rb") as f:
241
+ for chunk in iter(lambda: f.read(65536), b""):
242
+ h.update(chunk)
243
+ out[p.name] = h.hexdigest()
244
+ except OSError:
245
+ continue
246
+ return out
247
+
248
+
249
+ def _end_session(gateway: str, session_id: str) -> None:
250
+ import contextlib
251
+
252
+ # Best-effort: if the gateway is down we don't want to error on shutdown.
253
+ with contextlib.suppress(Exception):
254
+ httpx.post(
255
+ f"{gateway.rstrip('/')}/admin/session/register",
256
+ json={"session_id": session_id, "action": "end"},
257
+ timeout=3.0,
258
+ )
259
+
260
+
261
+ def _custom_headers_json(session_id: str, remote: str, repo_root: Path) -> str:
262
+ # Claude Code forwards this JSON as request headers.
263
+ import json as _json
264
+
265
+ return _json.dumps(
266
+ {
267
+ "X-AgentGov-Session": session_id,
268
+ "X-AgentGov-Repo": remote,
269
+ "X-AgentGov-Repo-Root": str(repo_root),
270
+ }
271
+ )
@@ -0,0 +1,122 @@
1
+ """127.0.0.1:8788 loopback admin listener (§R-CLI2 step 2).
2
+
3
+ Backing the `/workitem` slash command + a way to query current binding.
4
+ Talks to the gateway's admin endpoints. Every request is validated as
5
+ local by binding to 127.0.0.1 (not 0.0.0.0).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import http.server
12
+ import json
13
+ import logging
14
+ import socketserver
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+
23
+ class LoopbackListener:
24
+ def __init__(self, port: int, gateway_url: str, session_id: str) -> None:
25
+ # port=0 → OS-assigned free port. After serve_forever() binds, the real
26
+ # port is available on self.port. We write it to a per-session state
27
+ # file so `agentgov workitem` can find the right listener even when
28
+ # multiple wraps run concurrently (gap-P0-3).
29
+ self._requested_port = port
30
+ self._port: int | None = None
31
+ self._gateway_url = gateway_url.rstrip("/")
32
+ self._session_id = session_id
33
+ self._state_dir = self._prep_state_dir()
34
+
35
+ @property
36
+ def port(self) -> int | None:
37
+ """The port the listener is actually bound to. None until serve_forever()."""
38
+ return self._port
39
+
40
+ def serve_forever(self) -> None:
41
+ parent = self # closure
42
+
43
+ class Handler(http.server.BaseHTTPRequestHandler):
44
+ def log_message(self, fmt: str, *args: Any) -> None:
45
+ # Route through logging instead of stderr spam.
46
+ log.debug("loopback: " + fmt, *args)
47
+
48
+ def _read_json(self) -> dict[str, Any]:
49
+ length = int(self.headers.get("content-length") or 0)
50
+ raw = self.rfile.read(length) if length > 0 else b"{}"
51
+ try:
52
+ parsed: dict[str, Any] = json.loads(raw.decode("utf-8"))
53
+ return parsed
54
+ except Exception:
55
+ return {}
56
+
57
+ def _send_json(self, status: int, body: dict[str, Any]) -> None:
58
+ data = json.dumps(body).encode()
59
+ self.send_response(status)
60
+ self.send_header("Content-Type", "application/json")
61
+ self.send_header("Content-Length", str(len(data)))
62
+ self.end_headers()
63
+ self.wfile.write(data)
64
+
65
+ def do_POST(self) -> None:
66
+ # ALL admin routes require the request comes from a loopback address.
67
+ if self.client_address[0] not in ("127.0.0.1", "::1"):
68
+ self._send_json(403, {"error": "loopback_only"})
69
+ return
70
+
71
+ path = self.path.split("?")[0]
72
+ if path == "/workitem":
73
+ self._handle_workitem(self._read_json())
74
+ elif path == "/status":
75
+ self._handle_status()
76
+ else:
77
+ self._send_json(404, {"error": "unknown_route"})
78
+
79
+ def _handle_workitem(self, payload: dict[str, Any]) -> None:
80
+ ref = str(payload.get("ref") or "")
81
+ if not ref:
82
+ self._send_json(400, {"error": "missing_ref"})
83
+ return
84
+ # Ask the gateway (which relays to control-tower) to resolve+bind.
85
+ try:
86
+ r = httpx.post(
87
+ f"{parent._gateway_url}/admin/session/bind",
88
+ json={"session_id": parent._session_id, "work_item_ref": ref},
89
+ timeout=15.0,
90
+ )
91
+ r.raise_for_status()
92
+ self._send_json(200, r.json())
93
+ except Exception as e:
94
+ self._send_json(502, {"error": "bind_failed", "detail": str(e)})
95
+
96
+ def _handle_status(self) -> None:
97
+ # Serve from the state file the gateway writes to via SSE-ack path,
98
+ # or fall back to "unknown".
99
+ statefile = parent._state_dir / f"{parent._session_id}.json"
100
+ if statefile.exists():
101
+ self._send_json(200, json.loads(statefile.read_text()))
102
+ else:
103
+ self._send_json(200, {"work_item": None, "tokens": 0, "cost_usd": 0})
104
+
105
+ with socketserver.TCPServer(("127.0.0.1", self._requested_port), Handler) as httpd:
106
+ # Capture the actual bound port (may differ from _requested_port
107
+ # when caller passed 0). Write a session-scoped state file so
108
+ # `agentgov workitem` can look up the port by session id.
109
+ self._port = httpd.server_address[1]
110
+ port_file = self._state_dir / f"{self._session_id}.port"
111
+ port_file.write_text(str(self._port))
112
+ try:
113
+ httpd.serve_forever()
114
+ finally:
115
+ # Best-effort cleanup on shutdown.
116
+ with contextlib.suppress(FileNotFoundError):
117
+ port_file.unlink()
118
+
119
+ def _prep_state_dir(self) -> Path:
120
+ p = Path.home() / ".agentgov" / "state"
121
+ p.mkdir(parents=True, exist_ok=True)
122
+ return p
agentgov_cli/main.py ADDED
@@ -0,0 +1,32 @@
1
+ """CLI root — dispatches to commands/*."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from .commands import doctor, install, login, register_device, status, workitem, wrap
8
+
9
+ app = typer.Typer(
10
+ name="agentgov",
11
+ help="AgentGov CLI. See CLAUDE.md §6.3 + §13/§14.",
12
+ no_args_is_help=True,
13
+ )
14
+ app.command("login", help="Attach this machine to a user in the control tower (§13.5).")(login.run)
15
+ app.command(
16
+ "register-device",
17
+ help="Generate device X25519 keypair + register with control tower (§14.1).",
18
+ )(register_device.run)
19
+ app.command(
20
+ "install", help="Install client assets into ~/.claude/ and print managed-settings.json."
21
+ )(install.run)
22
+ app.command("wrap", help="Wrap `claude` — start a governed Claude Code session.")(wrap.run)
23
+ app.command("workitem", help="Bind the current session to a work item (JIRA/Notion/PRJ-...).")(
24
+ workitem.run
25
+ )
26
+ app.command("status", help="Show current binding + session totals for cwd.")(status.run)
27
+ app.command(
28
+ "doctor", help="Diagnose gateway reachability, hooks, managed settings, and safety rules."
29
+ )(doctor.run)
30
+
31
+ if __name__ == "__main__": # pragma: no cover
32
+ app()
@@ -0,0 +1,68 @@
1
+ """Resolve the loopback port for a session (gap-P0-3).
2
+
3
+ Two lookup paths, tried in order:
4
+
5
+ 1. AGENTGOV_LOOPBACK_PORT env — set by `agentgov wrap` in the child's
6
+ environment. Slash commands and same-terminal invocations get this
7
+ automatically.
8
+
9
+ 2. `~/.agentgov/state/<session>.port` file, written by the LoopbackListener
10
+ when it binds. Useful for a second terminal that isn't a descendant of
11
+ `wrap` (e.g. developer runs `agentgov status` from a separate tmux pane).
12
+
13
+ If neither works but exactly ONE port file exists in the state dir, use that
14
+ (single-session case). Multiple port files with no env hint → ambiguous, we
15
+ return None and the caller prints "which session?" guidance.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from pathlib import Path
22
+
23
+
24
+ def resolve_port(session_id: str | None = None) -> int | None:
25
+ # 1) env
26
+ p_env = os.environ.get("AGENTGOV_LOOPBACK_PORT", "").strip()
27
+ if p_env:
28
+ try:
29
+ n = int(p_env)
30
+ if n > 0:
31
+ return n
32
+ except ValueError:
33
+ pass
34
+
35
+ # 2) session id → port file
36
+ state_dir = Path.home() / ".agentgov" / "state"
37
+ if session_id:
38
+ f = state_dir / f"{session_id}.port"
39
+ if f.exists():
40
+ try:
41
+ return int(f.read_text().strip())
42
+ except (OSError, ValueError):
43
+ pass
44
+
45
+ # 3) single-session convenience
46
+ if state_dir.exists():
47
+ port_files = list(state_dir.glob("*.port"))
48
+ if len(port_files) == 1:
49
+ try:
50
+ return int(port_files[0].read_text().strip())
51
+ except (OSError, ValueError):
52
+ pass
53
+
54
+ return None
55
+
56
+
57
+ def list_active_sessions() -> list[tuple[str, int]]:
58
+ """Return [(session_id, port), ...] for all active listeners."""
59
+ state_dir = Path.home() / ".agentgov" / "state"
60
+ if not state_dir.exists():
61
+ return []
62
+ out: list[tuple[str, int]] = []
63
+ for f in state_dir.glob("*.port"):
64
+ try:
65
+ out.append((f.stem, int(f.read_text().strip())))
66
+ except (OSError, ValueError):
67
+ continue
68
+ return out
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentgov-cli
3
+ Version: 0.1.0
4
+ Summary: AgentGov CLI — wrap Claude Code, bind work items, check gateway health.
5
+ Author: AgentGov Contributors
6
+ License: Apache-2.0
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: httpx>=0.27.2
9
+ Requires-Dist: pydantic>=2.9.0
10
+ Requires-Dist: pynacl>=1.5.0
11
+ Requires-Dist: rich>=13.9.0
12
+ Requires-Dist: typer>=0.12.5
13
+ Requires-Dist: uuid7>=0.1.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: mypy>=1.11.2; extra == 'dev'
16
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
17
+ Requires-Dist: pytest>=8.3.0; extra == 'dev'
18
+ Requires-Dist: ruff>=0.6.9; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # agentgov
22
+
23
+ The developer-laptop CLI for AgentGov.
24
+
25
+ Wraps Claude Code to bind sessions to work items and enforce governance from a signed policy snapshot. Runs alongside `agentgov-gateway` (the local proxy) — this CLI handles login, device registration, session wrapping, work-item binding, and diagnostics.
26
+
27
+ Part of the AgentGov project — see [github.com/deepakahu/agentGov](https://github.com/deepakahu/agentGov) for the full monorepo.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install agentgov agentgov-gateway
33
+ ```
34
+
35
+ ## First-time setup
36
+
37
+ ```bash
38
+ # Attach this machine to your AgentGov control tower
39
+ agentgov login --tower https://your-agentgov.vercel.app
40
+
41
+ # Register this device (generates an X25519 keypair, gets a gtw_ token)
42
+ agentgov register-device --name "$(hostname)"
43
+
44
+ # Drop Claude Code hooks + statusline + slash command into ~/.claude/
45
+ agentgov install
46
+
47
+ # Start the gateway (background)
48
+ agentgov-gateway &
49
+ ```
50
+
51
+ ## Every day
52
+
53
+ ```bash
54
+ # Run Claude Code through the governed gateway
55
+ agentgov wrap claude
56
+
57
+ # Bind the current session to a work item
58
+ # (or use the /workitem slash command inside Claude Code)
59
+ agentgov workitem PROJ-1234
60
+ ```
61
+
62
+ ## Diagnose
63
+
64
+ ```bash
65
+ agentgov status # what's my current binding, tokens, quota
66
+ agentgov doctor # is everything wired correctly
67
+ ```
68
+
69
+ ## Commands
70
+
71
+ - `agentgov login` — attach this machine to a user in the control tower
72
+ - `agentgov register-device` — generate device keypair + register with tower
73
+ - `agentgov install` — drop client assets into `~/.claude/`
74
+ - `agentgov wrap <cmd>` — wrap `claude` (or another command) in a governed session
75
+ - `agentgov workitem <ref>` — bind the current session to a work item
76
+ - `agentgov status` — show current binding + totals
77
+ - `agentgov doctor` — diagnose the installation
78
+
79
+ ## License
80
+
81
+ Apache-2.0
@@ -0,0 +1,16 @@
1
+ agentgov_cli/__init__.py,sha256=MhFkd8l9GX1itDFyNS9mnohokruQWpeiq5nfHfxknYc,104
2
+ agentgov_cli/loopback.py,sha256=NP7udIRg5yDnItDo_fJ8NgCH66TPUqHk5NgtUVqzbKA,5095
3
+ agentgov_cli/main.py,sha256=YmcBR2KMJM_btA0YABhqxUHgaeHDWrHKI7glL3tkq1k,1152
4
+ agentgov_cli/port_resolver.py,sha256=E6aKN73ABEQjN7h0OllkAh8azWDFp6-rPOGsRM8WPU0,2132
5
+ agentgov_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ agentgov_cli/commands/doctor.py,sha256=qHwb5lIzpiSiXQUKWZ2gB7iXoWvQ03M7bDovbZuFsuQ,5865
7
+ agentgov_cli/commands/install.py,sha256=Rr4TEC8vdlOLo64S5xy9k9wm9wt_hcv_jMMJ3V4mGTM,4343
8
+ agentgov_cli/commands/login.py,sha256=lLm7RyD7CBUWmVxhuMcF7JMq5r2p-1MAVzs_JoJE1mY,2573
9
+ agentgov_cli/commands/register_device.py,sha256=hNYMQv7hnxL4MYWOGwrYDPrZL43ew8BGHm595xJBp2E,4794
10
+ agentgov_cli/commands/status.py,sha256=Rhvdn1bCere7mqNvkYj85scbLZU_ehV6U-3C5VI3QGk,1665
11
+ agentgov_cli/commands/workitem.py,sha256=tuY-ExRNoWiYQAVLP1QP-K1rZqlLroJ6K3pVGK8ewSs,2163
12
+ agentgov_cli/commands/wrap.py,sha256=x_XvN6XtKFZ-jZA3pTEFGHqA08BapLh1aI92LdisthU,10021
13
+ agentgov_cli-0.1.0.dist-info/METADATA,sha256=cUXrkb7-Vxhk7uK3U__BFuBUMc2E7RRVpqR2XtbYg8U,2419
14
+ agentgov_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
15
+ agentgov_cli-0.1.0.dist-info/entry_points.txt,sha256=rqHQ0f65XZ2xMhubArHj5tfIqYApSGUwKM2se52U23Q,51
16
+ agentgov_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agentgov = agentgov_cli.main:app