From 9005d729f054117bb09ac51d7b2d3876a8cec693 Mon Sep 17 00:00:00 2001 From: karthikrajaanandan Date: Wed, 15 Jul 2026 21:50:20 -0700 Subject: [PATCH] feat: LLM-free CLI dispatcher + runtime custom-art loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move every typed /buddy command off the LLM. cli/buddy.ts mirrors each config/state MCP tool (show, pet, stats, list, rename, personality, achievements, xp, upgrades, mood, memory, summon, save, dismiss, skin, frequency, style/position/rarity/width/margin/rainbow, statusline, theme, mute/unmute) with identical state writes, output, and achievements. The slash command now shells out to it; only proactive react/suggest stay on the model. buddy_save's advertised overwrite is honored (MCP tool throws). Add server/custom-art.ts: an override layer that loads validated species JSON from /custom-art/ at runtime. It is keyed by name and never joins the RNG SPECIES pool, so existing buddies' deterministic rolls are untouched (adding to SPECIES re-rolls everyone — golden tests guard it). Ship fox as custom-art/fox.json for exactly this reason. Verified: tsc clean, 246 tests pass, all commands exercised live. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: karthikrajaanandan --- cli/buddy.ts | 773 ++++++++++++++++++++++++++++++++++++++++++ custom-art/README.md | 49 +++ custom-art/fox.json | 9 + package.json | 2 + server/art.ts | 8 +- server/custom-art.ts | 85 +++++ server/engine.ts | 5 +- skills/buddy/SKILL.md | 93 ++--- 8 files changed, 982 insertions(+), 42 deletions(-) create mode 100644 cli/buddy.ts create mode 100644 custom-art/README.md create mode 100644 custom-art/fox.json create mode 100644 server/custom-art.ts diff --git a/cli/buddy.ts b/cli/buddy.ts new file mode 100644 index 0000000..f1654ed --- /dev/null +++ b/cli/buddy.ts @@ -0,0 +1,773 @@ +#!/usr/bin/env bun +/** + * claude-buddy — LLM-free command dispatcher. + * + * The `/buddy` slash command routes through the LLM, but almost every buddy + * command is a pure config/state operation that needs no model judgment — it + * just reads or writes JSON and prints. Those all live here and run as plain + * bun, no MCP round-trip, no tokens. + * + * The ONLY commands that genuinely need the LLM are the proactive ones that + * read the conversation or your code — buddy_react (name mention), + * buddy_suggest (teachable moment), and the end-of-turn comment. Those are not + * typed commands, so they are not here. + * + * Usage: + * bun run cli/buddy.ts [args] + * bun run buddy [args] (via package.json script) + * + * Each subcommand mirrors the matching MCP tool in server/index.ts exactly — + * same state calls, same output strings, same side effects. + */ + +import { resolve, dirname, join } from "path"; + +import { + generateBones, + generatePersonality, + renderBuddy, + renderFace, + RARITY_STARS, + type StatName, + type Companion, +} from "../server/engine.ts"; +import { + loadCompanion, + saveCompanion, + resolveUserId, + loadReaction, + saveReaction, + writeStatusState, + loadConfig, + saveConfig, + loadActiveSlot, + saveActiveSlot, + slugify, + unusedName, + loadCompanionSlot, + saveCompanionSlot, + updateCompanionSlot, + deleteCompanionSlot, + listCompanionSlots, + setBuddyStatusLine, + unsetBuddyStatusLine, + type BuddyConfig, +} from "../server/state.ts"; +import { claudeSettingsPath } from "../server/path.ts"; +import { getReaction } from "../server/reactions.ts"; +import { renderCompanionCardMarkdown } from "../server/art.ts"; +import { + incrementEvent, + checkAndAward, + trackActiveDay, + renderAchievementsCardMarkdown, +} from "../server/achievements.ts"; +import { + getXpState, + isUpgradeUnlocked, + applyUpgrade, + UNLOCKABLE_UPGRADES, + renderXpCardMarkdown, +} from "../server/xp.ts"; +import { getMood, MOOD_COLORS, MOOD_NAMES } from "../server/mood.ts"; +import { queryMemory, resolveBug } from "../server/memory.ts"; +import { listCustomArt, CUSTOM_ART_PATH } from "../server/custom-art.ts"; + +const BOLD = "\x1b[1m"; +const DIM = "\x1b[2m"; +const NC = "\x1b[0m"; + +const STAT_NAMES: StatName[] = [ + "DEBUGGING", + "PATIENCE", + "CHAOS", + "WISDOM", + "SNARK", +]; + +// ─── Shared helpers (ported from server/index.ts) ──────────────────────────── + +/** Mirror of index.ts ensureCompanion(): load, rescue, or generate. */ +function ensureCompanion(): Companion { + const existing = loadCompanion(); + if (existing) return existing; + + const saved = listCompanionSlots(); + if (saved.length > 0) { + const { slot, companion } = saved[0]; + saveActiveSlot(slot); + writeStatusState(companion, `*${companion.name} arrives*`); + return companion; + } + + const userId = resolveUserId(); + const bones = generateBones(userId); + const name = unusedName(); + const companion: Companion = { + bones, + name, + personality: generatePersonality(bones, userId), + hatchedAt: Date.now(), + userId, + }; + const slot = slugify(name); + saveCompanionSlot(companion, slot); + saveActiveSlot(slot); + writeStatusState(companion); + checkAndAward(slot); + trackActiveDay(); + incrementEvent("sessions", 1); + incrementEvent("buddies_collected", 1); + return companion; +} + +function activeSlot(): string { + return loadActiveSlot(); +} + +/** Mirror of the achievement-notice block duplicated across index.ts tools. */ +function achNotice(): string { + const newAch = checkAndAward(activeSlot()); + return newAch.length > 0 + ? "\n" + + newAch + .map((a) => `${a.icon} Achievement Unlocked: ${a.name}!`) + .join("\n") + : ""; +} + +function requireActive(): Companion { + const companion = loadCompanion(); + if (!companion) { + console.log("No companion found. Run 'claude-buddy install' first."); + process.exit(1); + } + return companion; +} + +// ─── Terminal display commands ─────────────────────────────────────────────── + +function cmdShow(): void { + const companion = requireActive(); + console.log(""); + console.log(renderBuddy(companion.bones)); + console.log(""); + console.log(` ${BOLD}${companion.name}${NC}`); + console.log(` ${DIM}${companion.personality}${NC}`); + console.log(""); + + const reaction = loadReaction(); + if (reaction) { + const face = renderFace(companion.bones.species, companion.bones.eye); + console.log(` ${face} "${reaction.reaction}"`); + console.log(""); + } +} + +function cmdPet(): void { + const companion = requireActive(); + const { bones } = companion; + const reaction = getReaction( + "pet", + bones.species, + bones.rarity, + bones.stats as unknown as Record, + ); + saveReaction(reaction, "pet"); + const face = renderFace(bones.species, bones.eye); + console.log(`${face} ${companion.name}: "${reaction}"`); +} + +function cmdList(): void { + const slots = listCompanionSlots(); + if (slots.length === 0) { + console.log("No buddies saved yet."); + return; + } + const active = loadActiveSlot(); + for (const { slot, companion } of slots) { + const { bones } = companion; + const stars = RARITY_STARS[bones.rarity]; + const marker = slot === active ? " ← active" : ""; + console.log( + ` ${companion.name} [${slot}] — ${bones.rarity} ${bones.species} ${stars}${marker}`, + ); + } +} + +function cmdStats(): void { + const companion = requireActive(); + const { bones } = companion; + console.log(""); + console.log(` ${BOLD}${companion.name}${NC} — ${bones.rarity} ${bones.species}`); + for (const stat of STAT_NAMES) { + const val = bones.stats[stat]; + const bar = + "█".repeat(Math.floor(val / 5)) + "░".repeat(20 - Math.floor(val / 5)); + const label = stat.padEnd(9); + const marker = + stat === bones.peak ? " ▲" : stat === bones.dump ? " ▼" : ""; + console.log(` ${label} ${bar} ${String(val).padStart(3)}${marker}`); + } + console.log(""); +} + +// ─── Identity / management commands ────────────────────────────────────────── + +function cmdRename(): void { + const name = process.argv[3]; + if (!name || name.length < 1 || name.length > 14) { + console.error("Usage: buddy rename (1-14 chars)"); + process.exit(1); + } + const companion = ensureCompanion(); + const oldName = companion.name; + companion.name = name; + saveCompanion(companion); + writeStatusState(companion); + incrementEvent("commands_run", 1, activeSlot()); + incrementEvent("renames", 1); + console.log(`Renamed: ${oldName} → ${name}${achNotice()}`); +} + +function cmdPersonality(): void { + const personality = process.argv.slice(3).join(" "); + if (!personality || personality.length < 1 || personality.length > 500) { + console.error("Usage: buddy personality (1-500 chars)"); + process.exit(1); + } + const companion = ensureCompanion(); + companion.personality = personality; + saveCompanion(companion); + incrementEvent("commands_run", 1, activeSlot()); + incrementEvent("personalities_set", 1); + console.log(`Personality updated for ${companion.name}.${achNotice()}`); +} + +function cmdSummon(): void { + const slotArg = process.argv[3]; + let targetSlot: string; + if (!slotArg) { + const saved = listCompanionSlots(); + if (saved.length === 0) { + console.log( + "Your menagerie is empty. Use buddy summon with a slot name to add one.", + ); + return; + } + targetSlot = saved[Math.floor(Math.random() * saved.length)].slot; + } else { + targetSlot = slugify(slotArg); + } + const companion = loadCompanionSlot(targetSlot); + if (!companion) { + console.log( + `No buddy found in slot "${targetSlot}". Use buddy list to see saved buddies.`, + ); + process.exit(1); + } + saveActiveSlot(targetSlot); + writeStatusState(companion, `*${companion.name} arrives*`); + incrementEvent("summons", 1); + const card = renderCompanionCardMarkdown( + companion.bones, + companion.name, + companion.personality, + `*${companion.name} arrives*`, + ); + console.log(`${card}${achNotice()}`); +} + +function cmdSave(): void { + const companion = ensureCompanion(); + const slotArg = process.argv[3]; + const targetSlot = slotArg ? slugify(slotArg) : slugify(companion.name); + // The MCP tool's saveCompanionSlot throws on an existing slot even though the + // tool advertises overwrite. Honor the advertised behavior: overwrite. + try { + saveCompanionSlot(companion, targetSlot); + } catch { + updateCompanionSlot(targetSlot, companion); + } + saveActiveSlot(targetSlot); + incrementEvent("buddies_collected", 1); + incrementEvent("saves", 1); + console.log(`${companion.name} saved to slot "${targetSlot}".${achNotice()}`); +} + +function cmdDismiss(): void { + const slotArg = process.argv[3]; + if (!slotArg) { + console.error("Usage: buddy dismiss "); + process.exit(1); + } + const targetSlot = slugify(slotArg); + const active = loadActiveSlot(); + if (targetSlot === active) { + console.log( + `Cannot dismiss the active buddy. Use buddy summon to switch first, then buddy dismiss "${targetSlot}".`, + ); + process.exit(1); + } + const companion = loadCompanionSlot(targetSlot); + if (!companion) { + console.log( + `No buddy found in slot "${targetSlot}". Use buddy list to see saved buddies.`, + ); + process.exit(1); + } + deleteCompanionSlot(targetSlot); + incrementEvent("dismissals", 1); + console.log(`${companion.name} [${targetSlot}] dismissed.${achNotice()}`); +} + +function cmdSkin(): void { + const available = listCustomArt(); + const target = process.argv[3]; + + if (!target) { + console.log(`Custom art dir: ${CUSTOM_ART_PATH}`); + if (available.length === 0) { + console.log("No custom art loaded. Drop a validated species JSON there."); + console.log("Validate first: bun run cli/validate-species.ts .json"); + } else { + console.log("Available skins:"); + for (const n of available) console.log(` ${n}`); + console.log(""); + console.log("Apply with: bun run cli/buddy.ts skin "); + } + return; + } + + if (!available.includes(target)) { + console.error(`No custom art named "${target}".`); + console.error( + available.length + ? `Available: ${available.join(", ")}` + : `Drop a JSON into ${CUSTOM_ART_PATH} first.`, + ); + process.exit(1); + } + + const companion = requireActive(); + companion.bones.species = target as typeof companion.bones.species; + saveCompanion(companion); + writeStatusState(companion); + console.log(`${companion.name} now wears the "${target}" skin.`); +} + +// ─── Card commands ─────────────────────────────────────────────────────────── + +function cmdAchievements(): void { + ensureCompanion(); + checkAndAward(activeSlot()); + incrementEvent("achievement_views", 1); + console.log(renderAchievementsCardMarkdown()); +} + +function cmdXp(): void { + ensureCompanion(); + console.log(renderXpCardMarkdown()); +} + +function cmdUpgrades(): void { + ensureCompanion(); + const state = getXpState(); + const apply = process.argv[3]; + + if (apply) { + if (!isUpgradeUnlocked(apply)) { + console.log( + `Upgrade "${apply}" is not yet unlocked. Reach the required level first.`, + ); + return; + } + const companion = loadCompanion(); + if (!companion) { + console.log("No active companion."); + return; + } + const updated = applyUpgrade(companion, apply); + if (updated) { + saveCompanion(updated); + const upg = UNLOCKABLE_UPGRADES.find((u) => u.id === apply); + console.log( + `${upg?.icon ?? ""} Applied: ${upg?.name}. ${upg?.description ?? ""}`, + ); + return; + } + // fall through to list if apply produced no change + } + + const currentLevel = state.level; + const lines: string[] = [`### Level ${currentLevel} — Upgrades`, ""]; + for (const upg of UNLOCKABLE_UPGRADES) { + const unlocked = currentLevel >= upg.level; + const status = unlocked ? "✅" : `🔒 Lvl ${upg.level}`; + lines.push(`${status} ${upg.icon} **${upg.name}**: ${upg.description}`); + } + console.log(lines.join("\n")); +} + +function cmdMood(): void { + const moodState = getMood(); + const mood = moodState.current; + const color = MOOD_COLORS[mood] ?? "💫"; + const name = MOOD_NAMES[mood] ?? mood; + const cfg = loadConfig(); + const lines: string[] = [ + `### ${color} ${name}`, + "", + `**Current mood:** ${name}`, + `**Intensity:** ${moodState.intensity}/3`, + "", + ]; + if (moodState.recentErrors > 0) + lines.push(`Recent errors: ${moodState.recentErrors}`); + if (moodState.recentTests > 0) + lines.push(`Recent tests passed: ${moodState.recentTests}`); + if (moodState.recentDiffs > 0) + lines.push(`Recent large diffs: ${moodState.recentDiffs}`); + lines.push(""); + lines.push( + "Mood shifts based on: tests, errors, session length, and time of day.", + ); + if (!cfg.moodEnabled) lines.push("\n*(Mood is currently disabled)*"); + console.log(lines.join("\n")); +} + +function cmdMemory(): void { + // Flags: --resolve-bug , --project , --type , --resolved + const argv = process.argv.slice(3); + const flag = (name: string): string | undefined => { + const i = argv.indexOf(name); + return i >= 0 ? argv[i + 1] : undefined; + }; + const resolveBugId = flag("--resolve-bug"); + if (resolveBugId) { + const bug = resolveBug(resolveBugId); + if (!bug) { + console.log(`Bug "${resolveBugId}" not found.`); + return; + } + incrementEvent("bugs_resolved", 1, activeSlot()); + checkAndAward(activeSlot()); + console.log(`Bug marked as resolved: ${bug.summary.slice(0, 100)}`); + return; + } + + const project = flag("--project"); + const type = flag("--type") as + | "projects" + | "bugs" + | "preferences" + | "all" + | undefined; + const resolved = argv.includes("--resolved") ? true : undefined; + const result = queryMemory({ project, type, resolved }); + const lines: string[] = []; + + if (result.projects.length > 0) { + lines.push("### Projects", ""); + for (const proj of result.projects) { + lines.push(`**${proj.name}** (${proj.language.join(", ") || "unknown"})`); + if (proj.framework) lines.push(` Framework: ${proj.framework}`); + lines.push(` Last seen: ${new Date(proj.lastSeen).toLocaleDateString()}`); + lines.push(""); + } + } + if (result.bugs.length > 0) { + lines.push("### Bugs", ""); + for (const bug of result.bugs) { + const status = bug.resolved ? "✅" : "❌"; + lines.push(`${status} **${bug.summary.slice(0, 80)}...**`); + lines.push( + ` Occurrences: ${bug.occurrenceCount} | First seen: ${new Date(bug.firstSeen).toLocaleDateString()}`, + ); + lines.push(` ID: \`${bug.id}\``); + lines.push(""); + } + } + if (result.preferences.length > 0) { + lines.push("### Preferences", ""); + for (const pref of result.preferences) { + lines.push( + `**${pref.key}** = "${pref.value}" (${Math.round(pref.confidence * 100)}% confidence)`, + ); + lines.push(` Context: ${pref.context}`); + lines.push(""); + } + } + if (lines.length === 0) { + lines.push( + "No memory yet. Start coding and buddy will remember your projects, bugs, and preferences.", + ); + } + console.log(lines.join("\n")); +} + +// ─── Config commands ───────────────────────────────────────────────────────── + +function cmdFrequency(): void { + const arg = process.argv[3]; + if (arg === undefined) { + const cfg = loadConfig(); + console.log( + `Comment cooldown: ${cfg.commentCooldown}s between displayed comments.\nUse buddy frequency to change.`, + ); + return; + } + const cooldown = parseInt(arg, 10); + if (Number.isNaN(cooldown) || cooldown < 0 || cooldown > 300) { + console.error("Usage: buddy frequency <0-300>"); + process.exit(1); + } + const cfg = saveConfig({ commentCooldown: cooldown }); + console.log( + `Updated: ${cfg.commentCooldown}s cooldown between displayed comments.`, + ); +} + +function styleShow(): void { + const cfg = loadConfig(); + const rainbowDisplay = cfg.rainbowColors + ? cfg.rainbowColors.join(", ") + : "default (ROYGBIV)"; + console.log( + `Bubble style: ${cfg.bubbleStyle}\nBubble position: ${cfg.bubblePosition}\nShow rarity: ${cfg.showRarity}\nBubble width: ${cfg.bubbleWidth}\nBubble margin: ${cfg.bubbleMargin}\nShiny rainbow: ${rainbowDisplay}\nUse buddy style , buddy position , buddy rarity , buddy width <10-60>, buddy margin <0-20>, buddy rainbow [<#hex>...] to change.`, + ); +} + +function styleApply(updates: Partial): void { + const cfg = saveConfig(updates); + const rainbowDisplay = cfg.rainbowColors + ? cfg.rainbowColors.join(", ") + : "default (ROYGBIV)"; + console.log( + `Updated: style=${cfg.bubbleStyle}, position=${cfg.bubblePosition}, showRarity=${cfg.showRarity}, width=${cfg.bubbleWidth}, margin=${cfg.bubbleMargin}, rainbow=${rainbowDisplay}\nRestart Claude Code for changes to take effect.`, + ); +} + +function cmdStyle(): void { + const v = process.argv[3]; + if (v === undefined) return styleShow(); + if (v !== "classic" && v !== "round") { + console.error("Usage: buddy style "); + process.exit(1); + } + styleApply({ bubbleStyle: v }); +} + +function cmdPosition(): void { + const v = process.argv[3]; + if (v === undefined) return styleShow(); + if (v !== "top" && v !== "left") { + console.error("Usage: buddy position "); + process.exit(1); + } + styleApply({ bubblePosition: v }); +} + +function cmdRarity(): void { + const v = process.argv[3]; + if (v === undefined) return styleShow(); + if (v !== "on" && v !== "off") { + console.error("Usage: buddy rarity "); + process.exit(1); + } + styleApply({ showRarity: v === "on" }); +} + +function cmdWidth(): void { + const v = parseInt(process.argv[3] ?? "", 10); + if (Number.isNaN(v) || v < 10 || v > 60) { + console.error("Usage: buddy width <10-60>"); + process.exit(1); + } + styleApply({ bubbleWidth: v }); +} + +function cmdMargin(): void { + const v = parseInt(process.argv[3] ?? "", 10); + if (Number.isNaN(v) || v < 0 || v > 20) { + console.error("Usage: buddy margin <0-20>"); + process.exit(1); + } + styleApply({ bubbleMargin: v }); +} + +function cmdRainbow(): void { + const args = process.argv.slice(3); + if (args.length === 0) return styleShow(); + if (args.length === 1 && args[0] === "reset") { + styleApply({ rainbowColors: undefined }); + return; + } + const hex = /^#[0-9a-fA-F]{6}$/; + if (!args.every((c) => hex.test(c)) || args.length > 16) { + console.error("Usage: buddy rainbow <#rrggbb> [...up to 16] | buddy rainbow reset"); + process.exit(1); + } + styleApply({ rainbowColors: args }); +} + +function cmdTheme(): void { + const v = process.argv[3]; + if (v === undefined) { + const cfg = loadConfig(); + console.log( + `Theme: ${cfg.theme ?? "auto"}\nUse buddy theme to change.`, + ); + return; + } + if (v !== "dark" && v !== "light" && v !== "auto") { + console.error("Usage: buddy theme "); + process.exit(1); + } + const cfg = saveConfig({ theme: v }); + console.log(`Theme set to ${cfg.theme}. Restart Claude Code to apply.`); +} + +function cmdStatusline(): void { + const arg = process.argv[3]; + let enabled: boolean | undefined; + let combined: boolean | undefined; + if (arg === "on") enabled = true; + else if (arg === "off") enabled = false; + else if (arg === "combined") combined = true; + else if (arg === "basic") combined = false; + + if (enabled === undefined && combined === undefined) { + const cfg = loadConfig(); + const state = cfg.statusLineEnabled ? "enabled" : "disabled"; + const mode = cfg.useCombinedStatus + ? "combined (with rate-limit bars)" + : "basic (buddy only)"; + console.log( + `Status line: ${state}\nMode: ${mode}\nUse buddy statusline on|off to toggle, buddy statusline combined to add rate-limit bars.\nRestart Claude Code after changes for them to take effect.`, + ); + return; + } + + if (combined !== undefined) saveConfig({ useCombinedStatus: combined }); + if (enabled !== undefined) saveConfig({ statusLineEnabled: enabled }); + const cfg = loadConfig(); + + if (cfg.statusLineEnabled) { + // index.ts is in server/, so its plugin root is dirname(import.meta.dir). + // This file is in cli/, at the same depth, so the same expression holds. + const pluginRoot = resolve(dirname(import.meta.dir)); + const scriptName = cfg.useCombinedStatus + ? "combined-status.sh" + : "buddy-status.sh"; + const statusScript = join(pluginRoot, "statusline", scriptName); + setBuddyStatusLine(statusScript); + console.log( + `Status line enabled (${cfg.useCombinedStatus ? "combined" : "basic"} mode)! Restart Claude Code to apply.\n\n` + + `Note: this writes an entry to ${claudeSettingsPath()} that \`claude plugin uninstall\` does not remove. ` + + "Run `/buddy uninstall` before uninstalling the plugin to clean it up.", + ); + } else { + unsetBuddyStatusLine(); + console.log("Status line disabled. Restart Claude Code to apply."); + } +} + +function cmdMute(): void { + const companion = ensureCompanion(); + writeStatusState(companion, "", true); + incrementEvent("commands_run", 1, activeSlot()); + incrementEvent("mutes", 1); + console.log(`${companion.name} goes quiet. buddy on to unmute.${achNotice()}`); +} + +function cmdUnmute(): void { + const companion = ensureCompanion(); + writeStatusState(companion, "*stretches* I'm back!", false); + saveReaction("*stretches* I'm back!", "pet"); + incrementEvent("commands_run", 1, activeSlot()); + incrementEvent("unmutes", 1); + console.log(`${companion.name} is back!${achNotice()}`); +} + +function cmdHelp(): void { + const help = [ + "claude-buddy commands (LLM-free CLI)", + "", + " buddy show Show companion in terminal", + " buddy help Show this help", + " buddy pet Pet your companion", + " buddy stats Detailed stat card", + " buddy off / mute Mute reactions", + " buddy on / unmute Unmute reactions", + " buddy rename Rename companion (1-14 chars)", + " buddy personality Set custom personality text", + " buddy achievements Show achievement badges", + " buddy xp Show XP, level, unlocks", + " buddy upgrades [id] List / apply level-up upgrades", + " buddy mood Show current mood", + " buddy memory Show remembered projects/bugs/prefs", + " buddy summon [slot] Summon a saved buddy (omit slot for random)", + " buddy save [slot] Save current buddy to a named slot", + " buddy list List all saved buddies", + " buddy dismiss Remove a saved buddy slot", + " buddy skin [name] List / apply custom art", + " buddy frequency [s] Show or set comment cooldown", + " buddy style [classic|round] Bubble style", + " buddy position [top|left] Bubble position", + " buddy rarity [on|off] Show/hide rarity stars", + " buddy width <10-60> Bubble width", + " buddy margin <0-20> Right margin", + " buddy rainbow [#hex...|reset] Shiny gradient", + " buddy statusline [on|off|combined|basic] Status line", + " buddy theme [dark|light|auto] Color theme", + ].join("\n"); + incrementEvent("commands_run", 1, activeSlot()); + incrementEvent("helps", 1); + console.log(help); +} + +// ─── Dispatch ──────────────────────────────────────────────────────────────── + +const COMMANDS: Record void> = { + show: cmdShow, + pet: cmdPet, + list: cmdList, + stats: cmdStats, + skin: cmdSkin, + rename: cmdRename, + personality: cmdPersonality, + summon: cmdSummon, + save: cmdSave, + dismiss: cmdDismiss, + achievements: cmdAchievements, + xp: cmdXp, + upgrades: cmdUpgrades, + mood: cmdMood, + memory: cmdMemory, + frequency: cmdFrequency, + style: cmdStyle, + position: cmdPosition, + rarity: cmdRarity, + width: cmdWidth, + margin: cmdMargin, + rainbow: cmdRainbow, + theme: cmdTheme, + statusline: cmdStatusline, + mute: cmdMute, + off: cmdMute, + unmute: cmdUnmute, + on: cmdUnmute, + help: cmdHelp, +}; + +const cmd = (process.argv[2] ?? "show").toLowerCase(); +const handler = COMMANDS[cmd]; + +if (!handler) { + console.error(`Unknown command: ${cmd}`); + console.error(`Commands: ${Object.keys(COMMANDS).join(", ")}`); + process.exit(1); +} + +handler(); diff --git a/custom-art/README.md b/custom-art/README.md new file mode 100644 index 0000000..b076161 --- /dev/null +++ b/custom-art/README.md @@ -0,0 +1,49 @@ +# Custom species art + +Runtime-loadable buddy skins — add a new drawing with **no code edit** and +**no change to the deterministic species roll**. + +## Why these are separate from `server/art.ts` + +The built-in species in `server/engine.ts` (`SPECIES`) form a fixed pool that +the generator picks from by index. Adding an entry there changes `SPECIES.length`, +which re-rolls the species of *every existing buddy* — the golden snapshot tests +guard against exactly that. Custom art sidesteps it: it is an **override layer** +keyed by name, never part of the RNG pool. Existing buddies are untouched. + +## Format + +```json +{ + "name": "fox", + "art": [ /* 3 frames, each exactly 5 lines, {E} = eye placeholder */ ], + "face": "({E}v{E})" // optional inline face template +} +``` + +Rules (enforced by `cli/validate-species.ts`): + +- exactly **3 frames**, each exactly **5 lines** +- each line **≤14 display columns** +- **no ANSI escape codes** +- `{E}` where the eye glyph should render + +## Install a skin + +1. Validate it: + ```bash + bun run cli/validate-species.ts custom-art/fox.json + ``` +2. Copy it into your buddy state dir (`~/.claude-buddy/custom-art/`, or + `$CLAUDE_CONFIG_DIR/buddy-state/custom-art/` when that env var is set): + ```bash + mkdir -p ~/.claude-buddy/custom-art + cp custom-art/fox.json ~/.claude-buddy/custom-art/ + ``` +3. Point your active buddy at it (LLM-free): + ```bash + bun run buddy skin fox # list available: `bun run buddy skin` + ``` + +The skin overrides only the drawing/face for a buddy whose `species` matches the +JSON `name`. Everything else (stats, rarity, personality) is unchanged. diff --git a/custom-art/fox.json b/custom-art/fox.json new file mode 100644 index 0000000..606100c --- /dev/null +++ b/custom-art/fox.json @@ -0,0 +1,9 @@ +{ + "name": "fox", + "art": [ + [" ", "/\\ {E}v{E} /\\ ", " \\ ^ / ", " \\___/ ", " ~=' '=~ "], + [" ", "/\\ {E}v{E} /\\ ", " \\ ^ / ", " \\___/ ", " ~=' '=~~ "], + [" ", "/| {E}v{E} /\\ ", " \\ ^ / ", " \\___/ ", " ~=' '=~ "] + ], + "face": "({E}v{E})" +} diff --git a/package.json b/package.json index c09d501..09076e1 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "hunt": "bun run cli/hunt.ts", "install-buddy": "bun run cli/install.ts", "show": "bun run cli/show.ts", + "buddy": "bun run cli/buddy.ts", "doctor": "bun run cli/doctor.ts", "test-statusline": "bun run cli/test-statusline.ts", "backup": "bun run cli/backup.ts", @@ -35,6 +36,7 @@ "hooks/", "statusline/", "scripts/", + "custom-art/", ".claude-plugin/", "!**/*.test.ts" ], diff --git a/server/art.ts b/server/art.ts index fc51248..c9c6968 100644 --- a/server/art.ts +++ b/server/art.ts @@ -1,5 +1,5 @@ /** - * ASCII art for all 18 buddy species + * ASCII art for all buddy species * * Each species has 3 animation frames (idle variations). * Each frame is 5 lines, ~12 chars wide. @@ -8,6 +8,7 @@ import type { Species, Eye, Hat, Rarity, StatName, BuddyBones } from "./engine.ts"; import { getRarityColor } from "./theme.ts"; +import { getCustomFrames } from "./custom-art.ts"; // ─── Species art: 3 frames × 5 lines each ────────────────────────────────── @@ -230,7 +231,7 @@ function dpad(s: string, targetW: number): string { // ─── Render functions ─────────────────────────────────────────────────────── export function getArtFrame(species: Species, eye: Eye, frame: number = 0): string[] { - const frames = SPECIES_ART[species]; + const frames = getCustomFrames(species) ?? SPECIES_ART[species]; const f = frames[frame % frames.length]; return f.map((line) => line.replace(/\{E\}/g, eye)); } @@ -249,7 +250,8 @@ export function getStatusFrames(bones: BuddyBones): { frameSequence: number[]; } { const resolveFrame = (frameIdx: number, eye: string): string => { - const raw = SPECIES_ART[bones.species][frameIdx]; + const src = getCustomFrames(bones.species) ?? SPECIES_ART[bones.species]; + const raw = src[frameIdx]; const art = raw.map((line) => line.replace(/\{E\}/g, eye)); const hatLine = HAT_ART[bones.hat]; if (hatLine && !art[0].trim()) { diff --git a/server/custom-art.ts b/server/custom-art.ts new file mode 100644 index 0000000..badeaea --- /dev/null +++ b/server/custom-art.ts @@ -0,0 +1,85 @@ +/** + * Custom species art — runtime-loadable skins with no code edit. + * + * The built-in SPECIES_ART / FACE_TEMPLATES in art.ts + engine.ts are a closed + * enum: adding a drawing there is a code change, and any new entry that joins + * the RNG SPECIES pool would shift every user's deterministic roll. Custom art + * deliberately sidesteps both problems — it is an OVERRIDE layer only: + * + * - Loaded from /custom-art/*.json at runtime. + * - Keyed by the JSON's "name" field. If that name matches a species a pet + * already uses, the pet renders with the custom frames instead of built-in. + * - NEVER added to the SPECIES generation pool, so existing pets are untouched. + * + * File schema (validated by cli/validate-species.ts): + * { "name": string, "art": string[][] // 3 frames x 5 lines, {E} = eye + * "face"?: string } // optional face template, {E} = eye + * + * To use one: drop a valid JSON into /custom-art/, then point a pet + * at that name (bones.species) — see cli/buddy.ts `skin` command. + */ + +import { readFileSync, readdirSync, existsSync } from "fs"; +import { join } from "path"; +import { STATE_DIR } from "./state.ts"; + +export interface CustomArt { + name: string; + art: string[][]; + face?: string; +} + +const CUSTOM_ART_DIR = join(STATE_DIR, "custom-art"); + +// Loaded once per process. The CLI and MCP server are short-lived, so a simple +// module-level cache is enough; there is no long-running watcher to invalidate. +let cache: Record | null = null; + +function isValidShape(d: unknown): d is CustomArt { + if (typeof d !== "object" || d === null) return false; + const o = d as Record; + if (typeof o.name !== "string" || o.name.length === 0) return false; + if (!Array.isArray(o.art) || o.art.length !== 3) return false; + for (const frame of o.art) { + if (!Array.isArray(frame) || frame.length !== 5) return false; + if (!frame.every((l) => typeof l === "string")) return false; + } + if (o.face !== undefined && typeof o.face !== "string") return false; + return true; +} + +/** Load every valid custom-art JSON, keyed by its declared name. */ +export function loadCustomArt(): Record { + if (cache) return cache; + const out: Record = {}; + if (existsSync(CUSTOM_ART_DIR)) { + for (const f of readdirSync(CUSTOM_ART_DIR)) { + if (!f.endsWith(".json")) continue; + try { + const data = JSON.parse(readFileSync(join(CUSTOM_ART_DIR, f), "utf8")); + if (isValidShape(data)) out[data.name] = data; + } catch { + // Skip malformed files silently — validate-species.ts is the linter. + } + } + } + cache = out; + return out; +} + +/** Return the 3-frame art array for a name, or null if no custom skin exists. */ +export function getCustomFrames(name: string): string[][] | null { + return loadCustomArt()[name]?.art ?? null; +} + +/** Return the face template for a name, or null. */ +export function getCustomFace(name: string): string | null { + return loadCustomArt()[name]?.face ?? null; +} + +/** List the names of all loaded custom skins. */ +export function listCustomArt(): string[] { + return Object.keys(loadCustomArt()); +} + +export const CUSTOM_ART_PATH = CUSTOM_ART_DIR; diff --git a/server/engine.ts b/server/engine.ts index 185129c..4ac63b4 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -402,7 +402,10 @@ const FACE_TEMPLATES: Record = { }; export function renderFace(species: Species, eye: Eye): string { - return FACE_TEMPLATES[species].replace(/\{E\}/g, eye); + const { getCustomFace } = + require("./custom-art.ts") as typeof import("./custom-art.ts"); + const tmpl = getCustomFace(species) ?? FACE_TEMPLATES[species]; + return (tmpl ?? "({E})").replace(/\{E\}/g, eye); } export function renderBuddy(bones: BuddyBones): string { diff --git a/skills/buddy/SKILL.md b/skills/buddy/SKILL.md index 2df8a44..ec71cd0 100644 --- a/skills/buddy/SKILL.md +++ b/skills/buddy/SKILL.md @@ -33,44 +33,61 @@ Handle the user's `/buddy` command using the claude-buddy MCP tools. 4. **Do not proceed with any buddy command in this session.** Tell the user: the MCP server is not running, here is what the diagnostic found, here is the recommended fix, and Claude Code must be restarted after the fix before buddy tools will be available. -## Command Routing - -Based on `$ARGUMENTS`: - -| Input | Action | -| ------------------------ | -------------------------------------------------------------------------------------------- | -| _(empty)_ or `show` | Call `buddy_show` | -| `help` | Call `buddy_help` | -| `pet` | Call `buddy_pet` | -| `stats` | Call `buddy_stats` | -| `off` | Call `buddy_mute` | -| `on` | Call `buddy_unmute` | -| `rename ` | Call `buddy_rename` with the given name | -| `personality ` | Call `buddy_set_personality` with the given text | -| `achievements` | Call `buddy_achievements` | -| `summon` | Call `buddy_summon` with no args — picks a random saved buddy | -| `summon ` | Call `buddy_summon` with the given slot name | -| `save [slot]` | Call `buddy_save` with optional slot name | -| `list` | Call `buddy_list` | -| `dismiss ` | Call `buddy_dismiss` with the slot name | -| `pick` | Tell user to run `! bun run pick` from the claude-buddy directory (launches interactive TUI) | -| `frequency` | Call `buddy_frequency` with no args (show current) | -| `frequency ` | Call `buddy_frequency` with cooldown=seconds | -| `style` | Call `buddy_style` with no args (show current) | -| `style ` | Call `buddy_style` with style arg | -| `position` | Call `buddy_style` with no args (show current) | -| `position ` | Call `buddy_style` with position arg | -| `rarity on` | Call `buddy_style` with showRarity=true | -| `rarity off` | Call `buddy_style` with showRarity=false | -| `rainbow` | Call `buddy_style` with no args (show current rainbow) | -| `rainbow <#hex> ...` | Call `buddy_style` with rainbow=[...hex colors] to set shiny gradient | -| `rainbow reset` | Call `buddy_style` with rainbow=[] to restore default ROYGBIV | -| `statusline` | Call `buddy_statusline` with no args (show current) | -| `statusline on` | Call `buddy_statusline` with enabled=true | -| `statusline off` | Call `buddy_statusline` with enabled=false | -| `statusline combined` | Call `buddy_statusline` with combined=true (adds rate-limit usage bars, needs python3) | -| `statusline basic` | Call `buddy_statusline` with combined=false (buddy only, no rate-limit bars) | -| `uninstall` | Run the uninstall sequence (see **Uninstall Orchestration** below) | +## Routing: run the LLM-free dispatcher + +**Every typed `/buddy` command is a pure config/state operation — route it to the +CLI dispatcher, not an MCP tool.** The dispatcher (`cli/buddy.ts`) mirrors each +MCP tool exactly: same state writes, same output strings, same achievements. It +runs as plain bun with no model round-trip. Run from the plugin directory and +output its result verbatim (CRITICAL OUTPUT RULES below still apply): + +```bash +bun run cli/buddy.ts [args] +``` + +Argument mapping (pass args positionally after the command): + +| Input | Dispatcher command | +| ------------------------ | ------------------------------------------- | +| _(empty)_ or `show` | `buddy.ts show` | +| `help` | `buddy.ts help` | +| `pet` | `buddy.ts pet` | +| `stats` | `buddy.ts stats` | +| `off` / `on` | `buddy.ts off` / `buddy.ts on` | +| `mute` / `unmute` | `buddy.ts mute` / `buddy.ts unmute` | +| `rename ` | `buddy.ts rename ` | +| `personality ` | `buddy.ts personality ` | +| `achievements` | `buddy.ts achievements` | +| `xp` | `buddy.ts xp` | +| `upgrades [id]` | `buddy.ts upgrades [id]` | +| `mood` | `buddy.ts mood` | +| `memory` | `buddy.ts memory` (flags: `--project`, `--type`, `--resolved`, `--resolve-bug `) | +| `summon [slot]` | `buddy.ts summon [slot]` | +| `save [slot]` | `buddy.ts save [slot]` | +| `list` | `buddy.ts list` | +| `dismiss ` | `buddy.ts dismiss ` | +| `skin [name]` | `buddy.ts skin [name]` | +| `frequency [seconds]` | `buddy.ts frequency [seconds]` | +| `style [classic\|round]` | `buddy.ts style [classic\|round]` | +| `position [top\|left]` | `buddy.ts position [top\|left]` | +| `rarity [on\|off]` | `buddy.ts rarity [on\|off]` | +| `width <10-60>` | `buddy.ts width ` | +| `margin <0-20>` | `buddy.ts margin ` | +| `rainbow [#hex...\|reset]`| `buddy.ts rainbow [#hex...\|reset]` | +| `statusline [on\|off\|combined\|basic]` | `buddy.ts statusline [on\|off\|combined\|basic]` | +| `theme [dark\|light\|auto]` | `buddy.ts theme [dark\|light\|auto]` | +| `pick` | Tell user to run `! bun run pick` (interactive TUI) | +| `uninstall` | Run the uninstall sequence (see **Uninstall Orchestration** below) | + +Only two things still use the model, and neither is a typed command: +- **Name mention** — if the user says the buddy's name in normal conversation, + call `buddy_react` (reason `turn`) and display it verbatim. This reads the + conversation, so it stays an MCP tool. +- **Proactive suggestions** — `buddy_suggest` on a teachable moment, and the + end-of-turn `` comment. These read your code/turn. + +If the MCP server is unavailable (see fallback above) the dispatcher still works +— it does not depend on the MCP server, only on `bun`. ## CRITICAL OUTPUT RULES