@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,595 @@
|
|
|
1
|
+
"""Tests for coach/bin/doctor.py — the plugin diagnostic surface.
|
|
2
|
+
|
|
3
|
+
Each probe is exercised via monkeypatching env / files / subprocess, so
|
|
4
|
+
the test suite never depends on the running box's actual plugin install,
|
|
5
|
+
settings.json, launchd state, or venv.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from unittest.mock import MagicMock, patch
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
import doctor
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# probe_plugin_install
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
def test_plugin_install_absent_when_no_installed_plugins_json(tmp_path, monkeypatch):
|
|
24
|
+
monkeypatch.setattr(doctor, "INSTALLED_PLUGINS_PATH", tmp_path / "missing.json")
|
|
25
|
+
r = doctor.probe_plugin_install()
|
|
26
|
+
assert r["status"] == "absent"
|
|
27
|
+
assert r["entries"] == []
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_plugin_install_absent_when_no_coach_claw_entries(tmp_path, monkeypatch):
|
|
31
|
+
p = tmp_path / "installed_plugins.json"
|
|
32
|
+
p.write_text(json.dumps({
|
|
33
|
+
"version": 2,
|
|
34
|
+
"plugins": {"some-other@market": [{"version": "1.0", "scope": "user"}]},
|
|
35
|
+
}))
|
|
36
|
+
monkeypatch.setattr(doctor, "INSTALLED_PLUGINS_PATH", p)
|
|
37
|
+
r = doctor.probe_plugin_install()
|
|
38
|
+
assert r["status"] == "absent"
|
|
39
|
+
assert "no coach-claw entries" in r["detail"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_plugin_install_ok_with_single_entry(tmp_path, monkeypatch):
|
|
43
|
+
p = tmp_path / "installed_plugins.json"
|
|
44
|
+
p.write_text(json.dumps({
|
|
45
|
+
"version": 2,
|
|
46
|
+
"plugins": {
|
|
47
|
+
"coach-claw@coach-claw-plugins": [
|
|
48
|
+
{
|
|
49
|
+
"scope": "user",
|
|
50
|
+
"installPath": "/path/to/plugin/0.1.2",
|
|
51
|
+
"version": "0.1.2",
|
|
52
|
+
"lastUpdated": "2026-05-09T00:00:00Z",
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
}))
|
|
57
|
+
monkeypatch.setattr(doctor, "INSTALLED_PLUGINS_PATH", p)
|
|
58
|
+
r = doctor.probe_plugin_install()
|
|
59
|
+
assert r["status"] == "ok"
|
|
60
|
+
assert len(r["entries"]) == 1
|
|
61
|
+
e = r["entries"][0]
|
|
62
|
+
assert e["plugin_id"] == "coach-claw@coach-claw-plugins"
|
|
63
|
+
assert e["marketplace"] == "coach-claw-plugins"
|
|
64
|
+
assert e["version"] == "0.1.2"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_plugin_install_warn_when_multiple_entries(tmp_path, monkeypatch):
|
|
68
|
+
"""Two marketplaces serving coach-claw is suspicious; report warn."""
|
|
69
|
+
p = tmp_path / "installed_plugins.json"
|
|
70
|
+
p.write_text(json.dumps({
|
|
71
|
+
"version": 2,
|
|
72
|
+
"plugins": {
|
|
73
|
+
"coach-claw@coach-claw-plugins": [{"version": "0.1.2", "scope": "user"}],
|
|
74
|
+
"coach-claw@my-fork": [{"version": "0.1.0", "scope": "user"}],
|
|
75
|
+
},
|
|
76
|
+
}))
|
|
77
|
+
monkeypatch.setattr(doctor, "INSTALLED_PLUGINS_PATH", p)
|
|
78
|
+
r = doctor.probe_plugin_install()
|
|
79
|
+
assert r["status"] == "warn"
|
|
80
|
+
assert len(r["entries"]) == 2
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_plugin_install_error_on_malformed_json(tmp_path, monkeypatch):
|
|
84
|
+
p = tmp_path / "installed_plugins.json"
|
|
85
|
+
p.write_text("{not json")
|
|
86
|
+
monkeypatch.setattr(doctor, "INSTALLED_PLUGINS_PATH", p)
|
|
87
|
+
r = doctor.probe_plugin_install()
|
|
88
|
+
assert r["status"] == "error"
|
|
89
|
+
assert r["entries"] == []
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# probe_coexistence
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def test_coexistence_active_when_no_marker(tmp_path):
|
|
97
|
+
r = doctor.probe_coexistence(tmp_path)
|
|
98
|
+
assert r["status"] == "active"
|
|
99
|
+
assert r["deferred_at"] == ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_coexistence_deferred_when_marker_present(tmp_path):
|
|
103
|
+
marker = tmp_path / ".plugin-deferred"
|
|
104
|
+
marker.write_text(json.dumps({
|
|
105
|
+
"deferred_at": "2026-05-09T00:00:00Z",
|
|
106
|
+
"reason": "cli-hooks-detected",
|
|
107
|
+
}))
|
|
108
|
+
r = doctor.probe_coexistence(tmp_path)
|
|
109
|
+
assert r["status"] == "deferred"
|
|
110
|
+
assert r["deferred_at"] == "2026-05-09T00:00:00Z"
|
|
111
|
+
assert r["reason"] == "cli-hooks-detected"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_coexistence_surfaces_cli_removed_marker(tmp_path):
|
|
115
|
+
(tmp_path / ".plugin-deferred").write_text(json.dumps({"deferred_at": "x", "reason": "y"}))
|
|
116
|
+
(tmp_path / ".cli-uninstalled-by-plugin").write_text(json.dumps({"uninstalled_at": "z"}))
|
|
117
|
+
r = doctor.probe_coexistence(tmp_path)
|
|
118
|
+
assert r["status"] == "deferred"
|
|
119
|
+
assert r["cli_removed_marker"] is not None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_coexistence_handles_unparseable_marker(tmp_path):
|
|
123
|
+
"""Malformed marker JSON shouldn't blow up the probe."""
|
|
124
|
+
(tmp_path / ".plugin-deferred").write_text("{not json")
|
|
125
|
+
r = doctor.probe_coexistence(tmp_path)
|
|
126
|
+
assert r["status"] == "deferred"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# _classify_statusline + probe_statusline
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def test_classify_plugin_statusline():
|
|
134
|
+
r = doctor._classify_statusline({
|
|
135
|
+
"type": "command",
|
|
136
|
+
"command": "/path/to/plugin/bin/bootstrap.sh /path/to/plugin/bin/default_statusline.py",
|
|
137
|
+
})
|
|
138
|
+
assert r["ownership"] == "ours-plugin"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_classify_cli_statusline():
|
|
142
|
+
r = doctor._classify_statusline({
|
|
143
|
+
"type": "command",
|
|
144
|
+
"command": "bash ~/.claude/coach/default-statusline-command.sh",
|
|
145
|
+
})
|
|
146
|
+
assert r["ownership"] == "ours-cli"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_classify_user_custom_statusline():
|
|
150
|
+
r = doctor._classify_statusline({
|
|
151
|
+
"type": "command",
|
|
152
|
+
"command": "bash ~/.claude/statusline-command.sh",
|
|
153
|
+
})
|
|
154
|
+
assert r["ownership"] == "claimed"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_classify_absent_statusline():
|
|
158
|
+
assert doctor._classify_statusline(None)["ownership"] == "absent"
|
|
159
|
+
assert doctor._classify_statusline("not-a-dict")["ownership"] == "absent"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_probe_statusline_absent_when_no_settings(tmp_path, monkeypatch):
|
|
163
|
+
monkeypatch.setattr(doctor, "SETTINGS_PATH", tmp_path / "missing.json")
|
|
164
|
+
r = doctor.probe_statusline()
|
|
165
|
+
assert r["status"] == "absent"
|
|
166
|
+
assert r["ownership"] == "absent"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_probe_statusline_ok_when_plugin_owned(tmp_path, monkeypatch):
|
|
170
|
+
settings = tmp_path / "settings.json"
|
|
171
|
+
settings.write_text(json.dumps({
|
|
172
|
+
"statusLine": {
|
|
173
|
+
"type": "command",
|
|
174
|
+
"command": "/p/bin/bootstrap.sh /p/bin/default_statusline.py",
|
|
175
|
+
},
|
|
176
|
+
}))
|
|
177
|
+
monkeypatch.setattr(doctor, "SETTINGS_PATH", settings)
|
|
178
|
+
r = doctor.probe_statusline()
|
|
179
|
+
assert r["status"] == "ok"
|
|
180
|
+
assert r["ownership"] == "ours-plugin"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_probe_statusline_claimed_when_user_custom(tmp_path, monkeypatch):
|
|
184
|
+
settings = tmp_path / "settings.json"
|
|
185
|
+
settings.write_text(json.dumps({
|
|
186
|
+
"statusLine": {"type": "command", "command": "bash ~/.claude/my-line.sh"},
|
|
187
|
+
}))
|
|
188
|
+
monkeypatch.setattr(doctor, "SETTINGS_PATH", settings)
|
|
189
|
+
r = doctor.probe_statusline()
|
|
190
|
+
assert r["status"] == "claimed"
|
|
191
|
+
assert r["ownership"] == "claimed"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_probe_statusline_error_on_malformed_settings(tmp_path, monkeypatch):
|
|
195
|
+
settings = tmp_path / "settings.json"
|
|
196
|
+
settings.write_text("{not json")
|
|
197
|
+
monkeypatch.setattr(doctor, "SETTINGS_PATH", settings)
|
|
198
|
+
r = doctor.probe_statusline()
|
|
199
|
+
assert r["status"] == "error"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# probe_cron — light wrapper test (the underlying cron_check has its own suite)
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def test_probe_cron_ok_when_registered(monkeypatch):
|
|
207
|
+
monkeypatch.setattr(doctor, "is_cron_registered", lambda: True)
|
|
208
|
+
monkeypatch.setattr(doctor.platform, "system", lambda: "Darwin")
|
|
209
|
+
r = doctor.probe_cron()
|
|
210
|
+
assert r["status"] == "ok"
|
|
211
|
+
assert r["registered"] is True
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_probe_cron_missing_when_not_registered(monkeypatch):
|
|
215
|
+
monkeypatch.setattr(doctor, "is_cron_registered", lambda: False)
|
|
216
|
+
monkeypatch.setattr(doctor.platform, "system", lambda: "Darwin")
|
|
217
|
+
r = doctor.probe_cron()
|
|
218
|
+
assert r["status"] == "missing"
|
|
219
|
+
assert r["registered"] is False
|
|
220
|
+
assert "launchd" in r["detail"]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_probe_cron_skipped_on_unsupported_platform(monkeypatch):
|
|
224
|
+
monkeypatch.setattr(doctor.platform, "system", lambda: "Windows")
|
|
225
|
+
r = doctor.probe_cron()
|
|
226
|
+
assert r["status"] == "skipped"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# probe_venv
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def test_probe_venv_missing_when_no_python(tmp_path, monkeypatch):
|
|
234
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path))
|
|
235
|
+
r = doctor.probe_venv()
|
|
236
|
+
assert r["status"] == "missing"
|
|
237
|
+
assert "no python3" in r["detail"]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_probe_venv_ok_when_yaml_imports(tmp_path, monkeypatch):
|
|
241
|
+
venv_bin = tmp_path / "venv" / "bin"
|
|
242
|
+
venv_bin.mkdir(parents=True)
|
|
243
|
+
py = venv_bin / "python3"
|
|
244
|
+
py.write_text("#!/bin/sh\n")
|
|
245
|
+
py.chmod(0o755)
|
|
246
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path))
|
|
247
|
+
|
|
248
|
+
def fake_run(*args, **kwargs):
|
|
249
|
+
return MagicMock(returncode=0, stdout="6.0.3\n", stderr="")
|
|
250
|
+
monkeypatch.setattr(doctor.subprocess, "run", fake_run)
|
|
251
|
+
r = doctor.probe_venv()
|
|
252
|
+
assert r["status"] == "ok"
|
|
253
|
+
assert r["yaml_version"] == "6.0.3"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_probe_venv_broken_when_yaml_import_fails(tmp_path, monkeypatch):
|
|
257
|
+
venv_bin = tmp_path / "venv" / "bin"
|
|
258
|
+
venv_bin.mkdir(parents=True)
|
|
259
|
+
py = venv_bin / "python3"
|
|
260
|
+
py.write_text("#!/bin/sh\n")
|
|
261
|
+
py.chmod(0o755)
|
|
262
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path))
|
|
263
|
+
|
|
264
|
+
def fake_run(*args, **kwargs):
|
|
265
|
+
return MagicMock(returncode=1, stdout="", stderr="ModuleNotFoundError: yaml\n")
|
|
266
|
+
monkeypatch.setattr(doctor.subprocess, "run", fake_run)
|
|
267
|
+
r = doctor.probe_venv()
|
|
268
|
+
assert r["status"] == "broken"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
# remove_statusline
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
def test_remove_statusline_no_op_when_no_settings(tmp_path, monkeypatch):
|
|
276
|
+
monkeypatch.setattr(doctor, "SETTINGS_PATH", tmp_path / "missing.json")
|
|
277
|
+
r = doctor.remove_statusline()
|
|
278
|
+
assert r["result"] == "no-op"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def test_remove_statusline_no_op_when_no_key(tmp_path, monkeypatch):
|
|
282
|
+
settings = tmp_path / "settings.json"
|
|
283
|
+
settings.write_text(json.dumps({"theme": "dark"}))
|
|
284
|
+
monkeypatch.setattr(doctor, "SETTINGS_PATH", settings)
|
|
285
|
+
r = doctor.remove_statusline()
|
|
286
|
+
assert r["result"] == "no-op"
|
|
287
|
+
# Ensure nothing was written.
|
|
288
|
+
assert json.loads(settings.read_text()) == {"theme": "dark"}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_remove_statusline_skipped_when_user_custom(tmp_path, monkeypatch):
|
|
292
|
+
"""Critical safety: must not clear a non-Coach statusLine."""
|
|
293
|
+
settings = tmp_path / "settings.json"
|
|
294
|
+
original = {
|
|
295
|
+
"statusLine": {"type": "command", "command": "bash ~/.claude/my-line.sh"},
|
|
296
|
+
}
|
|
297
|
+
settings.write_text(json.dumps(original))
|
|
298
|
+
monkeypatch.setattr(doctor, "SETTINGS_PATH", settings)
|
|
299
|
+
r = doctor.remove_statusline()
|
|
300
|
+
assert r["result"] == "skipped"
|
|
301
|
+
# Settings unchanged.
|
|
302
|
+
assert json.loads(settings.read_text()) == original
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_remove_statusline_skipped_when_integrated_externally(tmp_path, monkeypatch):
|
|
306
|
+
"""v0.1.5 regression guard: integrated-externally is a non-Coach
|
|
307
|
+
command (user's own script, just one that calls Coach internally).
|
|
308
|
+
--remove-statusline must NOT delete it.
|
|
309
|
+
|
|
310
|
+
Pre-v0.1.5 this fell through the `claimed`-only guard at
|
|
311
|
+
doctor.py:413 and got wiped by the `# ours-* — safe to clear`
|
|
312
|
+
branch — Ryan-style custom statuslines disappeared.
|
|
313
|
+
"""
|
|
314
|
+
coach_dir = tmp_path / "coach"
|
|
315
|
+
coach_dir.mkdir()
|
|
316
|
+
monkeypatch.setenv("COACH_CONFIG_DIR", str(coach_dir))
|
|
317
|
+
|
|
318
|
+
# Opt-out marker tagged `already-integrated` is what flips the
|
|
319
|
+
# classifier from `claimed` to `integrated-externally`.
|
|
320
|
+
(coach_dir / ".statusline-wrap-disabled").write_text(json.dumps({
|
|
321
|
+
"reason": "already-integrated",
|
|
322
|
+
"detected_in": "/Users/foo/.claude/statusline-command.sh",
|
|
323
|
+
}))
|
|
324
|
+
|
|
325
|
+
settings = tmp_path / "settings.json"
|
|
326
|
+
original = {
|
|
327
|
+
"statusLine": {
|
|
328
|
+
"type": "command",
|
|
329
|
+
"command": "bash /Users/foo/.claude/statusline-command.sh",
|
|
330
|
+
},
|
|
331
|
+
}
|
|
332
|
+
settings.write_text(json.dumps(original))
|
|
333
|
+
monkeypatch.setattr(doctor, "SETTINGS_PATH", settings)
|
|
334
|
+
|
|
335
|
+
r = doctor.remove_statusline()
|
|
336
|
+
assert r["result"] == "skipped", (
|
|
337
|
+
f"integrated-externally must be protected; got {r!r}"
|
|
338
|
+
)
|
|
339
|
+
assert json.loads(settings.read_text()) == original, (
|
|
340
|
+
"settings.json:statusLine was mutated — Ryan-style custom "
|
|
341
|
+
"statusline got wiped"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_remove_statusline_clears_plugin_owned(tmp_path, monkeypatch):
|
|
346
|
+
settings = tmp_path / "settings.json"
|
|
347
|
+
settings.write_text(json.dumps({
|
|
348
|
+
"statusLine": {
|
|
349
|
+
"type": "command",
|
|
350
|
+
"command": "/p/bin/bootstrap.sh /p/bin/default_statusline.py",
|
|
351
|
+
},
|
|
352
|
+
"theme": "dark",
|
|
353
|
+
}))
|
|
354
|
+
monkeypatch.setattr(doctor, "SETTINGS_PATH", settings)
|
|
355
|
+
r = doctor.remove_statusline()
|
|
356
|
+
assert r["result"] == "removed"
|
|
357
|
+
written = json.loads(settings.read_text())
|
|
358
|
+
assert "statusLine" not in written
|
|
359
|
+
assert written["theme"] == "dark" # other keys preserved
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_remove_statusline_clears_cli_owned(tmp_path, monkeypatch):
|
|
363
|
+
settings = tmp_path / "settings.json"
|
|
364
|
+
settings.write_text(json.dumps({
|
|
365
|
+
"statusLine": {
|
|
366
|
+
"type": "command",
|
|
367
|
+
"command": "bash ~/.claude/coach/default-statusline-command.sh",
|
|
368
|
+
},
|
|
369
|
+
}))
|
|
370
|
+
monkeypatch.setattr(doctor, "SETTINGS_PATH", settings)
|
|
371
|
+
r = doctor.remove_statusline()
|
|
372
|
+
assert r["result"] == "removed"
|
|
373
|
+
assert "statusLine" not in json.loads(settings.read_text())
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def test_remove_statusline_error_on_malformed(tmp_path, monkeypatch):
|
|
377
|
+
settings = tmp_path / "settings.json"
|
|
378
|
+
settings.write_text("{not json")
|
|
379
|
+
monkeypatch.setattr(doctor, "SETTINGS_PATH", settings)
|
|
380
|
+
r = doctor.remove_statusline()
|
|
381
|
+
assert r["result"] == "error"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
# CLI entry — exit codes + arg validation
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
def test_cli_default_exits_zero(monkeypatch, capsys):
|
|
389
|
+
monkeypatch.setattr(doctor, "collect_probes", lambda: {
|
|
390
|
+
"plugin_install": {"status": "absent", "detail": "x", "entries": []},
|
|
391
|
+
"coexistence": {"status": "active", "detail": "x", "deferred_at": "", "reason": "", "cli_removed_marker": None},
|
|
392
|
+
"statusline": {"status": "absent", "detail": "x", "ownership": "absent", "command": ""},
|
|
393
|
+
"cron": {"status": "ok", "detail": "x", "registered": True, "platform": "Darwin"},
|
|
394
|
+
"venv": {"status": "ok", "detail": "x", "venv_path": "/v", "yaml_version": "6.0"},
|
|
395
|
+
})
|
|
396
|
+
rc = doctor.main([])
|
|
397
|
+
assert rc == 0
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def test_cli_json_output_is_valid_json(monkeypatch, capsys):
|
|
401
|
+
monkeypatch.setattr(doctor, "collect_probes", lambda: {
|
|
402
|
+
"plugin_install": {"status": "ok", "detail": "x", "entries": []},
|
|
403
|
+
"coexistence": {"status": "active", "detail": "x", "deferred_at": "", "reason": "", "cli_removed_marker": None},
|
|
404
|
+
"statusline": {"status": "ok", "detail": "x", "ownership": "ours-plugin", "command": "x"},
|
|
405
|
+
"cron": {"status": "ok", "detail": "x", "registered": True, "platform": "Darwin"},
|
|
406
|
+
"venv": {"status": "ok", "detail": "x", "venv_path": "/v", "yaml_version": "6.0"},
|
|
407
|
+
})
|
|
408
|
+
rc = doctor.main(["--json"])
|
|
409
|
+
assert rc == 0
|
|
410
|
+
out = capsys.readouterr().out
|
|
411
|
+
parsed = json.loads(out)
|
|
412
|
+
assert set(parsed.keys()) == {"plugin_install", "coexistence", "statusline", "cron", "venv"}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def test_cli_rejects_combined_flags(capsys):
|
|
416
|
+
rc = doctor.main(["--json", "--remove-statusline"])
|
|
417
|
+
assert rc == 2
|
|
418
|
+
err = capsys.readouterr().err
|
|
419
|
+
assert "cannot combine" in err
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ---------------------------------------------------------------------------
|
|
423
|
+
# Wrap-mode classification + actions (v0.1.4)
|
|
424
|
+
# ---------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def test_classify_recognizes_wrapped_plugin_shape():
|
|
428
|
+
"""`bootstrap.sh statusline_wrap.py` (plugin) → ours-wrapped."""
|
|
429
|
+
r = doctor._classify_statusline({
|
|
430
|
+
"type": "command",
|
|
431
|
+
"command": "/p/bin/bootstrap.sh /p/bin/statusline_wrap.py",
|
|
432
|
+
})
|
|
433
|
+
assert r["ownership"] == "ours-wrapped"
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def test_classify_recognizes_wrapped_cli_shape():
|
|
437
|
+
"""`bash …/default-statusline-wrap-command.sh` (CLI) → ours-wrapped."""
|
|
438
|
+
r = doctor._classify_statusline({
|
|
439
|
+
"type": "command",
|
|
440
|
+
"command": "bash /home/u/.claude/coach/default-statusline-wrap-command.sh",
|
|
441
|
+
})
|
|
442
|
+
assert r["ownership"] == "ours-wrapped"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def test_classify_integrated_externally_when_optout_marker_says_so(tmp_path):
|
|
446
|
+
"""User has a custom statusLine AND a `.statusline-wrap-disabled`
|
|
447
|
+
marker with `reason: already-integrated` → integrated-externally."""
|
|
448
|
+
(tmp_path / ".statusline-wrap-disabled").write_text(json.dumps({
|
|
449
|
+
"reason": "already-integrated",
|
|
450
|
+
"detected_in": "/home/u/.claude/statusline-command.sh",
|
|
451
|
+
}))
|
|
452
|
+
r = doctor._classify_statusline(
|
|
453
|
+
{"type": "command", "command": "bash ~/.claude/statusline-command.sh"},
|
|
454
|
+
coach_dir=tmp_path,
|
|
455
|
+
)
|
|
456
|
+
assert r["ownership"] == "integrated-externally"
|
|
457
|
+
assert "/statusline-command.sh" in r["detected_in"]
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def test_probe_statusline_wrapped_surfaces_saved_original(tmp_path, monkeypatch):
|
|
461
|
+
"""ours-wrapped → probe reads .statusline-wrap.json and exposes the
|
|
462
|
+
original command alongside the wrapper."""
|
|
463
|
+
settings = tmp_path / "settings.json"
|
|
464
|
+
coach_dir = tmp_path / "coach"
|
|
465
|
+
coach_dir.mkdir()
|
|
466
|
+
settings.write_text(json.dumps({
|
|
467
|
+
"statusLine": {"type": "command", "command": "/p/bin/bootstrap.sh /p/bin/statusline_wrap.py"},
|
|
468
|
+
}))
|
|
469
|
+
(coach_dir / ".statusline-wrap.json").write_text(json.dumps({
|
|
470
|
+
"original_command": "bash /opt/saved.sh",
|
|
471
|
+
}))
|
|
472
|
+
monkeypatch.setattr(doctor, "SETTINGS_PATH", settings)
|
|
473
|
+
r = doctor.probe_statusline(coach_dir=coach_dir)
|
|
474
|
+
assert r["ownership"] == "ours-wrapped"
|
|
475
|
+
assert r["status"] == "ok"
|
|
476
|
+
assert r["wrapped_original"] == "bash /opt/saved.sh"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def test_probe_statusline_integrated_externally_status_is_ok(tmp_path, monkeypatch):
|
|
480
|
+
"""Integrated-externally is a green-light state, not a warning."""
|
|
481
|
+
settings = tmp_path / "settings.json"
|
|
482
|
+
coach_dir = tmp_path / "coach"
|
|
483
|
+
coach_dir.mkdir()
|
|
484
|
+
settings.write_text(json.dumps({
|
|
485
|
+
"statusLine": {"type": "command", "command": "bash /opt/x.sh"},
|
|
486
|
+
}))
|
|
487
|
+
(coach_dir / ".statusline-wrap-disabled").write_text(json.dumps({
|
|
488
|
+
"reason": "already-integrated",
|
|
489
|
+
"detected_in": "/opt/x.sh",
|
|
490
|
+
}))
|
|
491
|
+
monkeypatch.setattr(doctor, "SETTINGS_PATH", settings)
|
|
492
|
+
r = doctor.probe_statusline(coach_dir=coach_dir)
|
|
493
|
+
assert r["ownership"] == "integrated-externally"
|
|
494
|
+
assert r["status"] == "ok"
|
|
495
|
+
assert "no wrap needed" in r["detail"].lower()
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def test_render_report_includes_wrap_suggestion_on_claimed_row():
|
|
499
|
+
"""The claimed-row suggested-actions hint is the entire reason a user
|
|
500
|
+
knows wrap mode exists."""
|
|
501
|
+
probes = {
|
|
502
|
+
"plugin_install": {"status": "ok", "detail": "x", "entries": []},
|
|
503
|
+
"coexistence": {"status": "active", "detail": "x", "deferred_at": "", "reason": "", "cli_removed_marker": None},
|
|
504
|
+
"statusline": {
|
|
505
|
+
"status": "claimed",
|
|
506
|
+
"detail": "statusLine points elsewhere; can be wrapped",
|
|
507
|
+
"ownership": "claimed",
|
|
508
|
+
"command": "bash ~/.claude/my-line.sh",
|
|
509
|
+
},
|
|
510
|
+
"cron": {"status": "ok", "detail": "x", "registered": True, "platform": "Darwin"},
|
|
511
|
+
"venv": {"status": "ok", "detail": "x", "venv_path": "/v", "yaml_version": "6.0"},
|
|
512
|
+
}
|
|
513
|
+
out = doctor.render_report(probes)
|
|
514
|
+
assert "--wrap-statusline" in out
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def test_render_report_includes_unwrap_suggestion_on_wrapped_row():
|
|
518
|
+
probes = {
|
|
519
|
+
"plugin_install": {"status": "ok", "detail": "x", "entries": []},
|
|
520
|
+
"coexistence": {"status": "active", "detail": "x", "deferred_at": "", "reason": "", "cli_removed_marker": None},
|
|
521
|
+
"statusline": {
|
|
522
|
+
"status": "ok",
|
|
523
|
+
"detail": "statusLine wraps your existing command",
|
|
524
|
+
"ownership": "ours-wrapped",
|
|
525
|
+
"command": "/p/bin/bootstrap.sh /p/bin/statusline_wrap.py",
|
|
526
|
+
"wrapped_original": "bash /opt/saved.sh",
|
|
527
|
+
},
|
|
528
|
+
"cron": {"status": "ok", "detail": "x", "registered": True, "platform": "Darwin"},
|
|
529
|
+
"venv": {"status": "ok", "detail": "x", "venv_path": "/v", "yaml_version": "6.0"},
|
|
530
|
+
}
|
|
531
|
+
out = doctor.render_report(probes)
|
|
532
|
+
assert "--unwrap-statusline" in out
|
|
533
|
+
assert "/opt/saved.sh" in out
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def test_cli_wrap_statusline_writes_settings(tmp_path, monkeypatch, capsys):
|
|
537
|
+
"""`--wrap-statusline` end-to-end: claimed → wrapped + JSON result."""
|
|
538
|
+
settings = tmp_path / "settings.json"
|
|
539
|
+
coach_dir = tmp_path / "coach"
|
|
540
|
+
coach_dir.mkdir()
|
|
541
|
+
settings.write_text(json.dumps({
|
|
542
|
+
"statusLine": {"type": "command", "command": "bash /opt/x.sh"},
|
|
543
|
+
}))
|
|
544
|
+
monkeypatch.setenv("COACH_CONFIG_DIR", str(coach_dir))
|
|
545
|
+
monkeypatch.setenv("CLAUDE_SETTINGS_PATH", str(settings))
|
|
546
|
+
|
|
547
|
+
rc = doctor.main(["--wrap-statusline"])
|
|
548
|
+
assert rc == 0
|
|
549
|
+
parsed = json.loads(capsys.readouterr().out)
|
|
550
|
+
assert parsed["action"] == "wrap-statusline"
|
|
551
|
+
assert parsed["result"] == "wrapped"
|
|
552
|
+
new_cmd = json.loads(settings.read_text())["statusLine"]["command"]
|
|
553
|
+
assert "default-statusline-wrap-command.sh" in new_cmd
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def test_cli_unwrap_statusline_round_trip(tmp_path, monkeypatch, capsys):
|
|
557
|
+
settings = tmp_path / "settings.json"
|
|
558
|
+
coach_dir = tmp_path / "coach"
|
|
559
|
+
coach_dir.mkdir()
|
|
560
|
+
settings.write_text(json.dumps({
|
|
561
|
+
"statusLine": {"type": "command", "command": "bash /opt/x.sh"},
|
|
562
|
+
}))
|
|
563
|
+
monkeypatch.setenv("COACH_CONFIG_DIR", str(coach_dir))
|
|
564
|
+
monkeypatch.setenv("CLAUDE_SETTINGS_PATH", str(settings))
|
|
565
|
+
|
|
566
|
+
doctor.main(["--wrap-statusline"])
|
|
567
|
+
capsys.readouterr() # discard
|
|
568
|
+
rc = doctor.main(["--unwrap-statusline"])
|
|
569
|
+
assert rc == 0
|
|
570
|
+
parsed = json.loads(capsys.readouterr().out)
|
|
571
|
+
assert parsed["action"] == "unwrap-statusline"
|
|
572
|
+
assert parsed["result"] == "unwrapped"
|
|
573
|
+
assert json.loads(settings.read_text())["statusLine"]["command"] == "bash /opt/x.sh"
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def test_cli_wrap_and_unwrap_mutually_exclusive(capsys):
|
|
577
|
+
rc = doctor.main(["--wrap-statusline", "--unwrap-statusline"])
|
|
578
|
+
assert rc == 2
|
|
579
|
+
err = capsys.readouterr().err
|
|
580
|
+
assert "mutually exclusive" in err
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def test_cli_remove_and_wrap_mutually_exclusive(capsys):
|
|
584
|
+
rc = doctor.main(["--remove-statusline", "--wrap-statusline"])
|
|
585
|
+
assert rc == 2
|
|
586
|
+
err = capsys.readouterr().err
|
|
587
|
+
assert "mutually exclusive" in err
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def test_cli_force_requires_wrap_or_unwrap(capsys):
|
|
591
|
+
rc = doctor.main(["--force"])
|
|
592
|
+
assert rc == 2
|
|
593
|
+
err = capsys.readouterr().err
|
|
594
|
+
assert "--force" in err
|
|
595
|
+
assert "only valid" in err
|