@ramarivera/coding-buddy 0.4.0-alpha.1

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.
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Reaction templates — species-aware buddy responses to events
3
+ */
4
+
5
+ import type { Species, Rarity, StatName } from "./engine.ts";
6
+
7
+ type ReactionReason = "hatch" | "pet" | "error" | "test-fail" | "large-diff" | "turn" | "idle";
8
+
9
+ interface ReactionPool {
10
+ [key: string]: string[];
11
+ }
12
+
13
+ // General reactions by event type
14
+ const REACTIONS: Record<ReactionReason, string[]> = {
15
+ hatch: [
16
+ "*blinks* ...where am I?",
17
+ "*stretches* hello, world!",
18
+ "*looks around curiously* nice terminal you got here.",
19
+ "*yawns* ok I'm ready. show me the code.",
20
+ ],
21
+ pet: [
22
+ "*purrs contentedly*",
23
+ "*happy noises*",
24
+ "*nuzzles your cursor*",
25
+ "*wiggles*",
26
+ "again! again!",
27
+ "*closes eyes peacefully*",
28
+ ],
29
+ error: [
30
+ "*head tilts* ...that doesn't look right.",
31
+ "saw that one coming.",
32
+ "*adjusts glasses* line {line}, maybe?",
33
+ "*slow blink* the stack trace told you everything.",
34
+ "have you tried reading the error message?",
35
+ "*winces*",
36
+ ],
37
+ "test-fail": [
38
+ "*head rotates slowly* ...that test.",
39
+ "bold of you to assume that would pass.",
40
+ "*taps clipboard* {count} failed.",
41
+ "the tests are trying to tell you something.",
42
+ "*sips tea* interesting.",
43
+ "*marks calendar* test regression day.",
44
+ ],
45
+ "large-diff": [
46
+ "that's... a lot of changes.",
47
+ "*counts lines* are you refactoring or rewriting?",
48
+ "might want to split that PR.",
49
+ "*nervous laughter* {lines} lines changed.",
50
+ "bold move. let's see if CI agrees.",
51
+ ],
52
+ turn: [
53
+ "*watches quietly*",
54
+ "*takes notes*",
55
+ "*nods*",
56
+ "...",
57
+ "*adjusts hat*",
58
+ ],
59
+ idle: [
60
+ "*dozes off*",
61
+ "*doodles in margins*",
62
+ "*stares at cursor blinking*",
63
+ "zzz...",
64
+ ],
65
+ };
66
+
67
+ // Species-specific flavor
68
+ const SPECIES_REACTIONS: Partial<Record<Species, Partial<Record<ReactionReason, string[]>>>> = {
69
+ owl: {
70
+ error: [
71
+ "*head rotates 180\u00b0* ...I saw that.",
72
+ "*unblinking stare* check your types.",
73
+ "*hoots disapprovingly*",
74
+ ],
75
+ pet: ["*ruffles feathers contentedly*", "*dignified hoot*"],
76
+ },
77
+ cat: {
78
+ error: ["*knocks error off table*", "*licks paw, ignoring the stacktrace*"],
79
+ pet: ["*purrs* ...don't let it go to your head.", "*tolerates you*"],
80
+ idle: ["*pushes your coffee off the desk*", "*naps on keyboard*"],
81
+ },
82
+ duck: {
83
+ error: ["*quacks at the bug*", "have you tried rubber duck debugging? oh wait."],
84
+ pet: ["*happy quack*", "*waddles in circles*"],
85
+ },
86
+ dragon: {
87
+ error: ["*smoke curls from nostrils*", "*considers setting the codebase on fire*"],
88
+ "large-diff": ["*breathes fire on the old code* good riddance."],
89
+ },
90
+ ghost: {
91
+ error: ["*phases through the stack trace*", "I've seen worse... in the afterlife."],
92
+ idle: ["*floats through walls*", "*haunts your unused imports*"],
93
+ },
94
+ robot: {
95
+ error: ["SYNTAX. ERROR. DETECTED.", "*beeps aggressively*"],
96
+ "test-fail": ["FAILURE RATE: UNACCEPTABLE.", "*recalculating*"],
97
+ },
98
+ axolotl: {
99
+ error: ["*regenerates your hope*", "*smiles despite everything*"],
100
+ pet: ["*happy gill wiggle*", "*blushes pink*"],
101
+ },
102
+ capybara: {
103
+ error: ["*unbothered* it'll be fine.", "*continues vibing*"],
104
+ pet: ["*maximum chill achieved*", "*zen mode activated*"],
105
+ idle: ["*just sits there, radiating calm*"],
106
+ },
107
+ };
108
+
109
+ // Rarity affects reaction quality/length
110
+ const RARITY_BONUS: Partial<Record<Rarity, string[]>> = {
111
+ legendary: [
112
+ "*legendary aura intensifies*",
113
+ "*sparkles knowingly*",
114
+ ],
115
+ epic: [
116
+ "*epic presence noted*",
117
+ ],
118
+ };
119
+
120
+ export function getReaction(
121
+ reason: ReactionReason,
122
+ species: Species,
123
+ rarity: Rarity,
124
+ context?: { line?: number; count?: number; lines?: number },
125
+ ): string {
126
+ // Try species-specific first
127
+ const speciesPool = SPECIES_REACTIONS[species]?.[reason];
128
+ const generalPool = REACTIONS[reason];
129
+
130
+ // 40% chance of species-specific if available
131
+ const pool = speciesPool && Math.random() < 0.4 ? speciesPool : generalPool;
132
+ let reaction = pool[Math.floor(Math.random() * pool.length)];
133
+
134
+ // Template substitution
135
+ if (context?.line) reaction = reaction.replace("{line}", String(context.line));
136
+ if (context?.count) reaction = reaction.replace("{count}", String(context.count));
137
+ if (context?.lines) reaction = reaction.replace("{lines}", String(context.lines));
138
+
139
+ return reaction;
140
+ }
141
+
142
+ // ─── Personality generation (fallback names when API unavailable) ────────────
143
+
144
+ const FALLBACK_NAMES = [
145
+ "Crumpet", "Soup", "Pickle", "Biscuit", "Moth", "Gravy",
146
+ "Nugget", "Sprocket", "Miso", "Waffle", "Pixel", "Ember",
147
+ "Thimble", "Marble", "Sesame", "Cobalt", "Rusty", "Nimbus",
148
+ ];
149
+
150
+ const VIBE_WORDS = [
151
+ "thunder", "biscuit", "void", "accordion", "moss", "velvet", "rust",
152
+ "pickle", "crumb", "whisper", "gravy", "frost", "ember", "soup",
153
+ "marble", "thorn", "honey", "static", "copper", "dusk", "sprocket",
154
+ "quartz", "soot", "plum", "flint", "oyster", "loom", "anvil",
155
+ "cork", "bloom", "pebble", "vapor", "mirth", "glint", "cider",
156
+ ];
157
+
158
+ export function generateFallbackName(): string {
159
+ return FALLBACK_NAMES[Math.floor(Math.random() * FALLBACK_NAMES.length)];
160
+ }
161
+
162
+ export function generatePersonalityPrompt(
163
+ species: Species,
164
+ rarity: Rarity,
165
+ stats: Record<string, number>,
166
+ shiny: boolean,
167
+ ): string {
168
+ const vibes: string[] = [];
169
+ for (let i = 0; i < 4; i++) {
170
+ vibes.push(VIBE_WORDS[Math.floor(Math.random() * VIBE_WORDS.length)]);
171
+ }
172
+
173
+ const statStr = Object.entries(stats).map(([k, v]) => `${k}:${v}`).join(", ");
174
+
175
+ return [
176
+ "Generate a coding companion — a small creature that lives in a developer's terminal.",
177
+ "Don't repeat yourself — every companion should feel distinct.",
178
+ "",
179
+ `Rarity: ${rarity.toUpperCase()}`,
180
+ `Species: ${species}`,
181
+ `Stats: ${statStr}`,
182
+ `Inspiration words: ${vibes.join(", ")}`,
183
+ shiny ? "SHINY variant — extra special." : "",
184
+ "",
185
+ "Return JSON: {\"name\": \"1-14 chars\", \"personality\": \"2-3 sentences describing behavior\"}",
186
+ ].filter(Boolean).join("\n");
187
+ }
@@ -0,0 +1,409 @@
1
+ /**
2
+ * State management — reads/writes companion data to ~/.claude-buddy/
3
+ *
4
+ * Storage layout (v3 — single manifest):
5
+ * ~/.claude-buddy/
6
+ * menagerie.json <- SSOT: { active, companions: { [slot]: Companion } }
7
+ * reaction.$SID.json <- transient reaction state (session-scoped)
8
+ * status.json <- compact state for the status-line shell script
9
+ * config.json <- user preferences (cooldown, bubble style, etc.)
10
+ *
11
+ * Rules:
12
+ * - saveCompanionSlot() APPENDS only — throws if the slot already exists
13
+ * - saveCompanion() UPDATES the currently-active slot (rename / personality)
14
+ * - All manifest writes are atomic (write tmp -> rename)
15
+ *
16
+ * Combined: PR #4 menagerie + PR #6 session isolation + config
17
+ */
18
+
19
+ import {
20
+ readFileSync,
21
+ writeFileSync,
22
+ mkdirSync,
23
+ existsSync,
24
+ readdirSync,
25
+ renameSync,
26
+ } from "fs";
27
+ import { join } from "path";
28
+ import { homedir } from "os";
29
+ import type { Companion } from "./engine.ts";
30
+
31
+ const STATE_DIR = join(homedir(), ".claude-buddy");
32
+ const MANIFEST_FILE = join(STATE_DIR, "menagerie.json");
33
+ const CONFIG_FILE = join(STATE_DIR, "config.json");
34
+
35
+ // ─── Session ID (PR #6: tmux session isolation) ─────────────────────────────
36
+
37
+ function sessionId(): string {
38
+ const pane = process.env.TMUX_PANE;
39
+ if (!pane) return "default";
40
+ return pane.replace(/^%/, "");
41
+ }
42
+
43
+ function reactionFile(): string {
44
+ return join(STATE_DIR, `reaction.${sessionId()}.json`);
45
+ }
46
+
47
+ // ─── Manifest schema ─────────────────────────────────────────────────────────
48
+
49
+ interface Manifest {
50
+ active: string;
51
+ companions: Record<string, Companion>;
52
+ }
53
+
54
+ function emptyManifest(): Manifest {
55
+ return { active: "buddy", companions: {} };
56
+ }
57
+
58
+ // ─── Atomic manifest I/O ─────────────────────────────────────────────────────
59
+
60
+ function loadManifest(): Manifest {
61
+ try {
62
+ const raw = readFileSync(MANIFEST_FILE, "utf8");
63
+ const m = JSON.parse(raw) as Manifest;
64
+ if (!m.companions) m.companions = {};
65
+ return m;
66
+ } catch {
67
+ return emptyManifest();
68
+ }
69
+ }
70
+
71
+ function saveManifest(m: Manifest): void {
72
+ mkdirSync(STATE_DIR, { recursive: true });
73
+ const tmp = MANIFEST_FILE + ".tmp";
74
+ writeFileSync(tmp, JSON.stringify(m, null, 2));
75
+ renameSync(tmp, MANIFEST_FILE); // atomic on same filesystem
76
+ }
77
+
78
+ // ─── Slot helpers ────────────────────────────────────────────────────────────
79
+
80
+ /** Normalise a string to a safe slot key (a-z0-9-, max 14 chars). */
81
+ export function slugify(name: string): string {
82
+ return (
83
+ name
84
+ .toLowerCase()
85
+ .replace(/[^a-z0-9-]/g, "-")
86
+ .replace(/-+/g, "-")
87
+ .replace(/^-|-$/g, "")
88
+ .slice(0, 14) || "buddy"
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Return a random fallback name whose slug is not already in the manifest.
94
+ * Falls back to "buddy-<random 3 digits>" if all names are taken.
95
+ */
96
+ export function unusedName(): string {
97
+ const { generateFallbackName } =
98
+ require("./reactions.ts") as typeof import("./reactions.ts");
99
+ const taken = new Set(Object.keys(loadManifest().companions));
100
+ for (let i = 0; i < 50; i++) {
101
+ const n = generateFallbackName();
102
+ if (!taken.has(slugify(n))) return n;
103
+ }
104
+ let suffix = 0;
105
+ while (taken.has(`buddy-${suffix}`)) suffix++;
106
+ return `buddy-${suffix}`;
107
+ }
108
+
109
+ // ─── Active slot ─────────────────────────────────────────────────────────────
110
+
111
+ export function loadActiveSlot(): string {
112
+ const m = loadManifest();
113
+ if (m.active && m.companions[m.active]) return m.active;
114
+ const first = Object.keys(m.companions)[0];
115
+ if (first) {
116
+ m.active = first;
117
+ saveManifest(m);
118
+ return first;
119
+ }
120
+ return "buddy";
121
+ }
122
+
123
+ export function saveActiveSlot(slot: string): void {
124
+ const m = loadManifest();
125
+ m.active = slot;
126
+ saveManifest(m);
127
+ }
128
+
129
+ // ─── Companion slot API ───────────────────────────────────────────────────────
130
+
131
+ export function loadCompanionSlot(slot: string): Companion | null {
132
+ return loadManifest().companions[slot] ?? null;
133
+ }
134
+
135
+ /**
136
+ * APPEND a new companion to the manifest.
137
+ * Throws if the slot already exists — use saveCompanion() to update an existing buddy.
138
+ */
139
+ export function saveCompanionSlot(companion: Companion, slot: string): void {
140
+ const m = loadManifest();
141
+ if (m.companions[slot]) {
142
+ throw new Error(`Slot "${slot}" already exists. Choose a different name.`);
143
+ }
144
+ m.companions[slot] = companion;
145
+ saveManifest(m);
146
+ }
147
+
148
+ export function deleteCompanionSlot(slot: string): void {
149
+ const m = loadManifest();
150
+ delete m.companions[slot];
151
+ if (m.active === slot) {
152
+ m.active = Object.keys(m.companions)[0] ?? "buddy";
153
+ }
154
+ saveManifest(m);
155
+ }
156
+
157
+ export function listCompanionSlots(): Array<{
158
+ slot: string;
159
+ companion: Companion;
160
+ }> {
161
+ return Object.entries(loadManifest().companions).map(([slot, companion]) => ({
162
+ slot,
163
+ companion,
164
+ }));
165
+ }
166
+
167
+ // ─── Primary companion API ────────────────────────────────────────────────────
168
+
169
+ export function loadCompanion(): Companion | null {
170
+ migrateIfNeeded();
171
+ const m = loadManifest();
172
+ return m.companions[m.active] ?? null;
173
+ }
174
+
175
+ /**
176
+ * UPDATE the currently-active companion (rename, personality changes, etc.).
177
+ * This is the ONLY intentional in-place update path.
178
+ */
179
+ export function saveCompanion(companion: Companion): void {
180
+ const m = loadManifest();
181
+ m.companions[m.active] = companion;
182
+ saveManifest(m);
183
+ }
184
+
185
+ // ─── Migration: legacy companion.json -> single manifest ────────────────────
186
+
187
+ function migrateIfNeeded(): void {
188
+ if (existsSync(MANIFEST_FILE)) return;
189
+
190
+ const companions: Record<string, Companion> = {};
191
+ let active = "buddy";
192
+
193
+ // Absorb menagerie/<slot>.json files
194
+ const menagerieDir = join(STATE_DIR, "menagerie");
195
+ if (existsSync(menagerieDir)) {
196
+ try {
197
+ for (const f of readdirSync(menagerieDir).filter((f) =>
198
+ f.endsWith(".json"),
199
+ )) {
200
+ const slot = f.slice(0, -5);
201
+ try {
202
+ companions[slot] = JSON.parse(
203
+ readFileSync(join(menagerieDir, f), "utf8"),
204
+ );
205
+ } catch {
206
+ /* skip malformed */
207
+ }
208
+ }
209
+ } catch {
210
+ /* noop */
211
+ }
212
+ }
213
+
214
+ // Absorb legacy companion.json
215
+ const legacyFile = join(STATE_DIR, "companion.json");
216
+ if (existsSync(legacyFile) && Object.keys(companions).length === 0) {
217
+ try {
218
+ const c: Companion = JSON.parse(readFileSync(legacyFile, "utf8"));
219
+ const slot = slugify(c.name);
220
+ companions[slot] = c;
221
+ active = slot;
222
+ } catch {
223
+ /* noop */
224
+ }
225
+ }
226
+
227
+ // Read active pointer if it exists
228
+ const activeFile = join(STATE_DIR, "active");
229
+ if (existsSync(activeFile)) {
230
+ try {
231
+ const a = readFileSync(activeFile, "utf8").trim();
232
+ if (a && companions[a]) active = a;
233
+ } catch {
234
+ /* noop */
235
+ }
236
+ }
237
+
238
+ if (Object.keys(companions).length > 0) {
239
+ active = active && companions[active] ? active : Object.keys(companions)[0];
240
+ }
241
+
242
+ saveManifest({ active, companions });
243
+ }
244
+
245
+ // ─── Reaction state (session-scoped for tmux isolation) ──────────────────────
246
+
247
+ export interface ReactionState {
248
+ reaction: string;
249
+ timestamp: number;
250
+ reason: string;
251
+ }
252
+
253
+ export function loadReaction(): ReactionState | null {
254
+ try {
255
+ const data: ReactionState = JSON.parse(readFileSync(reactionFile(), "utf8"));
256
+ const { reactionTTL } = loadConfig();
257
+ if (reactionTTL > 0 && Date.now() - data.timestamp > reactionTTL * 1000) return null;
258
+ return data;
259
+ } catch {
260
+ return null;
261
+ }
262
+ }
263
+
264
+ export function saveReaction(reaction: string, reason: string): void {
265
+ mkdirSync(STATE_DIR, { recursive: true });
266
+ const state: ReactionState = { reaction, timestamp: Date.now(), reason };
267
+ writeFileSync(reactionFile(), JSON.stringify(state));
268
+ }
269
+
270
+ // ─── Identity resolution ─────────────────────────────────────────────────────
271
+
272
+ export function resolveUserId(): string {
273
+ try {
274
+ const claudeJson = JSON.parse(
275
+ readFileSync(join(homedir(), ".claude.json"), "utf8"),
276
+ );
277
+ return claudeJson.oauthAccount?.accountUuid ?? claudeJson.userID ?? "anon";
278
+ } catch {
279
+ return "anon";
280
+ }
281
+ }
282
+
283
+ // ─── Config persistence (PR #6: tmux popup settings) ─────────────────────────
284
+
285
+ export interface BuddyConfig {
286
+ commentCooldown: number;
287
+ reactionTTL: number;
288
+ bubbleStyle: "classic" | "round";
289
+ bubblePosition: "top" | "left";
290
+ showRarity: boolean;
291
+ statusLineEnabled: boolean;
292
+ }
293
+
294
+ const DEFAULT_CONFIG: BuddyConfig = {
295
+ commentCooldown: 30,
296
+ reactionTTL: 0,
297
+ bubbleStyle: "classic",
298
+ bubblePosition: "top",
299
+ showRarity: true,
300
+ statusLineEnabled: false,
301
+ };
302
+
303
+ export function loadConfig(): BuddyConfig {
304
+ try {
305
+ const data = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
306
+ return { ...DEFAULT_CONFIG, ...data };
307
+ } catch {
308
+ return { ...DEFAULT_CONFIG };
309
+ }
310
+ }
311
+
312
+ export function saveConfig(config: Partial<BuddyConfig>): BuddyConfig {
313
+ mkdirSync(STATE_DIR, { recursive: true });
314
+ const current = loadConfig();
315
+ const merged = { ...current, ...config };
316
+ writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
317
+ return merged;
318
+ }
319
+
320
+ // ─── Status line state (compact JSON for the shell script) ───────────────────
321
+
322
+ export interface StatusState {
323
+ name: string;
324
+ species: string;
325
+ rarity: string;
326
+ stars: string;
327
+ face: string;
328
+ eye: string;
329
+ shiny: boolean;
330
+ hat: string;
331
+ reaction: string;
332
+ muted: boolean;
333
+ achievement: string;
334
+ }
335
+
336
+ export function writeStatusState(
337
+ companion: Companion,
338
+ reaction?: string,
339
+ muted?: boolean,
340
+ achievement?: string,
341
+ ): void {
342
+ mkdirSync(STATE_DIR, { recursive: true });
343
+ const { renderFace, RARITY_STARS } =
344
+ require("./engine.ts") as typeof import("./engine.ts");
345
+ const state: StatusState = {
346
+ name: companion.name,
347
+ species: companion.bones.species,
348
+ rarity: companion.bones.rarity,
349
+ stars: RARITY_STARS[companion.bones.rarity],
350
+ face: renderFace(companion.bones.species, companion.bones.eye),
351
+ eye: companion.bones.eye,
352
+ shiny: companion.bones.shiny,
353
+ hat: companion.bones.hat,
354
+ reaction: reaction ?? "",
355
+ muted: muted ?? false,
356
+ achievement: achievement ?? "",
357
+ };
358
+ writeFileSync(join(STATE_DIR, "status.json"), JSON.stringify(state));
359
+ }
360
+
361
+ // ─── Claude Code settings.json patching (for buddy_statusline tool) ──────────
362
+
363
+ export const CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
364
+
365
+ /**
366
+ * Write settings.statusLine pointing to the given buddy-status script.
367
+ * Atomic via tmp + rename. Returns false if settings.json is unreachable.
368
+ */
369
+ export function setBuddyStatusLine(
370
+ statusScript: string,
371
+ settingsPath: string = CLAUDE_SETTINGS_PATH,
372
+ ): boolean {
373
+ try {
374
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
375
+ settings.statusLine = {
376
+ type: "command",
377
+ command: statusScript,
378
+ padding: 1,
379
+ refreshInterval: 1,
380
+ };
381
+ const tmp = settingsPath + ".tmp";
382
+ writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
383
+ renameSync(tmp, settingsPath);
384
+ return true;
385
+ } catch {
386
+ return false;
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Remove settings.statusLine — but only if it points to buddy-status.sh.
392
+ * Leaves foreign statusLines untouched. Returns false if no buddy line was
393
+ * present or settings.json is unreachable.
394
+ */
395
+ export function unsetBuddyStatusLine(
396
+ settingsPath: string = CLAUDE_SETTINGS_PATH,
397
+ ): boolean {
398
+ try {
399
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
400
+ if (!settings.statusLine?.command?.includes("buddy-status.sh")) return false;
401
+ delete settings.statusLine;
402
+ const tmp = settingsPath + ".tmp";
403
+ writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
404
+ renameSync(tmp, settingsPath);
405
+ return true;
406
+ } catch {
407
+ return false;
408
+ }
409
+ }
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: buddy
3
+ description: "Show, pet, or manage your coding companion. Use when the user types /buddy or mentions their companion by name."
4
+ argument-hint: "[show|pet|stats|help|off|on|rename <name>|personality <text>|achievements|summon [slot]|save [slot]|list|dismiss <slot>|pick|frequency [seconds]|style [classic|round]|position [top|left]|rarity [on|off]|statusline [on|off]]"
5
+ allowed-tools: mcp__claude_buddy__*
6
+ ---
7
+
8
+ # Buddy — Your Coding Companion
9
+
10
+ Handle the user's `/buddy` command using the claude-buddy MCP tools.
11
+
12
+ ## Command Routing
13
+
14
+ Based on `$ARGUMENTS`:
15
+
16
+ | Input | Action |
17
+ | ------------------------ | -------------------------------------------------------------------------------------------- |
18
+ | _(empty)_ or `show` | Call `buddy_show` |
19
+ | `help` | Call `buddy_help` |
20
+ | `pet` | Call `buddy_pet` |
21
+ | `stats` | Call `buddy_stats` |
22
+ | `off` | Call `buddy_mute` |
23
+ | `on` | Call `buddy_unmute` |
24
+ | `rename <name>` | Call `buddy_rename` with the given name |
25
+ | `personality <text>` | Call `buddy_set_personality` with the given text |
26
+ | `achievements` | Call `buddy_achievements` |
27
+ | `summon` | Call `buddy_summon` with no args — picks a random saved buddy |
28
+ | `summon <slot>` | Call `buddy_summon` with the given slot name |
29
+ | `save [slot]` | Call `buddy_save` with optional slot name |
30
+ | `list` | Call `buddy_list` |
31
+ | `dismiss <slot>` | Call `buddy_dismiss` with the slot name |
32
+ | `pick` | Tell user to run `! bun run pick` from the claude-buddy directory (launches interactive TUI) |
33
+ | `frequency` | Call `buddy_frequency` with no args (show current) |
34
+ | `frequency <seconds>` | Call `buddy_frequency` with cooldown=seconds |
35
+ | `style` | Call `buddy_style` with no args (show current) |
36
+ | `style <classic\|round>` | Call `buddy_style` with style arg |
37
+ | `position` | Call `buddy_style` with no args (show current) |
38
+ | `position <top\|left>` | Call `buddy_style` with position arg |
39
+ | `rarity on` | Call `buddy_style` with showRarity=true |
40
+ | `rarity off` | Call `buddy_style` with showRarity=false |
41
+ | `statusline` | Call `buddy_statusline` with no args (show current) |
42
+ | `statusline on` | Call `buddy_statusline` with enabled=true |
43
+ | `statusline off` | Call `buddy_statusline` with enabled=false |
44
+
45
+ ## CRITICAL OUTPUT RULES
46
+
47
+ The MCP tools return pre-formatted ASCII art with ANSI colors, box-drawing characters, stat bars, and species art. This is the companion's visual identity.
48
+
49
+ **You MUST output the tool result text EXACTLY as returned — character for character, line for line.** Do NOT:
50
+
51
+ - Summarize or paraphrase the ASCII art
52
+ - Describe what the companion looks like in prose
53
+ - Add commentary before or after the card
54
+ - Reformat, rephrase, or interpret the output
55
+ - Strip ANSI escape codes
56
+
57
+ **Just output the raw text content from the tool result. Nothing else.** The ASCII art IS the response.
58
+
59
+ If the user mentions the buddy's name in normal conversation, call `buddy_react` with reason "turn" and display the result verbatim.