agentgov-cli 0.1.0__tar.gz → 0.1.1__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.1
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
@@ -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
+ }
@@ -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.0"
5
+ version = "0.1.1"
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" }
@@ -37,6 +37,22 @@ build-backend = "hatchling.build"
37
37
  [tool.hatch.build.targets.wheel]
38
38
  packages = ["agentgov_cli"]
39
39
 
40
+ # Ship client_assets/ (sibling dir of cli/) as shared data. Installs to
41
+ # <venv>/share/agentgov/client_assets/, which is exactly where
42
+ # commands/install.py:ASSET_ROOTS[1] looks. Without this, a pip-installed CLI
43
+ # can't find hooks/statusline/slash-command and `agentgov install` fails with
44
+ # FileNotFoundError.
45
+ [tool.hatch.build.targets.wheel.shared-data]
46
+ "../client_assets" = "share/agentgov/client_assets"
47
+
48
+ [tool.hatch.build.targets.sdist]
49
+ include = [
50
+ "agentgov_cli/**",
51
+ "../client_assets/**",
52
+ "README.md",
53
+ "pyproject.toml",
54
+ ]
55
+
40
56
  [tool.ruff]
41
57
  line-length = 100
42
58
  target-version = "py310"
@@ -1,74 +0,0 @@
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'[/]")
@@ -1,84 +0,0 @@
1
- """PreToolUse path-guard hook tests (§R-CC1 acceptance).
2
-
3
- The hook is a subprocess boundary; we invoke it as a subprocess with crafted
4
- stdin (like Claude Code does) and assert exit codes.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import json
10
- import os
11
- import subprocess
12
- import sys
13
- from pathlib import Path
14
-
15
- HOOK = (
16
- Path(__file__).resolve().parent.parent.parent
17
- / "client_assets"
18
- / "hooks"
19
- / "pretooluse_pathguard.py"
20
- )
21
-
22
-
23
- def _run(payload: dict, repo_root: Path) -> tuple[int, str]:
24
- env = os.environ.copy()
25
- env["AGENTGOV_REPO_ROOT"] = str(repo_root)
26
- p = subprocess.run(
27
- [sys.executable, str(HOOK)],
28
- input=json.dumps(payload).encode(),
29
- capture_output=True,
30
- env=env,
31
- timeout=5,
32
- )
33
- return p.returncode, p.stderr.decode()
34
-
35
-
36
- def test_allows_file_inside_repo(tmp_path: Path) -> None:
37
- (tmp_path / "src").mkdir()
38
- target = tmp_path / "src" / "hello.py"
39
- target.write_text("# ok")
40
- rc, err = _run({"tool_name": "Read", "tool_input": {"file_path": str(target)}}, tmp_path)
41
- assert rc == 0, err
42
-
43
-
44
- def test_blocks_path_traversal(tmp_path: Path) -> None:
45
- rc, err = _run({"tool_name": "Read", "tool_input": {"file_path": "../../etc/passwd"}}, tmp_path)
46
- assert rc == 2
47
- assert "outside" in err
48
-
49
-
50
- def test_blocks_absolute_escape(tmp_path: Path) -> None:
51
- rc, err = _run({"tool_name": "Read", "tool_input": {"file_path": "/etc/passwd"}}, tmp_path)
52
- assert rc == 2
53
- assert "outside" in err
54
-
55
-
56
- def test_bash_cd_root_blocked(tmp_path: Path) -> None:
57
- rc, _ = _run({"tool_name": "Bash", "tool_input": {"command": "cd / && ls"}}, tmp_path)
58
- assert rc == 2
59
-
60
-
61
- def test_bash_relative_command_ok(tmp_path: Path) -> None:
62
- rc, _err = _run(
63
- {"tool_name": "Bash", "tool_input": {"command": "pytest tests/"}},
64
- tmp_path,
65
- )
66
- assert rc == 0
67
-
68
-
69
- def test_tmp_claude_scratch_allowed(tmp_path: Path) -> None:
70
- rc, _err = _run(
71
- {"tool_name": "Read", "tool_input": {"file_path": "/tmp/claude-scratch/x.txt"}},
72
- tmp_path,
73
- )
74
- assert rc == 0
75
-
76
-
77
- def test_fail_closed_on_bad_input(tmp_path: Path) -> None:
78
- # Missing tool_input entirely — hook should not accept.
79
- # Not-a-dict tool_input:
80
- rc, _err = _run(
81
- {"tool_name": "Read", "tool_input": "just a string"}, # type: ignore[dict-item]
82
- tmp_path,
83
- )
84
- assert rc == 2