@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,244 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Wrap the user's existing statusLine command and append the Coach segment.
|
|
3
|
+
|
|
4
|
+
Reads the JSON payload from stdin, runs the user's saved original command
|
|
5
|
+
with the same payload (subprocess + 2s timeout), then composes the
|
|
6
|
+
original output with `stats.render_segment(payload)` using trailing-aware
|
|
7
|
+
separator detection.
|
|
8
|
+
|
|
9
|
+
Saved-original lookup: `<coach_dir>/.statusline-wrap.json` →
|
|
10
|
+
`{"original_command": "<verbatim user command string>"}`. Written by
|
|
11
|
+
`statusline_wrap_action.wrap()` before settings.json is mutated.
|
|
12
|
+
|
|
13
|
+
Composer rules (separator-aware, ANSI-stripped):
|
|
14
|
+
1. If original ends with a known separator char → append with one space.
|
|
15
|
+
2. Else if an inline separator pattern (` X `) exists → reuse it.
|
|
16
|
+
3. Else → join with a single space.
|
|
17
|
+
|
|
18
|
+
Runtime duplicate-detection: if the (ANSI-stripped) original output already
|
|
19
|
+
contains a Coach signature (sigil glyph + roman numeral OR theme rank
|
|
20
|
+
name within ~30 chars), the wrapper writes
|
|
21
|
+
`<coach_dir>/.statusline-wrap-duplicate-detected` and emits the original
|
|
22
|
+
alone. The next user prompt surfaces a banner suggesting `--unwrap-statusline`.
|
|
23
|
+
|
|
24
|
+
Failsafe: every exception path emits the captured original (or empty
|
|
25
|
+
string) and exits 0. The statusline must never crash a render.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
from collections import Counter
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
38
|
+
|
|
39
|
+
from coach_paths import resolve_coach_dir # noqa: E402
|
|
40
|
+
|
|
41
|
+
# Lazy-import stats only inside the rendering path so a broken stats.py
|
|
42
|
+
# can't take down the wrapper before it's even tried to run the user's
|
|
43
|
+
# command (failsafe-first design).
|
|
44
|
+
|
|
45
|
+
WRAP_MARKER_NAME = ".statusline-wrap.json"
|
|
46
|
+
DUPLICATE_MARKER_NAME = ".statusline-wrap-duplicate-detected"
|
|
47
|
+
ORIGINAL_TIMEOUT_SECONDS = 2.0
|
|
48
|
+
|
|
49
|
+
ANSI_RE = re.compile(r"\x1b\[[\d;]*m")
|
|
50
|
+
SEPARATOR_CHARS = "┃│|·•‣→›—-"
|
|
51
|
+
|
|
52
|
+
# Coach-signature regex for duplicate detection. Requires a sigil glyph
|
|
53
|
+
# (◆ / ⚒ / ⚔) within ~40 chars of the end, immediately followed by either
|
|
54
|
+
# a roman numeral [Ⅰ-Ⅼ]+ or a theme rank-name fragment within ~30 chars.
|
|
55
|
+
# The rank-name list is loaded from themes._RANK_NAMES_BY_THEME at runtime
|
|
56
|
+
# (lazy-imported to keep startup cheap on the failsafe path).
|
|
57
|
+
_DUP_SIGIL_RE = re.compile(r"[◆⚒⚔]")
|
|
58
|
+
_DUP_ROMAN_RE = re.compile(r"[◆⚒⚔]\s+[Ⅰ-Ⅼ]+\b")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _strip_ansi(s: str) -> str:
|
|
62
|
+
return ANSI_RE.sub("", s)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _detect_inline_separator(text: str) -> str | None:
|
|
66
|
+
"""Most-frequent separator char appearing space-padded (` X `).
|
|
67
|
+
|
|
68
|
+
Returns None when no separator-char shows up surrounded by spaces;
|
|
69
|
+
that filters out incidental occurrences like `·` inside `opus·4.7`
|
|
70
|
+
where the chars are squashed against word characters.
|
|
71
|
+
"""
|
|
72
|
+
matches = re.findall(rf" ([{re.escape(SEPARATOR_CHARS)}]) ", text)
|
|
73
|
+
if not matches:
|
|
74
|
+
return None
|
|
75
|
+
return Counter(matches).most_common(1)[0][0]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def compose(original: str, coach: str) -> str:
|
|
79
|
+
"""Compose original + coach with trailing-aware separator handling.
|
|
80
|
+
|
|
81
|
+
Pure function — no I/O, no side effects. The full algorithm lives
|
|
82
|
+
here so it can be exhaustively unit-tested without subprocess
|
|
83
|
+
overhead.
|
|
84
|
+
"""
|
|
85
|
+
if not coach:
|
|
86
|
+
return original
|
|
87
|
+
if not original or not original.strip():
|
|
88
|
+
return coach
|
|
89
|
+
stripped = _strip_ansi(original).rstrip()
|
|
90
|
+
if stripped and stripped[-1] in SEPARATOR_CHARS:
|
|
91
|
+
return f"{original.rstrip()} {coach}"
|
|
92
|
+
sep = _detect_inline_separator(stripped)
|
|
93
|
+
if sep:
|
|
94
|
+
return f"{original.rstrip()} {sep} {coach}"
|
|
95
|
+
return f"{original.rstrip()} {coach}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _looks_like_coach_output(text: str) -> bool:
|
|
99
|
+
"""Detect a Coach segment already present in the original output.
|
|
100
|
+
|
|
101
|
+
Two-stage match (the narrowed signature from plan §E fix #6):
|
|
102
|
+
1. Sigil glyph (◆ / ⚒ / ⚔) within last ~40 chars.
|
|
103
|
+
2. Either a roman numeral [Ⅰ-Ⅼ]+ immediately after the sigil, OR a
|
|
104
|
+
theme rank-name within ~30 chars after the sigil.
|
|
105
|
+
|
|
106
|
+
Roman-numeral check is fast (regex). Rank-name check imports
|
|
107
|
+
`themes` lazily to avoid bloat on the common no-match path.
|
|
108
|
+
"""
|
|
109
|
+
plain = _strip_ansi(text)
|
|
110
|
+
if not plain:
|
|
111
|
+
return False
|
|
112
|
+
tail = plain[-80:]
|
|
113
|
+
sigil_match = _DUP_SIGIL_RE.search(tail)
|
|
114
|
+
if not sigil_match:
|
|
115
|
+
return False
|
|
116
|
+
if _DUP_ROMAN_RE.search(tail):
|
|
117
|
+
return True
|
|
118
|
+
try:
|
|
119
|
+
import themes # noqa: WPS433 — lazy by design
|
|
120
|
+
except Exception:
|
|
121
|
+
return False
|
|
122
|
+
rank_names: set[str] = set()
|
|
123
|
+
for ladder in getattr(themes, "THEMES", {}).values():
|
|
124
|
+
for name in ladder:
|
|
125
|
+
if isinstance(name, str) and len(name) >= 3:
|
|
126
|
+
rank_names.add(name)
|
|
127
|
+
after_sigil = tail[sigil_match.end():sigil_match.end() + 30]
|
|
128
|
+
return any(name in after_sigil for name in rank_names)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read_marker_path() -> Path:
|
|
132
|
+
return resolve_coach_dir() / WRAP_MARKER_NAME
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _read_saved_original() -> str | None:
|
|
136
|
+
"""Return the saved original command string, or None if marker
|
|
137
|
+
missing / unparseable. Never raises."""
|
|
138
|
+
try:
|
|
139
|
+
path = _read_marker_path()
|
|
140
|
+
if not path.exists():
|
|
141
|
+
return None
|
|
142
|
+
data = json.loads(path.read_text())
|
|
143
|
+
cmd = data.get("original_command")
|
|
144
|
+
return cmd if isinstance(cmd, str) and cmd else None
|
|
145
|
+
except Exception:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _write_duplicate_marker() -> None:
|
|
150
|
+
"""Best-effort marker write. The hook reads it on next prompt to
|
|
151
|
+
surface a banner. Never raises.
|
|
152
|
+
|
|
153
|
+
Schema matches the consumed-by pattern (`created_at` + `consumed_by`)
|
|
154
|
+
so `_read_and_consume` in the hook handles per-session dedup + TTL."""
|
|
155
|
+
try:
|
|
156
|
+
coach_dir = resolve_coach_dir()
|
|
157
|
+
coach_dir.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
marker = coach_dir / DUPLICATE_MARKER_NAME
|
|
159
|
+
if not marker.exists():
|
|
160
|
+
marker.write_text(json.dumps({
|
|
161
|
+
"created_at": _utc_now_iso(),
|
|
162
|
+
"consumed_by": [],
|
|
163
|
+
}))
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _utc_now_iso() -> str:
|
|
169
|
+
from datetime import datetime, timezone
|
|
170
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _run_original(command: str, payload_raw: str) -> str:
|
|
174
|
+
"""Run the user's original command with the stdin payload forwarded.
|
|
175
|
+
|
|
176
|
+
Returns its stdout on success (zero exit, within timeout), else "".
|
|
177
|
+
Stderr is discarded — the statusline must stay quiet.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
result = subprocess.run(
|
|
181
|
+
command,
|
|
182
|
+
shell=True,
|
|
183
|
+
input=payload_raw,
|
|
184
|
+
capture_output=True,
|
|
185
|
+
timeout=ORIGINAL_TIMEOUT_SECONDS,
|
|
186
|
+
text=True,
|
|
187
|
+
)
|
|
188
|
+
if result.returncode != 0:
|
|
189
|
+
return ""
|
|
190
|
+
return result.stdout or ""
|
|
191
|
+
except Exception:
|
|
192
|
+
return ""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _parse_payload(raw: str) -> dict:
|
|
196
|
+
try:
|
|
197
|
+
if not raw:
|
|
198
|
+
return {}
|
|
199
|
+
data = json.loads(raw)
|
|
200
|
+
return data if isinstance(data, dict) else {}
|
|
201
|
+
except Exception:
|
|
202
|
+
return {}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _coach_segment(payload: dict) -> str:
|
|
206
|
+
try:
|
|
207
|
+
from stats import render_segment # noqa: WPS433 — lazy
|
|
208
|
+
return render_segment(payload) or ""
|
|
209
|
+
except Exception:
|
|
210
|
+
return ""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main() -> int:
|
|
214
|
+
original = ""
|
|
215
|
+
try:
|
|
216
|
+
try:
|
|
217
|
+
payload_raw = "" if sys.stdin.isatty() else sys.stdin.read()
|
|
218
|
+
except Exception:
|
|
219
|
+
payload_raw = ""
|
|
220
|
+
|
|
221
|
+
saved = _read_saved_original()
|
|
222
|
+
if saved is not None:
|
|
223
|
+
original = _run_original(saved, payload_raw)
|
|
224
|
+
|
|
225
|
+
payload = _parse_payload(payload_raw)
|
|
226
|
+
coach = _coach_segment(payload)
|
|
227
|
+
|
|
228
|
+
if original and _looks_like_coach_output(original):
|
|
229
|
+
_write_duplicate_marker()
|
|
230
|
+
sys.stdout.write(original)
|
|
231
|
+
return 0
|
|
232
|
+
|
|
233
|
+
sys.stdout.write(compose(original, coach))
|
|
234
|
+
except Exception:
|
|
235
|
+
# Last-resort failsafe: emit whatever we captured (or nothing).
|
|
236
|
+
try:
|
|
237
|
+
sys.stdout.write(original)
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
sys.exit(main())
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""wrap / unwrap actions for the user's statusLine command.
|
|
3
|
+
|
|
4
|
+
Shared by `coach/bin/doctor.py`, the plugin's SessionStart hook
|
|
5
|
+
(`statusline_self_patch.py`), and the CLI's `install.sh`. All three
|
|
6
|
+
call into the same code so behavior stays consistent across surfaces.
|
|
7
|
+
|
|
8
|
+
The module exposes two main functions:
|
|
9
|
+
|
|
10
|
+
wrap(*, coach_dir, settings_path, plugin_root, force) -> dict
|
|
11
|
+
unwrap(*, coach_dir, settings_path, force, mark_opted_out) -> dict
|
|
12
|
+
|
|
13
|
+
…and one CLI subcommand for shell callers (install.sh, hooks):
|
|
14
|
+
|
|
15
|
+
python3 statusline_wrap_action.py wrap-if-claimed
|
|
16
|
+
Auto-wrap on first run when settings.json:statusLine is
|
|
17
|
+
`claimed` and no opt-out marker exists. Honors
|
|
18
|
+
$CLAUDE_PLUGIN_ROOT (plugin shape) when set, falls through to
|
|
19
|
+
CLI shape otherwise. Always exits 0 (never breaks an install).
|
|
20
|
+
|
|
21
|
+
Markers (all under `<coach_dir>/`):
|
|
22
|
+
|
|
23
|
+
.statusline-wrap.json saved original; written by wrap;
|
|
24
|
+
deleted by unwrap.
|
|
25
|
+
.statusline-wrap-disabled sticky opt-out; written by unwrap
|
|
26
|
+
or by the manual-Coach skip path;
|
|
27
|
+
cleared by an explicit wrap.
|
|
28
|
+
.statusline-wrap-announced one-time banner trigger; written by
|
|
29
|
+
wrap; consumed by the user-prompt hook.
|
|
30
|
+
.statusline-wrap-duplicate-detected runtime detection trigger; written by
|
|
31
|
+
statusline_wrap.py at render time when
|
|
32
|
+
it spots a Coach signature in the
|
|
33
|
+
original output.
|
|
34
|
+
|
|
35
|
+
Path resolution (fix #3): every public function takes explicit
|
|
36
|
+
`coach_dir` + `settings_path` kwargs. They default to env-aware
|
|
37
|
+
resolution (`coach_paths.resolve_coach_dir()` for coach_dir,
|
|
38
|
+
`$CLAUDE_SETTINGS_PATH` or `~/.claude/settings.json` for settings).
|
|
39
|
+
Tests always pass explicit paths.
|
|
40
|
+
|
|
41
|
+
Wrapper command shapes (fix #2):
|
|
42
|
+
- CLI: `bash <coach_dir>/default-statusline-wrap-command.sh`
|
|
43
|
+
- Plugin: `<plugin_root>/bin/bootstrap.sh <plugin_root>/bin/statusline_wrap.py`
|
|
44
|
+
"""
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import json
|
|
48
|
+
import os
|
|
49
|
+
import re
|
|
50
|
+
import shlex
|
|
51
|
+
import sys
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
from typing import Optional
|
|
54
|
+
|
|
55
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
56
|
+
|
|
57
|
+
from coach_paths import resolve_coach_dir # noqa: E402
|
|
58
|
+
from statusline_self_patch import ( # noqa: E402
|
|
59
|
+
COACH_STATUSLINE_MARKERS,
|
|
60
|
+
_atomic_write,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Marker filenames — single source of truth so doctor + hook + tests agree.
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
WRAP_MARKER_NAME = ".statusline-wrap.json"
|
|
68
|
+
DISABLED_MARKER_NAME = ".statusline-wrap-disabled"
|
|
69
|
+
ANNOUNCE_MARKER_NAME = ".statusline-wrap-announced"
|
|
70
|
+
DUPLICATE_MARKER_NAME = ".statusline-wrap-duplicate-detected"
|
|
71
|
+
|
|
72
|
+
# Substring fragments that identify our wrapper command in a
|
|
73
|
+
# settings.json statusLine entry. The plugin shape contains
|
|
74
|
+
# `statusline_wrap.py` directly; the CLI shape goes through a shell
|
|
75
|
+
# trampoline whose name contains `default-statusline-wrap-command.sh`.
|
|
76
|
+
# Either match means "this statusLine is currently our wrapper".
|
|
77
|
+
WRAP_SCRIPT_MARKER = "statusline_wrap.py"
|
|
78
|
+
WRAP_TRAMPOLINE_MARKER = "default-statusline-wrap-command.sh"
|
|
79
|
+
WRAP_COMMAND_MARKERS = (WRAP_SCRIPT_MARKER, WRAP_TRAMPOLINE_MARKER)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_wrap_command(cmd: str) -> bool:
|
|
83
|
+
return any(m in cmd for m in WRAP_COMMAND_MARKERS)
|
|
84
|
+
|
|
85
|
+
# Regex pulled from the existing patcher — recognizes Coach references in
|
|
86
|
+
# a user's custom shell statusline (default integration via stats.py /
|
|
87
|
+
# default_statusline.py / etc.). Anchors the manual-Coach pre-flight.
|
|
88
|
+
_INTEGRATION_REFS = (
|
|
89
|
+
"coach/bin/stats.py",
|
|
90
|
+
"default_statusline.py",
|
|
91
|
+
"coach/bin/",
|
|
92
|
+
"default-statusline-command.sh",
|
|
93
|
+
"default_statusline_path",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Path + marker helpers
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _default_settings_path() -> Path:
|
|
103
|
+
env = os.environ.get("CLAUDE_SETTINGS_PATH")
|
|
104
|
+
if env:
|
|
105
|
+
return Path(env)
|
|
106
|
+
return Path.home() / ".claude" / "settings.json"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _resolve_dir(coach_dir: Path | None) -> Path:
|
|
110
|
+
return coach_dir if coach_dir is not None else resolve_coach_dir()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _resolve_settings(settings_path: Path | None) -> Path:
|
|
114
|
+
return settings_path if settings_path is not None else _default_settings_path()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _utc_now_iso() -> str:
|
|
118
|
+
from datetime import datetime, timezone
|
|
119
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _read_json(path: Path) -> dict | None:
|
|
123
|
+
try:
|
|
124
|
+
if not path.exists():
|
|
125
|
+
return None
|
|
126
|
+
data = json.loads(path.read_text())
|
|
127
|
+
return data if isinstance(data, dict) else None
|
|
128
|
+
except Exception:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _write_json(path: Path, payload: dict) -> None:
|
|
133
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
135
|
+
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True))
|
|
136
|
+
os.replace(tmp, path)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Classification (matches doctor / statusline_self_patch logic).
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _classify(entry, coach_dir: Path) -> str:
|
|
145
|
+
"""One of: absent, ours-default, ours-wrapped, integrated-externally, claimed."""
|
|
146
|
+
if not isinstance(entry, dict):
|
|
147
|
+
return "absent"
|
|
148
|
+
cmd = str(entry.get("command", ""))
|
|
149
|
+
if not cmd:
|
|
150
|
+
return "absent"
|
|
151
|
+
if _is_wrap_command(cmd):
|
|
152
|
+
return "ours-wrapped"
|
|
153
|
+
if any(m in cmd for m in COACH_STATUSLINE_MARKERS):
|
|
154
|
+
return "ours-default"
|
|
155
|
+
disabled = _read_json(coach_dir / DISABLED_MARKER_NAME) or {}
|
|
156
|
+
if disabled.get("reason") == "already-integrated":
|
|
157
|
+
return "integrated-externally"
|
|
158
|
+
return "claimed"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Manual-Coach pre-flight: sniff the user's script for Coach references.
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
_SCRIPT_PATH_RE = re.compile(r"(?:^|\s)((?:/|~|\$HOME|\$\{HOME\})\S+\.sh)")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _resolve_script_path(command: str) -> Path | None:
|
|
170
|
+
"""Best-effort: locate the script file referenced by `command`.
|
|
171
|
+
|
|
172
|
+
Handles:
|
|
173
|
+
- `bash /abs/path/to/script.sh`
|
|
174
|
+
- `/abs/path/to/script.sh`
|
|
175
|
+
- `bash ~/path/to/script.sh` (expanduser)
|
|
176
|
+
- `bash $HOME/path/...` (env-expand)
|
|
177
|
+
|
|
178
|
+
Returns None if no script-file is identifiable (e.g., `bash -c "..."`,
|
|
179
|
+
inline pipelines), in which case the static sniff is skipped.
|
|
180
|
+
"""
|
|
181
|
+
if not command:
|
|
182
|
+
return None
|
|
183
|
+
expanded = os.path.expanduser(os.path.expandvars(command))
|
|
184
|
+
# Tokenize like a shell does; first .sh / .py path token wins.
|
|
185
|
+
try:
|
|
186
|
+
parts = shlex.split(expanded, comments=False, posix=True)
|
|
187
|
+
except ValueError:
|
|
188
|
+
return None
|
|
189
|
+
for tok in parts:
|
|
190
|
+
# Skip bash flags and `-c` (signals an inline script body).
|
|
191
|
+
if tok.startswith("-"):
|
|
192
|
+
return None
|
|
193
|
+
if tok.endswith(".sh") or tok.endswith(".py"):
|
|
194
|
+
p = Path(tok)
|
|
195
|
+
if p.is_absolute() and p.exists():
|
|
196
|
+
return p
|
|
197
|
+
return None
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _detect_manual_coach_integration(command: str) -> tuple[bool, Path | None]:
|
|
202
|
+
"""If the user's statusLine script references Coach internals, return
|
|
203
|
+
(True, path). Else (False, None)."""
|
|
204
|
+
script = _resolve_script_path(command)
|
|
205
|
+
if script is None:
|
|
206
|
+
return False, None
|
|
207
|
+
try:
|
|
208
|
+
content = script.read_text(errors="replace")
|
|
209
|
+
except Exception:
|
|
210
|
+
return False, None
|
|
211
|
+
if any(ref in content for ref in _INTEGRATION_REFS):
|
|
212
|
+
return True, script
|
|
213
|
+
return False, None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Wrapper-command builders.
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _build_wrapper_command(
|
|
222
|
+
*, coach_dir: Path, plugin_root: Path | None
|
|
223
|
+
) -> str:
|
|
224
|
+
"""Return the settings.json `command` string for the wrap shape.
|
|
225
|
+
|
|
226
|
+
Plugin path-shape uses bootstrap.sh + statusline_wrap.py (mirrors
|
|
227
|
+
`_desired_entry` in statusline_self_patch.py). CLI uses a shell
|
|
228
|
+
trampoline at `<coach_dir>/default-statusline-wrap-command.sh` so
|
|
229
|
+
the path stays stable across coach upgrades.
|
|
230
|
+
"""
|
|
231
|
+
if plugin_root is not None:
|
|
232
|
+
bootstrap = shlex.quote(str(plugin_root / "bin" / "bootstrap.sh"))
|
|
233
|
+
wrap_py = shlex.quote(str(plugin_root / "bin" / "statusline_wrap.py"))
|
|
234
|
+
return f"{bootstrap} {wrap_py}"
|
|
235
|
+
trampoline = shlex.quote(str(coach_dir / "default-statusline-wrap-command.sh"))
|
|
236
|
+
return f"bash {trampoline}"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# Public API: wrap / unwrap
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def wrap(
|
|
245
|
+
*,
|
|
246
|
+
coach_dir: Path | None = None,
|
|
247
|
+
settings_path: Path | None = None,
|
|
248
|
+
plugin_root: Path | None = None,
|
|
249
|
+
force: bool = False,
|
|
250
|
+
) -> dict:
|
|
251
|
+
"""Idempotent wrap. Returns a result dict.
|
|
252
|
+
|
|
253
|
+
Result `result` field:
|
|
254
|
+
"wrapped" — settings.json mutated, original saved.
|
|
255
|
+
"skipped" — no mutation; reason in `reason` field.
|
|
256
|
+
"no-op" — already in target state.
|
|
257
|
+
"error" — read/write failure (always fail-soft).
|
|
258
|
+
|
|
259
|
+
Detailed `reason` strings (when `result == "skipped"`):
|
|
260
|
+
"absent" — settings.json has no statusLine entry at all.
|
|
261
|
+
"already-coach" — statusLine is already Coach (default or wrapped).
|
|
262
|
+
"opted-out" — opt-out marker present (cleared by force=True).
|
|
263
|
+
"already-integrated" — manual-Coach pre-flight detected refs.
|
|
264
|
+
|
|
265
|
+
`force=True` clears the opt-out marker AND bypasses the manual-Coach
|
|
266
|
+
pre-flight skip. It does NOT bypass `already-coach` (idempotent
|
|
267
|
+
no-op stays a no-op).
|
|
268
|
+
"""
|
|
269
|
+
cdir = _resolve_dir(coach_dir)
|
|
270
|
+
spath = _resolve_settings(settings_path)
|
|
271
|
+
cdir.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
settings = _read_json(spath)
|
|
275
|
+
if settings is None:
|
|
276
|
+
return {"result": "skipped", "reason": "no-settings"}
|
|
277
|
+
|
|
278
|
+
entry = settings.get("statusLine")
|
|
279
|
+
kind = _classify(entry, cdir)
|
|
280
|
+
if kind == "absent":
|
|
281
|
+
return {"result": "skipped", "reason": "absent"}
|
|
282
|
+
if kind in ("ours-default", "ours-wrapped"):
|
|
283
|
+
# Special case: ours-wrapped with stale wrapper path → refresh.
|
|
284
|
+
if kind == "ours-wrapped":
|
|
285
|
+
desired = _build_wrapper_command(
|
|
286
|
+
coach_dir=cdir, plugin_root=plugin_root
|
|
287
|
+
)
|
|
288
|
+
current = str((entry or {}).get("command", ""))
|
|
289
|
+
if current != desired:
|
|
290
|
+
settings["statusLine"] = {
|
|
291
|
+
"type": "command",
|
|
292
|
+
"command": desired,
|
|
293
|
+
}
|
|
294
|
+
_atomic_write(spath, settings)
|
|
295
|
+
return {"result": "wrapped", "reason": "refreshed-path",
|
|
296
|
+
"command": desired}
|
|
297
|
+
return {"result": "no-op", "reason": "already-wrapped"}
|
|
298
|
+
return {"result": "no-op", "reason": "already-coach"}
|
|
299
|
+
|
|
300
|
+
# Opt-out gate (clearable via force=True).
|
|
301
|
+
disabled_path = cdir / DISABLED_MARKER_NAME
|
|
302
|
+
if disabled_path.exists() and not force:
|
|
303
|
+
return {"result": "skipped", "reason": "opted-out"}
|
|
304
|
+
|
|
305
|
+
original_cmd = str((entry or {}).get("command", ""))
|
|
306
|
+
|
|
307
|
+
# Manual-Coach pre-flight: skip + write opt-out marker.
|
|
308
|
+
manual, script = _detect_manual_coach_integration(original_cmd)
|
|
309
|
+
if manual and not force:
|
|
310
|
+
_write_json(disabled_path, {
|
|
311
|
+
"reason": "already-integrated",
|
|
312
|
+
"detected_in": str(script) if script else None,
|
|
313
|
+
"detected_at": _utc_now_iso(),
|
|
314
|
+
})
|
|
315
|
+
return {"result": "skipped", "reason": "already-integrated",
|
|
316
|
+
"detected_in": str(script) if script else None}
|
|
317
|
+
|
|
318
|
+
# Save original BEFORE mutating settings.json.
|
|
319
|
+
wrap_payload = {
|
|
320
|
+
"original_command": original_cmd,
|
|
321
|
+
"wrapped_at": _utc_now_iso(),
|
|
322
|
+
}
|
|
323
|
+
_write_json(cdir / WRAP_MARKER_NAME, wrap_payload)
|
|
324
|
+
|
|
325
|
+
# Mutate settings.json atomically.
|
|
326
|
+
wrapper_cmd = _build_wrapper_command(
|
|
327
|
+
coach_dir=cdir, plugin_root=plugin_root
|
|
328
|
+
)
|
|
329
|
+
settings["statusLine"] = {"type": "command", "command": wrapper_cmd}
|
|
330
|
+
_atomic_write(spath, settings)
|
|
331
|
+
|
|
332
|
+
# Clear opt-out (re-opt-in semantics) and set the announce marker.
|
|
333
|
+
if disabled_path.exists():
|
|
334
|
+
try:
|
|
335
|
+
disabled_path.unlink()
|
|
336
|
+
except Exception:
|
|
337
|
+
pass
|
|
338
|
+
_write_json(cdir / ANNOUNCE_MARKER_NAME, {
|
|
339
|
+
# `created_at` is the field _read_and_consume reads for TTL —
|
|
340
|
+
# stay aligned with the rest of the marker ecosystem.
|
|
341
|
+
"created_at": _utc_now_iso(),
|
|
342
|
+
"consumed_by": [],
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
return {"result": "wrapped", "command": wrapper_cmd,
|
|
346
|
+
"saved_original": original_cmd}
|
|
347
|
+
except Exception as exc:
|
|
348
|
+
return {"result": "error", "reason": "exception", "detail": str(exc)}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def unwrap(
|
|
352
|
+
*,
|
|
353
|
+
coach_dir: Path | None = None,
|
|
354
|
+
settings_path: Path | None = None,
|
|
355
|
+
force: bool = False,
|
|
356
|
+
mark_opted_out: bool = True,
|
|
357
|
+
) -> dict:
|
|
358
|
+
"""Restore the saved original command. Sticky opt-out by default.
|
|
359
|
+
|
|
360
|
+
Result `result` field:
|
|
361
|
+
"unwrapped" — restored.
|
|
362
|
+
"no-op" — nothing to do (no marker / no settings).
|
|
363
|
+
"refused" — current command was edited since wrap; refusing
|
|
364
|
+
unless `force=True`.
|
|
365
|
+
"error" — read/write failure.
|
|
366
|
+
"""
|
|
367
|
+
cdir = _resolve_dir(coach_dir)
|
|
368
|
+
spath = _resolve_settings(settings_path)
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
marker = cdir / WRAP_MARKER_NAME
|
|
372
|
+
wrap_data = _read_json(marker)
|
|
373
|
+
if wrap_data is None:
|
|
374
|
+
return {"result": "no-op", "reason": "no-wrap-marker"}
|
|
375
|
+
|
|
376
|
+
saved_original = wrap_data.get("original_command")
|
|
377
|
+
if not isinstance(saved_original, str):
|
|
378
|
+
return {"result": "error", "reason": "marker-invalid"}
|
|
379
|
+
|
|
380
|
+
settings = _read_json(spath)
|
|
381
|
+
if settings is None:
|
|
382
|
+
return {"result": "no-op", "reason": "no-settings"}
|
|
383
|
+
|
|
384
|
+
current_cmd = str(((settings.get("statusLine") or {}) or {}).get("command", ""))
|
|
385
|
+
if not _is_wrap_command(current_cmd) and not force:
|
|
386
|
+
return {
|
|
387
|
+
"result": "refused",
|
|
388
|
+
"reason": "command-changed-since-wrap",
|
|
389
|
+
"current_command": current_cmd,
|
|
390
|
+
"saved_original": saved_original,
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
settings["statusLine"] = {"type": "command", "command": saved_original}
|
|
394
|
+
_atomic_write(spath, settings)
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
marker.unlink()
|
|
398
|
+
except Exception:
|
|
399
|
+
pass
|
|
400
|
+
|
|
401
|
+
if mark_opted_out:
|
|
402
|
+
_write_json(cdir / DISABLED_MARKER_NAME, {
|
|
403
|
+
"reason": "user-unwrapped",
|
|
404
|
+
"unwrapped_at": _utc_now_iso(),
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
return {"result": "unwrapped", "restored_command": saved_original}
|
|
408
|
+
except Exception as exc:
|
|
409
|
+
return {"result": "error", "reason": "exception", "detail": str(exc)}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
# CLI: `wrap-if-claimed`
|
|
414
|
+
# ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _cmd_wrap_if_claimed(argv: list[str]) -> int:
|
|
418
|
+
plugin_root_env = os.environ.get("CLAUDE_PLUGIN_ROOT")
|
|
419
|
+
plugin_root = Path(plugin_root_env).resolve() if plugin_root_env else None
|
|
420
|
+
result = wrap(plugin_root=plugin_root)
|
|
421
|
+
code = result.get("result", "error")
|
|
422
|
+
reason = result.get("reason", "")
|
|
423
|
+
if code == "wrapped":
|
|
424
|
+
print(f" OK: wrapped existing statusLine (Coach segment will append)")
|
|
425
|
+
elif code == "skipped" and reason == "already-integrated":
|
|
426
|
+
path = result.get("detected_in", "")
|
|
427
|
+
suffix = f" (detected in {path})" if path else ""
|
|
428
|
+
print(f" OK: leaving statusLine alone — already integrates Coach{suffix}")
|
|
429
|
+
elif code == "skipped" and reason == "opted-out":
|
|
430
|
+
print(f" OK: leaving statusLine alone — user opted out via --unwrap-statusline")
|
|
431
|
+
elif code == "skipped" and reason == "absent":
|
|
432
|
+
print(f" note: no statusLine to wrap — nothing to do")
|
|
433
|
+
elif code == "no-op":
|
|
434
|
+
print(f" OK: statusLine already wrapped (no change)")
|
|
435
|
+
elif code == "error":
|
|
436
|
+
print(f" warn: wrap-if-claimed failed: {result.get('detail', '')}")
|
|
437
|
+
else:
|
|
438
|
+
print(f" note: wrap-if-claimed result={code} reason={reason}")
|
|
439
|
+
return 0 # never break an install
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _build_parser():
|
|
443
|
+
import argparse
|
|
444
|
+
p = argparse.ArgumentParser(prog="statusline_wrap_action")
|
|
445
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
446
|
+
sub.add_parser("wrap-if-claimed",
|
|
447
|
+
help="Auto-wrap when statusLine is claimed; else no-op.")
|
|
448
|
+
return p
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
452
|
+
parser = _build_parser()
|
|
453
|
+
args = parser.parse_args(argv)
|
|
454
|
+
if args.cmd == "wrap-if-claimed":
|
|
455
|
+
return _cmd_wrap_if_claimed(argv or [])
|
|
456
|
+
return 0
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
if __name__ == "__main__":
|
|
460
|
+
sys.exit(main())
|