@rm0nroe/coach-claw 1.0.6
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.
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/coach/README.md +99 -0
- package/coach/bin/aggregate_facets.py +274 -0
- package/coach/bin/analyze.py +678 -0
- package/coach/bin/bank.py +247 -0
- package/coach/bin/banner_themes.py +645 -0
- package/coach/bin/coach_paths.py +33 -0
- package/coach/bin/coexistence_check.py +129 -0
- package/coach/bin/configure.py +245 -0
- package/coach/bin/cron_check.py +81 -0
- package/coach/bin/default_statusline.py +135 -0
- package/coach/bin/doctor.py +663 -0
- package/coach/bin/insights-llm.sh +264 -0
- package/coach/bin/insights.sh +163 -0
- package/coach/bin/insights_window.py +111 -0
- package/coach/bin/marker_io.py +154 -0
- package/coach/bin/merge.py +671 -0
- package/coach/bin/redact.py +86 -0
- package/coach/bin/render_env.py +148 -0
- package/coach/bin/reward_hints.py +87 -0
- package/coach/bin/run-insights.sh +20 -0
- package/coach/bin/run_with_lock.py +85 -0
- package/coach/bin/scoring.py +260 -0
- package/coach/bin/skill_inventory.py +215 -0
- package/coach/bin/stats.py +459 -0
- package/coach/bin/status.py +293 -0
- package/coach/bin/statusline_self_patch.py +205 -0
- package/coach/bin/statusline_variants.py +146 -0
- package/coach/bin/statusline_wrap.py +244 -0
- package/coach/bin/statusline_wrap_action.py +460 -0
- package/coach/bin/switch_to_plugin.py +256 -0
- package/coach/bin/themes.py +256 -0
- package/coach/bin/user_config.py +176 -0
- package/coach/bin/xp_accounting.py +98 -0
- package/coach/changelog.md +4 -0
- package/coach/default-statusline-command.sh +19 -0
- package/coach/default-statusline-wrap-command.sh +15 -0
- package/coach/profile.yaml +37 -0
- package/coach/tests/conftest.py +13 -0
- package/coach/tests/test_aggregate_facets.py +379 -0
- package/coach/tests/test_analyze_aggregate.py +153 -0
- package/coach/tests/test_analyze_redaction.py +105 -0
- package/coach/tests/test_analyze_strengths.py +165 -0
- package/coach/tests/test_bank_atomic_write.py +61 -0
- package/coach/tests/test_bank_concurrency.py +126 -0
- package/coach/tests/test_banner_themes.py +981 -0
- package/coach/tests/test_celebrate_dedup.py +409 -0
- package/coach/tests/test_coach_paths.py +50 -0
- package/coach/tests/test_coexistence_check.py +128 -0
- package/coach/tests/test_configure.py +258 -0
- package/coach/tests/test_cron_check.py +118 -0
- package/coach/tests/test_cron_nudge_hook.py +134 -0
- package/coach/tests/test_detection_parity.py +105 -0
- package/coach/tests/test_doctor.py +595 -0
- package/coach/tests/test_hook_bespoke_dispatch.py +288 -0
- package/coach/tests/test_hook_module_resolution.py +116 -0
- package/coach/tests/test_hook_relevance.py +996 -0
- package/coach/tests/test_hook_render_env.py +364 -0
- package/coach/tests/test_hook_session_id_guard.py +160 -0
- package/coach/tests/test_insights_llm.py +759 -0
- package/coach/tests/test_insights_llm_venv_path.py +109 -0
- package/coach/tests/test_insights_window.py +237 -0
- package/coach/tests/test_install.py +1150 -0
- package/coach/tests/test_install_pyyaml_fallback.py +142 -0
- package/coach/tests/test_marker_consumption.py +167 -0
- package/coach/tests/test_marker_writer_locking.py +305 -0
- package/coach/tests/test_merge.py +413 -0
- package/coach/tests/test_no_broken_mktemp.py +90 -0
- package/coach/tests/test_render_env.py +137 -0
- package/coach/tests/test_render_env_glyphs.py +119 -0
- package/coach/tests/test_reward_hints.py +59 -0
- package/coach/tests/test_scoring.py +147 -0
- package/coach/tests/test_session_start_weekly_trigger.py +92 -0
- package/coach/tests/test_skill_inventory.py +368 -0
- package/coach/tests/test_stats_hybrid.py +142 -0
- package/coach/tests/test_status_accounting.py +41 -0
- package/coach/tests/test_statusline_failsafe.py +70 -0
- package/coach/tests/test_statusline_self_patch.py +261 -0
- package/coach/tests/test_statusline_variants.py +110 -0
- package/coach/tests/test_statusline_wrap.py +196 -0
- package/coach/tests/test_statusline_wrap_action.py +408 -0
- package/coach/tests/test_switch_to_plugin.py +360 -0
- package/coach/tests/test_themes.py +104 -0
- package/coach/tests/test_user_config.py +160 -0
- package/coach/tests/test_wrap_announce_hook.py +130 -0
- package/coach/tests/test_xp_accounting.py +55 -0
- package/hooks/coach-session-start.py +536 -0
- package/hooks/coach-user-prompt.py +2288 -0
- package/install-launchd.sh +102 -0
- package/install.sh +597 -0
- package/launchd/com.local.claude-coach.plist.template +34 -0
- package/launchd/run-insights.sh +20 -0
- package/npm/coach-claw.js +259 -0
- package/package.json +52 -0
- package/requirements.txt +11 -0
- package/settings-snippet.json +31 -0
- package/skills/coach/SKILL.md +107 -0
- package/skills/coach-insights/SKILL.md +78 -0
- package/skills/config/SKILL.md +149 -0
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _git_env() -> dict:
|
|
15
|
+
return {
|
|
16
|
+
"GIT_AUTHOR_NAME": "Coach Tests",
|
|
17
|
+
"GIT_AUTHOR_EMAIL": "coach-tests@example.invalid",
|
|
18
|
+
"GIT_COMMITTER_NAME": "Coach Tests",
|
|
19
|
+
"GIT_COMMITTER_EMAIL": "coach-tests@example.invalid",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run_install(
|
|
24
|
+
repo: Path,
|
|
25
|
+
claude_dir: Path,
|
|
26
|
+
*,
|
|
27
|
+
extra_env: dict[str, str] | None = None,
|
|
28
|
+
args: list[str] | None = None,
|
|
29
|
+
) -> subprocess.CompletedProcess:
|
|
30
|
+
env = os.environ.copy()
|
|
31
|
+
env.update({"CLAUDE_DIR": str(claude_dir), **_git_env()})
|
|
32
|
+
if extra_env:
|
|
33
|
+
env.update(extra_env)
|
|
34
|
+
return subprocess.run(
|
|
35
|
+
["bash", str(repo / "install.sh"), *(args or [])],
|
|
36
|
+
cwd=repo,
|
|
37
|
+
env=env,
|
|
38
|
+
text=True,
|
|
39
|
+
stdout=subprocess.PIPE,
|
|
40
|
+
stderr=subprocess.PIPE,
|
|
41
|
+
timeout=30,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_install_uses_custom_claude_dir_in_generated_commands(tmp_path: Path) -> None:
|
|
46
|
+
repo = Path(__file__).resolve().parents[2]
|
|
47
|
+
if not (repo / "install.sh").exists():
|
|
48
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
49
|
+
|
|
50
|
+
claude_dir = tmp_path / "Claude Dir With Spaces"
|
|
51
|
+
python3 = shutil.which("python3")
|
|
52
|
+
assert python3
|
|
53
|
+
|
|
54
|
+
result = _run_install(repo, claude_dir)
|
|
55
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
56
|
+
|
|
57
|
+
settings = json.loads((claude_dir / "settings.json").read_text())
|
|
58
|
+
py_cmd = shlex.quote(python3)
|
|
59
|
+
|
|
60
|
+
session_start = settings["hooks"]["SessionStart"][0]["hooks"][0]
|
|
61
|
+
assert session_start["command"] == (
|
|
62
|
+
f"{py_cmd} {shlex.quote(str(claude_dir / 'hooks/coach-session-start.py'))}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
user_prompt = settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]
|
|
66
|
+
assert user_prompt["command"] == (
|
|
67
|
+
f"{py_cmd} {shlex.quote(str(claude_dir / 'hooks/coach-user-prompt.py'))}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# v0.3.0+: statusLine points at the rich shell wrapper that composes
|
|
71
|
+
# model + context-bar + coach segment. The wrapper itself contains
|
|
72
|
+
# the resolved python path (sed-substituted from @PY@ at install
|
|
73
|
+
# time) so the install command line is just `bash <wrapper>`.
|
|
74
|
+
statusline_path = claude_dir / "coach/default-statusline-command.sh"
|
|
75
|
+
assert settings["statusLine"]["command"] == (
|
|
76
|
+
f"bash {shlex.quote(str(statusline_path))}"
|
|
77
|
+
)
|
|
78
|
+
assert statusline_path.exists()
|
|
79
|
+
wrapper = statusline_path.read_text()
|
|
80
|
+
assert "@PY@" not in wrapper, "installer should have substituted @PY@"
|
|
81
|
+
assert python3 in wrapper, "installer should have written the python path"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_install_preserves_user_config_json(tmp_path: Path) -> None:
|
|
85
|
+
"""Re-installing must NOT reset the user's /config choices.
|
|
86
|
+
|
|
87
|
+
Pre-create `.user_config.json` with non-default values, run the
|
|
88
|
+
installer twice (the second run is the upgrade path that exercises
|
|
89
|
+
the preserve-and-restore loop), and assert the file content is
|
|
90
|
+
byte-identical to what the user had before.
|
|
91
|
+
"""
|
|
92
|
+
repo = Path(__file__).resolve().parents[2]
|
|
93
|
+
if not (repo / "install.sh").exists():
|
|
94
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
95
|
+
|
|
96
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
97
|
+
|
|
98
|
+
# Fresh install first — sets up coach/ so the second install hits
|
|
99
|
+
# the preserve path at install.sh:86-103.
|
|
100
|
+
first = _run_install(repo, claude_dir)
|
|
101
|
+
assert first.returncode == 0, first.stdout + first.stderr
|
|
102
|
+
|
|
103
|
+
user_cfg_path = claude_dir / "coach/.user_config.json"
|
|
104
|
+
payload = {
|
|
105
|
+
"schema_version": 1,
|
|
106
|
+
"statusline_variant": "forge",
|
|
107
|
+
"theme": "skyrim",
|
|
108
|
+
"elo_min": 500,
|
|
109
|
+
"elo_max": 3000,
|
|
110
|
+
}
|
|
111
|
+
user_cfg_path.write_text(json.dumps(payload, indent=2))
|
|
112
|
+
pre = user_cfg_path.read_text()
|
|
113
|
+
|
|
114
|
+
# Re-install — this exercises the preserve-and-restore loop.
|
|
115
|
+
second = _run_install(repo, claude_dir)
|
|
116
|
+
assert second.returncode == 0, second.stdout + second.stderr
|
|
117
|
+
|
|
118
|
+
assert user_cfg_path.exists(), (
|
|
119
|
+
".user_config.json was dropped by reinstall — preserve list at "
|
|
120
|
+
"install.sh:91 is missing the entry."
|
|
121
|
+
)
|
|
122
|
+
post = user_cfg_path.read_text()
|
|
123
|
+
assert post == pre, (
|
|
124
|
+
"reinstall mutated .user_config.json:\n"
|
|
125
|
+
f"pre: {pre!r}\n"
|
|
126
|
+
f"post: {post!r}"
|
|
127
|
+
)
|
|
128
|
+
assert json.loads(post) == payload
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_install_uses_mktemp_for_preserve_dir(tmp_path: Path) -> None:
|
|
132
|
+
"""Upgrade preservation must use a private randomized temp directory.
|
|
133
|
+
|
|
134
|
+
A predictable `/tmp/coach-preserve.$TS` directory is race/symlink-prone
|
|
135
|
+
on multi-user machines because it temporarily holds user-owned state such
|
|
136
|
+
as profile.yaml, changelog.md, log.ndjson, and pending markers.
|
|
137
|
+
"""
|
|
138
|
+
repo = Path(__file__).resolve().parents[2]
|
|
139
|
+
if not (repo / "install.sh").exists():
|
|
140
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
141
|
+
|
|
142
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
143
|
+
first = _run_install(repo, claude_dir)
|
|
144
|
+
assert first.returncode == 0, first.stdout + first.stderr
|
|
145
|
+
|
|
146
|
+
preserve_parent = tmp_path / "preserve parent"
|
|
147
|
+
preserve_parent.mkdir()
|
|
148
|
+
second = _run_install(
|
|
149
|
+
repo,
|
|
150
|
+
claude_dir,
|
|
151
|
+
extra_env={"TMPDIR": str(preserve_parent)},
|
|
152
|
+
)
|
|
153
|
+
assert second.returncode == 0, second.stdout + second.stderr
|
|
154
|
+
|
|
155
|
+
match = re.search(r"preserved existing coach state → (.+)", second.stdout)
|
|
156
|
+
assert match, second.stdout
|
|
157
|
+
preserve_dir = Path(match.group(1).strip())
|
|
158
|
+
assert preserve_dir.parent == preserve_parent
|
|
159
|
+
assert re.fullmatch(r"coach-preserve\.[A-Za-z0-9._-]+", preserve_dir.name)
|
|
160
|
+
assert preserve_dir.name != "coach-preserve.$TS"
|
|
161
|
+
assert not preserve_dir.exists(), "preserve dir should be removed after restore"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_install_preserves_per_run_git_history(tmp_path: Path) -> None:
|
|
165
|
+
"""Re-installing must NOT reset ~/.claude/coach/.git/ to a single bootstrap commit.
|
|
166
|
+
|
|
167
|
+
CLAUDE.md treats the per-run git log as authoritative for profile
|
|
168
|
+
history; the documented rollback UX is
|
|
169
|
+
`git -C ~/.claude/coach checkout HEAD~1 -- profile.yaml`. Pre-fix,
|
|
170
|
+
install.sh's preserve loop covered state files but moved the whole
|
|
171
|
+
coach/ dir (including .git/) aside, so every upgrade produced a
|
|
172
|
+
fresh `git init` and the rollback chain was silently broken.
|
|
173
|
+
"""
|
|
174
|
+
repo = Path(__file__).resolve().parents[2]
|
|
175
|
+
if not (repo / "install.sh").exists():
|
|
176
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
177
|
+
|
|
178
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
179
|
+
|
|
180
|
+
first = _run_install(repo, claude_dir)
|
|
181
|
+
assert first.returncode == 0, first.stdout + first.stderr
|
|
182
|
+
|
|
183
|
+
coach_dir = claude_dir / "coach"
|
|
184
|
+
assert (coach_dir / ".git").is_dir(), "fresh install did not git init coach/"
|
|
185
|
+
|
|
186
|
+
# Simulate a /coach-insights run committing a profile mutation.
|
|
187
|
+
(coach_dir / "profile.yaml").write_text(
|
|
188
|
+
"schema_version: 1\nentries:\n - id: test-fake-pattern\n type: weakness\n"
|
|
189
|
+
)
|
|
190
|
+
git_env = {**os.environ, **_git_env()}
|
|
191
|
+
subprocess.run(["git", "add", "-A"], cwd=coach_dir, env=git_env, check=True)
|
|
192
|
+
subprocess.run(
|
|
193
|
+
["git", "commit", "-q", "-m", "insights-test-fake: simulated /coach-insights commit"],
|
|
194
|
+
cwd=coach_dir, env=git_env, check=True,
|
|
195
|
+
)
|
|
196
|
+
pre_sha = subprocess.run(
|
|
197
|
+
["git", "rev-parse", "HEAD"], cwd=coach_dir,
|
|
198
|
+
capture_output=True, text=True, check=True,
|
|
199
|
+
).stdout.strip()
|
|
200
|
+
|
|
201
|
+
# Upgrade install — must restore .git/ from coach.bak.<ts>/.
|
|
202
|
+
second = _run_install(repo, claude_dir)
|
|
203
|
+
assert second.returncode == 0, second.stdout + second.stderr
|
|
204
|
+
|
|
205
|
+
assert (coach_dir / ".git").is_dir(), "upgrade install dropped .git/"
|
|
206
|
+
post_sha = subprocess.run(
|
|
207
|
+
["git", "rev-parse", "HEAD"], cwd=coach_dir,
|
|
208
|
+
capture_output=True, text=True, check=True,
|
|
209
|
+
).stdout.strip()
|
|
210
|
+
assert post_sha == pre_sha, (
|
|
211
|
+
"upgrade install reset coach/.git/ — documented rollback UX broken.\n"
|
|
212
|
+
f"pre-upgrade HEAD: {pre_sha}\n"
|
|
213
|
+
f"post-upgrade HEAD: {post_sha}\n"
|
|
214
|
+
"Fix: install.sh must cp -R coach.bak.<ts>/.git into the new coach/."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_default_statusline_runs_without_jq(tmp_path: Path) -> None:
|
|
219
|
+
"""The installed default statusline must render without jq on PATH.
|
|
220
|
+
|
|
221
|
+
macOS does not ship jq, so the v0.3.0 bash wrapper emitted
|
|
222
|
+
`jq: command not found` and rendered an empty model + 0%. This
|
|
223
|
+
test pins the parity contract for the Python replacement: model
|
|
224
|
+
name normalization, percent rounding, 20-segment bar, separator,
|
|
225
|
+
and absence of any `command not found` noise.
|
|
226
|
+
"""
|
|
227
|
+
repo = Path(__file__).resolve().parents[2]
|
|
228
|
+
if not (repo / "install.sh").exists():
|
|
229
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
230
|
+
|
|
231
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
232
|
+
result = _run_install(repo, claude_dir)
|
|
233
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
234
|
+
|
|
235
|
+
wrapper = claude_dir / "coach/default-statusline-command.sh"
|
|
236
|
+
assert wrapper.exists()
|
|
237
|
+
|
|
238
|
+
# Build a sandbox PATH that contains only bash + python3 (and their
|
|
239
|
+
# transitive shell deps), explicitly NOT jq. Symlinking known-good
|
|
240
|
+
# binaries lets us test "what if jq isn't installed" without relying
|
|
241
|
+
# on /usr/bin (which on this user's machine actually has jq).
|
|
242
|
+
sandbox_bin = tmp_path / "sandbox-bin"
|
|
243
|
+
sandbox_bin.mkdir()
|
|
244
|
+
bash_path = shutil.which("bash")
|
|
245
|
+
py_path = shutil.which("python3")
|
|
246
|
+
assert bash_path and py_path
|
|
247
|
+
(sandbox_bin / "bash").symlink_to(bash_path)
|
|
248
|
+
(sandbox_bin / "python3").symlink_to(py_path)
|
|
249
|
+
# Defensive sanity check: nothing named jq in the sandbox.
|
|
250
|
+
assert not (sandbox_bin / "jq").exists()
|
|
251
|
+
|
|
252
|
+
payload = (
|
|
253
|
+
'{"model":{"display_name":"Sonnet 4.6"},'
|
|
254
|
+
'"context_window":{"used_percentage":42.7}}'
|
|
255
|
+
)
|
|
256
|
+
proc = subprocess.run(
|
|
257
|
+
[bash_path, str(wrapper)],
|
|
258
|
+
env={"PATH": str(sandbox_bin), "HOME": os.environ.get("HOME", "")},
|
|
259
|
+
input=payload,
|
|
260
|
+
text=True,
|
|
261
|
+
stdout=subprocess.PIPE,
|
|
262
|
+
stderr=subprocess.PIPE,
|
|
263
|
+
timeout=15,
|
|
264
|
+
)
|
|
265
|
+
assert proc.returncode == 0, (
|
|
266
|
+
f"wrapper exited {proc.returncode}\n"
|
|
267
|
+
f"stdout={proc.stdout!r}\n"
|
|
268
|
+
f"stderr={proc.stderr!r}"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
out = proc.stdout
|
|
272
|
+
# Strip ANSI for content assertions; keep raw `out` for separator/glyph counts.
|
|
273
|
+
ansi = re.compile(r"\x1b\[[0-9;]*m")
|
|
274
|
+
plain = ansi.sub("", out)
|
|
275
|
+
|
|
276
|
+
assert "sonnet·4.6" in plain, f"model normalization broke: {plain!r}"
|
|
277
|
+
assert "43%" in plain, f"percent round-half-up broke (42.7 → 43): {plain!r}"
|
|
278
|
+
assert plain.count("┃") == 2, f"expected 2 ┃ separators: {plain!r}"
|
|
279
|
+
bar_segments = plain.count("▰") + plain.count("▱")
|
|
280
|
+
assert bar_segments == 20, (
|
|
281
|
+
f"expected 20 bar segments (▰+▱), got {bar_segments}: {plain!r}"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Stderr must not contain jq error noise. stdout must not contain
|
|
285
|
+
# the bash 'command not found' fallback message.
|
|
286
|
+
assert "command not found" not in proc.stderr, proc.stderr
|
|
287
|
+
assert "jq" not in proc.stderr.lower(), proc.stderr
|
|
288
|
+
assert "command not found" not in out, out
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_install_creates_coach_insights_skill_directory(tmp_path: Path) -> None:
|
|
292
|
+
"""Fresh install must put the skill at skills/coach-insights/ and must
|
|
293
|
+
NOT install a fresh skills/insights/ shadow over Claude Code's built-in
|
|
294
|
+
/insights command."""
|
|
295
|
+
repo = Path(__file__).resolve().parents[2]
|
|
296
|
+
if not (repo / "install.sh").exists():
|
|
297
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
298
|
+
|
|
299
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
300
|
+
result = _run_install(repo, claude_dir)
|
|
301
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
302
|
+
|
|
303
|
+
skill_md = claude_dir / "skills/coach-insights/SKILL.md"
|
|
304
|
+
assert skill_md.exists(), (
|
|
305
|
+
"v0.4.0 installer must create skills/coach-insights/SKILL.md "
|
|
306
|
+
"(was skills/insights/ in v0.3.x)"
|
|
307
|
+
)
|
|
308
|
+
body = skill_md.read_text()
|
|
309
|
+
assert "/coach-insights" in body, "skill body should describe its new name"
|
|
310
|
+
assert "disable-model-invocation: true" in body, (
|
|
311
|
+
"frontmatter must opt out of implicit model invocation — the skill "
|
|
312
|
+
"mutates profile state and creates git commits"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
legacy = claude_dir / "skills/insights"
|
|
316
|
+
assert not legacy.exists(), (
|
|
317
|
+
f"fresh install must NOT create {legacy} — that path is reserved "
|
|
318
|
+
"for Claude Code's built-in /insights, which our skill used to shadow"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# v0.5.0: SKILL.md is a thin wrapper around insights-llm.sh --force,
|
|
322
|
+
# not a full prose translator. The presence of `insights-llm.sh` in
|
|
323
|
+
# the body and the absence of the v0.4.0 prose-translation steps is
|
|
324
|
+
# the contract.
|
|
325
|
+
assert "insights-llm.sh" in body, (
|
|
326
|
+
"v0.5.0 SKILL.md must reference the insights-llm.sh wrapper"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_install_creates_aggregate_facets_script(tmp_path: Path) -> None:
|
|
331
|
+
"""v0.5.0 installer must ship coach/bin/aggregate_facets.py executable."""
|
|
332
|
+
repo = Path(__file__).resolve().parents[2]
|
|
333
|
+
if not (repo / "install.sh").exists():
|
|
334
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
335
|
+
|
|
336
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
337
|
+
result = _run_install(repo, claude_dir)
|
|
338
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
339
|
+
|
|
340
|
+
script = claude_dir / "coach/bin/aggregate_facets.py"
|
|
341
|
+
assert script.exists(), "aggregate_facets.py was not installed"
|
|
342
|
+
assert os.access(script, os.X_OK), "aggregate_facets.py is not executable"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_install_creates_insights_llm_script(tmp_path: Path) -> None:
|
|
346
|
+
"""v0.5.0 installer must ship coach/bin/insights-llm.sh executable."""
|
|
347
|
+
repo = Path(__file__).resolve().parents[2]
|
|
348
|
+
if not (repo / "install.sh").exists():
|
|
349
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
350
|
+
|
|
351
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
352
|
+
result = _run_install(repo, claude_dir)
|
|
353
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
354
|
+
|
|
355
|
+
script = claude_dir / "coach/bin/insights-llm.sh"
|
|
356
|
+
assert script.exists(), "insights-llm.sh was not installed"
|
|
357
|
+
assert os.access(script, os.X_OK), "insights-llm.sh is not executable"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_install_creates_run_with_lock_helper(tmp_path: Path) -> None:
|
|
361
|
+
"""v0.5.0 installer must ship coach/bin/run_with_lock.py — the
|
|
362
|
+
flock helper that serializes concurrent weekly-insights runs.
|
|
363
|
+
Without it, insights-llm.sh's `exec ... run_with_lock.py ...`
|
|
364
|
+
fails immediately and the wrapper crashes on every invocation."""
|
|
365
|
+
repo = Path(__file__).resolve().parents[2]
|
|
366
|
+
if not (repo / "install.sh").exists():
|
|
367
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
368
|
+
|
|
369
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
370
|
+
result = _run_install(repo, claude_dir)
|
|
371
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
372
|
+
|
|
373
|
+
script = claude_dir / "coach/bin/run_with_lock.py"
|
|
374
|
+
assert script.exists(), "run_with_lock.py was not installed"
|
|
375
|
+
assert os.access(script, os.X_OK), "run_with_lock.py is not executable"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_install_preserves_last_weekly_insights_marker(tmp_path: Path) -> None:
|
|
379
|
+
"""Re-installing must NOT reset the weekly throttle marker — otherwise
|
|
380
|
+
upgrades cause an immediate weekly run on the next session start."""
|
|
381
|
+
repo = Path(__file__).resolve().parents[2]
|
|
382
|
+
if not (repo / "install.sh").exists():
|
|
383
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
384
|
+
|
|
385
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
386
|
+
first = _run_install(repo, claude_dir)
|
|
387
|
+
assert first.returncode == 0, first.stdout + first.stderr
|
|
388
|
+
|
|
389
|
+
marker = claude_dir / "coach/.last_weekly_insights"
|
|
390
|
+
marker.write_text("")
|
|
391
|
+
pre_mtime = marker.stat().st_mtime
|
|
392
|
+
|
|
393
|
+
second = _run_install(repo, claude_dir)
|
|
394
|
+
assert second.returncode == 0, second.stdout + second.stderr
|
|
395
|
+
|
|
396
|
+
assert marker.exists(), (
|
|
397
|
+
".last_weekly_insights was dropped by reinstall — preserve list at "
|
|
398
|
+
"install.sh:91-95 is missing it."
|
|
399
|
+
)
|
|
400
|
+
post_mtime = marker.stat().st_mtime
|
|
401
|
+
assert post_mtime == pre_mtime, (
|
|
402
|
+
f"reinstall mutated marker mtime: pre={pre_mtime} post={post_mtime}"
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def test_install_migrates_legacy_insights_skill(tmp_path: Path) -> None:
|
|
407
|
+
"""Upgrading from v0.3.x: any pre-existing skills/insights/ must be
|
|
408
|
+
moved aside (mv → .bak.<ts>, not deleted) so the user keeps any
|
|
409
|
+
customizations and the built-in /insights becomes reachable again."""
|
|
410
|
+
repo = Path(__file__).resolve().parents[2]
|
|
411
|
+
if not (repo / "install.sh").exists():
|
|
412
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
413
|
+
|
|
414
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
415
|
+
legacy_skill_dir = claude_dir / "skills/insights"
|
|
416
|
+
legacy_skill_dir.mkdir(parents=True)
|
|
417
|
+
sentinel = legacy_skill_dir / "SKILL.md"
|
|
418
|
+
sentinel_text = "---\ndescription: legacy v0.3.x skill\n---\nLegacy body — must survive the migration.\n"
|
|
419
|
+
sentinel.write_text(sentinel_text)
|
|
420
|
+
|
|
421
|
+
result = _run_install(repo, claude_dir)
|
|
422
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
423
|
+
|
|
424
|
+
assert not legacy_skill_dir.exists(), (
|
|
425
|
+
"installer must move skills/insights/ aside, not leave it in place "
|
|
426
|
+
"(it would continue to shadow Claude Code's built-in /insights)"
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
bak_dirs = sorted((claude_dir / "skills").glob("insights.bak.*"))
|
|
430
|
+
assert len(bak_dirs) == 1, (
|
|
431
|
+
f"expected exactly one skills/insights.bak.<ts>/ sibling, got "
|
|
432
|
+
f"{[p.name for p in bak_dirs]}"
|
|
433
|
+
)
|
|
434
|
+
# Claude Code's skill loader picks up any skills/<dir>/SKILL.md as a
|
|
435
|
+
# slash command. The migration must rename the legacy SKILL.md inside
|
|
436
|
+
# the bak dir so the loader doesn't surface it as `/insights.bak.<ts>`
|
|
437
|
+
# — the whole point of the migration is to UN-shadow the built-in.
|
|
438
|
+
bak_dir = bak_dirs[0]
|
|
439
|
+
assert not (bak_dir / "SKILL.md").exists(), (
|
|
440
|
+
f"{bak_dir}/SKILL.md must NOT exist post-install — Claude Code "
|
|
441
|
+
f"would surface it as /insights.bak.<ts> and clutter the slash-"
|
|
442
|
+
f"command catalog"
|
|
443
|
+
)
|
|
444
|
+
bak_skill_md = bak_dir / "SKILL.md.bak"
|
|
445
|
+
assert bak_skill_md.exists(), (
|
|
446
|
+
"legacy SKILL.md content must survive — installer should rename "
|
|
447
|
+
"to SKILL.md.bak (preserves customizations, defangs the loader)"
|
|
448
|
+
)
|
|
449
|
+
assert bak_skill_md.read_text() == sentinel_text, (
|
|
450
|
+
"user's customized SKILL.md content must be preserved byte-for-byte"
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
new_skill = claude_dir / "skills/coach-insights/SKILL.md"
|
|
454
|
+
assert new_skill.exists(), "v0.4.0 skill must be installed alongside"
|
|
455
|
+
assert "Legacy body" not in new_skill.read_text(), (
|
|
456
|
+
"v0.4.0 skill must NOT inherit content from the legacy skill"
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def test_install_under_temp_home(tmp_path: Path) -> None:
|
|
461
|
+
"""Sandbox the entire HOME, not just CLAUDE_DIR. This catches any
|
|
462
|
+
`$HOME/...` literal in install.sh that escaped the CLAUDE_DIR
|
|
463
|
+
abstraction (which would silently leak into the developer's real
|
|
464
|
+
HOME during prior test runs)."""
|
|
465
|
+
repo = Path(__file__).resolve().parents[2]
|
|
466
|
+
if not (repo / "install.sh").exists():
|
|
467
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
468
|
+
|
|
469
|
+
fake_home = tmp_path / "fake-home"
|
|
470
|
+
fake_home.mkdir()
|
|
471
|
+
|
|
472
|
+
env = os.environ.copy()
|
|
473
|
+
env.update({
|
|
474
|
+
"HOME": str(fake_home),
|
|
475
|
+
# Don't set CLAUDE_DIR — let install.sh derive it from HOME.
|
|
476
|
+
**_git_env(),
|
|
477
|
+
})
|
|
478
|
+
# Drop any inherited CLAUDE_DIR from the developer's environment.
|
|
479
|
+
env.pop("CLAUDE_DIR", None)
|
|
480
|
+
|
|
481
|
+
result = subprocess.run(
|
|
482
|
+
["bash", str(repo / "install.sh")],
|
|
483
|
+
cwd=repo,
|
|
484
|
+
env=env,
|
|
485
|
+
text=True,
|
|
486
|
+
stdout=subprocess.PIPE,
|
|
487
|
+
stderr=subprocess.PIPE,
|
|
488
|
+
timeout=30,
|
|
489
|
+
)
|
|
490
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
491
|
+
|
|
492
|
+
derived_target = fake_home / ".claude"
|
|
493
|
+
assert derived_target.is_dir(), (
|
|
494
|
+
"install.sh derived its target from HOME but did not create "
|
|
495
|
+
f"{derived_target}; either HOME respect broke or the installer "
|
|
496
|
+
"leaked into the real $HOME"
|
|
497
|
+
)
|
|
498
|
+
assert (derived_target / "coach/profile.yaml").exists()
|
|
499
|
+
assert (derived_target / "skills/coach-insights/SKILL.md").exists()
|
|
500
|
+
assert (derived_target / "settings.json").exists()
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def test_install_skips_bak_on_byte_identical_files(tmp_path: Path) -> None:
|
|
504
|
+
"""Re-running install on an unchanged install produces NO redundant .bak.<ts>.
|
|
505
|
+
|
|
506
|
+
Pre-fix, every reinstall created `hooks/<hook>.bak.<ts>` and
|
|
507
|
+
`settings.json.bak.<ts>` even when content was byte-identical. Now
|
|
508
|
+
those backups only appear on real diffs (P2-1 from v0.5.2 audit).
|
|
509
|
+
"""
|
|
510
|
+
repo = Path(__file__).resolve().parents[2]
|
|
511
|
+
if not (repo / "install.sh").exists():
|
|
512
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
513
|
+
|
|
514
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
515
|
+
|
|
516
|
+
first = _run_install(repo, claude_dir)
|
|
517
|
+
assert first.returncode == 0, first.stdout + first.stderr
|
|
518
|
+
|
|
519
|
+
second = _run_install(repo, claude_dir)
|
|
520
|
+
assert second.returncode == 0, second.stdout + second.stderr
|
|
521
|
+
|
|
522
|
+
hook_baks = sorted((claude_dir / "hooks").glob("*.bak.*"))
|
|
523
|
+
assert hook_baks == [], (
|
|
524
|
+
f"byte-identical reinstall left hook backups: {hook_baks}\n"
|
|
525
|
+
"Fix: install.sh hook backup loop must gate on `cmp -s` between "
|
|
526
|
+
"bundle and live copy."
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
settings_baks = sorted(claude_dir.glob("settings.json.bak.*"))
|
|
530
|
+
assert settings_baks == [], (
|
|
531
|
+
f"byte-identical reinstall left settings.json backups: {settings_baks}\n"
|
|
532
|
+
"Fix: post-patch cleanup must rm the snapshot when cmp -s shows no diff."
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
# Note: coach.bak.<ts>/ IS expected (intentional — user state always
|
|
536
|
+
# differs from the bundle). The --prune-backups flag handles long-term
|
|
537
|
+
# accumulation.
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def test_install_default_prunes_backups_keeps_three_most_recent(tmp_path: Path) -> None:
|
|
541
|
+
"""Default install keeps the 3 most recent .bak.<ts> of each kind.
|
|
542
|
+
|
|
543
|
+
v0.5.2 flipped the default to prune-on so ~/.claude/ doesn't
|
|
544
|
+
accumulate hundreds of backups across upgrades. `--no-prune-backups`
|
|
545
|
+
opts out (covered by `test_install_no_prune_backups_keeps_all`).
|
|
546
|
+
|
|
547
|
+
`--fresh` is set so the recovery-from-prior-bak block doesn't
|
|
548
|
+
`mv` the most-recent fake .bak back to `coach/` and skew counts.
|
|
549
|
+
"""
|
|
550
|
+
import time
|
|
551
|
+
repo = Path(__file__).resolve().parents[2]
|
|
552
|
+
if not (repo / "install.sh").exists():
|
|
553
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
554
|
+
|
|
555
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
556
|
+
claude_dir.mkdir()
|
|
557
|
+
(claude_dir / "hooks").mkdir()
|
|
558
|
+
|
|
559
|
+
for i in range(5):
|
|
560
|
+
d = claude_dir / f"coach.bak.20260101-12000{i}"
|
|
561
|
+
d.mkdir()
|
|
562
|
+
(d / "marker").write_text(f"backup-{i}")
|
|
563
|
+
f = claude_dir / f"settings.json.bak.20260101-12000{i}"
|
|
564
|
+
f.write_text(f'{{"backup": {i}}}')
|
|
565
|
+
for hook in ("coach-session-start.py", "coach-user-prompt.py"):
|
|
566
|
+
(claude_dir / "hooks" / f"{hook}.bak.20260101-12000{i}").write_text(
|
|
567
|
+
f"# bak {i}"
|
|
568
|
+
)
|
|
569
|
+
time.sleep(0.02)
|
|
570
|
+
|
|
571
|
+
result = _run_install(repo, claude_dir, args=["--fresh"])
|
|
572
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
573
|
+
|
|
574
|
+
coach_baks = sorted(p.name for p in claude_dir.glob("coach.bak.20260101-*"))
|
|
575
|
+
settings_baks = sorted(p.name for p in claude_dir.glob("settings.json.bak.20260101-*"))
|
|
576
|
+
assert len(coach_baks) == 3, f"expected 3 coach.bak.*, got {coach_baks}"
|
|
577
|
+
assert len(settings_baks) == 3, f"expected 3 settings.json.bak.*, got {settings_baks}"
|
|
578
|
+
|
|
579
|
+
for hook in ("coach-session-start.py", "coach-user-prompt.py"):
|
|
580
|
+
baks = sorted(p.name for p in (claude_dir / "hooks").glob(f"{hook}.bak.20260101-*"))
|
|
581
|
+
assert len(baks) == 3, f"expected 3 {hook}.bak.*, got {baks}"
|
|
582
|
+
|
|
583
|
+
surviving_suffixes = {p.split("-")[-1] for p in coach_baks}
|
|
584
|
+
assert surviving_suffixes == {"120002", "120003", "120004"}, (
|
|
585
|
+
f"prune kept the wrong 3: {surviving_suffixes}"
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def test_install_no_prune_backups_keeps_all(tmp_path: Path) -> None:
|
|
590
|
+
"""`--no-prune-backups` opts out of v0.5.2's default-on prune.
|
|
591
|
+
|
|
592
|
+
Use case: user is intentionally holding many .bak.<ts> for forensic
|
|
593
|
+
or recovery purposes and doesn't want install to thin them. Pinned
|
|
594
|
+
so a future "small UX improvement" can't silently flip the default
|
|
595
|
+
again without flagging this contract.
|
|
596
|
+
"""
|
|
597
|
+
repo = Path(__file__).resolve().parents[2]
|
|
598
|
+
if not (repo / "install.sh").exists():
|
|
599
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
600
|
+
|
|
601
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
602
|
+
claude_dir.mkdir()
|
|
603
|
+
for i in range(5):
|
|
604
|
+
d = claude_dir / f"coach.bak.20260101-12000{i}"
|
|
605
|
+
d.mkdir()
|
|
606
|
+
(d / "marker").write_text(f"backup-{i}")
|
|
607
|
+
|
|
608
|
+
result = _run_install(repo, claude_dir, args=["--fresh", "--no-prune-backups"])
|
|
609
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
610
|
+
|
|
611
|
+
coach_baks = sorted(claude_dir.glob("coach.bak.20260101-*"))
|
|
612
|
+
assert len(coach_baks) == 5, (
|
|
613
|
+
f"--no-prune-backups must NOT delete .bak.<ts>; only {len(coach_baks)} of 5 survived."
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def test_install_recovers_state_from_prior_bak_after_uninstall(tmp_path: Path) -> None:
|
|
618
|
+
"""install.sh recovers profile + throttle marker + .git from a prior uninstall.
|
|
619
|
+
|
|
620
|
+
Scenario: `/coach uninstall` renamed `coach/` to `coach.bak.<ts>/`,
|
|
621
|
+
then user re-runs `./install.sh`. Without recovery, install treats
|
|
622
|
+
it as a fresh install and the user silently loses:
|
|
623
|
+
- profile.yaml (their tracked weaknesses/strengths)
|
|
624
|
+
- .last_weekly_insights (→ unintended paid /insights API call
|
|
625
|
+
on next SessionStart)
|
|
626
|
+
- per-run git history (rollback UX broken)
|
|
627
|
+
|
|
628
|
+
v0.5.2 fix: when no live `coach/` exists but `coach.bak.*` does,
|
|
629
|
+
install renames the most-recent .bak back to coach/ before the
|
|
630
|
+
existing preserve + .git restore logic runs.
|
|
631
|
+
"""
|
|
632
|
+
repo = Path(__file__).resolve().parents[2]
|
|
633
|
+
if not (repo / "install.sh").exists():
|
|
634
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
635
|
+
|
|
636
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
637
|
+
claude_dir.mkdir()
|
|
638
|
+
|
|
639
|
+
# Build a realistic post-uninstall coach.bak.<ts> with state files +
|
|
640
|
+
# a real .git repo with one commit (so we can verify history survives).
|
|
641
|
+
bak = claude_dir / "coach.bak.20260105-100000"
|
|
642
|
+
bak.mkdir()
|
|
643
|
+
(bak / "profile.yaml").write_text("schema_version: 1\nentries:\n- id: marker\n")
|
|
644
|
+
(bak / "banked_sessions.json").write_text('{"sess-1": {"xp": 5}}')
|
|
645
|
+
(bak / ".last_weekly_insights").write_text("") # zero-byte throttle marker
|
|
646
|
+
(bak / "changelog.md").write_text("# changelog\n")
|
|
647
|
+
subprocess.run(["git", "init", "-q"], cwd=bak, check=True, env={**os.environ, **_git_env()})
|
|
648
|
+
subprocess.run(["git", "add", "-A"], cwd=bak, check=True, env={**os.environ, **_git_env()})
|
|
649
|
+
subprocess.run(
|
|
650
|
+
["git", "commit", "-q", "-m", "pre-uninstall snapshot"],
|
|
651
|
+
cwd=bak, check=True, env={**os.environ, **_git_env()},
|
|
652
|
+
)
|
|
653
|
+
pre_commit = subprocess.run(
|
|
654
|
+
["git", "rev-parse", "HEAD"],
|
|
655
|
+
cwd=bak, capture_output=True, text=True, check=True,
|
|
656
|
+
).stdout.strip()
|
|
657
|
+
|
|
658
|
+
result = _run_install(repo, claude_dir)
|
|
659
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
660
|
+
assert "Install mode: recovered" in result.stdout, result.stdout
|
|
661
|
+
|
|
662
|
+
coach = claude_dir / "coach"
|
|
663
|
+
assert coach.exists(), "coach/ should exist after recovery"
|
|
664
|
+
assert (coach / "profile.yaml").read_text() == "schema_version: 1\nentries:\n- id: marker\n"
|
|
665
|
+
assert (coach / "banked_sessions.json").read_text() == '{"sess-1": {"xp": 5}}'
|
|
666
|
+
assert (coach / ".last_weekly_insights").exists(), (
|
|
667
|
+
"throttle marker MUST be preserved — its absence triggers an unintended "
|
|
668
|
+
"paid /insights API call on next SessionStart"
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
# The pre-uninstall commit must still be in the live coach git log.
|
|
672
|
+
log = subprocess.run(
|
|
673
|
+
["git", "log", "--format=%H"],
|
|
674
|
+
cwd=coach, capture_output=True, text=True, check=True,
|
|
675
|
+
).stdout
|
|
676
|
+
assert pre_commit in log, (
|
|
677
|
+
f"pre-uninstall commit {pre_commit[:7]} missing from recovered git history"
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def test_install_fresh_flag_skips_bak_recovery(tmp_path: Path) -> None:
|
|
682
|
+
"""`--fresh` forces a true fresh install even if coach.bak.<ts> exists."""
|
|
683
|
+
repo = Path(__file__).resolve().parents[2]
|
|
684
|
+
if not (repo / "install.sh").exists():
|
|
685
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
686
|
+
|
|
687
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
688
|
+
claude_dir.mkdir()
|
|
689
|
+
bak = claude_dir / "coach.bak.20260105-100000"
|
|
690
|
+
bak.mkdir()
|
|
691
|
+
(bak / "profile.yaml").write_text("schema_version: 1\nentries:\n- id: from-bak\n")
|
|
692
|
+
|
|
693
|
+
result = _run_install(repo, claude_dir, args=["--fresh"])
|
|
694
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
695
|
+
assert "Install mode: fresh" in result.stdout, result.stdout
|
|
696
|
+
|
|
697
|
+
# The .bak must still be sitting where we left it (untouched).
|
|
698
|
+
assert bak.exists()
|
|
699
|
+
assert (bak / "profile.yaml").read_text() == "schema_version: 1\nentries:\n- id: from-bak\n"
|
|
700
|
+
|
|
701
|
+
# The new live coach/ must be the bundle template, not the .bak content.
|
|
702
|
+
live_profile = (claude_dir / "coach" / "profile.yaml").read_text()
|
|
703
|
+
assert "from-bak" not in live_profile, (
|
|
704
|
+
"--fresh should NOT have copied .bak's profile into the new install"
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def test_install_recovery_picks_most_recent_bak(tmp_path: Path) -> None:
|
|
709
|
+
"""When multiple coach.bak.<ts> exist, recovery picks the most recent."""
|
|
710
|
+
import time
|
|
711
|
+
repo = Path(__file__).resolve().parents[2]
|
|
712
|
+
if not (repo / "install.sh").exists():
|
|
713
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
714
|
+
|
|
715
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
716
|
+
claude_dir.mkdir()
|
|
717
|
+
|
|
718
|
+
# Older bak — should be left alone.
|
|
719
|
+
older = claude_dir / "coach.bak.20260101-100000"
|
|
720
|
+
older.mkdir()
|
|
721
|
+
(older / "profile.yaml").write_text("# old\n")
|
|
722
|
+
|
|
723
|
+
time.sleep(0.05)
|
|
724
|
+
|
|
725
|
+
# Newer bak — should be the one recovered.
|
|
726
|
+
newer = claude_dir / "coach.bak.20260105-100000"
|
|
727
|
+
newer.mkdir()
|
|
728
|
+
(newer / "profile.yaml").write_text("# new\n")
|
|
729
|
+
|
|
730
|
+
result = _run_install(repo, claude_dir)
|
|
731
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
732
|
+
assert "Install mode: recovered" in result.stdout, result.stdout
|
|
733
|
+
assert "20260105" in result.stdout, (
|
|
734
|
+
f"recovery should report which bak was restored, got: {result.stdout}"
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
# The newer bak became coach/ → its content lives there now.
|
|
738
|
+
# install.sh then moves coach/ to coach.bak.<install-TS>, so the original
|
|
739
|
+
# newer/ path should NOT exist anymore.
|
|
740
|
+
assert not newer.exists(), (
|
|
741
|
+
f"newer bak should have been renamed to coach/ then re-baked under install TS"
|
|
742
|
+
)
|
|
743
|
+
# The older bak must still be intact.
|
|
744
|
+
assert older.exists()
|
|
745
|
+
assert (older / "profile.yaml").read_text() == "# old\n"
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def test_install_recovers_launchd_plist_from_prior_uninstall(tmp_path: Path) -> None:
|
|
749
|
+
"""install.sh restores ~/Library/LaunchAgents/com.local.claude-coach.plist
|
|
750
|
+
when a prior `/coach uninstall` left it as `.uninstalled.<TS>`.
|
|
751
|
+
|
|
752
|
+
Without this, the user has to run install-launchd.sh as a second step
|
|
753
|
+
after `./install.sh` to get the daily cron pass back online — a silent
|
|
754
|
+
gap between "reinstall finished" and "Coach is autonomous again".
|
|
755
|
+
Symmetric with the coach.bak.<TS>/ recovery: same shape, same trigger.
|
|
756
|
+
"""
|
|
757
|
+
import platform
|
|
758
|
+
if platform.system() != "Darwin":
|
|
759
|
+
pytest.skip("launchd recovery is macOS-only")
|
|
760
|
+
|
|
761
|
+
repo = Path(__file__).resolve().parents[2]
|
|
762
|
+
if not (repo / "install.sh").exists():
|
|
763
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
764
|
+
|
|
765
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
766
|
+
claude_dir.mkdir()
|
|
767
|
+
la_dir = tmp_path / "LaunchAgents"
|
|
768
|
+
la_dir.mkdir()
|
|
769
|
+
|
|
770
|
+
# Stage a post-uninstall world: coach.bak.<TS>/ exists with state, AND a
|
|
771
|
+
# .uninstalled.<TS> plist sibling exists in the LaunchAgents dir.
|
|
772
|
+
bak = claude_dir / "coach.bak.20260105-100000"
|
|
773
|
+
bak.mkdir()
|
|
774
|
+
(bak / "profile.yaml").write_text("schema_version: 1\nentries:\n- id: marker\n")
|
|
775
|
+
|
|
776
|
+
plist_content = (
|
|
777
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
778
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
|
|
779
|
+
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
|
|
780
|
+
'<plist version="1.0"><dict>'
|
|
781
|
+
'<key>Label</key><string>com.local.claude-coach</string>'
|
|
782
|
+
'<key>ProgramArguments</key><array><string>/usr/bin/true</string></array>'
|
|
783
|
+
'</dict></plist>\n'
|
|
784
|
+
)
|
|
785
|
+
uninstalled_plist = la_dir / "com.local.claude-coach.plist.uninstalled.20260105-100000"
|
|
786
|
+
uninstalled_plist.write_text(plist_content)
|
|
787
|
+
live_plist = la_dir / "com.local.claude-coach.plist"
|
|
788
|
+
assert not live_plist.exists()
|
|
789
|
+
|
|
790
|
+
result = _run_install(
|
|
791
|
+
repo,
|
|
792
|
+
claude_dir,
|
|
793
|
+
extra_env={"LAUNCHAGENTS_DIR": str(la_dir)},
|
|
794
|
+
)
|
|
795
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
796
|
+
assert "Install mode: recovered" in result.stdout, result.stdout
|
|
797
|
+
|
|
798
|
+
# The renamed plist should be back at its canonical path.
|
|
799
|
+
assert live_plist.exists(), (
|
|
800
|
+
"launchd plist should be restored to its canonical path after recovery"
|
|
801
|
+
)
|
|
802
|
+
assert live_plist.read_text() == plist_content, (
|
|
803
|
+
"restored plist content must be byte-identical to the .uninstalled.<TS> sibling"
|
|
804
|
+
)
|
|
805
|
+
# The .uninstalled.<TS> sibling should be gone (renamed away, not copied).
|
|
806
|
+
assert not uninstalled_plist.exists(), (
|
|
807
|
+
"recovery should mv (not cp) so the .uninstalled.<TS> sibling is gone"
|
|
808
|
+
)
|
|
809
|
+
# And the install banner should mention the launchd recovery so the user
|
|
810
|
+
# knows the daemon is back online without a second script.
|
|
811
|
+
assert "restored launchd plist" in result.stdout, (
|
|
812
|
+
f"recovery banner should announce launchd plist restore, got: {result.stdout}"
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def test_install_fresh_flag_skips_launchd_plist_recovery(tmp_path: Path) -> None:
|
|
817
|
+
"""`--fresh` must NOT pull a renamed launchd plist back into place either.
|
|
818
|
+
|
|
819
|
+
Symmetry with --fresh skipping coach.bak.<TS>/ recovery: a true fresh
|
|
820
|
+
install leaves both .uninstalled.<TS> and coach.bak.<TS>/ untouched so
|
|
821
|
+
the user can roll back manually if needed.
|
|
822
|
+
"""
|
|
823
|
+
import platform
|
|
824
|
+
if platform.system() != "Darwin":
|
|
825
|
+
pytest.skip("launchd recovery is macOS-only")
|
|
826
|
+
|
|
827
|
+
repo = Path(__file__).resolve().parents[2]
|
|
828
|
+
if not (repo / "install.sh").exists():
|
|
829
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
830
|
+
|
|
831
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
832
|
+
claude_dir.mkdir()
|
|
833
|
+
la_dir = tmp_path / "LaunchAgents"
|
|
834
|
+
la_dir.mkdir()
|
|
835
|
+
|
|
836
|
+
uninstalled_plist = la_dir / "com.local.claude-coach.plist.uninstalled.20260105-100000"
|
|
837
|
+
uninstalled_plist.write_text("<plist></plist>\n")
|
|
838
|
+
|
|
839
|
+
result = _run_install(
|
|
840
|
+
repo,
|
|
841
|
+
claude_dir,
|
|
842
|
+
args=["--fresh"],
|
|
843
|
+
extra_env={"LAUNCHAGENTS_DIR": str(la_dir)},
|
|
844
|
+
)
|
|
845
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
846
|
+
assert "Install mode: fresh" in result.stdout, result.stdout
|
|
847
|
+
|
|
848
|
+
# The .uninstalled.<TS> plist must still be sitting where we left it.
|
|
849
|
+
assert uninstalled_plist.exists(), (
|
|
850
|
+
"--fresh should leave the .uninstalled.<TS> plist untouched"
|
|
851
|
+
)
|
|
852
|
+
# And the live plist path should remain absent — install.sh doesn't
|
|
853
|
+
# create the plist itself; that's install-launchd.sh's job.
|
|
854
|
+
assert not (la_dir / "com.local.claude-coach.plist").exists()
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def test_install_banner_includes_config_preview(tmp_path: Path) -> None:
|
|
858
|
+
"""The post-install banner must surface the /config slash command so
|
|
859
|
+
first-time installers discover the look-customization surface.
|
|
860
|
+
|
|
861
|
+
Pre-2026-05-08 the banner listed /coach off, /coach status, and
|
|
862
|
+
/coach uninstall but omitted /config — the actual discoverability
|
|
863
|
+
gap that prompted the Phase 1 banner rewrite.
|
|
864
|
+
"""
|
|
865
|
+
repo = Path(__file__).resolve().parents[2]
|
|
866
|
+
if not (repo / "install.sh").exists():
|
|
867
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
868
|
+
|
|
869
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
870
|
+
result = _run_install(repo, claude_dir)
|
|
871
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
872
|
+
|
|
873
|
+
assert "/config preview" in result.stdout, (
|
|
874
|
+
"post-install banner must mention /config preview so first-time "
|
|
875
|
+
"installers discover the customization surface"
|
|
876
|
+
)
|
|
877
|
+
assert "/config theme" in result.stdout, (
|
|
878
|
+
"banner should show a theme example (e.g. '/config theme ocean')"
|
|
879
|
+
)
|
|
880
|
+
assert "/config statusline" in result.stdout, (
|
|
881
|
+
"banner should show a statusline-variant example "
|
|
882
|
+
"(e.g. '/config statusline pips')"
|
|
883
|
+
)
|
|
884
|
+
# v0.1.5 regression guard: bracket was removed in v0.1.4 so the
|
|
885
|
+
# variant count is 4. The closeout banner copy drifted and still
|
|
886
|
+
# said "5 variants × 12 themes" through v0.1.4.
|
|
887
|
+
assert "4 variants" in result.stdout, (
|
|
888
|
+
"banner must reflect the v0.1.4 variant count (4 after bracket "
|
|
889
|
+
"was dropped)"
|
|
890
|
+
)
|
|
891
|
+
assert "5 variants" not in result.stdout, (
|
|
892
|
+
"banner is using stale '5 variants' copy — bracket was removed "
|
|
893
|
+
"in v0.1.4"
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def test_install_no_seed_flag_parses_and_banner_reflects_choice(tmp_path: Path) -> None:
|
|
898
|
+
"""--no-seed parses cleanly and the banner acknowledges the explicit
|
|
899
|
+
skip.
|
|
900
|
+
|
|
901
|
+
Today the seed step only fires when --seed is passed, so --no-seed
|
|
902
|
+
is functionally a no-op. It exists to (a) reserve the flag namespace
|
|
903
|
+
for a future TTY-gated seed prompt and (b) let scripted/CI installs
|
|
904
|
+
suppress that future prompt unambiguously.
|
|
905
|
+
"""
|
|
906
|
+
repo = Path(__file__).resolve().parents[2]
|
|
907
|
+
if not (repo / "install.sh").exists():
|
|
908
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
909
|
+
|
|
910
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
911
|
+
result = _run_install(repo, claude_dir, args=["--no-seed"])
|
|
912
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
913
|
+
|
|
914
|
+
# Banner must acknowledge --no-seed so the user sees their flag was honored.
|
|
915
|
+
assert "--no-seed honored" in result.stdout, (
|
|
916
|
+
"banner should acknowledge --no-seed (e.g. 'Seed: --no-seed honored; ...')"
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
# The seed step itself must not have run — the bold 'Seeding profile'
|
|
920
|
+
# header is install.sh's section marker for the seed branch.
|
|
921
|
+
assert "Seeding profile" not in result.stdout, (
|
|
922
|
+
"--no-seed must suppress the seed branch entirely"
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def test_install_rejects_seed_and_no_seed_together(tmp_path: Path) -> None:
|
|
927
|
+
"""--seed and --no-seed are mutually exclusive; conflicting flags
|
|
928
|
+
must fail fast BEFORE any destructive operation.
|
|
929
|
+
|
|
930
|
+
Pinned because the validation point in install.sh runs immediately
|
|
931
|
+
after arg parse and before preflight; a regression that moved it
|
|
932
|
+
past `mv coach → coach.bak.<ts>` would silently destroy state on
|
|
933
|
+
a flag typo.
|
|
934
|
+
"""
|
|
935
|
+
repo = Path(__file__).resolve().parents[2]
|
|
936
|
+
if not (repo / "install.sh").exists():
|
|
937
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
938
|
+
|
|
939
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
940
|
+
result = _run_install(repo, claude_dir, args=["--seed", "--no-seed"])
|
|
941
|
+
assert result.returncode != 0, (
|
|
942
|
+
"install must fail when both --seed and --no-seed are passed"
|
|
943
|
+
)
|
|
944
|
+
combined = (result.stdout + result.stderr).lower()
|
|
945
|
+
assert "mutually exclusive" in combined, (
|
|
946
|
+
"error message must explain why install failed:\n"
|
|
947
|
+
f"stdout: {result.stdout!r}\nstderr: {result.stderr!r}"
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
# Crucially: nothing got written. coach/ must NOT exist because
|
|
951
|
+
# the validation runs before any mv/cp.
|
|
952
|
+
assert not (claude_dir / "coach").exists(), (
|
|
953
|
+
"destructive operations must not run when flag validation fails"
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def test_install_creates_configure_py(tmp_path: Path) -> None:
|
|
958
|
+
"""Phase 2 installer must ship coach/bin/configure.py executable —
|
|
959
|
+
it's the entrypoint the npm wrapper calls for `coach-claw config
|
|
960
|
+
<set|preview|wizard>`."""
|
|
961
|
+
repo = Path(__file__).resolve().parents[2]
|
|
962
|
+
if not (repo / "install.sh").exists():
|
|
963
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
964
|
+
|
|
965
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
966
|
+
result = _run_install(repo, claude_dir)
|
|
967
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
968
|
+
|
|
969
|
+
script = claude_dir / "coach/bin/configure.py"
|
|
970
|
+
assert script.exists(), "configure.py was not installed"
|
|
971
|
+
assert os.access(script, os.X_OK), "configure.py is not executable"
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
# ---------------------------------------------------------------------------
|
|
975
|
+
# Wrap-mode install (v0.1.4)
|
|
976
|
+
# ---------------------------------------------------------------------------
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def test_install_creates_wrap_trampoline_with_substituted_python(tmp_path: Path) -> None:
|
|
980
|
+
"""The CLI wrap trampoline must land in coach/, with `@PY@`
|
|
981
|
+
substituted to the resolved python3 path, and execute bit set.
|
|
982
|
+
Symmetric with default-statusline-command.sh."""
|
|
983
|
+
repo = Path(__file__).resolve().parents[2]
|
|
984
|
+
if not (repo / "install.sh").exists():
|
|
985
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
986
|
+
|
|
987
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
988
|
+
result = _run_install(repo, claude_dir)
|
|
989
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
990
|
+
|
|
991
|
+
trampoline = claude_dir / "coach/default-statusline-wrap-command.sh"
|
|
992
|
+
assert trampoline.exists(), "default-statusline-wrap-command.sh missing"
|
|
993
|
+
assert os.access(trampoline, os.X_OK), "trampoline not executable"
|
|
994
|
+
|
|
995
|
+
contents = trampoline.read_text()
|
|
996
|
+
assert "@PY@" not in contents, "@PY@ placeholder was not substituted"
|
|
997
|
+
py = shutil.which("python3")
|
|
998
|
+
assert py is not None
|
|
999
|
+
assert py in contents, f"resolved python ({py}) not in trampoline contents"
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def test_install_preserves_wrap_markers_on_reinstall(tmp_path: Path) -> None:
|
|
1003
|
+
"""The preserve list must include all wrap markers — losing them on
|
|
1004
|
+
reinstall would either re-wrap an unwrapped user (.statusline-wrap-disabled)
|
|
1005
|
+
or wipe their saved-original (.statusline-wrap.json)."""
|
|
1006
|
+
repo = Path(__file__).resolve().parents[2]
|
|
1007
|
+
if not (repo / "install.sh").exists():
|
|
1008
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
1009
|
+
|
|
1010
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
1011
|
+
first = _run_install(repo, claude_dir)
|
|
1012
|
+
assert first.returncode == 0, first.stdout + first.stderr
|
|
1013
|
+
|
|
1014
|
+
coach_dir = claude_dir / "coach"
|
|
1015
|
+
markers = {
|
|
1016
|
+
".statusline-wrap.json": json.dumps({"original_command": "bash /opt/x.sh"}),
|
|
1017
|
+
".statusline-wrap-disabled": json.dumps({"reason": "user-unwrapped"}),
|
|
1018
|
+
".statusline-wrap-announced": json.dumps({"created_at": "2026-05-09T00:00:00Z"}),
|
|
1019
|
+
".statusline-wrap-duplicate-detected": json.dumps({"created_at": "2026-05-09T00:00:00Z"}),
|
|
1020
|
+
}
|
|
1021
|
+
for name, content in markers.items():
|
|
1022
|
+
(coach_dir / name).write_text(content)
|
|
1023
|
+
|
|
1024
|
+
second = _run_install(repo, claude_dir)
|
|
1025
|
+
assert second.returncode == 0, second.stdout + second.stderr
|
|
1026
|
+
|
|
1027
|
+
for name, expected in markers.items():
|
|
1028
|
+
path = coach_dir / name
|
|
1029
|
+
assert path.exists(), (
|
|
1030
|
+
f"{name} was dropped by reinstall — preserve list at "
|
|
1031
|
+
f"install.sh is missing it"
|
|
1032
|
+
)
|
|
1033
|
+
assert path.read_text() == expected, (
|
|
1034
|
+
f"{name} content mutated by reinstall: was {expected!r}, "
|
|
1035
|
+
f"now {path.read_text()!r}"
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def test_install_helper_runs_before_redundant_backup_cleanup(tmp_path: Path) -> None:
|
|
1040
|
+
"""The wrap-if-claimed helper must invoke BEFORE the redundant-backup
|
|
1041
|
+
prune in install.sh. Otherwise any settings.json mutation from the
|
|
1042
|
+
helper would land outside the .bak.<ts> backup window."""
|
|
1043
|
+
repo = Path(__file__).resolve().parents[2]
|
|
1044
|
+
install_sh = repo / "install.sh"
|
|
1045
|
+
if not install_sh.exists():
|
|
1046
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
1047
|
+
|
|
1048
|
+
text = install_sh.read_text()
|
|
1049
|
+
helper_idx = text.find("statusline_wrap_action.py")
|
|
1050
|
+
cleanup_idx = text.find("settings.json unchanged — discarded redundant backup")
|
|
1051
|
+
assert helper_idx > -1, "wrap helper invocation missing from install.sh"
|
|
1052
|
+
assert cleanup_idx > -1, "redundant-backup-cleanup block missing"
|
|
1053
|
+
assert helper_idx < cleanup_idx, (
|
|
1054
|
+
"wrap-if-claimed helper must run BEFORE the redundant-backup "
|
|
1055
|
+
"cleanup; ordering inverted in install.sh"
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def test_install_auto_wraps_claimed_statusline(tmp_path: Path) -> None:
|
|
1060
|
+
"""End-to-end: install.sh on a settings.json with a non-Coach
|
|
1061
|
+
statusLine command auto-wraps it. Ryan's box scenario — except his
|
|
1062
|
+
is filtered by manual-Coach pre-flight; this fixture uses an
|
|
1063
|
+
unrelated script that doesn't trigger the pre-flight."""
|
|
1064
|
+
repo = Path(__file__).resolve().parents[2]
|
|
1065
|
+
if not (repo / "install.sh").exists():
|
|
1066
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
1067
|
+
|
|
1068
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
1069
|
+
claude_dir.mkdir()
|
|
1070
|
+
user_script = tmp_path / "my-line.sh"
|
|
1071
|
+
user_script.write_text("#!/bin/bash\necho 'plain custom statusline'\n")
|
|
1072
|
+
user_script.chmod(0o755)
|
|
1073
|
+
|
|
1074
|
+
settings = claude_dir / "settings.json"
|
|
1075
|
+
settings.write_text(json.dumps({
|
|
1076
|
+
"statusLine": {"type": "command", "command": f"bash {user_script}"},
|
|
1077
|
+
}))
|
|
1078
|
+
|
|
1079
|
+
result = _run_install(repo, claude_dir)
|
|
1080
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
1081
|
+
|
|
1082
|
+
new_cmd = json.loads(settings.read_text())["statusLine"]["command"]
|
|
1083
|
+
assert "default-statusline-wrap-command.sh" in new_cmd, (
|
|
1084
|
+
f"expected wrap-trampoline in command, got {new_cmd!r}"
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
saved = json.loads((claude_dir / "coach/.statusline-wrap.json").read_text())
|
|
1088
|
+
assert saved["original_command"] == f"bash {user_script}"
|
|
1089
|
+
|
|
1090
|
+
# v0.1.5 regression guard: the generated statusLine.command runs
|
|
1091
|
+
# under bash with shell=True (Claude Code semantics). Pre-v0.1.5,
|
|
1092
|
+
# CLAUDE_DIR with spaces produced an unquoted path that bash split
|
|
1093
|
+
# mid-token: `bash /tmp/.../Claude Dir/coach/...` → ENOENT on
|
|
1094
|
+
# `Dir/coach/...`. shlex.quote on the trampoline path fixes it.
|
|
1095
|
+
# `claude_dir` here is `tmp_path / "Claude Dir"` — exactly the
|
|
1096
|
+
# fixture shape that exposes the bug.
|
|
1097
|
+
bash_path = shutil.which("bash")
|
|
1098
|
+
assert bash_path
|
|
1099
|
+
payload = (
|
|
1100
|
+
'{"model":{"display_name":"opus 4.7"},'
|
|
1101
|
+
'"context_window":{"used_percentage":10}}'
|
|
1102
|
+
)
|
|
1103
|
+
proc = subprocess.run(
|
|
1104
|
+
[bash_path, "-c", new_cmd],
|
|
1105
|
+
input=payload,
|
|
1106
|
+
text=True,
|
|
1107
|
+
stdout=subprocess.PIPE,
|
|
1108
|
+
stderr=subprocess.PIPE,
|
|
1109
|
+
timeout=10,
|
|
1110
|
+
)
|
|
1111
|
+
assert proc.returncode == 0, (
|
|
1112
|
+
f"generated statusLine command failed under bash -c — likely "
|
|
1113
|
+
f"unquoted path with spaces.\ncommand={new_cmd!r}\n"
|
|
1114
|
+
f"stderr={proc.stderr!r}"
|
|
1115
|
+
)
|
|
1116
|
+
assert "No such file or directory" not in proc.stderr, proc.stderr
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def test_install_wrap_helper_respects_optout_marker(tmp_path: Path) -> None:
|
|
1120
|
+
"""If the user previously unwrapped (sticky opt-out marker), reinstall
|
|
1121
|
+
must NOT auto-rewrap them."""
|
|
1122
|
+
repo = Path(__file__).resolve().parents[2]
|
|
1123
|
+
if not (repo / "install.sh").exists():
|
|
1124
|
+
pytest.skip("install.sh is only present in the shareable repo checkout")
|
|
1125
|
+
|
|
1126
|
+
claude_dir = tmp_path / "Claude Dir"
|
|
1127
|
+
# Run install once so coach/ exists.
|
|
1128
|
+
first = _run_install(repo, claude_dir)
|
|
1129
|
+
assert first.returncode == 0, first.stdout + first.stderr
|
|
1130
|
+
|
|
1131
|
+
# Set up a custom statusLine and an opt-out marker.
|
|
1132
|
+
user_script = tmp_path / "my-line.sh"
|
|
1133
|
+
user_script.write_text("#!/bin/bash\necho 'plain'\n")
|
|
1134
|
+
user_script.chmod(0o755)
|
|
1135
|
+
settings = claude_dir / "settings.json"
|
|
1136
|
+
data = json.loads(settings.read_text())
|
|
1137
|
+
data["statusLine"] = {"type": "command", "command": f"bash {user_script}"}
|
|
1138
|
+
settings.write_text(json.dumps(data))
|
|
1139
|
+
(claude_dir / "coach/.statusline-wrap-disabled").write_text(json.dumps({
|
|
1140
|
+
"reason": "user-unwrapped",
|
|
1141
|
+
}))
|
|
1142
|
+
|
|
1143
|
+
second = _run_install(repo, claude_dir)
|
|
1144
|
+
assert second.returncode == 0, second.stdout + second.stderr
|
|
1145
|
+
|
|
1146
|
+
# statusLine still points at the user's script — not auto-rewrapped
|
|
1147
|
+
new_cmd = json.loads(settings.read_text())["statusLine"]["command"]
|
|
1148
|
+
assert new_cmd == f"bash {user_script}", (
|
|
1149
|
+
f"opt-out marker ignored — settings.json was rewrapped: {new_cmd!r}"
|
|
1150
|
+
)
|