agentgov-cli 0.1.8__tar.gz → 0.1.9__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 (30) hide show
  1. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/PKG-INFO +1 -1
  2. agentgov_cli-0.1.9/agentgov_cli/client_assets/commands/workitem.md +58 -0
  3. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/client_assets/hooks/userpromptsubmit_workitem.py +11 -4
  4. agentgov_cli-0.1.9/agentgov_cli/client_assets/statusline/agentgov_statusline.sh +151 -0
  5. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/commands/install.py +61 -2
  6. agentgov_cli-0.1.9/agentgov_cli/commands/status.py +72 -0
  7. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/commands/uninstall.py +56 -2
  8. agentgov_cli-0.1.9/agentgov_cli/commands/workitem.py +291 -0
  9. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/pyproject.toml +1 -1
  10. agentgov_cli-0.1.8/agentgov_cli/client_assets/commands/workitem.md +0 -18
  11. agentgov_cli-0.1.8/agentgov_cli/client_assets/statusline/agentgov_statusline.sh +0 -74
  12. agentgov_cli-0.1.8/agentgov_cli/commands/status.py +0 -46
  13. agentgov_cli-0.1.8/agentgov_cli/commands/workitem.py +0 -72
  14. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/.gitignore +0 -0
  15. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/README.md +0 -0
  16. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/__init__.py +0 -0
  17. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/client_assets/hooks/pretooluse_pathguard.py +0 -0
  18. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/client_assets/hooks/sessionstart_register.py +0 -0
  19. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/client_assets/managed-settings.json.tmpl +0 -0
  20. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/commands/__init__.py +0 -0
  21. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/commands/doctor.py +0 -0
  22. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/commands/gateway.py +0 -0
  23. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/commands/login.py +0 -0
  24. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/commands/register_device.py +0 -0
  25. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/commands/wrap.py +0 -0
  26. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/gateway_process.py +0 -0
  27. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/loopback.py +0 -0
  28. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/main.py +0 -0
  29. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/agentgov_cli/port_resolver.py +0 -0
  30. {agentgov_cli-0.1.8 → agentgov_cli-0.1.9}/hatch_build.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentgov-cli
3
- Version: 0.1.8
3
+ Version: 0.1.9
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,58 @@
1
+ ---
2
+ description: Bind this session to a work item — ticket id, URL, or any short label
3
+ allowed-tools: Bash(agentgov workitem:*)
4
+ argument-hint: <PROJ-1234 | https://tracker/issue/42 | any short label>
5
+ ---
6
+
7
+ !`agentgov workitem --session "${CLAUDE_SESSION_ID}" -- '$ARGUMENTS'`
8
+
9
+ The command above has already run and its output is shown. Relay that output to
10
+ the user verbatim — do not restate, reformat, or interpret it — and then stop.
11
+
12
+ If it reported a successful bind, simply acknowledge it and carry on with
13
+ whatever the user was doing. If it reported a failure, show the message as-is;
14
+ it already contains the remediation step.
15
+
16
+ <!--
17
+ WHY THIS FILE LOOKS THE WAY IT DOES
18
+ ===================================
19
+
20
+ 1. WHY A `!` SHELL BLOCK AND NOT A MODEL-DRIVEN TOOL CALL
21
+ An unbound session is DENIED by the gateway (WorkItemMiddleware →
22
+ NO_WORK_ITEM), so no model turn is possible until the bind succeeds. Asking
23
+ the model to run the bind would deadlock: the request that would fix the
24
+ problem is the request the problem blocks. The bind must therefore complete
25
+ entirely in the shell, before anything is sent upstream.
26
+
27
+ 2. WHY `${CLAUDE_SESSION_ID}` IS PASSED EXPLICITLY
28
+ Claude Code substitutes it into slash-command shell lines, and it is the same
29
+ id sent as `x-claude-code-session-id` on every API request — i.e. exactly the
30
+ key the gateway stores sessions under. Passing it is what lets this work in
31
+ the VS Code extension, with no `agentgov wrap` and no loopback listener on
32
+ :8788. Requiring that listener is what made `/workitem` fail for every editor
33
+ user.
34
+
35
+ 3. WHY `agentgov workitem` NEVER EXITS NON-ZERO
36
+ Claude Code aborts the entire invocation when a `!` command exits non-zero and
37
+ reports `Shell command failed for pattern "..."`. That message is Claude Code
38
+ quoting the shell line it tried to run — it is NOT AgentGov matching the
39
+ user's text against a pattern, and nothing here inspects the format of what
40
+ the user typed. Our real error text was being swallowed by it, which is why
41
+ the failure was unreadable. The CLI now reports every failure as ordinary
42
+ output with exit code 0.
43
+
44
+ 4. WHY `'$ARGUMENTS'` IS SINGLE-QUOTED
45
+ `$ARGUMENTS` is substituted as text into a shell line, so unquoted user input
46
+ would let `$(...)`, backticks, `;`, `|`, `&` and `>` be interpreted by the
47
+ shell. Single quotes make every one of those literal.
48
+
49
+ KNOWN GAP: a literal apostrophe in the user's text (e.g. `don't`) closes the
50
+ quote early and breaks the line. Anthropic's documentation does not state
51
+ whether Claude Code escapes `$ARGUMENTS` before substitution, so this could
52
+ not be settled from the docs and is deliberately not guessed at. The CLI
53
+ defends on its side by stripping a stray surrounding quote pair and by
54
+ joining all argv entries into one reference. The clean fix is a
55
+ `UserPromptExpansion` hook, which receives the typed text as structured JSON
56
+ and never involves a shell — revisit once that hook's schema and exit-code
57
+ semantics are documented.
58
+ -->
@@ -39,6 +39,12 @@ TIMEOUT_SEC = 4.0
39
39
 
40
40
  # Prompts that exist to FIX an unbound session must not be blocked by the
41
41
  # unbound session — otherwise the developer is deadlocked with no way out.
42
+ #
43
+ # NOTE: per Anthropic's hook reference, `UserPromptSubmit` does NOT fire for
44
+ # slash commands at all — those raise `UserPromptExpansion` instead. So this
45
+ # list is belt-and-braces, not the thing that lets `/workitem` through, and it
46
+ # must not be relied on. Keep it: it costs nothing, and it still covers a
47
+ # developer who types the command as plain text.
42
48
  ESCAPE_PREFIXES = ("/workitem", "/agentgov", "/help", "/status", "/doctor")
43
49
 
44
50
 
@@ -186,10 +192,11 @@ def main() -> None:
186
192
  "📌 AgentGov: bind this session to a work item before continuing.\n"
187
193
  f" Repository: {repo}\n"
188
194
  "\n"
189
- " Run one of:\n"
190
- " /workitem PROJ-1234 (JIRA issue)\n"
191
- " /workitem PRJ-0042 (internal project)\n"
192
- " /workitem https://notion.so/... (Notion page)\n"
195
+ " Run /workitem with anything that identifies the work — a ticket id,\n"
196
+ " a URL, or a short label. There is no required format:\n"
197
+ " /workitem PROJ-1234\n"
198
+ " /workitem https://your-tracker/issue/42\n"
199
+ " /workitem payment retry loop spike\n"
193
200
  "\n"
194
201
  " Your prompt was not sent and no tokens were used.\n"
195
202
  )
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env bash
2
+ # AgentGov statusline (§R-CC3).
3
+ #
4
+ # Renders one of:
5
+ # 🛡 <repo> │ 📌 <work item> │ 41% weekly · resets 08-18 09:00
6
+ # 🛡 <repo> │ 📌 <work item> │ 8% context
7
+ # 🛡 <repo> │ 📌 UNBOUND — /workitem required
8
+ #
9
+ # WHY THIS WAS REWRITTEN
10
+ # ----------------------
11
+ # It read $AGENTGOV_SESSION and a state file at ~/.agentgov/state/<session>.json.
12
+ # Both only ever existed under `agentgov wrap`: the variable was exported by the
13
+ # launcher, and the file was written by the loopback listener the launcher
14
+ # started. In the VS Code extension neither exists, so the statusline printed
15
+ #
16
+ # 📌 UNGOVERNED — run agentgov wrap claude
17
+ #
18
+ # permanently — for sessions that were registered, bound and governed. It was the
19
+ # main surface a developer would look at to confirm a bind had worked, and it was
20
+ # reporting the opposite of the truth.
21
+ #
22
+ # Claude Code hands the statusline a JSON document on stdin containing
23
+ # `session_id`, `workspace.repo`, `cost`, `context_window` and `rate_limits`
24
+ # (documented at https://code.claude.com/docs/en/statusline.md). That is the same
25
+ # session id the gateway keys sessions under, so the binding can be read straight
26
+ # from the gateway with no wrapper, no exported variable and no state file.
27
+ #
28
+ # The body is Python rather than jq + `read`, because the previous word-splitting
29
+ # approach corrupted any value containing a space — which now includes work-item
30
+ # labels, since those are free text. It is held in a quoted heredoc so the shell
31
+ # performs no substitution on it, and passed via -c so that stdin stays the JSON
32
+ # Claude Code sent.
33
+
34
+ set -u
35
+
36
+ read -r -d '' AGENTGOV_STATUSLINE_PY <<'PYEOF'
37
+ import json
38
+ import os
39
+ import sys
40
+ import urllib.request
41
+ from datetime import datetime, timezone
42
+
43
+ GATEWAY = os.environ.get("AGENTGOV_GATEWAY_URL", "http://127.0.0.1:8000").rstrip("/")
44
+ # Runs on every assistant message, so keep it tight. This is a loopback call to
45
+ # an in-memory lookup; if it is slow, something is wrong and we show less rather
46
+ # than stall the status bar.
47
+ TIMEOUT = 1.0
48
+
49
+ RED = "\033[31m"
50
+ YELLOW = "\033[33m"
51
+ GREEN = "\033[32m"
52
+ DIM = "\033[2m"
53
+ RESET = "\033[0m"
54
+
55
+
56
+ def payload():
57
+ try:
58
+ return json.load(sys.stdin)
59
+ except Exception:
60
+ return {}
61
+
62
+
63
+ def binding(session_id):
64
+ """Ask the gateway about this session. None means 'could not tell'."""
65
+ if not session_id:
66
+ return None
67
+ try:
68
+ url = GATEWAY + "/admin/session/" + str(session_id) + "/binding"
69
+ with urllib.request.urlopen(url, timeout=TIMEOUT) as r:
70
+ data = json.loads(r.read() or b"{}")
71
+ return data if isinstance(data, dict) else None
72
+ except Exception:
73
+ return None
74
+
75
+
76
+ def repo_label(data, state):
77
+ """Prefer the repo the gateway registered, then Claude Code's, then the folder."""
78
+ if state and state.get("repo"):
79
+ return str(state["repo"])
80
+ repo = (data.get("workspace") or {}).get("repo") or {}
81
+ owner = repo.get("owner")
82
+ name = repo.get("name")
83
+ if owner and name:
84
+ return "{}/{}".format(owner, name)
85
+ cwd = (data.get("workspace") or {}).get("current_dir") or data.get("cwd") or ""
86
+ return os.path.basename(cwd.rstrip("/")) or "unknown"
87
+
88
+
89
+ def shorten(text, limit=42):
90
+ """Work items are free text now, so a long label must not crowd out the rest."""
91
+ text = " ".join(str(text).split())
92
+ if len(text) <= limit:
93
+ return text
94
+ return text[: limit - 1] + "…"
95
+
96
+
97
+ def usage_segment(data):
98
+ """Weekly quota if Claude Code reported it, else context use, else cost.
99
+
100
+ These come from Claude Code itself, which is strictly more accurate than
101
+ anything AgentGov could accumulate locally between ingest acknowledgements.
102
+ """
103
+ week = (data.get("rate_limits") or {}).get("seven_day") or {}
104
+ pct = week.get("used_percentage")
105
+ if isinstance(pct, (int, float)):
106
+ color = RED if pct >= 85 else YELLOW if pct >= 60 else GREEN
107
+ seg = "{}{:.0f}% weekly{}".format(color, pct, RESET)
108
+ resets = week.get("resets_at")
109
+ if isinstance(resets, (int, float)):
110
+ when = datetime.fromtimestamp(resets, tz=timezone.utc).astimezone()
111
+ seg += when.strftime(" · resets %m-%d %H:%M")
112
+ return seg
113
+
114
+ ctx = (data.get("context_window") or {}).get("used_percentage")
115
+ if isinstance(ctx, (int, float)):
116
+ return "{}{:.0f}% context{}".format(DIM, ctx, RESET)
117
+
118
+ cost = (data.get("cost") or {}).get("total_cost_usd")
119
+ if isinstance(cost, (int, float)):
120
+ return "{}${:.2f}{}".format(DIM, cost, RESET)
121
+ return ""
122
+
123
+
124
+ def main():
125
+ data = payload()
126
+ state = binding(data.get("session_id"))
127
+
128
+ parts = ["\U0001f6e1 " + repo_label(data, state)]
129
+
130
+ if state is None:
131
+ # Gateway unreachable. Say so plainly — "not bound" and "cannot tell"
132
+ # are different answers, and conflating them is what made the old
133
+ # statusline misleading.
134
+ parts.append("{}\U0001f4cc gateway offline{}".format(YELLOW, RESET))
135
+ elif state.get("bound"):
136
+ label = shorten(state.get("work_item_ref") or "bound")
137
+ parts.append("\U0001f4cc " + label)
138
+ else:
139
+ parts.append("{}\U0001f4cc UNBOUND — /workitem required{}".format(RED, RESET))
140
+
141
+ usage = usage_segment(data)
142
+ if usage:
143
+ parts.append(usage)
144
+
145
+ sys.stdout.write(" │ ".join(parts))
146
+
147
+
148
+ main()
149
+ PYEOF
150
+
151
+ exec python3 -c "$AGENTGOV_STATUSLINE_PY"
@@ -140,6 +140,58 @@ def run(
140
140
  console.print("\n[dim]Contents (for reference):[/]")
141
141
  console.print(settings_json)
142
142
 
143
+ _print_next_steps(target_path)
144
+
145
+
146
+ def _print_next_steps(managed_target: str) -> None:
147
+ """Say plainly what happens next, and what will NOT happen.
148
+
149
+ Installing hooks is not the last step, and on its own it changes nothing a
150
+ developer can see. Without this, `install` finishes on a wall of JSON and
151
+ the obvious conclusion is "it's set up" — then the work-item prompt never
152
+ appears and the install looks broken when it is behaving correctly.
153
+ """
154
+ governed_everywhere = Path(managed_target).exists()
155
+
156
+ console.rule("[bold]Next steps[/]")
157
+ console.print("[bold]1.[/] Fully quit and reopen Claude Code / VS Code.")
158
+ console.print(
159
+ " [dim]Hooks are read when a session starts. A window that is already "
160
+ "open will not pick them up.[/]\n"
161
+ )
162
+
163
+ if governed_everywhere:
164
+ console.print(
165
+ "[bold]2.[/] Machine-wide settings are installed, so [green]every[/] Claude Code "
166
+ "session is governed."
167
+ )
168
+ console.print(" Open a granted repo and send a prompt — you will be asked to bind a "
169
+ "work item.\n")
170
+ else:
171
+ console.print(
172
+ "[bold]2.[/] Choose how you want to be governed — "
173
+ "[yellow]until you do, nothing changes[/]:\n"
174
+ )
175
+ console.print(" [cyan]agentgov wrap claude[/] (per session, no sudo)")
176
+ console.print(
177
+ " Governs just that session. Plain `claude` and the VS Code extension "
178
+ "keep talking to Anthropic directly.\n"
179
+ )
180
+ console.print(
181
+ f" [cyan]sudo cp ~/.agentgov/managed-settings.json {managed_target}[/] "
182
+ "(whole machine)"
183
+ )
184
+ console.print(
185
+ " The only way to govern the VS Code / Cursor extension. Requires sudo, and "
186
+ "means a stopped gateway blocks all Claude Code.\n"
187
+ )
188
+
189
+ console.print("[bold]3.[/] Check it took: [cyan]agentgov doctor[/]")
190
+ console.print(
191
+ " [dim]Step 1 CLIENT tells you whether anything is actually routed through the "
192
+ "gateway.[/]"
193
+ )
194
+
143
195
 
144
196
  def _agentgov_hook_entries(hooks_dest: Path) -> dict[str, list[dict[str, object]]]:
145
197
  """The hook registrations AgentGov owns, keyed by Claude Code event name."""
@@ -224,9 +276,16 @@ def _merge_user_settings(gateway_url: str, hooks_dest: Path, statusline_dest: Pa
224
276
  # to undo. Routing is the job of managed-settings.json (highest precedence).
225
277
  path.write_text(json.dumps(data, indent=2) + "\n")
226
278
  console.print(f"[green]Registered[/] hooks + statusline → {path}")
279
+ # Be precise about what this does and does not do. The earlier wording
280
+ # ("these run in every Claude Code session, including the VS Code
281
+ # extension") was wrong twice over: hooks load only at session start, and
282
+ # since 0.1.7 they intentionally do nothing in sessions that are not routed
283
+ # through the gateway. Overstating it makes the next step look broken when
284
+ # it is working exactly as designed.
227
285
  console.print(
228
- " [dim]These run in every Claude Code session on this machine, including the "
229
- "VS Code extension. Restart Claude Code to pick them up.[/]"
286
+ " [dim]Loaded when a Claude Code session STARTS — restart Claude Code.\n"
287
+ " They act only on sessions routed through the gateway; other sessions are "
288
+ "left alone.[/]"
230
289
  )
231
290
 
232
291
 
@@ -0,0 +1,72 @@
1
+ """`agentgov status` — show the current work-item binding for this session.
2
+
3
+ Rewritten for the same reason as `agentgov workitem`: this asked the loopback
4
+ listener on :8788 that only `agentgov wrap` starts, so in the VS Code extension
5
+ it always answered "No active AgentGov session. Start one: agentgov wrap claude"
6
+ about a session that was registered and working. The one command a developer
7
+ runs to check whether their bind took effect was reporting the opposite of the
8
+ truth.
9
+
10
+ It now asks the gateway directly, keyed on Claude Code's own session id — the
11
+ same identifier the gateway stores sessions under.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import httpx
17
+ import typer
18
+ from rich.console import Console
19
+
20
+ from .workitem import DEFAULT_GATEWAY, _body, _resolve_session
21
+
22
+ console = Console()
23
+
24
+
25
+ def run(
26
+ session: str = typer.Option(
27
+ None,
28
+ "--session",
29
+ help="Claude Code session id. Defaults to $CLAUDE_SESSION_ID / $AGENTGOV_SESSION.",
30
+ ),
31
+ gateway: str = typer.Option(
32
+ None,
33
+ "--gateway",
34
+ envvar="AGENTGOV_GATEWAY_URL",
35
+ help=f"Gateway base URL (default {DEFAULT_GATEWAY}).",
36
+ ),
37
+ ) -> None:
38
+ """Report whether this session is bound, and to what."""
39
+ base = (gateway or DEFAULT_GATEWAY).rstrip("/")
40
+ session_id = _resolve_session(session)
41
+
42
+ if not session_id:
43
+ console.print(
44
+ "[yellow]No Claude Code session id in this shell.[/]\n"
45
+ " Run `agentgov status` from inside Claude Code, or pass --session <id>.\n"
46
+ " Session ids are listed by: [bold]agentgov doctor[/]"
47
+ )
48
+ raise typer.Exit(0)
49
+
50
+ try:
51
+ r = httpx.get(f"{base}/admin/session/{session_id}/binding", timeout=10.0)
52
+ data = _body(r)
53
+ except Exception:
54
+ console.print(
55
+ f"[red]Gateway at {base} is not reachable.[/]\n"
56
+ " Start it with: [bold]agentgov gateway install[/]"
57
+ )
58
+ raise typer.Exit(0) from None
59
+
60
+ if not data.get("known"):
61
+ console.print(
62
+ "[yellow]This session is not registered with the gateway.[/]\n"
63
+ " It will re-register on your next prompt, or when you run `/workitem`."
64
+ )
65
+ raise typer.Exit(0)
66
+
67
+ console.print(f"🛡 repo: [bold]{data.get('repo') or 'unknown'}[/]")
68
+ if data.get("bound"):
69
+ console.print(f"📌 work item: [bold]{data.get('work_item_ref')}[/]")
70
+ else:
71
+ console.print("[red]📌 UNBOUND[/] — run `/workitem <anything>` in Claude Code.")
72
+ raise typer.Exit(0)
@@ -22,6 +22,7 @@ from __future__ import annotations
22
22
  import json
23
23
  import shutil
24
24
  import subprocess
25
+ import sys
25
26
  from pathlib import Path
26
27
 
27
28
  import typer
@@ -34,6 +35,33 @@ SETTINGS = CLAUDE_DIR / "settings.json"
34
35
  AGENTGOV_DIR = Path.home() / ".agentgov"
35
36
 
36
37
 
38
+ def _running_from(directory: Path) -> Path | None:
39
+ """Is this process's interpreter installed underneath `directory`?
40
+
41
+ The documented install puts the CLI in a venv at ~/.agentgov/venv — INSIDE
42
+ the directory `uninstall` deletes. So `shutil.rmtree(~/.agentgov)` was
43
+ deleting the interpreter and site-packages out from under the running
44
+ process. It did not fail immediately, because Python had already imported
45
+ what it needed; it failed on the NEXT lazy import. In practice that was
46
+ rich's on-demand unicode table, from the very next console.print():
47
+
48
+ ModuleNotFoundError: No module named 'rich._unicode_data.unicode17-0-0'
49
+
50
+ followed by typer's excepthook failing too, because typer's own modules were
51
+ gone as well. It read like a rich/typer version conflict. It was not — it was
52
+ self-deletion, and it left the user with no `agentgov` command at all and no
53
+ obvious way back.
54
+
55
+ Returns the venv root when this process runs from inside `directory`.
56
+ """
57
+ try:
58
+ prefix = Path(sys.prefix).resolve()
59
+ target = directory.resolve()
60
+ except OSError:
61
+ return None
62
+ return prefix if prefix == target or target in prefix.parents else None
63
+
64
+
37
65
  def run(
38
66
  keep_credentials: bool = typer.Option(
39
67
  False,
@@ -70,12 +98,38 @@ def run(
70
98
  workitem.unlink()
71
99
  console.print(f"[green]Removed[/] {workitem}")
72
100
 
101
+ self_venv: Path | None = None
73
102
  if not keep_credentials and AGENTGOV_DIR.exists():
74
- shutil.rmtree(AGENTGOV_DIR, ignore_errors=True)
75
- console.print(f"[green]Removed[/] {AGENTGOV_DIR}")
103
+ self_venv = _running_from(AGENTGOV_DIR)
104
+ if self_venv is None:
105
+ shutil.rmtree(AGENTGOV_DIR, ignore_errors=True)
106
+ console.print(f"[green]Removed[/] {AGENTGOV_DIR}")
107
+ else:
108
+ # Delete everything EXCEPT the venv we are executing from. Removing
109
+ # it here crashes this process mid-teardown (see _running_from) and
110
+ # destroys the only copy of the CLI, so the user cannot even re-run
111
+ # uninstall to finish the job. Leave it and hand them one command.
112
+ for child in AGENTGOV_DIR.iterdir():
113
+ if child == self_venv:
114
+ continue
115
+ if child.is_dir() and not child.is_symlink():
116
+ shutil.rmtree(child, ignore_errors=True)
117
+ else:
118
+ child.unlink(missing_ok=True)
119
+ console.print(f"[green]Removed[/] {AGENTGOV_DIR} [dim](except the venv below)[/]")
76
120
 
77
121
  console.print("\n[bold green]AgentGov removed.[/]")
78
122
  console.print("Claude Code is unaffected — restart it to clear the old session.")
123
+
124
+ if self_venv is not None:
125
+ console.print(
126
+ f"\n[yellow]Left in place:[/] {self_venv}\n"
127
+ " That is the virtualenv running this command — deleting it mid-run would "
128
+ "crash before the teardown finished.\n"
129
+ " Remove it once this command has exited:\n"
130
+ f" rm -rf {self_venv}"
131
+ )
132
+
79
133
  console.print(
80
134
  "\n[dim]Machine-wide managed-settings.json (if you installed it) needs sudo:[/]\n"
81
135
  " sudo rm -f /etc/claude-code/managed-settings.json"
@@ -0,0 +1,291 @@
1
+ """`agentgov workitem [ref...]` — bind the current Claude Code session to a work item.
2
+
3
+ WHY THIS WAS REWRITTEN
4
+ ----------------------
5
+ This command used to POST to the loopback admin listener on :8788 that
6
+ `agentgov wrap` starts. That made it unusable for almost everyone: the rest of
7
+ the system had already moved to governing whatever Claude Code the developer
8
+ actually runs — the VS Code / Cursor / JetBrains extension — via hooks that talk
9
+ straight to the gateway using Claude Code's own `session_id` (see
10
+ gateway/agentgov_gateway/session_identity.py). But `workitem` stayed on the old
11
+ wrapper-only path, so in an editor session there was no listener and every
12
+ attempt died with:
13
+
14
+ No AgentGov loopback listener on :8788.
15
+ No active AgentGov session. Start one: agentgov wrap claude
16
+
17
+ ...for a session that was perfectly healthy and already registered. The loopback
18
+ listener never did anything except relay to `POST /admin/session/bind`, so this
19
+ now calls that endpoint directly and works identically under `wrap` and under the
20
+ editor extension.
21
+
22
+ THREE RULES THIS COMMAND OBEYS
23
+ ------------------------------
24
+ 1. **Never exit non-zero.** The `/workitem` slash command runs this inside a
25
+ `` !`...` `` block, and Claude Code aborts the whole invocation on any
26
+ non-zero exit, reporting `Shell command failed for pattern "..."` — which is
27
+ what the developer saw instead of our actual error text. Failures are
28
+ reported as readable output with exit code 0.
29
+ 2. **Accept any text.** A reference is whatever the developer typed: a JIRA key,
30
+ a URL, an id from some other tracker, or a plain label. Nothing is validated
31
+ client-side; the only limit is MAX_REF_LEN characters, and over-long input is
32
+ truncated with a notice rather than refused.
33
+ 3. **Self-heal a forgotten session.** The gateway holds sessions in memory, so a
34
+ restart drops every editor window that is already open. Rather than telling
35
+ the developer to start a new session, re-register and retry the bind once.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import os
42
+ import subprocess
43
+ from typing import Any
44
+
45
+ import httpx
46
+ import typer
47
+ from rich.console import Console
48
+
49
+ console = Console()
50
+
51
+ # Mirrors MAX_REF_LEN in control-tower/lib/work-items.ts and the CHECK
52
+ # constraint in supabase/migrations/016_freeform_work_items.sql.
53
+ MAX_REF_LEN = 200
54
+
55
+ DEFAULT_GATEWAY = "http://127.0.0.1:8000"
56
+
57
+
58
+ def run(
59
+ ref: list[str] = typer.Argument(
60
+ None,
61
+ help=(
62
+ "Anything that identifies the work: a ticket id, a URL, or a short "
63
+ f"description. Free text is fine. Truncated to {MAX_REF_LEN} characters."
64
+ ),
65
+ ),
66
+ session: str = typer.Option(
67
+ None,
68
+ "--session",
69
+ help="Claude Code session id. Defaults to $CLAUDE_SESSION_ID / $AGENTGOV_SESSION.",
70
+ ),
71
+ gateway: str = typer.Option(
72
+ None,
73
+ "--gateway",
74
+ envvar="AGENTGOV_GATEWAY_URL",
75
+ help=f"Gateway base URL (default {DEFAULT_GATEWAY}).",
76
+ ),
77
+ ) -> None:
78
+ """Bind this Claude Code session to a work item, so its tokens are attributed to it."""
79
+ base = (gateway or DEFAULT_GATEWAY).rstrip("/")
80
+ session_id = _resolve_session(session)
81
+
82
+ if not session_id:
83
+ console.print(
84
+ "[red]AgentGov: no Claude Code session id.[/]\n"
85
+ " Run /workitem from inside Claude Code, or pass --session <id> explicitly."
86
+ )
87
+ raise typer.Exit(0)
88
+
89
+ raw = _join(ref)
90
+ if not raw:
91
+ _show_current(base, session_id)
92
+ raise typer.Exit(0)
93
+
94
+ work_ref, truncated = _clamp(raw)
95
+
96
+ result = _bind(base, session_id, work_ref)
97
+ if result is None:
98
+ console.print(
99
+ f"[red]AgentGov: the gateway at {base} is not reachable,[/] so the work item "
100
+ "could not be bound.\n"
101
+ " Start it with: [bold]agentgov gateway install[/] (then: agentgov doctor)"
102
+ )
103
+ raise typer.Exit(0)
104
+
105
+ status, payload = result
106
+
107
+ # A session the gateway has forgotten (restart, reinstall) is transient, not
108
+ # an error the developer should have to act on. Re-register and retry once.
109
+ forgotten = status == 409 or _error_code(payload) == "session_not_registered"
110
+ if forgotten and _register(base, session_id):
111
+ retried = _bind(base, session_id, work_ref)
112
+ if retried is not None:
113
+ status, payload = retried
114
+
115
+ if status >= 400:
116
+ console.print(f"[red]AgentGov: could not bind to[/] {work_ref!r}")
117
+ console.print(f" {_error_text(payload)}")
118
+ raise typer.Exit(0)
119
+
120
+ wi = payload.get("work_item") or {}
121
+ bound = str(wi.get("external_id") or work_ref)
122
+ console.print(f"[green]✅ Bound to[/] [bold]{bound}[/]")
123
+ if truncated:
124
+ console.print(
125
+ f" [yellow]Note:[/] your text was longer than {MAX_REF_LEN} characters "
126
+ "and was shortened to the above."
127
+ )
128
+ console.print(" Tokens from here on are attributed to this work item.")
129
+ raise typer.Exit(0)
130
+
131
+
132
+ # -----------------------------------------------------------------------------
133
+ # Reference handling
134
+ # -----------------------------------------------------------------------------
135
+
136
+
137
+ def _join(ref: list[str] | None) -> str:
138
+ """Join argv into one reference string.
139
+
140
+ The slash command substitutes the developer's text into a shell line, so the
141
+ text may arrive as many argv entries ("fix", "the", "retry", "loop") and may
142
+ carry the quote characters the slash command wrapped it in. Rejoin, collapse
143
+ whitespace, and drop one matching pair of surrounding quotes if present —
144
+ a developer who types `PROJ-1 fix retry` means one work item, not four, and
145
+ should never see stray quotes in their dashboard.
146
+ """
147
+ if not ref:
148
+ return ""
149
+ joined = " ".join(ref).strip()
150
+ joined = " ".join(joined.split())
151
+ for q in ("'", '"'):
152
+ if len(joined) >= 2 and joined.startswith(q) and joined.endswith(q):
153
+ joined = joined[1:-1].strip()
154
+ break
155
+ return joined
156
+
157
+
158
+ def _clamp(raw: str) -> tuple[str, bool]:
159
+ """Enforce MAX_REF_LEN. Returns (ref, was_truncated).
160
+
161
+ Truncate rather than refuse. Refusing would block the developer over a
162
+ pasted URL, and the whole point of binding is attribution — a shortened
163
+ label attributes just as well as a long one.
164
+ """
165
+ if len(raw) <= MAX_REF_LEN:
166
+ return raw, False
167
+ return raw[:MAX_REF_LEN].rstrip(), True
168
+
169
+
170
+ def _resolve_session(explicit: str | None) -> str:
171
+ """Claude Code's session id, preferring the one Claude Code itself provides.
172
+
173
+ CLAUDE_SESSION_ID is substituted by Claude Code into slash-command shell
174
+ lines and is the id the gateway keys sessions by (it is the same value sent
175
+ as `x-claude-code-session-id` on every API request). AGENTGOV_SESSION is the
176
+ wrapper's own UUIDv7 and is only a fallback for legacy `agentgov wrap` use.
177
+ """
178
+ candidates = (
179
+ explicit,
180
+ os.environ.get("CLAUDE_SESSION_ID"),
181
+ os.environ.get("AGENTGOV_SESSION"),
182
+ )
183
+ for candidate in candidates:
184
+ value = (candidate or "").strip()
185
+ # Guard against an unsubstituted placeholder reaching us as literal text.
186
+ if value and not value.startswith("$"):
187
+ return value
188
+ return ""
189
+
190
+
191
+ # -----------------------------------------------------------------------------
192
+ # Gateway calls. None = transport failure; otherwise (status_code, json_body).
193
+ # -----------------------------------------------------------------------------
194
+
195
+
196
+ def _bind(base: str, session_id: str, work_ref: str) -> tuple[int, dict[str, Any]] | None:
197
+ try:
198
+ r = httpx.post(
199
+ f"{base}/admin/session/bind",
200
+ json={"session_id": session_id, "work_item_ref": work_ref},
201
+ timeout=20.0,
202
+ )
203
+ except Exception:
204
+ return None
205
+ return r.status_code, _body(r)
206
+
207
+
208
+ def _register(base: str, session_id: str) -> bool:
209
+ """Re-register this session from the current working directory."""
210
+ cwd = os.getcwd()
211
+ root = _git(cwd, "rev-parse", "--show-toplevel")
212
+ if not root:
213
+ return False
214
+ try:
215
+ httpx.post(
216
+ f"{base}/admin/session/register",
217
+ json={
218
+ "session_id": session_id,
219
+ "repo": _git(cwd, "remote", "get-url", "origin"),
220
+ "repo_root": root,
221
+ },
222
+ timeout=15.0,
223
+ )
224
+ except Exception:
225
+ return False
226
+ return True
227
+
228
+
229
+ def _show_current(base: str, session_id: str) -> None:
230
+ """`/workitem` with no arguments: report the binding instead of erroring.
231
+
232
+ This previously fell through to Typer's `Missing argument 'ref'` usage error
233
+ and a non-zero exit, which Claude Code surfaced as a failed shell command.
234
+ Asking "what am I bound to?" is a reasonable thing to do.
235
+ """
236
+ try:
237
+ r = httpx.get(f"{base}/admin/session/{session_id}/binding", timeout=10.0)
238
+ data = _body(r)
239
+ except Exception:
240
+ data = {}
241
+
242
+ if data.get("bound"):
243
+ console.print(f"[green]📌 Bound to[/] [bold]{data.get('work_item_ref')}[/]")
244
+ else:
245
+ console.print("[yellow]📌 This session is not bound to a work item.[/]")
246
+
247
+ console.print(
248
+ "\n Bind it with anything that identifies the work — there is no required format:\n"
249
+ " /workitem PROJ-1234\n"
250
+ " /workitem https://your-tracker/issue/42\n"
251
+ " /workitem payment retry loop spike\n"
252
+ f"\n [dim]Up to {MAX_REF_LEN} characters; longer text is shortened.[/]"
253
+ )
254
+
255
+
256
+ # -----------------------------------------------------------------------------
257
+ # Small helpers
258
+ # -----------------------------------------------------------------------------
259
+
260
+
261
+ def _body(r: httpx.Response) -> dict[str, Any]:
262
+ try:
263
+ parsed = r.json()
264
+ except Exception:
265
+ return {"error": {"detail": r.text[:400]}}
266
+ return parsed if isinstance(parsed, dict) else {"error": {"detail": str(parsed)[:400]}}
267
+
268
+
269
+ def _error_code(payload: dict[str, Any]) -> str:
270
+ err = payload.get("error")
271
+ return str(err.get("code") or "") if isinstance(err, dict) else ""
272
+
273
+
274
+ def _error_text(payload: dict[str, Any]) -> str:
275
+ err = payload.get("error")
276
+ if isinstance(err, dict):
277
+ detail = err.get("detail") or err.get("message") or err.get("code") or ""
278
+ return str(detail)
279
+ if isinstance(err, str):
280
+ return err
281
+ return json.dumps(payload)[:400] if payload else "no detail returned by the gateway"
282
+
283
+
284
+ def _git(cwd: str, *args: str) -> str:
285
+ try:
286
+ out = subprocess.run(
287
+ ["git", *args], cwd=cwd, capture_output=True, text=True, timeout=5, check=False
288
+ )
289
+ except (OSError, subprocess.SubprocessError):
290
+ return ""
291
+ return out.stdout.strip() if out.returncode == 0 else ""
@@ -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.8"
5
+ version = "0.1.9"
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" }
@@ -1,18 +0,0 @@
1
- ---
2
- description: Bind this session to a work item (JIRA / Notion / PRJ-XXXX)
3
- allowed-tools: Bash(agentgov workitem:*)
4
- argument-hint: <PROJ-1234 | https://notion.so/... | PRJ-0042>
5
- ---
6
-
7
- Bind the current session to a validated work item so token spend is
8
- attributed to the right story.
9
-
10
- Run:
11
-
12
- !`agentgov workitem $ARGUMENTS`
13
-
14
- If the CLI prints an error (JIRA not reachable, item DONE, etc.), relay it
15
- verbatim to the user and stop — do not attempt to run other tools until they
16
- provide a valid work item. If the CLI prints a success line
17
- (`✅ Bound to PROJ-1234 …`), acknowledge the binding and continue with whatever
18
- the user was doing.
@@ -1,74 +0,0 @@
1
- #!/usr/bin/env bash
2
- # AgentGov statusline (§R-CC3, §13.6).
3
- # Renders one of:
4
- # 🛡 <repo> │ 📌 <work-item> │ N% weekly · resets <when> (personal mode w/ rate-limit)
5
- # 🛡 <repo> │ 📌 <work-item> │ N.NNk tok / $C.CC (vault mode w/ cost)
6
- # 🛡 <repo> │ 📌 UNBOUND — /workitem required (no binding)
7
-
8
- set -u
9
-
10
- session="${AGENTGOV_SESSION:-}"
11
- state_file="${HOME}/.agentgov/state/${session}.json"
12
-
13
- if [[ -z "${session}" ]]; then
14
- printf "\033[31m📌 UNGOVERNED — run agentgov wrap claude\033[0m"
15
- exit 0
16
- fi
17
-
18
- if [[ ! -f "${state_file}" ]]; then
19
- printf "\033[33m🛡 %s\033[0m │ \033[31m📌 UNBOUND — /workitem required\033[0m" "${AGENTGOV_REPO:-unknown}"
20
- exit 0
21
- fi
22
-
23
- # Parse fields — jq preferred; python fallback.
24
- if command -v jq >/dev/null 2>&1; then
25
- repo=$(jq -r '.repo // env.AGENTGOV_REPO // "unknown"' "${state_file}")
26
- wi=$(jq -r '.work_item.external_id // "UNBOUND"' "${state_file}")
27
- tokens=$(jq -r '.tokens // 0' "${state_file}")
28
- cost=$(jq -r '.cost_usd // 0' "${state_file}")
29
- percent=$(jq -r '.rate_limit_percent // ""' "${state_file}")
30
- reset=$(jq -r '.rate_limit_reset // ""' "${state_file}")
31
- else
32
- read -r repo wi tokens cost percent reset < <(python3 - "${state_file}" <<'PY'
33
- import json, os, sys
34
- d = json.load(open(sys.argv[1]))
35
- print(
36
- d.get("repo") or os.environ.get("AGENTGOV_REPO", "unknown"),
37
- (d.get("work_item") or {}).get("external_id") or "UNBOUND",
38
- d.get("tokens") or 0,
39
- d.get("cost_usd") or 0,
40
- d.get("rate_limit_percent") or "",
41
- d.get("rate_limit_reset") or "",
42
- )
43
- PY
44
- )
45
- fi
46
-
47
- # Format tokens as N.Nk if >= 1000.
48
- if [[ "$tokens" -ge 1000 ]] 2>/dev/null; then
49
- tokens_h=$(awk "BEGIN {printf \"%.1fk\", $tokens/1000}")
50
- else
51
- tokens_h="${tokens}"
52
- fi
53
-
54
- if [[ "$wi" == "UNBOUND" ]]; then
55
- wi_display="\033[31m📌 UNBOUND — /workitem required\033[0m"
56
- else
57
- wi_display="📌 ${wi}"
58
- fi
59
-
60
- # Prefer rate-limit view (personal mode) when we have it; fall back to cost.
61
- if [[ -n "${percent}" && "${percent}" != "null" && "${percent}" != "None" ]]; then
62
- # Color the % red under 15%, yellow under 40%, green otherwise.
63
- if awk "BEGIN {exit !($percent < 15)}"; then color=31
64
- elif awk "BEGIN {exit !($percent < 40)}"; then color=33
65
- else color=32; fi
66
- if [[ -n "${reset}" && "${reset}" != "null" ]]; then
67
- reset_short=$(printf "%s" "$reset" | cut -c 6-16 | tr T " ")
68
- printf "🛡 %s │ %b │ \033[%sm%s%% weekly\033[0m · resets %s" "$repo" "$wi_display" "$color" "$percent" "$reset_short"
69
- else
70
- printf "🛡 %s │ %b │ \033[%sm%s%% weekly\033[0m" "$repo" "$wi_display" "$color" "$percent"
71
- fi
72
- else
73
- printf "🛡 %s │ %b │ %s tok / \$%.2f" "$repo" "$wi_display" "$tokens_h" "$cost"
74
- fi
@@ -1,46 +0,0 @@
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}")
@@ -1,72 +0,0 @@
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}")
File without changes
File without changes