@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,663 @@
1
+ #!/usr/bin/env python3
2
+ """Diagnostic surface for the Coach Claw plugin distribution.
3
+
4
+ Runs five read-only probes (plugin install, coexistence marker,
5
+ statusLine ownership, cron registration, venv health) and prints a
6
+ human-readable report by default. `--json` emits the same probe
7
+ results as machine-readable JSON for bug-report triage.
8
+
9
+ Also supports `--remove-statusline`: the documented uninstall-cleanup
10
+ path. Claude Code's plugin lifecycle does NOT clear the `statusLine`
11
+ key from `~/.claude/settings.json` when a plugin is uninstalled, so
12
+ this flag handles that one mutation. Only clears entries that match a
13
+ Coach marker; user-custom statusLines are left alone (reported as
14
+ "claimed" instead).
15
+
16
+ Reuses:
17
+ - coach_paths.resolve_coach_dir for state-dir path
18
+ - cron_check.is_cron_registered for the cron probe
19
+ - statusline_self_patch.COACH_STATUSLINE_MARKERS for ownership matching
20
+ - statusline_self_patch._atomic_write for the --remove-statusline path
21
+
22
+ Exit codes:
23
+ 0 — probes ran (the report itself may surface degraded states)
24
+ 1 — fatal error during probe rendering or settings.json mutation
25
+ 2 — invalid arguments
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import json
31
+ import os
32
+ import platform
33
+ import subprocess
34
+ import sys
35
+ from pathlib import Path
36
+ from typing import Any, Dict
37
+
38
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
39
+ from coach_paths import resolve_coach_dir # noqa: E402
40
+ from cron_check import is_cron_registered # noqa: E402
41
+ from statusline_self_patch import ( # noqa: E402
42
+ COACH_STATUSLINE_MARKERS,
43
+ _atomic_write as _atomic_write_settings,
44
+ )
45
+ import statusline_wrap_action as wrap_action # noqa: E402
46
+
47
+
48
+ SETTINGS_PATH = Path.home() / ".claude" / "settings.json"
49
+ INSTALLED_PLUGINS_PATH = (
50
+ Path.home() / ".claude" / "plugins" / "installed_plugins.json"
51
+ )
52
+
53
+ # Distinguish the two Coach statusLine shapes so the report can call
54
+ # them by name. These are substring matches on the full command string.
55
+ PLUGIN_STATUSLINE_MARKER = "default_statusline.py"
56
+ CLI_STATUSLINE_MARKER = "default-statusline-command.sh"
57
+
58
+ # ANSI palette — matches status.py's quiet, low-saturation style.
59
+ BOLD = "\x1b[1m"
60
+ DIM = "\x1b[2m"
61
+ RESET = "\x1b[0m"
62
+ GREEN = "\x1b[38;2;120;180;130m"
63
+ YELLOW = "\x1b[38;2;212;175;55m"
64
+ RED = "\x1b[38;2;200;110;100m"
65
+ GREY = "\x1b[38;2;110;122;140m"
66
+
67
+ OK = f"{GREEN}OK{RESET}"
68
+ WARN = f"{YELLOW}WARN{RESET}"
69
+ ERR = f"{RED}ERR{RESET}"
70
+ INFO = f"{GREY}--{RESET}"
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Probes — each returns a dict with at least a "status" key.
75
+ # ---------------------------------------------------------------------------
76
+
77
+ def probe_plugin_install() -> Dict[str, Any]:
78
+ """Read installed_plugins.json; return any coach-claw entries."""
79
+ if not INSTALLED_PLUGINS_PATH.exists():
80
+ return {
81
+ "status": "absent",
82
+ "detail": "no installed_plugins.json (Claude Code plugin "
83
+ "system not initialized)",
84
+ "entries": [],
85
+ }
86
+ try:
87
+ data = json.loads(INSTALLED_PLUGINS_PATH.read_text())
88
+ except Exception as e:
89
+ return {
90
+ "status": "error",
91
+ "detail": f"failed to read installed_plugins.json: {e}",
92
+ "entries": [],
93
+ }
94
+ plugins = data.get("plugins") if isinstance(data, dict) else None
95
+ if not isinstance(plugins, dict):
96
+ return {
97
+ "status": "error",
98
+ "detail": "installed_plugins.json missing 'plugins' object",
99
+ "entries": [],
100
+ }
101
+
102
+ entries = []
103
+ for plugin_id, installs in plugins.items():
104
+ if not plugin_id.startswith("coach-claw@"):
105
+ continue
106
+ if not isinstance(installs, list):
107
+ continue
108
+ for inst in installs:
109
+ if not isinstance(inst, dict):
110
+ continue
111
+ entries.append({
112
+ "plugin_id": plugin_id,
113
+ "marketplace": plugin_id.split("@", 1)[1] if "@" in plugin_id else "",
114
+ "version": inst.get("version", ""),
115
+ "install_path": inst.get("installPath", ""),
116
+ "last_updated": inst.get("lastUpdated", ""),
117
+ "scope": inst.get("scope", ""),
118
+ })
119
+
120
+ if not entries:
121
+ return {
122
+ "status": "absent",
123
+ "detail": "no coach-claw entries in installed_plugins.json",
124
+ "entries": [],
125
+ }
126
+ return {
127
+ "status": "ok" if len(entries) == 1 else "warn",
128
+ "detail": (
129
+ "single install"
130
+ if len(entries) == 1
131
+ else f"{len(entries)} installs found (multiple marketplaces)"
132
+ ),
133
+ "entries": entries,
134
+ }
135
+
136
+
137
+ def probe_coexistence(coach_dir: Path) -> Dict[str, Any]:
138
+ """Look for the .plugin-deferred / .cli-uninstalled-by-plugin markers."""
139
+ deferred = coach_dir / ".plugin-deferred"
140
+ cli_removed = coach_dir / ".cli-uninstalled-by-plugin"
141
+
142
+ deferred_payload = None
143
+ if deferred.exists():
144
+ try:
145
+ deferred_payload = json.loads(deferred.read_text())
146
+ except Exception:
147
+ deferred_payload = {"raw": "<unparseable>"}
148
+
149
+ cli_removed_payload = None
150
+ if cli_removed.exists():
151
+ try:
152
+ cli_removed_payload = json.loads(cli_removed.read_text())
153
+ except Exception:
154
+ cli_removed_payload = {"raw": "<unparseable>"}
155
+
156
+ if deferred_payload is not None:
157
+ return {
158
+ "status": "deferred",
159
+ "detail": "plugin is yielding to the npm CLI hooks",
160
+ "deferred_at": deferred_payload.get("deferred_at", ""),
161
+ "reason": deferred_payload.get("reason", ""),
162
+ "cli_removed_marker": cli_removed_payload,
163
+ }
164
+ return {
165
+ "status": "active",
166
+ "detail": "no .plugin-deferred marker; plugin's hooks fire normally",
167
+ "deferred_at": "",
168
+ "reason": "",
169
+ "cli_removed_marker": cli_removed_payload,
170
+ }
171
+
172
+
173
+ def _classify_statusline(entry: Any, coach_dir: Path | None = None) -> Dict[str, Any]:
174
+ """Classify a settings.json statusLine value.
175
+
176
+ Ownership states:
177
+ absent — no statusLine key
178
+ ours-wrapped — Coach's wrap shape (plugin or CLI trampoline)
179
+ ours-plugin — Coach's plugin default statusline
180
+ ours-cli — Coach's CLI default statusline
181
+ ours-unknown — Coach marker but unrecognized shape
182
+ integrated-externally — non-Coach command BUT user's script already
183
+ calls Coach internals (sniffed via opt-out
184
+ marker reason `already-integrated`)
185
+ claimed — non-Coach command, no integration detected
186
+ """
187
+ if not isinstance(entry, dict):
188
+ return {"ownership": "absent", "command": ""}
189
+ cmd = str(entry.get("command", ""))
190
+ if wrap_action._is_wrap_command(cmd):
191
+ return {"ownership": "ours-wrapped", "command": cmd}
192
+ if PLUGIN_STATUSLINE_MARKER in cmd:
193
+ return {"ownership": "ours-plugin", "command": cmd}
194
+ if CLI_STATUSLINE_MARKER in cmd:
195
+ return {"ownership": "ours-cli", "command": cmd}
196
+ if any(m in cmd for m in COACH_STATUSLINE_MARKERS):
197
+ return {"ownership": "ours-unknown", "command": cmd}
198
+ # Final check: did the manual-Coach pre-flight already mark this user
199
+ # as integrated externally? The opt-out marker carries the reason.
200
+ cdir = coach_dir if coach_dir is not None else resolve_coach_dir()
201
+ disabled_path = cdir / wrap_action.DISABLED_MARKER_NAME
202
+ try:
203
+ if disabled_path.exists():
204
+ data = json.loads(disabled_path.read_text())
205
+ if isinstance(data, dict) and data.get("reason") == "already-integrated":
206
+ return {
207
+ "ownership": "integrated-externally",
208
+ "command": cmd,
209
+ "detected_in": data.get("detected_in", ""),
210
+ }
211
+ except Exception:
212
+ pass
213
+ return {"ownership": "claimed", "command": cmd}
214
+
215
+
216
+ def probe_statusline(coach_dir: Path | None = None) -> Dict[str, Any]:
217
+ """Read settings.json; classify the statusLine entry."""
218
+ cdir = coach_dir if coach_dir is not None else resolve_coach_dir()
219
+ if not SETTINGS_PATH.exists():
220
+ return {
221
+ "status": "absent",
222
+ "detail": "no settings.json (Claude Code creates it on first run)",
223
+ "ownership": "absent",
224
+ "command": "",
225
+ }
226
+ try:
227
+ data = json.loads(SETTINGS_PATH.read_text())
228
+ except Exception as e:
229
+ return {
230
+ "status": "error",
231
+ "detail": f"settings.json malformed: {e}",
232
+ "ownership": "error",
233
+ "command": "",
234
+ }
235
+
236
+ entry = data.get("statusLine") if isinstance(data, dict) else None
237
+ classified = _classify_statusline(entry, coach_dir=cdir)
238
+ ownership = classified["ownership"]
239
+
240
+ result: Dict[str, Any] = {
241
+ "ownership": ownership,
242
+ "command": classified["command"],
243
+ }
244
+
245
+ if ownership == "absent":
246
+ result["status"] = "absent"
247
+ result["detail"] = "no statusLine key — plugin will install on next session"
248
+ elif ownership == "ours-wrapped":
249
+ result["status"] = "ok"
250
+ result["detail"] = "statusLine wraps your existing command (Coach segment appended)"
251
+ # Surface the saved original so the user can see what's underneath.
252
+ try:
253
+ marker = cdir / wrap_action.WRAP_MARKER_NAME
254
+ if marker.exists():
255
+ wrap_data = json.loads(marker.read_text())
256
+ if isinstance(wrap_data, dict):
257
+ result["wrapped_original"] = wrap_data.get("original_command", "")
258
+ except Exception:
259
+ pass
260
+ elif ownership in ("ours-plugin", "ours-cli", "ours-unknown"):
261
+ result["status"] = "ok"
262
+ result["detail"] = f"statusLine is ours ({ownership.split('-', 1)[1]})"
263
+ elif ownership == "integrated-externally":
264
+ result["status"] = "ok"
265
+ result["detail"] = (
266
+ "Custom statusline already integrates Coach internally — "
267
+ "no wrap needed."
268
+ )
269
+ result["detected_in"] = classified.get("detected_in", "")
270
+ else:
271
+ result["status"] = "claimed"
272
+ result["detail"] = "statusLine points elsewhere; can be wrapped"
273
+
274
+ return result
275
+
276
+
277
+ def probe_cron() -> Dict[str, Any]:
278
+ """Return whether a Coach insights cron is registered (best-effort)."""
279
+ system = platform.system()
280
+ if system not in ("Darwin", "Linux"):
281
+ return {
282
+ "status": "skipped",
283
+ "detail": f"{system}: no cron path on this platform",
284
+ "registered": True,
285
+ "platform": system,
286
+ }
287
+ try:
288
+ registered = is_cron_registered()
289
+ except Exception as e:
290
+ return {
291
+ "status": "error",
292
+ "detail": f"cron probe failed: {e}",
293
+ "registered": True, # fail-safe (matches cron_check semantics)
294
+ "platform": system,
295
+ }
296
+ if registered:
297
+ return {
298
+ "status": "ok",
299
+ "detail": (
300
+ "launchd plist com.local.claude-coach loaded"
301
+ if system == "Darwin"
302
+ else "crontab references coach insights script"
303
+ ),
304
+ "registered": True,
305
+ "platform": system,
306
+ }
307
+ return {
308
+ "status": "missing",
309
+ "detail": (
310
+ "no Coach launchd plist found — daily insights will not run.\n"
311
+ " Install via: npx @rm0nroe/coach-claw launchd"
312
+ if system == "Darwin"
313
+ else "no Coach line in crontab — daily insights will not run.\n"
314
+ " See README.md → Install → step 3 for the cron entry"
315
+ ),
316
+ "registered": False,
317
+ "platform": system,
318
+ }
319
+
320
+
321
+ def probe_venv() -> Dict[str, Any]:
322
+ """Check that the plugin's PyYAML venv exists and imports yaml."""
323
+ data_dir_env = os.environ.get("CLAUDE_PLUGIN_DATA")
324
+ if data_dir_env:
325
+ venv_path = Path(data_dir_env) / "venv"
326
+ else:
327
+ venv_path = (
328
+ Path.home() / ".claude" / "plugins" / "data" / "coach-claw" / "venv"
329
+ )
330
+ py = venv_path / "bin" / "python3"
331
+
332
+ if not py.exists():
333
+ return {
334
+ "status": "missing",
335
+ "detail": f"no python3 at {py} — bootstrap may have failed",
336
+ "venv_path": str(venv_path),
337
+ "yaml_version": "",
338
+ }
339
+ try:
340
+ r = subprocess.run(
341
+ [str(py), "-c", "import yaml; print(yaml.__version__)"],
342
+ capture_output=True,
343
+ timeout=5,
344
+ text=True,
345
+ )
346
+ except Exception as e:
347
+ return {
348
+ "status": "error",
349
+ "detail": f"venv python3 failed to launch: {e}",
350
+ "venv_path": str(venv_path),
351
+ "yaml_version": "",
352
+ }
353
+ if r.returncode != 0:
354
+ return {
355
+ "status": "broken",
356
+ "detail": (
357
+ "venv python3 exists but cannot import yaml "
358
+ f"(stderr: {r.stderr.strip()[:120]})"
359
+ ),
360
+ "venv_path": str(venv_path),
361
+ "yaml_version": "",
362
+ }
363
+ return {
364
+ "status": "ok",
365
+ "detail": f"PyYAML {r.stdout.strip()} importable from venv",
366
+ "venv_path": str(venv_path),
367
+ "yaml_version": r.stdout.strip(),
368
+ }
369
+
370
+
371
+ # ---------------------------------------------------------------------------
372
+ # --remove-statusline action.
373
+ # ---------------------------------------------------------------------------
374
+
375
+ def remove_statusline() -> Dict[str, Any]:
376
+ """Clear settings.json:statusLine if it currently points at Coach.
377
+
378
+ Returns a result dict for the caller to render. Never raises into
379
+ the SKILL surface — flock + atomic write semantics borrowed from
380
+ statusline_self_patch._atomic_write.
381
+ """
382
+ if not SETTINGS_PATH.exists():
383
+ return {
384
+ "action": "remove-statusline",
385
+ "result": "no-op",
386
+ "detail": "no settings.json to modify",
387
+ }
388
+ try:
389
+ data = json.loads(SETTINGS_PATH.read_text())
390
+ except Exception as e:
391
+ return {
392
+ "action": "remove-statusline",
393
+ "result": "error",
394
+ "detail": f"settings.json malformed: {e}",
395
+ }
396
+ if not isinstance(data, dict):
397
+ return {
398
+ "action": "remove-statusline",
399
+ "result": "error",
400
+ "detail": "settings.json is not a JSON object",
401
+ }
402
+
403
+ entry = data.get("statusLine")
404
+ classified = _classify_statusline(entry, coach_dir=resolve_coach_dir())
405
+ ownership = classified["ownership"]
406
+
407
+ if ownership == "absent":
408
+ return {
409
+ "action": "remove-statusline",
410
+ "result": "no-op",
411
+ "detail": "no statusLine key present",
412
+ }
413
+ # Protect non-Coach commands. `integrated-externally` is also non-
414
+ # Coach (user's custom script that already calls Coach internals via
415
+ # `coach/bin/stats.py` etc.) — deleting it would wipe their working
416
+ # statusline. The ownership classifier marks both shapes as
417
+ # leave-alone; --remove-statusline must respect that.
418
+ if ownership in ("claimed", "integrated-externally"):
419
+ return {
420
+ "action": "remove-statusline",
421
+ "result": "skipped",
422
+ "detail": (
423
+ "statusLine points at a non-Coach command; left untouched. "
424
+ f"Command: {classified['command'][:120]}"
425
+ ),
426
+ }
427
+
428
+ # ours-* — safe to clear.
429
+ new_data = {k: v for k, v in data.items() if k != "statusLine"}
430
+ try:
431
+ _atomic_write_settings(SETTINGS_PATH, new_data)
432
+ except Exception as e:
433
+ return {
434
+ "action": "remove-statusline",
435
+ "result": "error",
436
+ "detail": f"failed to write settings.json: {e}",
437
+ }
438
+ return {
439
+ "action": "remove-statusline",
440
+ "result": "removed",
441
+ "detail": (
442
+ "cleared the Coach statusLine entry. "
443
+ "Run /plugin uninstall coach-claw to finish."
444
+ ),
445
+ "previous_command": classified["command"],
446
+ }
447
+
448
+
449
+ # ---------------------------------------------------------------------------
450
+ # Output: human-readable report and JSON.
451
+ # ---------------------------------------------------------------------------
452
+
453
+ def _badge(status: str) -> str:
454
+ return {
455
+ "ok": OK,
456
+ "active": OK,
457
+ "absent": INFO,
458
+ "skipped": INFO,
459
+ "deferred": INFO,
460
+ "claimed": WARN,
461
+ "warn": WARN,
462
+ "missing": WARN,
463
+ "broken": ERR,
464
+ "error": ERR,
465
+ }.get(status, INFO)
466
+
467
+
468
+ def render_report(probes: Dict[str, Dict[str, Any]]) -> str:
469
+ """Build the plain-text report. Sections are headed by 1) … 5)."""
470
+ lines: list[str] = []
471
+ lines.append(f"{BOLD}Coach Claw plugin diagnostic{RESET}")
472
+ lines.append("")
473
+
474
+ # 1) Plugin install
475
+ p = probes["plugin_install"]
476
+ lines.append(f"{_badge(p['status'])} {BOLD}1) Plugin install{RESET}")
477
+ if p["entries"]:
478
+ for e in p["entries"]:
479
+ lines.append(
480
+ f" {e['plugin_id']} v{e['version']} "
481
+ f"({DIM}{e['scope']}{RESET})"
482
+ )
483
+ lines.append(f" {DIM}{e['install_path']}{RESET}")
484
+ else:
485
+ lines.append(f" {DIM}{p['detail']}{RESET}")
486
+ lines.append("")
487
+
488
+ # 2) Coexistence
489
+ c = probes["coexistence"]
490
+ lines.append(f"{_badge(c['status'])} {BOLD}2) Coexistence{RESET}")
491
+ lines.append(f" {c['detail']}")
492
+ if c["status"] == "deferred" and c.get("deferred_at"):
493
+ lines.append(f" {DIM}deferred_at: {c['deferred_at']}{RESET}")
494
+ if c.get("cli_removed_marker"):
495
+ lines.append(
496
+ f" {DIM}also: .cli-uninstalled-by-plugin marker present"
497
+ f"{RESET}"
498
+ )
499
+ if c["status"] == "deferred":
500
+ lines.append(f" {DIM}▸ Coach IS running via the npm CLI — no action needed.{RESET}")
501
+ lines.append(f" {DIM}▸ /coach-claw:switch to flip control to the plugin instead.{RESET}")
502
+ lines.append("")
503
+
504
+ # 3) statusLine
505
+ s = probes["statusline"]
506
+ lines.append(f"{_badge(s['status'])} {BOLD}3) statusLine ownership{RESET}")
507
+ lines.append(f" {s['detail']}")
508
+ if s["ownership"] == "ours-wrapped" and s.get("wrapped_original"):
509
+ lines.append(
510
+ f" {DIM}wrapped command: {s['wrapped_original'][:120]}{RESET}"
511
+ )
512
+ lines.append(f" {DIM}wrapper: {s['command'][:120]}{RESET}")
513
+ elif s["ownership"] == "integrated-externally":
514
+ if s.get("detected_in"):
515
+ lines.append(f" {DIM}detected in: {s['detected_in']}{RESET}")
516
+ lines.append(f" {DIM}command: {s['command'][:120]}{RESET}")
517
+ elif s["command"]:
518
+ lines.append(f" {DIM}command: {s['command'][:120]}{RESET}")
519
+
520
+ if s["ownership"] == "claimed":
521
+ lines.append(
522
+ f" {DIM}▸ /coach-claw:doctor --wrap-statusline "
523
+ f"append Coach segment to it{RESET}"
524
+ )
525
+ lines.append(
526
+ f" {DIM}▸ Or replace settings.json statusLine.command with{RESET}"
527
+ )
528
+ lines.append(
529
+ f" {DIM}`bash ~/.claude/coach/default-statusline-command.sh`{RESET}"
530
+ )
531
+ elif s["ownership"] == "ours-wrapped":
532
+ lines.append(
533
+ f" {DIM}▸ /coach-claw:doctor --unwrap-statusline "
534
+ f"restore the original{RESET}"
535
+ )
536
+ lines.append("")
537
+
538
+ # 4) Cron
539
+ cr = probes["cron"]
540
+ lines.append(
541
+ f"{_badge(cr['status'])} {BOLD}4) Cron schedule{RESET} "
542
+ f"{DIM}({cr['platform']}){RESET}"
543
+ )
544
+ for line in cr["detail"].split("\n"):
545
+ lines.append(f" {line}")
546
+ lines.append("")
547
+
548
+ # 5) Venv
549
+ v = probes["venv"]
550
+ lines.append(f"{_badge(v['status'])} {BOLD}5) Venv health{RESET}")
551
+ lines.append(f" {v['detail']}")
552
+ lines.append(f" {DIM}path: {v['venv_path']}{RESET}")
553
+ lines.append("")
554
+
555
+ return "\n".join(lines)
556
+
557
+
558
+ def collect_probes() -> Dict[str, Dict[str, Any]]:
559
+ coach_dir = resolve_coach_dir()
560
+ return {
561
+ "plugin_install": probe_plugin_install(),
562
+ "coexistence": probe_coexistence(coach_dir),
563
+ "statusline": probe_statusline(coach_dir=coach_dir),
564
+ "cron": probe_cron(),
565
+ "venv": probe_venv(),
566
+ }
567
+
568
+
569
+ # ---------------------------------------------------------------------------
570
+ # CLI entry.
571
+ # ---------------------------------------------------------------------------
572
+
573
+ def main(argv: list[str] | None = None) -> int:
574
+ parser = argparse.ArgumentParser(
575
+ prog="coach-claw doctor",
576
+ description="Diagnose Coach Claw plugin state.",
577
+ )
578
+ parser.add_argument(
579
+ "--remove-statusline",
580
+ action="store_true",
581
+ help="Clear the plugin's statusLine entry from settings.json "
582
+ "(only if Coach-owned; user-custom entries are left alone).",
583
+ )
584
+ parser.add_argument(
585
+ "--wrap-statusline",
586
+ action="store_true",
587
+ help="Wrap the user's existing statusLine so the Coach segment "
588
+ "appends to it. Saves the original to "
589
+ "~/.claude/coach/.statusline-wrap.json.",
590
+ )
591
+ parser.add_argument(
592
+ "--unwrap-statusline",
593
+ action="store_true",
594
+ help="Restore the original statusLine and write a sticky opt-out "
595
+ "marker so future auto-wrap attempts skip.",
596
+ )
597
+ parser.add_argument(
598
+ "--force",
599
+ action="store_true",
600
+ help="With --wrap-statusline: clear opt-out marker and bypass the "
601
+ "manual-Coach-integration pre-flight skip. With "
602
+ "--unwrap-statusline: restore the saved original even if "
603
+ "the current statusLine command was edited since wrap.",
604
+ )
605
+ parser.add_argument(
606
+ "--json",
607
+ action="store_true",
608
+ help="Emit probe results as machine-readable JSON.",
609
+ )
610
+ args = parser.parse_args(argv)
611
+
612
+ actions = sum([
613
+ args.remove_statusline,
614
+ args.wrap_statusline,
615
+ args.unwrap_statusline,
616
+ ])
617
+ if actions > 1:
618
+ sys.stderr.write(
619
+ "doctor.py: --remove-statusline / --wrap-statusline / "
620
+ "--unwrap-statusline are mutually exclusive\n"
621
+ )
622
+ return 2
623
+ if actions and args.json:
624
+ sys.stderr.write(
625
+ "doctor.py: action flags cannot combine with --json\n"
626
+ )
627
+ return 2
628
+ if args.force and not (args.wrap_statusline or args.unwrap_statusline):
629
+ sys.stderr.write(
630
+ "doctor.py: --force is only valid with --wrap-statusline or "
631
+ "--unwrap-statusline\n"
632
+ )
633
+ return 2
634
+
635
+ if args.remove_statusline:
636
+ result = remove_statusline()
637
+ print(json.dumps(result, indent=2))
638
+ return 0 if result["result"] in ("removed", "no-op", "skipped") else 1
639
+
640
+ if args.wrap_statusline:
641
+ result = wrap_action.wrap(force=args.force)
642
+ result["action"] = "wrap-statusline"
643
+ print(json.dumps(result, indent=2))
644
+ return 0 if result["result"] in ("wrapped", "no-op", "skipped") else 1
645
+
646
+ if args.unwrap_statusline:
647
+ result = wrap_action.unwrap(force=args.force)
648
+ result["action"] = "unwrap-statusline"
649
+ print(json.dumps(result, indent=2))
650
+ return 0 if result["result"] in ("unwrapped", "no-op", "refused") else 1
651
+
652
+ probes = collect_probes()
653
+
654
+ if args.json:
655
+ print(json.dumps(probes, indent=2))
656
+ return 0
657
+
658
+ print(render_report(probes))
659
+ return 0
660
+
661
+
662
+ if __name__ == "__main__":
663
+ sys.exit(main())