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