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