graphban-cli 0.3.0__tar.gz → 0.5.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.
Files changed (23) hide show
  1. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/.gitignore +18 -0
  2. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/PKG-INFO +33 -12
  3. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/README.md +32 -11
  4. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/pyproject.toml +1 -1
  5. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/src/gban/cli.py +18 -11
  6. graphban_cli-0.5.0/src/gban/config.py +212 -0
  7. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/src/gban/doctor.py +70 -7
  8. graphban_cli-0.5.0/src/gban/gitignore.py +176 -0
  9. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/src/gban/setup.py +398 -69
  10. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/src/gban/skills/graphban-delegation/SKILL.md +22 -4
  11. graphban_cli-0.5.0/src/gban/skills/watch-wave/SKILL.md +66 -0
  12. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/src/gban/swamp.py +5 -0
  13. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/tests/test_acts.py +4 -1
  14. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/tests/test_doctor.py +258 -23
  15. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/tests/test_session.py +1 -2
  16. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/tests/test_setup.py +407 -5
  17. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/tests/test_swamp.py +26 -1
  18. graphban_cli-0.3.0/src/gban/config.py +0 -102
  19. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/LICENSE +0 -0
  20. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/src/gban/__init__.py +0 -0
  21. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/src/gban/client.py +0 -0
  22. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/tests/test_login_projects.py +0 -0
  23. {graphban_cli-0.3.0 → graphban_cli-0.5.0}/tests/test_packaging.py +0 -0
@@ -57,6 +57,24 @@ dist-release/
57
57
  # Project MCP config — holds a real API key
58
58
  .mcp.json
59
59
 
60
+ # gban --scope project writes the same live key into Grok's project file, and
61
+ # gbfleet's grok adapter writes the child's seat there. Tracked, spawn refuses
62
+ # (fleet/tests/test_repo_keeps_its_own_config.py); unignored, it is one `git add`
63
+ # from a committed credential. `.grok/mcp.json` is the pre-GRPH-575 name.
64
+ .grok/config.toml
65
+ .grok/mcp.json
66
+
67
+ # gbfleet: enrolment code (`.gbfleet-instruction`), fallback seat, doctor/spawn
68
+ # probes, in-tree worktree pool. Salvage already excludes the seat paths from a
69
+ # WIP commit; git status in the parent checkout still offers them to `git add`.
70
+ .gbfleet-*
71
+
72
+ # Swamp vault ciphertext, and a nested copy of the adapter. docs/swamp.md: commit
73
+ # `.swamp.yaml`; do not commit these. The adapter is a sibling on purpose — a
74
+ # nested clone is one add from a tree whose licence forbids carrying Swamp source.
75
+ .swamp/
76
+ graphban-swamp/
77
+
60
78
  # Cursor claim manifest — per-session runtime state (see .cursor/hooks/README.md)
61
79
  .cursor/graphban-claim.json
62
80
  .cursor/agentledger-claim.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphban-cli
3
- Version: 0.3.0
3
+ Version: 0.5.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
@@ -127,28 +127,49 @@ is named in the refusal when it exists — logging in once inside one project mu
127
127
  mint a credential for it while you are standing in another repository, and a key in the wrong
128
128
  project is not a mistake anybody notices quickly.
129
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.
130
+ `setup` mints a project-scoped credential, writes the `graphban` and `gbfleet` MCP entries
131
+ into every parent harness that would actually read them, stores that key next to the login
132
+ session (a `gb_sk_…`, never the refresh token), installs the supervisor if it is missing,
133
+ drops the delegation skill into `.claude/skills/`, and then **verifies** rather than
134
+ asserting: it asks the new credential what it can actually see. Restart the session that will
135
+ call `delegate`/`spawn` afterwards — MCP servers are read at startup.
136
+
137
+ `gban fleet` and `gban doctor`'s local half then use that minted key. They do not feed the
138
+ login session to the supervisor. The order is `$GBFLEET_API_KEY` if you set it, then
139
+ `$GRAPHBAN_API_KEY`, then the key `gban setup` stored — and, for a machine that already ran
140
+ setup, the one already in the harness dest.
134
141
 
135
142
  Three properties worth knowing, each of which is a bug this command exists to not have:
136
143
 
137
144
  - **The credential does not expire.** `gban keys mint` produces a *wave* key, which lasts a
138
145
  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.
146
+ - **It writes where the harness will actually read.** There is more than one. Claude Code
147
+ reads `~/.claude.json`'s per-project `mcpServers` (JSON), which outranks a repository
148
+ `.mcp.json` — writing the repository file under a stale entry leaves the agent on the old
149
+ key, and surfaces as a JSON parse error because the harness is parsing a 401 body. Grok
150
+ reads `~/.grok/config.toml`'s `mcp_servers` (TOML, snake_case; `mcpServers` parses and
151
+ loads nothing). Writing only Claude's file while Grok holds a different key reports
152
+ success and leaves `delegate` unadvertised (GRPH-825). Grok's `gbfleet` MCP also
153
+ gets `--workspace ~/.grok/gbfleet-wt/<repo>`: the sibling default is a path Grok's
154
+ sandbox cannot write, and spawn then dies as `git worktree add` 128 (GRPH-826).
155
+ Claude keeps the sibling default. `--scope user` is the default because a credential
156
+ outside the repository cannot be committed. Re-running a working setup still
157
+ *repairs* a missing `gbfleet` entry and a missing `--workspace`; reuse skips a mint,
158
+ never a write.
144
159
  - **`--auto` matches, and says so.** A project carries no repository link — no remote, no
145
160
  path — so `--auto` compares your project ids and names against this directory, what is in
146
161
  it, and its siblings. One level, never a recursive walk. Two directories answering to one
147
162
  project, or one directory answering to two, are **refused rather than guessed**: a
148
163
  credential minted into the wrong repository is not a mistake anybody notices quickly.
149
164
  - **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.
165
+ `.mcp.json` or `.grok/config.toml` is refused rather than warned about, because a warning
166
+ attached to committing a credential still commits it.
167
+ - **It gitignores the files it and `gbfleet` write into the checkout.** `.mcp.json`,
168
+ `.cursor/mcp.json`, `.grok/config.toml`, `.gbfleet-*`, `.swamp/`, `graphban-swamp/`.
169
+ A warning that said "make sure it is gitignored" still wrote the key; `gban setup`
170
+ now adds the lines, and `gban doctor` FAILs if a credential file would still be
171
+ committed. It asks git (`check-ignore`), so an equivalent pattern already in the
172
+ file is left alone.
152
173
 
153
174
  ## Wiring a checkout to Swamp
154
175
 
@@ -112,28 +112,49 @@ is named in the refusal when it exists — logging in once inside one project mu
112
112
  mint a credential for it while you are standing in another repository, and a key in the wrong
113
113
  project is not a mistake anybody notices quickly.
114
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.
115
+ `setup` mints a project-scoped credential, writes the `graphban` and `gbfleet` MCP entries
116
+ into every parent harness that would actually read them, stores that key next to the login
117
+ session (a `gb_sk_…`, never the refresh token), installs the supervisor if it is missing,
118
+ drops the delegation skill into `.claude/skills/`, and then **verifies** rather than
119
+ asserting: it asks the new credential what it can actually see. Restart the session that will
120
+ call `delegate`/`spawn` afterwards — MCP servers are read at startup.
121
+
122
+ `gban fleet` and `gban doctor`'s local half then use that minted key. They do not feed the
123
+ login session to the supervisor. The order is `$GBFLEET_API_KEY` if you set it, then
124
+ `$GRAPHBAN_API_KEY`, then the key `gban setup` stored — and, for a machine that already ran
125
+ setup, the one already in the harness dest.
119
126
 
120
127
  Three properties worth knowing, each of which is a bug this command exists to not have:
121
128
 
122
129
  - **The credential does not expire.** `gban keys mint` produces a *wave* key, which lasts a
123
130
  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.
131
+ - **It writes where the harness will actually read.** There is more than one. Claude Code
132
+ reads `~/.claude.json`'s per-project `mcpServers` (JSON), which outranks a repository
133
+ `.mcp.json` — writing the repository file under a stale entry leaves the agent on the old
134
+ key, and surfaces as a JSON parse error because the harness is parsing a 401 body. Grok
135
+ reads `~/.grok/config.toml`'s `mcp_servers` (TOML, snake_case; `mcpServers` parses and
136
+ loads nothing). Writing only Claude's file while Grok holds a different key reports
137
+ success and leaves `delegate` unadvertised (GRPH-825). Grok's `gbfleet` MCP also
138
+ gets `--workspace ~/.grok/gbfleet-wt/<repo>`: the sibling default is a path Grok's
139
+ sandbox cannot write, and spawn then dies as `git worktree add` 128 (GRPH-826).
140
+ Claude keeps the sibling default. `--scope user` is the default because a credential
141
+ outside the repository cannot be committed. Re-running a working setup still
142
+ *repairs* a missing `gbfleet` entry and a missing `--workspace`; reuse skips a mint,
143
+ never a write.
129
144
  - **`--auto` matches, and says so.** A project carries no repository link — no remote, no
130
145
  path — so `--auto` compares your project ids and names against this directory, what is in
131
146
  it, and its siblings. One level, never a recursive walk. Two directories answering to one
132
147
  project, or one directory answering to two, are **refused rather than guessed**: a
133
148
  credential minted into the wrong repository is not a mistake anybody notices quickly.
134
149
  - **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.
150
+ `.mcp.json` or `.grok/config.toml` is refused rather than warned about, because a warning
151
+ attached to committing a credential still commits it.
152
+ - **It gitignores the files it and `gbfleet` write into the checkout.** `.mcp.json`,
153
+ `.cursor/mcp.json`, `.grok/config.toml`, `.gbfleet-*`, `.swamp/`, `graphban-swamp/`.
154
+ A warning that said "make sure it is gitignored" still wrote the key; `gban setup`
155
+ now adds the lines, and `gban doctor` FAILs if a credential file would still be
156
+ committed. It asks git (`check-ignore`), so an equivalent pattern already in the
157
+ file is left alone.
137
158
 
138
159
  ## Wiring a checkout to Swamp
139
160
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphban-cli"
3
- version = "0.3.0"
3
+ version = "0.5.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"
@@ -56,14 +56,17 @@ def _parser() -> argparse.ArgumentParser:
56
56
  setup_cmd = sub.add_parser(
57
57
  "setup", help="enable delegation on a project",
58
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."))
59
+ "graphban and gbfleet MCP entries into Claude Code (~/.claude.json) and "
60
+ "Grok (~/.grok/config.toml, TOML mcp_servers) — every file a parent "
61
+ "harness will actually read — and installs the supervisor. Only seats "
62
+ "expire; a credential that died overnight would make 'delegation is set "
63
+ "up' quietly stop being true. Re-running is safe: a working key is "
64
+ "reused, a missing gbfleet entry is still repaired."))
63
65
  setup_cmd.add_argument(
64
66
  "--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
+ help="user (default) writes ~/.claude.json and, when it exists, ~/.grok/config.toml; "
68
+ "project writes .mcp.json and .grok/config.toml beside the repo. A user file "
69
+ "outranks the repo copy and cannot be committed")
67
70
  setup_cmd.add_argument(
68
71
  "--auto", action="store_true",
69
72
  help="every project with a repository here: matches this directory, what is in it and "
@@ -275,7 +278,7 @@ def cmd_whoami(args) -> int:
275
278
  def cmd_doctor(args) -> int:
276
279
  url = config.resolve(args.server, config.URL_ENV, "url")
277
280
  project = config.resolve(args.project, config.PROJECT_ENV, "project")
278
- api_key = os.environ.get(config.API_KEY_ENV, "")
281
+ api_key = doctor_mod.resolve_supervisor_key(project)
279
282
  lines, code = doctor_mod.run(url, project, api_key)
280
283
  _out({"lines": lines, "ok": code == 0}, doctor_mod.render(lines), args.as_json)
281
284
  return code
@@ -310,7 +313,8 @@ def cmd_setup(args) -> int:
310
313
  if code == 0:
311
314
  human.append(f"\n delegation is enabled on {project}. An agent can now "
312
315
  f"`delegate(id=…, lane=…, tier=…, seat=true)` then `spawn`.")
313
- human.append(f" restart the harness so it reads the new config.")
316
+ human.append(" restart Claude Code and/or Grok — whichever session will call "
317
+ "`delegate`/`spawn` — so it reads the new config.")
314
318
  _out({"lines": lines, "ok": code == 0, "project": project, **made},
315
319
  "\n".join(human), args.as_json)
316
320
  return code
@@ -340,7 +344,8 @@ def _setup_auto(args, url: str, client) -> int:
340
344
  done[pid], worst = {"repo": str(repo), "ok": code == 0, **made}, max(worst, code)
341
345
  human = [doctor_mod.render(lines),
342
346
  f"\n {sum(1 for d in done.values() if d['ok'])} of {len(done)} enabled. "
343
- f"Restart the harness so it reads the new config."]
347
+ "Restart Claude Code and/or Grok — whichever session will call "
348
+ "`delegate`/`spawn` — so it reads the new config."]
344
349
  _out({"lines": lines, "ok": worst == 0, "projects": done}, "\n".join(human), args.as_json)
345
350
  return worst
346
351
 
@@ -406,9 +411,11 @@ def cmd_fleet(args) -> int:
406
411
  # message is the one its own tests pin.
407
412
  #
408
413
  # The ENVIRONMENT goes through the same function `doctor` uses (GRPH-782). It used not
409
- # to, so the doctor certified a local half this command could not reproduce.
414
+ # to, so the doctor certified a local half this command could not reproduce. The key
415
+ # is resolved the same way too: env, then the setup-minted agent key, never the login
416
+ # session — a doctor PASS that does not predict `gban fleet` is the original lie.
410
417
  return subprocess.run(argv, env=doctor_mod.child_environment(
411
- os.environ.get(config.API_KEY_ENV, ""))).returncode
418
+ doctor_mod.resolve_supervisor_key(project))).returncode
412
419
 
413
420
 
414
421
  def _project(args, act: str) -> str:
@@ -0,0 +1,212 @@
1
+ """Where `gban` keeps its two facts, and why they are two files (PRD-40 D3, D10).
2
+
3
+ `~/.graphban/` is shared with `graphban`, the operator's database-side tool, and the sharing
4
+ stops at the directory. `gban` reads `gban.json` (`url`, `project`) and `session.json` (a refresh
5
+ token) and **never opens `config.json`**, which is `graphban`'s and may hold a database link.
6
+
7
+ A third file, `supervisor.json`, holds the project-scoped **agent** keys `gban setup` minted
8
+ (`gb_sk_…`). It is not the login session. `gban fleet` / `gban doctor` hand that key to
9
+ `gbfleet`; feeding the refresh token instead is how a working session produces "session
10
+ expired" on the supervisor (GRPH-782).
11
+
12
+ The grill made that stricter than the draft. Reading `config.json` and ignoring the keys it did
13
+ not recognise would have been true and insufficient: the risk is not misreading a database
14
+ password, it is that password living in a file which now has a second consumer and a second
15
+ reason to be copied onto another machine. `graphban` runs in a container against a database;
16
+ `gban` runs on a laptop against HTTP; the credential that must not cross that line lives in its
17
+ own file, so copying a config never carries it.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import stat
24
+ import subprocess
25
+ from pathlib import Path
26
+
27
+ #: The directory both tools use. Shared deliberately — a person has one Graphban.
28
+ HOME_ENV = "GRAPHBAN_HOME"
29
+
30
+ #: `gban`'s own settings. NOT `config.json`, which belongs to `graphban` (D10).
31
+ SETTINGS_FILE = "gban.json"
32
+
33
+ #: The refresh token, alone in its own file so that copying settings never carries it.
34
+ SESSION_FILE = "session.json"
35
+
36
+ #: Project-scoped agent keys `gban setup` minted. Next to the session, never inside it —
37
+ #: a refresh token and a `gb_sk_…` are different credentials, and mixing them is the
38
+ #: remaining hole GRPH-782 was reopened for.
39
+ SUPERVISOR_KEYS_FILE = "supervisor.json"
40
+
41
+ #: `graphban`'s file. Named here only so the test that asserts `gban` never opens it has
42
+ #: something to name, and so a reader knows the omission is deliberate.
43
+ NOT_OURS = "config.json"
44
+
45
+ #: What a minted agent key looks like. `gbfleet` wants this; a user refresh token is not it.
46
+ AGENT_KEY_PREFIX = "gb_sk_"
47
+
48
+ URL_ENV = "GRAPHBAN_URL"
49
+ PROJECT_ENV = "GRAPHBAN_PROJECT"
50
+ API_KEY_ENV = "GRAPHBAN_API_KEY"
51
+
52
+ #: Owner read/write and nothing else. A credential at rest gets the same mode the seat files
53
+ #: in `gbfleet` get, for the same reason.
54
+ PRIVATE = stat.S_IRUSR | stat.S_IWUSR
55
+
56
+
57
+ def home() -> Path:
58
+ return Path(os.environ.get(HOME_ENV) or (Path.home() / ".graphban"))
59
+
60
+
61
+ def _read(path: Path) -> dict:
62
+ try:
63
+ loaded = json.loads(path.read_text(encoding="utf-8"))
64
+ except (OSError, ValueError):
65
+ # A missing file and an unreadable one are the same to a caller that has a default,
66
+ # and neither is worth a traceback in front of somebody trying to log in.
67
+ return {}
68
+ return loaded if isinstance(loaded, dict) else {}
69
+
70
+
71
+ def _write(path: Path, payload: dict) -> Path:
72
+ path.parent.mkdir(parents=True, exist_ok=True)
73
+ # Created private BEFORE anything is written to it. Writing first and chmod-ing after
74
+ # leaves a window where the token is world-readable, which is the whole failure.
75
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, PRIVATE)
76
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
77
+ json.dump(payload, fh, indent=1, sort_keys=True)
78
+ fh.write("\n")
79
+ _make_private(path)
80
+ return path
81
+
82
+
83
+ def _make_private(path: Path) -> None:
84
+ """Owner-only, including on Windows where `chmod 0600` is a no-op (GRPH-855)."""
85
+ os.chmod(path, PRIVATE)
86
+ if os.name != "nt":
87
+ return
88
+ user = os.environ.get("USERNAME") or ""
89
+ if not user:
90
+ return
91
+ subprocess.run(
92
+ ["icacls", str(path), "/inheritance:r", "/grant:r", f"{user}:(F)"],
93
+ capture_output=True, text=True, timeout=30,
94
+ )
95
+ listing = subprocess.run(
96
+ ["icacls", str(path)], capture_output=True, text=True, timeout=30,
97
+ )
98
+ if listing.returncode != 0:
99
+ return
100
+ me = user.casefold()
101
+ prefix = str(path)
102
+ for line in listing.stdout.splitlines():
103
+ if ":(" not in line:
104
+ continue
105
+ principal = line.split(":(")[0].strip()
106
+ if principal.lower().startswith(prefix.lower()):
107
+ principal = principal[len(prefix):].strip()
108
+ if not principal:
109
+ continue
110
+ if principal.rsplit("\\", 1)[-1].casefold() == me:
111
+ continue
112
+ subprocess.run(
113
+ ["icacls", str(path), "/remove", principal],
114
+ capture_output=True, text=True, timeout=30,
115
+ )
116
+
117
+
118
+ def is_private(path: Path) -> bool:
119
+ """Whether `path` is owner-only. POSIX is the mode bits; Windows is the ACL.
120
+
121
+ Asserting `st_mode == 0o600` goes red on NTFS for a reason that has nothing to do
122
+ with whether anyone else can read the file (GRPH-855).
123
+ """
124
+ path = Path(path)
125
+ if os.name != "nt":
126
+ try:
127
+ return stat.S_IMODE(path.stat().st_mode) == PRIVATE
128
+ except OSError:
129
+ return False
130
+ user = (os.environ.get("USERNAME") or "").casefold()
131
+ listing = subprocess.run(
132
+ ["icacls", str(path)], capture_output=True, text=True, timeout=30,
133
+ )
134
+ if listing.returncode != 0:
135
+ return False
136
+ prefix = str(path)
137
+ saw_owner = False
138
+ for line in listing.stdout.splitlines():
139
+ if ":(" not in line:
140
+ continue
141
+ principal = line.split(":(")[0].strip()
142
+ if principal.lower().startswith(prefix.lower()):
143
+ principal = principal[len(prefix):].strip()
144
+ if not principal:
145
+ continue
146
+ account = principal.rsplit("\\", 1)[-1].casefold()
147
+ if account == user:
148
+ saw_owner = True
149
+ continue
150
+ return False
151
+ return saw_owner
152
+
153
+
154
+ def settings() -> dict:
155
+ return _read(home() / SETTINGS_FILE)
156
+
157
+
158
+ def save_settings(**values: str) -> Path:
159
+ merged = {k: v for k, v in {**settings(), **values}.items() if v}
160
+ return _write(home() / SETTINGS_FILE, merged)
161
+
162
+
163
+ def session() -> dict:
164
+ return _read(home() / SESSION_FILE)
165
+
166
+
167
+ def save_session(refresh_token: str, *, user: str = "") -> Path:
168
+ return _write(home() / SESSION_FILE,
169
+ {"refresh_token": refresh_token, "user": user})
170
+
171
+
172
+ def clear_session() -> bool:
173
+ path = home() / SESSION_FILE
174
+ try:
175
+ path.unlink()
176
+ return True
177
+ except FileNotFoundError:
178
+ return False
179
+
180
+
181
+ def _agent_key(value: str) -> str:
182
+ value = (value or "").strip()
183
+ return value if value.startswith(AGENT_KEY_PREFIX) else ""
184
+
185
+
186
+ def save_supervisor_key(project: str, key: str) -> Path | None:
187
+ """Store the project-scoped agent key `gban setup` minted. Refuses a refresh token."""
188
+ key = _agent_key(key)
189
+ if not project or not key:
190
+ return None
191
+ blob = _read(home() / SUPERVISOR_KEYS_FILE)
192
+ keys = blob.get("keys") if isinstance(blob.get("keys"), dict) else {}
193
+ keys[project] = key
194
+ return _write(home() / SUPERVISOR_KEYS_FILE, {"keys": keys})
195
+
196
+
197
+ def stored_supervisor_key(project: str) -> str:
198
+ """The agent key setup stored for this project, or empty. Never reads `session.json`."""
199
+ if not project:
200
+ return ""
201
+ blob = _read(home() / SUPERVISOR_KEYS_FILE)
202
+ keys = blob.get("keys") if isinstance(blob.get("keys"), dict) else {}
203
+ return _agent_key(str(keys.get(project) or ""))
204
+
205
+
206
+ def resolve(flag: str | None, env: str, key: str) -> str:
207
+ """D10's precedence, in one place: flag, then environment, then the settings file.
208
+
209
+ One function rather than three lookups at each call site, because a precedence that is
210
+ re-implemented per option is one that will eventually differ per option.
211
+ """
212
+ return (flag or os.environ.get(env) or settings().get(key) or "").strip()
@@ -16,7 +16,7 @@ import shutil
16
16
  import subprocess
17
17
  from pathlib import Path
18
18
 
19
- from gban import config
19
+ from gban import config, gitignore
20
20
  from gban.client import Client, NoSession, Refused, Unreachable, authenticated
21
21
 
22
22
  PASS, FAIL, UNKNOWN = "PASS", "FAIL", "UNKNOWN"
@@ -200,6 +200,31 @@ def install_supervisor(timeout: float = 600.0) -> tuple[bool, str]:
200
200
  return True, ""
201
201
 
202
202
 
203
+ def _sibling_binary(name: str) -> str:
204
+ """An executable beside this interpreter, trying the platform suffix that actually
205
+ exists. On Windows a `pip install` of both packages drops `gbfleet.exe` into
206
+ `Scripts\\`; on Unix it drops `gbfleet` into `bin/`. Checking the bare name on Windows
207
+ is the same hole the Unix sibling check used to have — the file is there and the lookup
208
+ says it is not.
209
+
210
+ `os.access(X_OK)` is true of almost every file on Windows, so a leftover extensionless
211
+ `gbfleet` (chmod 0644 even) would win over `gbfleet.exe` if we asked for the bare name
212
+ first (GRPH-855).
213
+ """
214
+ import sys
215
+
216
+ parent = Path(sys.executable).parent
217
+ bare, exe = parent / name, parent / f"{name}.exe"
218
+ if os.name == "nt":
219
+ # X_OK is true of almost every file here, so a leftover extensionless
220
+ # `gbfleet` would win over `gbfleet.exe` (GRPH-855).
221
+ return str(exe) if exe.is_file() else ""
222
+ for candidate in (bare, exe):
223
+ if candidate.is_file() and os.access(candidate, os.X_OK):
224
+ return str(candidate)
225
+ return ""
226
+
227
+
203
228
  def find_supervisor() -> str:
204
229
  """`gbfleet`, from beside this interpreter first and only then from PATH.
205
230
 
@@ -213,13 +238,10 @@ def find_supervisor() -> str:
213
238
  PATH still wins for a supervisor the operator installed separately and put there on
214
239
  purpose — a sibling is a fallback for the case PATH cannot see, not an override of it.
215
240
  """
216
- import sys
217
-
218
241
  found = shutil.which("gbfleet")
219
242
  if found:
220
243
  return found
221
- beside = Path(sys.executable).parent / "gbfleet"
222
- return str(beside) if beside.exists() and os.access(beside, os.X_OK) else ""
244
+ return _sibling_binary("gbfleet")
223
245
 
224
246
 
225
247
  #: What `gbfleet` reads. `gban` names it once, here, and both paths that launch a supervisor
@@ -227,6 +249,45 @@ def find_supervisor() -> str:
227
249
  SUPERVISOR_KEY_ENV = "GBFLEET_API_KEY"
228
250
 
229
251
 
252
+ def resolve_supervisor_key(project: str, repo: Path | None = None) -> str:
253
+ """The agent key we hand `child_environment` as `api_key`.
254
+
255
+ Order (GRPH-782 remaining hole): `$GRAPHBAN_API_KEY`, then the project-scoped key
256
+ `gban setup` stored next to `session.json`, then the key setup already wrote into a
257
+ harness dest for this repository. `$GBFLEET_API_KEY` the caller set is preserved
258
+ inside `child_environment`, so it is not resolved here.
259
+
260
+ Never the login session. A refresh token is a different kind of credential.
261
+ """
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)
269
+
270
+
271
+ def _harness_supervisor_key(repo: Path | None) -> str:
272
+ """The key `gban setup` already wrote into a parent-harness dest.
273
+
274
+ Lazy-imports setup: that module imports this one, and a top-level cycle would
275
+ make `gban doctor` fail to import on a machine that has never run setup.
276
+ """
277
+ from gban import setup as setup_mod
278
+
279
+ repo = (repo or Path.cwd()).resolve()
280
+ seen: list[str] = []
281
+ for scope in ("user", "project"):
282
+ dests, _ = setup_mod.destinations(
283
+ repo, scope, setup_mod.claude_home(), setup_mod.grok_home())
284
+ for dest in dests:
285
+ key = (setup_mod.dest_key(dest, repo) or "").strip()
286
+ if key.startswith(config.AGENT_KEY_PREFIX) and key not in seen:
287
+ seen.append(key)
288
+ return seen[0] if seen else ""
289
+
290
+
230
291
  def child_environment(api_key: str) -> dict:
231
292
  """The environment a `gbfleet` child is launched with, for `doctor` AND for `fleet`.
232
293
 
@@ -251,8 +312,10 @@ def child_environment(api_key: str) -> dict:
251
312
  return env
252
313
 
253
314
 
254
- def run(url: str, project: str, api_key: str = "") -> tuple[list[dict], int]:
255
- lines = ledger(url, project) + local(url, project, api_key)
315
+ def run(url: str, project: str, api_key: str = "",
316
+ repo: Path | None = None) -> tuple[list[dict], int]:
317
+ lines = (ledger(url, project) + local(url, project, api_key)
318
+ + gitignore.check(repo or Path.cwd()))
256
319
  worst = max((SEVERITY[l["status"]] for l in lines), default=0)
257
320
  # FAIL exits 1, UNKNOWN exits 0: "could not check" is not "broken", and a doctor that
258
321
  # failed a script because a laptop lacked `gbfleet` would stop being run.