@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,360 @@
1
+ """switch_to_plugin.py — flip Coach control from npm CLI to plugin.
2
+
3
+ Pairs with /coach-claw:switch skill. Removes CLI-installed hook entries
4
+ from settings.json, optionally clears the CLI's statusLine, writes a
5
+ marker so /coach-claw:doctor knows the user explicitly switched.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import shlex
12
+ import shutil
13
+ import subprocess
14
+ from pathlib import Path
15
+
16
+ import pytest
17
+
18
+ import switch_to_plugin as sp
19
+
20
+
21
+ def _settings_with_cli_hooks_and_statusline() -> dict:
22
+ return {
23
+ "permissions": {"allow": ["read"]},
24
+ "hooks": {
25
+ "SessionStart": [{"hooks": [{
26
+ "type": "command",
27
+ "command": "/usr/bin/python3 /Users/foo/.claude/hooks/coach-session-start.py",
28
+ "timeout": 3,
29
+ }]}],
30
+ "UserPromptSubmit": [{"hooks": [{
31
+ "type": "command",
32
+ "command": "/usr/bin/python3 /Users/foo/.claude/hooks/coach-user-prompt.py",
33
+ "timeout": 2,
34
+ }]}],
35
+ },
36
+ "statusLine": {
37
+ "type": "command",
38
+ "command": "bash /Users/foo/.claude/coach/default-statusline-command.sh",
39
+ },
40
+ }
41
+
42
+
43
+ def _settings_with_plugin_hooks(plugin_root: str) -> dict:
44
+ cmd = f"{plugin_root}/bin/bootstrap.sh {plugin_root}/hooks/coach-session-start.py"
45
+ return {
46
+ "hooks": {
47
+ "SessionStart": [{"hooks": [{"type": "command", "command": cmd}]}],
48
+ }
49
+ }
50
+
51
+
52
+ @pytest.fixture
53
+ def env_under_plugin(tmp_path, monkeypatch):
54
+ """Set up CLAUDE_PLUGIN_ROOT and a coach state dir."""
55
+ plugin_root = tmp_path / "plugin"
56
+ plugin_root.mkdir()
57
+ coach_dir = tmp_path / "coach"
58
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(plugin_root))
59
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(coach_dir))
60
+ return plugin_root, coach_dir
61
+
62
+
63
+ def test_strips_cli_hooks_and_statusline(tmp_path, env_under_plugin, capsys):
64
+ plugin_root, coach_dir = env_under_plugin
65
+ settings = tmp_path / "settings.json"
66
+ settings.write_text(json.dumps(_settings_with_cli_hooks_and_statusline()))
67
+
68
+ rc = sp.main(["--settings", str(settings)])
69
+ assert rc == 0
70
+
71
+ after = json.loads(settings.read_text())
72
+ # Hook entries gone
73
+ assert "SessionStart" not in after.get("hooks", {})
74
+ assert "UserPromptSubmit" not in after.get("hooks", {})
75
+ # statusLine removed
76
+ assert "statusLine" not in after
77
+ # Unrelated keys preserved
78
+ assert after["permissions"] == {"allow": ["read"]}
79
+ # Marker written
80
+ assert (coach_dir / ".cli-uninstalled-by-plugin").exists()
81
+
82
+ out = capsys.readouterr().out
83
+ assert "Removed 2 CLI hook entries" in out
84
+ assert "Removed CLI statusLine" in out
85
+
86
+
87
+ def test_preserves_plugin_hooks(tmp_path, env_under_plugin):
88
+ """Plugin's own hook entries (which include plugin_root in command)
89
+ must survive the strip."""
90
+ plugin_root, coach_dir = env_under_plugin
91
+ base = _settings_with_cli_hooks_and_statusline()
92
+ plugin_cmd = f"{plugin_root}/bin/bootstrap.sh {plugin_root}/hooks/coach-session-start.py"
93
+ base["hooks"]["SessionStart"][0]["hooks"].append({
94
+ "type": "command",
95
+ "command": plugin_cmd,
96
+ })
97
+ settings = tmp_path / "settings.json"
98
+ settings.write_text(json.dumps(base))
99
+
100
+ sp.main(["--settings", str(settings)])
101
+ after = json.loads(settings.read_text())
102
+ sessions = after["hooks"]["SessionStart"][0]["hooks"]
103
+ # Only plugin command remains
104
+ assert len(sessions) == 1
105
+ assert plugin_cmd in sessions[0]["command"]
106
+
107
+
108
+ def test_preserves_user_custom_statusline(tmp_path, env_under_plugin):
109
+ """If the user has a non-Coach statusLine, leave it alone."""
110
+ plugin_root, _ = env_under_plugin
111
+ settings_data = _settings_with_cli_hooks_and_statusline()
112
+ settings_data["statusLine"] = {
113
+ "type": "command",
114
+ "command": "bash /opt/my-custom-statusline.sh",
115
+ }
116
+ settings = tmp_path / "settings.json"
117
+ settings.write_text(json.dumps(settings_data))
118
+
119
+ sp.main(["--settings", str(settings)])
120
+ after = json.loads(settings.read_text())
121
+ assert after["statusLine"]["command"] == "bash /opt/my-custom-statusline.sh"
122
+
123
+
124
+ def test_noop_when_nothing_to_remove(tmp_path, env_under_plugin, capsys):
125
+ plugin_root, coach_dir = env_under_plugin
126
+ settings = tmp_path / "settings.json"
127
+ settings.write_text(json.dumps(_settings_with_plugin_hooks(str(plugin_root))))
128
+
129
+ rc = sp.main(["--settings", str(settings)])
130
+ assert rc == 0
131
+ out = capsys.readouterr().out
132
+ assert "nothing to do" in out.lower()
133
+ # No marker written when nothing changed
134
+ assert not (coach_dir / ".cli-uninstalled-by-plugin").exists()
135
+
136
+
137
+ def test_dry_run_does_not_write(tmp_path, env_under_plugin, capsys):
138
+ plugin_root, coach_dir = env_under_plugin
139
+ settings = tmp_path / "settings.json"
140
+ raw = json.dumps(_settings_with_cli_hooks_and_statusline())
141
+ settings.write_text(raw)
142
+ mtime_before = settings.stat().st_mtime_ns
143
+
144
+ rc = sp.main(["--settings", str(settings), "--dry-run"])
145
+ assert rc == 0
146
+ assert settings.stat().st_mtime_ns == mtime_before
147
+ assert settings.read_text() == raw
148
+ out = capsys.readouterr().out
149
+ assert "Would remove" in out
150
+ # Marker NOT written on dry-run
151
+ assert not (coach_dir / ".cli-uninstalled-by-plugin").exists()
152
+
153
+
154
+ def test_clears_stale_defer_marker(tmp_path, env_under_plugin):
155
+ """When the user explicitly switches, any prior .plugin-deferred
156
+ marker should be cleared (CLI hooks are gone, plugin is now in
157
+ charge — no reason for the deferred state to linger)."""
158
+ plugin_root, coach_dir = env_under_plugin
159
+ coach_dir.mkdir()
160
+ (coach_dir / ".plugin-deferred").write_text(json.dumps({"reason": "stale"}))
161
+
162
+ settings = tmp_path / "settings.json"
163
+ settings.write_text(json.dumps(_settings_with_cli_hooks_and_statusline()))
164
+
165
+ sp.main(["--settings", str(settings)])
166
+ assert not (coach_dir / ".plugin-deferred").exists()
167
+ assert (coach_dir / ".cli-uninstalled-by-plugin").exists()
168
+
169
+
170
+ def test_settings_missing_returns_1(tmp_path, env_under_plugin, capsys):
171
+ """No settings.json → exit 1 + clear error. Don't create one."""
172
+ rc = sp.main(["--settings", str(tmp_path / "no-such.json")])
173
+ assert rc == 1
174
+ err = capsys.readouterr().err
175
+ assert "not found" in err.lower()
176
+
177
+
178
+ def test_malformed_settings_returns_2(tmp_path, env_under_plugin, capsys):
179
+ settings = tmp_path / "settings.json"
180
+ settings.write_text("{ not valid json")
181
+ rc = sp.main(["--settings", str(settings)])
182
+ assert rc == 2
183
+ err = capsys.readouterr().err
184
+ assert "not valid json" in err.lower() or "json" in err.lower()
185
+
186
+
187
+ def test_atomic_no_partial_write_on_failure(tmp_path, env_under_plugin, monkeypatch):
188
+ """If os.replace raises mid-write, settings.json must not be
189
+ truncated."""
190
+ settings = tmp_path / "settings.json"
191
+ raw = json.dumps(_settings_with_cli_hooks_and_statusline())
192
+ settings.write_text(raw)
193
+ original = json.loads(raw)
194
+
195
+ def boom(*args, **kwargs):
196
+ raise OSError("simulated disk failure")
197
+
198
+ monkeypatch.setattr(sp.os, "replace", boom)
199
+
200
+ with pytest.raises(OSError):
201
+ sp.main(["--settings", str(settings)])
202
+
203
+ # Original file content preserved (atomic semantics).
204
+ after = json.loads(settings.read_text())
205
+ assert after == original
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Wrap-shape policy (v0.1.4)
210
+ # ---------------------------------------------------------------------------
211
+
212
+
213
+ def test_rewrites_cli_wrap_to_plugin_shape(tmp_path, env_under_plugin):
214
+ """ours-wrapped pointing at the CLI trampoline → switch rewrites it
215
+ to point at the plugin's bootstrap.sh + statusline_wrap.py. Saved
216
+ original (`.statusline-wrap.json`) is untouched — coach state is
217
+ shared between distributions."""
218
+ plugin_root, _ = env_under_plugin
219
+ settings = tmp_path / "settings.json"
220
+ settings.write_text(json.dumps({
221
+ "statusLine": {
222
+ "type": "command",
223
+ "command": "bash /Users/foo/.claude/coach/default-statusline-wrap-command.sh",
224
+ },
225
+ }))
226
+ rc = sp.main(["--settings", str(settings)])
227
+ assert rc == 0
228
+
229
+ new_cmd = json.loads(settings.read_text())["statusLine"]["command"]
230
+ assert str(plugin_root) in new_cmd
231
+ assert "bootstrap.sh" in new_cmd
232
+ assert "statusline_wrap.py" in new_cmd
233
+ assert "default-statusline-wrap-command.sh" not in new_cmd
234
+
235
+
236
+ def test_noop_when_already_plugin_wrap_shape(tmp_path, env_under_plugin):
237
+ """If the wrap shape already points at the plugin, switch is a no-op
238
+ (no double-rewrite, no churn)."""
239
+ plugin_root, _ = env_under_plugin
240
+ plugin_cmd = (
241
+ f"{plugin_root}/bin/bootstrap.sh {plugin_root}/bin/statusline_wrap.py"
242
+ )
243
+ settings = tmp_path / "settings.json"
244
+ settings.write_text(json.dumps({
245
+ "statusLine": {"type": "command", "command": plugin_cmd},
246
+ }))
247
+ rc = sp.main(["--settings", str(settings)])
248
+ assert rc == 0
249
+
250
+ after = json.loads(settings.read_text())["statusLine"]["command"]
251
+ assert after == plugin_cmd # exact byte match — no rewrite
252
+
253
+
254
+ def test_leaves_integrated_externally_alone(tmp_path, env_under_plugin):
255
+ """When statusLine is a custom user command and the opt-out marker
256
+ says `already-integrated`, switch must NOT install a default plugin
257
+ statusline — the user already integrates Coach themselves."""
258
+ _, coach_dir = env_under_plugin
259
+ coach_dir.mkdir(parents=True, exist_ok=True)
260
+ (coach_dir / ".statusline-wrap-disabled").write_text(json.dumps({
261
+ "reason": "already-integrated",
262
+ "detected_in": "/Users/foo/.claude/statusline-command.sh",
263
+ }))
264
+ settings = tmp_path / "settings.json"
265
+ settings.write_text(json.dumps({
266
+ "statusLine": {
267
+ "type": "command",
268
+ "command": "bash /Users/foo/.claude/statusline-command.sh",
269
+ },
270
+ }))
271
+ rc = sp.main(["--settings", str(settings)])
272
+ assert rc == 0
273
+
274
+ after = json.loads(settings.read_text())
275
+ # statusLine preserved exactly
276
+ assert after["statusLine"]["command"] == "bash /Users/foo/.claude/statusline-command.sh"
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # Shell-safety of the plugin-shape rewrite (v0.1.6 fix)
281
+ # ---------------------------------------------------------------------------
282
+
283
+
284
+ def test_rewrite_quotes_plugin_paths_with_spaces(tmp_path, monkeypatch):
285
+ """Same defect class as v0.1.5's _build_wrapper_command +
286
+ _desired_entry: a CLAUDE_PLUGIN_ROOT containing spaces must produce
287
+ a settings.json command string that bash parses as exactly two
288
+ tokens. Pre-v0.1.6 the unquoted f-string interpolation generated
289
+ `/tmp/.../Plugin Dir/bin/bootstrap.sh /tmp/.../Plugin Dir/bin/...`
290
+ which shlex.split breaks into 4+ tokens, ENOENT'ing under bash."""
291
+ plugin_root = tmp_path / "Plugin Dir With Spaces"
292
+ (plugin_root / "bin").mkdir(parents=True)
293
+ coach_dir = tmp_path / "coach"
294
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(plugin_root))
295
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(coach_dir))
296
+
297
+ settings = tmp_path / "settings.json"
298
+ settings.write_text(json.dumps({
299
+ "statusLine": {
300
+ "type": "command",
301
+ "command": "bash /Users/foo/.claude/coach/default-statusline-wrap-command.sh",
302
+ },
303
+ }))
304
+ rc = sp.main(["--settings", str(settings)])
305
+ assert rc == 0
306
+
307
+ new_cmd = json.loads(settings.read_text())["statusLine"]["command"]
308
+ tokens = shlex.split(new_cmd)
309
+ assert len(tokens) == 2, (
310
+ f"plugin_root paths split by bash; tokens={tokens!r} cmd={new_cmd!r}"
311
+ )
312
+ assert tokens[0] == str(plugin_root / "bin" / "bootstrap.sh")
313
+ assert tokens[1] == str(plugin_root / "bin" / "statusline_wrap.py")
314
+
315
+
316
+ def test_rewrite_command_executes_under_bash_with_spaces(tmp_path, monkeypatch):
317
+ """End-to-end: the rewritten command must actually run under
318
+ `bash -c` without ENOENT when CLAUDE_PLUGIN_ROOT has a space.
319
+ Mirrors test_install_auto_wraps_claimed_statusline's exec guard
320
+ from v0.1.5."""
321
+ plugin_root = tmp_path / "Plugin Dir With Spaces"
322
+ plugin_bin = plugin_root / "bin"
323
+ plugin_bin.mkdir(parents=True)
324
+ # Stand-in scripts so bash actually has something to exec on the
325
+ # generated command path. Doesn't matter what they print — just
326
+ # has to be findable + executable.
327
+ (plugin_bin / "bootstrap.sh").write_text("#!/bin/bash\nexec \"$@\"\n")
328
+ (plugin_bin / "statusline_wrap.py").write_text(
329
+ "#!/usr/bin/env python3\nprint('ok')\n"
330
+ )
331
+ (plugin_bin / "bootstrap.sh").chmod(0o755)
332
+ (plugin_bin / "statusline_wrap.py").chmod(0o755)
333
+
334
+ coach_dir = tmp_path / "coach"
335
+ monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(plugin_root))
336
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(coach_dir))
337
+
338
+ settings = tmp_path / "settings.json"
339
+ settings.write_text(json.dumps({
340
+ "statusLine": {
341
+ "type": "command",
342
+ "command": "bash /tmp/cli/coach/default-statusline-wrap-command.sh",
343
+ },
344
+ }))
345
+ sp.main(["--settings", str(settings)])
346
+
347
+ new_cmd = json.loads(settings.read_text())["statusLine"]["command"]
348
+ bash_path = shutil.which("bash")
349
+ assert bash_path
350
+ proc = subprocess.run(
351
+ [bash_path, "-c", new_cmd],
352
+ capture_output=True,
353
+ text=True,
354
+ timeout=10,
355
+ )
356
+ assert proc.returncode == 0, (
357
+ f"rewritten command failed under bash -c — likely unquoted "
358
+ f"path with spaces.\ncommand={new_cmd!r}\nstderr={proc.stderr!r}"
359
+ )
360
+ assert "No such file or directory" not in proc.stderr, proc.stderr
@@ -0,0 +1,104 @@
1
+ """themes.py — every theme is exactly 50 unique single-word entries."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ import themes
7
+
8
+
9
+ def test_all_themes_have_exactly_50_entries():
10
+ for name, ladder in themes.THEMES.items():
11
+ assert len(ladder) == 50, f"{name} has {len(ladder)} entries, expected 50"
12
+
13
+
14
+ def test_no_duplicate_names_within_a_theme():
15
+ for name, ladder in themes.THEMES.items():
16
+ assert len(set(ladder)) == 50, f"{name} has duplicate level names"
17
+
18
+
19
+ def test_default_theme_preserves_v0_2_0_lader():
20
+ """Backwards-compat: existing installs without .user_config.json read
21
+ the default theme (`craft`), which must match the v0.2.0 ladder so
22
+ no level-name drift on upgrade."""
23
+ expected_first_eight = [
24
+ "Drafter", "Iterator", "Builder", "Shipper",
25
+ "Craftsman", "Architect", "Virtuoso", "Sensei",
26
+ ]
27
+ assert themes.THEME_CRAFT[:8] == expected_first_eight
28
+ assert themes.THEME_CRAFT[-1] == "Origin"
29
+
30
+
31
+ def test_get_ladder_falls_back_to_default_on_unknown():
32
+ assert themes.get_ladder("does-not-exist") == themes.THEMES[themes.DEFAULT_THEME]
33
+
34
+
35
+ def test_list_themes_puts_default_first():
36
+ keys = themes.list_themes()
37
+ assert keys[0] == themes.DEFAULT_THEME
38
+ assert sorted(keys) == sorted(themes.THEMES.keys())
39
+
40
+
41
+ def test_theme_names_are_valid_identifiers():
42
+ """Each level name should be a single word (no spaces, no leading
43
+ digits) so it composes cleanly inside the statusline variants."""
44
+ pattern = re.compile(r"^[A-Za-z][A-Za-z\-]*$")
45
+ for name, ladder in themes.THEMES.items():
46
+ for entry in ladder:
47
+ assert pattern.match(entry), (
48
+ f"{name} contains invalid name {entry!r}"
49
+ )
50
+
51
+
52
+ def test_full_theme_lineup_is_present():
53
+ """Pin the v0.3.0 expanded theme set: 4 abstract + 8 pop-culture = 12.
54
+ Drop / rename / add to this assertion when the set genuinely changes."""
55
+ expected = {
56
+ # abstract (mythic intentionally dropped vs early v0.3.0 draft)
57
+ "craft", "forge", "cosmic", "ocean",
58
+ # pop-culture
59
+ "skyrim", "marvel", "dc", "finalfantasy",
60
+ "military", "lotr", "starwars", "hacker",
61
+ }
62
+ assert set(themes.THEMES.keys()) == expected, (
63
+ f"theme set drifted from expected: "
64
+ f"missing={expected - themes.THEMES.keys()}, "
65
+ f"extra={themes.THEMES.keys() - expected}"
66
+ )
67
+
68
+
69
+ def test_pop_culture_themes_exclude_franchise_coined_words():
70
+ """Brand-safety pin. None of the franchise-coined neologisms or named
71
+ characters listed below should appear in any pop-culture ladder. This
72
+ is a regression guard, not exhaustive — see themes.py docstring for
73
+ the full policy."""
74
+ forbidden = {
75
+ # Bethesda / Skyrim coinages
76
+ "Dovahkiin", "Daedra", "Aedra", "Daedric", "Aedric", "Talos",
77
+ "Alduin", "Paarthurnax", "Akatosh", "Tamriel",
78
+ # Marvel character / trademarked group names
79
+ "SpiderMan", "IronMan", "Wolverine", "Avengers", "Vibranium",
80
+ "Adamantium", "OneAboveAll",
81
+ # DC character / trademarked group names
82
+ "Batman", "Superman", "GreenLantern", "JusticeLeague",
83
+ "Krypton", "Kryptonian",
84
+ # Final Fantasy specific (broader summons left in — public-domain
85
+ # mythology — but franchise-coined character/job terms excluded)
86
+ "OnionKnight", "Cetra", "Sephiroth", "Cloud", "Tidus",
87
+ "Cidolfus", "BlackMage", "WhiteMage", "RedMage",
88
+ # Tolkien coinages
89
+ "Hobbit", "Maiar", "Valar", "Eru", "Numenor", "Mordor",
90
+ "Shire", "Gondor", "Rohan", "Frodo", "Aragorn", "Gandalf",
91
+ "Istari",
92
+ # Star Wars coinages
93
+ "Jedi", "Sith", "Padawan", "Mandalorian", "Yoda", "Vader",
94
+ "Skywalker", # Skywalker is a SW character; "Skywarden" is fine
95
+ # Trademarked product names in the dev-culture theme
96
+ "Linux", "Unix", "Microsoft", "Google", "Apple",
97
+ "BellLabs", "Knuth", "Torvalds", "Stallman",
98
+ }
99
+ for theme_name, ladder in themes.THEMES.items():
100
+ for entry in ladder:
101
+ assert entry not in forbidden, (
102
+ f"theme {theme_name!r} contains forbidden franchise-coined "
103
+ f"name {entry!r} — see themes.py docstring 'Brand safety'"
104
+ )
@@ -0,0 +1,160 @@
1
+ """user_config.py — defaults, validation, atomic writes."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ import user_config
10
+
11
+
12
+ @pytest.fixture
13
+ def isolated_config(tmp_path, monkeypatch):
14
+ """Redirect config to a tmp dir via COACH_CONFIG_DIR so tests don't
15
+ touch the real user config. Uses the public env-var contract that
16
+ the npm wrapper / configure.py also rely on."""
17
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(tmp_path))
18
+ return tmp_path / ".user_config.json"
19
+
20
+
21
+ def test_load_returns_defaults_when_file_missing(isolated_config):
22
+ cfg = user_config.load()
23
+ assert cfg["statusline_variant"] == "crystal"
24
+ assert cfg["theme"] == "craft"
25
+ assert cfg["elo_min"] == 1000
26
+ assert cfg["elo_max"] == 2800
27
+
28
+
29
+ def test_load_corrupt_file_falls_back_to_defaults(isolated_config):
30
+ isolated_config.write_text("{not valid json")
31
+ cfg = user_config.load()
32
+ assert cfg["statusline_variant"] == "crystal"
33
+
34
+
35
+ def test_load_partial_file_fills_in_defaults(isolated_config):
36
+ isolated_config.write_text(json.dumps({"theme": "ocean"}))
37
+ cfg = user_config.load()
38
+ assert cfg["theme"] == "ocean"
39
+ assert cfg["statusline_variant"] == "crystal" # default
40
+ assert cfg["elo_min"] == 1000
41
+
42
+
43
+ def test_save_writes_atomically(isolated_config):
44
+ user_config.save({"statusline_variant": "pips", "theme": "ocean"})
45
+ raw = json.loads(isolated_config.read_text())
46
+ assert raw["statusline_variant"] == "pips"
47
+ assert raw["theme"] == "ocean"
48
+ # atomic — no leftover .tmp files
49
+ leftovers = list(isolated_config.parent.glob(".user_config.json.*.tmp"))
50
+ assert leftovers == [], leftovers
51
+
52
+
53
+ def test_save_rejects_unknown_variant(isolated_config):
54
+ with pytest.raises(ValueError, match="statusline_variant"):
55
+ user_config.save({"statusline_variant": "rainbow"})
56
+
57
+
58
+ def test_save_rejects_unknown_theme(isolated_config):
59
+ with pytest.raises(ValueError, match="theme"):
60
+ user_config.save({"theme": "scifi"})
61
+
62
+
63
+ def test_save_rejects_invalid_elo_range(isolated_config):
64
+ with pytest.raises(ValueError, match="elo"):
65
+ user_config.save({"elo_min": 2000, "elo_max": 1000})
66
+
67
+
68
+ def test_update_persists_and_returns_full_config(isolated_config):
69
+ cfg = user_config.update(theme="forge")
70
+ assert cfg["theme"] == "forge"
71
+ assert cfg["statusline_variant"] == "crystal"
72
+ # round-trip
73
+ again = user_config.load()
74
+ assert again["theme"] == "forge"
75
+
76
+
77
+ def test_load_ignores_invalid_field_values(isolated_config):
78
+ """File on disk contains a typo'd theme — load should silently fall
79
+ back to the default for that field rather than crash."""
80
+ isolated_config.write_text(json.dumps({
81
+ "schema_version": 1,
82
+ "statusline_variant": "crystal",
83
+ "theme": "rainbow", # invalid
84
+ "elo_min": 1000,
85
+ "elo_max": 2800,
86
+ }))
87
+ cfg = user_config.load()
88
+ assert cfg["theme"] == "craft" # default applied silently
89
+
90
+
91
+ def test_get_variant_get_theme_get_elo_range_helpers(isolated_config):
92
+ user_config.save({
93
+ "statusline_variant": "pips",
94
+ "theme": "skyrim",
95
+ "elo_min": 800,
96
+ "elo_max": 3000,
97
+ })
98
+ assert user_config.get_variant() == "pips"
99
+ assert user_config.get_theme() == "skyrim"
100
+ assert user_config.get_elo_range() == (800, 3000)
101
+
102
+
103
+ def test_config_path_respects_coach_config_dir_env(tmp_path, monkeypatch):
104
+ """COACH_CONFIG_DIR env var redirects writes to a custom directory.
105
+
106
+ This is the contract that lets `npx coach-claw config` work against a
107
+ custom CLAUDE_DIR install — the npm wrapper exports COACH_CONFIG_DIR
108
+ so the Python entrypoint resolves the right path. Without this, a
109
+ user with `CLAUDE_DIR=/srv/foo ./install.sh` then `coach-claw config
110
+ set --theme ocean` would write to ~/.claude/coach/.user_config.json
111
+ instead of /srv/foo/coach/.user_config.json.
112
+ """
113
+ custom_dir = tmp_path / "custom-coach-dir"
114
+ custom_dir.mkdir()
115
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(custom_dir))
116
+
117
+ user_config.save({"theme": "ocean", "statusline_variant": "pips"})
118
+
119
+ expected = custom_dir / ".user_config.json"
120
+ assert expected.exists(), (
121
+ f"save() should have written to {expected} when COACH_CONFIG_DIR "
122
+ f"is set, but the file is missing. Files in dir: "
123
+ f"{list(custom_dir.iterdir())}"
124
+ )
125
+ payload = json.loads(expected.read_text())
126
+ assert payload["theme"] == "ocean"
127
+ assert payload["statusline_variant"] == "pips"
128
+
129
+ # And the default path must NOT have been written.
130
+ default_path = Path.home() / ".claude" / "coach" / ".user_config.json"
131
+ if default_path.exists():
132
+ # If the user actually has a config file in their real home, this
133
+ # check is moot — but we can at least verify the default path
134
+ # wasn't *just* written by this test (mtime comparison).
135
+ # Skip the negative assertion in that case.
136
+ pass
137
+
138
+
139
+ def test_config_path_resolves_per_call(tmp_path, monkeypatch):
140
+ """Path resolution happens at every read/write, not at import time —
141
+ so tests / wrappers can change COACH_CONFIG_DIR mid-process and
142
+ subsequent calls honor the new path."""
143
+ dir_a = tmp_path / "a"
144
+ dir_a.mkdir()
145
+ dir_b = tmp_path / "b"
146
+ dir_b.mkdir()
147
+
148
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(dir_a))
149
+ user_config.save({"theme": "ocean"})
150
+ assert (dir_a / ".user_config.json").exists()
151
+
152
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(dir_b))
153
+ user_config.save({"theme": "skyrim"})
154
+ assert (dir_b / ".user_config.json").exists()
155
+ payload = json.loads((dir_b / ".user_config.json").read_text())
156
+ assert payload["theme"] == "skyrim"
157
+
158
+ # The first dir's file is untouched
159
+ payload_a = json.loads((dir_a / ".user_config.json").read_text())
160
+ assert payload_a["theme"] == "ocean"