@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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +311 -0
  3. package/coach/README.md +99 -0
  4. package/coach/bin/aggregate_facets.py +274 -0
  5. package/coach/bin/analyze.py +678 -0
  6. package/coach/bin/bank.py +247 -0
  7. package/coach/bin/banner_themes.py +645 -0
  8. package/coach/bin/coach_paths.py +33 -0
  9. package/coach/bin/coexistence_check.py +129 -0
  10. package/coach/bin/configure.py +245 -0
  11. package/coach/bin/cron_check.py +81 -0
  12. package/coach/bin/default_statusline.py +135 -0
  13. package/coach/bin/doctor.py +663 -0
  14. package/coach/bin/insights-llm.sh +264 -0
  15. package/coach/bin/insights.sh +163 -0
  16. package/coach/bin/insights_window.py +111 -0
  17. package/coach/bin/marker_io.py +154 -0
  18. package/coach/bin/merge.py +671 -0
  19. package/coach/bin/redact.py +86 -0
  20. package/coach/bin/render_env.py +148 -0
  21. package/coach/bin/reward_hints.py +87 -0
  22. package/coach/bin/run-insights.sh +20 -0
  23. package/coach/bin/run_with_lock.py +85 -0
  24. package/coach/bin/scoring.py +260 -0
  25. package/coach/bin/skill_inventory.py +215 -0
  26. package/coach/bin/stats.py +459 -0
  27. package/coach/bin/status.py +293 -0
  28. package/coach/bin/statusline_self_patch.py +205 -0
  29. package/coach/bin/statusline_variants.py +146 -0
  30. package/coach/bin/statusline_wrap.py +244 -0
  31. package/coach/bin/statusline_wrap_action.py +460 -0
  32. package/coach/bin/switch_to_plugin.py +256 -0
  33. package/coach/bin/themes.py +256 -0
  34. package/coach/bin/user_config.py +176 -0
  35. package/coach/bin/xp_accounting.py +98 -0
  36. package/coach/changelog.md +4 -0
  37. package/coach/default-statusline-command.sh +19 -0
  38. package/coach/default-statusline-wrap-command.sh +15 -0
  39. package/coach/profile.yaml +37 -0
  40. package/coach/tests/conftest.py +13 -0
  41. package/coach/tests/test_aggregate_facets.py +379 -0
  42. package/coach/tests/test_analyze_aggregate.py +153 -0
  43. package/coach/tests/test_analyze_redaction.py +105 -0
  44. package/coach/tests/test_analyze_strengths.py +165 -0
  45. package/coach/tests/test_bank_atomic_write.py +61 -0
  46. package/coach/tests/test_bank_concurrency.py +126 -0
  47. package/coach/tests/test_banner_themes.py +981 -0
  48. package/coach/tests/test_celebrate_dedup.py +409 -0
  49. package/coach/tests/test_coach_paths.py +50 -0
  50. package/coach/tests/test_coexistence_check.py +128 -0
  51. package/coach/tests/test_configure.py +258 -0
  52. package/coach/tests/test_cron_check.py +118 -0
  53. package/coach/tests/test_cron_nudge_hook.py +134 -0
  54. package/coach/tests/test_detection_parity.py +105 -0
  55. package/coach/tests/test_doctor.py +595 -0
  56. package/coach/tests/test_hook_bespoke_dispatch.py +288 -0
  57. package/coach/tests/test_hook_module_resolution.py +116 -0
  58. package/coach/tests/test_hook_relevance.py +996 -0
  59. package/coach/tests/test_hook_render_env.py +364 -0
  60. package/coach/tests/test_hook_session_id_guard.py +160 -0
  61. package/coach/tests/test_insights_llm.py +759 -0
  62. package/coach/tests/test_insights_llm_venv_path.py +109 -0
  63. package/coach/tests/test_insights_window.py +237 -0
  64. package/coach/tests/test_install.py +1150 -0
  65. package/coach/tests/test_install_pyyaml_fallback.py +142 -0
  66. package/coach/tests/test_marker_consumption.py +167 -0
  67. package/coach/tests/test_marker_writer_locking.py +305 -0
  68. package/coach/tests/test_merge.py +413 -0
  69. package/coach/tests/test_no_broken_mktemp.py +90 -0
  70. package/coach/tests/test_render_env.py +137 -0
  71. package/coach/tests/test_render_env_glyphs.py +119 -0
  72. package/coach/tests/test_reward_hints.py +59 -0
  73. package/coach/tests/test_scoring.py +147 -0
  74. package/coach/tests/test_session_start_weekly_trigger.py +92 -0
  75. package/coach/tests/test_skill_inventory.py +368 -0
  76. package/coach/tests/test_stats_hybrid.py +142 -0
  77. package/coach/tests/test_status_accounting.py +41 -0
  78. package/coach/tests/test_statusline_failsafe.py +70 -0
  79. package/coach/tests/test_statusline_self_patch.py +261 -0
  80. package/coach/tests/test_statusline_variants.py +110 -0
  81. package/coach/tests/test_statusline_wrap.py +196 -0
  82. package/coach/tests/test_statusline_wrap_action.py +408 -0
  83. package/coach/tests/test_switch_to_plugin.py +360 -0
  84. package/coach/tests/test_themes.py +104 -0
  85. package/coach/tests/test_user_config.py +160 -0
  86. package/coach/tests/test_wrap_announce_hook.py +130 -0
  87. package/coach/tests/test_xp_accounting.py +55 -0
  88. package/hooks/coach-session-start.py +536 -0
  89. package/hooks/coach-user-prompt.py +2288 -0
  90. package/install-launchd.sh +102 -0
  91. package/install.sh +597 -0
  92. package/launchd/com.local.claude-coach.plist.template +34 -0
  93. package/launchd/run-insights.sh +20 -0
  94. package/npm/coach-claw.js +259 -0
  95. package/package.json +52 -0
  96. package/requirements.txt +11 -0
  97. package/settings-snippet.json +31 -0
  98. package/skills/coach/SKILL.md +107 -0
  99. package/skills/coach-insights/SKILL.md +78 -0
  100. 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
+ )