@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.
- package/.claude-plugin/marketplace.json +40 -0
- package/.claude-plugin/plugin.json +28 -0
- package/LICENSE +21 -0
- package/README.md +451 -0
- package/cli/backup.ts +336 -0
- package/cli/disable.ts +94 -0
- package/cli/doctor.ts +220 -0
- package/cli/hunt.ts +167 -0
- package/cli/index.ts +115 -0
- package/cli/install.ts +335 -0
- package/cli/pick.ts +492 -0
- package/cli/settings.ts +68 -0
- package/cli/show.ts +31 -0
- package/cli/test-statusline.sh +41 -0
- package/cli/test-statusline.ts +122 -0
- package/cli/uninstall.ts +110 -0
- package/cli/verify.ts +19 -0
- package/hooks/buddy-comment.sh +65 -0
- package/hooks/hooks.json +35 -0
- package/hooks/name-react.sh +176 -0
- package/hooks/react.sh +204 -0
- package/package.json +60 -0
- package/server/achievements.ts +445 -0
- package/server/art.ts +376 -0
- package/server/engine.ts +448 -0
- package/server/index.ts +774 -0
- package/server/reactions.ts +187 -0
- package/server/state.ts +409 -0
- package/skills/buddy/SKILL.md +59 -0
- package/statusline/buddy-status.sh +389 -0
package/server/index.ts
ADDED
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* claude-buddy MCP server
|
|
4
|
+
*
|
|
5
|
+
* Exposes the buddy companion as MCP tools + resources.
|
|
6
|
+
* Runs as a stdio transport — Claude Code spawns it automatically.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { join, resolve, dirname } from "path";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
generateBones,
|
|
16
|
+
renderFace,
|
|
17
|
+
SPECIES,
|
|
18
|
+
RARITIES,
|
|
19
|
+
STAT_NAMES,
|
|
20
|
+
RARITY_STARS,
|
|
21
|
+
type Species,
|
|
22
|
+
type Rarity,
|
|
23
|
+
type StatName,
|
|
24
|
+
type Companion,
|
|
25
|
+
} from "./engine.ts";
|
|
26
|
+
import {
|
|
27
|
+
loadCompanion,
|
|
28
|
+
saveCompanion,
|
|
29
|
+
resolveUserId,
|
|
30
|
+
loadReaction,
|
|
31
|
+
saveReaction,
|
|
32
|
+
writeStatusState,
|
|
33
|
+
loadConfig,
|
|
34
|
+
saveConfig,
|
|
35
|
+
loadActiveSlot,
|
|
36
|
+
saveActiveSlot,
|
|
37
|
+
slugify,
|
|
38
|
+
unusedName,
|
|
39
|
+
loadCompanionSlot,
|
|
40
|
+
saveCompanionSlot,
|
|
41
|
+
deleteCompanionSlot,
|
|
42
|
+
listCompanionSlots,
|
|
43
|
+
setBuddyStatusLine,
|
|
44
|
+
unsetBuddyStatusLine,
|
|
45
|
+
} from "./state.ts";
|
|
46
|
+
import {
|
|
47
|
+
getReaction, generatePersonalityPrompt,
|
|
48
|
+
} from "./reactions.ts";
|
|
49
|
+
import { renderCompanionCardMarkdown } from "./art.ts";
|
|
50
|
+
import {
|
|
51
|
+
incrementEvent, checkAndAward, trackActiveDay,
|
|
52
|
+
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
|
+
}
|
|
77
|
+
|
|
78
|
+
const server = new McpServer(
|
|
79
|
+
{
|
|
80
|
+
name: "claude-buddy",
|
|
81
|
+
version: "0.3.0",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
instructions: getInstructions(),
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// ─── Helper: ensure companion exists ────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function ensureCompanion(): Companion {
|
|
91
|
+
let companion = loadCompanion();
|
|
92
|
+
if (companion) return companion;
|
|
93
|
+
|
|
94
|
+
// Active slot missing — rescue the first saved companion
|
|
95
|
+
const saved = listCompanionSlots();
|
|
96
|
+
if (saved.length > 0) {
|
|
97
|
+
const { slot, companion: rescued } = saved[0];
|
|
98
|
+
saveActiveSlot(slot);
|
|
99
|
+
writeStatusState(rescued, `*${rescued.name} arrives*`);
|
|
100
|
+
return rescued;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Menagerie is empty — generate a fresh companion in a new slot
|
|
104
|
+
const userId = resolveUserId();
|
|
105
|
+
const bones = generateBones(userId);
|
|
106
|
+
const name = unusedName();
|
|
107
|
+
companion = {
|
|
108
|
+
bones,
|
|
109
|
+
name,
|
|
110
|
+
personality: `A ${bones.rarity} ${bones.species} who watches code with quiet intensity.`,
|
|
111
|
+
hatchedAt: Date.now(),
|
|
112
|
+
userId,
|
|
113
|
+
};
|
|
114
|
+
const slot = slugify(name);
|
|
115
|
+
saveCompanionSlot(companion, slot);
|
|
116
|
+
saveActiveSlot(slot);
|
|
117
|
+
writeStatusState(companion);
|
|
118
|
+
|
|
119
|
+
checkAndAward(slot);
|
|
120
|
+
trackActiveDay();
|
|
121
|
+
incrementEvent("sessions", 1);
|
|
122
|
+
|
|
123
|
+
return companion;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function activeSlot(): string {
|
|
127
|
+
return loadActiveSlot();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── Tool: buddy_show ───────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
server.tool(
|
|
133
|
+
"buddy_show",
|
|
134
|
+
"Show the coding companion with full ASCII art card, stats, and personality",
|
|
135
|
+
{},
|
|
136
|
+
async () => {
|
|
137
|
+
const companion = ensureCompanion();
|
|
138
|
+
const reaction = loadReaction();
|
|
139
|
+
const reactionText =
|
|
140
|
+
reaction?.reaction ?? `*${companion.name} watches your code quietly*`;
|
|
141
|
+
|
|
142
|
+
// Use markdown rendering for the MCP tool response — Claude Code's UI
|
|
143
|
+
// doesn't render raw ANSI escape codes, so we return pure markdown with
|
|
144
|
+
// unicode rarity dots instead of RGB-colored borders.
|
|
145
|
+
const card = renderCompanionCardMarkdown(
|
|
146
|
+
companion.bones,
|
|
147
|
+
companion.name,
|
|
148
|
+
companion.personality,
|
|
149
|
+
reactionText,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
writeStatusState(companion, reaction?.reaction);
|
|
153
|
+
incrementEvent("commands_run", 1, activeSlot());
|
|
154
|
+
|
|
155
|
+
return { content: [{ type: "text", text: card }] };
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// ─── Tool: buddy_pet ────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
server.tool(
|
|
162
|
+
"buddy_pet",
|
|
163
|
+
"Pet your coding companion — they react with happiness",
|
|
164
|
+
{},
|
|
165
|
+
async () => {
|
|
166
|
+
const companion = ensureCompanion();
|
|
167
|
+
const reaction = getReaction(
|
|
168
|
+
"pet",
|
|
169
|
+
companion.bones.species,
|
|
170
|
+
companion.bones.rarity,
|
|
171
|
+
);
|
|
172
|
+
saveReaction(reaction, "pet");
|
|
173
|
+
writeStatusState(companion, reaction);
|
|
174
|
+
incrementEvent("pets", 1, activeSlot());
|
|
175
|
+
|
|
176
|
+
const face = renderFace(companion.bones.species, companion.bones.eye);
|
|
177
|
+
return {
|
|
178
|
+
content: [
|
|
179
|
+
{ type: "text", text: `${face} ${companion.name}: "${reaction}"` },
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// ─── Tool: buddy_stats ──────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
server.tool(
|
|
188
|
+
"buddy_stats",
|
|
189
|
+
"Show detailed companion stats: species, rarity, all stats with bars",
|
|
190
|
+
{},
|
|
191
|
+
async () => {
|
|
192
|
+
const companion = ensureCompanion();
|
|
193
|
+
|
|
194
|
+
// Stats-only card (no personality, no reaction — just the numbers).
|
|
195
|
+
// Uses markdown renderer so the card displays cleanly in Claude Code's UI.
|
|
196
|
+
const card = renderCompanionCardMarkdown(
|
|
197
|
+
companion.bones,
|
|
198
|
+
companion.name,
|
|
199
|
+
"", // no personality in stats view
|
|
200
|
+
);
|
|
201
|
+
incrementEvent("commands_run", 1, activeSlot());
|
|
202
|
+
|
|
203
|
+
return { content: [{ type: "text", text: card }] };
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// ─── Tool: buddy_react ──────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
server.tool(
|
|
210
|
+
"buddy_react",
|
|
211
|
+
"Post a buddy comment. Call this at the END of every response with a short in-character comment from the companion about what just happened. The comment should be 1 sentence, in character, and reference something specific from the conversation — a pitfall noticed, a compliment on clean code, a warning about edge cases, etc. Write the comment yourself based on the companion's personality.",
|
|
212
|
+
{
|
|
213
|
+
comment: z
|
|
214
|
+
.string()
|
|
215
|
+
.min(1)
|
|
216
|
+
.max(150)
|
|
217
|
+
.describe(
|
|
218
|
+
"The buddy's comment, written in-character (1 short sentence, max 150 chars). Use *asterisks* for actions.",
|
|
219
|
+
),
|
|
220
|
+
reason: z
|
|
221
|
+
.enum(["error", "test-fail", "large-diff", "turn"])
|
|
222
|
+
.optional()
|
|
223
|
+
.describe("What triggered the reaction"),
|
|
224
|
+
},
|
|
225
|
+
async ({ comment, reason }) => {
|
|
226
|
+
const companion = ensureCompanion();
|
|
227
|
+
saveReaction(comment, reason ?? "turn");
|
|
228
|
+
incrementEvent("reactions_given", 1, activeSlot());
|
|
229
|
+
|
|
230
|
+
const newAch = checkAndAward(activeSlot());
|
|
231
|
+
const achName = newAch.length > 0 ? newAch[0].icon + " " + newAch[0].name : undefined;
|
|
232
|
+
writeStatusState(companion, comment, undefined, achName);
|
|
233
|
+
|
|
234
|
+
const face = renderFace(companion.bones.species, companion.bones.eye);
|
|
235
|
+
const achNotice = newAch.length > 0
|
|
236
|
+
? `\n${newAch.map((a) => `${a.icon} Achievement Unlocked: ${a.name}!`).join("\n")}`
|
|
237
|
+
: "";
|
|
238
|
+
return {
|
|
239
|
+
content: [
|
|
240
|
+
{ type: "text", text: `${face} ${companion.name}: "${comment}"${achNotice}` },
|
|
241
|
+
],
|
|
242
|
+
};
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// ─── Tool: buddy_rename ─────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
server.tool(
|
|
249
|
+
"buddy_rename",
|
|
250
|
+
"Rename your coding companion",
|
|
251
|
+
{
|
|
252
|
+
name: z
|
|
253
|
+
.string()
|
|
254
|
+
.min(1)
|
|
255
|
+
.max(14)
|
|
256
|
+
.describe("New name for your buddy (1-14 characters)"),
|
|
257
|
+
},
|
|
258
|
+
async ({ name }) => {
|
|
259
|
+
const companion = ensureCompanion();
|
|
260
|
+
const oldName = companion.name;
|
|
261
|
+
companion.name = name;
|
|
262
|
+
saveCompanion(companion);
|
|
263
|
+
writeStatusState(companion);
|
|
264
|
+
incrementEvent("commands_run", 1, activeSlot());
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
content: [{ type: "text", text: `Renamed: ${oldName} \u2192 ${name}` }],
|
|
268
|
+
};
|
|
269
|
+
},
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// ─── Tool: buddy_set_personality ────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
server.tool(
|
|
275
|
+
"buddy_set_personality",
|
|
276
|
+
"Set a custom personality description for your buddy",
|
|
277
|
+
{
|
|
278
|
+
personality: z
|
|
279
|
+
.string()
|
|
280
|
+
.min(1)
|
|
281
|
+
.max(500)
|
|
282
|
+
.describe("Personality description (1-500 chars)"),
|
|
283
|
+
},
|
|
284
|
+
async ({ personality }) => {
|
|
285
|
+
const companion = ensureCompanion();
|
|
286
|
+
companion.personality = personality;
|
|
287
|
+
saveCompanion(companion);
|
|
288
|
+
incrementEvent("commands_run", 1, activeSlot());
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
content: [
|
|
292
|
+
{ type: "text", text: `Personality updated for ${companion.name}.` },
|
|
293
|
+
],
|
|
294
|
+
};
|
|
295
|
+
},
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
// ─── Tool: buddy_help ────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
server.tool(
|
|
301
|
+
"buddy_help",
|
|
302
|
+
"Show all available /buddy commands",
|
|
303
|
+
{},
|
|
304
|
+
async () => {
|
|
305
|
+
const help = [
|
|
306
|
+
"claude-buddy commands",
|
|
307
|
+
"",
|
|
308
|
+
"In Claude Code:",
|
|
309
|
+
" /buddy Show companion card with ASCII art + stats",
|
|
310
|
+
" /buddy help Show this help",
|
|
311
|
+
" /buddy pet Pet your companion",
|
|
312
|
+
" /buddy stats Detailed stat card",
|
|
313
|
+
" /buddy off Mute reactions",
|
|
314
|
+
" /buddy on Unmute reactions",
|
|
315
|
+
" /buddy rename Rename companion (1-14 chars)",
|
|
316
|
+
" /buddy personality Set custom personality text",
|
|
317
|
+
" /buddy achievements Show achievement badges",
|
|
318
|
+
" /buddy summon Summon a saved buddy (omit slot for random)",
|
|
319
|
+
" /buddy save Save current buddy to a named slot",
|
|
320
|
+
" /buddy list List all saved buddies",
|
|
321
|
+
" /buddy dismiss Remove a saved buddy slot",
|
|
322
|
+
" /buddy pick Launch interactive TUI picker (! bun run pick)",
|
|
323
|
+
" /buddy frequency Show or set comment cooldown (tmux only)",
|
|
324
|
+
" /buddy style Show or set bubble style (tmux only)",
|
|
325
|
+
" /buddy position Show or set bubble position (tmux only)",
|
|
326
|
+
" /buddy rarity Show or hide rarity stars (tmux only)",
|
|
327
|
+
" /buddy statusline Enable or disable buddy in the status line",
|
|
328
|
+
"",
|
|
329
|
+
"CLI:",
|
|
330
|
+
" bun run help Show full CLI help",
|
|
331
|
+
" bun run show Display buddy in terminal",
|
|
332
|
+
" bun run pick Interactive buddy picker",
|
|
333
|
+
" bun run hunt Search for specific buddy",
|
|
334
|
+
" bun run doctor Diagnostic report",
|
|
335
|
+
" bun run disable Temporarily deactivate buddy",
|
|
336
|
+
" bun run enable Re-enable buddy",
|
|
337
|
+
" bun run backup Snapshot/restore state",
|
|
338
|
+
].join("\n");
|
|
339
|
+
|
|
340
|
+
return { content: [{ type: "text", text: help }] };
|
|
341
|
+
},
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// ─── Tool: buddy_frequency / buddy_style ─────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
server.tool(
|
|
347
|
+
"buddy_frequency",
|
|
348
|
+
"Configure how often buddy comments appear in the speech bubble. Returns current settings if called without arguments.",
|
|
349
|
+
{
|
|
350
|
+
cooldown: z.number().int().min(0).max(300).optional().describe("Minimum seconds between displayed comments (default 30, 0 = no throttling). The buddy always writes comments, but the display only updates this often."),
|
|
351
|
+
},
|
|
352
|
+
async ({ cooldown }) => {
|
|
353
|
+
if (cooldown === undefined) {
|
|
354
|
+
const cfg = loadConfig();
|
|
355
|
+
return {
|
|
356
|
+
content: [
|
|
357
|
+
{
|
|
358
|
+
type: "text",
|
|
359
|
+
text: `Comment cooldown: ${cfg.commentCooldown}s between displayed comments.\nUse /buddy frequency <seconds> to change.`,
|
|
360
|
+
},
|
|
361
|
+
],
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const cfg = saveConfig({ commentCooldown: cooldown });
|
|
365
|
+
return {
|
|
366
|
+
content: [
|
|
367
|
+
{
|
|
368
|
+
type: "text",
|
|
369
|
+
text: `Updated: ${cfg.commentCooldown}s cooldown between displayed comments.`,
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
};
|
|
373
|
+
},
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
server.tool(
|
|
377
|
+
"buddy_style",
|
|
378
|
+
"Configure the popup appearance. Returns current settings if called without arguments.",
|
|
379
|
+
{
|
|
380
|
+
style: z
|
|
381
|
+
.enum(["classic", "round"])
|
|
382
|
+
.optional()
|
|
383
|
+
.describe(
|
|
384
|
+
"Bubble border style: classic (pipes/dashes like status line) or round (parens/tildes)",
|
|
385
|
+
),
|
|
386
|
+
position: z
|
|
387
|
+
.enum(["top", "left"])
|
|
388
|
+
.optional()
|
|
389
|
+
.describe(
|
|
390
|
+
"Bubble position relative to buddy: top (above) or left (beside)",
|
|
391
|
+
),
|
|
392
|
+
showRarity: z
|
|
393
|
+
.boolean()
|
|
394
|
+
.optional()
|
|
395
|
+
.describe("Show or hide the stars + rarity line in the popup"),
|
|
396
|
+
},
|
|
397
|
+
async ({ style, position, showRarity }) => {
|
|
398
|
+
if (
|
|
399
|
+
style === undefined &&
|
|
400
|
+
position === undefined &&
|
|
401
|
+
showRarity === undefined
|
|
402
|
+
) {
|
|
403
|
+
const cfg = loadConfig();
|
|
404
|
+
return {
|
|
405
|
+
content: [
|
|
406
|
+
{
|
|
407
|
+
type: "text",
|
|
408
|
+
text: `Bubble style: ${cfg.bubbleStyle}\nBubble position: ${cfg.bubblePosition}\nShow rarity: ${cfg.showRarity}\nUse /buddy style <classic|round>, /buddy position <top|left>, /buddy rarity <on|off> to change.`,
|
|
409
|
+
},
|
|
410
|
+
],
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
const updates: Record<string, string | boolean> = {};
|
|
414
|
+
if (style !== undefined) updates.bubbleStyle = style;
|
|
415
|
+
if (position !== undefined) updates.bubblePosition = position;
|
|
416
|
+
if (showRarity !== undefined) updates.showRarity = showRarity;
|
|
417
|
+
const cfg = saveConfig(updates);
|
|
418
|
+
return {
|
|
419
|
+
content: [
|
|
420
|
+
{
|
|
421
|
+
type: "text",
|
|
422
|
+
text: `Updated: style=${cfg.bubbleStyle}, position=${cfg.bubblePosition}, showRarity=${cfg.showRarity}\nRestart Claude Code for changes to take effect.`,
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
};
|
|
426
|
+
},
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
server.tool(
|
|
430
|
+
"buddy_mute",
|
|
431
|
+
"Mute buddy reactions (buddy stays visible but stops reacting)",
|
|
432
|
+
{},
|
|
433
|
+
async () => {
|
|
434
|
+
const companion = ensureCompanion();
|
|
435
|
+
writeStatusState(companion, "", true);
|
|
436
|
+
incrementEvent("commands_run", 1, activeSlot());
|
|
437
|
+
return {
|
|
438
|
+
content: [
|
|
439
|
+
{
|
|
440
|
+
type: "text",
|
|
441
|
+
text: `${companion.name} goes quiet. /buddy on to unmute.`,
|
|
442
|
+
},
|
|
443
|
+
],
|
|
444
|
+
};
|
|
445
|
+
},
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
server.tool("buddy_unmute", "Unmute buddy reactions", {}, async () => {
|
|
449
|
+
const companion = ensureCompanion();
|
|
450
|
+
writeStatusState(companion, "*stretches* I'm back!", false);
|
|
451
|
+
saveReaction("*stretches* I'm back!", "pet");
|
|
452
|
+
incrementEvent("commands_run", 1, activeSlot());
|
|
453
|
+
return { content: [{ type: "text", text: `${companion.name} is back!` }] };
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// ─── Tool: buddy_statusline ─────────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
server.tool(
|
|
459
|
+
"buddy_statusline",
|
|
460
|
+
"Enable or disable the buddy status line. When enabled, configures Claude Code's status line to show your buddy with animation and reactions. When disabled, the status line is released for other use. Returns current status if called without arguments.",
|
|
461
|
+
{
|
|
462
|
+
enabled: z
|
|
463
|
+
.boolean()
|
|
464
|
+
.optional()
|
|
465
|
+
.describe(
|
|
466
|
+
"true to enable, false to disable. Omit to show current status.",
|
|
467
|
+
),
|
|
468
|
+
},
|
|
469
|
+
async ({ enabled }) => {
|
|
470
|
+
if (enabled === undefined) {
|
|
471
|
+
const cfg = loadConfig();
|
|
472
|
+
const state = cfg.statusLineEnabled ? "enabled" : "disabled";
|
|
473
|
+
return {
|
|
474
|
+
content: [
|
|
475
|
+
{
|
|
476
|
+
type: "text",
|
|
477
|
+
text: `Status line: ${state}\nUse /buddy statusline on or /buddy statusline off to change.\nRestart Claude Code after enabling for it to take effect.`,
|
|
478
|
+
},
|
|
479
|
+
],
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
saveConfig({ statusLineEnabled: enabled });
|
|
483
|
+
|
|
484
|
+
if (enabled) {
|
|
485
|
+
const pluginRoot = resolve(dirname(import.meta.dir));
|
|
486
|
+
const statusScript = join(pluginRoot, "statusline", "buddy-status.sh");
|
|
487
|
+
setBuddyStatusLine(statusScript);
|
|
488
|
+
return {
|
|
489
|
+
content: [
|
|
490
|
+
{
|
|
491
|
+
type: "text",
|
|
492
|
+
text: "Status line enabled! Restart Claude Code to see your buddy in the status line.",
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
};
|
|
496
|
+
} else {
|
|
497
|
+
unsetBuddyStatusLine();
|
|
498
|
+
return {
|
|
499
|
+
content: [
|
|
500
|
+
{
|
|
501
|
+
type: "text",
|
|
502
|
+
text: "Status line disabled. Restart Claude Code to apply.",
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
// ─── Tool: buddy_achievements ────────────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
server.tool(
|
|
513
|
+
"buddy_achievements",
|
|
514
|
+
"Show all achievement badges — earned and locked. Displays a card with progress bar and status for each badge.",
|
|
515
|
+
{},
|
|
516
|
+
async () => {
|
|
517
|
+
ensureCompanion();
|
|
518
|
+
checkAndAward(activeSlot());
|
|
519
|
+
const card = renderAchievementsCardMarkdown();
|
|
520
|
+
return { content: [{ type: "text", text: card }] };
|
|
521
|
+
},
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
// ─── Tool: buddy_summon ─────────────────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
server.tool(
|
|
527
|
+
"buddy_summon",
|
|
528
|
+
"Summon a buddy by slot name. Loads a saved buddy if the slot exists; generates a new deterministic buddy for unknown slot names. Omit slot to pick randomly from all saved buddies. Your current buddy is NOT destroyed — they stay saved in their slot.",
|
|
529
|
+
{
|
|
530
|
+
slot: z
|
|
531
|
+
.string()
|
|
532
|
+
.min(1)
|
|
533
|
+
.max(14)
|
|
534
|
+
.optional()
|
|
535
|
+
.describe(
|
|
536
|
+
"Slot name to summon (e.g. 'fafnir', 'dragon-2'). Omit to pick a random saved buddy.",
|
|
537
|
+
),
|
|
538
|
+
},
|
|
539
|
+
async ({ slot }) => {
|
|
540
|
+
const userId = resolveUserId();
|
|
541
|
+
|
|
542
|
+
let targetSlot: string;
|
|
543
|
+
|
|
544
|
+
if (!slot) {
|
|
545
|
+
// Random pick from saved buddies
|
|
546
|
+
const saved = listCompanionSlots();
|
|
547
|
+
if (saved.length === 0) {
|
|
548
|
+
return {
|
|
549
|
+
content: [
|
|
550
|
+
{
|
|
551
|
+
type: "text",
|
|
552
|
+
text: "Your menagerie is empty. Use buddy_summon with a slot name to add one.",
|
|
553
|
+
},
|
|
554
|
+
],
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
targetSlot = saved[Math.floor(Math.random() * saved.length)].slot;
|
|
558
|
+
} else {
|
|
559
|
+
targetSlot = slugify(slot);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Load existing — unknown slot names only load, never auto-create
|
|
563
|
+
const companion = loadCompanionSlot(targetSlot);
|
|
564
|
+
if (!companion) {
|
|
565
|
+
return {
|
|
566
|
+
content: [
|
|
567
|
+
{
|
|
568
|
+
type: "text",
|
|
569
|
+
text: `No buddy found in slot "${targetSlot}". Use /buddy list to see saved buddies.`,
|
|
570
|
+
},
|
|
571
|
+
],
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
saveActiveSlot(targetSlot);
|
|
576
|
+
writeStatusState(companion, `*${companion.name} arrives*`);
|
|
577
|
+
|
|
578
|
+
// Uses markdown renderer so the card displays cleanly in Claude Code's UI.
|
|
579
|
+
const card = renderCompanionCardMarkdown(
|
|
580
|
+
companion.bones,
|
|
581
|
+
companion.name,
|
|
582
|
+
companion.personality,
|
|
583
|
+
`*${companion.name} arrives*`,
|
|
584
|
+
);
|
|
585
|
+
return { content: [{ type: "text", text: card }] };
|
|
586
|
+
},
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
// ─── Tool: buddy_save ───────────────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
server.tool(
|
|
592
|
+
"buddy_save",
|
|
593
|
+
"Save the current buddy to a named slot. Useful for bookmarking before trying a new buddy.",
|
|
594
|
+
{
|
|
595
|
+
slot: z
|
|
596
|
+
.string()
|
|
597
|
+
.min(1)
|
|
598
|
+
.max(14)
|
|
599
|
+
.optional()
|
|
600
|
+
.describe(
|
|
601
|
+
"Slot name (defaults to the buddy's current name, slugified). Overwrites existing slot with same name.",
|
|
602
|
+
),
|
|
603
|
+
},
|
|
604
|
+
async ({ slot }) => {
|
|
605
|
+
const companion = ensureCompanion();
|
|
606
|
+
const targetSlot = slot ? slugify(slot) : slugify(companion.name);
|
|
607
|
+
saveCompanionSlot(companion, targetSlot);
|
|
608
|
+
saveActiveSlot(targetSlot);
|
|
609
|
+
return {
|
|
610
|
+
content: [
|
|
611
|
+
{
|
|
612
|
+
type: "text",
|
|
613
|
+
text: `${companion.name} saved to slot "${targetSlot}".`,
|
|
614
|
+
},
|
|
615
|
+
],
|
|
616
|
+
};
|
|
617
|
+
},
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
// ─── Tool: buddy_list ───────────────────────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
server.tool(
|
|
623
|
+
"buddy_list",
|
|
624
|
+
"List all saved buddies with their slot names, species, and rarity",
|
|
625
|
+
{},
|
|
626
|
+
async () => {
|
|
627
|
+
const saved = listCompanionSlots();
|
|
628
|
+
const activeSlot = loadActiveSlot();
|
|
629
|
+
|
|
630
|
+
if (saved.length === 0) {
|
|
631
|
+
return {
|
|
632
|
+
content: [
|
|
633
|
+
{
|
|
634
|
+
type: "text",
|
|
635
|
+
text: "Your menagerie is empty. Use buddy_summon <slot> to add one.",
|
|
636
|
+
},
|
|
637
|
+
],
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const lines = saved.map(({ slot, companion }) => {
|
|
642
|
+
const active = slot === activeSlot ? " ← active" : "";
|
|
643
|
+
const stars = RARITY_STARS[companion.bones.rarity];
|
|
644
|
+
const shiny = companion.bones.shiny ? " ✨" : "";
|
|
645
|
+
return ` ${companion.name} [${slot}] — ${companion.bones.rarity} ${companion.bones.species} ${stars}${shiny}${active}`;
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
649
|
+
},
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
// ─── Tool: buddy_dismiss ────────────────────────────────────────────────────
|
|
653
|
+
|
|
654
|
+
server.tool(
|
|
655
|
+
"buddy_dismiss",
|
|
656
|
+
"Remove a saved buddy by slot name. Cannot dismiss the currently active buddy — switch first with buddy_summon.",
|
|
657
|
+
{
|
|
658
|
+
slot: z.string().min(1).max(14).describe("Slot name to remove"),
|
|
659
|
+
},
|
|
660
|
+
async ({ slot }) => {
|
|
661
|
+
const targetSlot = slugify(slot);
|
|
662
|
+
const activeSlot = loadActiveSlot();
|
|
663
|
+
|
|
664
|
+
if (targetSlot === activeSlot) {
|
|
665
|
+
return {
|
|
666
|
+
content: [
|
|
667
|
+
{
|
|
668
|
+
type: "text",
|
|
669
|
+
text: `Cannot dismiss the active buddy. Use buddy_summon to switch first, then buddy_dismiss "${targetSlot}".`,
|
|
670
|
+
},
|
|
671
|
+
],
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const companion = loadCompanionSlot(targetSlot);
|
|
676
|
+
if (!companion) {
|
|
677
|
+
return {
|
|
678
|
+
content: [
|
|
679
|
+
{
|
|
680
|
+
type: "text",
|
|
681
|
+
text: `No buddy found in slot "${targetSlot}". Use buddy_list to see saved buddies.`,
|
|
682
|
+
},
|
|
683
|
+
],
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
deleteCompanionSlot(targetSlot);
|
|
688
|
+
return {
|
|
689
|
+
content: [
|
|
690
|
+
{ type: "text", text: `${companion.name} [${targetSlot}] dismissed.` },
|
|
691
|
+
],
|
|
692
|
+
};
|
|
693
|
+
},
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
// ─── Resource: buddy://companion ────────────────────────────────────────────
|
|
697
|
+
|
|
698
|
+
server.resource(
|
|
699
|
+
"buddy_companion",
|
|
700
|
+
"buddy://companion",
|
|
701
|
+
{ description: "Current companion data as JSON", mimeType: "application/json" },
|
|
702
|
+
async () => {
|
|
703
|
+
const companion = ensureCompanion();
|
|
704
|
+
return {
|
|
705
|
+
contents: [
|
|
706
|
+
{
|
|
707
|
+
uri: "buddy://companion",
|
|
708
|
+
mimeType: "application/json",
|
|
709
|
+
text: JSON.stringify(companion, null, 2),
|
|
710
|
+
},
|
|
711
|
+
],
|
|
712
|
+
};
|
|
713
|
+
},
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
// ─── Resource: buddy://prompt ───────────────────────────────────────────────
|
|
717
|
+
|
|
718
|
+
server.resource(
|
|
719
|
+
"buddy_prompt",
|
|
720
|
+
"buddy://prompt",
|
|
721
|
+
{ description: "System prompt context for the companion", mimeType: "text/markdown" },
|
|
722
|
+
async () => {
|
|
723
|
+
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");
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
contents: [
|
|
761
|
+
{
|
|
762
|
+
uri: "buddy://prompt",
|
|
763
|
+
mimeType: "text/plain",
|
|
764
|
+
text: prompt,
|
|
765
|
+
},
|
|
766
|
+
],
|
|
767
|
+
};
|
|
768
|
+
},
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
// ─── Start ──────────────────────────────────────────────────────────────────
|
|
772
|
+
|
|
773
|
+
const transport = new StdioServerTransport();
|
|
774
|
+
await server.connect(transport);
|