graphban-cli 0.1.0__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.0
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
@@ -26,32 +26,63 @@ Specified by [PRD-40](https://github.com/asc-me/graphban/blob/main/docs/prd-40-g
26
26
 
27
27
  ## Install
28
28
 
29
- Not on PyPI yet, so it installs from the repository. `uv tool install` puts it on your PATH
30
- in its own environment, which is what you want for a CLI:
29
+ ```bash
30
+ uv tool install graphban-cli
31
+ ```
32
+
33
+ or, on macOS:
31
34
 
32
35
  ```bash
33
- uv tool install "git+https://github.com/asc-me/graphban.git#subdirectory=cli"
36
+ brew install asc-me/tap/gban
34
37
  ```
35
38
 
36
- Add `gbfleet` too if you run waves it is a separate package, and `gban fleet` hands off to it:
39
+ The formula carries no `resource` stanzas`graphban-cli` has no runtime dependencies, so
40
+ there is nothing to vendor and nothing to regenerate when a transitive moves. The supervisor
41
+ is deliberately not in the tap; see below.
42
+
43
+ Add the supervisor too if you run waves — a separate package, and `gban fleet` hands off to
44
+ it. At a terminal, `gban fleet` will offer to run this for you when it finds no supervisor;
45
+ it asks first and never installs on a bare return, because a person who typed a read-only
46
+ command did not consent to software being installed:
37
47
 
38
48
  ```bash
39
- uv tool install "git+https://github.com/asc-me/graphban.git#subdirectory=fleet"
49
+ uv tool install graphban-fleet
40
50
  ```
41
51
 
42
- `uv tool update-shell` once, if uv says the bin directory is not on your PATH. Upgrade either
43
- with `uv tool upgrade graphban-cli` (or `--all`); reinstalling from the same URL also works,
44
- since the spec is a branch rather than a pin.
52
+ That also gives you `gbagent`, the first-party coding agent: it is an entry point of
53
+ `graphban-fleet`, not a package of its own, because the supervisor resolves it on PATH like
54
+ any other vendor binary.
55
+
56
+ `uv tool update-shell` once, if uv says the bin directory is not on your PATH.
57
+ `uv tool upgrade --all` to move both forward.
58
+
59
+ Two commands rather than one, and an extra (`graphban-cli[fleet]`) is deliberately not
60
+ offered: `uv tool install` exposes only the REQUESTED package's executables, so an extra
61
+ installs `gbfleet` into `gban`'s environment and puts it on no path at all. Measured — with
62
+ the extra, `gban fleet` reported "gbfleet is not installed here" while `gbfleet` sat in the
63
+ very environment it was running from.
64
+
65
+ `gban` pulls **nothing**: `client.py` is `urllib.request` throughout, and the install lands
66
+ exactly one distribution. `gbfleet` brings httpx and its transitives, which is why they are
67
+ separate packages and not one.
45
68
 
46
69
  With pip instead, into an environment you already have:
47
70
 
48
71
  ```bash
49
- pip install "graphban-cli @ git+https://github.com/asc-me/graphban.git#subdirectory=cli"
72
+ pip install graphban-cli
50
73
  ```
51
74
 
52
- `gban` pulls **nothing**: `client.py` is `urllib.request` throughout, and the install lands
53
- exactly one distribution. `gbfleet` brings httpx and its four transitive dependencies, which
54
- is why they are separate packages and not one.
75
+ `gban` looks for `gbfleet` beside its own interpreter before falling back to PATH, so that
76
+ shape works with neither on PATH.
77
+
78
+ To run an unreleased change, install from the repository instead — the same spec the release
79
+ builds from:
80
+
81
+ ```bash
82
+ uv tool install "git+https://github.com/asc-me/graphban.git#subdirectory=cli"
83
+ ```
84
+
85
+ Releasing is [docs/releasing.md](https://github.com/asc-me/graphban/blob/main/docs/releasing.md).
55
86
 
56
87
  ```bash
57
88
  gban login --server https://cloud.agentldgr.dev
@@ -77,6 +108,48 @@ role the key does not permit is the server's refusal, printed in the server's ow
77
108
  widening a ceiling means minting a different credential, and keeping those two acts apart is
78
109
  the point of having a ceiling. It lands on the agent's next poll.
79
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
+
80
153
  ## `gban login` wants a real terminal
81
154
 
82
155
  It refuses without one, rather than prompting. `getpass` falls back to a plain **echoing**
@@ -11,32 +11,63 @@ Specified by [PRD-40](https://github.com/asc-me/graphban/blob/main/docs/prd-40-g
11
11
 
12
12
  ## Install
13
13
 
14
- Not on PyPI yet, so it installs from the repository. `uv tool install` puts it on your PATH
15
- in its own environment, which is what you want for a CLI:
14
+ ```bash
15
+ uv tool install graphban-cli
16
+ ```
17
+
18
+ or, on macOS:
16
19
 
17
20
  ```bash
18
- uv tool install "git+https://github.com/asc-me/graphban.git#subdirectory=cli"
21
+ brew install asc-me/tap/gban
19
22
  ```
20
23
 
21
- Add `gbfleet` too if you run waves it is a separate package, and `gban fleet` hands off to it:
24
+ The formula carries no `resource` stanzas`graphban-cli` has no runtime dependencies, so
25
+ there is nothing to vendor and nothing to regenerate when a transitive moves. The supervisor
26
+ is deliberately not in the tap; see below.
27
+
28
+ Add the supervisor too if you run waves — a separate package, and `gban fleet` hands off to
29
+ it. At a terminal, `gban fleet` will offer to run this for you when it finds no supervisor;
30
+ it asks first and never installs on a bare return, because a person who typed a read-only
31
+ command did not consent to software being installed:
22
32
 
23
33
  ```bash
24
- uv tool install "git+https://github.com/asc-me/graphban.git#subdirectory=fleet"
34
+ uv tool install graphban-fleet
25
35
  ```
26
36
 
27
- `uv tool update-shell` once, if uv says the bin directory is not on your PATH. Upgrade either
28
- with `uv tool upgrade graphban-cli` (or `--all`); reinstalling from the same URL also works,
29
- since the spec is a branch rather than a pin.
37
+ That also gives you `gbagent`, the first-party coding agent: it is an entry point of
38
+ `graphban-fleet`, not a package of its own, because the supervisor resolves it on PATH like
39
+ any other vendor binary.
40
+
41
+ `uv tool update-shell` once, if uv says the bin directory is not on your PATH.
42
+ `uv tool upgrade --all` to move both forward.
43
+
44
+ Two commands rather than one, and an extra (`graphban-cli[fleet]`) is deliberately not
45
+ offered: `uv tool install` exposes only the REQUESTED package's executables, so an extra
46
+ installs `gbfleet` into `gban`'s environment and puts it on no path at all. Measured — with
47
+ the extra, `gban fleet` reported "gbfleet is not installed here" while `gbfleet` sat in the
48
+ very environment it was running from.
49
+
50
+ `gban` pulls **nothing**: `client.py` is `urllib.request` throughout, and the install lands
51
+ exactly one distribution. `gbfleet` brings httpx and its transitives, which is why they are
52
+ separate packages and not one.
30
53
 
31
54
  With pip instead, into an environment you already have:
32
55
 
33
56
  ```bash
34
- pip install "graphban-cli @ git+https://github.com/asc-me/graphban.git#subdirectory=cli"
57
+ pip install graphban-cli
35
58
  ```
36
59
 
37
- `gban` pulls **nothing**: `client.py` is `urllib.request` throughout, and the install lands
38
- exactly one distribution. `gbfleet` brings httpx and its four transitive dependencies, which
39
- is why they are separate packages and not one.
60
+ `gban` looks for `gbfleet` beside its own interpreter before falling back to PATH, so that
61
+ shape works with neither on PATH.
62
+
63
+ To run an unreleased change, install from the repository instead — the same spec the release
64
+ builds from:
65
+
66
+ ```bash
67
+ uv tool install "git+https://github.com/asc-me/graphban.git#subdirectory=cli"
68
+ ```
69
+
70
+ Releasing is [docs/releasing.md](https://github.com/asc-me/graphban/blob/main/docs/releasing.md).
40
71
 
41
72
  ```bash
42
73
  gban login --server https://cloud.agentldgr.dev
@@ -62,6 +93,48 @@ role the key does not permit is the server's refusal, printed in the server's ow
62
93
  widening a ceiling means minting a different credential, and keeping those two acts apart is
63
94
  the point of having a ceiling. It lands on the agent's next poll.
64
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
+
65
138
  ## `gban login` wants a real terminal
66
139
 
67
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.0"
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
 
@@ -215,10 +337,17 @@ def cmd_fleet(args) -> int:
215
337
  and leaves `gbfleet --help` authoritative about its own commands.
216
338
  """
217
339
  binary = doctor_mod.find_supervisor()
340
+ if not binary:
341
+ # At a terminal, offer to fix it here rather than making the person read a command,
342
+ # copy it, run it and type this one again. Declined or unavailable, the message is
343
+ # what it always was.
344
+ if doctor_mod.offer_to_install():
345
+ binary = doctor_mod.find_supervisor()
218
346
  if not binary:
219
347
  print(f"{PROG}: gbfleet is not installed here. Install it with:\n"
220
348
  f" {doctor_mod.INSTALL_SUPERVISOR}\n"
221
- f" …or run it from the repository's fleet/ directory.", file=sys.stderr)
349
+ f" …or `brew install asc-me/tap/gban` installs this client only — the "
350
+ f"supervisor is a separate package.", file=sys.stderr)
222
351
  return EXIT_NO_SUPERVISOR
223
352
  argv = [binary, *[a for a in args.rest if a != "--"]]
224
353
  url = config.resolve(args.server, config.URL_ENV, "url")
@@ -351,7 +480,7 @@ def cmd_keys(args) -> int:
351
480
 
352
481
 
353
482
  COMMANDS = {"login": cmd_login, "logout": cmd_logout, "whoami": cmd_whoami,
354
- "doctor": cmd_doctor, "fleet": cmd_fleet, "seats": cmd_seats,
483
+ "doctor": cmd_doctor, "setup": cmd_setup, "fleet": cmd_fleet, "seats": cmd_seats,
355
484
  "agents": cmd_agents, "keys": cmd_keys}
356
485
 
357
486
 
@@ -118,12 +118,86 @@ def local(url: str, project: str, api_key: str) -> list[dict]:
118
118
  f"`gbfleet doctor` {verdict}; its own report follows", report=body)]
119
119
 
120
120
 
121
- #: The command that actually installs the supervisor. NOT `uv pip install graphban-fleet`,
122
- #: which is what this said until somebody ran it: neither package is on PyPI, so that line
123
- #: 404s. A tool whose remedy does not work is worse than one that offers none — it spends the
124
- #: reader's trust before spending their time.
125
- INSTALL_SUPERVISOR = (
126
- 'uv tool install "git+https://github.com/asc-me/graphban.git#subdirectory=fleet"')
121
+ #: The command that actually installs the supervisor. It has been wrong twice: first naming a
122
+ #: PyPI package that did not exist, then a git spec that worked but stopped being the right
123
+ #: advice the moment `graphban-fleet` was published. A remedy a tool prints is a promise, and
124
+ #: `cli/tests/test_packaging.py` now checks this one against the same source the README uses.
125
+ INSTALL_SUPERVISOR = "uv tool install graphban-fleet"
126
+
127
+
128
+ def offer_to_install(stream=None) -> bool:
129
+ """Ask, then install `graphban-fleet` with uv. Returns whether it is there afterwards.
130
+
131
+ **Asked, never assumed.** Installing software is not a side effect anybody should get
132
+ from `gban fleet ps`: it writes outside this program, it takes a minute, and a person
133
+ who typed a read-only command did not consent to it. So it happens only at an
134
+ interactive prompt, only on an explicit `y`, and only when the wall has actually been
135
+ hit — a missing supervisor, on the command that needs one.
136
+
137
+ Nothing is installed into `gban`'s OWN environment. `uv tool install` gives the
138
+ supervisor its own, which is the only correct answer when `gban` may itself live in a
139
+ Homebrew Cellar that brew will replace: a `pip install` next to it would be silently
140
+ undone by the next upgrade.
141
+
142
+ Declines, non-interactive shells and a machine without uv all return False and leave the
143
+ caller to print the command — a prompt nobody can answer is a hang, and a hang in a
144
+ script is worse than the error it replaced.
145
+ """
146
+ import sys
147
+
148
+ out = stream or sys.stderr
149
+ if not sys.stdin.isatty():
150
+ return False
151
+ if not shutil.which("uv"):
152
+ return False
153
+ print(f"gban: gbfleet is not installed here.", file=out)
154
+ try:
155
+ answer = input(f" run `{INSTALL_SUPERVISOR}` now? [y/N] ").strip().lower()
156
+ except (EOFError, KeyboardInterrupt):
157
+ print("", file=out)
158
+ return False
159
+ if answer not in ("y", "yes"):
160
+ return False
161
+ done = subprocess.run(INSTALL_SUPERVISOR.split())
162
+ if done.returncode != 0:
163
+ print(f"gban: that install failed ({done.returncode}); nothing changed here.",
164
+ file=out)
165
+ return False
166
+ return bool(find_supervisor())
167
+
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, ""
127
201
 
128
202
 
129
203
  def find_supervisor() -> str: