graphban-cli 0.1.1__tar.gz → 0.2.0__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: graphban-cli
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: gban — a Graphban client for the human at a terminal
5
5
  Project-URL: Homepage, https://github.com/asc-me/graphban
6
6
  Project-URL: Repository, https://github.com/asc-me/graphban
@@ -108,6 +108,48 @@ role the key does not permit is the server's refusal, printed in the server's ow
108
108
  widening a ceiling means minting a different credential, and keeping those two acts apart is
109
109
  the point of having a ceiling. It lands on the agent's next poll.
110
110
 
111
+ ## Enabling delegation on a project
112
+
113
+ ```bash
114
+ gban login # once, at a terminal
115
+ gban setup # everything mechanical between that and a delegating agent
116
+ gban setup --auto # …or every project whose repository sits here or beside here
117
+ ```
118
+
119
+ An agent with the delegation skill runs all of this for you except `gban login`, which it
120
+ hands back as a `! gban login` line to type — it needs a terminal, and no agent has one — and
121
+ then carries on from where you left it.
122
+
123
+ **The directory names the project.** Run `setup` from inside the repository the work belongs
124
+ to and it matches that directory against the projects you can read. An explicit `--project`
125
+ wins; nothing else does. In particular the default `gban login` stores is *not* used here and
126
+ is named in the refusal when it exists — logging in once inside one project must not quietly
127
+ mint a credential for it while you are standing in another repository, and a key in the wrong
128
+ project is not a mistake anybody notices quickly.
129
+
130
+ `setup` mints a project-scoped credential, writes the `graphban` and `gbfleet` MCP entries,
131
+ installs the supervisor if it is missing, drops the delegation skill into `.claude/skills/`,
132
+ and then **verifies** rather than asserting: it asks the new credential what it can actually
133
+ see. Restart the harness afterwards — MCP servers are read at startup.
134
+
135
+ Three properties worth knowing, each of which is a bug this command exists to not have:
136
+
137
+ - **The credential does not expire.** `gban keys mint` produces a *wave* key, which lasts a
138
+ day (`FLEET_KEY_DAYS`); that is right for a wave and wrong for a project. Only seats expire.
139
+ - **It writes where the harness will actually read.** `~/.claude.json`'s per-project
140
+ `mcpServers` outranks a repository `.mcp.json`, so writing the repository file under a
141
+ stale entry leaves the agent on the old key — which surfaces as a JSON parse error, because
142
+ the harness is parsing a 401 body. `--scope user` is the default for that reason, and
143
+ because a credential outside the repository cannot be committed.
144
+ - **`--auto` matches, and says so.** A project carries no repository link — no remote, no
145
+ path — so `--auto` compares your project ids and names against this directory, what is in
146
+ it, and its siblings. One level, never a recursive walk. Two directories answering to one
147
+ project, or one directory answering to two, are **refused rather than guessed**: a
148
+ credential minted into the wrong repository is not a mistake anybody notices quickly.
149
+ - **It refuses to write a key into a file git tracks.** `--scope project` on a tracked
150
+ `.mcp.json` is refused rather than warned about, because a warning attached to committing a
151
+ credential still commits it.
152
+
111
153
  ## `gban login` wants a real terminal
112
154
 
113
155
  It refuses without one, rather than prompting. `getpass` falls back to a plain **echoing**
@@ -93,6 +93,48 @@ role the key does not permit is the server's refusal, printed in the server's ow
93
93
  widening a ceiling means minting a different credential, and keeping those two acts apart is
94
94
  the point of having a ceiling. It lands on the agent's next poll.
95
95
 
96
+ ## Enabling delegation on a project
97
+
98
+ ```bash
99
+ gban login # once, at a terminal
100
+ gban setup # everything mechanical between that and a delegating agent
101
+ gban setup --auto # …or every project whose repository sits here or beside here
102
+ ```
103
+
104
+ An agent with the delegation skill runs all of this for you except `gban login`, which it
105
+ hands back as a `! gban login` line to type — it needs a terminal, and no agent has one — and
106
+ then carries on from where you left it.
107
+
108
+ **The directory names the project.** Run `setup` from inside the repository the work belongs
109
+ to and it matches that directory against the projects you can read. An explicit `--project`
110
+ wins; nothing else does. In particular the default `gban login` stores is *not* used here and
111
+ is named in the refusal when it exists — logging in once inside one project must not quietly
112
+ mint a credential for it while you are standing in another repository, and a key in the wrong
113
+ project is not a mistake anybody notices quickly.
114
+
115
+ `setup` mints a project-scoped credential, writes the `graphban` and `gbfleet` MCP entries,
116
+ installs the supervisor if it is missing, drops the delegation skill into `.claude/skills/`,
117
+ and then **verifies** rather than asserting: it asks the new credential what it can actually
118
+ see. Restart the harness afterwards — MCP servers are read at startup.
119
+
120
+ Three properties worth knowing, each of which is a bug this command exists to not have:
121
+
122
+ - **The credential does not expire.** `gban keys mint` produces a *wave* key, which lasts a
123
+ day (`FLEET_KEY_DAYS`); that is right for a wave and wrong for a project. Only seats expire.
124
+ - **It writes where the harness will actually read.** `~/.claude.json`'s per-project
125
+ `mcpServers` outranks a repository `.mcp.json`, so writing the repository file under a
126
+ stale entry leaves the agent on the old key — which surfaces as a JSON parse error, because
127
+ the harness is parsing a 401 body. `--scope user` is the default for that reason, and
128
+ because a credential outside the repository cannot be committed.
129
+ - **`--auto` matches, and says so.** A project carries no repository link — no remote, no
130
+ path — so `--auto` compares your project ids and names against this directory, what is in
131
+ it, and its siblings. One level, never a recursive walk. Two directories answering to one
132
+ project, or one directory answering to two, are **refused rather than guessed**: a
133
+ credential minted into the wrong repository is not a mistake anybody notices quickly.
134
+ - **It refuses to write a key into a file git tracks.** `--scope project` on a tracked
135
+ `.mcp.json` is refused rather than warned about, because a warning attached to committing a
136
+ credential still commits it.
137
+
96
138
  ## `gban login` wants a real terminal
97
139
 
98
140
  It refuses without one, rather than prompting. `getpass` falls back to a plain **echoing**
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphban-cli"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  description = "gban — a Graphban client for the human at a terminal"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -13,8 +13,9 @@ import os
13
13
  import shutil
14
14
  import subprocess
15
15
  import sys
16
+ from pathlib import Path
16
17
 
17
- from gban import config, doctor as doctor_mod
18
+ from gban import config, doctor as doctor_mod, setup as setup_mod
18
19
  from gban.client import (EXIT_NO_SESSION, EXIT_NO_SUPERVISOR, EXIT_REFUSED, EXIT_UNREACHABLE,
19
20
  Client, NoSession,
20
21
  Refused, Unreachable, authenticated, login)
@@ -52,6 +53,26 @@ def _parser() -> argparse.ArgumentParser:
52
53
  "Neither half silences the other: what could not be checked prints "
53
54
  "UNKNOWN with its reason, never a pass."))
54
55
 
56
+ setup_cmd = sub.add_parser(
57
+ "setup", help="enable delegation on a project",
58
+ description=("Mints a project-scoped credential that does NOT expire, writes the "
59
+ "graphban and gbfleet MCP entries where the harness will actually read "
60
+ "them, and installs the supervisor. Only seats expire; a credential that "
61
+ "died overnight would make 'delegation is set up' quietly stop being "
62
+ "true. Re-running is safe: a working configuration is left alone."))
63
+ setup_cmd.add_argument(
64
+ "--scope", choices=["user", "project"], default="user",
65
+ help="user (default) writes ~/.claude.json, which OUTRANKS a repo .mcp.json and "
66
+ "cannot be committed; project writes .mcp.json beside the repo")
67
+ setup_cmd.add_argument(
68
+ "--auto", action="store_true",
69
+ help="every project with a repository here: matches this directory, what is in it and "
70
+ "its siblings against your projects BY NAME, and refuses any ambiguity rather "
71
+ "than guessing. A project carries no repository link, so this is a match, not a "
72
+ "lookup")
73
+ setup_cmd.add_argument("--no-install", action="store_true",
74
+ help="do not install the supervisor")
75
+
55
76
  fleet = sub.add_parser(
56
77
  "fleet", help="hand off to gbfleet (the supervisor)",
57
78
  description=("Passes everything through to `gbfleet` and returns its exit code "
@@ -150,15 +171,52 @@ def cmd_login(args) -> int:
150
171
  if not refresh:
151
172
  print(f"{PROG}: the server returned no refresh token", file=sys.stderr)
152
173
  return EXIT_REFUSED
153
- config.save_settings(url=url, project=config.resolve(args.project, config.PROJECT_ENV,
154
- "project"))
155
174
  path = config.save_session(refresh, user=email)
156
- _out({"server": url, "session": str(path), "user": email},
157
- f"{PROG}: signed in to {url} as {email}\n session stored at {path} (mode 600)",
158
- args.as_json)
175
+ asked = config.resolve(args.project, config.PROJECT_ENV, "project")
176
+ chosen, projects, why = _default_project(url, asked)
177
+ config.save_settings(url=url, project=chosen)
178
+ human = [f"{PROG}: signed in to {url} as {email}",
179
+ f" session stored at {path} (mode 600)"]
180
+ if chosen:
181
+ human.append(f" project {chosen}" + (" (the only one you can read)"
182
+ if not asked and len(projects) == 1 else ""))
183
+ human.append(f" next: `{PROG} setup` enables delegation on it")
184
+ elif len(projects) > 1:
185
+ human.append(f" {len(projects)} projects — none set as default:")
186
+ human += [f" {p.get('id', ''):<20} {p.get('name', '')}" for p in projects]
187
+ human.append(f" pick one: `{PROG} setup --project <id>`")
188
+ elif why:
189
+ # Discovery failed, the login did not. Reporting this as a failed login would send
190
+ # somebody to re-enter a password that was already accepted.
191
+ human.append(f" WARNING: could not list your projects ({why}).")
192
+ human.append(f" Name one when you need it: `{PROG} setup --project <id>`")
193
+ else:
194
+ human.append(" you can read no projects yet — create one in the web app first")
195
+ _out({"server": url, "session": str(path), "user": email, "project": chosen,
196
+ "projects": [p.get("id") for p in projects]}, "\n".join(human), args.as_json)
159
197
  return 0
160
198
 
161
199
 
200
+ def _default_project(url: str, asked: str) -> tuple[str, list[dict], str]:
201
+ """What `login` should store as the default, the projects it saw, and why it saw none.
202
+
203
+ ONE project is chosen for you; several are listed and none is chosen. Picking the first of
204
+ several would be a coin toss whose result is invisible until a key lands in the wrong
205
+ project, and the list is short enough to read.
206
+
207
+ An explicit `--project` is never overridden — the person naming one has said the thing
208
+ this function exists to guess.
209
+ """
210
+ if asked:
211
+ return asked, [], ""
212
+ try:
213
+ rows = authenticated(url, act="login").call("GET", "/api/projects")
214
+ except (Unreachable, Refused, NoSession) as exc:
215
+ return "", [], str(exc)
216
+ projects = [p for p in (rows if isinstance(rows, list) else []) if isinstance(p, dict)]
217
+ return (projects[0].get("id", "") if len(projects) == 1 else ""), projects, ""
218
+
219
+
162
220
  def cmd_logout(args) -> int:
163
221
  url = _server(args)
164
222
  told_server, why = False, ""
@@ -208,6 +266,70 @@ def cmd_doctor(args) -> int:
208
266
  return code
209
267
 
210
268
 
269
+ def cmd_setup(args) -> int:
270
+ """Everything between a session and a delegating agent, in one act (GRPH-792)."""
271
+ url = _server(args)
272
+ # THE SESSION FIRST, and the order is the point. Resolving the project first told somebody
273
+ # who had never logged in that they had "no project" — true about the wrong thing, with
274
+ # the wrong exit code (1 rather than 3), and it sends a reader hunting for a project id
275
+ # when the remedy is a person at a terminal. Found by running the built wheel rather than
276
+ # by a test, because every test here starts from a stored session.
277
+ client = authenticated(url, act="setup")
278
+ if args.auto:
279
+ return _setup_auto(args, url, client)
280
+ # EXPLICIT ONLY. `config.resolve` would fold in the default `gban login` stored, and this
281
+ # is the one verb where that default is dangerous rather than convenient — see
282
+ # `setup.resolve_project`.
283
+ asked = (args.project or os.environ.get(config.PROJECT_ENV) or "").strip()
284
+ try:
285
+ project, how = setup_mod.resolve_project(
286
+ client, Path.cwd(), asked, config.settings().get("project", ""))
287
+ except setup_mod.Unresolved as exc:
288
+ print(f"{PROG}: {exc}", file=sys.stderr)
289
+ return EXIT_REFUSED
290
+ lines, code, made = setup_mod.run(client, url, project, Path.cwd(), scope=args.scope,
291
+ install=not args.no_install)
292
+ human = [doctor_mod.render(lines)]
293
+ if how != "named":
294
+ human.insert(0, f"{PROG}: {project} — {how}")
295
+ if code == 0:
296
+ human.append(f"\n delegation is enabled on {project}. An agent can now "
297
+ f"`delegate(id=…, lane=…, tier=…, seat=true)` then `spawn`.")
298
+ human.append(f" restart the harness so it reads the new config.")
299
+ _out({"lines": lines, "ok": code == 0, "project": project, **made},
300
+ "\n".join(human), args.as_json)
301
+ return code
302
+
303
+
304
+ def _setup_auto(args, url: str, client) -> int:
305
+ """Every project that has a repository here, each in its own.
306
+
307
+ Reports what it did NOT match as loudly as what it did. A sweep that silently skipped a
308
+ project would leave somebody believing delegation is enabled everywhere.
309
+ """
310
+ projects = client.call("GET", "/api/projects")
311
+ projects = [p for p in (projects if isinstance(projects, list) else []) if isinstance(p, dict)]
312
+ found, notes = setup_mod.match(projects, Path.cwd())
313
+ if not found:
314
+ print(doctor_mod.render(notes) if notes else
315
+ f"{PROG}: no projects to match", file=sys.stderr)
316
+ print(f"{PROG}: matched no repository here. `{PROG} setup --project <id>` from inside "
317
+ f"one names it directly.", file=sys.stderr)
318
+ return EXIT_REFUSED
319
+ lines, worst, done = list(notes), 0, {}
320
+ for pid, repo in sorted(found.items()):
321
+ lines.append(doctor_mod._line("match", doctor_mod.PASS, pid, str(repo)))
322
+ got, code, made = setup_mod.run(client, url, pid, repo, scope=args.scope,
323
+ install=not args.no_install)
324
+ lines += [{**l, "name": f"{pid}: {l['name']}"} for l in got]
325
+ done[pid], worst = {"repo": str(repo), "ok": code == 0, **made}, max(worst, code)
326
+ human = [doctor_mod.render(lines),
327
+ f"\n {sum(1 for d in done.values() if d['ok'])} of {len(done)} enabled. "
328
+ f"Restart the harness so it reads the new config."]
329
+ _out({"lines": lines, "ok": worst == 0, "projects": done}, "\n".join(human), args.as_json)
330
+ return worst
331
+
332
+
211
333
  def cmd_fleet(args) -> int:
212
334
  """A subprocess, never an import (D5).
213
335
 
@@ -358,7 +480,7 @@ def cmd_keys(args) -> int:
358
480
 
359
481
 
360
482
  COMMANDS = {"login": cmd_login, "logout": cmd_logout, "whoami": cmd_whoami,
361
- "doctor": cmd_doctor, "fleet": cmd_fleet, "seats": cmd_seats,
483
+ "doctor": cmd_doctor, "setup": cmd_setup, "fleet": cmd_fleet, "seats": cmd_seats,
362
484
  "agents": cmd_agents, "keys": cmd_keys}
363
485
 
364
486
 
@@ -166,6 +166,40 @@ def offer_to_install(stream=None) -> bool:
166
166
  return bool(find_supervisor())
167
167
 
168
168
 
169
+ def install_supervisor(timeout: float = 600.0) -> tuple[bool, str]:
170
+ """Install it, without asking. `(installed, why_not)`.
171
+
172
+ `offer_to_install` prompts because it is reached from `gban fleet`, where a person typed a
173
+ read-only command and did not consent to software being installed. `gban setup` is the
174
+ other case entirely: it mints a credential and rewrites the harness config, and installing
175
+ the supervisor is squarely inside what "enable delegation on this project" asks for. So
176
+ the consent argument is satisfied by the verb rather than by a second prompt — which an
177
+ agent driving this could not answer anyway.
178
+
179
+ Still nothing into `gban`'s own environment: `uv tool install` gives the supervisor its
180
+ own, which is the only correct answer when `gban` may live in a Homebrew Cellar that brew
181
+ will replace.
182
+ """
183
+ if not shutil.which("uv"):
184
+ return False, ("uv is not on PATH, so there is nothing to install with — "
185
+ "`brew install uv`, or see https://docs.astral.sh/uv/")
186
+ try:
187
+ done = subprocess.run(INSTALL_SUPERVISOR.split(), capture_output=True, text=True,
188
+ timeout=timeout)
189
+ except (OSError, subprocess.SubprocessError) as exc:
190
+ return False, f"`{INSTALL_SUPERVISOR}` could not run: {exc}"
191
+ if done.returncode != 0:
192
+ tail = (done.stderr or done.stdout or "").strip().splitlines()
193
+ return False, (f"`{INSTALL_SUPERVISOR}` failed ({done.returncode})"
194
+ + (f": {tail[-1][:160]}" if tail else ""))
195
+ if not find_supervisor():
196
+ # It reported success and the binary is not resolvable. Almost always uv's tool bin
197
+ # directory missing from PATH, which is a real state and not a failed install.
198
+ return False, ("installed, but `gbfleet` is not on PATH — run `uv tool update-shell` "
199
+ "and open a new shell")
200
+ return True, ""
201
+
202
+
169
203
  def find_supervisor() -> str:
170
204
  """`gbfleet`, from beside this interpreter first and only then from PATH.
171
205