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.
- {graphban_cli-0.1.0 → graphban_cli-0.2.0}/PKG-INFO +86 -13
- {graphban_cli-0.1.0 → graphban_cli-0.2.0}/README.md +85 -12
- {graphban_cli-0.1.0 → graphban_cli-0.2.0}/pyproject.toml +1 -1
- {graphban_cli-0.1.0 → graphban_cli-0.2.0}/src/gban/cli.py +137 -8
- {graphban_cli-0.1.0 → graphban_cli-0.2.0}/src/gban/doctor.py +80 -6
- 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.0 → graphban_cli-0.2.0}/tests/test_acts.py +4 -2
- {graphban_cli-0.1.0 → graphban_cli-0.2.0}/tests/test_doctor.py +108 -4
- graphban_cli-0.2.0/tests/test_login_projects.py +112 -0
- {graphban_cli-0.1.0 → graphban_cli-0.2.0}/tests/test_packaging.py +28 -0
- graphban_cli-0.2.0/tests/test_setup.py +748 -0
- {graphban_cli-0.1.0 → graphban_cli-0.2.0}/.gitignore +0 -0
- {graphban_cli-0.1.0 → graphban_cli-0.2.0}/LICENSE +0 -0
- {graphban_cli-0.1.0 → graphban_cli-0.2.0}/src/gban/__init__.py +0 -0
- {graphban_cli-0.1.0 → graphban_cli-0.2.0}/src/gban/client.py +0 -0
- {graphban_cli-0.1.0 → graphban_cli-0.2.0}/src/gban/config.py +0 -0
- {graphban_cli-0.1.0 → 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
|
|
@@ -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
|
-
|
|
30
|
-
|
|
29
|
+
```bash
|
|
30
|
+
uv tool install graphban-cli
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
or, on macOS:
|
|
31
34
|
|
|
32
35
|
```bash
|
|
33
|
-
|
|
36
|
+
brew install asc-me/tap/gban
|
|
34
37
|
```
|
|
35
38
|
|
|
36
|
-
|
|
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
|
|
49
|
+
uv tool install graphban-fleet
|
|
40
50
|
```
|
|
41
51
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
72
|
+
pip install graphban-cli
|
|
50
73
|
```
|
|
51
74
|
|
|
52
|
-
`gban`
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
15
|
-
|
|
14
|
+
```bash
|
|
15
|
+
uv tool install graphban-cli
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
or, on macOS:
|
|
16
19
|
|
|
17
20
|
```bash
|
|
18
|
-
|
|
21
|
+
brew install asc-me/tap/gban
|
|
19
22
|
```
|
|
20
23
|
|
|
21
|
-
|
|
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
|
|
34
|
+
uv tool install graphban-fleet
|
|
25
35
|
```
|
|
26
36
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
57
|
+
pip install graphban-cli
|
|
35
58
|
```
|
|
36
59
|
|
|
37
|
-
`gban`
|
|
38
|
-
|
|
39
|
-
|
|
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**
|
|
@@ -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
|
|
|
@@ -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
|
|
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.
|
|
122
|
-
#:
|
|
123
|
-
#:
|
|
124
|
-
#:
|
|
125
|
-
INSTALL_SUPERVISOR =
|
|
126
|
-
|
|
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:
|