@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,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"
|