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