graphban-cli 0.1.0__tar.gz → 0.1.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphban-cli
3
- Version: 0.1.0
3
+ Version: 0.1.1
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
- 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:
29
+ ```bash
30
+ uv tool install graphban-cli
31
+ ```
32
+
33
+ or, on macOS:
31
34
 
32
35
  ```bash
33
- uv tool install "git+https://github.com/asc-me/graphban.git#subdirectory=cli"
36
+ brew install asc-me/tap/gban
34
37
  ```
35
38
 
36
- Add `gbfleet` too if you run waves — it is a separate package, and `gban fleet` hands off to it:
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 "git+https://github.com/asc-me/graphban.git#subdirectory=fleet"
49
+ uv tool install graphban-fleet
40
50
  ```
41
51
 
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.
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 "graphban-cli @ git+https://github.com/asc-me/graphban.git#subdirectory=cli"
72
+ pip install graphban-cli
50
73
  ```
51
74
 
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.
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
@@ -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
- Not on PyPI yet, so it installs from the repository. `uv tool install` puts it on your PATH
15
- in its own environment, which is what you want for a CLI:
14
+ ```bash
15
+ uv tool install graphban-cli
16
+ ```
17
+
18
+ or, on macOS:
16
19
 
17
20
  ```bash
18
- uv tool install "git+https://github.com/asc-me/graphban.git#subdirectory=cli"
21
+ brew install asc-me/tap/gban
19
22
  ```
20
23
 
21
- Add `gbfleet` too if you run waves — it is a separate package, and `gban fleet` hands off to it:
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 "git+https://github.com/asc-me/graphban.git#subdirectory=fleet"
34
+ uv tool install graphban-fleet
25
35
  ```
26
36
 
27
- `uv tool update-shell` once, if uv says the bin directory is not on your PATH. Upgrade either
28
- with `uv tool upgrade graphban-cli` (or `--all`); reinstalling from the same URL also works,
29
- since the spec is a branch rather than a pin.
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 "graphban-cli @ git+https://github.com/asc-me/graphban.git#subdirectory=cli"
57
+ pip install graphban-cli
35
58
  ```
36
59
 
37
- `gban` pulls **nothing**: `client.py` is `urllib.request` throughout, and the install lands
38
- exactly one distribution. `gbfleet` brings httpx and its four transitive dependencies, which
39
- is why they are separate packages and not one.
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphban-cli"
3
- version = "0.1.0"
3
+ version = "0.1.1"
4
4
  description = "gban — a Graphban client for the human at a terminal"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -215,10 +215,17 @@ def cmd_fleet(args) -> int:
215
215
  and leaves `gbfleet --help` authoritative about its own commands.
216
216
  """
217
217
  binary = doctor_mod.find_supervisor()
218
+ if not binary:
219
+ # At a terminal, offer to fix it here rather than making the person read a command,
220
+ # copy it, run it and type this one again. Declined or unavailable, the message is
221
+ # what it always was.
222
+ if doctor_mod.offer_to_install():
223
+ binary = doctor_mod.find_supervisor()
218
224
  if not binary:
219
225
  print(f"{PROG}: gbfleet is not installed here. Install it with:\n"
220
226
  f" {doctor_mod.INSTALL_SUPERVISOR}\n"
221
- f" …or run it from the repository's fleet/ directory.", file=sys.stderr)
227
+ f" …or `brew install asc-me/tap/gban` installs this client only — the "
228
+ f"supervisor is a separate package.", file=sys.stderr)
222
229
  return EXIT_NO_SUPERVISOR
223
230
  argv = [binary, *[a for a in args.rest if a != "--"]]
224
231
  url = config.resolve(args.server, config.URL_ENV, "url")
@@ -118,12 +118,52 @@ 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. 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"')
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())
127
167
 
128
168
 
129
169
  def find_supervisor() -> str:
@@ -171,7 +171,7 @@ def test_a_missing_supervisor_says_how_to_install_it(home, monkeypatch, capsys):
171
171
  a real gbfleet on the machine running it."""
172
172
  monkeypatch.setattr("gban.cli.doctor_mod.find_supervisor", lambda: "")
173
173
  assert main(["fleet", "ps"]) == EXIT_NO_SUPERVISOR
174
- assert "graphban.git" in capsys.readouterr().err
174
+ assert doctor.INSTALL_SUPERVISOR in capsys.readouterr().err
175
175
 
176
176
 
177
177
  def test_the_credential_reaches_gbfleet_in_the_environment_never_argv(home, monkeypatch):
@@ -356,8 +356,8 @@ def test_the_install_it_names_is_one_that_exists(home, monkeypatch, capsys):
356
356
  monkeypatch.setattr("gban.cli.doctor_mod.find_supervisor", lambda: "")
357
357
  assert main(["fleet", "ps"]) == EXIT_NO_SUPERVISOR
358
358
  err = capsys.readouterr().err
359
- assert "git+https://github.com/asc-me/graphban.git#subdirectory=fleet" in err
360
- assert "uv pip install graphban-fleet" not in err
359
+ assert doctor.INSTALL_SUPERVISOR in err
360
+ assert "uv pip install graphban-fleet" not in err, "that package name has never existed"
361
361
 
362
362
 
363
363
  def test_the_doctor_names_the_same_install(home, monkeypatch):
@@ -365,7 +365,7 @@ def test_the_doctor_names_the_same_install(home, monkeypatch):
365
365
  monkeypatch.setattr("gban.doctor.authenticated", lambda url: _Server())
366
366
  lines, _ = doctor.run("http://gb.invalid", "core", "")
367
367
  local = [l for l in lines if l["side"] == "local"][0]
368
- assert "subdirectory=fleet" in local["detail"]
368
+ assert doctor.INSTALL_SUPERVISOR in local["detail"]
369
369
  assert "uv pip install graphban-fleet" not in local["detail"]
370
370
 
371
371
 
@@ -404,3 +404,107 @@ def test_both_commands_find_the_supervisor_the_same_way(home):
404
404
  for fn in (cli_mod.cmd_fleet, doctor.local):
405
405
  assert "find_supervisor" in inspect.getsource(fn), (
406
406
  f"{fn.__qualname__} looks for gbfleet some other way")
407
+
408
+
409
+ # ---- offering to install the supervisor, never doing it uninvited -----------------------------
410
+
411
+ class _Tty:
412
+ """Stdin that claims to be a terminal, so the guard below is not what is under test."""
413
+
414
+ def isatty(self) -> bool:
415
+ return True
416
+
417
+
418
+ def test_it_never_installs_without_being_asked(home, monkeypatch, capsys):
419
+ """THE INVARIANT. Installing software is not a side effect anybody should get from
420
+ `gban fleet ps` — it writes outside this program and a person who typed a read-only
421
+ command did not consent to it.
422
+
423
+ Sabotage: install on a bare return and this fails."""
424
+ ran = []
425
+ monkeypatch.setattr("sys.stdin", _Tty())
426
+ monkeypatch.setattr("gban.doctor.shutil.which", lambda name: "/usr/bin/uv")
427
+ monkeypatch.setattr("gban.doctor.subprocess.run", lambda *a, **kw: ran.append(a))
428
+ monkeypatch.setattr("builtins.input", lambda *_: "") # a bare return is not a yes
429
+
430
+ assert doctor.offer_to_install() is False
431
+ assert ran == [], "it installed without an explicit yes"
432
+
433
+
434
+ def test_a_yes_runs_the_install_and_reports_what_it_found(home, monkeypatch):
435
+ ran = []
436
+
437
+ class Done:
438
+ returncode = 0
439
+
440
+ monkeypatch.setattr("sys.stdin", _Tty())
441
+ monkeypatch.setattr("gban.doctor.shutil.which",
442
+ lambda name: "/usr/bin/uv" if name == "uv" else None)
443
+ monkeypatch.setattr("gban.doctor.subprocess.run",
444
+ lambda argv, **kw: (ran.append(argv), Done())[1])
445
+ monkeypatch.setattr("builtins.input", lambda *_: "y")
446
+ monkeypatch.setattr("gban.doctor.find_supervisor", lambda: "/usr/bin/gbfleet")
447
+
448
+ assert doctor.offer_to_install() is True
449
+ assert ran == [["uv", "tool", "install", "graphban-fleet"]]
450
+
451
+
452
+ def test_a_failed_install_is_reported_and_not_papered_over(home, monkeypatch, capsys):
453
+ """Returning True after a failed install would send the caller to look for a binary that
454
+ is not there, and the second error would describe the wrong thing."""
455
+ class Died:
456
+ returncode = 2
457
+
458
+ monkeypatch.setattr("sys.stdin", _Tty())
459
+ monkeypatch.setattr("gban.doctor.shutil.which", lambda name: "/usr/bin/uv")
460
+ monkeypatch.setattr("gban.doctor.subprocess.run", lambda *a, **kw: Died())
461
+ monkeypatch.setattr("builtins.input", lambda *_: "y")
462
+
463
+ assert doctor.offer_to_install() is False
464
+ assert "install failed" in capsys.readouterr().err
465
+
466
+
467
+ def test_it_does_not_prompt_where_nobody_can_answer(home, monkeypatch):
468
+ """A prompt in a script is a hang, and a hang is worse than the error it replaced."""
469
+ import io
470
+
471
+ monkeypatch.setattr("sys.stdin", io.StringIO(""))
472
+ monkeypatch.setattr("gban.doctor.shutil.which", lambda name: "/usr/bin/uv")
473
+ monkeypatch.setattr("builtins.input",
474
+ lambda *_: pytest.fail("it prompted with no terminal"))
475
+ assert doctor.offer_to_install() is False
476
+
477
+
478
+ def test_no_uv_means_no_offer(home, monkeypatch):
479
+ """The offer names one installer. Without it there is nothing to offer, and the caller's
480
+ message is the whole answer."""
481
+ monkeypatch.setattr("sys.stdin", _Tty())
482
+ monkeypatch.setattr("gban.doctor.shutil.which", lambda name: None)
483
+ monkeypatch.setattr("builtins.input",
484
+ lambda *_: pytest.fail("it offered an installer it does not have"))
485
+ assert doctor.offer_to_install() is False
486
+
487
+
488
+ def test_gban_fleet_runs_the_command_after_a_successful_install(home, monkeypatch):
489
+ """The point of asking at all: the person typed `gban fleet ps`, and after saying yes
490
+ they should get `ps` — not a second error telling them to type it again."""
491
+ seen = {}
492
+
493
+ class Done:
494
+ returncode = 0
495
+
496
+ calls = iter(["", "/usr/bin/gbfleet"]) # missing, then present after the install
497
+ monkeypatch.setattr("gban.cli.doctor_mod.find_supervisor", lambda: next(calls))
498
+ monkeypatch.setattr("gban.cli.doctor_mod.offer_to_install", lambda: True)
499
+ monkeypatch.setattr("gban.cli.subprocess.run",
500
+ lambda argv, **kw: (seen.update(argv=argv), Done())[1])
501
+
502
+ assert main(["fleet", "ps"]) == 0
503
+ assert seen["argv"][0] == "/usr/bin/gbfleet"
504
+
505
+
506
+ def test_a_declined_offer_still_prints_the_command(home, monkeypatch, capsys):
507
+ monkeypatch.setattr("gban.cli.doctor_mod.find_supervisor", lambda: "")
508
+ monkeypatch.setattr("gban.cli.doctor_mod.offer_to_install", lambda: False)
509
+ assert main(["fleet", "ps"]) == EXIT_NO_SUPERVISOR
510
+ assert doctor.INSTALL_SUPERVISOR in capsys.readouterr().err
@@ -183,3 +183,31 @@ def test_the_readme_ships_and_none_of_its_links_are_repo_relative():
183
183
  if not t.startswith(("http://", "https://", "mailto:", "#"))]
184
184
  assert not relative, (
185
185
  f"cli/README.md ships to PyPI, where these resolve to nothing: {relative}")
186
+
187
+
188
+ def test_the_install_the_tool_prints_is_the_one_the_readme_gives():
189
+ """It has been wrong twice: first naming a PyPI package that did not exist, then a git
190
+ spec that worked and stopped being the right advice the moment `graphban-fleet` was
191
+ published. A remedy a tool prints is a promise, and the README is where the promise is
192
+ maintained — so they are checked against each other rather than both against memory.
193
+
194
+ Sabotage: change either and this fails."""
195
+ from gban.doctor import INSTALL_SUPERVISOR
196
+
197
+ readmes = ((CLI / "README.md").read_text(encoding="utf-8")
198
+ + (REPO / "fleet" / "README.md").read_text(encoding="utf-8"))
199
+ assert INSTALL_SUPERVISOR in readmes, (
200
+ f"gban prints {INSTALL_SUPERVISOR!r}, which neither README tells anyone to run")
201
+
202
+
203
+ def test_the_printed_install_names_a_package_that_is_actually_published():
204
+ """The distribution name in the command must be one this repository builds. Checked
205
+ against the pyprojects rather than against PyPI, because a test that reaches the network
206
+ fails for reasons that have nothing to do with the code."""
207
+ from gban.doctor import INSTALL_SUPERVISOR
208
+
209
+ built = {tomllib.loads((REPO / d / "pyproject.toml").read_text())["project"]["name"]
210
+ for d in ("cli", "fleet")}
211
+ named = [w for w in INSTALL_SUPERVISOR.split() if w.startswith("graphban")]
212
+ assert named, f"{INSTALL_SUPERVISOR!r} names no package at all"
213
+ assert set(named) <= built, f"{named} is not built here; built are {sorted(built)}"
File without changes
File without changes