@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,129 @@
|
|
|
1
|
+
"""Detect whether the npm CLI distribution has Coach hooks registered
|
|
2
|
+
in ~/.claude/settings.json, so the plugin's hooks can self-defer.
|
|
3
|
+
|
|
4
|
+
Plugin distribution only — invoked from plugin/bin/bootstrap.sh BEFORE
|
|
5
|
+
any venv setup or Python entry-point exec, so the check is cheap and
|
|
6
|
+
the defer path is fast.
|
|
7
|
+
|
|
8
|
+
Why this exists:
|
|
9
|
+
|
|
10
|
+
npm CLI registers hooks in settings.json:
|
|
11
|
+
SessionStart -> ~/.claude/hooks/coach-session-start.py
|
|
12
|
+
UserPromptSubmit -> ~/.claude/hooks/coach-user-prompt.py
|
|
13
|
+
|
|
14
|
+
Plugin registers hooks in plugin/hooks/hooks.json:
|
|
15
|
+
SessionStart -> ${CLAUDE_PLUGIN_ROOT}/hooks/coach-session-start.py
|
|
16
|
+
UserPromptSubmit -> ${CLAUDE_PLUGIN_ROOT}/hooks/coach-user-prompt.py
|
|
17
|
+
|
|
18
|
+
A user with both installs gets 2x SessionStart + 2x UserPromptSubmit
|
|
19
|
+
fires per event. bank.py runs twice (double XP). Tips render twice.
|
|
20
|
+
`_assemble_celebrate_block` consumes a marker, plugin then re-reads
|
|
21
|
+
cleared state — depends on which hook lands first; flaky.
|
|
22
|
+
|
|
23
|
+
The defer rule (CLI wins) is deliberate: the CLI is the canonical
|
|
24
|
+
provider-agnostic distribution, manages OS-side bits the plugin can't
|
|
25
|
+
reach (launchd cron, statusLine), and was likely installed first by
|
|
26
|
+
the user. Plugin self-disables until the user runs `/coach-claw:switch`.
|
|
27
|
+
|
|
28
|
+
Exit codes:
|
|
29
|
+
0 — no CLI hooks detected; plugin should proceed.
|
|
30
|
+
10 — CLI hooks detected; bootstrap.sh should `exit 0` without
|
|
31
|
+
exec-ing the wrapped Python entry.
|
|
32
|
+
|
|
33
|
+
Always exits 0 on error (malformed settings.json, missing file). The
|
|
34
|
+
worst case is double-fire, not a broken hook.
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import sys
|
|
41
|
+
from datetime import datetime, timezone
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
DEFER_EXIT = 10
|
|
46
|
+
HOOK_SCRIPT_NAMES = ("coach-session-start.py", "coach-user-prompt.py")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _coach_dir() -> Path:
|
|
50
|
+
"""Mirror coach_paths.resolve_coach_dir() — duplicated here to keep
|
|
51
|
+
this script importable BEFORE the venv exists (and hence before the
|
|
52
|
+
plugin's bin/ is on sys.path)."""
|
|
53
|
+
base = os.environ.get("COACH_CONFIG_DIR")
|
|
54
|
+
if base:
|
|
55
|
+
return Path(base)
|
|
56
|
+
return Path.home() / ".claude" / "coach"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _command_points_at_cli(cmd: str, plugin_root: str) -> bool:
|
|
60
|
+
"""A hook command is a CLI hook if it references one of our hook
|
|
61
|
+
script names AND does NOT live under the plugin's own root.
|
|
62
|
+
Plugin-self matches return False (those are our own hooks)."""
|
|
63
|
+
if not any(name in cmd for name in HOOK_SCRIPT_NAMES):
|
|
64
|
+
return False
|
|
65
|
+
if plugin_root and plugin_root in cmd:
|
|
66
|
+
return False
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _scan_hooks(data: dict, plugin_root: str) -> bool:
|
|
71
|
+
"""Walk settings.json hooks tree; return True if any non-plugin
|
|
72
|
+
coach hook command is registered."""
|
|
73
|
+
hooks = data.get("hooks")
|
|
74
|
+
if not isinstance(hooks, dict):
|
|
75
|
+
return False
|
|
76
|
+
for event in ("SessionStart", "UserPromptSubmit"):
|
|
77
|
+
groups = hooks.get(event) or []
|
|
78
|
+
if not isinstance(groups, list):
|
|
79
|
+
continue
|
|
80
|
+
for grp in groups:
|
|
81
|
+
if not isinstance(grp, dict):
|
|
82
|
+
continue
|
|
83
|
+
for h in (grp.get("hooks") or []):
|
|
84
|
+
if not isinstance(h, dict):
|
|
85
|
+
continue
|
|
86
|
+
cmd = str(h.get("command", ""))
|
|
87
|
+
if _command_points_at_cli(cmd, plugin_root):
|
|
88
|
+
return True
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _write_defer_marker() -> None:
|
|
93
|
+
"""Persist a marker so /coach-claw:doctor can surface the deferral.
|
|
94
|
+
Best-effort — never raises into the bootstrap path."""
|
|
95
|
+
try:
|
|
96
|
+
coach_dir = _coach_dir()
|
|
97
|
+
coach_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
marker = coach_dir / ".plugin-deferred"
|
|
99
|
+
payload = {
|
|
100
|
+
"deferred_at": datetime.now(timezone.utc).isoformat(),
|
|
101
|
+
"reason": "cli-hooks-detected",
|
|
102
|
+
}
|
|
103
|
+
marker.write_text(json.dumps(payload))
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main() -> int:
|
|
109
|
+
plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
|
|
110
|
+
settings_path = Path(
|
|
111
|
+
os.environ.get("CLAUDE_SETTINGS_PATH")
|
|
112
|
+
or (Path.home() / ".claude" / "settings.json")
|
|
113
|
+
)
|
|
114
|
+
if not settings_path.exists():
|
|
115
|
+
return 0
|
|
116
|
+
try:
|
|
117
|
+
data = json.loads(settings_path.read_text())
|
|
118
|
+
except Exception:
|
|
119
|
+
return 0
|
|
120
|
+
if not isinstance(data, dict):
|
|
121
|
+
return 0
|
|
122
|
+
if _scan_hooks(data, plugin_root):
|
|
123
|
+
_write_defer_marker()
|
|
124
|
+
return DEFER_EXIT
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
sys.exit(main())
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""coach-claw config — terminal-side entrypoint to user_config.
|
|
3
|
+
|
|
4
|
+
Three subcommands:
|
|
5
|
+
|
|
6
|
+
coach-claw config set [--theme NAME] [--statusline VARIANT] [--elo MIN MAX]
|
|
7
|
+
coach-claw config preview
|
|
8
|
+
coach-claw config wizard
|
|
9
|
+
|
|
10
|
+
Wired up by `npm/coach-claw.js` (case "config" → spawnSync python3
|
|
11
|
+
against this file). Inside Claude Code the equivalent surface is the
|
|
12
|
+
`/config` slash command at `skills/config/SKILL.md` — both write to the
|
|
13
|
+
same `.user_config.json` via `user_config.save()`.
|
|
14
|
+
|
|
15
|
+
The wizard is intentionally narrow: variant + theme only. ELO range is
|
|
16
|
+
a power-user knob; surface it through `config set --elo MIN MAX`.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
# Allow `python3 ~/.claude/coach/bin/configure.py …` AND in-repo runs.
|
|
25
|
+
_BIN_DIR = Path(__file__).resolve().parent
|
|
26
|
+
if str(_BIN_DIR) not in sys.path:
|
|
27
|
+
sys.path.insert(0, str(_BIN_DIR))
|
|
28
|
+
|
|
29
|
+
import user_config # noqa: E402
|
|
30
|
+
from statusline_variants import VARIANTS, Glyphs, render # noqa: E402
|
|
31
|
+
from themes import THEMES, list_themes # noqa: E402
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _sample_glyphs(theme_name: str, level: int = 7) -> Glyphs:
|
|
35
|
+
"""Build a representative `Glyphs` payload for previews. Matches the
|
|
36
|
+
payload `/config preview` uses so terminal and slash-command output
|
|
37
|
+
stay visually consistent."""
|
|
38
|
+
ladder = THEMES.get(theme_name, THEMES["craft"])
|
|
39
|
+
return Glyphs(
|
|
40
|
+
level=level,
|
|
41
|
+
name=ladder[level - 1],
|
|
42
|
+
elo=1232,
|
|
43
|
+
session_xp=15,
|
|
44
|
+
sigil_tier="silver",
|
|
45
|
+
bar_pct=0.30,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- preview --------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def cmd_preview(_args: argparse.Namespace) -> int:
|
|
52
|
+
"""Render every variant × the user's current theme, plus L1/L25/L50
|
|
53
|
+
sample names per theme. Byte-equivalent to `/config preview` from
|
|
54
|
+
inside Claude Code."""
|
|
55
|
+
cfg = user_config.load()
|
|
56
|
+
sample = _sample_glyphs(cfg["theme"])
|
|
57
|
+
|
|
58
|
+
print("STATUSLINE VARIANTS (rendered with your current theme):")
|
|
59
|
+
for k in VARIANTS:
|
|
60
|
+
marker = " ← current" if k == cfg["statusline_variant"] else ""
|
|
61
|
+
print(f" {k:>8} → {render(k, sample)}{marker}")
|
|
62
|
+
|
|
63
|
+
print()
|
|
64
|
+
print("THEMES (sample L1 / L25 / L50 names):")
|
|
65
|
+
for name in list_themes():
|
|
66
|
+
arr = THEMES[name]
|
|
67
|
+
marker = " ← current" if name == cfg["theme"] else ""
|
|
68
|
+
print(f" {name:>13} → {arr[0]} … {arr[24]} … {arr[49]}{marker}")
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# --- set ------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def cmd_set(args: argparse.Namespace) -> int:
|
|
75
|
+
"""Apply explicitly-passed flags to the config. Keys not passed stay
|
|
76
|
+
at their existing value (delegated to `user_config.update()`)."""
|
|
77
|
+
updates: dict = {}
|
|
78
|
+
if args.theme is not None:
|
|
79
|
+
updates["theme"] = args.theme
|
|
80
|
+
if args.statusline is not None:
|
|
81
|
+
updates["statusline_variant"] = args.statusline
|
|
82
|
+
if args.elo is not None:
|
|
83
|
+
emin, emax = args.elo
|
|
84
|
+
updates["elo_min"] = emin
|
|
85
|
+
updates["elo_max"] = emax
|
|
86
|
+
|
|
87
|
+
if not updates:
|
|
88
|
+
print("nothing to do — pass at least one of --theme / --statusline / --elo",
|
|
89
|
+
file=sys.stderr)
|
|
90
|
+
print("(or run `coach-claw config preview` to see what's available)",
|
|
91
|
+
file=sys.stderr)
|
|
92
|
+
return 1
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
user_config.update(**updates)
|
|
96
|
+
except ValueError as exc:
|
|
97
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
98
|
+
return 1
|
|
99
|
+
|
|
100
|
+
summary = ", ".join(f"{k}={v}" for k, v in updates.items())
|
|
101
|
+
print(f"saved: {summary}")
|
|
102
|
+
print("Open a new Claude Code prompt to see it.")
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --- wizard ---------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def _ask_choice(label: str, choices: list[str], current: str) -> str:
|
|
109
|
+
"""Prompt for a value from `choices`, defaulting to `current` on
|
|
110
|
+
blank input. Re-prompts on invalid input. After 3 invalid tries,
|
|
111
|
+
falls through with the default to avoid infinite loops in weird
|
|
112
|
+
terminal contexts."""
|
|
113
|
+
print()
|
|
114
|
+
print(f"{label} (current: {current}):")
|
|
115
|
+
for i, name in enumerate(choices, start=1):
|
|
116
|
+
marker = " ←" if name == current else ""
|
|
117
|
+
print(f" {i:>2}. {name}{marker}")
|
|
118
|
+
print(f" (Enter to keep '{current}')")
|
|
119
|
+
|
|
120
|
+
for attempt in range(3):
|
|
121
|
+
raw = input("> ").strip()
|
|
122
|
+
if not raw:
|
|
123
|
+
return current
|
|
124
|
+
# Numeric pick
|
|
125
|
+
if raw.isdigit():
|
|
126
|
+
idx = int(raw)
|
|
127
|
+
if 1 <= idx <= len(choices):
|
|
128
|
+
return choices[idx - 1]
|
|
129
|
+
# Name pick
|
|
130
|
+
if raw in choices:
|
|
131
|
+
return raw
|
|
132
|
+
print(f" '{raw}' is not a valid choice. Pick a number 1-{len(choices)} or a name from the list.")
|
|
133
|
+
|
|
134
|
+
print(f" (giving up after 3 invalid tries — keeping '{current}')")
|
|
135
|
+
return current
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_wizard(_args: argparse.Namespace) -> int:
|
|
139
|
+
"""Interactive variant + theme picker. TTY-gated; non-interactive
|
|
140
|
+
invocations get a pointer to `config set` and exit 0."""
|
|
141
|
+
if not sys.stdin.isatty():
|
|
142
|
+
print("Wizard requires an interactive terminal.")
|
|
143
|
+
print("For scripted/CI installs, use:")
|
|
144
|
+
print(" coach-claw config set --theme ocean --statusline pips")
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
cfg = user_config.load()
|
|
148
|
+
|
|
149
|
+
# Show the live preview up front so the user has a visual reference
|
|
150
|
+
# for the choices to come.
|
|
151
|
+
cmd_preview(_args)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
new_variant = _ask_choice(
|
|
155
|
+
"Pick a statusline variant",
|
|
156
|
+
list(VARIANTS.keys()),
|
|
157
|
+
cfg["statusline_variant"],
|
|
158
|
+
)
|
|
159
|
+
new_theme = _ask_choice(
|
|
160
|
+
"Pick a theme (50-name level ladder + celebration banners)",
|
|
161
|
+
list_themes(),
|
|
162
|
+
cfg["theme"],
|
|
163
|
+
)
|
|
164
|
+
except (KeyboardInterrupt, EOFError):
|
|
165
|
+
print()
|
|
166
|
+
print("Wizard cancelled — no changes saved.")
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
if new_variant == cfg["statusline_variant"] and new_theme == cfg["theme"]:
|
|
170
|
+
print()
|
|
171
|
+
print("No changes — config left untouched.")
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
user_config.update(
|
|
176
|
+
statusline_variant=new_variant,
|
|
177
|
+
theme=new_theme,
|
|
178
|
+
)
|
|
179
|
+
except ValueError as exc:
|
|
180
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
181
|
+
return 1
|
|
182
|
+
|
|
183
|
+
print()
|
|
184
|
+
sample = _sample_glyphs(new_theme)
|
|
185
|
+
print(f" saved: {render(new_variant, sample)}")
|
|
186
|
+
print("Open a new Claude Code prompt to see it.")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# --- argparse glue --------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def _parse_elo(raw: str) -> list[int]:
|
|
193
|
+
"""argparse type for `--elo MIN MAX`. Accepts two whitespace-separated
|
|
194
|
+
ints; argparse runs this for each token with nargs=2."""
|
|
195
|
+
try:
|
|
196
|
+
value = int(raw)
|
|
197
|
+
except ValueError:
|
|
198
|
+
raise argparse.ArgumentTypeError(f"ELO bound must be an integer, got {raw!r}")
|
|
199
|
+
if value <= 0:
|
|
200
|
+
raise argparse.ArgumentTypeError(f"ELO bounds must be positive, got {value}")
|
|
201
|
+
return value
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
205
|
+
parser = argparse.ArgumentParser(
|
|
206
|
+
prog="coach-claw config",
|
|
207
|
+
description="Terminal-side editor for ~/.claude/coach/.user_config.json. "
|
|
208
|
+
"Same backing file as the /config slash command in Claude Code.",
|
|
209
|
+
)
|
|
210
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
211
|
+
|
|
212
|
+
p_set = sub.add_parser("set", help="Apply specific values without prompting")
|
|
213
|
+
p_set.add_argument("--theme", help=f"one of: {', '.join(list_themes())}")
|
|
214
|
+
p_set.add_argument(
|
|
215
|
+
"--statusline",
|
|
216
|
+
help=f"one of: {', '.join(VARIANTS.keys())}",
|
|
217
|
+
)
|
|
218
|
+
p_set.add_argument(
|
|
219
|
+
"--elo",
|
|
220
|
+
nargs=2,
|
|
221
|
+
type=_parse_elo,
|
|
222
|
+
metavar=("MIN", "MAX"),
|
|
223
|
+
help="ELO interpolation range (default 1000 2800)",
|
|
224
|
+
)
|
|
225
|
+
p_set.set_defaults(func=cmd_set)
|
|
226
|
+
|
|
227
|
+
p_preview = sub.add_parser(
|
|
228
|
+
"preview", help="Print every variant × theme combo so you can pick one")
|
|
229
|
+
p_preview.set_defaults(func=cmd_preview)
|
|
230
|
+
|
|
231
|
+
p_wizard = sub.add_parser(
|
|
232
|
+
"wizard", help="Interactive picker for variant + theme (TTY only)")
|
|
233
|
+
p_wizard.set_defaults(func=cmd_wizard)
|
|
234
|
+
|
|
235
|
+
return parser
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def main(argv: list[str] | None = None) -> int:
|
|
239
|
+
parser = _build_parser()
|
|
240
|
+
args = parser.parse_args(argv)
|
|
241
|
+
return args.func(args)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == "__main__":
|
|
245
|
+
sys.exit(main())
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Detect whether the daily Coach insights cron is registered.
|
|
2
|
+
|
|
3
|
+
Plugin distribution context only — the npm CLI's `coach-claw launchd`
|
|
4
|
+
subcommand is the canonical way to register OS-level scheduling. The
|
|
5
|
+
plugin model has no equivalent (monitors are event-streaming, not
|
|
6
|
+
cron-like). When a user installs only the plugin, profile.yaml never
|
|
7
|
+
gets the daily deterministic refresh and Coach silently grows stale.
|
|
8
|
+
|
|
9
|
+
This module powers a one-time nudge banner: detect the gap, suggest
|
|
10
|
+
the CLI command, write a marker so we don't re-nudge.
|
|
11
|
+
|
|
12
|
+
Detection is best-effort and fail-safe — if launchctl/crontab errors
|
|
13
|
+
or times out, we return True (assume registered) so we never harass a
|
|
14
|
+
user with false-positive nudges.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import platform
|
|
19
|
+
import subprocess
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Default plist label registered by install-launchd.sh.
|
|
23
|
+
LAUNCHD_LABEL = "com.local.claude-coach"
|
|
24
|
+
|
|
25
|
+
# Substrings that indicate a Coach cron line on Linux. Matches both the
|
|
26
|
+
# canonical `~/.claude/coach/bin/insights.sh 1d` pattern and the
|
|
27
|
+
# possibly-renamed `claude-coach` script if a future helper introduces
|
|
28
|
+
# one.
|
|
29
|
+
LINUX_CRON_MARKERS = ("claude-coach", "coach/bin/insights.sh")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_cron_registered() -> bool:
|
|
33
|
+
"""Return True if a Coach insights cron is already registered.
|
|
34
|
+
|
|
35
|
+
macOS: queries `launchctl list <LAUNCHD_LABEL>`. Exit 0 means the
|
|
36
|
+
plist is loaded.
|
|
37
|
+
|
|
38
|
+
Linux: greps `crontab -l` for known Coach markers. Empty crontab
|
|
39
|
+
or grep miss → False.
|
|
40
|
+
|
|
41
|
+
Other platforms: returns True (no-op — Windows and other systems
|
|
42
|
+
don't use the cron path; we don't want to nudge).
|
|
43
|
+
|
|
44
|
+
Errors during detection (timeouts, missing binaries) → True. Better
|
|
45
|
+
to suppress a nudge than to fire a false-positive.
|
|
46
|
+
"""
|
|
47
|
+
system = platform.system()
|
|
48
|
+
if system == "Darwin":
|
|
49
|
+
return _launchctl_loaded(LAUNCHD_LABEL)
|
|
50
|
+
if system == "Linux":
|
|
51
|
+
return _crontab_has_coach()
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _launchctl_loaded(label: str) -> bool:
|
|
56
|
+
try:
|
|
57
|
+
r = subprocess.run(
|
|
58
|
+
["launchctl", "list", label],
|
|
59
|
+
capture_output=True,
|
|
60
|
+
timeout=5,
|
|
61
|
+
)
|
|
62
|
+
return r.returncode == 0
|
|
63
|
+
except Exception:
|
|
64
|
+
return True # fail-safe: don't nudge on detection error
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _crontab_has_coach() -> bool:
|
|
68
|
+
try:
|
|
69
|
+
r = subprocess.run(
|
|
70
|
+
["crontab", "-l"],
|
|
71
|
+
capture_output=True,
|
|
72
|
+
timeout=5,
|
|
73
|
+
)
|
|
74
|
+
# `crontab -l` exits nonzero when the user has no crontab at all.
|
|
75
|
+
# That's the strongest "not registered" signal we get.
|
|
76
|
+
if r.returncode != 0:
|
|
77
|
+
return False
|
|
78
|
+
out = r.stdout.decode("utf-8", errors="replace")
|
|
79
|
+
return any(marker in out for marker in LINUX_CRON_MARKERS)
|
|
80
|
+
except Exception:
|
|
81
|
+
return True
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render the default Coach Claw statusline composition:
|
|
3
|
+
|
|
4
|
+
◆ <model> ┃ <bar> NN% ┃ <coach segment>
|
|
5
|
+
|
|
6
|
+
Reads the Claude Code statusline JSON from stdin once and emits a
|
|
7
|
+
single ANSI-colored line on stdout. Replaces an earlier bash wrapper
|
|
8
|
+
that piped through `jq` twice — jq is not in macOS's default PATH,
|
|
9
|
+
so a fresh box rendered `jq: command not found` instead of a model
|
|
10
|
+
name and 0% instead of a bar.
|
|
11
|
+
|
|
12
|
+
Visual contract — must match what the v0.3.0 bash wrapper rendered:
|
|
13
|
+
|
|
14
|
+
• ICE_SILVER ◆ + lowercased model name with " context" stripped and
|
|
15
|
+
spaces collapsed to "·"
|
|
16
|
+
• DEEP_COBALT ┃ separator
|
|
17
|
+
• 20-segment context-window bar with white→cobalt gradient (filled)
|
|
18
|
+
and DIM_STEEL ▱ (empty); int-clamped 0-100; round-half-up of float
|
|
19
|
+
percentage
|
|
20
|
+
• ICE_SILVER NN%
|
|
21
|
+
• DEEP_COBALT ┃ separator
|
|
22
|
+
• coach segment from stats.render_segment() (level + ELO + session
|
|
23
|
+
arrow); rendered in-process — no second Python invocation
|
|
24
|
+
|
|
25
|
+
The prefix always renders even on a fresh install with no profile;
|
|
26
|
+
the trailing coach segment is silent until stats.py has signal.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
35
|
+
from stats import render_segment # noqa: E402
|
|
36
|
+
|
|
37
|
+
# Palette — RGB triples match coach/default-statusline-command.sh.
|
|
38
|
+
ICE_SILVER = "\x1b[38;2;200;214;229m"
|
|
39
|
+
DEEP_COBALT = "\x1b[38;2;30;144;255m"
|
|
40
|
+
DIM_STEEL = "\x1b[38;2;58;58;74m"
|
|
41
|
+
RESET = "\x1b[0m"
|
|
42
|
+
|
|
43
|
+
BAR_SEGMENTS = 20
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _normalize_model(display_name: str) -> str:
|
|
47
|
+
"""Lowercase, strip ' context' substrings, collapse spaces to '·'.
|
|
48
|
+
|
|
49
|
+
Mirrors `tr '[:upper:]' '[:lower:]' | sed 's/ context//g; s/ /·/g'`
|
|
50
|
+
from the deleted bash wrapper. Order matters: lowercase first so
|
|
51
|
+
"Context" matches " context" after casing.
|
|
52
|
+
"""
|
|
53
|
+
s = display_name.lower().replace(" context", "")
|
|
54
|
+
return s.replace(" ", "·")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _render_bar(used_int: int) -> str:
|
|
58
|
+
"""20-segment bar with white→cobalt gradient on filled segments."""
|
|
59
|
+
used_int = max(0, min(100, used_int))
|
|
60
|
+
filled = used_int * BAR_SEGMENTS // 100
|
|
61
|
+
empty = BAR_SEGMENTS - filled
|
|
62
|
+
parts: list[str] = []
|
|
63
|
+
for i in range(filled):
|
|
64
|
+
if filled <= 1:
|
|
65
|
+
t_num, t_den = 0, 1
|
|
66
|
+
else:
|
|
67
|
+
t_num, t_den = i, filled - 1
|
|
68
|
+
# White (#FFFFFF) → DEEP_COBALT (#1E90FF). Integer math matches
|
|
69
|
+
# bash's `$(( ))` rounding-toward-zero so visual output is
|
|
70
|
+
# byte-identical to the deleted wrapper for any used_int.
|
|
71
|
+
r = 255 - (255 - 30) * t_num // t_den
|
|
72
|
+
g = 255 - (255 - 144) * t_num // t_den
|
|
73
|
+
b = 255
|
|
74
|
+
parts.append(f"\x1b[38;2;{r};{g};{b}m▰{RESET}")
|
|
75
|
+
parts.extend(f"{DIM_STEEL}▱{RESET}" for _ in range(empty))
|
|
76
|
+
return "".join(parts)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _read_stdin_payload() -> dict:
|
|
80
|
+
try:
|
|
81
|
+
if sys.stdin.isatty():
|
|
82
|
+
return {}
|
|
83
|
+
raw = sys.stdin.read()
|
|
84
|
+
if not raw:
|
|
85
|
+
return {}
|
|
86
|
+
data = json.loads(raw)
|
|
87
|
+
return data if isinstance(data, dict) else {}
|
|
88
|
+
except Exception:
|
|
89
|
+
return {}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _safe_get(d: dict, *path):
|
|
93
|
+
cur = d
|
|
94
|
+
for key in path:
|
|
95
|
+
if not isinstance(cur, dict):
|
|
96
|
+
return None
|
|
97
|
+
cur = cur.get(key)
|
|
98
|
+
return cur
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def main() -> int:
|
|
102
|
+
try:
|
|
103
|
+
payload = _read_stdin_payload()
|
|
104
|
+
|
|
105
|
+
raw_name = _safe_get(payload, "model", "display_name")
|
|
106
|
+
display_name = raw_name if isinstance(raw_name, str) and raw_name else "unknown"
|
|
107
|
+
model_label = _normalize_model(display_name)
|
|
108
|
+
|
|
109
|
+
raw_pct = _safe_get(payload, "context_window", "used_percentage")
|
|
110
|
+
used_pct = float(raw_pct) if isinstance(raw_pct, (int, float)) else 0.0
|
|
111
|
+
used_int = max(0, min(100, int(round(used_pct))))
|
|
112
|
+
|
|
113
|
+
out = (
|
|
114
|
+
f"{ICE_SILVER}◆ {model_label}{RESET} {DEEP_COBALT}┃{RESET} "
|
|
115
|
+
f"{_render_bar(used_int)} {ICE_SILVER}{used_int}%{RESET} "
|
|
116
|
+
f"{DEEP_COBALT}┃{RESET}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
coach = render_segment(payload)
|
|
120
|
+
if coach:
|
|
121
|
+
out = f"{out} {coach}"
|
|
122
|
+
|
|
123
|
+
sys.stdout.write(out)
|
|
124
|
+
except Exception:
|
|
125
|
+
# Failsafe: the statusline runs on every render. A crash here
|
|
126
|
+
# must not noise up the terminal with a traceback or break
|
|
127
|
+
# Claude Code's render. Match the hook fail-soft contract —
|
|
128
|
+
# emit nothing and exit 0; the user's native statusline (or
|
|
129
|
+
# none) takes over for the next render.
|
|
130
|
+
pass
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
sys.exit(main())
|