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