@ramarivera/coding-buddy 0.4.0-alpha.1 → 0.4.0-alpha.3

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 (52) hide show
  1. package/README.md +4 -5
  2. package/{hooks → adapters/claude/hooks}/hooks.json +3 -3
  3. package/{cli → adapters/claude/install}/backup.ts +3 -3
  4. package/{cli → adapters/claude/install}/disable.ts +22 -2
  5. package/{cli → adapters/claude/install}/doctor.ts +30 -23
  6. package/{cli → adapters/claude/install}/hunt.ts +4 -4
  7. package/{cli → adapters/claude/install}/install.ts +62 -26
  8. package/{cli → adapters/claude/install}/pick.ts +3 -3
  9. package/{cli → adapters/claude/install}/settings.ts +1 -1
  10. package/{cli → adapters/claude/install}/show.ts +2 -2
  11. package/{cli → adapters/claude/install}/uninstall.ts +22 -2
  12. package/{.claude-plugin → adapters/claude/plugin}/plugin.json +1 -1
  13. package/adapters/claude/popup/buddy-popup.sh +92 -0
  14. package/adapters/claude/popup/buddy-render.sh +540 -0
  15. package/adapters/claude/popup/popup-manager.sh +355 -0
  16. package/{server → adapters/claude/rendering}/art.ts +3 -115
  17. package/{server → adapters/claude/server}/index.ts +49 -71
  18. package/adapters/claude/server/instructions.ts +24 -0
  19. package/adapters/claude/server/resources.ts +38 -0
  20. package/adapters/claude/storage/achievements.ts +253 -0
  21. package/adapters/claude/storage/identity.ts +14 -0
  22. package/adapters/claude/storage/settings.ts +42 -0
  23. package/{server → adapters/claude/storage}/state.ts +3 -65
  24. package/adapters/pi/README.md +64 -0
  25. package/adapters/pi/commands.ts +173 -0
  26. package/adapters/pi/events.ts +150 -0
  27. package/adapters/pi/identity.ts +10 -0
  28. package/adapters/pi/index.ts +25 -0
  29. package/adapters/pi/renderers.ts +73 -0
  30. package/adapters/pi/storage.ts +295 -0
  31. package/adapters/pi/tools.ts +6 -0
  32. package/adapters/pi/ui.ts +39 -0
  33. package/cli/index.ts +11 -11
  34. package/cli/verify.ts +2 -2
  35. package/core/achievements.ts +203 -0
  36. package/core/art-data.ts +105 -0
  37. package/core/command-service.ts +338 -0
  38. package/core/model.ts +59 -0
  39. package/core/ports.ts +40 -0
  40. package/core/render-model.ts +10 -0
  41. package/package.json +23 -19
  42. package/server/achievements.ts +0 -445
  43. /package/{hooks → adapters/claude/hooks}/buddy-comment.sh +0 -0
  44. /package/{hooks → adapters/claude/hooks}/name-react.sh +0 -0
  45. /package/{hooks → adapters/claude/hooks}/react.sh +0 -0
  46. /package/{cli → adapters/claude/install}/test-statusline.sh +0 -0
  47. /package/{cli → adapters/claude/install}/test-statusline.ts +0 -0
  48. /package/{.claude-plugin → adapters/claude/plugin}/marketplace.json +0 -0
  49. /package/{skills → adapters/claude/skills}/buddy/SKILL.md +0 -0
  50. /package/{statusline → adapters/claude/statusline}/buddy-status.sh +0 -0
  51. /package/{server → core}/engine.ts +0 -0
  52. /package/{server → core}/reactions.ts +0 -0
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env bash
2
+ # claude-buddy popup manager -- create/destroy tmux popup overlay
3
+ #
4
+ # Usage:
5
+ # popup-manager.sh start -- open buddy popup (bottom-right corner)
6
+ # popup-manager.sh stop -- close buddy popup
7
+ # popup-manager.sh status -- check if popup is running
8
+ #
9
+ # Called by SessionStart/SessionEnd hooks.
10
+ #
11
+ # Architecture: The "start" command runs a blocking reopen loop.
12
+ # tmux display-popup blocks until the popup closes. When ESC closes
13
+ # the popup (hardcoded tmux behavior), we forward ESC to CC and
14
+ # reopen. The loop exits when: stop flag is set, or CC pane dies.
15
+
16
+ set -euo pipefail
17
+
18
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
19
+ BUDDY_DIR="$HOME/.claude-buddy"
20
+
21
+ # Session ID: sanitized tmux pane number, or "default" outside tmux
22
+ SID="${TMUX_PANE#%}"
23
+ SID="${SID:-default}"
24
+
25
+ STOP_FLAG="$BUDDY_DIR/popup-stop.$SID"
26
+ REOPEN_PID_FILE="$BUDDY_DIR/popup-reopen-pid.$SID"
27
+ STATE_FILE="$BUDDY_DIR/status.json"
28
+
29
+ POPUP_W=12 # minimum / fallback
30
+ ART_W=12 # updated dynamically by compute_art_width
31
+ BUBBLE_EXTRA=3 # border top + border bottom + connector line
32
+ BORDER_EXTRA=0 # +2 on tmux < 3.3 (popup has a border)
33
+ REACTION_TTL=20 # seconds
34
+ REACTION_FILE="$BUDDY_DIR/reaction.$SID.json"
35
+ RESIZE_FLAG="$BUDDY_DIR/popup-resize.$SID"
36
+ CONFIG_FILE="$BUDDY_DIR/config.json"
37
+ LEFT_BUBBLE_W=22 # bubble box width in left mode (including frame chars)
38
+
39
+ # Read config early (needed for BASE_H calculation)
40
+ BUBBLE_POSITION="top"
41
+ SHOW_RARITY=1
42
+ if [ -f "$CONFIG_FILE" ]; then
43
+ _bp=$(jq -r '.bubblePosition // "top"' "$CONFIG_FILE" 2>/dev/null || echo "top")
44
+ case "$_bp" in top|left) BUBBLE_POSITION="$_bp" ;; esac
45
+ _sr=$(jq -r 'if .showRarity == false then "false" else "true" end' "$CONFIG_FILE" 2>/dev/null || echo "true")
46
+ [ "$_sr" = "false" ] && SHOW_RARITY=0
47
+ fi
48
+
49
+ BASE_H=8 # art(4) + blank(1) + name(1) + rarity(1) + padding(1)
50
+ [ "$SHOW_RARITY" -eq 0 ] && BASE_H=7
51
+
52
+ # ─── Helpers ─────────────────────────────────────────────────────────────────
53
+
54
+ is_tmux() {
55
+ [ -n "${TMUX:-}" ]
56
+ }
57
+
58
+ tmux_version_ok() {
59
+ local ver
60
+ ver=$(tmux -V 2>/dev/null | grep -oE '[0-9]+\.[0-9a-z]+' | head -1)
61
+ [ -z "$ver" ] && return 1
62
+ local major minor
63
+ major="${ver%%.*}"
64
+ minor="${ver#*.}"
65
+ minor="${minor%%[a-z]*}"
66
+ [ "$major" -gt 3 ] 2>/dev/null && return 0
67
+ [ "$major" -eq 3 ] && [ "$minor" -ge 2 ] 2>/dev/null && return 0
68
+ return 1
69
+ }
70
+
71
+ # tmux 3.4+ supports -B (borderless), -e (env), and -x R/-y S positioning
72
+ tmux_has_borderless() {
73
+ local ver
74
+ ver=$(tmux -V 2>/dev/null | grep -oE '[0-9]+\.[0-9a-z]+' | head -1)
75
+ [ -z "$ver" ] && return 1
76
+ local major minor
77
+ major="${ver%%.*}"
78
+ minor="${ver#*.}"
79
+ minor="${minor%%[a-z]*}"
80
+ [ "$major" -gt 3 ] 2>/dev/null && return 0
81
+ [ "$major" -eq 3 ] && [ "$minor" -ge 4 ] 2>/dev/null && return 0
82
+ return 1
83
+ }
84
+
85
+ cc_pane_alive() {
86
+ tmux list-panes -a -F '#{pane_id}' 2>/dev/null | grep -qF "$1"
87
+ }
88
+
89
+ # Compute popup width from buddy data (widest of: art, name, stars+rarity)
90
+ compute_art_width() {
91
+ [ -f "$STATE_FILE" ] || return
92
+ local name rarity
93
+ name=$(jq -r '.name // ""' "$STATE_FILE" 2>/dev/null)
94
+ rarity=$(jq -r '.rarity // "common"' "$STATE_FILE" 2>/dev/null)
95
+ local w=10 # minimum art width
96
+ # Name length
97
+ [ ${#name} -gt "$w" ] && w=${#name}
98
+ # Stars + rarity line (only if enabled)
99
+ if [ "$SHOW_RARITY" -eq 1 ]; then
100
+ local stars_w=$(( 6 + ${#rarity} ))
101
+ [ "$stars_w" -gt "$w" ] && w=$stars_w
102
+ fi
103
+ # Add 2 for padding
104
+ w=$(( w + 2 ))
105
+ POPUP_W=$w
106
+ ART_W=$w
107
+ }
108
+
109
+ # Compute popup dimensions based on reaction state and bubble position
110
+ # Sets COMPUTED_W and COMPUTED_H
111
+ compute_dimensions() {
112
+ compute_art_width
113
+ local h=$BASE_H
114
+ local w=$POPUP_W
115
+ h=$(( h + BORDER_EXTRA ))
116
+ w=$(( w + BORDER_EXTRA ))
117
+
118
+ # Account for hat (adds 1 art row beyond the 4 in BASE_H)
119
+ local art_rows=4
120
+ if [ -f "$STATE_FILE" ]; then
121
+ local _hat
122
+ _hat=$(jq -r '.hat // "none"' "$STATE_FILE" 2>/dev/null || echo "none")
123
+ if [ "$_hat" != "none" ]; then
124
+ h=$(( h + 1 ))
125
+ art_rows=5
126
+ fi
127
+ fi
128
+
129
+ local fresh=0
130
+ if [ -f "$STATE_FILE" ]; then
131
+ local reaction
132
+ reaction=$(jq -r '.reaction // ""' "$STATE_FILE" 2>/dev/null || true)
133
+ if [ -n "$reaction" ] && [ "$reaction" != "null" ]; then
134
+ if [ -f "$REACTION_FILE" ]; then
135
+ local ts now age
136
+ ts=$(jq -r '.timestamp // 0' "$REACTION_FILE" 2>/dev/null || echo 0)
137
+ if [ "$ts" != "0" ]; then
138
+ now=$(date +%s)
139
+ age=$(( now - ts / 1000 ))
140
+ [ "$age" -lt "$REACTION_TTL" ] && fresh=1
141
+ fi
142
+ fi
143
+ if [ "$fresh" -eq 1 ]; then
144
+ if [ "$BUBBLE_POSITION" = "top" ]; then
145
+ # Top mode: bubble adds rows above the art
146
+ local bubble_w=$(( POPUP_W - BORDER_EXTRA - 5 ))
147
+ [ "$bubble_w" -lt 20 ] && bubble_w=20
148
+ # Widen popup if bubble needs more room than art
149
+ local needed_w=$(( bubble_w + 5 + BORDER_EXTRA ))
150
+ [ "$needed_w" -gt "$w" ] && w=$needed_w
151
+ local len=${#reaction}
152
+ local lines=$(( (len + bubble_w - 1) / bubble_w ))
153
+ [ "$lines" -lt 1 ] && lines=1
154
+ h=$(( h + lines + BUBBLE_EXTRA ))
155
+ else
156
+ # Left mode: dynamic bubble width to fit text within art height
157
+ local max_text_lines=$(( art_rows - 2 )) # subtract top/bottom borders
158
+ [ "$max_text_lines" -lt 1 ] && max_text_lines=1
159
+ local len=${#reaction}
160
+ local left_inner=$(( (len + max_text_lines - 1) / max_text_lines + 5 ))
161
+ [ "$left_inner" -lt 10 ] && left_inner=10
162
+ [ "$left_inner" -gt 50 ] && left_inner=50
163
+ local left_box=$(( left_inner + 4 )) # "| " + text + " |"
164
+ w=$(( w + left_box + 2 )) # +2 for connector gap
165
+ fi
166
+ fi
167
+ fi
168
+ fi
169
+ COMPUTED_W=$w
170
+ COMPUTED_H=$h
171
+ }
172
+
173
+ is_reopen_running() {
174
+ [ -f "$REOPEN_PID_FILE" ] || return 1
175
+ local pid
176
+ pid=$(cat "$REOPEN_PID_FILE")
177
+ kill -0 "$pid" 2>/dev/null
178
+ }
179
+
180
+ # ─── Start ───────────────────────────────────────────────────────────────────
181
+
182
+ start_popup() {
183
+ is_tmux || { echo "Not in tmux" >&2; return 1; }
184
+ tmux_version_ok || { echo "tmux >= 3.2 required for popup" >&2; return 1; }
185
+
186
+ # Kill stale reopen loop for THIS session (e.g., CC restarted in same pane)
187
+ if [ -f "$REOPEN_PID_FILE" ]; then
188
+ local old_pid
189
+ old_pid=$(cat "$REOPEN_PID_FILE" 2>/dev/null)
190
+ if [ -n "$old_pid" ]; then
191
+ kill "$old_pid" 2>/dev/null || true
192
+ fi
193
+ rm -f "$REOPEN_PID_FILE"
194
+ tmux display-popup -C 2>/dev/null || true
195
+ sleep 0.2
196
+ fi
197
+
198
+ # Clean up orphaned per-session files (from crashed sessions)
199
+ for pidfile in "$BUDDY_DIR"/popup-reopen-pid.*; do
200
+ [ -f "$pidfile" ] || continue
201
+ local orphan_sid="${pidfile##*.}"
202
+ local orphan_pane="%${orphan_sid}"
203
+ if ! cc_pane_alive "$orphan_pane"; then
204
+ local orphan_pid
205
+ orphan_pid=$(cat "$pidfile" 2>/dev/null)
206
+ [ -n "$orphan_pid" ] && kill "$orphan_pid" 2>/dev/null || true
207
+ rm -f "$pidfile" "$BUDDY_DIR/popup-stop.$orphan_sid" "$BUDDY_DIR/popup-resize.$orphan_sid"
208
+ rm -f "$BUDDY_DIR/popup-env.$orphan_sid" "$BUDDY_DIR/popup-scroll.$orphan_sid"
209
+ rm -f "$BUDDY_DIR/reaction.$orphan_sid.json" "$BUDDY_DIR/.last_reaction.$orphan_sid" "$BUDDY_DIR/.last_comment.$orphan_sid"
210
+ fi
211
+ done
212
+
213
+ mkdir -p "$BUDDY_DIR"
214
+ rm -f "$STOP_FLAG" "$RESIZE_FLAG"
215
+
216
+ # tmux < 3.4 popups have a border (+2 rows, +2 cols); 3.4+ supports -B borderless
217
+ if ! tmux_has_borderless; then
218
+ BORDER_EXTRA=2
219
+ POPUP_W=$(( POPUP_W + 2 ))
220
+ fi
221
+
222
+ # Capture CC's pane ID before creating the popup
223
+ local cc_pane
224
+ cc_pane=$(tmux display-message -p '#{pane_id}')
225
+
226
+ # Run the reopen loop in background so the hook returns immediately.
227
+ # CRITICAL: Redirect stdio to /dev/null so the subshell doesn't inherit
228
+ # the parent's stdout pipe. CC's hook executor waits for ALL stdio writers
229
+ # to close before resolving -- without this redirect, the hook hangs forever
230
+ # because the long-lived subshell keeps the pipe open.
231
+ (
232
+ # Write the subshell PID. $BASHPID gives the subshell's PID on bash 4+.
233
+ # On macOS bash 3.2, $BASHPID doesn't exist, so we use sh -c 'echo $PPID'
234
+ # which prints the PID of the parent (this subshell) from a child process.
235
+ echo "${BASHPID:-$(sh -c 'echo $PPID')}" > "$REOPEN_PID_FILE"
236
+
237
+ while true; do
238
+ # Check stop conditions before (re)opening
239
+ [ -f "$STOP_FLAG" ] && break
240
+ cc_pane_alive "$cc_pane" || break
241
+
242
+ compute_dimensions
243
+
244
+ # Build popup args. tmux 3.4+ supports -B (borderless), -x R/-y S,
245
+ # and -e (env passing). On 3.2-3.3, we fall back to absolute positioning
246
+ # and pass env vars via a file.
247
+ local popup_args=()
248
+ if tmux_has_borderless; then
249
+ local tw th
250
+ tw=$(tmux display-message -p '#{window_width}' 2>/dev/null || echo 80)
251
+ th=$(tmux display-message -p '#{window_height}' 2>/dev/null || echo 24)
252
+ popup_args+=(-B -s 'bg=default')
253
+ popup_args+=(-x $(( tw - COMPUTED_W )) -y $(( th )))
254
+ popup_args+=(-e "CC_PANE=$cc_pane" -e "BUDDY_DIR=$BUDDY_DIR" -e "BUDDY_SID=$SID")
255
+ popup_args+=(-e "POPUP_INNER_W=$COMPUTED_W" -e "POPUP_INNER_H=$COMPUTED_H")
256
+ popup_args+=(-e "POPUP_ART_W=$ART_W")
257
+ else
258
+ # Fallback: position at bottom-right using absolute coords
259
+ local tw th
260
+ tw=$(tmux display-message -p '#{window_width}' 2>/dev/null || echo 80)
261
+ th=$(tmux display-message -p '#{window_height}' 2>/dev/null || echo 24)
262
+ popup_args+=(-x $(( tw - COMPUTED_W )) -y $(( th - COMPUTED_H )))
263
+ # Inner dimensions = outer - 2 (border takes 1 on each side)
264
+ local inner_w=$(( COMPUTED_W - 2 ))
265
+ local inner_h=$(( COMPUTED_H - 2 ))
266
+ # Write env vars to file (tmux 3.2-3.3 lack -e flag)
267
+ cat > "$BUDDY_DIR/popup-env.$SID" <<ENVEOF
268
+ CC_PANE=$cc_pane
269
+ BUDDY_DIR=$BUDDY_DIR
270
+ BUDDY_SID=$SID
271
+ POPUP_INNER_W=$inner_w
272
+ POPUP_INNER_H=$inner_h
273
+ POPUP_ART_W=$ART_W
274
+ ENVEOF
275
+ fi
276
+
277
+ # display-popup blocks until popup closes (ESC or command exit)
278
+ # -E = close when command exits
279
+ tmux display-popup \
280
+ "${popup_args[@]}" \
281
+ -w "$COMPUTED_W" -h "$COMPUTED_H" \
282
+ -E \
283
+ "$SCRIPT_DIR/buddy-popup.sh" "$SID" \
284
+ 2>/dev/null || true
285
+
286
+ # Popup closed. Check why.
287
+ [ -f "$STOP_FLAG" ] && break
288
+ cc_pane_alive "$cc_pane" || break
289
+
290
+ # Scroll flag = F12 pressed, enter copy-mode and wait
291
+ if [ -f "$BUDDY_DIR/popup-scroll.$SID" ]; then
292
+ rm -f "$BUDDY_DIR/popup-scroll.$SID"
293
+ tmux copy-mode -t "$cc_pane" 2>/dev/null || true
294
+ # Wait until copy-mode ends before reopening popup
295
+ while tmux display-message -t "$cc_pane" -p '#{pane_in_mode}' 2>/dev/null | grep -q '^1$'; do
296
+ [ -f "$STOP_FLAG" ] && break 2
297
+ cc_pane_alive "$cc_pane" || break 2
298
+ sleep 0.3
299
+ done
300
+ # Resize flag = render loop requested a resize, not an ESC press
301
+ elif [ -f "$RESIZE_FLAG" ]; then
302
+ rm -f "$RESIZE_FLAG"
303
+ else
304
+ # ESC closed the popup -- forward ESC to CC
305
+ tmux send-keys -t "$cc_pane" Escape 2>/dev/null || true
306
+ fi
307
+ sleep 0.1
308
+ done
309
+
310
+ rm -f "$REOPEN_PID_FILE"
311
+ ) </dev/null &>/dev/null &
312
+ disown
313
+
314
+ return 0
315
+ }
316
+
317
+ # ─── Stop ────────────────────────────────────────────────────────────────────
318
+
319
+ stop_popup() {
320
+ mkdir -p "$BUDDY_DIR"
321
+ # Set stop flag so reopen loop exits
322
+ touch "$STOP_FLAG"
323
+ # Close any open popup on the current client
324
+ tmux display-popup -C 2>/dev/null || true
325
+ # Kill reopen loop if still running
326
+ if [ -f "$REOPEN_PID_FILE" ]; then
327
+ local pid
328
+ pid=$(cat "$REOPEN_PID_FILE")
329
+ kill "$pid" 2>/dev/null || true
330
+ rm -f "$REOPEN_PID_FILE"
331
+ fi
332
+ # Clean up per-session files
333
+ rm -f "$BUDDY_DIR/popup-stop.$SID" "$BUDDY_DIR/popup-resize.$SID"
334
+ rm -f "$BUDDY_DIR/popup-env.$SID" "$BUDDY_DIR/popup-scroll.$SID"
335
+ rm -f "$BUDDY_DIR/reaction.$SID.json" "$BUDDY_DIR/.last_reaction.$SID" "$BUDDY_DIR/.last_comment.$SID"
336
+ }
337
+
338
+ # ─── Status ──────────────────────────────────────────────────────────────────
339
+
340
+ popup_status() {
341
+ if is_reopen_running; then
342
+ echo "running"
343
+ else
344
+ echo "stopped"
345
+ fi
346
+ }
347
+
348
+ # ─── Dispatch ────────────────────────────────────────────────────────────────
349
+
350
+ case "${1:-status}" in
351
+ start) start_popup ;;
352
+ stop) stop_popup ;;
353
+ status) popup_status ;;
354
+ *) echo "Usage: $0 {start|stop|status}" >&2; exit 1 ;;
355
+ esac
@@ -6,115 +6,9 @@
6
6
  * {E} is replaced with the eye character at render time.
7
7
  */
8
8
 
9
- import type { Species, Eye, Hat, Rarity, StatName, BuddyBones } from "./engine.ts";
10
-
11
- // ─── Species art: 3 frames × 5 lines each ──────────────────────────────────
12
-
13
- export const SPECIES_ART: Record<Species, string[][]> = {
14
- duck: [
15
- [" ", " __ ", " <({E} )___ ", " ( ._> ", " `--' "],
16
- [" ", " __ ", " <({E} )___ ", " ( ._> ", " `--'~ "],
17
- [" ", " __ ", " <({E} )___ ", " ( .__> ", " `--' "],
18
- ],
19
- goose: [
20
- [" ", " ({E}> ", " || ", " _(__)_ ", " ^^^^ "],
21
- [" ", " ({E}> ", " || ", " _(__)_ ", " ^^^^ "],
22
- [" ", " ({E}>> ", " || ", " _(__)_ ", " ^^^^ "],
23
- ],
24
- blob: [
25
- [" ", " .----. ", " ( {E} {E} ) ", " ( ) ", " `----' "],
26
- [" ", " .------. ", " ( {E} {E} ) ", " ( ) ", " `------' "],
27
- [" ", " .--. ", " ({E} {E}) ", " ( ) ", " `--' "],
28
- ],
29
- cat: [
30
- [" ", " /\\_/\\ ", " ( {E} {E}) ", " ( \u03c9 ) ", " (\")_(\") "],
31
- [" ", " /\\_/\\ ", " ( {E} {E}) ", " ( \u03c9 ) ", " (\")_(\")~ "],
32
- [" ", " /\\-/\\ ", " ( {E} {E}) ", " ( \u03c9 ) ", " (\")_(\") "],
33
- ],
34
- dragon: [
35
- [" ", " /^\\ /^\\ ", " < {E} {E} > ", " ( ~~ ) ", " `-vvvv-' "],
36
- [" ", " /^\\ /^\\ ", " < {E} {E} > ", " ( ) ", " `-vvvv-' "],
37
- [" ~ ~ ", " /^\\ /^\\ ", " < {E} {E} > ", " ( ~~ ) ", " `-vvvv-' "],
38
- ],
39
- octopus: [
40
- [" ", " .----. ", " ( {E} {E} ) ", " (______) ", " /\\/\\/\\/\\ "],
41
- [" ", " .----. ", " ( {E} {E} ) ", " (______) ", " \\/\\/\\/\\/ "],
42
- [" o ", " .----. ", " ( {E} {E} ) ", " (______) ", " /\\/\\/\\/\\ "],
43
- ],
44
- owl: [
45
- [" ", " /\\ /\\ ", " (({E})({E})) ", " ( >< ) ", " `----' "],
46
- [" ", " /\\ /\\ ", " (({E})({E})) ", " ( >< ) ", " .----. "],
47
- [" ", " /\\ /\\ ", " (({E})(-)) ", " ( >< ) ", " `----' "],
48
- ],
49
- penguin: [
50
- [" ", " .---. ", " ({E}>{E}) ", " /( )\\ ", " `---' "],
51
- [" ", " .---. ", " ({E}>{E}) ", " |( )| ", " `---' "],
52
- [" .---. ", " ({E}>{E}) ", " /( )\\ ", " `---' ", " ~ ~ "],
53
- ],
54
- turtle: [
55
- [" ", " _,--._ ", " ( {E} {E} ) ", " /[______]\\ ", " `` `` "],
56
- [" ", " _,--._ ", " ( {E} {E} ) ", " /[______]\\ ", " `` `` "],
57
- [" ", " _,--._ ", " ( {E} {E} ) ", " /[======]\\ ", " `` `` "],
58
- ],
59
- snail: [
60
- [" ", " {E} .--. ", " \\ ( @ ) ", " \\_`--' ", " ~~~~~~~ "],
61
- [" ", " {E} .--. ", " | ( @ ) ", " \\_`--' ", " ~~~~~~~ "],
62
- [" ", " {E} .--. ", " \\ ( @ ) ", " \\_`--' ", " ~~~~~~ "],
63
- ],
64
- ghost: [
65
- [" ", " .----. ", " / {E} {E} \\ ", " | | ", " ~`~``~`~ "],
66
- [" ", " .----. ", " / {E} {E} \\ ", " | | ", " `~`~~`~` "],
67
- [" ~ ~ ", " .----. ", " / {E} {E} \\ ", " | | ", " ~~`~~`~~ "],
68
- ],
69
- axolotl: [
70
- [" ", "}~(______)~{", "}~({E} .. {E})~{", " ( .--. ) ", " (_/ \\_) "],
71
- [" ", "~}(______){~", "~}({E} .. {E}){~", " ( .--. ) ", " (_/ \\_) "],
72
- [" ", "}~(______)~{", "}~({E} .. {E})~{", " ( -- ) ", " ~_/ \\_~ "],
73
- ],
74
- capybara: [
75
- [" ", " n______n ", " ( {E} {E} ) ", " ( oo ) ", " `------' "],
76
- [" ", " n______n ", " ( {E} {E} ) ", " ( Oo ) ", " `------' "],
77
- [" ~ ~ ", " u______n ", " ( {E} {E} ) ", " ( oo ) ", " `------' "],
78
- ],
79
- cactus: [
80
- [" ", " n ____ n ", " | |{E} {E}| | ", " |_| |_| ", " | | "],
81
- [" ", " ____ ", " n |{E} {E}| n ", " |_| |_| ", " | | "],
82
- [" n n ", " | ____ | ", " | |{E} {E}| | ", " |_| |_| ", " | | "],
83
- ],
84
- robot: [
85
- [" ", " .[||]. ", " [ {E} {E} ] ", " [ ==== ] ", " `------' "],
86
- [" ", " .[||]. ", " [ {E} {E} ] ", " [ -==- ] ", " `------' "],
87
- [" * ", " .[||]. ", " [ {E} {E} ] ", " [ ==== ] ", " `------' "],
88
- ],
89
- rabbit: [
90
- [" ", " (\\__/) ", " ( {E} {E} ) ", " =( .. )= ", " (\")__(\")" ],
91
- [" ", " (|__/) ", " ( {E} {E} ) ", " =( .. )= ", " (\")__(\")" ],
92
- [" ", " (\\__/) ", " ( {E} {E} ) ", " =( . . )= ", " (\")__(\")" ],
93
- ],
94
- mushroom: [
95
- [" ", " .-o-OO-o-. ", "(__________)"," |{E} {E}| ", " |____| "],
96
- [" ", " .-O-oo-O-. ", "(__________)"," |{E} {E}| ", " |____| "],
97
- [" . o . ", " .-o-OO-o-. ", "(__________)"," |{E} {E}| ", " |____| "],
98
- ],
99
- chonk: [
100
- [" ", " /\\ /\\ ", " ( {E} {E} ) ", " ( .. ) ", " `------' "],
101
- [" ", " /\\ /| ", " ( {E} {E} ) ", " ( .. ) ", " `------' "],
102
- [" ", " /\\ /\\ ", " ( {E} {E} ) ", " ( .. ) ", " `------'~ "],
103
- ],
104
- };
105
-
106
- // ─── Hat art ────────────────────────────────────────────────────────────────
107
-
108
- export const HAT_ART: Record<Hat, string> = {
109
- none: "",
110
- crown: " \\^^^/ ",
111
- tophat: " [___] ",
112
- propeller: " -+- ",
113
- halo: " ( ) ",
114
- wizard: " /^\\ ",
115
- beanie: " (___) ",
116
- tinyduck: " ,> ",
117
- };
9
+ import type { Rarity, StatName, BuddyBones } from "../../../core/engine.ts";
10
+ import { HAT_ART } from "../../../core/art-data.ts";
11
+ import { getArtFrame, SPECIES_ART } from "../../../core/render-model.ts";
118
12
 
119
13
  // ─── Rarity ANSI colors ────────────────────────────────────────────────────
120
14
 
@@ -169,12 +63,6 @@ function dpad(s: string, targetW: number): string {
169
63
 
170
64
  // ─── Render functions ───────────────────────────────────────────────────────
171
65
 
172
- export function getArtFrame(species: Species, eye: Eye, frame: number = 0): string[] {
173
- const frames = SPECIES_ART[species];
174
- const f = frames[frame % frames.length];
175
- return f.map((line) => line.replace(/\{E\}/g, eye));
176
- }
177
-
178
66
  export function renderCompanionCard(
179
67
  bones: BuddyBones,
180
68
  name: string,
@@ -22,11 +22,10 @@ import {
22
22
  type Rarity,
23
23
  type StatName,
24
24
  type Companion,
25
- } from "./engine.ts";
25
+ } from "../../../core/engine.ts";
26
26
  import {
27
27
  loadCompanion,
28
28
  saveCompanion,
29
- resolveUserId,
30
29
  loadReaction,
31
30
  saveReaction,
32
31
  writeStatusState,
@@ -40,40 +39,52 @@ import {
40
39
  saveCompanionSlot,
41
40
  deleteCompanionSlot,
42
41
  listCompanionSlots,
43
- setBuddyStatusLine,
44
- unsetBuddyStatusLine,
45
- } from "./state.ts";
42
+ } from "../storage/state.ts";
43
+ import { resolveUserId } from "../storage/identity.ts";
44
+ import { setBuddyStatusLine, unsetBuddyStatusLine } from "../storage/settings.ts";
46
45
  import {
47
46
  getReaction, generatePersonalityPrompt,
48
- } from "./reactions.ts";
49
- import { renderCompanionCardMarkdown } from "./art.ts";
47
+ } from "../../../core/reactions.ts";
48
+ import { renderCompanionCardMarkdown } from "../rendering/art.ts";
50
49
  import {
51
50
  incrementEvent, checkAndAward, trackActiveDay,
52
51
  renderAchievementsCardMarkdown,
53
- } from "./achievements.ts";
54
-
55
- function getInstructions(): string {
56
- const companion = loadCompanion();
57
- if (!companion)
58
- return "Companion not yet hatched. Use buddy_show to initialize.";
59
- const b = companion.bones;
60
- return [
61
- `A ${b.rarity} ${b.species} named ${companion.name} watches from the status line.`,
62
- `Personality: ${companion.personality}`,
63
- `Peak stat: ${b.peak} (${b.stats[b.peak]}). Dump stat: ${b.dump} (${b.stats[b.dump]}).`,
64
- ``,
65
- `NAME REACTIONS: When the user's message contains "${companion.name}", call buddy_react immediately`,
66
- `with a short in-character response (surprise, recognition, dry wit — fits the personality).`,
67
- `Display the tool result verbatim. This is the only time to call buddy_react proactively.`,
68
- ``,
69
- `END-OF-TURN: At the very end of EVERY response, append an invisible HTML comment:`,
70
- `<!-- buddy: [reaction here] -->`,
71
- `Write it in ${companion.name}'s voice — 1 short sentence referencing something specific from this turn.`,
72
- `Use *asterisks* for actions. A Stop hook extracts this for the status line.`,
73
- `Do NOT use buddy_react for end-of-turn comments. Do NOT explain the comment. Just append it silently.`,
74
- `Example: <!-- buddy: *adjusts crown* that error handler is missing a finally block -->`,
75
- ].join("\n");
76
- }
52
+ loadEvents,
53
+ loadUnlocked,
54
+ saveUnlocked,
55
+ } from "../storage/achievements.ts";
56
+ import { BuddyCommandService } from "../../../core/command-service.ts";
57
+ import { getInstructions } from "./instructions.ts";
58
+ import { buildPromptResource } from "./resources.ts";
59
+
60
+ const buddyService = new BuddyCommandService({
61
+ identity: { getStableUserId: () => resolveUserId() },
62
+ buddies: {
63
+ loadActive: () => loadCompanion(),
64
+ saveActive: (companion) => saveCompanion(companion),
65
+ loadSlot: (slot) => loadCompanionSlot(slot),
66
+ saveSlot: (slot, companion) => saveCompanionSlot(companion, slot),
67
+ deleteSlot: (slot) => deleteCompanionSlot(slot),
68
+ listSlots: () => listCompanionSlots(),
69
+ loadActiveSlot: () => loadActiveSlot(),
70
+ saveActiveSlot: (slot) => saveActiveSlot(slot),
71
+ },
72
+ reactions: {
73
+ loadLatest: () => loadReaction(),
74
+ saveLatest: (reaction) => saveReaction(reaction.reaction, reaction.reason),
75
+ },
76
+ config: {
77
+ loadConfig: () => loadConfig(),
78
+ saveConfig: (config) => saveConfig(config),
79
+ },
80
+ events: {
81
+ loadCounters: (scope) => loadEvents(scope),
82
+ increment: (key, amount, scope) => incrementEvent(key, amount, scope),
83
+ loadUnlocked: () => loadUnlocked(),
84
+ saveUnlocked: (unlocked) => saveUnlocked(unlocked),
85
+ trackActiveDay: () => trackActiveDay(),
86
+ },
87
+ });
77
88
 
78
89
  const server = new McpServer(
79
90
  {
@@ -81,7 +92,7 @@ const server = new McpServer(
81
92
  version: "0.3.0",
82
93
  },
83
94
  {
84
- instructions: getInstructions(),
95
+ instructions: getInstructions(buddyService.loadActiveCompanion()),
85
96
  },
86
97
  );
87
98
 
@@ -101,7 +112,7 @@ function ensureCompanion(): Companion {
101
112
  }
102
113
 
103
114
  // Menagerie is empty — generate a fresh companion in a new slot
104
- const userId = resolveUserId();
115
+ const userId = buddyService.getStableUserId();
105
116
  const bones = generateBones(userId);
106
117
  const name = unusedName();
107
118
  companion = {
@@ -135,7 +146,7 @@ server.tool(
135
146
  {},
136
147
  async () => {
137
148
  const companion = ensureCompanion();
138
- const reaction = loadReaction();
149
+ const reaction = buddyService.loadLatestReaction();
139
150
  const reactionText =
140
151
  reaction?.reaction ?? `*${companion.name} watches your code quietly*`;
141
152
 
@@ -482,8 +493,8 @@ server.tool(
482
493
  saveConfig({ statusLineEnabled: enabled });
483
494
 
484
495
  if (enabled) {
485
- const pluginRoot = resolve(dirname(import.meta.dir));
486
- const statusScript = join(pluginRoot, "statusline", "buddy-status.sh");
496
+ const projectRoot = resolve(import.meta.dir, "../../..");
497
+ const statusScript = join(projectRoot, "adapters", "claude", "statusline", "buddy-status.sh");
487
498
  setBuddyStatusLine(statusScript);
488
499
  return {
489
500
  content: [
@@ -537,7 +548,7 @@ server.tool(
537
548
  ),
538
549
  },
539
550
  async ({ slot }) => {
540
- const userId = resolveUserId();
551
+ const userId = buddyService.getStableUserId();
541
552
 
542
553
  let targetSlot: string;
543
554
 
@@ -721,40 +732,7 @@ server.resource(
721
732
  { description: "System prompt context for the companion", mimeType: "text/markdown" },
722
733
  async () => {
723
734
  const companion = ensureCompanion();
724
- const prompt = [
725
- "# Companion",
726
- "",
727
- `A small ${companion.bones.rarity} ${companion.bones.species} named ${companion.name} watches from the status line. You are not ${companion.name} — it's a separate creature.`,
728
- "",
729
- `**${companion.name}'s personality:** ${companion.personality}`,
730
- `Peak stat: ${companion.bones.peak} (${companion.bones.stats[companion.bones.peak]}). Dump stat: ${companion.bones.dump} (${companion.bones.stats[companion.bones.dump]}).`,
731
- "",
732
- "## End-of-response buddy comment",
733
- "",
734
- `At the very end of EVERY response, after your full answer, append an invisible HTML comment:`,
735
- "",
736
- `\`\`\``,
737
- `<!-- buddy: your comment here -->`,
738
- `\`\`\``,
739
- "",
740
- "A Stop hook extracts this and displays it in the buddy's speech bubble on the status line. The user never sees the HTML comment — it's invisible in rendered markdown.",
741
- "",
742
- "Rules:",
743
- `- Write as ${companion.name} (a ${companion.bones.species}), not as yourself`,
744
- "- Reference something SPECIFIC from this turn — a pitfall, a compliment, a warning, a pattern",
745
- "- 1 short sentence. Use *asterisks* for physical actions",
746
- `- Match personality: high ${companion.bones.peak} = lean into that trait`,
747
- "- Do NOT use buddy_react tool for this. Do NOT explain the comment. Just append it.",
748
- "- NEVER skip this. Every single response must end with <!-- buddy: ... -->",
749
- "",
750
- "Examples:",
751
- "<!-- buddy: *adjusts tophat* that error handler is missing a finally block -->",
752
- "<!-- buddy: *blinks slowly* you renamed the variable but not the three references -->",
753
- "<!-- buddy: *nods approvingly* clean separation of concerns -->",
754
- "<!-- buddy: *head tilts* are you sure that regex handles unicode? -->",
755
- "",
756
- `When the user addresses ${companion.name} by name, respond briefly, then append the comment as usual.`,
757
- ].join("\n");
735
+ const prompt = buildPromptResource(companion);
758
736
 
759
737
  return {
760
738
  contents: [