meanwhile-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.
meanwhile/cli.py ADDED
@@ -0,0 +1,223 @@
1
+ """Command line entry point: `meanwhile` plays, `meanwhile validate` checks a pool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import repo, state, strings
11
+ from .pool import POOL_RELPATH, PoolError, load_pool
12
+
13
+
14
+ def launch_dir(start: str | Path) -> Path:
15
+ """The directory the pane was launched in.
16
+
17
+ This — not the git root — is the state-file key: hooks/meanwhile-state.sh
18
+ keys on CLAUDE_PROJECT_DIR, which Claude Code sets to the directory the
19
+ session was started in. Keying the TUI on the git root instead would
20
+ silently never meet the hook whenever Claude Code is launched below it
21
+ (a monorepo package, `backend/`, ...).
22
+ """
23
+ return Path(start).resolve()
24
+
25
+
26
+ def repo_root(start: str | Path) -> Path:
27
+ """The git root of `start`, or `start` itself when it is not a repo."""
28
+ try:
29
+ result = subprocess.run(
30
+ ["git", "rev-parse", "--show-toplevel"],
31
+ cwd=str(start),
32
+ capture_output=True,
33
+ text=True,
34
+ timeout=5,
35
+ )
36
+ except (OSError, subprocess.SubprocessError):
37
+ return Path(start)
38
+ if result.returncode != 0:
39
+ return Path(start)
40
+ return Path(result.stdout.strip())
41
+
42
+
43
+ def staleness_banner(
44
+ repo_dir: str | Path, head_sha: str, language: str = strings.DEFAULT_LANGUAGE
45
+ ) -> str:
46
+ behind = repo.commits_behind(repo_dir, head_sha)
47
+ if behind is None or behind < repo.STALE_THRESHOLD:
48
+ return ""
49
+ return strings.text(language, "stale", behind=behind)
50
+
51
+
52
+ def build_prompt(language: str, repo_name: str) -> str:
53
+ """The packaged build instructions, with `{language}` and `{repo}` filled in.
54
+
55
+ Plain `str.replace`, not `str.format`: the file is a worked JSON example
56
+ full of braces that `.format` would try (and fail) to parse as fields.
57
+ This is the one copy — `commands/meanwhile-build.md` points at it instead
58
+ of duplicating it, and `tests/test_plugin_files.py` pins its example to
59
+ `meanwhile.pool.load_pool`.
60
+ """
61
+ source = Path(__file__).with_name("build_prompt.md").read_text(encoding="utf-8")
62
+ return source.replace("{language}", language).replace("{repo}", repo_name)
63
+
64
+
65
+ def validate(repo_dir: str | Path) -> tuple[int, list[str]]:
66
+ root = Path(repo_dir)
67
+ try:
68
+ pool = load_pool(root / POOL_RELPATH)
69
+ except PoolError as exc:
70
+ return 1, [str(exc)]
71
+
72
+ missing = sorted({item.source for item in pool.items if not (root / item.source).exists()})
73
+ if missing:
74
+ return 1, [f"source file does not exist: {path}" for path in missing]
75
+ return 0, [f"pool is valid: {len(pool.items)} item(s)"]
76
+
77
+
78
+ def main(argv: list[str] | None = None) -> int:
79
+ if argv is None:
80
+ argv = sys.argv[1:]
81
+ # `hooks` takes the rest of the line for its own parser, so `meanwhile
82
+ # hooks install --agent codex` does not have to share flags with `play`.
83
+ if argv and argv[0] == "hooks":
84
+ return run_hooks(argv[1:])
85
+
86
+ # No prog=: argparse takes it from sys.argv[0], so `meanwhile --help` and
87
+ # `meanwhile-cli --help` each print the name they were invoked by.
88
+ parser = argparse.ArgumentParser()
89
+ parser.add_argument(
90
+ "command", nargs="?", default="play", choices=["play", "validate", "build-prompt"]
91
+ )
92
+ parser.add_argument("--repo", default=".", help="project directory (default: current)")
93
+ parser.add_argument(
94
+ "--lang",
95
+ default=None,
96
+ # Spells out that this is a code and names the two, because nothing
97
+ # else on the documented path does: a user who reads "language" and
98
+ # passes "Turkish" gets an English interface and no complaint.
99
+ help=(
100
+ "short language code, e.g. tr or en (default: the pool's own). "
101
+ "tr and en are the two the interface speaks; any other code leaves "
102
+ "the interface in English, and on build-prompt still writes the "
103
+ "cards in it"
104
+ ),
105
+ )
106
+ args = parser.parse_args(argv)
107
+
108
+ launch = launch_dir(args.repo) # keys the busy/idle state file
109
+ root = repo_root(launch) # holds .meanwhile/pool.json and scores.json
110
+
111
+ if args.command == "build-prompt":
112
+ # No pool exists yet — the point of this command is to build one —
113
+ # so there is no pool.language to fall back to, unlike `play`.
114
+ language = args.lang or strings.DEFAULT_LANGUAGE
115
+ print(build_prompt(language=language, repo_name=root.name), end="")
116
+ return 0
117
+
118
+ if args.command == "validate":
119
+ code, messages = validate(root)
120
+ for message in messages:
121
+ print(message)
122
+ return code
123
+
124
+ try:
125
+ pool = load_pool(root / POOL_RELPATH)
126
+ except PoolError as exc:
127
+ # Both routes, not just the slash command: a Codex or Cursor user who
128
+ # opens the pane before building a pool is the likeliest first run on
129
+ # the new paths, and /meanwhile-build does not exist for them.
130
+ print(
131
+ f"{exc}\n\nRun /meanwhile-build inside Claude Code, or "
132
+ "`meanwhile build-prompt` with any other agent, to create one."
133
+ )
134
+ return 1
135
+
136
+ language = args.lang or pool.language
137
+ return _run_app(
138
+ pool=pool,
139
+ repo_dir=root,
140
+ status_source=lambda: state.read_state(launch)["status"],
141
+ banner=staleness_banner(root, pool.head_sha, language=language),
142
+ language=language,
143
+ agent_source=lambda: state.read_state(launch)["agent"],
144
+ )
145
+
146
+
147
+ def _run_app(**kwargs) -> int:
148
+ from .app import MeanwhileApp # imported late so `validate` needs no terminal
149
+
150
+ MeanwhileApp(**kwargs).run()
151
+ return 0
152
+
153
+
154
+ def run_hooks(argv: list[str]) -> int:
155
+ """`meanwhile hooks install|status|uninstall` — register the busy/idle hooks.
156
+
157
+ Claude Code and Codex users can get these from the plugin; this is for
158
+ Cursor, OpenCode, and either of those two without it.
159
+ """
160
+ from . import hooks_cli
161
+ from .agents import AGENTS, agent_by_slug
162
+
163
+ parser = argparse.ArgumentParser(
164
+ prog=f"{Path(sys.argv[0]).name} hooks",
165
+ description="register meanwhile's busy/idle hooks with a coding agent",
166
+ )
167
+ parser.add_argument("action", choices=["install", "status", "uninstall"])
168
+ parser.add_argument(
169
+ "--agent",
170
+ default="auto",
171
+ choices=[agent.slug for agent in AGENTS] + ["auto"],
172
+ help="which agent (default: auto — every agent already configured here)",
173
+ )
174
+ parser.add_argument(
175
+ "--user",
176
+ action="store_true",
177
+ help="register for every project of this user, not just this one",
178
+ )
179
+ parser.add_argument("--repo", default=".", help="project directory (default: current)")
180
+ args = parser.parse_args(argv)
181
+
182
+ home = Path.home()
183
+ repo_dir = Path(args.repo).resolve()
184
+
185
+ if args.action == "status":
186
+ for line in hooks_cli.status(repo=repo_dir, home=home):
187
+ print(line)
188
+ return 0
189
+
190
+ if args.agent == "auto":
191
+ targets = []
192
+ for agent in AGENTS:
193
+ # An agent's own directory is the only evidence that it is in use
194
+ # here; `auto` never conjures one up from nothing.
195
+ if not hooks_cli.agent_dir(agent, repo_dir, home, args.user).is_dir():
196
+ continue
197
+ if args.user and not agent.supports_user_scope:
198
+ print(f"{agent.label}: skipped, it cannot be hooked at the user level")
199
+ continue
200
+ targets.append(agent)
201
+ if not targets:
202
+ scope = "under your home directory" if args.user else f"in {repo_dir}"
203
+ print(
204
+ f"no agent config directory found {scope} — name one with --agent",
205
+ file=sys.stderr,
206
+ )
207
+ return 1
208
+ else:
209
+ targets = [agent_by_slug(args.agent)]
210
+
211
+ act = hooks_cli.install if args.action == "install" else hooks_cli.uninstall
212
+ failed = False
213
+ for agent in targets:
214
+ try:
215
+ print(act(agent, repo=repo_dir, home=home, user=args.user))
216
+ except (hooks_cli.HooksError, OSError) as exc:
217
+ # OSError too: a read-only .codex/hooks.json is a thing that
218
+ # happens, and it is a message, not a traceback out of main().
219
+ # Not prefixed with the label: every HooksError already names the
220
+ # agent, by label or by the path it refused to touch.
221
+ print(str(exc), file=sys.stderr)
222
+ failed = True
223
+ return 1 if failed else 0
meanwhile/game.py ADDED
@@ -0,0 +1,70 @@
1
+ """Scoring rules. No I/O here — the TUI renders what these functions return."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from dataclasses import dataclass, field
7
+
8
+ from .pool import Pool
9
+
10
+
11
+ class GameError(Exception):
12
+ """The caller drove the session into an impossible state."""
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Verdict:
17
+ correct: bool
18
+ explanation: str
19
+
20
+
21
+ @dataclass
22
+ class Session:
23
+ items: list
24
+ index: int = 0
25
+ correct: int = 0
26
+ answered: int = 0
27
+ streak: int = 0
28
+ best_streak: int = 0
29
+ # Bookkeeping, not part of the session's identity: keep it out of
30
+ # __init__, __repr__ and __eq__.
31
+ _answered_index: int = field(default=-1, init=False, repr=False, compare=False)
32
+
33
+
34
+ def start_session(pool: Pool, rng: random.Random | None = None) -> Session:
35
+ items = list(pool.items)
36
+ (rng or random.Random()).shuffle(items)
37
+ return Session(items=items)
38
+
39
+
40
+ def finished(session: Session) -> bool:
41
+ return session.index >= len(session.items)
42
+
43
+
44
+ def current(session: Session):
45
+ if finished(session):
46
+ return None
47
+ return session.items[session.index]
48
+
49
+
50
+ def advance(session: Session) -> None:
51
+ session.index += 1
52
+
53
+
54
+ def answer(session: Session, response: int) -> Verdict:
55
+ item = current(session)
56
+ if item is None:
57
+ raise GameError("session is finished")
58
+ if session._answered_index == session.index:
59
+ raise GameError("this item has already been answered; call advance() first")
60
+
61
+ session._answered_index = session.index
62
+ session.answered += 1
63
+ correct = response == item.answer
64
+ if correct:
65
+ session.correct += 1
66
+ session.streak += 1
67
+ session.best_streak = max(session.best_streak, session.streak)
68
+ else:
69
+ session.streak = 0
70
+ return Verdict(correct=correct, explanation=item.why)
meanwhile/hooks_cli.py ADDED
@@ -0,0 +1,308 @@
1
+ """Register meanwhile's busy/idle hooks with an agent, without its plugin system.
2
+
3
+ Claude Code and Codex can get these two hooks from the plugin; Cursor has no
4
+ plugin mechanism, and OpenCode's is a JavaScript file somebody has to put in
5
+ place, so the registration has to be written by hand. This module does it.
6
+
7
+ Every failure mode here is silent: a registration written to the wrong path,
8
+ naming the wrong event, or pointing at a script that has moved raises nothing
9
+ at all — the pane simply never wakes, and nothing anywhere says why. So this
10
+ module refuses rather than guesses. A config file it cannot parse is an error
11
+ instead of something to overwrite, and a scope an agent cannot honour is an
12
+ error carrying its reason.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ from pathlib import Path
22
+
23
+ from .agents import AGENTS, MARKER, Agent, merge, plugin_source, registered_commands, unmerge
24
+
25
+ SCRIPT_NAME = "meanwhile-state.sh"
26
+
27
+ #: Agents whose project-scope config file has no gitignored sibling to move to,
28
+ #: so the registration we write there is one the user is likely to commit.
29
+ #: Claude Code is absent on purpose: its project registration goes to
30
+ #: `.claude/settings.local.json`, which is the gitignored one already.
31
+ _COMMITTED_PROJECT_CONFIG = ("codex", "cursor", "opencode")
32
+
33
+ #: What `uninstall` reads as "the file held nothing but ours": an empty
34
+ #: document, or the bare "version" key `merge` adds for Cursor.
35
+ _EMPTY_DOCUMENTS = ({}, {"version": 1})
36
+
37
+ #: Appended to an agent's "not installed" line. `status` reads the
38
+ #: registration files this module writes and nothing else, so a Claude Code
39
+ #: plugin install — which registers the same two hooks through its own
40
+ #: hooks.json — is invisible here. Detecting it would mean reading Claude
41
+ #: Code's internal install state, so the line says what it can and cannot see
42
+ #: instead, so nobody reads `not installed` and adds a redundant second copy.
43
+ _NOT_INSTALLED_NOTES = {"claude": " (the plugin registers its own hooks separately)"}
44
+
45
+
46
+ class HooksError(Exception):
47
+ """A registration that could not work, refused with its reason."""
48
+
49
+
50
+ def packaged_script() -> Path:
51
+ """The copy of the hook script that ships inside the wheel.
52
+
53
+ A second copy lives at `hooks/meanwhile-state.sh` for the plugin, which can
54
+ only name its own file through ${CLAUDE_PLUGIN_ROOT}. The two are held
55
+ byte-identical by a test.
56
+ """
57
+ return Path(__file__).resolve().parent / SCRIPT_NAME
58
+
59
+
60
+ def installed_script(home: str | Path | None = None) -> Path:
61
+ """Where the registrations point — a copy under the user's home.
62
+
63
+ Never the packaged file itself: under `uvx` the package lives in a cache
64
+ directory that can be reclaimed at any time, and a registration pointing
65
+ there would quietly stop writing the state file.
66
+ """
67
+ base = Path.home() if home is None else Path(home)
68
+ return base / ".meanwhile" / SCRIPT_NAME
69
+
70
+
71
+ #: The default of `installed_script`, spelled out for callers that only need
72
+ #: the one path (each function takes `home` so tests can point it elsewhere).
73
+ INSTALLED_SCRIPT = installed_script()
74
+
75
+
76
+ def install_script(dest: str | Path) -> Path:
77
+ """Copy the packaged script to `dest`, executable. Returns `dest`."""
78
+ dest = Path(dest)
79
+ dest.parent.mkdir(parents=True, exist_ok=True)
80
+ shutil.copyfile(packaged_script(), dest)
81
+ dest.chmod(0o755)
82
+ return dest
83
+
84
+
85
+ def config_path(agent: Agent, repo: str | Path, home: str | Path, user: bool = False) -> Path:
86
+ """The config file this agent reads its hooks from, in the given scope."""
87
+ if user:
88
+ return Path(home) / agent.user_relpath
89
+ return Path(repo) / agent.project_relpath
90
+
91
+
92
+ def agent_dir(agent: Agent, repo: str | Path, home: str | Path, user: bool = False) -> Path:
93
+ """The directory whose existence says this agent is in use in that scope.
94
+
95
+ Normally the one the config file sits in. OpenCode's plugin file sits one
96
+ level further down, in a `plugins/` directory most projects do not have
97
+ until something is installed into it.
98
+ """
99
+ parent = config_path(agent, repo, home, user).parent
100
+ return parent.parent if agent.shape == "opencode" else parent
101
+
102
+
103
+ def _is_plugin_file(agent: Agent) -> bool:
104
+ """Whether the registration is a whole file of ours rather than entries
105
+ merged into somebody else's JSON."""
106
+ return agent.shape == "opencode"
107
+
108
+
109
+ def _read_plugin(path: Path) -> str | None:
110
+ """Our plugin file's text, `None` when there is no file.
111
+
112
+ A file at that path we did not write is a `HooksError`, for the same
113
+ reason an unparseable config is: it is somebody's own plugin, and writing
114
+ or deleting over it would take their work with it.
115
+ """
116
+ if not path.exists():
117
+ return None
118
+ try:
119
+ text = path.read_text(encoding="utf-8")
120
+ except (OSError, ValueError) as exc:
121
+ raise HooksError(f"{path} cannot be read ({exc}); fix it and run this again") from exc
122
+ if MARKER not in text:
123
+ raise HooksError(f"{path} exists and was not written by meanwhile; move it and run this again")
124
+ return text
125
+
126
+
127
+ def _write_text(path: Path, text: str) -> None:
128
+ """Write `text` to `path`, atomically. See `_write_document`."""
129
+ path.parent.mkdir(parents=True, exist_ok=True)
130
+ tmp = path.with_name(f"{path.name}.meanwhile.tmp")
131
+ try:
132
+ tmp.write_text(text, encoding="utf-8")
133
+ os.replace(tmp, path)
134
+ except OSError:
135
+ tmp.unlink(missing_ok=True)
136
+ raise
137
+
138
+
139
+ def _read_document(path: Path) -> dict:
140
+ """The JSON object at `path`, `{}` when there is no file.
141
+
142
+ A file we cannot parse is a `HooksError`: it is somebody's hand-edited
143
+ config, and overwriting it with ours would take their settings with it.
144
+ """
145
+ if not path.exists():
146
+ return {}
147
+ try:
148
+ text = path.read_text(encoding="utf-8")
149
+ if not text.strip():
150
+ # A zero-byte (or whitespace-only) file has nothing to lose, so
151
+ # leniency here costs none of the safety the strictness buys.
152
+ return {}
153
+ document = json.loads(text)
154
+ except (OSError, ValueError) as exc:
155
+ raise HooksError(f"{path} is not valid JSON ({exc}); fix it and run this again") from exc
156
+ if not isinstance(document, dict):
157
+ raise HooksError(f"{path} does not hold a JSON object; fix it and run this again")
158
+ return document
159
+
160
+
161
+ def _write_document(path: Path, document: dict) -> None:
162
+ """Write `document` to `path`, atomically.
163
+
164
+ `write_text` truncates before it writes, so a crash or a full disk between
165
+ the two would destroy somebody's settings file — from the one function
166
+ whose whole job is not losing a config. Temp file beside the target (same
167
+ filesystem, so `os.replace` is a rename) and then one atomic swap, exactly
168
+ as the hook script next door writes the state file.
169
+ """
170
+ _write_text(path, json.dumps(document, indent=2) + "\n")
171
+
172
+
173
+ def _tracked_by_git(path: Path) -> bool:
174
+ """Whether `path` is in a git index. Advice only — never a reason to fail.
175
+
176
+ Any trouble running git at all (not installed, not a repo, a timeout) reads
177
+ as "not tracked": the worst outcome of getting this wrong is a note the
178
+ user does not see.
179
+ """
180
+ try:
181
+ result = subprocess.run(
182
+ ["git", "ls-files", "--error-unmatch", "--", path.name],
183
+ cwd=str(path.parent),
184
+ capture_output=True,
185
+ timeout=5,
186
+ )
187
+ except (OSError, subprocess.SubprocessError):
188
+ return False
189
+ return result.returncode == 0
190
+
191
+
192
+ def _tracked_note(path: Path, agent: Agent, user: bool) -> str:
193
+ """The warning for a registration that is about to be committed.
194
+
195
+ The command we write carries an absolute path to this machine's copy of the
196
+ script. Codex and Cursor have no gitignored sibling to move to, and their
197
+ hooks.json is commonly committed, so all we can do is say so.
198
+
199
+ Already tracked is the rare case. The dominant one is the opposite: this
200
+ very install creates `.codex/hooks.json` from nothing and the user commits
201
+ it afterwards, when git tracks nothing yet — so the note goes out for every
202
+ project-scope install of theirs, tracked or not, only worded differently.
203
+ """
204
+ if _tracked_by_git(path):
205
+ return (
206
+ f"\nnote: {path} is tracked by git, and the path recorded in it is specific to "
207
+ "this machine.\n Teammates who commit it will run a script that does not "
208
+ "exist for them."
209
+ )
210
+ if user or agent.slug not in _COMMITTED_PROJECT_CONFIG:
211
+ return ""
212
+ return (
213
+ f"\nnote: the path recorded in {path} is specific to this machine.\n"
214
+ " Commit it and teammates will run a script that does not exist for them."
215
+ )
216
+
217
+
218
+ def install(agent: Agent, repo: str | Path, home: str | Path, user: bool = False) -> str:
219
+ """Register our two hooks for `agent`, and report what was written."""
220
+ if user and not agent.supports_user_scope:
221
+ raise HooksError(
222
+ f"{agent.label} runs user-level hooks from ~/{Path(agent.user_relpath).parts[0]}/, "
223
+ "but the script keys the state file on its working directory — a user-level "
224
+ "registration would key the wrong directory and never wake the pane. "
225
+ f"Install it per project instead: meanwhile hooks install --agent {agent.slug}"
226
+ )
227
+ # Read and validate before copying anything: a refused install must leave
228
+ # no trace, and the script copy is a write like any other.
229
+ path = config_path(agent, repo, home, user)
230
+ if _is_plugin_file(agent):
231
+ _read_plugin(path)
232
+ script = install_script(installed_script(home))
233
+ _write_text(path, plugin_source(str(script), agent))
234
+ return f"{agent.label}: plugin written to {path}{_tracked_note(path, agent, user)}"
235
+ document = _read_document(path)
236
+ script = install_script(installed_script(home))
237
+ _write_document(path, merge(document, agent, str(script)))
238
+ return f"{agent.label}: hooks registered in {path}{_tracked_note(path, agent, user)}"
239
+
240
+
241
+ def uninstall(agent: Agent, repo: str | Path, home: str | Path, user: bool = False) -> str:
242
+ """Take our two hooks — and only ours — back out, and report what changed."""
243
+ path = config_path(agent, repo, home, user)
244
+ if not path.exists():
245
+ return f"{agent.label}: nothing to remove, {path} does not exist"
246
+ if _is_plugin_file(agent):
247
+ _read_plugin(path)
248
+ path.unlink()
249
+ return f"{agent.label}: plugin removed, {path} deleted"
250
+ remaining = unmerge(_read_document(path), agent)
251
+ if remaining in _EMPTY_DOCUMENTS:
252
+ # The file held nothing but our registration, so leave no husk behind.
253
+ path.unlink()
254
+ return f"{agent.label}: hooks removed, {path} deleted"
255
+ _write_document(path, remaining)
256
+ return f"{agent.label}: hooks removed from {path}"
257
+
258
+
259
+ def _script_note(home: str | Path) -> str:
260
+ """How the installed copy of the script compares to the packaged one.
261
+
262
+ A registration keeps working after an upgrade only because it points at
263
+ this copy — which also means an upgrade cannot refresh it. Nothing else
264
+ would report the difference.
265
+ """
266
+ script = installed_script(home)
267
+ if not script.exists():
268
+ return " (stale: the script copy is missing — reinstall)"
269
+ if script.read_bytes() != packaged_script().read_bytes():
270
+ return " (stale: script copy differs from the packaged one — reinstall)"
271
+ return ""
272
+
273
+
274
+ def status(repo: str | Path, home: str | Path) -> list[str]:
275
+ """One line per agent: whether our hooks are registered, and where."""
276
+ note = _script_note(home)
277
+ lines = []
278
+ for agent in AGENTS:
279
+ scopes, unreadable = [], []
280
+ for scope, user in (("project", False), ("user", True)):
281
+ if user and not agent.supports_user_scope:
282
+ continue
283
+ path = config_path(agent, repo, home, user)
284
+ try:
285
+ if _is_plugin_file(agent):
286
+ registered = _read_plugin(path) is not None
287
+ else:
288
+ registered = bool(registered_commands(_read_document(path), agent))
289
+ except HooksError:
290
+ unreadable.append(str(path))
291
+ continue
292
+ if registered:
293
+ scopes.append(scope)
294
+ if scopes:
295
+ line = f"{agent.slug} ({agent.label}): installed ({', '.join(scopes)}){note}"
296
+ if unreadable:
297
+ line += f", unreadable config: {', '.join(unreadable)}"
298
+ elif unreadable:
299
+ # Not "not installed": we do not know, and saying so would be a
300
+ # guess in the one direction the user cannot check.
301
+ line = f"{agent.slug} ({agent.label}): unreadable config: {', '.join(unreadable)}"
302
+ else:
303
+ line = (
304
+ f"{agent.slug} ({agent.label}): not installed"
305
+ f"{_NOT_INSTALLED_NOTES.get(agent.slug, '')}"
306
+ )
307
+ lines.append(line)
308
+ return lines