@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,408 @@
|
|
|
1
|
+
"""statusline_wrap_action — wrap/unwrap idempotency, opt-out stickiness,
|
|
2
|
+
path freshness, current-command guard, manual-Coach pre-flight, and the
|
|
3
|
+
`wrap-if-claimed` CLI subcommand."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shlex
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
import statusline_wrap_action as wa
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# --- fixtures --------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def settings_path(tmp_path):
|
|
21
|
+
p = tmp_path / "settings.json"
|
|
22
|
+
p.write_text(json.dumps({}))
|
|
23
|
+
return p
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def coach_dir(tmp_path):
|
|
28
|
+
p = tmp_path / "coach"
|
|
29
|
+
p.mkdir()
|
|
30
|
+
return p
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def plugin_root(tmp_path):
|
|
35
|
+
p = tmp_path / "plugin"
|
|
36
|
+
(p / "bin").mkdir(parents=True)
|
|
37
|
+
return p
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _set_statusline(settings_path: Path, command: str) -> None:
|
|
41
|
+
data = json.loads(settings_path.read_text())
|
|
42
|
+
data["statusLine"] = {"type": "command", "command": command}
|
|
43
|
+
settings_path.write_text(json.dumps(data))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read_statusline(settings_path: Path) -> str:
|
|
47
|
+
return json.loads(settings_path.read_text())["statusLine"]["command"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- wrap() happy paths ---------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_wrap_on_claimed_writes_marker_and_mutates_settings(
|
|
54
|
+
settings_path, coach_dir
|
|
55
|
+
):
|
|
56
|
+
_set_statusline(settings_path, "bash /opt/my-line.sh")
|
|
57
|
+
res = wa.wrap(coach_dir=coach_dir, settings_path=settings_path)
|
|
58
|
+
|
|
59
|
+
assert res["result"] == "wrapped"
|
|
60
|
+
marker = json.loads((coach_dir / wa.WRAP_MARKER_NAME).read_text())
|
|
61
|
+
assert marker["original_command"] == "bash /opt/my-line.sh"
|
|
62
|
+
|
|
63
|
+
new_cmd = _read_statusline(settings_path)
|
|
64
|
+
assert "default-statusline-wrap-command.sh" in new_cmd # CLI shape
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_wrap_with_plugin_root_uses_plugin_shape(
|
|
68
|
+
settings_path, coach_dir, plugin_root
|
|
69
|
+
):
|
|
70
|
+
_set_statusline(settings_path, "bash /opt/my-line.sh")
|
|
71
|
+
res = wa.wrap(
|
|
72
|
+
coach_dir=coach_dir,
|
|
73
|
+
settings_path=settings_path,
|
|
74
|
+
plugin_root=plugin_root,
|
|
75
|
+
)
|
|
76
|
+
assert res["result"] == "wrapped"
|
|
77
|
+
new_cmd = _read_statusline(settings_path)
|
|
78
|
+
assert "bootstrap.sh" in new_cmd
|
|
79
|
+
assert "statusline_wrap.py" in new_cmd
|
|
80
|
+
assert "default-statusline-wrap-command.sh" not in new_cmd
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_wrap_writes_announce_marker(settings_path, coach_dir):
|
|
84
|
+
_set_statusline(settings_path, "bash /opt/x.sh")
|
|
85
|
+
wa.wrap(coach_dir=coach_dir, settings_path=settings_path)
|
|
86
|
+
assert (coach_dir / wa.ANNOUNCE_MARKER_NAME).exists()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_wrap_clears_existing_optout_marker(settings_path, coach_dir):
|
|
90
|
+
_set_statusline(settings_path, "bash /opt/x.sh")
|
|
91
|
+
(coach_dir / wa.DISABLED_MARKER_NAME).write_text(json.dumps({"reason": "stale"}))
|
|
92
|
+
wa.wrap(coach_dir=coach_dir, settings_path=settings_path, force=True)
|
|
93
|
+
assert not (coach_dir / wa.DISABLED_MARKER_NAME).exists()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --- wrap() idempotency / skip paths --------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_wrap_idempotent_when_already_wrapped(
|
|
100
|
+
settings_path, coach_dir, plugin_root
|
|
101
|
+
):
|
|
102
|
+
_set_statusline(settings_path, "bash /opt/x.sh")
|
|
103
|
+
wa.wrap(
|
|
104
|
+
coach_dir=coach_dir, settings_path=settings_path,
|
|
105
|
+
plugin_root=plugin_root,
|
|
106
|
+
)
|
|
107
|
+
cmd_after_first = _read_statusline(settings_path)
|
|
108
|
+
|
|
109
|
+
res2 = wa.wrap(
|
|
110
|
+
coach_dir=coach_dir, settings_path=settings_path,
|
|
111
|
+
plugin_root=plugin_root,
|
|
112
|
+
)
|
|
113
|
+
assert res2["result"] == "no-op"
|
|
114
|
+
assert _read_statusline(settings_path) == cmd_after_first
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_wrap_refreshes_stale_plugin_root_path(
|
|
118
|
+
settings_path, coach_dir, tmp_path
|
|
119
|
+
):
|
|
120
|
+
"""ours-wrapped with a stale plugin_root → command rewrites with the
|
|
121
|
+
fresh path."""
|
|
122
|
+
old_root = tmp_path / "plugin-old"
|
|
123
|
+
(old_root / "bin").mkdir(parents=True)
|
|
124
|
+
new_root = tmp_path / "plugin-new"
|
|
125
|
+
(new_root / "bin").mkdir(parents=True)
|
|
126
|
+
|
|
127
|
+
_set_statusline(settings_path, "bash /opt/x.sh")
|
|
128
|
+
wa.wrap(coach_dir=coach_dir, settings_path=settings_path, plugin_root=old_root)
|
|
129
|
+
assert str(old_root) in _read_statusline(settings_path)
|
|
130
|
+
|
|
131
|
+
res = wa.wrap(coach_dir=coach_dir, settings_path=settings_path, plugin_root=new_root)
|
|
132
|
+
assert res["result"] == "wrapped"
|
|
133
|
+
assert res["reason"] == "refreshed-path"
|
|
134
|
+
assert str(new_root) in _read_statusline(settings_path)
|
|
135
|
+
assert str(old_root) not in _read_statusline(settings_path)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_wrap_respects_optout_marker(settings_path, coach_dir):
|
|
139
|
+
"""Sticky opt-out → wrap skips and does not mutate settings."""
|
|
140
|
+
_set_statusline(settings_path, "bash /opt/x.sh")
|
|
141
|
+
(coach_dir / wa.DISABLED_MARKER_NAME).write_text(json.dumps({
|
|
142
|
+
"reason": "user-unwrapped",
|
|
143
|
+
}))
|
|
144
|
+
res = wa.wrap(coach_dir=coach_dir, settings_path=settings_path)
|
|
145
|
+
assert res["result"] == "skipped"
|
|
146
|
+
assert res["reason"] == "opted-out"
|
|
147
|
+
assert _read_statusline(settings_path) == "bash /opt/x.sh" # untouched
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_wrap_force_bypasses_optout(settings_path, coach_dir):
|
|
151
|
+
_set_statusline(settings_path, "bash /opt/x.sh")
|
|
152
|
+
(coach_dir / wa.DISABLED_MARKER_NAME).write_text(json.dumps({
|
|
153
|
+
"reason": "user-unwrapped",
|
|
154
|
+
}))
|
|
155
|
+
res = wa.wrap(coach_dir=coach_dir, settings_path=settings_path, force=True)
|
|
156
|
+
assert res["result"] == "wrapped"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_wrap_skips_when_settings_absent(coach_dir, tmp_path):
|
|
160
|
+
no_settings = tmp_path / "nope.json"
|
|
161
|
+
res = wa.wrap(coach_dir=coach_dir, settings_path=no_settings)
|
|
162
|
+
assert res["result"] == "skipped"
|
|
163
|
+
assert res["reason"] == "no-settings"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_wrap_skips_when_statusline_absent(settings_path, coach_dir):
|
|
167
|
+
"""Empty settings.json → no statusLine → wrap does nothing."""
|
|
168
|
+
res = wa.wrap(coach_dir=coach_dir, settings_path=settings_path)
|
|
169
|
+
assert res["result"] == "skipped"
|
|
170
|
+
assert res["reason"] == "absent"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --- manual-Coach pre-flight ----------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_wrap_skips_when_user_script_references_stats_py(
|
|
177
|
+
settings_path, coach_dir, tmp_path
|
|
178
|
+
):
|
|
179
|
+
"""Ryan's exact case: user's statusline-command.sh internally calls
|
|
180
|
+
coach/bin/stats.py → wrap must detect and skip."""
|
|
181
|
+
user_script = tmp_path / "statusline-command.sh"
|
|
182
|
+
user_script.write_text(
|
|
183
|
+
"#!/bin/bash\n"
|
|
184
|
+
'exec "$HOME/.claude/coach/bin/stats.py"\n'
|
|
185
|
+
)
|
|
186
|
+
_set_statusline(settings_path, f"bash {user_script}")
|
|
187
|
+
|
|
188
|
+
res = wa.wrap(coach_dir=coach_dir, settings_path=settings_path)
|
|
189
|
+
assert res["result"] == "skipped"
|
|
190
|
+
assert res["reason"] == "already-integrated"
|
|
191
|
+
assert str(user_script) in res.get("detected_in", "")
|
|
192
|
+
# Settings.json untouched
|
|
193
|
+
assert _read_statusline(settings_path) == f"bash {user_script}"
|
|
194
|
+
# Sticky opt-out marker written so future auto-wrap also skips.
|
|
195
|
+
disabled = json.loads((coach_dir / wa.DISABLED_MARKER_NAME).read_text())
|
|
196
|
+
assert disabled["reason"] == "already-integrated"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_wrap_skips_when_user_script_references_default_statusline(
|
|
200
|
+
settings_path, coach_dir, tmp_path
|
|
201
|
+
):
|
|
202
|
+
user_script = tmp_path / "my-line.sh"
|
|
203
|
+
user_script.write_text(
|
|
204
|
+
"#!/bin/bash\n"
|
|
205
|
+
'"$HOME/.claude/coach/bin/default_statusline.py"\n'
|
|
206
|
+
)
|
|
207
|
+
_set_statusline(settings_path, f"bash {user_script}")
|
|
208
|
+
res = wa.wrap(coach_dir=coach_dir, settings_path=settings_path)
|
|
209
|
+
assert res["result"] == "skipped"
|
|
210
|
+
assert res["reason"] == "already-integrated"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_wrap_proceeds_on_unrelated_user_script(settings_path, coach_dir, tmp_path):
|
|
214
|
+
"""User's script doesn't reference Coach internals → wrap proceeds."""
|
|
215
|
+
user_script = tmp_path / "other.sh"
|
|
216
|
+
user_script.write_text("#!/bin/bash\necho 'just a custom statusline'\n")
|
|
217
|
+
_set_statusline(settings_path, f"bash {user_script}")
|
|
218
|
+
|
|
219
|
+
res = wa.wrap(coach_dir=coach_dir, settings_path=settings_path)
|
|
220
|
+
assert res["result"] == "wrapped"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_wrap_proceeds_when_command_has_no_script_file(settings_path, coach_dir):
|
|
224
|
+
"""`bash -c '...'` shape — static sniff can't introspect; fall through
|
|
225
|
+
and let runtime duplicate-detection handle it."""
|
|
226
|
+
_set_statusline(settings_path, "bash -c 'echo hi'")
|
|
227
|
+
res = wa.wrap(coach_dir=coach_dir, settings_path=settings_path)
|
|
228
|
+
assert res["result"] == "wrapped"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_wrap_force_bypasses_manual_coach_pre_flight(
|
|
232
|
+
settings_path, coach_dir, tmp_path
|
|
233
|
+
):
|
|
234
|
+
"""`--force` proceeds even when manual integration is detected."""
|
|
235
|
+
user_script = tmp_path / "statusline-command.sh"
|
|
236
|
+
user_script.write_text(
|
|
237
|
+
"#!/bin/bash\nexec coach/bin/stats.py\n"
|
|
238
|
+
)
|
|
239
|
+
_set_statusline(settings_path, f"bash {user_script}")
|
|
240
|
+
res = wa.wrap(coach_dir=coach_dir, settings_path=settings_path, force=True)
|
|
241
|
+
assert res["result"] == "wrapped"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# --- unwrap() happy paths -------------------------------------------------
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_unwrap_round_trip_restores_original(settings_path, coach_dir):
|
|
248
|
+
original = "bash /opt/my-original.sh"
|
|
249
|
+
_set_statusline(settings_path, original)
|
|
250
|
+
wa.wrap(coach_dir=coach_dir, settings_path=settings_path)
|
|
251
|
+
|
|
252
|
+
res = wa.unwrap(coach_dir=coach_dir, settings_path=settings_path)
|
|
253
|
+
assert res["result"] == "unwrapped"
|
|
254
|
+
assert _read_statusline(settings_path) == original
|
|
255
|
+
# Marker deleted
|
|
256
|
+
assert not (coach_dir / wa.WRAP_MARKER_NAME).exists()
|
|
257
|
+
# Sticky opt-out written
|
|
258
|
+
disabled = json.loads((coach_dir / wa.DISABLED_MARKER_NAME).read_text())
|
|
259
|
+
assert disabled["reason"] == "user-unwrapped"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_unwrap_no_op_when_no_marker(settings_path, coach_dir):
|
|
263
|
+
res = wa.unwrap(coach_dir=coach_dir, settings_path=settings_path)
|
|
264
|
+
assert res["result"] == "no-op"
|
|
265
|
+
assert res["reason"] == "no-wrap-marker"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_unwrap_refused_when_command_changed_since_wrap(
|
|
269
|
+
settings_path, coach_dir
|
|
270
|
+
):
|
|
271
|
+
"""User manually edited settings.json:statusLine after wrap → unwrap
|
|
272
|
+
refuses to clobber the manual edit."""
|
|
273
|
+
_set_statusline(settings_path, "bash /opt/x.sh")
|
|
274
|
+
wa.wrap(coach_dir=coach_dir, settings_path=settings_path)
|
|
275
|
+
# User overrides statusLine manually
|
|
276
|
+
_set_statusline(settings_path, "bash /something/else.sh")
|
|
277
|
+
|
|
278
|
+
res = wa.unwrap(coach_dir=coach_dir, settings_path=settings_path)
|
|
279
|
+
assert res["result"] == "refused"
|
|
280
|
+
assert res["reason"] == "command-changed-since-wrap"
|
|
281
|
+
# Manual edit preserved
|
|
282
|
+
assert _read_statusline(settings_path) == "bash /something/else.sh"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_unwrap_force_clobbers_manual_edit(settings_path, coach_dir):
|
|
286
|
+
_set_statusline(settings_path, "bash /opt/x.sh")
|
|
287
|
+
wa.wrap(coach_dir=coach_dir, settings_path=settings_path)
|
|
288
|
+
_set_statusline(settings_path, "bash /something/else.sh")
|
|
289
|
+
|
|
290
|
+
res = wa.unwrap(coach_dir=coach_dir, settings_path=settings_path, force=True)
|
|
291
|
+
assert res["result"] == "unwrapped"
|
|
292
|
+
assert _read_statusline(settings_path) == "bash /opt/x.sh"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_wrap_after_unwrap_clears_optout_for_re_optin(settings_path, coach_dir):
|
|
296
|
+
_set_statusline(settings_path, "bash /opt/x.sh")
|
|
297
|
+
wa.wrap(coach_dir=coach_dir, settings_path=settings_path)
|
|
298
|
+
wa.unwrap(coach_dir=coach_dir, settings_path=settings_path)
|
|
299
|
+
# opt-out marker present
|
|
300
|
+
assert (coach_dir / wa.DISABLED_MARKER_NAME).exists()
|
|
301
|
+
|
|
302
|
+
# Explicit wrap with force re-opts in (clears marker).
|
|
303
|
+
wa.wrap(coach_dir=coach_dir, settings_path=settings_path, force=True)
|
|
304
|
+
assert not (coach_dir / wa.DISABLED_MARKER_NAME).exists()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# --- custom-paths contract (fix #3) ---------------------------------------
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_explicit_paths_isolate_writes(tmp_path):
|
|
311
|
+
"""No `Path.home()` calls leak through — markers land in coach_dir,
|
|
312
|
+
settings mutation writes to settings_path, even when both are
|
|
313
|
+
arbitrary tmp paths."""
|
|
314
|
+
custom_coach = tmp_path / "x"
|
|
315
|
+
custom_coach.mkdir()
|
|
316
|
+
custom_settings = tmp_path / "y" / "settings.json"
|
|
317
|
+
custom_settings.parent.mkdir()
|
|
318
|
+
custom_settings.write_text(json.dumps({"statusLine": {"command": "bash /a.sh"}}))
|
|
319
|
+
|
|
320
|
+
res = wa.wrap(coach_dir=custom_coach, settings_path=custom_settings)
|
|
321
|
+
assert res["result"] == "wrapped"
|
|
322
|
+
# Markers in custom_coach, NOT in $HOME
|
|
323
|
+
assert (custom_coach / wa.WRAP_MARKER_NAME).exists()
|
|
324
|
+
# Settings.json mutated at custom path
|
|
325
|
+
assert "default-statusline-wrap-command.sh" in _read_statusline(custom_settings)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# --- CLI: wrap-if-claimed --------------------------------------------------
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_wrap_if_claimed_cli_uses_env_settings_path(
|
|
332
|
+
settings_path, coach_dir, monkeypatch, capsys
|
|
333
|
+
):
|
|
334
|
+
"""install.sh sets COACH_CONFIG_DIR + CLAUDE_SETTINGS_PATH; the CLI
|
|
335
|
+
subcommand honors both."""
|
|
336
|
+
_set_statusline(settings_path, "bash /opt/x.sh")
|
|
337
|
+
monkeypatch.setenv("COACH_CONFIG_DIR", str(coach_dir))
|
|
338
|
+
monkeypatch.setenv("CLAUDE_SETTINGS_PATH", str(settings_path))
|
|
339
|
+
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
|
|
340
|
+
|
|
341
|
+
rc = wa.main(["wrap-if-claimed"])
|
|
342
|
+
assert rc == 0 # never breaks an install
|
|
343
|
+
out = capsys.readouterr().out
|
|
344
|
+
assert "wrapped" in out.lower()
|
|
345
|
+
# CLI shape used (no plugin root in env)
|
|
346
|
+
assert "default-statusline-wrap-command.sh" in _read_statusline(settings_path)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_wrap_if_claimed_cli_with_plugin_root_uses_plugin_shape(
|
|
350
|
+
settings_path, coach_dir, plugin_root, monkeypatch
|
|
351
|
+
):
|
|
352
|
+
_set_statusline(settings_path, "bash /opt/x.sh")
|
|
353
|
+
monkeypatch.setenv("COACH_CONFIG_DIR", str(coach_dir))
|
|
354
|
+
monkeypatch.setenv("CLAUDE_SETTINGS_PATH", str(settings_path))
|
|
355
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(plugin_root))
|
|
356
|
+
|
|
357
|
+
rc = wa.main(["wrap-if-claimed"])
|
|
358
|
+
assert rc == 0
|
|
359
|
+
cmd = _read_statusline(settings_path)
|
|
360
|
+
assert "bootstrap.sh" in cmd
|
|
361
|
+
assert "statusline_wrap.py" in cmd
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def test_wrap_if_claimed_cli_exits_zero_on_no_settings(
|
|
365
|
+
coach_dir, tmp_path, monkeypatch
|
|
366
|
+
):
|
|
367
|
+
"""CLI must NEVER fail-hard; install.sh expects exit 0."""
|
|
368
|
+
monkeypatch.setenv("COACH_CONFIG_DIR", str(coach_dir))
|
|
369
|
+
monkeypatch.setenv("CLAUDE_SETTINGS_PATH", str(tmp_path / "missing.json"))
|
|
370
|
+
rc = wa.main(["wrap-if-claimed"])
|
|
371
|
+
assert rc == 0
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
# Shell-safety of generated wrapper commands (v0.1.5 fix)
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def test_build_wrapper_command_quotes_cli_paths_with_spaces(tmp_path):
|
|
380
|
+
"""CLI shape: a coach_dir containing spaces must produce a command
|
|
381
|
+
string that bash parses as ONE token for the trampoline path. Pre-
|
|
382
|
+
v0.1.5 this generated `bash /tmp/.../Claude Dir/coach/...` which
|
|
383
|
+
bash split into `bash /tmp/.../Claude` + `Dir/coach/...`, ENOENT'ing
|
|
384
|
+
the second token."""
|
|
385
|
+
coach_dir = tmp_path / "Claude Dir With Spaces" / "coach"
|
|
386
|
+
coach_dir.mkdir(parents=True)
|
|
387
|
+
cmd = wa._build_wrapper_command(coach_dir=coach_dir, plugin_root=None)
|
|
388
|
+
|
|
389
|
+
tokens = shlex.split(cmd)
|
|
390
|
+
assert tokens[0] == "bash"
|
|
391
|
+
assert len(tokens) == 2, (
|
|
392
|
+
f"trampoline path was split by bash; tokens={tokens!r} cmd={cmd!r}"
|
|
393
|
+
)
|
|
394
|
+
assert tokens[1] == str(coach_dir / "default-statusline-wrap-command.sh")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def test_build_wrapper_command_quotes_plugin_paths_with_spaces(tmp_path):
|
|
398
|
+
"""Plugin shape: same protection for plugin_root."""
|
|
399
|
+
plugin_root = tmp_path / "Plugin Dir With Spaces"
|
|
400
|
+
(plugin_root / "bin").mkdir(parents=True)
|
|
401
|
+
cmd = wa._build_wrapper_command(coach_dir=tmp_path, plugin_root=plugin_root)
|
|
402
|
+
|
|
403
|
+
tokens = shlex.split(cmd)
|
|
404
|
+
assert len(tokens) == 2, (
|
|
405
|
+
f"plugin_root paths split by bash; tokens={tokens!r} cmd={cmd!r}"
|
|
406
|
+
)
|
|
407
|
+
assert tokens[0] == str(plugin_root / "bin" / "bootstrap.sh")
|
|
408
|
+
assert tokens[1] == str(plugin_root / "bin" / "statusline_wrap.py")
|