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