Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
773 changes: 773 additions & 0 deletions cli/buddy.ts

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions custom-art/README.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions custom-art/fox.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "fox",
"art": [
[" ", "/\\ {E}v{E} /\\ ", " \\ ^ / ", " \\___/ ", " ~=' '=~ "],
[" ", "/\\ {E}v{E} /\\ ", " \\ ^ / ", " \\___/ ", " ~=' '=~~ "],
[" ", "/| {E}v{E} /\\ ", " \\ ^ / ", " \\___/ ", " ~=' '=~ "]
],
"face": "({E}v{E})"
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -35,6 +36,7 @@
"hooks/",
"statusline/",
"scripts/",
"custom-art/",
".claude-plugin/",
"!**/*.test.ts"
],
Expand Down
8 changes: 5 additions & 3 deletions server/art.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 ──────────────────────────────────

Expand Down Expand Up @@ -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));
}
Expand All @@ -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()) {
Expand Down
85 changes: 85 additions & 0 deletions server/custom-art.ts
Original file line number Diff line number Diff line change
@@ -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 <state-dir>/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 <state-dir>/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<string, CustomArt> | null = null;

function isValidShape(d: unknown): d is CustomArt {
if (typeof d !== "object" || d === null) return false;
const o = d as Record<string, unknown>;
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<string, CustomArt> {
if (cache) return cache;
const out: Record<string, CustomArt> = {};
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;
5 changes: 4 additions & 1 deletion server/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,10 @@ const FACE_TEMPLATES: Record<Species, string> = {
};

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 {
Expand Down
93 changes: 55 additions & 38 deletions skills/buddy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` | Call `buddy_rename` with the given name |
| `personality <text>` | 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 <slot>` | Call `buddy_summon` with the given slot name |
| `save [slot]` | Call `buddy_save` with optional slot name |
| `list` | Call `buddy_list` |
| `dismiss <slot>` | 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 <seconds>` | Call `buddy_frequency` with cooldown=seconds |
| `style` | Call `buddy_style` with no args (show current) |
| `style <classic\|round>` | Call `buddy_style` with style arg |
| `position` | Call `buddy_style` with no args (show current) |
| `position <top\|left>` | 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 <command> [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 <name>` | `buddy.ts rename <name>` |
| `personality <text>` | `buddy.ts personality <text>` |
| `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 <id>`) |
| `summon [slot]` | `buddy.ts summon [slot]` |
| `save [slot]` | `buddy.ts save [slot]` |
| `list` | `buddy.ts list` |
| `dismiss <slot>` | `buddy.ts dismiss <slot>` |
| `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 <n>` |
| `margin <0-20>` | `buddy.ts margin <n>` |
| `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 `<!-- buddy: -->` 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

Expand Down