agentgov-cli 0.1.5__tar.gz → 0.1.6__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.
Files changed (26) hide show
  1. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/PKG-INFO +1 -1
  2. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/client_assets/hooks/userpromptsubmit_workitem.py +80 -15
  3. agentgov_cli-0.1.6/agentgov_cli/commands/uninstall.py +149 -0
  4. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/main.py +15 -1
  5. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/pyproject.toml +5 -1
  6. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/.gitignore +0 -0
  7. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/README.md +0 -0
  8. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/__init__.py +0 -0
  9. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/client_assets/commands/workitem.md +0 -0
  10. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/client_assets/hooks/pretooluse_pathguard.py +0 -0
  11. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/client_assets/hooks/sessionstart_register.py +0 -0
  12. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/client_assets/managed-settings.json.tmpl +0 -0
  13. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/client_assets/statusline/agentgov_statusline.sh +0 -0
  14. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/commands/__init__.py +0 -0
  15. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/commands/doctor.py +0 -0
  16. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/commands/gateway.py +0 -0
  17. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/commands/install.py +0 -0
  18. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/commands/login.py +0 -0
  19. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/commands/register_device.py +0 -0
  20. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/commands/status.py +0 -0
  21. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/commands/workitem.py +0 -0
  22. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/commands/wrap.py +0 -0
  23. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/gateway_process.py +0 -0
  24. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/loopback.py +0 -0
  25. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/agentgov_cli/port_resolver.py +0 -0
  26. {agentgov_cli-0.1.5 → agentgov_cli-0.1.6}/hatch_build.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentgov-cli
3
- Version: 0.1.5
3
+ Version: 0.1.6
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
@@ -29,8 +29,10 @@ from __future__ import annotations
29
29
 
30
30
  import json
31
31
  import os
32
+ import subprocess
32
33
  import sys
33
34
  import urllib.request
35
+ from typing import NoReturn
34
36
 
35
37
  GATEWAY_URL = os.environ.get("AGENTGOV_GATEWAY_URL", "http://127.0.0.1:8000")
36
38
  TIMEOUT_SEC = 4.0
@@ -40,15 +42,75 @@ TIMEOUT_SEC = 4.0
40
42
  ESCAPE_PREFIXES = ("/workitem", "/agentgov", "/help", "/status", "/doctor")
41
43
 
42
44
 
43
- def _allow() -> None:
45
+ def _allow() -> NoReturn:
44
46
  sys.exit(0)
45
47
 
46
48
 
47
- def _block(message: str) -> None:
49
+ def _block(message: str) -> NoReturn:
48
50
  sys.stderr.write(message)
49
51
  sys.exit(2)
50
52
 
51
53
 
54
+ def _binding(session_id: str) -> dict | None:
55
+ """Ask the gateway about this session. None = gateway unreachable."""
56
+ try:
57
+ with urllib.request.urlopen( # noqa: S310 — loopback only
58
+ f"{GATEWAY_URL.rstrip('/')}/admin/session/{session_id}/binding",
59
+ timeout=TIMEOUT_SEC,
60
+ ) as resp:
61
+ result = json.loads(resp.read() or b"{}")
62
+ return result if isinstance(result, dict) else {}
63
+ except Exception:
64
+ return None
65
+
66
+
67
+ def _register(session_id: str, cwd: str) -> bool:
68
+ """Re-register a session the gateway has forgotten (e.g. after a restart).
69
+
70
+ The gateway holds sessions in memory, so any restart drops every editor
71
+ window that is already open. Re-registering here means the developer never
72
+ has to know that happened.
73
+ """
74
+ if not cwd:
75
+ return False
76
+ try:
77
+ repo_root = subprocess.run(
78
+ ["git", "rev-parse", "--show-toplevel"],
79
+ cwd=cwd,
80
+ capture_output=True,
81
+ text=True,
82
+ timeout=5,
83
+ check=False,
84
+ )
85
+ if repo_root.returncode != 0:
86
+ return False
87
+ root = repo_root.stdout.strip()
88
+ remote = subprocess.run(
89
+ ["git", "remote", "get-url", "origin"],
90
+ cwd=cwd,
91
+ capture_output=True,
92
+ text=True,
93
+ timeout=5,
94
+ check=False,
95
+ )
96
+ body = json.dumps(
97
+ {
98
+ "session_id": session_id,
99
+ "repo": remote.stdout.strip() if remote.returncode == 0 else "",
100
+ "repo_root": root,
101
+ }
102
+ ).encode()
103
+ req = urllib.request.Request(
104
+ f"{GATEWAY_URL.rstrip('/')}/admin/session/register",
105
+ data=body,
106
+ headers={"Content-Type": "application/json"},
107
+ )
108
+ urllib.request.urlopen(req, timeout=TIMEOUT_SEC).read() # noqa: S310 — loopback only
109
+ return True
110
+ except Exception:
111
+ return False
112
+
113
+
52
114
  def main() -> None:
53
115
  try:
54
116
  payload = json.load(sys.stdin)
@@ -57,6 +119,7 @@ def main() -> None:
57
119
 
58
120
  session_id = str(payload.get("session_id") or "")
59
121
  user_input = str(payload.get("user_input") or "").strip()
122
+ cwd = str(payload.get("cwd") or "")
60
123
 
61
124
  if not session_id:
62
125
  _allow()
@@ -65,13 +128,8 @@ def main() -> None:
65
128
  if user_input.startswith(ESCAPE_PREFIXES):
66
129
  _allow()
67
130
 
68
- try:
69
- with urllib.request.urlopen( # noqa: S310 — loopback only
70
- f"{GATEWAY_URL.rstrip('/')}/admin/session/{session_id}/binding",
71
- timeout=TIMEOUT_SEC,
72
- ) as resp:
73
- data = json.loads(resp.read() or b"{}")
74
- except Exception:
131
+ data = _binding(session_id)
132
+ if data is None:
75
133
  # Gateway down or unreachable. Do NOT block here: Claude Code cannot
76
134
  # reach the model either way, and blocking would bury the real error
77
135
  # ("connection refused") under a confusing governance message.
@@ -81,12 +139,19 @@ def main() -> None:
81
139
  _allow()
82
140
 
83
141
  if not data.get("known"):
84
- _block(
85
- "AgentGov: this session is not registered with the gateway, so it cannot "
86
- "be governed.\n"
87
- "Start a new Claude Code session so the SessionStart hook can run. If this "
88
- "persists, run: agentgov doctor\n"
89
- )
142
+ # The gateway does not recognise this session. This is almost always
143
+ # TRANSIENT, not an attack: the gateway keeps sessions in memory, so a
144
+ # restart or reinstall forgets every editor window that is already open.
145
+ #
146
+ # Blocking here was a serious mistake. It bricked every open Claude Code
147
+ # window after a gateway restart, with a message telling the developer to
148
+ # start a new session — while the API plane would have enforced anyway.
149
+ # Self-heal instead: re-register, then re-check.
150
+ if not _register(session_id, cwd):
151
+ _allow() # Could not re-register — let the gateway be the judge.
152
+ data = _binding(session_id)
153
+ if data is None or data.get("bound"):
154
+ _allow()
90
155
 
91
156
  repo = data.get("repo") or "this repository"
92
157
  _block(
@@ -0,0 +1,149 @@
1
+ """`agentgov uninstall` — remove AgentGov from this machine, safely.
2
+
3
+ WHY THIS EXISTS
4
+ ---------------
5
+ `agentgov install` writes hook registrations into ~/.claude/settings.json that
6
+ point at ~/.claude/agentgov-hooks/*.py. Nothing removed them, so the documented
7
+ teardown —
8
+
9
+ rm -rf ~/.agentgov ~/.claude/agentgov-hooks
10
+
11
+ — left Claude Code registering three hooks whose files no longer existed. Every
12
+ session and every prompt then tried to execute a missing command. Deleting the
13
+ files made Claude Code WORSE, not neutral, and no amount of reinstalling fixed
14
+ it because the stale registrations were never the thing being reinstalled.
15
+
16
+ Order matters and is the whole point of this command: **deregister first, delete
17
+ second**. A half-removed install must never be able to break the editor.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import shutil
24
+ import subprocess
25
+ from pathlib import Path
26
+
27
+ import typer
28
+ from rich.console import Console
29
+
30
+ console = Console()
31
+
32
+ CLAUDE_DIR = Path.home() / ".claude"
33
+ SETTINGS = CLAUDE_DIR / "settings.json"
34
+ AGENTGOV_DIR = Path.home() / ".agentgov"
35
+
36
+
37
+ def run(
38
+ keep_credentials: bool = typer.Option(
39
+ False,
40
+ "--keep-credentials",
41
+ help="Keep ~/.agentgov (device key, identity, logs). Only remove Claude Code wiring.",
42
+ ),
43
+ yes: bool = typer.Option(False, "--yes", "-y", help="Do not prompt for confirmation."),
44
+ ) -> None:
45
+ """Remove AgentGov's Claude Code hooks, statusline, and local state."""
46
+ if not yes:
47
+ console.print("This will remove AgentGov's hooks and statusline from Claude Code.")
48
+ if not keep_credentials:
49
+ console.print(
50
+ f"It will also delete [cyan]{AGENTGOV_DIR}[/] "
51
+ "(device key, identity, gateway logs)."
52
+ )
53
+ if not typer.confirm("Continue?", default=False):
54
+ raise typer.Abort()
55
+
56
+ # 1. STOP the gateway service first, so nothing restarts mid-teardown.
57
+ _stop_service()
58
+
59
+ # 2. DEREGISTER before deleting. This is the step whose absence broke
60
+ # Claude Code: settings.json outlives the files it points at.
61
+ _deregister()
62
+
63
+ # 3. Now the files are safe to delete — nothing references them.
64
+ for path in (CLAUDE_DIR / "agentgov-hooks", CLAUDE_DIR / "agentgov-statusline"):
65
+ if path.exists():
66
+ shutil.rmtree(path, ignore_errors=True)
67
+ console.print(f"[green]Removed[/] {path}")
68
+ workitem = CLAUDE_DIR / "commands" / "workitem.md"
69
+ if workitem.exists():
70
+ workitem.unlink()
71
+ console.print(f"[green]Removed[/] {workitem}")
72
+
73
+ if not keep_credentials and AGENTGOV_DIR.exists():
74
+ shutil.rmtree(AGENTGOV_DIR, ignore_errors=True)
75
+ console.print(f"[green]Removed[/] {AGENTGOV_DIR}")
76
+
77
+ console.print("\n[bold green]AgentGov removed.[/]")
78
+ console.print("Claude Code is unaffected — restart it to clear the old session.")
79
+ console.print(
80
+ "\n[dim]Machine-wide managed-settings.json (if you installed it) needs sudo:[/]\n"
81
+ " sudo rm -f /etc/claude-code/managed-settings.json"
82
+ )
83
+
84
+
85
+ def _stop_service() -> None:
86
+ """Stop + disable the gateway service, ignoring 'not installed'."""
87
+ unit = Path.home() / ".config" / "systemd" / "user" / "agentgov-gateway.service"
88
+ if not unit.exists():
89
+ return
90
+ for args in (["stop", "agentgov-gateway"], ["disable", "agentgov-gateway"]):
91
+ subprocess.run(
92
+ ["systemctl", "--user", *args], capture_output=True, check=False, timeout=30
93
+ )
94
+ unit.unlink(missing_ok=True)
95
+ subprocess.run(
96
+ ["systemctl", "--user", "daemon-reload"], capture_output=True, check=False, timeout=30
97
+ )
98
+ console.print(f"[green]Stopped[/] gateway service ({unit.name})")
99
+
100
+
101
+ def _deregister() -> None:
102
+ """Strip AgentGov's entries from ~/.claude/settings.json, leaving the rest."""
103
+ if not SETTINGS.exists():
104
+ return
105
+ try:
106
+ data = json.loads(SETTINGS.read_text() or "{}")
107
+ except json.JSONDecodeError:
108
+ console.print(
109
+ f"[yellow]Could not parse {SETTINGS}[/] — leaving it alone. "
110
+ "Remove any 'agentgov-hooks' entries by hand."
111
+ )
112
+ return
113
+ if not isinstance(data, dict):
114
+ return
115
+
116
+ removed = 0
117
+ hooks = data.get("hooks")
118
+ if isinstance(hooks, dict):
119
+ for event in list(hooks):
120
+ entries = hooks[event]
121
+ if not isinstance(entries, list):
122
+ continue
123
+ kept = [e for e in entries if not _is_agentgov(e)]
124
+ removed += len(entries) - len(kept)
125
+ if kept:
126
+ hooks[event] = kept
127
+ else:
128
+ del hooks[event]
129
+ if hooks:
130
+ data["hooks"] = hooks
131
+ else:
132
+ data.pop("hooks", None)
133
+
134
+ if "agentgov" in str(data.get("statusLine", "")):
135
+ data.pop("statusLine", None)
136
+ removed += 1
137
+
138
+ if removed:
139
+ SETTINGS.write_text(json.dumps(data, indent=2) + "\n")
140
+ console.print(f"[green]Deregistered[/] {removed} entr(ies) from {SETTINGS}")
141
+
142
+
143
+ def _is_agentgov(entry: object) -> bool:
144
+ if not isinstance(entry, dict):
145
+ return False
146
+ return any(
147
+ isinstance(h, dict) and "agentgov-hooks" in str(h.get("command", ""))
148
+ for h in (entry.get("hooks") or [])
149
+ )
@@ -4,7 +4,17 @@ from __future__ import annotations
4
4
 
5
5
  import typer
6
6
 
7
- from .commands import doctor, gateway, install, login, register_device, status, workitem, wrap
7
+ from .commands import (
8
+ doctor,
9
+ gateway,
10
+ install,
11
+ login,
12
+ register_device,
13
+ status,
14
+ uninstall,
15
+ workitem,
16
+ wrap,
17
+ )
8
18
 
9
19
  app = typer.Typer(
10
20
  name="agentgov",
@@ -19,6 +29,10 @@ app.command(
19
29
  app.command(
20
30
  "install", help="Install client assets into ~/.claude/ and print managed-settings.json."
21
31
  )(install.run)
32
+ app.command(
33
+ "uninstall",
34
+ help="Remove AgentGov hooks, statusline and local state (deregisters before deleting).",
35
+ )(uninstall.run)
22
36
  app.add_typer(gateway.app, name="gateway")
23
37
  app.command("wrap", help="Wrap `claude` — start a governed Claude Code session.")(wrap.run)
24
38
  app.command("workitem", help="Bind the current session to a work item (JIRA/Notion/PRJ-...).")(
@@ -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"
5
+ version = "0.1.6"
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" }
@@ -65,6 +65,7 @@ include = [
65
65
 
66
66
  [tool.ruff]
67
67
  line-length = 100
68
+ extend-exclude = ["agentgov_cli/client_assets"]
68
69
  target-version = "py310"
69
70
  [tool.ruff.lint]
70
71
  select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"]
@@ -73,6 +74,9 @@ select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"]
73
74
  ignore = ["B008"]
74
75
 
75
76
  [tool.mypy]
77
+ # agentgov_cli/client_assets is a build artifact vendored by hatch_build.py;
78
+ # the real source lives at the repo root and is checked there.
79
+ exclude = ['agentgov_cli/client_assets/']
76
80
  python_version = "3.10"
77
81
  strict = true
78
82
  disallow_untyped_defs = true
File without changes
File without changes