graphban-cli 0.8.3__tar.gz → 0.8.5__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 (37) hide show
  1. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/PKG-INFO +14 -3
  2. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/README.md +13 -2
  3. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/pyproject.toml +1 -1
  4. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/cli.py +59 -6
  5. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/client.py +9 -0
  6. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/doctor.py +90 -10
  7. graphban_cli-0.8.5/src/gban/machine.py +91 -0
  8. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/setup.py +68 -3
  9. graphban_cli-0.8.5/src/gban/skills/graphban-answer/SKILL.md +103 -0
  10. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/skills/graphban-delegation/SKILL.md +2 -1
  11. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/skills/graphban-review/SKILL.md +2 -0
  12. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/skills/graphban-wave/SKILL.md +2 -0
  13. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/skills/graphban-work/SKILL.md +6 -0
  14. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/skills/watch-wave/SKILL.md +4 -3
  15. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/swamp.py +6 -4
  16. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/tests/test_doctor.py +42 -0
  17. graphban_cli-0.8.5/tests/test_login_pair.py +46 -0
  18. graphban_cli-0.8.5/tests/test_machine_report.py +81 -0
  19. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/tests/test_setup.py +191 -1
  20. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/tests/test_swamp.py +15 -1
  21. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/uv.lock +1 -1
  22. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/.gitignore +0 -0
  23. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/LICENSE +0 -0
  24. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/__init__.py +0 -0
  25. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/config.py +0 -0
  26. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/data/stack_sdks.toml +0 -0
  27. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/gitignore.py +0 -0
  28. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/skills/graphban-grill/SKILL.md +0 -0
  29. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/skills/graphban-orient/SKILL.md +0 -0
  30. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/skills/graphban-prd/SKILL.md +0 -0
  31. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/skills/graphban-scan/SKILL.md +0 -0
  32. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/stackmap.py +0 -0
  33. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/tests/test_acts.py +0 -0
  34. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/tests/test_login_projects.py +0 -0
  35. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/tests/test_packaging.py +0 -0
  36. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/tests/test_session.py +0 -0
  37. {graphban_cli-0.8.3 → graphban_cli-0.8.5}/tests/test_stackmap.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphban-cli
3
- Version: 0.8.3
3
+ Version: 0.8.5
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
@@ -87,6 +87,7 @@ Releasing is [docs/releasing.md](https://github.com/asc-me/graphban/blob/main/do
87
87
  ```bash
88
88
  gban login --server https://cloud.agentldgr.dev
89
89
  gban doctor # both halves: the ledger, and the local fleet
90
+ # (also reports the result to the server; --no-report opts out)
90
91
  gban agents # the roster, and why an agent is stuck
91
92
  gban agents role SA-A4 planner # what used to need a browser
92
93
  gban seats issue worker worker planner # one entry per agent
@@ -171,6 +172,15 @@ Three properties worth knowing, each of which is a bug this command exists to no
171
172
  committed. It asks git (`check-ignore`), so an equivalent pattern already in the
172
173
  file is left alone.
173
174
 
175
+ ## Machine reports
176
+
177
+ `gban setup` and `gban doctor` send the server a report of their PASS/FAIL/UNKNOWN lines, tagged
178
+ with a random machine id kept in `~/.graphban/machine.json` (delete it and you are a new
179
+ machine). A report whose every line is PASS turns the dashboard's "Connect your agent" step ✓ (an UNKNOWN is never a pass); the list is
180
+ under Settings → Machines. Pass `--no-report` to skip it. When the report cannot be delivered,
181
+ the command prints `UNKNOWN report could not report to <server>: <reason>` and still exits on
182
+ its own checks — an undelivered report never counts as proof.
183
+
174
184
  ## Wiring a checkout to Swamp
175
185
 
176
186
  ```bash
@@ -180,7 +190,8 @@ gban swamp setup
180
190
  Steps 3 and 5 of [the Swamp runbook](https://github.com/asc-me/graphban/blob/main/docs/swamp.md):
181
191
  `repo init`, `extension source add`, `vault create`, and the **gate** credential — minted,
182
192
  piped to `swamp vault put` on stdin, then checked for the scopes it actually came back with.
183
- Every step is skipped when already done.
193
+ Every step is skipped when already done. When the vault secret is already set it is left alone;
194
+ `gban swamp setup --replace` mints a new gate credential and overwrites that vault entry.
184
195
 
185
196
  Two things it will not do. It does not install Swamp, because that install pipes a remote
186
197
  script into a shell. And it never writes the gate key into an MCP config: `gban setup`'s agent
@@ -220,7 +231,7 @@ A real post needs a git commit (there is nothing to send for a repo with no HEAD
220
231
  It refuses without one, rather than prompting. `getpass` falls back to a plain **echoing**
221
232
  read when it cannot turn echo off — it warns, but the warning arrives after the person has
222
233
  decided to type — so a login through a pipe, a heredoc or an editor's command runner would
223
- put the password in the scrollback. There is no non-interactive login yet (PRD-40 open
234
+ put the password in the scrollback. `gban login --pair` is the alternative with no password prompt: it prints a short code, and a person signed in to the web app approves it in the quick-start dialog (step ③). The session you get is the approver's, and the code is single-use and expires after 10 minutes. There is no unattended login yet (PRD-40 open
224
235
  question 2: an API key cannot reach the JWT routes, so CI would need a service session).
225
236
 
226
237
  ## Why not `gb`
@@ -72,6 +72,7 @@ Releasing is [docs/releasing.md](https://github.com/asc-me/graphban/blob/main/do
72
72
  ```bash
73
73
  gban login --server https://cloud.agentldgr.dev
74
74
  gban doctor # both halves: the ledger, and the local fleet
75
+ # (also reports the result to the server; --no-report opts out)
75
76
  gban agents # the roster, and why an agent is stuck
76
77
  gban agents role SA-A4 planner # what used to need a browser
77
78
  gban seats issue worker worker planner # one entry per agent
@@ -156,6 +157,15 @@ Three properties worth knowing, each of which is a bug this command exists to no
156
157
  committed. It asks git (`check-ignore`), so an equivalent pattern already in the
157
158
  file is left alone.
158
159
 
160
+ ## Machine reports
161
+
162
+ `gban setup` and `gban doctor` send the server a report of their PASS/FAIL/UNKNOWN lines, tagged
163
+ with a random machine id kept in `~/.graphban/machine.json` (delete it and you are a new
164
+ machine). A report whose every line is PASS turns the dashboard's "Connect your agent" step ✓ (an UNKNOWN is never a pass); the list is
165
+ under Settings → Machines. Pass `--no-report` to skip it. When the report cannot be delivered,
166
+ the command prints `UNKNOWN report could not report to <server>: <reason>` and still exits on
167
+ its own checks — an undelivered report never counts as proof.
168
+
159
169
  ## Wiring a checkout to Swamp
160
170
 
161
171
  ```bash
@@ -165,7 +175,8 @@ gban swamp setup
165
175
  Steps 3 and 5 of [the Swamp runbook](https://github.com/asc-me/graphban/blob/main/docs/swamp.md):
166
176
  `repo init`, `extension source add`, `vault create`, and the **gate** credential — minted,
167
177
  piped to `swamp vault put` on stdin, then checked for the scopes it actually came back with.
168
- Every step is skipped when already done.
178
+ Every step is skipped when already done. When the vault secret is already set it is left alone;
179
+ `gban swamp setup --replace` mints a new gate credential and overwrites that vault entry.
169
180
 
170
181
  Two things it will not do. It does not install Swamp, because that install pipes a remote
171
182
  script into a shell. And it never writes the gate key into an MCP config: `gban setup`'s agent
@@ -205,7 +216,7 @@ A real post needs a git commit (there is nothing to send for a repo with no HEAD
205
216
  It refuses without one, rather than prompting. `getpass` falls back to a plain **echoing**
206
217
  read when it cannot turn echo off — it warns, but the warning arrives after the person has
207
218
  decided to type — so a login through a pipe, a heredoc or an editor's command runner would
208
- put the password in the scrollback. There is no non-interactive login yet (PRD-40 open
219
+ put the password in the scrollback. `gban login --pair` is the alternative with no password prompt: it prints a short code, and a person signed in to the web app approves it in the quick-start dialog (step ③). The session you get is the approver's, and the code is single-use and expires after 10 minutes. There is no unattended login yet (PRD-40 open
209
220
  question 2: an API key cannot reach the JWT routes, so CI would need a service session).
210
221
 
211
222
  ## Why not `gb`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphban-cli"
3
- version = "0.8.3"
3
+ version = "0.8.5"
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,12 +13,14 @@ import os
13
13
  import shutil
14
14
  import subprocess
15
15
  import sys
16
+ import time
16
17
  from pathlib import Path
17
18
 
18
- from gban import config, doctor as doctor_mod, setup as setup_mod, stackmap, swamp as swamp_mod
19
+ from gban import config, doctor as doctor_mod, machine as machine_mod, setup as setup_mod, stackmap, swamp as swamp_mod
19
20
  from gban.client import (EXIT_NO_SESSION, EXIT_NO_SUPERVISOR, EXIT_REFUSED, EXIT_UNREACHABLE,
20
21
  Client, NoSession,
21
- Refused, Unreachable, authenticated, login)
22
+ Refused, Unreachable, authenticated, login, pair_poll,
23
+ pair_start)
22
24
 
23
25
  PROG = "gban"
24
26
 
@@ -44,14 +46,20 @@ def _parser() -> argparse.ArgumentParser:
44
46
  "written to session.json at mode 600; the access token is never stored, "
45
47
  "because it is renewed on every invocation anyway."))
46
48
  login_cmd.add_argument("--email", default=None)
49
+ login_cmd.add_argument(
50
+ "--pair", action="store_true",
51
+ help=("sign in without typing a password here: prints a short code that a signed-in "
52
+ "person approves in the web app's quick-start dialog"))
47
53
 
48
54
  sub.add_parser("logout", help="end the session, here and on the server")
49
- sub.add_parser(
55
+ doctor_cmd = sub.add_parser(
50
56
  "doctor", help="check both halves: the ledger, and the local fleet",
51
57
  description=("Everything that can be checked before anything is spawned. The ledger "
52
58
  "half runs here; the local half is `gbfleet doctor`, run as a subprocess. "
53
59
  "Neither half silences the other: what could not be checked prints "
54
60
  "UNKNOWN with its reason, never a pass."))
61
+ doctor_cmd.add_argument("--no-report", action="store_true",
62
+ help="do not send the result to the server (Settings → Machines)")
55
63
 
56
64
  setup_cmd = sub.add_parser(
57
65
  "setup", help="enable delegation on a project",
@@ -75,6 +83,8 @@ def _parser() -> argparse.ArgumentParser:
75
83
  "lookup")
76
84
  setup_cmd.add_argument("--no-install", action="store_true",
77
85
  help="do not install the supervisor")
86
+ setup_cmd.add_argument("--no-report", action="store_true",
87
+ help="do not send the result to the server (Settings → Machines)")
78
88
 
79
89
  swamp_cmd = sub.add_parser(
80
90
  "swamp", help="wire this checkout to Swamp's Graphban adapter",
@@ -88,6 +98,9 @@ def _parser() -> argparse.ArgumentParser:
88
98
  swamp_setup = swamp_do.add_parser("setup", help="init, source, vault, and the gate key")
89
99
  swamp_setup.add_argument("--adapter", default=swamp_mod.DEFAULT_ADAPTER,
90
100
  help=f"the graphban-swamp checkout (default: {swamp_mod.DEFAULT_ADAPTER})")
101
+ swamp_setup.add_argument("--replace", action="store_true",
102
+ help="when the vault secret is already set, mint a new gate "
103
+ "credential and overwrite it")
91
104
  swamp_setup.add_argument("--no-init", action="store_true",
92
105
  help="refuse rather than run `swamp repo init` here")
93
106
 
@@ -180,6 +193,8 @@ def _server(args) -> str:
180
193
 
181
194
  def cmd_login(args) -> int:
182
195
  url = _server(args)
196
+ if args.pair:
197
+ return _login_pair(args, url)
183
198
  # A TERMINAL, OR NOTHING. `getpass` falls back to a plain echoing read when it cannot
184
199
  # turn echo off, and warns about it — which is the wrong trade for a password: the
185
200
  # warning arrives after the person has already decided to type. Without a tty the
@@ -202,7 +217,38 @@ def cmd_login(args) -> int:
202
217
  # same rule criterion 4 applies to an expired session applies to a cancelled login.
203
218
  print(f"\n{PROG}: cancelled", file=sys.stderr)
204
219
  return EXIT_REFUSED
205
- pair = login(url, email, password)
220
+ return _finish_login(args, url, email, login(url, email, password))
221
+
222
+
223
+ def _login_pair(args, url: str) -> int:
224
+ """Device-code sign-in (GRPH-1366): the identity is whoever approves the code in the app."""
225
+ started = pair_start(url)
226
+ code = started.get("user_code") or ""
227
+ print(f"{PROG}: open the quick-start dialog in {url} (step 3) and enter this code:\n\n"
228
+ f" {code}\n\n waiting for approval (expires in "
229
+ f"{int(started.get('expires_in', 0)) // 60} min)…", file=sys.stderr)
230
+ deadline = time.monotonic() + float(started.get("expires_in", 600))
231
+ interval = max(float(started.get("interval", 3)), 0.0)
232
+ try:
233
+ while time.monotonic() < deadline:
234
+ time.sleep(interval)
235
+ try:
236
+ got = pair_poll(url, started.get("device_code", ""))
237
+ except Refused:
238
+ print(f"{PROG}: the code expired or was already used. Run `{PROG} login --pair` "
239
+ f"again.", file=sys.stderr)
240
+ return EXIT_REFUSED
241
+ if got.get("status") == "approved":
242
+ return _finish_login(args, url, got.get("email") or "", got)
243
+ except KeyboardInterrupt:
244
+ print(f"\n{PROG}: cancelled", file=sys.stderr)
245
+ return EXIT_REFUSED
246
+ print(f"{PROG}: the code expired before anyone approved it. Run `{PROG} login --pair` "
247
+ f"again.", file=sys.stderr)
248
+ return EXIT_REFUSED
249
+
250
+
251
+ def _finish_login(args, url: str, email: str, pair: dict) -> int:
206
252
  refresh = pair.get("refresh_token") or ""
207
253
  if not refresh:
208
254
  print(f"{PROG}: the server returned no refresh token", file=sys.stderr)
@@ -322,8 +368,10 @@ def _cwd_project_mismatch(client, configured: str) -> str:
322
368
  def cmd_doctor(args) -> int:
323
369
  url = config.resolve(args.server, config.URL_ENV, "url")
324
370
  project = config.resolve(args.project, config.PROJECT_ENV, "project")
325
- api_key = doctor_mod.resolve_supervisor_key(project)
371
+ api_key = doctor_mod.resolve_supervisor_key(project, url=url)
326
372
  lines, code = doctor_mod.run(url, project, api_key)
373
+ if not getattr(args, "no_report", False):
374
+ lines += machine_mod.send(url, project, lines) # UNKNOWN when undelivered: exit unchanged
327
375
  _out({"lines": lines, "ok": code == 0}, doctor_mod.render(lines), args.as_json)
328
376
  return code
329
377
 
@@ -351,6 +399,8 @@ def cmd_setup(args) -> int:
351
399
  return EXIT_REFUSED
352
400
  lines, code, made = setup_mod.run(client, url, project, Path.cwd(), scope=args.scope,
353
401
  install=not args.no_install)
402
+ if not args.no_report:
403
+ lines += machine_mod.send(url, project, lines)
354
404
  human = [doctor_mod.render(lines)]
355
405
  if how != "named":
356
406
  human.insert(0, f"{PROG}: {project} — {how}")
@@ -385,6 +435,8 @@ def _setup_auto(args, url: str, client) -> int:
385
435
  got, code, made = setup_mod.run(client, url, pid, repo, scope=args.scope,
386
436
  install=not args.no_install)
387
437
  lines += [{**l, "name": f"{pid}: {l['name']}"} for l in got]
438
+ if not args.no_report:
439
+ lines += [{**l, "name": f"{pid}: {l['name']}"} for l in machine_mod.send(url, pid, got)]
388
440
  done[pid], worst = {"repo": str(repo), "ok": code == 0, **made}, max(worst, code)
389
441
  human = [doctor_mod.render(lines),
390
442
  f"\n {sum(1 for d in done.values() if d['ok'])} of {len(done)} enabled. "
@@ -415,7 +467,8 @@ def cmd_swamp(args) -> int:
415
467
  print(f"{PROG}: {exc}", file=sys.stderr)
416
468
  return EXIT_REFUSED
417
469
  lines, code = swamp_mod.setup(client, url, project, repo,
418
- Path(args.adapter).expanduser(), init=not args.no_init)
470
+ Path(args.adapter).expanduser(), init=not args.no_init,
471
+ replace=args.replace)
419
472
  human = [f"{PROG}: {project} — {how}" if how != "named" else f"{PROG}: {project}",
420
473
  doctor_mod.render(lines)]
421
474
  if code == 0:
@@ -135,6 +135,15 @@ def login(url: str, email: str, password: str) -> dict:
135
135
  {"email": email, "password": password})
136
136
 
137
137
 
138
+ def pair_start(url: str) -> dict:
139
+ """Begin a device-code sign-in: no password, no credential yet (GRPH-1366)."""
140
+ return Client(url).call("POST", "/api/cli/pair")
141
+
142
+
143
+ def pair_poll(url: str, device_code: str) -> dict:
144
+ return Client(url).call("POST", "/api/cli/pair/token", {"device_code": device_code})
145
+
146
+
138
147
  def authenticated(url: str, *, act: str = "") -> Client:
139
148
  """A client for this invocation: one refresh exchange, access token held in memory.
140
149
 
@@ -249,7 +249,62 @@ def find_supervisor() -> str:
249
249
  SUPERVISOR_KEY_ENV = "GBFLEET_API_KEY"
250
250
 
251
251
 
252
- def resolve_supervisor_key(project: str, repo: Path | None = None) -> str:
252
+ def supervisor_key_candidates(project: str, repo: Path | None = None) -> list[tuple[str, str]]:
253
+ """Every key we could hand the supervisor, as (source name, key), in preference order.
254
+
255
+ The names are for messages; the values never are (GRPH-1346).
256
+ """
257
+ found: list[tuple[str, str]] = []
258
+ env_key = (os.environ.get(config.API_KEY_ENV) or "").strip()
259
+ if env_key:
260
+ found.append((f"${config.API_KEY_ENV}", env_key))
261
+ stored = config.stored_supervisor_key(project)
262
+ if stored:
263
+ found.append(("the key `gban setup` stored for this project", stored))
264
+ for key in _harness_supervisor_keys(repo):
265
+ found.append(("a key `gban setup` wrote into a harness config", key))
266
+ unique: list[tuple[str, str]] = []
267
+ for name, key in found:
268
+ if key not in [k for _, k in unique]:
269
+ unique.append((name, key))
270
+ return unique
271
+
272
+
273
+ # GRPH-1346: the same body as `gbfleet.client.OBSERVATION_PROBE`. It must validate: the
274
+ # route's `item_id` is required, and a body without it is 422 before the credential check,
275
+ # so every key would read as able. A real kind and an item no key resolves: 401/403 is the
276
+ # refusal, 404 "no such item" is the pass, and nothing is written either way.
277
+ PROBE_BODY = {"item_id": "__gbfleet_probe_no_such_item__", "kind": "cut_from"}
278
+
279
+
280
+ def can_supervise(url: str, key: str, timeout: float = 5.0) -> bool | None:
281
+ """Whether the observations route accepts this key: False on 401/403, True on 404 (the
282
+ probe item does not exist, so the credential gate passed) or 2xx, None when the server
283
+ could not be asked or answered anything else — a check that did not run is not a pass."""
284
+ import json
285
+ import urllib.error
286
+ import urllib.request
287
+
288
+ if not url.startswith(("http://", "https://")):
289
+ # urlopen would read `file:` and friends too, and send the credential with it.
290
+ return None
291
+ req = urllib.request.Request(
292
+ url.rstrip("/") + "/api/fleet/observations", data=json.dumps(PROBE_BODY).encode(),
293
+ headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
294
+ method="POST")
295
+ try:
296
+ # bandit B310: the scheme is checked to be http(s) above.
297
+ urllib.request.urlopen(req, timeout=timeout).close() # nosec B310
298
+ except urllib.error.HTTPError as exc:
299
+ if exc.code in (401, 403):
300
+ return False
301
+ return True if exc.code == 404 else None
302
+ except Exception: # noqa: BLE001 — could not ask is not a verdict
303
+ return None
304
+ return True
305
+
306
+
307
+ def resolve_supervisor_key(project: str, repo: Path | None = None, url: str = "") -> str:
253
308
  """The agent key we hand `child_environment` as `api_key`.
254
309
 
255
310
  Order (GRPH-782 remaining hole): `$GRAPHBAN_API_KEY`, then the project-scoped key
@@ -257,19 +312,44 @@ def resolve_supervisor_key(project: str, repo: Path | None = None) -> str:
257
312
  harness dest for this repository. `$GBFLEET_API_KEY` the caller set is preserved
258
313
  inside `child_environment`, so it is not resolved here.
259
314
 
315
+ With `url`, a candidate the observations route refuses is passed over for the next one
316
+ that it accepts (GRPH-1346): a key that can build but not supervise cost a whole wave its
317
+ base, lease and PR records. When none can, the first is returned and `until` refuses it
318
+ by name; `supervisor_key_verdict` says why. Without `url` the order alone decides.
319
+
260
320
  Never the login session. A refresh token is a different kind of credential.
261
321
  """
262
- env_key = (os.environ.get(config.API_KEY_ENV) or "").strip()
263
- if env_key:
264
- return env_key
265
- stored = config.stored_supervisor_key(project)
266
- if stored:
267
- return stored
268
- return _harness_supervisor_key(repo)
322
+ candidates = supervisor_key_candidates(project, repo)
323
+ if not candidates:
324
+ return ""
325
+ if url:
326
+ for _, key in candidates:
327
+ if can_supervise(url, key) is not False:
328
+ return key
329
+ return candidates[0][1]
330
+
331
+
332
+ def supervisor_key_verdict(project: str, url: str, repo: Path | None = None) -> str:
333
+ """Why no candidate key can supervise, naming sources and never values; "" when one can
334
+ or when the server could not be asked."""
335
+ candidates = supervisor_key_candidates(project, repo)
336
+ if not candidates or not url:
337
+ return ""
338
+ results = [(name, can_supervise(url, key)) for name, key in candidates]
339
+ if any(r is not False for _, r in results):
340
+ return ""
341
+ return ("no key can supervise (the server refused each for observations; recording needs "
342
+ "the planner role and a fleet wave or project scope): "
343
+ + "; ".join(name for name, _ in results))
269
344
 
270
345
 
271
346
  def _harness_supervisor_key(repo: Path | None) -> str:
272
- """The key `gban setup` already wrote into a parent-harness dest.
347
+ keys = _harness_supervisor_keys(repo)
348
+ return keys[0] if keys else ""
349
+
350
+
351
+ def _harness_supervisor_keys(repo: Path | None) -> list[str]:
352
+ """The keys `gban setup` already wrote into a parent-harness dest.
273
353
 
274
354
  Lazy-imports setup: that module imports this one, and a top-level cycle would
275
355
  make `gban doctor` fail to import on a machine that has never run setup.
@@ -285,7 +365,7 @@ def _harness_supervisor_key(repo: Path | None) -> str:
285
365
  key = (setup_mod.dest_key(dest, repo) or "").strip()
286
366
  if key.startswith(config.AGENT_KEY_PREFIX) and key not in seen:
287
367
  seen.append(key)
288
- return seen[0] if seen else ""
368
+ return seen
289
369
 
290
370
 
291
371
  def child_environment(api_key: str) -> dict:
@@ -0,0 +1,91 @@
1
+ """Machine identity and the report `gban setup` / `gban doctor` send (GRPH-1442).
2
+
3
+ The machine id is random and lives in `machine.json` in gban's home. Wiping that directory makes
4
+ a new machine, which is the intent: the id names an installation, not a person or a label.
5
+
6
+ A report that cannot be delivered NEVER counts. It comes back as an UNKNOWN line naming the
7
+ server and the reason, and the caller still exits on its own checks.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import platform
12
+ import shutil
13
+ import socket
14
+ import subprocess # nosec B404 - fixed argv, no shell
15
+ import uuid
16
+ from importlib import metadata
17
+
18
+ from gban import config
19
+ from gban.client import NoSession, Refused, Unreachable, authenticated
20
+ from gban.doctor import UNKNOWN, _line
21
+
22
+ MACHINE_FILE = "machine.json"
23
+
24
+
25
+ def machine_id() -> str:
26
+ """The stored id, minted on first use. An unwritable home still yields a (volatile) id."""
27
+ path = config.home() / MACHINE_FILE
28
+ existing = config._read(path).get("machine_id")
29
+ if isinstance(existing, str) and existing:
30
+ return existing
31
+ fresh = "gbm_" + uuid.uuid4().hex
32
+ try:
33
+ config._write(path, {"machine_id": fresh})
34
+ except OSError:
35
+ pass
36
+ return fresh
37
+
38
+
39
+ def _version(dist: str) -> str:
40
+ try:
41
+ return metadata.version(dist)
42
+ except metadata.PackageNotFoundError:
43
+ return ""
44
+
45
+
46
+ def _gbfleet_version() -> str:
47
+ exe = shutil.which("gbfleet")
48
+ if not exe:
49
+ return ""
50
+ try:
51
+ done = subprocess.run([exe, "--version"], capture_output=True, text=True, # nosec B603
52
+ timeout=5)
53
+ return (done.stdout or "").strip().split()[-1] if done.returncode == 0 and done.stdout.strip() else ""
54
+ except (OSError, subprocess.SubprocessError):
55
+ return ""
56
+
57
+
58
+ def _harnesses() -> list[str]:
59
+ return [name for name, binary in (("claude", "claude"), ("grok", "grok"), ("codex", "codex"))
60
+ if shutil.which(binary)]
61
+
62
+
63
+ def payload(project: str, lines: list[dict]) -> dict:
64
+ return {
65
+ "machine_id": machine_id(), "label": socket.gethostname().split(".")[0],
66
+ "os": f"{platform.system()} {platform.release()}".strip(),
67
+ "gban_version": _version("gban"), "gbfleet_version": _gbfleet_version(),
68
+ "harnesses": _harnesses(), "project": project,
69
+ "lines": [{"status": l["status"], "name": l["name"], "detail": l.get("detail", "")}
70
+ for l in lines],
71
+ }
72
+
73
+
74
+ def send(url: str, project: str, lines: list[dict]) -> list[dict]:
75
+ """Deliver the report. Returns the line to append when it could not be delivered, else []."""
76
+ try:
77
+ authenticated(url, act="report").call("POST", "/api/machines/report",
78
+ payload(project, lines))
79
+ except NoSession as exc:
80
+ return [_unreported(url, exc.why)]
81
+ except Refused as exc:
82
+ return [_unreported(url, exc.detail)]
83
+ except Unreachable as exc:
84
+ return [_unreported(url, str(exc))]
85
+ except Exception as exc: # noqa: BLE001 - a report must never take the command down
86
+ return [_unreported(url, f"{type(exc).__name__}: {exc}")]
87
+ return []
88
+
89
+
90
+ def _unreported(url: str, reason: str) -> dict:
91
+ return _line("report", UNKNOWN, "report", f"could not report to {url}: {reason}")
@@ -49,6 +49,26 @@ TIERS = ["fleet", "prd"]
49
49
  LEDGER_SERVER = "graphban"
50
50
  FLEET_SERVER = "gbfleet"
51
51
 
52
+ #: The agents `gban setup` writes a harness config for (see `destinations`). The web
53
+ #: quick-start's agent picker offers the gban route to exactly these and switches the rest to
54
+ #: paste-a-key (GRPH-P67 G9). `scripts/gen_gban_agents.py` exports this to the web bundle;
55
+ #: `cli/tests/test_setup.py` fails when that file drifts, so adding a harness here is the
56
+ #: one edit that changes what the dialog offers.
57
+ SETUP_AGENTS = ("claude", "grok")
58
+
59
+ #: The commands the quick-start dialog's gban route shows, in order, with `{server}` and
60
+ #: `{project}` filled in by the web app. Exported beside SETUP_AGENTS so the dialog never
61
+ #: types its own copy; `cli/tests/test_setup.py` parses each with `gban.cli._parser()`, so a
62
+ #: flag that moves (e.g. `--server` is a top-level option, not a `login` one) fails a test
63
+ #: rather than a person's terminal.
64
+ QUICKSTART_COMMANDS = (
65
+ "uv tool install graphban-cli && uv tool install graphban-fleet",
66
+ "gban --server {server} login",
67
+ "gban --server {server} login --pair",
68
+ "gban setup",
69
+ "gban --project {project} setup",
70
+ )
71
+
52
72
 
53
73
  # ---- where the config goes ------------------------------------------------------------------
54
74
 
@@ -375,7 +395,8 @@ def mint(client: Client, project: str, label: str = "") -> dict:
375
395
  "project_id": project,
376
396
  # NULL, deliberately. See the module docstring: only seats expire.
377
397
  "expires_in_days": None,
378
- "tool_tiers": TIERS})
398
+ "tool_tiers": TIERS,
399
+ "origin": "gban_setup"})
379
400
 
380
401
 
381
402
  def verify(url: str, key: str, project: str) -> list[dict]:
@@ -474,8 +495,9 @@ def run(client: Client, url: str, project: str, repo: Path, *, scope: str = "use
474
495
  # A key that cannot do the job is replaced, never patched: `tool_tiers` are fixed at
475
496
  # mint and there is no route that changes them.
476
497
  lines.append(_line("config", UNKNOWN, "credential",
477
- "the configured key cannot delegate, so a new one is being minted; "
478
- "the old one is still live — remove it in Settings → API keys"))
498
+ "the configured credential no longer works (revoked, or it cannot "
499
+ "delegate), so a new one is being minted; if the old one still "
500
+ "works, remove it in Settings → API keys"))
479
501
  if not key:
480
502
  try:
481
503
  minted = mint(client, project)
@@ -750,6 +772,40 @@ def match(projects: list[dict], here: Path) -> tuple[dict, list[dict]]:
750
772
  return by_project, notes
751
773
 
752
774
 
775
+ def normalize_remote(raw: str) -> str:
776
+ """`host/owner/name`, lowercased, no scheme, credentials, port or `.git`. The same rule as
777
+ backend/app/services/projects.py:normalize_repo_url (a separate distribution cannot import
778
+ it); tests on both sides pin the same table. "" when it does not look like a remote."""
779
+ value = (raw or "").strip()
780
+ m = re.match(r"^(?:[a-z][a-z0-9+.-]*://(?:[^@/]*@)?([^/:]+)(?::\d*)?|(?:[^@/]*@)?([^/:]+):?)/?(.+)$",
781
+ value, re.IGNORECASE)
782
+ if not m or any(ch.isspace() for ch in value):
783
+ return ""
784
+ path = m.group(3).strip("/")
785
+ path = path[:-4] if path.endswith(".git") else path
786
+ return f"{m.group(1) or m.group(2)}/{path}".lower() if "/" in path else ""
787
+
788
+
789
+ def git_remotes(here: Path) -> list[str]:
790
+ """Normalized URLs of every remote of the checkout at `here`, `origin` first. [] when it is
791
+ not a checkout or git is absent — which is "no signal", and falls through to the name match."""
792
+ try:
793
+ names_out = subprocess.run(["git", "-C", str(here), "remote"], capture_output=True,
794
+ text=True, timeout=10)
795
+ if names_out.returncode != 0:
796
+ return []
797
+ out = []
798
+ for name in sorted(names_out.stdout.split(), key=lambda n: n != "origin"):
799
+ got = subprocess.run(["git", "-C", str(here), "remote", "get-url", name],
800
+ capture_output=True, text=True, timeout=10)
801
+ norm = normalize_remote(got.stdout) if got.returncode == 0 else ""
802
+ if norm and norm not in out:
803
+ out.append(norm)
804
+ return out
805
+ except (OSError, subprocess.SubprocessError):
806
+ return []
807
+
808
+
753
809
  class Unresolved(Exception):
754
810
  """The directory does not name a project on this deployment (GRPH-795)."""
755
811
 
@@ -772,6 +828,15 @@ def resolve_project(client, here: Path, asked: str = "", stored: str = "") -> tu
772
828
  return asked, "named"
773
829
  rows = client.call("GET", "/api/projects")
774
830
  projects = [p for p in (rows if isinstance(rows, list) else []) if isinstance(p, dict)]
831
+ # The git remote is the stronger signal: a directory can be renamed, a remote names the repo.
832
+ remotes = git_remotes(here)
833
+ by_remote = [p for p in projects if str(p.get("repo_url") or "") in remotes]
834
+ if len(by_remote) == 1:
835
+ return str(by_remote[0].get("id") or ""), f"matched the git remote {by_remote[0].get('repo_url')!r}"
836
+ if len(by_remote) > 1:
837
+ raise Unresolved(
838
+ f"this checkout's remote matches several projects "
839
+ f"({', '.join(sorted(str(p.get('id')) for p in by_remote))}), so none was chosen — name one with --project")
775
840
  hits = [p for p in projects if slug(here.name) in names(p)]
776
841
  if len(hits) == 1:
777
842
  return str(hits[0].get("id") or ""), f"matched the directory name {here.name!r}"