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 +1 -0
- meanwhile/agents.py +314 -0
- meanwhile/app.py +187 -0
- meanwhile/app.tcss +6 -0
- meanwhile/build_prompt.md +73 -0
- meanwhile/cli.py +223 -0
- meanwhile/game.py +70 -0
- meanwhile/hooks_cli.py +308 -0
- meanwhile/meanwhile-state.sh +103 -0
- meanwhile/pool.py +101 -0
- meanwhile/repo.py +30 -0
- meanwhile/scores.py +44 -0
- meanwhile/state.py +66 -0
- meanwhile/strings.py +42 -0
- meanwhile_cli-0.1.0.dist-info/METADATA +265 -0
- meanwhile_cli-0.1.0.dist-info/RECORD +19 -0
- meanwhile_cli-0.1.0.dist-info/WHEEL +4 -0
- meanwhile_cli-0.1.0.dist-info/entry_points.txt +3 -0
- meanwhile_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|