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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
meanwhile/agents.py ADDED
@@ -0,0 +1,314 @@
1
+ """Which agents meanwhile can hook into, and how each one registers hooks.
2
+
3
+ The busy/idle signal is not Claude-specific: Codex, Cursor and OpenCode expose
4
+ the same two moments under their own names, and the same bash script serves
5
+ all four. Only the registration file and its shape differ, so that is all this
6
+ module knows about.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import copy
12
+ import json
13
+ import re
14
+ import shlex
15
+ from dataclasses import dataclass
16
+
17
+ #: How our entries are recognised inside somebody else's config file. Matching
18
+ #: on the script name rather than on an exact command string is what lets
19
+ #: `merge` replace an entry that points at an older copy of the script.
20
+ MARKER = "meanwhile-state.sh"
21
+
22
+ SLUG_PATTERN = re.compile(r"^[a-z][a-z-]{0,15}\Z")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Agent:
27
+ slug: str
28
+ label: str
29
+ busy_event: str
30
+ idle_event: str
31
+ #: Which config shape this agent's file uses: "claude" (a "hooks" map of
32
+ #: event -> [{"hooks": [{"type": "command", ...}]}], shared with Codex) or
33
+ #: "cursor" (a versioned "hooks" map of event -> [{"command": ...}]), or
34
+ #: "opencode" (no config entry at all: a whole JavaScript file of ours,
35
+ #: see `plugin_source`).
36
+ shape: str
37
+ project_relpath: str
38
+ user_relpath: str
39
+ supports_user_scope: bool
40
+
41
+
42
+ AGENTS: tuple[Agent, ...] = (
43
+ Agent(
44
+ slug="claude",
45
+ label="Claude Code",
46
+ busy_event="UserPromptSubmit",
47
+ idle_event="Stop",
48
+ shape="claude",
49
+ # Not hooks.json: a standalone (non-plugin) Claude Code registration
50
+ # lives in a settings file alongside unrelated settings, which is why
51
+ # the merge only ever touches document["hooks"].
52
+ #
53
+ # settings.local.json, not settings.json, for the project scope:
54
+ # settings.json is the committed file, and the command we register
55
+ # carries an absolute path to this machine's copy of the script. A
56
+ # committed registration would make every teammate's Claude Code run a
57
+ # script that does not exist for them, on every prompt submit and every
58
+ # Stop. settings.local.json is the gitignored per-user sibling.
59
+ # Under $HOME there is no such split, and settings.json is personal
60
+ # already.
61
+ project_relpath=".claude/settings.local.json",
62
+ user_relpath=".claude/settings.json",
63
+ supports_user_scope=True,
64
+ ),
65
+ Agent(
66
+ slug="codex",
67
+ label="Codex",
68
+ busy_event="UserPromptSubmit",
69
+ idle_event="Stop",
70
+ shape="claude",
71
+ project_relpath=".codex/hooks.json",
72
+ user_relpath=".codex/hooks.json",
73
+ supports_user_scope=True,
74
+ ),
75
+ Agent(
76
+ slug="cursor",
77
+ label="Cursor",
78
+ busy_event="beforeSubmitPrompt",
79
+ idle_event="stop",
80
+ shape="cursor",
81
+ project_relpath=".cursor/hooks.json",
82
+ user_relpath=".cursor/hooks.json",
83
+ # Cursor runs user-level hooks from ~/.cursor/ rather than from the
84
+ # project root, and the script keys the state file on its working
85
+ # directory — so a user-level registration would key the wrong
86
+ # directory and the pane would simply never wake.
87
+ supports_user_scope=False,
88
+ ),
89
+ Agent(
90
+ slug="opencode",
91
+ label="OpenCode",
92
+ # One event for both moments: OpenCode has no shell hooks, only
93
+ # plugins, and a plugin hears `session.status` with the new status
94
+ # inside it.
95
+ busy_event="session.status",
96
+ idle_event="session.status",
97
+ shape="opencode",
98
+ project_relpath=".opencode/plugins/meanwhile.js",
99
+ user_relpath=".config/opencode/plugins/meanwhile.js",
100
+ # Unlike Cursor, a global plugin is still handed the project's own
101
+ # directory, so a user-level registration keys the right state file.
102
+ supports_user_scope=True,
103
+ ),
104
+ )
105
+
106
+
107
+ def agent_by_slug(slug: str) -> Agent | None:
108
+ for agent in AGENTS:
109
+ if agent.slug == slug:
110
+ return agent
111
+ return None
112
+
113
+
114
+ def label_for(slug: str | None) -> str | None:
115
+ agent = agent_by_slug(slug) if slug else None
116
+ return agent.label if agent else None
117
+
118
+
119
+ def command_for(script: str, agent: Agent, status: str) -> str:
120
+ """The shell command a registration runs.
121
+
122
+ `shlex.quote`, not a bare pair of double quotes: the path carries the
123
+ user's home directory, and a `"`, `$` or backtick in it would close the
124
+ quoting or be expanded by the shell — a hook pointing at a path that does
125
+ not exist, which is exactly the silent failure this module exists to avoid.
126
+ """
127
+ return f"{shlex.quote(script)} {status} {agent.slug}"
128
+
129
+
130
+ _OPENCODE_PLUGIN = """\
131
+ // Written by `meanwhile hooks install --agent __AGENT__`, and removed again by
132
+ // `meanwhile hooks uninstall --agent __AGENT__`. Edits here are overwritten.
133
+ const SCRIPT = __SCRIPT__
134
+
135
+ export const Meanwhile = async ({ $, client, directory }) => {
136
+ // A subagent runs in a session of its own, which goes busy and idle while
137
+ // the session that spawned it is still working. Only a session without a
138
+ // parent is the one the user is waiting on.
139
+ const children = new Map()
140
+ const isChild = async (id) => {
141
+ if (!children.has(id)) {
142
+ let child = false
143
+ try {
144
+ const found = await client.session.get({ path: { id } })
145
+ child = Boolean(found?.data?.parentID)
146
+ } catch {}
147
+ children.set(id, child)
148
+ }
149
+ return children.get(id)
150
+ }
151
+
152
+ return {
153
+ event: async ({ event }) => {
154
+ if (event.type !== "session.status") return
155
+ const status = event.properties.status.type
156
+ // "retry" is the third one: still working, so nothing to write.
157
+ if (status !== "busy" && status !== "idle") return
158
+ if (await isChild(event.properties.sessionID)) return
159
+ // The script keys the state file on CLAUDE_PROJECT_DIR, then on its
160
+ // working directory. Set both to the directory OpenCode was started in.
161
+ await $`${SCRIPT} ${status} __AGENT__`
162
+ .cwd(directory)
163
+ .env({ ...process.env, CLAUDE_PROJECT_DIR: directory })
164
+ .quiet()
165
+ .nothrow()
166
+ },
167
+ }
168
+ }
169
+ """
170
+
171
+
172
+ def plugin_source(script: str, agent: Agent) -> str:
173
+ """The whole plugin file an "opencode"-shaped agent loads.
174
+
175
+ `json.dumps` for the path: a JSON string is a JavaScript string literal,
176
+ so a quote or backslash in the user's home directory cannot end it early.
177
+ The shell side needs no quoting of its own — Bun's `$` passes every
178
+ interpolated value as one argument.
179
+ """
180
+ return _OPENCODE_PLUGIN.replace("__SCRIPT__", json.dumps(script)).replace(
181
+ "__AGENT__", agent.slug
182
+ )
183
+
184
+
185
+ def _entry(command: str, shape: str) -> dict:
186
+ if shape == "cursor":
187
+ return {"command": command}
188
+ return {"hooks": [{"type": "command", "command": command, "timeout": 5}]}
189
+
190
+
191
+ def _commands_in(entry: dict) -> list[str]:
192
+ if "command" in entry:
193
+ return [entry["command"]]
194
+ return [h.get("command", "") for h in entry.get("hooks", []) if isinstance(h, dict)]
195
+
196
+
197
+ def _is_ours(entry: dict) -> bool:
198
+ return any(MARKER in command for command in _commands_in(entry))
199
+
200
+
201
+ def _filtered_entry(entry: dict, shape: str) -> dict | None:
202
+ """Filter out our hooks from an entry, returning the filtered entry or None if empty.
203
+
204
+ For cursor shape (one command per entry), return the entry or None.
205
+ For claude/codex shape (array of hooks), filter the inner hooks array and return
206
+ the entry only if it still has hooks.
207
+ """
208
+ if shape == "cursor":
209
+ # For cursor: entry has {"command": "..."}, drop it if it's ours
210
+ if _is_ours(entry):
211
+ return None
212
+ return entry
213
+
214
+ # For claude/codex: entry has {"hooks": [...], possibly "matcher": ...}
215
+ # Filter the inner hooks array, keeping only those without our marker
216
+ if "hooks" not in entry or not isinstance(entry["hooks"], list):
217
+ return entry
218
+
219
+ kept_hooks = [
220
+ h for h in entry["hooks"]
221
+ if isinstance(h, dict) and MARKER not in h.get("command", "")
222
+ ]
223
+
224
+ if not kept_hooks:
225
+ return None
226
+
227
+ # Reconstruct entry with filtered hooks, preserving other keys like "matcher"
228
+ filtered = {k: v for k, v in entry.items() if k != "hooks"}
229
+ filtered["hooks"] = kept_hooks
230
+ return filtered
231
+
232
+
233
+ def _kept_entries(entries, shape: str) -> list:
234
+ """Everything in `entries` except our own hooks, in order.
235
+
236
+ A neighbour this module does not understand — a bare string in the event's
237
+ array, a number, anything that is not a hook entry — is carried through
238
+ untouched rather than dropped. The guarantee is "never destroys a foreign
239
+ hook", and it must not quietly mean "never destroys a well-formed one".
240
+ """
241
+ if not isinstance(entries, list):
242
+ # An event whose value is not an array is malformed for every agent we
243
+ # know, but it is still somebody's content: keep it as the one entry it
244
+ # is, so `merge` can append ours beside it instead of over it.
245
+ entries = [entries]
246
+ kept = []
247
+ for entry in entries:
248
+ if not isinstance(entry, dict):
249
+ kept.append(entry)
250
+ continue
251
+ filtered = _filtered_entry(entry, shape)
252
+ if filtered is not None:
253
+ kept.append(filtered)
254
+ return kept
255
+
256
+
257
+ def merge(document: dict, agent: Agent, script: str) -> dict:
258
+ """Our two entries, folded into a copy of `document`.
259
+
260
+ Idempotent, and safe on a file that already holds other tools' hooks:
261
+ every hook that is not ours is carried through untouched (even if in an
262
+ entry with ours), and ours are rewritten rather than appended so a changed
263
+ script path replaces the old registration instead of doubling it.
264
+ """
265
+ out = copy.deepcopy(document)
266
+ if agent.shape == "cursor":
267
+ out.setdefault("version", 1)
268
+ hooks = out.setdefault("hooks", {})
269
+ for status, event in (("busy", agent.busy_event), ("idle", agent.idle_event)):
270
+ # Filter each entry: for claude/codex, this removes our inner hooks but
271
+ # keeps the entry if foreign hooks remain; for cursor, drops the entry if it's ours
272
+ kept = _kept_entries(hooks.get(event, []), agent.shape)
273
+ hooks[event] = kept + [_entry(command_for(script, agent, status), agent.shape)]
274
+ return out
275
+
276
+
277
+ def unmerge(document: dict, agent: Agent) -> dict:
278
+ """A copy of `document` with our hooks — and only ours — taken out.
279
+
280
+ For claude/codex shape, removes our inner hooks but keeps entries that still
281
+ have foreign hooks. For cursor shape, removes entire entries that are ours.
282
+ """
283
+ out = copy.deepcopy(document)
284
+ hooks = out.get("hooks")
285
+ if not isinstance(hooks, dict):
286
+ return out
287
+ for event in (agent.busy_event, agent.idle_event):
288
+ entries = hooks.get(event)
289
+ if entries is not None and not isinstance(entries, list):
290
+ # Not a list, so nothing of ours can be in it (merge only ever
291
+ # writes a list). Leave the stranger exactly as we found it.
292
+ continue
293
+ # Filter each entry to remove our hooks, keeping entries if they still have foreign hooks
294
+ kept = _kept_entries(entries or [], agent.shape)
295
+ if kept:
296
+ hooks[event] = kept
297
+ else:
298
+ hooks.pop(event, None)
299
+ if not hooks:
300
+ out.pop("hooks", None)
301
+ return out
302
+
303
+
304
+ def registered_commands(document: dict, agent: Agent) -> list[str]:
305
+ """Every command string of ours in `document`, in busy-then-idle order."""
306
+ found = []
307
+ hooks = document.get("hooks")
308
+ if not isinstance(hooks, dict):
309
+ return found
310
+ for event in (agent.busy_event, agent.idle_event):
311
+ for entry in hooks.get(event, []):
312
+ if isinstance(entry, dict) and _is_ours(entry):
313
+ found.extend(c for c in _commands_in(entry) if MARKER in c)
314
+ return found
meanwhile/app.py ADDED
@@ -0,0 +1,187 @@
1
+ """The terminal UI. Renders cards; all rules live in meanwhile.game."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Callable
7
+
8
+ from textual.app import App, ComposeResult
9
+ from textual.containers import Vertical
10
+ from textual.widgets import Footer, Static
11
+
12
+ from . import agents, game, scores, strings
13
+ from .pool import Pool, QuizItem
14
+
15
+ # Lifecycle phases. The pane is opened once and left open: it waits for the
16
+ # agent to start, plays for exactly as long as it is busy, then waits again.
17
+ WAITING = "waiting"
18
+ PLAYING = "playing"
19
+ DONE = "done"
20
+
21
+
22
+ class MeanwhileApp(App):
23
+ CSS_PATH = "app.tcss"
24
+ TITLE = "meanwhile"
25
+
26
+ # Every key the game can ever offer is declared here, statically. Which of
27
+ # them the footer actually shows is decided per card by `check_action`.
28
+ BINDINGS = [
29
+ ("q", "quit", "Quit"),
30
+ ("space", "next", "Next card"),
31
+ *[(str(n), f"choose({n})", f"Choice {n}") for n in range(1, 10)],
32
+ ]
33
+
34
+ # The actions `check_action` gates; everything else is always available.
35
+ CARD_ACTIONS = ("choose",)
36
+
37
+ def __init__(
38
+ self,
39
+ pool: Pool,
40
+ repo_dir: Path,
41
+ status_source: Callable[[], str],
42
+ banner: str = "",
43
+ poll_seconds: float = 0.5,
44
+ language: str = "en",
45
+ agent_source: Callable[[], str | None] = lambda: None,
46
+ ) -> None:
47
+ super().__init__()
48
+ self.pool = pool
49
+ self.repo_dir = repo_dir
50
+ self.status_source = status_source
51
+ self.banner = banner
52
+ self.poll_seconds = poll_seconds
53
+ self.language = language
54
+ self.agent_source = agent_source
55
+ self.session = game.start_session(pool)
56
+ self.phase = WAITING
57
+ self._last_status = "idle"
58
+ self._answered = False
59
+
60
+ def _agent_name(self) -> str:
61
+ return agents.label_for(self.agent_source()) or strings.text(self.language, "agent")
62
+
63
+ # --- dynamic bindings -------------------------------------------------
64
+
65
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
66
+ """Offer only the keys the card on screen can actually take.
67
+
68
+ A three-choice card should not fill a narrow pane's footer with
69
+ "Choice 4" .. "Choice 9". Returning False is what *removes* a binding:
70
+ verified on Textual 8.2.8, True shows it, None shows it greyed out,
71
+ and only False keeps it out of `Screen.active_bindings` — and so out
72
+ of the Footer — entirely.
73
+
74
+ This decides what the footer advertises and what a keypress may reach;
75
+ the guards in the action handlers stay the last line of defence.
76
+ """
77
+ if action not in self.CARD_ACTIONS:
78
+ return True
79
+ if self.phase != PLAYING:
80
+ return False
81
+ item = game.current(self.session)
82
+ return isinstance(item, QuizItem) and 1 <= int(parameters[0]) <= len(item.choices)
83
+
84
+ def compose(self) -> ComposeResult:
85
+ # markup=False: card/feedback text comes from an untrusted pool.json
86
+ # (see pool.py) and from literal "[space] next" hints — neither should
87
+ # be parsed as Textual console markup.
88
+ with Vertical():
89
+ yield Static(self.banner, id="banner", markup=False)
90
+ yield Static("", id="status", markup=False)
91
+ yield Static("", id="card", markup=False)
92
+ yield Static("", id="feedback", markup=False)
93
+ yield Static("", id="score", markup=False)
94
+ yield Footer()
95
+
96
+ def on_mount(self) -> None:
97
+ self.query_one("#banner").display = bool(self.banner)
98
+ self._render()
99
+ # Poll once up front so a pane opened while the agent is already busy
100
+ # joins that wait instead of sitting out until the first tick.
101
+ self._poll_status()
102
+ self.set_interval(self.poll_seconds, self._poll_status)
103
+
104
+ # --- status ---------------------------------------------------------
105
+
106
+ def _poll_status(self) -> None:
107
+ """Drive the lifecycle off the edges of the busy/idle state file."""
108
+ status = self.status_source()
109
+ if status == self._last_status:
110
+ return
111
+ self._last_status = status
112
+ if status == "busy":
113
+ self._start_playing()
114
+ else:
115
+ self._finish_playing()
116
+
117
+ def _start_playing(self) -> None:
118
+ self.phase = PLAYING
119
+ self.session = game.start_session(self.pool) # re-shuffled: the cards come back
120
+ self._answered = False
121
+ self.query_one("#status").update("")
122
+ self.query_one("#feedback").update("")
123
+ self._render()
124
+
125
+ def _finish_playing(self) -> None:
126
+ if self.phase != PLAYING:
127
+ return
128
+ self.phase = DONE
129
+ self.refresh_bindings() # DONE takes no input: the card keys leave the footer
130
+ self.query_one("#status").update(strings.text(self.language, "done", agent=self._agent_name()))
131
+ # Exactly once per session: the `phase != PLAYING` guard above is what
132
+ # guarantees it — the only way back into PLAYING is _start_playing,
133
+ # which deals a new session.
134
+ if self.repo_dir is not None and self.session.answered:
135
+ scores.record_session(self.repo_dir, self.session)
136
+
137
+ # --- actions --------------------------------------------------------
138
+
139
+ def action_choose(self, number: int) -> None:
140
+ item = game.current(self.session)
141
+ if self.phase != PLAYING or self._answered or not isinstance(item, QuizItem):
142
+ return
143
+ index = number - 1
144
+ if not 0 <= index < len(item.choices):
145
+ return
146
+ self._resolve(game.answer(self.session, index))
147
+
148
+ def action_next(self) -> None:
149
+ if self.phase != PLAYING or not self._answered:
150
+ return
151
+ game.advance(self.session)
152
+ self._answered = False
153
+ self.query_one("#feedback").update("")
154
+ self._render()
155
+
156
+ # --- rendering ------------------------------------------------------
157
+
158
+ def _resolve(self, verdict: game.Verdict) -> None:
159
+ self._answered = True
160
+ mark = strings.text(self.language, "correct" if verdict.correct else "wrong")
161
+ self.query_one("#feedback").update(f"{mark} — {verdict.explanation} [space] next")
162
+ self._render_score()
163
+
164
+ def _render(self) -> None:
165
+ # The card (or the phase) has just changed, so the footer's answer for
166
+ # `check_action` has too.
167
+ self.refresh_bindings()
168
+ card = self.query_one("#card")
169
+ if self.phase == WAITING:
170
+ self.query_one("#status").update(strings.text(self.language, "waiting", agent=self._agent_name()))
171
+ card.update("")
172
+ self.query_one("#score").update("")
173
+ return
174
+
175
+ item = game.current(self.session)
176
+ if item is None:
177
+ card.update(strings.text(self.language, "exhausted"))
178
+ elif isinstance(item, QuizItem):
179
+ choices = "\n".join(f" {n}. {text}" for n, text in enumerate(item.choices, start=1))
180
+ card.update(f"{item.q}\n\n{choices}\n\n({item.source})")
181
+ self._render_score()
182
+
183
+ def _render_score(self) -> None:
184
+ self.query_one("#score").update(
185
+ f"{self.session.correct}/{self.session.answered} correct "
186
+ f"streak {self.session.streak} (best {self.session.best_streak})"
187
+ )
meanwhile/app.tcss ADDED
@@ -0,0 +1,6 @@
1
+ Screen { background: $surface; }
2
+ #banner { color: $warning; padding: 0 1; }
3
+ #status { color: $success; padding: 0 1; }
4
+ #card { padding: 1 2; }
5
+ #feedback { padding: 0 2; color: $accent; }
6
+ #score { padding: 0 2; color: $text-muted; }
@@ -0,0 +1,73 @@
1
+ Build `.meanwhile/pool.json` for the current repository.
2
+
3
+ ## What to read
4
+
5
+ Survey the repo to understand what it does and what is distinctive about it:
6
+
7
+ - data models, schemas, and migrations
8
+ - API routes, serializers, and their validation rules
9
+ - the README, and any docs describing domain concepts
10
+ - `CHANGELOG.md` if present
11
+ - `git log --oneline -50` for recent direction
12
+
13
+ Prefer the project's own domain terms over generic programming vocabulary. A
14
+ good question could only be answered by someone who has read this repository.
15
+ Write the questions in {language}. Record that language in the pool too, so
16
+ the playing interface follows it.
17
+
18
+ The language is given as a short language code, not a language name, and the
19
+ pool's `language` field records exactly that code. `tr` and `en` are the two
20
+ the playing interface itself speaks; any other code is still fine — the cards
21
+ get written in that language and the interface stays English.
22
+
23
+ ## What to write
24
+
25
+ Write `.meanwhile/pool.json` with 30-50 `quiz` items, using exactly this shape:
26
+
27
+ {
28
+ "builtAt": "<today, YYYY-MM-DD>",
29
+ "headSha": "<output of: git rev-parse --short HEAD>",
30
+ "repo": "{repo}",
31
+ "language": "{language}",
32
+ "items": [
33
+ {
34
+ "type": "quiz",
35
+ "q": "<question about this project's domain or behavior>",
36
+ "choices": ["<option>", "<option>", "<option>"],
37
+ "answer": <0-based index of the correct choice>,
38
+ "why": "<one sentence explaining the answer>",
39
+ "source": "<repo-relative path the question came from>"
40
+ }
41
+ ]
42
+ }
43
+
44
+ Rules:
45
+
46
+ - Never invent code. Every question must be grounded in a file that exists.
47
+ - **Never take a question or an answer from a secret file.**
48
+ `.meanwhile/pool.json` is committed and shared with the whole team, so
49
+ anything copied into it is published. Skip `.env` and any `.env.*`,
50
+ credential/key/token/certificate files (`*.pem`, `*.key`, `id_rsa`,
51
+ `credentials*`, `secrets*`), fixtures holding real keys, and **any file
52
+ excluded by `.gitignore`** — check with `git check-ignore -q <path>`
53
+ before using a file. Draw only on files that are tracked in git.
54
+ - Wrong quiz choices should be plausible to someone who half-remembers the
55
+ code, not obviously absurd.
56
+
57
+ ## Finish
58
+
59
+ 1. Ensure `.meanwhile/scores.json` is in `.gitignore`; add it if missing.
60
+ 2. Run `uvx meanwhile-cli validate --repo .` (during development, from a
61
+ checkout of this plugin: `uv run meanwhile validate --repo .`) and fix
62
+ anything it reports.
63
+ 3. Tell the user the item count and that `.meanwhile/pool.json` is ready to
64
+ commit so their team shares the pool.
65
+ 4. Tell the user how to play, in {language} — building the pool starts
66
+ nothing, and no other step says how. Give them the command itself, in a
67
+ code block they can copy:
68
+
69
+ uvx meanwhile-cli
70
+
71
+ and say where it goes: a second terminal pane beside this one, opened in
72
+ this same directory. The pane waits there, deals cards from their next
73
+ prompt on, and `q` quits it.