@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,261 @@
|
|
|
1
|
+
"""statusline_self_patch.ensure_statusline_installed — plugin self-patch
|
|
2
|
+
of ~/.claude/settings.json:statusLine.
|
|
3
|
+
|
|
4
|
+
Plugin distribution only. Gating happens at the call site
|
|
5
|
+
(coach-session-start.py:_maybe_install_plugin_statusline checks
|
|
6
|
+
CLAUDE_PLUGIN_ROOT). These tests exercise the patcher directly with a
|
|
7
|
+
tmpdir settings.json.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import shlex
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
import statusline_self_patch as sp
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def settings(tmp_path):
|
|
22
|
+
"""Return a settings.json path inside tmp_path. Caller writes it."""
|
|
23
|
+
return tmp_path / "settings.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def plugin_root(tmp_path):
|
|
28
|
+
root = tmp_path / "plugin"
|
|
29
|
+
(root / "bin").mkdir(parents=True)
|
|
30
|
+
return root
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_inserts_statusline_when_absent(settings, plugin_root):
|
|
34
|
+
settings.write_text(json.dumps({}))
|
|
35
|
+
result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
|
|
36
|
+
assert result == "installed"
|
|
37
|
+
|
|
38
|
+
written = json.loads(settings.read_text())
|
|
39
|
+
assert "statusLine" in written
|
|
40
|
+
cmd = written["statusLine"]["command"]
|
|
41
|
+
assert "bootstrap.sh" in cmd
|
|
42
|
+
assert "default_statusline.py" in cmd
|
|
43
|
+
# Absolute path (no ${CLAUDE_PLUGIN_ROOT} placeholder; that wouldn't
|
|
44
|
+
# expand inside settings.json).
|
|
45
|
+
assert "${CLAUDE_PLUGIN_ROOT}" not in cmd
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_preserves_other_keys(settings, plugin_root):
|
|
49
|
+
"""Patching statusLine must not clobber unrelated settings."""
|
|
50
|
+
settings.write_text(json.dumps({
|
|
51
|
+
"permissions": {"allow": ["read"]},
|
|
52
|
+
"hooks": {"SessionStart": [{"hooks": [{"command": "foo"}]}]},
|
|
53
|
+
}))
|
|
54
|
+
sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
|
|
55
|
+
written = json.loads(settings.read_text())
|
|
56
|
+
assert written["permissions"] == {"allow": ["read"]}
|
|
57
|
+
assert "SessionStart" in written["hooks"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_noop_when_coach_statusline_already_present(settings, plugin_root):
|
|
61
|
+
"""statusLine pointing at Coach's known marker → no-op (file
|
|
62
|
+
untouched)."""
|
|
63
|
+
settings.write_text(json.dumps({
|
|
64
|
+
"statusLine": {
|
|
65
|
+
"type": "command",
|
|
66
|
+
"command": "bash /some/other/path/default-statusline-command.sh",
|
|
67
|
+
},
|
|
68
|
+
}))
|
|
69
|
+
mtime_before = settings.stat().st_mtime_ns
|
|
70
|
+
result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
|
|
71
|
+
assert result == "matched"
|
|
72
|
+
assert settings.stat().st_mtime_ns == mtime_before, (
|
|
73
|
+
"matched path must not rewrite the file"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_auto_wraps_when_other_statusline_present(
|
|
78
|
+
settings, plugin_root, tmp_path, monkeypatch
|
|
79
|
+
):
|
|
80
|
+
"""v0.1.4: claimed statusLine on first encounter → auto-wrap (instead
|
|
81
|
+
of leaving alone). Original is saved to .statusline-wrap.json and the
|
|
82
|
+
wrapper command replaces it in settings.json."""
|
|
83
|
+
coach_dir = tmp_path / "coach"
|
|
84
|
+
coach_dir.mkdir()
|
|
85
|
+
monkeypatch.setenv("COACH_CONFIG_DIR", str(coach_dir))
|
|
86
|
+
|
|
87
|
+
settings.write_text(json.dumps({
|
|
88
|
+
"statusLine": {"type": "command", "command": "bash /custom/user-thing.sh"},
|
|
89
|
+
}))
|
|
90
|
+
result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
|
|
91
|
+
assert result == "wrapped"
|
|
92
|
+
new_cmd = json.loads(settings.read_text())["statusLine"]["command"]
|
|
93
|
+
assert "statusline_wrap.py" in new_cmd
|
|
94
|
+
saved = json.loads((coach_dir / ".statusline-wrap.json").read_text())
|
|
95
|
+
assert saved["original_command"] == "bash /custom/user-thing.sh"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_claimed_when_optout_marker_present(
|
|
99
|
+
settings, plugin_root, tmp_path, monkeypatch, capfd
|
|
100
|
+
):
|
|
101
|
+
"""User explicitly unwrapped earlier → opt-out marker exists → patcher
|
|
102
|
+
leaves the user's claimed statusLine alone (no auto-wrap, no rewrite)."""
|
|
103
|
+
coach_dir = tmp_path / "coach"
|
|
104
|
+
coach_dir.mkdir()
|
|
105
|
+
monkeypatch.setenv("COACH_CONFIG_DIR", str(coach_dir))
|
|
106
|
+
(coach_dir / ".statusline-wrap-disabled").write_text(json.dumps({
|
|
107
|
+
"reason": "user-unwrapped",
|
|
108
|
+
}))
|
|
109
|
+
|
|
110
|
+
settings.write_text(json.dumps({
|
|
111
|
+
"statusLine": {"type": "command", "command": "bash /custom/user-thing.sh"},
|
|
112
|
+
}))
|
|
113
|
+
result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
|
|
114
|
+
assert result == "claimed"
|
|
115
|
+
# User's command preserved
|
|
116
|
+
assert json.loads(settings.read_text())["statusLine"]["command"] == "bash /custom/user-thing.sh"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_claimed_when_user_script_integrates_coach(
|
|
120
|
+
settings, plugin_root, tmp_path, monkeypatch
|
|
121
|
+
):
|
|
122
|
+
"""Manual-Coach pre-flight: user's script already calls coach/bin/stats.py
|
|
123
|
+
→ patcher detects, writes opt-out marker, leaves statusLine alone."""
|
|
124
|
+
coach_dir = tmp_path / "coach"
|
|
125
|
+
coach_dir.mkdir()
|
|
126
|
+
monkeypatch.setenv("COACH_CONFIG_DIR", str(coach_dir))
|
|
127
|
+
|
|
128
|
+
user_script = tmp_path / "statusline-command.sh"
|
|
129
|
+
user_script.write_text(
|
|
130
|
+
"#!/bin/bash\nexec coach/bin/stats.py\n"
|
|
131
|
+
)
|
|
132
|
+
settings.write_text(json.dumps({
|
|
133
|
+
"statusLine": {"type": "command", "command": f"bash {user_script}"},
|
|
134
|
+
}))
|
|
135
|
+
result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
|
|
136
|
+
assert result == "claimed"
|
|
137
|
+
# Opt-out marker auto-written by manual-Coach pre-flight
|
|
138
|
+
disabled = json.loads((coach_dir / ".statusline-wrap-disabled").read_text())
|
|
139
|
+
assert disabled["reason"] == "already-integrated"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_recognizes_wrapped_statusline_as_ours(settings, plugin_root):
|
|
143
|
+
"""ours-wrapped pointing at the CURRENT plugin_root → matched no-op
|
|
144
|
+
(the wrap shape is recognized as ours, no rewrite)."""
|
|
145
|
+
settings.write_text(json.dumps({
|
|
146
|
+
"statusLine": {
|
|
147
|
+
"type": "command",
|
|
148
|
+
"command": f"{plugin_root}/bin/bootstrap.sh {plugin_root}/bin/statusline_wrap.py",
|
|
149
|
+
},
|
|
150
|
+
}))
|
|
151
|
+
result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
|
|
152
|
+
assert result == "matched"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_refreshes_stale_plugin_path_on_wrapped(
|
|
156
|
+
settings, plugin_root, tmp_path, monkeypatch
|
|
157
|
+
):
|
|
158
|
+
"""ours-wrapped pointing at a stale plugin version dir → patcher
|
|
159
|
+
rewrites with the current plugin_root."""
|
|
160
|
+
coach_dir = tmp_path / "coach"
|
|
161
|
+
coach_dir.mkdir()
|
|
162
|
+
monkeypatch.setenv("COACH_CONFIG_DIR", str(coach_dir))
|
|
163
|
+
# Pretend an older plugin dir wrote the entry
|
|
164
|
+
old_root = tmp_path / "plugin-old"
|
|
165
|
+
settings.write_text(json.dumps({
|
|
166
|
+
"statusLine": {
|
|
167
|
+
"type": "command",
|
|
168
|
+
"command": f"{old_root}/bin/bootstrap.sh {old_root}/bin/statusline_wrap.py",
|
|
169
|
+
},
|
|
170
|
+
}))
|
|
171
|
+
# Also need a wrap marker so the action recognizes ours-wrapped
|
|
172
|
+
(coach_dir / ".statusline-wrap.json").write_text(json.dumps({
|
|
173
|
+
"original_command": "bash /opt/x.sh",
|
|
174
|
+
}))
|
|
175
|
+
|
|
176
|
+
result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
|
|
177
|
+
assert result == "wrap-refreshed"
|
|
178
|
+
new_cmd = json.loads(settings.read_text())["statusLine"]["command"]
|
|
179
|
+
assert str(plugin_root) in new_cmd
|
|
180
|
+
assert str(old_root) not in new_cmd
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_skipped_when_settings_absent(tmp_path, plugin_root):
|
|
184
|
+
"""No settings.json → no-op (returns 'skipped'). Don't create it
|
|
185
|
+
from scratch — Claude Code itself will create it on first run."""
|
|
186
|
+
missing = tmp_path / "no-such-settings.json"
|
|
187
|
+
result = sp.ensure_statusline_installed(str(plugin_root), settings_path=missing)
|
|
188
|
+
assert result == "skipped"
|
|
189
|
+
assert not missing.exists()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_error_on_malformed_json(settings, plugin_root):
|
|
193
|
+
"""Existing settings.json that's not valid JSON → returns 'error',
|
|
194
|
+
never raises (caller is hook context — mustn't crash)."""
|
|
195
|
+
settings.write_text("{ not valid json")
|
|
196
|
+
result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
|
|
197
|
+
assert result == "error"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_atomic_no_partial_write_on_failure(settings, plugin_root, monkeypatch):
|
|
201
|
+
"""If json.dump raises mid-write, settings.json must not be
|
|
202
|
+
truncated. Verified by simulating an exception in os.replace."""
|
|
203
|
+
settings.write_text(json.dumps({"statusLine": None}))
|
|
204
|
+
original = json.loads(settings.read_text())
|
|
205
|
+
|
|
206
|
+
real_replace = sp.os.replace
|
|
207
|
+
|
|
208
|
+
def boom(*args, **kwargs):
|
|
209
|
+
raise OSError("simulated disk failure")
|
|
210
|
+
|
|
211
|
+
monkeypatch.setattr(sp.os, "replace", boom)
|
|
212
|
+
result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
|
|
213
|
+
assert result == "error"
|
|
214
|
+
# Original file content preserved (atomic semantics).
|
|
215
|
+
after = json.loads(settings.read_text())
|
|
216
|
+
assert after == original
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_recognizes_cli_installed_statusline(settings, plugin_root):
|
|
220
|
+
"""A CLI-installed statusLine (uses default-statusline-command.sh)
|
|
221
|
+
must be recognized as 'ours' so the plugin doesn't try to
|
|
222
|
+
overwrite when the user has both installs side-by-side."""
|
|
223
|
+
settings.write_text(json.dumps({
|
|
224
|
+
"statusLine": {
|
|
225
|
+
"type": "command",
|
|
226
|
+
"command": "bash /Users/foo/.claude/coach/default-statusline-command.sh",
|
|
227
|
+
},
|
|
228
|
+
}))
|
|
229
|
+
result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
|
|
230
|
+
assert result == "matched"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_recognizes_plugin_installed_statusline(settings, plugin_root):
|
|
234
|
+
"""Plugin-installed statusLine (uses default_statusline.py via
|
|
235
|
+
bootstrap.sh) must also be recognized as 'ours' on the second
|
|
236
|
+
SessionStart."""
|
|
237
|
+
settings.write_text(json.dumps({
|
|
238
|
+
"statusLine": {
|
|
239
|
+
"type": "command",
|
|
240
|
+
"command": "/path/to/plugin/bin/bootstrap.sh /path/to/plugin/bin/default_statusline.py",
|
|
241
|
+
},
|
|
242
|
+
}))
|
|
243
|
+
result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
|
|
244
|
+
assert result == "matched"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_desired_entry_quotes_paths_with_spaces(tmp_path):
|
|
248
|
+
"""Symmetric with `_build_wrapper_command` in statusline_wrap_action:
|
|
249
|
+
a plugin_root with spaces must produce a command that bash parses
|
|
250
|
+
as exactly two tokens (bootstrap.sh + default_statusline.py)."""
|
|
251
|
+
plugin_root = tmp_path / "Plugin Dir With Spaces"
|
|
252
|
+
(plugin_root / "bin").mkdir(parents=True)
|
|
253
|
+
entry = sp._desired_entry(plugin_root)
|
|
254
|
+
|
|
255
|
+
tokens = shlex.split(entry["command"])
|
|
256
|
+
assert len(tokens) == 2, (
|
|
257
|
+
f"plugin_root paths split by bash; tokens={tokens!r} "
|
|
258
|
+
f"command={entry['command']!r}"
|
|
259
|
+
)
|
|
260
|
+
assert tokens[0] == str(plugin_root / "bin" / "bootstrap.sh")
|
|
261
|
+
assert tokens[1] == str(plugin_root / "bin" / "default_statusline.py")
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""statusline_variants.py — every variant renders for sample inputs and
|
|
2
|
+
contains the expected key glyphs (level/name/elo/arrow)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import statusline_variants as sv
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _strip_ansi(s: str) -> str:
|
|
11
|
+
return re.sub(r"\x1b\[[0-9;]*m", "", s)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _sample_glyphs(level: int = 7, session_xp: int = 15) -> sv.Glyphs:
|
|
15
|
+
return sv.Glyphs(
|
|
16
|
+
level=level,
|
|
17
|
+
name="Virtuoso",
|
|
18
|
+
elo=1232,
|
|
19
|
+
session_xp=session_xp,
|
|
20
|
+
sigil_tier="silver",
|
|
21
|
+
bar_pct=0.30,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_every_variant_renders_a_non_empty_string():
|
|
26
|
+
g = _sample_glyphs()
|
|
27
|
+
for name in sv.VARIANTS:
|
|
28
|
+
assert sv.render(name, g), f"{name} produced empty output"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_unknown_variant_falls_back_to_default():
|
|
32
|
+
g = _sample_glyphs()
|
|
33
|
+
fallback = sv.render("does-not-exist", g)
|
|
34
|
+
assert fallback == sv.render(sv.DEFAULT_VARIANT, g)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_default_variant_is_crystal_and_includes_canonical_glyphs():
|
|
38
|
+
"""Pin v0.2.0 visual contract — `◆ Ⅶ 1232 Virtuoso ↑15` shape."""
|
|
39
|
+
plain = _strip_ansi(sv.render("crystal", _sample_glyphs()))
|
|
40
|
+
assert plain == "◆ Ⅶ 1232 Virtuoso ↑15"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_pips_variant_renders_pip_bar():
|
|
44
|
+
plain = _strip_ansi(sv.render("pips", _sample_glyphs()))
|
|
45
|
+
# bar_pct=0.30 → round(0.30*5) = 2 filled pips, 3 empty
|
|
46
|
+
assert plain.startswith("●●○○○ Virtuoso")
|
|
47
|
+
assert plain.endswith("↑15")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _glyphs_with_tier(tier: str) -> sv.Glyphs:
|
|
51
|
+
base = _sample_glyphs()
|
|
52
|
+
return sv.Glyphs(
|
|
53
|
+
level=base.level, name=base.name, elo=base.elo,
|
|
54
|
+
session_xp=base.session_xp, sigil_tier=tier, bar_pct=base.bar_pct,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_pips_filled_glyph_color_tracks_sigil_tier():
|
|
59
|
+
"""Filled `●` glyphs render in the sigil-tier color so the bar
|
|
60
|
+
progresses bronze → silver → gold → platinum → diamond as the user
|
|
61
|
+
levels up. Empty `○` glyphs stay DIM_STEEL."""
|
|
62
|
+
bronze_out = sv.render("pips", _glyphs_with_tier("bronze"))
|
|
63
|
+
diamond_out = sv.render("pips", _glyphs_with_tier("diamond"))
|
|
64
|
+
assert sv.SIGIL_COLORS["bronze"] in bronze_out
|
|
65
|
+
assert sv.SIGIL_COLORS["diamond"] in diamond_out
|
|
66
|
+
assert bronze_out != diamond_out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_slash_variant_has_swords_sigil_and_drops_elo():
|
|
70
|
+
plain = _strip_ansi(sv.render("slash", _sample_glyphs()))
|
|
71
|
+
assert plain == "⚔ L7 / Virtuoso ↑15"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_slash_sigil_color_tracks_sigil_tier():
|
|
75
|
+
"""⚔ joins ◆ and ⚒ in the tier-color family — same mechanism."""
|
|
76
|
+
out = sv.render("slash", _glyphs_with_tier("diamond"))
|
|
77
|
+
assert sv.SIGIL_COLORS["diamond"] in out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_forge_variant_uses_anvil_sigil():
|
|
81
|
+
plain = _strip_ansi(sv.render("forge", _sample_glyphs()))
|
|
82
|
+
assert plain == "⚒ Virtuoso · L7 ↑15"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_bracket_variant_removed_from_registry():
|
|
86
|
+
"""v0.1.4: bracket dropped. `render()` falls back to crystal so
|
|
87
|
+
saved configs with statusline_variant=bracket keep rendering."""
|
|
88
|
+
assert "bracket" not in sv.VARIANTS
|
|
89
|
+
assert len(sv.VARIANTS) == 4
|
|
90
|
+
fallback = sv.render("bracket", _sample_glyphs())
|
|
91
|
+
assert fallback == sv.render("crystal", _sample_glyphs())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_zero_session_xp_drops_the_arrow():
|
|
95
|
+
g = _sample_glyphs(session_xp=0)
|
|
96
|
+
for name in sv.VARIANTS:
|
|
97
|
+
plain = _strip_ansi(sv.render(name, g))
|
|
98
|
+
assert "↑" not in plain, f"{name} kept the arrow at session_xp=0"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_to_roman_handles_full_50_level_range():
|
|
102
|
+
expectations = {1: "Ⅰ", 4: "Ⅳ", 9: "Ⅸ", 10: "Ⅹ", 13: "ⅩⅢ", 50: "Ⅼ"}
|
|
103
|
+
for n, want in expectations.items():
|
|
104
|
+
assert sv.to_roman(n) == want, f"to_roman({n})"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_list_variants_puts_default_first():
|
|
108
|
+
keys = sv.list_variants()
|
|
109
|
+
assert keys[0] == sv.DEFAULT_VARIANT
|
|
110
|
+
assert sorted(keys) == sorted(sv.VARIANTS.keys())
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""statusline_wrap.py — runtime composer with ANSI-strip + trailing-aware
|
|
2
|
+
separator detection. Failsafe contract: never crash the render."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import io
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
import statusline_wrap as sw
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# --- compose() pure function ----------------------------------------------
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_compose_appends_when_original_ends_with_separator():
|
|
19
|
+
"""User's command already trails with `┃` → just append, single space."""
|
|
20
|
+
out = sw.compose("opus·4.7 ┃ ▰▰▱▱ 14% ┃", "◆ Ⅷ 1265 Sensei ↑1")
|
|
21
|
+
assert out == "opus·4.7 ┃ ▰▰▱▱ 14% ┃ ◆ Ⅷ 1265 Sensei ↑1"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_compose_strips_ansi_before_trailing_check():
|
|
25
|
+
"""Real terminal output has ANSI color codes wrapped around the `┃`.
|
|
26
|
+
Last raw char is `m`; after ANSI-strip, last visible char is `┃`."""
|
|
27
|
+
ansi_pipe = "\x1b[38;2;30;144;255m┃\x1b[0m"
|
|
28
|
+
original = f"opus·4.7 \x1b[38;2;30;144;255m┃\x1b[0m ▰▰▱▱ 14% {ansi_pipe}"
|
|
29
|
+
out = sw.compose(original, "COACH")
|
|
30
|
+
# No double separator inserted — trailing `┃` was detected post-strip.
|
|
31
|
+
assert out.endswith(" COACH")
|
|
32
|
+
assert " ┃ COACH" not in out # would be doubled
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_compose_uses_inline_separator_when_no_trailing_one():
|
|
36
|
+
"""No trailing sep, but inline ` | ` runs around → insert one."""
|
|
37
|
+
out = sw.compose("a | b | c", "COACH")
|
|
38
|
+
assert out == "a | b | c | COACH"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_compose_falls_through_to_space_when_no_separator_signal():
|
|
42
|
+
"""Nothing recognizable → single-space join."""
|
|
43
|
+
out = sw.compose("plain text", "COACH")
|
|
44
|
+
assert out == "plain text COACH"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_compose_empty_original_returns_coach_alone():
|
|
48
|
+
assert sw.compose("", "COACH") == "COACH"
|
|
49
|
+
assert sw.compose(" ", "COACH") == "COACH"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_compose_empty_coach_returns_original_alone():
|
|
53
|
+
assert sw.compose("original ┃", "") == "original ┃"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --- inline-separator detection edge cases ---------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_detect_inline_separator_picks_most_frequent():
|
|
60
|
+
assert sw._detect_inline_separator("a ┃ b ┃ c | d") == "┃"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_detect_inline_separator_returns_none_when_squashed():
|
|
64
|
+
"""`opus·4.7·(1m)` — `·` between alphanumerics, not space-padded.
|
|
65
|
+
Must not be picked as a separator."""
|
|
66
|
+
assert sw._detect_inline_separator("opus·4.7·(1m)") is None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_detect_inline_separator_returns_none_on_no_separators():
|
|
70
|
+
assert sw._detect_inline_separator("plain text only") is None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# --- runtime duplicate-detection signature --------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_looks_like_coach_output_matches_sigil_plus_roman():
|
|
77
|
+
"""`◆ Ⅷ` — sigil + roman within last 80 chars → duplicate signature."""
|
|
78
|
+
assert sw._looks_like_coach_output("model bar ┃ ◆ Ⅷ 1265 Sensei ↑1") is True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_looks_like_coach_output_matches_sigil_plus_rank_name():
|
|
82
|
+
"""`⚒ Virtuoso` — sigil + theme rank name → duplicate signature."""
|
|
83
|
+
assert sw._looks_like_coach_output("custom prefix ⚒ Virtuoso · L7 ↑15") is True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_looks_like_coach_output_no_match_on_unrelated():
|
|
87
|
+
"""No sigil → no match."""
|
|
88
|
+
assert sw._looks_like_coach_output("just plain text 14%") is False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_looks_like_coach_output_no_match_on_sigil_alone():
|
|
92
|
+
"""Sigil present but no roman + no rank → not a Coach signature."""
|
|
93
|
+
assert sw._looks_like_coach_output("symbol ◆ alone with no rank") is False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_looks_like_coach_output_strips_ansi_first():
|
|
97
|
+
"""ANSI colors around the sigil/numeral mustn't break detection."""
|
|
98
|
+
text = "x \x1b[38;2;200;205;215m◆\x1b[0m \x1b[1mⅧ\x1b[0m 1265"
|
|
99
|
+
assert sw._looks_like_coach_output(text) is True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- main() integration with subprocess ------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.fixture
|
|
106
|
+
def isolated_coach_dir(tmp_path, monkeypatch):
|
|
107
|
+
"""Redirect resolve_coach_dir() to a tmp dir."""
|
|
108
|
+
monkeypatch.setenv("COACH_CONFIG_DIR", str(tmp_path))
|
|
109
|
+
return tmp_path
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _write_marker(coach_dir: Path, command: str) -> None:
|
|
113
|
+
(coach_dir / sw.WRAP_MARKER_NAME).write_text(json.dumps({
|
|
114
|
+
"original_command": command,
|
|
115
|
+
}))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _run_main(monkeypatch, capsys, payload: str = ""):
|
|
119
|
+
monkeypatch.setattr("sys.stdin", io.StringIO(payload))
|
|
120
|
+
rc = sw.main()
|
|
121
|
+
return rc, capsys.readouterr().out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_main_no_marker_emits_coach_alone(isolated_coach_dir, monkeypatch, capsys):
|
|
125
|
+
"""Without a saved-original marker, the wrapper has nothing to run.
|
|
126
|
+
It still composes — original is empty, so just the coach segment
|
|
127
|
+
emits (or empty if no profile)."""
|
|
128
|
+
rc, out = _run_main(monkeypatch, capsys, payload="")
|
|
129
|
+
assert rc == 0
|
|
130
|
+
# Either empty (no profile signal) or starts with a sigil; both are
|
|
131
|
+
# valid failsafe outcomes. Key invariant: no traceback, no crash.
|
|
132
|
+
assert isinstance(out, str)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_main_runs_saved_command_and_appends_coach(
|
|
136
|
+
isolated_coach_dir, monkeypatch, capsys, tmp_path
|
|
137
|
+
):
|
|
138
|
+
"""Saved original is `echo 'orig ┃'` → wrapper appends Coach with
|
|
139
|
+
trailing-aware logic (already has trailing ┃)."""
|
|
140
|
+
_write_marker(isolated_coach_dir, "printf 'orig ┃'")
|
|
141
|
+
monkeypatch.setattr(sw, "_coach_segment", lambda payload: "COACH")
|
|
142
|
+
|
|
143
|
+
rc, out = _run_main(monkeypatch, capsys, payload="")
|
|
144
|
+
assert rc == 0
|
|
145
|
+
assert out == "orig ┃ COACH"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_main_timeout_emits_coach_alone(isolated_coach_dir, monkeypatch, capsys):
|
|
149
|
+
"""Saved command sleeps past the 2s budget → original captured as ""
|
|
150
|
+
→ wrapper falls through to coach-alone."""
|
|
151
|
+
_write_marker(isolated_coach_dir, "sleep 5")
|
|
152
|
+
monkeypatch.setattr(sw, "ORIGINAL_TIMEOUT_SECONDS", 0.1)
|
|
153
|
+
monkeypatch.setattr(sw, "_coach_segment", lambda payload: "COACH")
|
|
154
|
+
|
|
155
|
+
rc, out = _run_main(monkeypatch, capsys, payload="")
|
|
156
|
+
assert rc == 0
|
|
157
|
+
assert out == "COACH"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_main_invalid_payload_does_not_crash(isolated_coach_dir, monkeypatch, capsys):
|
|
161
|
+
"""Garbage stdin → parsed as empty dict; wrapper still emits."""
|
|
162
|
+
_write_marker(isolated_coach_dir, "printf 'orig'")
|
|
163
|
+
monkeypatch.setattr(sw, "_coach_segment", lambda payload: "COACH")
|
|
164
|
+
|
|
165
|
+
rc, out = _run_main(monkeypatch, capsys, payload="not json {{{")
|
|
166
|
+
assert rc == 0
|
|
167
|
+
assert "orig" in out
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_main_duplicate_detection_writes_marker_and_skips_coach(
|
|
171
|
+
isolated_coach_dir, monkeypatch, capsys
|
|
172
|
+
):
|
|
173
|
+
"""Original output already contains a Coach signature → the wrapper
|
|
174
|
+
must NOT append a second segment AND must drop a marker file for the
|
|
175
|
+
hook to surface the suggestion banner."""
|
|
176
|
+
_write_marker(isolated_coach_dir, "printf '◆ Ⅷ 1265 Sensei ↑1'")
|
|
177
|
+
monkeypatch.setattr(sw, "_coach_segment", lambda payload: "COACH")
|
|
178
|
+
|
|
179
|
+
rc, out = _run_main(monkeypatch, capsys, payload="")
|
|
180
|
+
assert rc == 0
|
|
181
|
+
assert "COACH" not in out
|
|
182
|
+
assert out == "◆ Ⅷ 1265 Sensei ↑1"
|
|
183
|
+
marker = isolated_coach_dir / sw.DUPLICATE_MARKER_NAME
|
|
184
|
+
assert marker.exists()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_main_subprocess_failure_falls_back_to_coach(
|
|
188
|
+
isolated_coach_dir, monkeypatch, capsys
|
|
189
|
+
):
|
|
190
|
+
"""Saved command exits nonzero → original captured as "" → coach alone."""
|
|
191
|
+
_write_marker(isolated_coach_dir, "false")
|
|
192
|
+
monkeypatch.setattr(sw, "_coach_segment", lambda payload: "COACH")
|
|
193
|
+
|
|
194
|
+
rc, out = _run_main(monkeypatch, capsys, payload="")
|
|
195
|
+
assert rc == 0
|
|
196
|
+
assert out == "COACH"
|