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