graphban-cli 0.1.0__py3-none-any.whl

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.
gban/__init__.py ADDED
File without changes
gban/cli.py ADDED
@@ -0,0 +1,379 @@
1
+ """`gban` — the client for a human at a terminal (PRD-40).
2
+
3
+ The v1 verb list is short on purpose (D11). Every verb here is one HTTP call to one route
4
+ that already exists, named one-to-one, because a client that composes several calls into a
5
+ new act becomes a second place the rules live.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import getpass
11
+ import json
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+
17
+ from gban import config, doctor as doctor_mod
18
+ from gban.client import (EXIT_NO_SESSION, EXIT_NO_SUPERVISOR, EXIT_REFUSED, EXIT_UNREACHABLE,
19
+ Client, NoSession,
20
+ Refused, Unreachable, authenticated, login)
21
+
22
+ PROG = "gban"
23
+
24
+
25
+ def _parser() -> argparse.ArgumentParser:
26
+ parser = argparse.ArgumentParser(
27
+ prog=PROG,
28
+ description=("A Graphban client for a human at a terminal. The web app, `gbfleet` and "
29
+ "`graphban` all still exist; this is for the acts that otherwise need a "
30
+ "browser, and for finding out why something is stuck."),
31
+ )
32
+ parser.add_argument("--json", action="store_true", dest="as_json",
33
+ help="machine-readable output; the human format is never parsed")
34
+ parser.add_argument("--server", default=None,
35
+ help=f"Graphban base URL (or ${config.URL_ENV}, or {config.SETTINGS_FILE})")
36
+ parser.add_argument("--project", default=None,
37
+ help=f"project id (or ${config.PROJECT_ENV}, or {config.SETTINGS_FILE})")
38
+ sub = parser.add_subparsers(dest="command", required=True)
39
+
40
+ login_cmd = sub.add_parser(
41
+ "login", help="start a session on this machine",
42
+ description=("Exchanges an email and password for a session. The refresh token is "
43
+ "written to session.json at mode 600; the access token is never stored, "
44
+ "because it is renewed on every invocation anyway."))
45
+ login_cmd.add_argument("--email", default=None)
46
+
47
+ sub.add_parser("logout", help="end the session, here and on the server")
48
+ sub.add_parser(
49
+ "doctor", help="check both halves: the ledger, and the local fleet",
50
+ description=("Everything that can be checked before anything is spawned. The ledger "
51
+ "half runs here; the local half is `gbfleet doctor`, run as a subprocess. "
52
+ "Neither half silences the other: what could not be checked prints "
53
+ "UNKNOWN with its reason, never a pass."))
54
+
55
+ fleet = sub.add_parser(
56
+ "fleet", help="hand off to gbfleet (the supervisor)",
57
+ description=("Passes everything through to `gbfleet` and returns its exit code "
58
+ "unchanged — 75 is stuck, 69 an unreachable model endpoint, 55 a spent "
59
+ "budget, and folding those into one code would destroy a taxonomy the "
60
+ "supervisor's own tests pin."),
61
+ add_help=False)
62
+ fleet.add_argument("rest", nargs=argparse.REMAINDER,
63
+ help="arguments for gbfleet; try `gban fleet --help`")
64
+ sub.add_parser("whoami", help="who this session belongs to, and where it points")
65
+
66
+ seats = sub.add_parser(
67
+ "seats", help="the seats a wave was issued, and issuing more",
68
+ description=("A SEAT is an enrolment code: one agent's right to register once, for "
69
+ "half an hour. It is not a credential — the credential is the API key "
70
+ "the child authenticates with, and `gban keys` is where those live."))
71
+ seats_do = seats.add_subparsers(dest="act")
72
+ issue = seats_do.add_parser("issue", help="issue seats, one role per agent")
73
+ issue.add_argument("roles", nargs="+", metavar="ROLE",
74
+ help="one entry per agent, repeats included: worker worker planner")
75
+ issue.add_argument("--wave", default="",
76
+ help="blank means the next one, computed server-side")
77
+ revoke = seats_do.add_parser("revoke-unused", help="throw away seats nobody redeemed")
78
+ revoke.add_argument("--wave", default="")
79
+
80
+ agents = sub.add_parser(
81
+ "agents", help="the roster, and re-tasking one",
82
+ description=("Who is live, what each is doing, and — the reason this verb exists — "
83
+ "what any of them was last refused and why."))
84
+ agents_do = agents.add_subparsers(dest="act")
85
+ role = agents_do.add_parser(
86
+ "role", help="re-task a live agent, as the human who owns its credential",
87
+ description=("The credential ceiling still decides. A role the agent's key does not "
88
+ "permit is refused by the server, and widening a ceiling means minting "
89
+ "a different credential — keeping those two acts apart is the point of "
90
+ "having a ceiling. Lands on the agent's next poll."))
91
+ role.add_argument("agent_id")
92
+ role.add_argument("role", metavar="ROLE")
93
+ role.add_argument("--reason", default="", help="recorded on the event, and told to the agent")
94
+
95
+ keys = sub.add_parser("keys", help="the credentials this project's agents authenticate with")
96
+ keys_do = keys.add_subparsers(dest="act")
97
+ mint = keys_do.add_parser(
98
+ "mint", help="mint a credential narrowed to a role, or to several",
99
+ description=("Repeat --role to mint a credential an agent can be RE-TASKED within. "
100
+ "One role is the default and is a real bound: it is what stops a client "
101
+ "config from registering a worker as a planner. But a role change cannot "
102
+ "climb past the credential the agent already holds, so an agent minted "
103
+ "for one role can never be promoted without being restarted."))
104
+ mint.add_argument("--role", required=True, action="append", metavar="ROLE",
105
+ help="repeatable; the first is the role the agent registers into")
106
+ mint.add_argument("--wave", default="wave-1")
107
+ mint.add_argument("--label", default="")
108
+ return parser
109
+
110
+
111
+ def _out(payload: dict, human: str, as_json: bool) -> None:
112
+ print(json.dumps(payload, indent=1, sort_keys=True) if as_json else human)
113
+
114
+
115
+ def _server(args) -> str:
116
+ url = config.resolve(args.server, config.URL_ENV, "url")
117
+ if not url:
118
+ print(f"{PROG}: no server. Pass --server, set ${config.URL_ENV}, or run `gban login "
119
+ f"--server …` once.", file=sys.stderr)
120
+ raise SystemExit(EXIT_REFUSED)
121
+ return url
122
+
123
+
124
+ def cmd_login(args) -> int:
125
+ url = _server(args)
126
+ # A TERMINAL, OR NOTHING. `getpass` falls back to a plain echoing read when it cannot
127
+ # turn echo off, and warns about it — which is the wrong trade for a password: the
128
+ # warning arrives after the person has already decided to type. Without a tty the
129
+ # password would land in the scrollback, in a transcript, or in whatever captured the
130
+ # session. Refusing is the only safe branch, and it is not a limitation somebody can
131
+ # work around by trying harder.
132
+ if not sys.stdin.isatty():
133
+ print(f"{PROG}: `{PROG} login` needs a terminal. Without one, the prompt cannot turn "
134
+ f"off echo and your password would be written to the scrollback.\n"
135
+ f" Run it in a shell, not through a pipe, a heredoc or an editor's "
136
+ f"command runner.", file=sys.stderr)
137
+ return EXIT_REFUSED
138
+ try:
139
+ email = args.email or input("email: ").strip()
140
+ # Prompted, never an argument: argv is world-readable in `ps`, and a password in
141
+ # shell history is a credential nobody remembers leaving there.
142
+ password = getpass.getpass("password: ")
143
+ except (EOFError, KeyboardInterrupt):
144
+ # Somebody pressed ctrl-C or the input ended. One line, not a traceback — the
145
+ # same rule criterion 4 applies to an expired session applies to a cancelled login.
146
+ print(f"\n{PROG}: cancelled", file=sys.stderr)
147
+ return EXIT_REFUSED
148
+ pair = login(url, email, password)
149
+ refresh = pair.get("refresh_token") or ""
150
+ if not refresh:
151
+ print(f"{PROG}: the server returned no refresh token", file=sys.stderr)
152
+ return EXIT_REFUSED
153
+ config.save_settings(url=url, project=config.resolve(args.project, config.PROJECT_ENV,
154
+ "project"))
155
+ path = config.save_session(refresh, user=email)
156
+ _out({"server": url, "session": str(path), "user": email},
157
+ f"{PROG}: signed in to {url} as {email}\n session stored at {path} (mode 600)",
158
+ args.as_json)
159
+ return 0
160
+
161
+
162
+ def cmd_logout(args) -> int:
163
+ url = _server(args)
164
+ told_server, why = False, ""
165
+ try:
166
+ authenticated(url).call("POST", "/api/auth/logout")
167
+ told_server = True
168
+ except NoSession:
169
+ # Already dead server-side. Not an error: the person asked to be logged out and they
170
+ # are, which is the outcome they wanted.
171
+ told_server, why = True, "the session was already ended"
172
+ except (Unreachable, Refused) as exc:
173
+ why = str(exc)
174
+ removed = config.clear_session()
175
+ # The file goes EITHER WAY. Leaving it because the server could not be told would leave a
176
+ # live token on disk belonging to somebody who believes they logged out, which is the one
177
+ # outcome worth avoiding here.
178
+ human = f"{PROG}: signed out" + ("" if told_server else
179
+ f"\n WARNING: the server was not told ({why}).\n"
180
+ " The local session is gone; the server-side one "
181
+ "stays valid until it expires.\n Revoke it from the "
182
+ "web app, or run `gban logout` again when the network is "
183
+ "back.")
184
+ if not removed and told_server:
185
+ human = f"{PROG}: no local session to remove"
186
+ _out({"signed_out": True, "server_told": told_server, "local_removed": removed,
187
+ "reason": why}, human, args.as_json)
188
+ return 0
189
+
190
+
191
+ def cmd_whoami(args) -> int:
192
+ url = _server(args)
193
+ me = authenticated(url).call("GET", "/api/auth/me")
194
+ project = config.resolve(args.project, config.PROJECT_ENV, "project")
195
+ _out({"server": url, "project": project, **me},
196
+ f"{PROG}: {me.get('email') or me.get('id')} at {url}"
197
+ + (f"\n project {project}" if project else "\n no default project set"),
198
+ args.as_json)
199
+ return 0
200
+
201
+
202
+ def cmd_doctor(args) -> int:
203
+ url = config.resolve(args.server, config.URL_ENV, "url")
204
+ project = config.resolve(args.project, config.PROJECT_ENV, "project")
205
+ api_key = os.environ.get(config.API_KEY_ENV, "")
206
+ lines, code = doctor_mod.run(url, project, api_key)
207
+ _out({"lines": lines, "ok": code == 0}, doctor_mod.render(lines), args.as_json)
208
+ return code
209
+
210
+
211
+ def cmd_fleet(args) -> int:
212
+ """A subprocess, never an import (D5).
213
+
214
+ Keeps `gban` free of the supervisor's dependencies, keeps the Apache-2.0 boundary intact,
215
+ and leaves `gbfleet --help` authoritative about its own commands.
216
+ """
217
+ binary = doctor_mod.find_supervisor()
218
+ if not binary:
219
+ print(f"{PROG}: gbfleet is not installed here. Install it with:\n"
220
+ f" {doctor_mod.INSTALL_SUPERVISOR}\n"
221
+ f" …or run it from the repository's fleet/ directory.", file=sys.stderr)
222
+ return EXIT_NO_SUPERVISOR
223
+ argv = [binary, *[a for a in args.rest if a != "--"]]
224
+ url = config.resolve(args.server, config.URL_ENV, "url")
225
+ if url and "--server" not in argv:
226
+ argv += ["--server", url]
227
+ project = config.resolve(args.project, config.PROJECT_ENV, "project")
228
+ if project and "--project" not in argv:
229
+ argv += ["--project", project]
230
+ # Its exit code, unchanged. `gban` adds nothing and explains nothing: the supervisor's
231
+ # message is the one its own tests pin.
232
+ #
233
+ # The ENVIRONMENT goes through the same function `doctor` uses (GRPH-782). It used not
234
+ # to, so the doctor certified a local half this command could not reproduce.
235
+ return subprocess.run(argv, env=doctor_mod.child_environment(
236
+ os.environ.get(config.API_KEY_ENV, ""))).returncode
237
+
238
+
239
+ def _project(args, act: str) -> str:
240
+ project = config.resolve(args.project, config.PROJECT_ENV, "project")
241
+ if not project:
242
+ print(f"{PROG}: `{PROG} {act}` needs a project. Pass --project, set "
243
+ f"${config.PROJECT_ENV}, or run `gban login --project …` once.", file=sys.stderr)
244
+ raise SystemExit(EXIT_REFUSED)
245
+ return project
246
+
247
+
248
+ def _fleet_read(args, act: str) -> tuple[dict, str]:
249
+ """The one read behind `seats`, `agents` and `keys` (D11).
250
+
251
+ `GET /api/fleet` already returns the roster, the seats and the credentials together,
252
+ because they are read together. Three verbs over one route is not three routes.
253
+ """
254
+ url, project = _server(args), _project(args, act)
255
+ client = authenticated(url, act=act)
256
+ return client.call("GET", f"/api/fleet?project_id={project}"), project
257
+
258
+
259
+ def cmd_seats(args) -> int:
260
+ if args.act == "issue":
261
+ url, project = _server(args), _project(args, "seats issue")
262
+ out = authenticated(url, act="seats issue").call(
263
+ "POST", "/api/fleet/seats",
264
+ {"project_id": project, "roles": args.roles, "wave": args.wave})
265
+ # The codes are returned ONCE, by the server, and nothing here stores them. A CLI
266
+ # that helpfully wrote them to a file would be inventing a second credential at rest
267
+ # that no route and no test knows about.
268
+ lines = [f"{PROG}: {len(out.get('seats', []))} seats on {out.get('wave')}",
269
+ " each code is shown once and is not stored anywhere:"]
270
+ lines += [f" {s['code']} {s['role']}" for s in out.get("seats", [])]
271
+ lines.append(" feed them to `gban fleet up --seats-file`, one per line.")
272
+ _out(out, "\n".join(lines), args.as_json)
273
+ return 0
274
+ if args.act == "revoke-unused":
275
+ url, project = _server(args), _project(args, "seats revoke-unused")
276
+ out = authenticated(url, act="seats revoke-unused").call(
277
+ "POST", "/api/fleet/seats/revoke-unused",
278
+ {"project_id": project, "wave": args.wave or None})
279
+ _out(out, f"{PROG}: {out.get('revoked', 0)} unused seats revoked"
280
+ f"\n consumed seats are untouched: they record which agent took what.",
281
+ args.as_json)
282
+ return 0
283
+ fleet, _ = _fleet_read(args, "seats")
284
+ seats = fleet.get("seats", [])
285
+ human = [f"{PROG}: {len(seats)} seats"] + [
286
+ f" {s.get('wave', ''):<10} {s.get('role', ''):<8} {s.get('state', '')}"
287
+ + (f" taken by {s['consumed_by']}" if s.get("consumed_by") else "")
288
+ for s in seats] or [f"{PROG}: no seats"]
289
+ _out({"seats": seats}, "\n".join(human), args.as_json)
290
+ return 0
291
+
292
+
293
+ def cmd_agents(args) -> int:
294
+ if args.act == "role":
295
+ url = _server(args)
296
+ # No project in the path: the agent id already names one, and the server resolves it.
297
+ # Asking for a project here would let a person name one the agent is not on.
298
+ out = authenticated(url, act="agents role").call(
299
+ "PUT", f"/api/fleet/agents/{args.agent_id}/role",
300
+ {"role": args.role, "reason": args.reason})
301
+ _out(out, f"{PROG}: {out.get('agent_id')} is now {out.get('active_role')}"
302
+ f"\n {out.get("takes_effect") or "on the agent's next poll"}", args.as_json)
303
+ return 0
304
+ fleet, _ = _fleet_read(args, "agents")
305
+ agents = fleet.get("agents", [])
306
+ human = [f"{PROG}: {len(agents)} agents"]
307
+ for a in agents:
308
+ # WHAT IT COULD BE MOVED TO, next to what it is. Without the ceiling on the row,
309
+ # `agents role` is a coin flip against a bound nothing shows (GRPH-780).
310
+ ceiling = a.get("credential_roles") or []
311
+ human.append(f" {a.get('key') or a.get('id'):<12} {a.get('active_role', ''):<8} "
312
+ f"{a.get('state', ''):<10} {a.get('credential') or ''}"
313
+ + (f" [{'|'.join(ceiling)}]" if len(ceiling) > 1 else ""))
314
+ # THE LINE THIS VERB EXISTS FOR (criterion 8). A roster that says "idle worker" for an
315
+ # agent being refused every call it makes is the thing that cost an afternoon and a
316
+ # database query on Super-Arc.
317
+ refusal = a.get("last_refusal") or {}
318
+ if refusal.get("tool"):
319
+ human.append(f" last refused {refusal['tool']} x{refusal.get('count', 1)}: "
320
+ f"{refusal.get('reason', '')}")
321
+ _out({"agents": agents}, "\n".join(human) if agents else f"{PROG}: no agents", args.as_json)
322
+ return 0
323
+
324
+
325
+ def cmd_keys(args) -> int:
326
+ if args.act == "mint":
327
+ url, project = _server(args), _project(args, "keys mint")
328
+ first, *also = args.role
329
+ out = authenticated(url, act="keys mint").call(
330
+ "POST", "/api/fleet/keys",
331
+ {"project_id": project, "role": first, "also": also, "wave": args.wave,
332
+ "label": args.label})
333
+ ceiling = out.get("roles") or [out.get("role")]
334
+ _out(out, f"{PROG}: {out.get('plaintext')}"
335
+ f"\n {out.get('role')} on {out.get('wave')}, shown once, expires "
336
+ f"{out.get('expires_at')}"
337
+ + (f"\n re-taskable within {', '.join(ceiling)}" if len(ceiling) > 1
338
+ else "\n one role only: an agent on this key cannot be re-tasked"),
339
+ args.as_json)
340
+ return 0
341
+ fleet, _ = _fleet_read(args, "keys")
342
+ creds = fleet.get("credentials", [])
343
+ human = [f"{PROG}: {len(creds)} credentials"] + [
344
+ f" {c.get('prefix', ''):<14} {c.get('name', ''):<20} {c.get('wave') or '-':<10}"
345
+ + (" REVOKED" if c.get("revoked") else "")
346
+ + (" all-in-one" if c.get("posture") == "single" else "")
347
+ for c in creds]
348
+ _out({"credentials": creds}, "\n".join(human) if creds else f"{PROG}: no credentials",
349
+ args.as_json)
350
+ return 0
351
+
352
+
353
+ COMMANDS = {"login": cmd_login, "logout": cmd_logout, "whoami": cmd_whoami,
354
+ "doctor": cmd_doctor, "fleet": cmd_fleet, "seats": cmd_seats,
355
+ "agents": cmd_agents, "keys": cmd_keys}
356
+
357
+
358
+ def main(argv: list[str] | None = None) -> int:
359
+ args = _parser().parse_args(argv)
360
+ try:
361
+ return COMMANDS[args.command](args)
362
+ except NoSession as exc:
363
+ # Never a 401 traceback: the person needs one instruction, and this is it.
364
+ print(f"{PROG}: {exc.advice(PROG)}", file=sys.stderr)
365
+ return EXIT_NO_SESSION
366
+ except Unreachable as exc:
367
+ print(f"{PROG}: {exc}", file=sys.stderr)
368
+ return EXIT_UNREACHABLE
369
+ except Refused as exc:
370
+ # The server's own words, unedited (D8). Its `hint` is already the machine-readable
371
+ # next step, and re-wording it here would be a second definition of the rule.
372
+ print(f"{PROG}: {exc.detail}", file=sys.stderr)
373
+ if exc.hint:
374
+ print(f" {exc.hint}", file=sys.stderr)
375
+ return EXIT_REFUSED
376
+
377
+
378
+ if __name__ == "__main__": # pragma: no cover
379
+ raise SystemExit(main())
gban/client.py ADDED
@@ -0,0 +1,154 @@
1
+ """The one place `gban` talks to a Graphban server (PRD-40 D3, D11).
2
+
3
+ Every verb is one endpoint, called once. The only call that is not a verb is the token
4
+ exchange, and it is transport rather than policy: it carries no rule, decides nothing, and
5
+ happens before any verb runs.
6
+
7
+ **The session model, and why it needs no machinery.** `POST /api/auth/refresh` issues a new
8
+ pair but does not consume the token presented — validity is keyed on `user.token_version`,
9
+ which moves only on logout or a password change (AL-59). So an invocation exchanges once, holds
10
+ the access token in memory, makes its call, and exits; two `gban` processes at once cannot
11
+ disturb each other; and `session.json` is written only by `gban login`, never per call. Writing
12
+ it every time would manufacture the race that does not otherwise exist.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import urllib.error
19
+ import urllib.request
20
+
21
+ from gban import config
22
+
23
+ #: Exit codes. Chosen so they cannot collide with a passed-through `gbfleet` code, which
24
+ #: carries meaning of its own (75 stuck, 69 unreachable endpoint, 55 budget) and is returned
25
+ #: unchanged by `gban fleet` (D5).
26
+ EXIT_REFUSED = 1
27
+ EXIT_UNREACHABLE = 2
28
+ EXIT_NO_SESSION = 3
29
+ EXIT_NO_SUPERVISOR = 4
30
+
31
+ TIMEOUT = 30.0
32
+
33
+
34
+ class Unreachable(Exception):
35
+ """No HTTP response at all: the server, the network, or the URL."""
36
+
37
+
38
+ class NoSession(Exception):
39
+ """No stored session, or one the server will not renew.
40
+
41
+ Expired and revoked are deliberately the SAME state here. Both come back 401, and the
42
+ person does the same thing either way — a distinction would be precision nobody can act on.
43
+
44
+ "You have a credential, but not one that can do this" IS a different state, and the only
45
+ one where the next step is not simply `gban login` (criterion 5). Somebody who exported
46
+ `GRAPHBAN_API_KEY` and watched `gban fleet` work has every reason to read "session expired"
47
+ as a bug in the tool rather than as a statement about what a key is for.
48
+ """
49
+
50
+ def __init__(self, why: str, *, act: str = "", have_key: bool = False) -> None:
51
+ super().__init__(why)
52
+ self.why = why
53
+ self.act = act
54
+ self.have_key = have_key
55
+
56
+ def advice(self, prog: str) -> str:
57
+ if self.have_key and self.act:
58
+ return (f"`{prog} {self.act}` needs a session, not an API key. It acts as YOU — "
59
+ f"the ledger records which human did it — and ${config.API_KEY_ENV} names "
60
+ f"an agent.\n Run `{prog} login`.")
61
+ return f"session expired, run `{prog} login`"
62
+
63
+
64
+ class Refused(Exception):
65
+ """The server said no, in its own words (D8).
66
+
67
+ Carries the server's `detail` and `hint` unedited. Re-wording either would put a second
68
+ copy of a rule in the client, and the server's phrasing is the one under test.
69
+ """
70
+
71
+ def __init__(self, status: int, detail: str, hint: str = "") -> None:
72
+ super().__init__(detail)
73
+ self.status = status
74
+ self.detail = detail
75
+ self.hint = hint
76
+
77
+
78
+ class Client:
79
+ """A server, and the credential this invocation is using."""
80
+
81
+ def __init__(self, url: str, *, token: str = "", api_key: str = "") -> None:
82
+ self.url = url.rstrip("/")
83
+ self.token = token
84
+ self.api_key = api_key
85
+
86
+ # ---- transport ---------------------------------------------------------------------
87
+ def _open(self, method: str, path: str, body: dict | None, headers: dict) -> tuple[int, dict]:
88
+ data = json.dumps(body).encode() if body is not None else None
89
+ request = urllib.request.Request(self.url + path, data=data, method=method,
90
+ headers={"Content-Type": "application/json", **headers})
91
+ try:
92
+ with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
93
+ raw = response.read().decode() or "{}"
94
+ return response.status, (json.loads(raw) if raw.strip() else {})
95
+ except urllib.error.HTTPError as exc:
96
+ raw = exc.read().decode() or "{}"
97
+ try:
98
+ payload = json.loads(raw)
99
+ except ValueError:
100
+ payload = {"detail": raw[:400]}
101
+ return exc.code, payload if isinstance(payload, dict) else {"detail": str(payload)}
102
+ except (urllib.error.URLError, OSError, TimeoutError) as exc:
103
+ # No HTTP response at all. Structurally different from a refusal, and reported as
104
+ # such — the client never reads a message to decide which failure it is in (D8).
105
+ raise Unreachable(f"could not reach {self.url}: {exc}") from exc
106
+
107
+ def call(self, method: str, path: str, body: dict | None = None) -> dict:
108
+ headers = {}
109
+ if self.token:
110
+ headers["Authorization"] = f"Bearer {self.token}"
111
+ elif self.api_key:
112
+ headers["X-API-Key"] = self.api_key
113
+ status, payload = self._open(method, path, body, headers)
114
+ if status >= 400:
115
+ detail = payload.get("detail") or payload.get("message") or f"HTTP {status}"
116
+ if isinstance(detail, dict):
117
+ detail, hint = (detail.get("message") or str(detail)), detail.get("hint", "")
118
+ else:
119
+ hint = payload.get("hint", "")
120
+ raise Refused(status, str(detail), str(hint))
121
+ return payload
122
+
123
+
124
+ def login(url: str, email: str, password: str) -> dict:
125
+ """Exchange credentials for a token pair. The only call that sends a password."""
126
+ return Client(url).call("POST", "/api/auth/login",
127
+ {"email": email, "password": password})
128
+
129
+
130
+ def authenticated(url: str, *, act: str = "") -> Client:
131
+ """A client for this invocation: one refresh exchange, access token held in memory.
132
+
133
+ Raises `NoSession` when there is nothing stored or the server will not renew it. The
134
+ caller turns that into one instruction and exit 3 — never a traceback, and never a bare
135
+ 401 (criterion 4). `act` is the verb the person typed; it appears only in the message for
136
+ somebody holding an API key and nothing else.
137
+ """
138
+ stored = config.session().get("refresh_token")
139
+ if not stored:
140
+ # `act` is what the person typed, so the message can name it rather than describe a
141
+ # category they would then have to work out they are in.
142
+ raise NoSession("no stored session", act=act,
143
+ have_key=bool(os.environ.get(config.API_KEY_ENV)))
144
+ try:
145
+ pair = Client(url).call("POST", "/api/auth/refresh", {"refresh_token": stored})
146
+ except Refused as exc:
147
+ raise NoSession(str(exc)) from exc
148
+ token = pair.get("access_token") or ""
149
+ if not token:
150
+ raise NoSession("the server returned no access token")
151
+ # The new refresh token is NOT written back. The presented one stays valid (the server
152
+ # keys on `token_version`), so rewriting the file on every call would buy nothing and
153
+ # create a race between concurrent invocations.
154
+ return Client(url, token=token)
gban/config.py ADDED
@@ -0,0 +1,102 @@
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
+ The grill made that stricter than the draft. Reading `config.json` and ignoring the keys it did
8
+ not recognise would have been true and insufficient: the risk is not misreading a database
9
+ password, it is that password living in a file which now has a second consumer and a second
10
+ reason to be copied onto another machine. `graphban` runs in a container against a database;
11
+ `gban` runs on a laptop against HTTP; the credential that must not cross that line lives in its
12
+ own file, so copying a config never carries it.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import stat
19
+ from pathlib import Path
20
+
21
+ #: The directory both tools use. Shared deliberately — a person has one Graphban.
22
+ HOME_ENV = "GRAPHBAN_HOME"
23
+
24
+ #: `gban`'s own settings. NOT `config.json`, which belongs to `graphban` (D10).
25
+ SETTINGS_FILE = "gban.json"
26
+
27
+ #: The refresh token, alone in its own file so that copying settings never carries it.
28
+ SESSION_FILE = "session.json"
29
+
30
+ #: `graphban`'s file. Named here only so the test that asserts `gban` never opens it has
31
+ #: something to name, and so a reader knows the omission is deliberate.
32
+ NOT_OURS = "config.json"
33
+
34
+ URL_ENV = "GRAPHBAN_URL"
35
+ PROJECT_ENV = "GRAPHBAN_PROJECT"
36
+ API_KEY_ENV = "GRAPHBAN_API_KEY"
37
+
38
+ #: Owner read/write and nothing else. A credential at rest gets the same mode the seat files
39
+ #: in `gbfleet` get, for the same reason.
40
+ PRIVATE = stat.S_IRUSR | stat.S_IWUSR
41
+
42
+
43
+ def home() -> Path:
44
+ return Path(os.environ.get(HOME_ENV) or (Path.home() / ".graphban"))
45
+
46
+
47
+ def _read(path: Path) -> dict:
48
+ try:
49
+ loaded = json.loads(path.read_text(encoding="utf-8"))
50
+ except (OSError, ValueError):
51
+ # A missing file and an unreadable one are the same to a caller that has a default,
52
+ # and neither is worth a traceback in front of somebody trying to log in.
53
+ return {}
54
+ return loaded if isinstance(loaded, dict) else {}
55
+
56
+
57
+ def _write(path: Path, payload: dict) -> Path:
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ # Created private BEFORE anything is written to it. Writing first and chmod-ing after
60
+ # leaves a window where the token is world-readable, which is the whole failure.
61
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, PRIVATE)
62
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
63
+ json.dump(payload, fh, indent=1, sort_keys=True)
64
+ fh.write("\n")
65
+ os.chmod(path, PRIVATE)
66
+ return path
67
+
68
+
69
+ def settings() -> dict:
70
+ return _read(home() / SETTINGS_FILE)
71
+
72
+
73
+ def save_settings(**values: str) -> Path:
74
+ merged = {k: v for k, v in {**settings(), **values}.items() if v}
75
+ return _write(home() / SETTINGS_FILE, merged)
76
+
77
+
78
+ def session() -> dict:
79
+ return _read(home() / SESSION_FILE)
80
+
81
+
82
+ def save_session(refresh_token: str, *, user: str = "") -> Path:
83
+ return _write(home() / SESSION_FILE,
84
+ {"refresh_token": refresh_token, "user": user})
85
+
86
+
87
+ def clear_session() -> bool:
88
+ path = home() / SESSION_FILE
89
+ try:
90
+ path.unlink()
91
+ return True
92
+ except FileNotFoundError:
93
+ return False
94
+
95
+
96
+ def resolve(flag: str | None, env: str, key: str) -> str:
97
+ """D10's precedence, in one place: flag, then environment, then the settings file.
98
+
99
+ One function rather than three lookups at each call site, because a precedence that is
100
+ re-implemented per option is one that will eventually differ per option.
101
+ """
102
+ return (flag or os.environ.get(env) or settings().get(key) or "").strip()
gban/doctor.py ADDED
@@ -0,0 +1,196 @@
1
+ """`gban doctor` — one answer to "is this set up correctly", across both halves (PRD-40 D7).
2
+
3
+ Two halves fail in each other's terms. `gbfleet doctor` already checks the local one — repo,
4
+ workspace, adapter binary, seats file — and nothing checked the other: whether the credential
5
+ is valid, whether the project exists, whether an agent is quarantined or being refused every
6
+ call it makes. Diagnosing a stuck worker on the deployed instance needed a database query.
7
+
8
+ **Neither half may silence the other.** A half that could not be checked prints UNKNOWN with
9
+ its reason, never a pass and never a fail — the same three states `gbfleet doctor` uses, for
10
+ the same reason: "we could not tell" must never render as "nothing is wrong".
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ from gban import config
20
+ from gban.client import Client, NoSession, Refused, Unreachable, authenticated
21
+
22
+ PASS, FAIL, UNKNOWN = "PASS", "FAIL", "UNKNOWN"
23
+
24
+ #: Worst first. The exit code is the worst finding across BOTH halves, never whichever ran
25
+ #: last — a local pass after a ledger failure must not report success.
26
+ SEVERITY = {PASS: 0, UNKNOWN: 1, FAIL: 2}
27
+
28
+
29
+ def _line(side: str, status: str, name: str, detail: str = "", report: str = "") -> dict:
30
+ """One finding. `detail` is this line's own reason; `report` is another tool's output,
31
+ kept in its own field so it can never be mistaken for one."""
32
+ return {"side": side, "status": status, "name": name, "detail": detail, "report": report}
33
+
34
+
35
+ def ledger(url: str, project: str) -> list[dict]:
36
+ """The half nothing checked. Every line says `ledger`, because a line that does not say
37
+ where it came from sends a reader to the wrong machine."""
38
+ if not url:
39
+ return [_line("ledger", UNKNOWN, "server",
40
+ f"no server configured; pass --server or run `gban login`")]
41
+ try:
42
+ client = authenticated(url)
43
+ except NoSession as exc:
44
+ return [_line("ledger", FAIL, "session", f"{exc} — run `gban login`")]
45
+ except Unreachable as exc:
46
+ # Three distinct lines for three distinct failures, because they send a reader to
47
+ # three different places: the network, the credential, or the project.
48
+ return [_line("ledger", UNKNOWN, "server", str(exc))]
49
+
50
+ out = [_line("ledger", PASS, "server", url)]
51
+ try:
52
+ me = client.call("GET", "/api/auth/me")
53
+ out.append(_line("ledger", PASS, "session", me.get("email") or me.get("id", "")))
54
+ except Refused as exc:
55
+ return out + [_line("ledger", FAIL, "session", exc.detail)]
56
+ except Unreachable as exc:
57
+ return out + [_line("ledger", UNKNOWN, "session", str(exc))]
58
+
59
+ if not project:
60
+ out.append(_line("ledger", UNKNOWN, "project",
61
+ "no project configured; pass --project or set one with `gban login`"))
62
+ return out
63
+ try:
64
+ fleet = client.call("GET", f"/api/fleet?project_id={project}")
65
+ except Refused as exc:
66
+ out.append(_line("ledger", FAIL, "project",
67
+ f"{project}: {exc.detail}" + (f" — {exc.hint}" if exc.hint else "")))
68
+ return out
69
+ except Unreachable as exc:
70
+ out.append(_line("ledger", UNKNOWN, "project", str(exc)))
71
+ return out
72
+
73
+ agents = fleet.get("agents") or []
74
+ live = [a for a in agents if a.get("state") != "offline"]
75
+ out.append(_line("ledger", PASS, "project",
76
+ f"{project}: {len(live)} agent(s) online of {len(agents)}"))
77
+
78
+ # The two states that are the whole reason a person runs this. Reported per agent, because
79
+ # "one agent is quarantined" and "which one" are different amounts of help.
80
+ for agent in agents:
81
+ if agent.get("state") == "quarantined":
82
+ out.append(_line("ledger", FAIL, f"agent {agent.get('id')}",
83
+ "quarantined — it kept calling tools it is not permitted"))
84
+ refusal = agent.get("last_refusal") or {}
85
+ if refusal.get("tool"):
86
+ times = f" x{refusal['count']}" if refusal.get("count", 1) > 1 else ""
87
+ out.append(_line("ledger", FAIL, f"agent {agent.get('id')}",
88
+ f"refused {refusal['tool']}{times} — {refusal.get('reason', '')}"))
89
+ return out
90
+
91
+
92
+ def local(url: str, project: str, api_key: str) -> list[dict]:
93
+ """`gbfleet doctor`, run as a subprocess (D5). Its output is passed through, not parsed:
94
+ the supervisor's own report is the one under test."""
95
+ binary = find_supervisor()
96
+ if not binary:
97
+ return [_line("local", UNKNOWN, "gbfleet",
98
+ f"not installed here — {INSTALL_SUPERVISOR} to check the local half")]
99
+ argv = [binary, "doctor"]
100
+ if url:
101
+ argv += ["--server", url]
102
+ if project:
103
+ argv += ["--project", project]
104
+ try:
105
+ done = subprocess.run(argv, capture_output=True, text=True, timeout=120,
106
+ env=child_environment(api_key))
107
+ except (OSError, subprocess.SubprocessError) as exc:
108
+ return [_line("local", UNKNOWN, "gbfleet", f"could not run: {exc}")]
109
+ status = PASS if done.returncode == 0 else FAIL
110
+ body = (done.stdout or done.stderr or "").strip()
111
+ # The summary line says what HAPPENED; the child's report goes underneath, verbatim, in
112
+ # its own field. Putting the body in `detail` put the child's banner where the reason
113
+ # belongs — the walk read `FAIL local gbfleet gbfleet 0.1.0 doctor`, which names a
114
+ # version and explains nothing. Choosing some line of the body to promote instead would
115
+ # be parsing the supervisor's output, which is the thing D5 is careful not to do.
116
+ verdict = "every check passed" if status is PASS else f"exited {done.returncode}"
117
+ return [_line("local", status, "gbfleet",
118
+ f"`gbfleet doctor` {verdict}; its own report follows", report=body)]
119
+
120
+
121
+ #: The command that actually installs the supervisor. NOT `uv pip install graphban-fleet`,
122
+ #: which is what this said until somebody ran it: neither package is on PyPI, so that line
123
+ #: 404s. A tool whose remedy does not work is worse than one that offers none — it spends the
124
+ #: reader's trust before spending their time.
125
+ INSTALL_SUPERVISOR = (
126
+ 'uv tool install "git+https://github.com/asc-me/graphban.git#subdirectory=fleet"')
127
+
128
+
129
+ def find_supervisor() -> str:
130
+ """`gbfleet`, from beside this interpreter first and only then from PATH.
131
+
132
+ **A sibling install is invisible to `PATH` alone**, and that is not a corner case: a
133
+ `graphban-cli[fleet]` extra, or a plain `pip install` of both into one virtualenv, puts
134
+ `gbfleet` in the same `bin/` as `gban` — and `uv tool install` deliberately exposes only
135
+ the requested package's executables, so the supervisor lands there and on no path at all.
136
+ Measured: with the extra installed, `gban fleet` said "gbfleet is not installed here"
137
+ while `gbfleet` sat in the very environment it was running from.
138
+
139
+ PATH still wins for a supervisor the operator installed separately and put there on
140
+ purpose — a sibling is a fallback for the case PATH cannot see, not an override of it.
141
+ """
142
+ import sys
143
+
144
+ found = shutil.which("gbfleet")
145
+ if found:
146
+ return found
147
+ beside = Path(sys.executable).parent / "gbfleet"
148
+ return str(beside) if beside.exists() and os.access(beside, os.X_OK) else ""
149
+
150
+
151
+ #: What `gbfleet` reads. `gban` names it once, here, and both paths that launch a supervisor
152
+ #: go through this function (GRPH-782).
153
+ SUPERVISOR_KEY_ENV = "GBFLEET_API_KEY"
154
+
155
+
156
+ def child_environment(api_key: str) -> dict:
157
+ """The environment a `gbfleet` child is launched with, for `doctor` AND for `fleet`.
158
+
159
+ **The two used to disagree, and that made the doctor a liar.** `doctor` translated
160
+ `$GRAPHBAN_API_KEY` into `$GBFLEET_API_KEY` for the child; `gban fleet` was a bare
161
+ `subprocess.run` and did not. So on a machine set up the way this tool's own premise
162
+ assumes — one key exported, under `gban`'s name — `gban doctor` reported the local half
163
+ green using a credential it manufactured, and `gban fleet up`, which `gban seats issue`
164
+ sends people to by name, started the supervisor with nothing. D7's promise is
165
+ "everything that can be checked before anything is spawned"; a local verdict that does
166
+ not predict what the next command does is the failure that promise exists to prevent.
167
+
168
+ In the ENVIRONMENT, never argv: `ps` shows argv to every process on the machine. And
169
+ never over a value the caller set themselves — somebody who exported `$GBFLEET_API_KEY`
170
+ deliberately, to run the supervisor on a different credential, means it.
171
+ """
172
+ import os
173
+
174
+ env = dict(os.environ)
175
+ if api_key and not env.get(SUPERVISOR_KEY_ENV):
176
+ env[SUPERVISOR_KEY_ENV] = api_key
177
+ return env
178
+
179
+
180
+ def run(url: str, project: str, api_key: str = "") -> tuple[list[dict], int]:
181
+ lines = ledger(url, project) + local(url, project, api_key)
182
+ worst = max((SEVERITY[l["status"]] for l in lines), default=0)
183
+ # FAIL exits 1, UNKNOWN exits 0: "could not check" is not "broken", and a doctor that
184
+ # failed a script because a laptop lacked `gbfleet` would stop being run.
185
+ return lines, (1 if worst == SEVERITY[FAIL] else 0)
186
+
187
+
188
+ def render(lines: list[dict]) -> str:
189
+ width = max((len(l["name"]) for l in lines), default=0)
190
+ out = []
191
+ for l in lines:
192
+ out.append(f"{l['status']:<7} {l['side']:<6} {l['name']:<{width}} {l['detail']}".rstrip())
193
+ # Indented, so a reader scanning the summary column can skip a page of somebody
194
+ # else's report without losing the two lines they came for.
195
+ out += [f" {row}".rstrip() for row in (l.get("report") or "").splitlines()]
196
+ return "\n".join(out)
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.5
2
+ Name: graphban-cli
3
+ Version: 0.1.0
4
+ Summary: gban — a Graphban client for the human at a terminal
5
+ Project-URL: Homepage, https://github.com/asc-me/graphban
6
+ Project-URL: Repository, https://github.com/asc-me/graphban
7
+ Project-URL: Documentation, https://github.com/asc-me/graphban/blob/main/cli/README.md
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.12
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
13
+ Requires-Dist: pytest>=8.0; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # graphban-cli
17
+
18
+ **`gban`** — the client for a human at a terminal.
19
+
20
+ Five surfaces existed before this and none of them was for a person at a shell prompt:
21
+ `graphban` talks to the database from inside the container, `gbfleet` supervises processes,
22
+ `gbagent` is a spawned child, the web app is a browser, and `/api/mcp` is for agents. Issuing
23
+ a seat, seeing why an agent is stuck, or re-tasking one meant opening a browser.
24
+
25
+ Specified by [PRD-40](https://github.com/asc-me/graphban/blob/main/docs/prd-40-gb-cli.md).
26
+
27
+ ## Install
28
+
29
+ Not on PyPI yet, so it installs from the repository. `uv tool install` puts it on your PATH
30
+ in its own environment, which is what you want for a CLI:
31
+
32
+ ```bash
33
+ uv tool install "git+https://github.com/asc-me/graphban.git#subdirectory=cli"
34
+ ```
35
+
36
+ Add `gbfleet` too if you run waves — it is a separate package, and `gban fleet` hands off to it:
37
+
38
+ ```bash
39
+ uv tool install "git+https://github.com/asc-me/graphban.git#subdirectory=fleet"
40
+ ```
41
+
42
+ `uv tool update-shell` once, if uv says the bin directory is not on your PATH. Upgrade either
43
+ with `uv tool upgrade graphban-cli` (or `--all`); reinstalling from the same URL also works,
44
+ since the spec is a branch rather than a pin.
45
+
46
+ With pip instead, into an environment you already have:
47
+
48
+ ```bash
49
+ pip install "graphban-cli @ git+https://github.com/asc-me/graphban.git#subdirectory=cli"
50
+ ```
51
+
52
+ `gban` pulls **nothing**: `client.py` is `urllib.request` throughout, and the install lands
53
+ exactly one distribution. `gbfleet` brings httpx and its four transitive dependencies, which
54
+ is why they are separate packages and not one.
55
+
56
+ ```bash
57
+ gban login --server https://cloud.agentldgr.dev
58
+ gban doctor # both halves: the ledger, and the local fleet
59
+ gban agents # the roster, and why an agent is stuck
60
+ gban agents role SA-A4 planner # what used to need a browser
61
+ gban seats issue worker worker planner # one entry per agent
62
+ gban keys # which key is that agent on
63
+ gban fleet up --seats-file seats.txt --adapter claude # hands off to gbfleet
64
+ ```
65
+
66
+ `seats issue` takes **one role per agent, repeats included**, because that is the server's
67
+ own shape: two agents on one seat share a session and cannot review each other. Each code is
68
+ printed once and written nowhere — a CLI that helpfully saved them would invent a second
69
+ credential at rest that no route and no test knows about.
70
+
71
+ `agents` prints what an agent was **last refused, and why**. That line is the reason the verb
72
+ exists: a roster saying "idle worker" for an agent being told no on every call it makes is
73
+ what made the Super-Arc diagnosis take a database query.
74
+
75
+ `agents role` re-tasks a live agent **within its credential's ceiling** and never past it. A
76
+ role the key does not permit is the server's refusal, printed in the server's own words;
77
+ widening a ceiling means minting a different credential, and keeping those two acts apart is
78
+ the point of having a ceiling. It lands on the agent's next poll.
79
+
80
+ ## `gban login` wants a real terminal
81
+
82
+ It refuses without one, rather than prompting. `getpass` falls back to a plain **echoing**
83
+ read when it cannot turn echo off — it warns, but the warning arrives after the person has
84
+ decided to type — so a login through a pipe, a heredoc or an editor's command runner would
85
+ put the password in the scrollback. There is no non-interactive login yet (PRD-40 open
86
+ question 2: an API key cannot reach the JWT routes, so CI would need a service session).
87
+
88
+ ## Why not `gb`
89
+
90
+ Because `gb` is already `git branch` on a large share of developer machines, and **an alias
91
+ beats a binary on `PATH`**. The deployed walk hit it on the very first command and got git's
92
+ usage text; nothing inside the process can detect that, because by the time `gb` would have
93
+ run, the alias did not.
94
+
95
+ It is not one alias but a whole namespace. oh-my-zsh's git plugin — which is where most of
96
+ these come from — defines sixteen `gb*` aliases and ten `grb*`, so `gb`, `gba`, `grb` and
97
+ `gbl` are all spoken for. `gban` is outside it, still short, and still says which product it
98
+ belongs to.
99
+
100
+ ## Licence — Apache-2.0, deliberately not the repository's FSL-1.1
101
+
102
+ The repository is [FSL-1.1-Apache-2.0](https://github.com/asc-me/graphban/blob/main/LICENSE.md). This directory is
103
+ [Apache-2.0](https://github.com/asc-me/graphban/blob/main/cli/LICENSE), for the reasons PRD-22 §8 gives for `fleet/` — every one of which
104
+ applies here identically. `gban` is inert without a Graphban server and holds no authority of
105
+ its own, so FSL's Competing Use clause protects the server and protects nothing here. It is a
106
+ laptop-installed developer CLI, which is exactly the kind of dependency that has to clear a
107
+ corporate licence policy scanner.
108
+
109
+ ## Why it is in this repository
110
+
111
+ **Not a second repository**, for the reason [`fleet/README.md`](https://github.com/asc-me/graphban/blob/main/fleet/README.md) gives for
112
+ the supervisor, with more force: the client↔server contract has no schema anywhere, and a
113
+ cross-repo break would present as absence reading clean — `gban` still runs, nothing errors, the
114
+ verb quietly stops meaning what it said. The evidence is recent and specific: `ROLES` lost
115
+ `reviewer` in one PR while another added a test naming it, and CI caught the pair inside
116
+ seventeen minutes because both lived in one repository. Split across two, that lands as a bug
117
+ report from somebody whose `gban agents role ... reviewer` started refusing.
118
+
119
+ **Not inside `backend/`**, because `graphban-api` pulls fastapi, sqlalchemy, pgvector, psycopg,
120
+ alembic, redis and cryptography, and this installs on a laptop. `tests/test_packaging.py`
121
+ derives its forbidden set from the backend's own dependency list rather than a denylist
122
+ somebody maintains.
123
+
124
+ ## What it is not
125
+
126
+ It is **not a second web app**: no board, no PRD editor, no search. Every verb is either
127
+ something a human currently opens a browser for, or a diagnosis nothing else gives.
128
+
129
+ It is **not `graphban`**, which talks to the local database from inside the container and
130
+ stays exactly as it is. Mixing "against the DB in the container" and "over HTTP from a laptop"
131
+ into one command is an ambiguity that ends with somebody purging the wrong instance.
132
+
133
+ It **holds no state the server does not** and computes nothing the server computes. Every verb
134
+ is one endpoint, called once (PRD-40 D11); an ordering between two calls would be a rule, and
135
+ a rule in the client is a second definition of something the server already enforces.
@@ -0,0 +1,10 @@
1
+ gban/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ gban/cli.py,sha256=MNzyjywOXNSdzixpio0tp_M_J6Z-gKlXSJGVxuMtQEI,19163
3
+ gban/client.py,sha256=IZc7C7V9EYY0Iq51X6j9acZa7f_nUKpChgHZLTiR_Sc,7026
4
+ gban/config.py,sha256=-BI1KUeSvU11aJ79faOMoTuuv2YHc0ShQ-J6zjxzjRE,3816
5
+ gban/doctor.py,sha256=7xiCAWlGpi3aCeHN2d_G8WDqKZsNpKytjQia6U_jMn4,9766
6
+ graphban_cli-0.1.0.dist-info/METADATA,sha256=bsMjyMZdpzdih6OpbAPo1O8WVwcy4oL9BJtYPJwRrto,7074
7
+ graphban_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ graphban_cli-0.1.0.dist-info/entry_points.txt,sha256=nc7a--ylRW5tl7Y3zGYYj8iCfvEU4bHFXuLC5XrzR2Y,39
9
+ graphban_cli-0.1.0.dist-info/licenses/LICENSE,sha256=bTJXVKNN5dcSiUDYFuITA3s6oqa7HFoEOyxmodoZ8Ak,10833
10
+ graphban_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gban = gban.cli:main
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Ascme Labs
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.