@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/cli/hunt.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * claude-buddy hunt — brute-force search for a specific buddy
3
+ *
4
+ * Rules:
5
+ * - Asks for a name before saving
6
+ * - If no name given, picks a random unused name from the manifest
7
+ * - Appends to the manifest — never overwrites an existing slot
8
+ */
9
+
10
+ import {
11
+ searchBuddy, renderBuddy, SPECIES, RARITIES, STAT_NAMES,
12
+ type Species, type Rarity, type StatName, type SearchCriteria,
13
+ } from "../server/engine.ts";
14
+ import {
15
+ saveCompanionSlot, saveActiveSlot, writeStatusState,
16
+ slugify, unusedName, listCompanionSlots,
17
+ } from "../server/state.ts";
18
+ import { createInterface } from "readline";
19
+
20
+ const CYAN = "\x1b[36m";
21
+ const GREEN = "\x1b[32m";
22
+ const YELLOW = "\x1b[33m";
23
+ const RED = "\x1b[31m";
24
+ const BOLD = "\x1b[1m";
25
+ const DIM = "\x1b[2m";
26
+ const NC = "\x1b[0m";
27
+
28
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
29
+
30
+ function ask(prompt: string): Promise<string> {
31
+ return new Promise((resolve) => rl.question(prompt, resolve));
32
+ }
33
+
34
+ function pickFromList<T extends string>(label: string, items: readonly T[]): Promise<T> {
35
+ return new Promise(async (resolve) => {
36
+ console.log(`\n${BOLD}${label}${NC}`);
37
+ items.forEach((item, i) => console.log(` ${CYAN}${(i + 1).toString().padStart(2)}${NC}) ${item}`));
38
+ while (true) {
39
+ const ans = await ask(`\n Choice [1-${items.length}]: `);
40
+ const idx = parseInt(ans) - 1;
41
+ if (idx >= 0 && idx < items.length) { resolve(items[idx]); return; }
42
+ console.log(` ${RED}Invalid. Enter 1-${items.length}.${NC}`);
43
+ }
44
+ });
45
+ }
46
+
47
+ async function main() {
48
+ console.log(`
49
+ ${CYAN}╔══════════════════════════════════════════════════════════╗${NC}
50
+ ${CYAN}║${NC} ${BOLD}claude-buddy hunt${NC} — find your perfect companion ${CYAN}║${NC}
51
+ ${CYAN}╚══════════════════════════════════════════════════════════╝${NC}
52
+ `);
53
+
54
+ const species = await pickFromList("Species:", SPECIES);
55
+ console.log(`${GREEN}✓${NC} ${species}`);
56
+
57
+ const rarity = await pickFromList("Rarity:", RARITIES);
58
+ console.log(`${GREEN}✓${NC} ${rarity}`);
59
+
60
+ const shinyAns = await ask(`\n Shiny? (much longer search) [y/N]: `);
61
+ const wantShiny = shinyAns.toLowerCase() === "y";
62
+ console.log(`${GREEN}✓${NC} shiny: ${wantShiny ? "yes" : "any"}`);
63
+
64
+ let wantPeak: StatName | undefined;
65
+ let wantDump: StatName | undefined;
66
+
67
+ const statsAns = await ask(`\n Configure stats? [Y/n]: `);
68
+ if (statsAns.toLowerCase() !== "n") {
69
+ wantPeak = await pickFromList("Peak stat (highest):", STAT_NAMES);
70
+ const dumpOptions = STAT_NAMES.filter((s) => s !== wantPeak);
71
+ wantDump = await pickFromList("Dump stat (lowest):", dumpOptions as any);
72
+ console.log(`${GREEN}✓${NC} peak=${wantPeak} dump=${wantDump}`);
73
+ }
74
+
75
+ let maxAttempts = 10_000_000;
76
+ if (rarity === "legendary") maxAttempts = 200_000_000;
77
+ else if (rarity === "epic") maxAttempts = 50_000_000;
78
+ if (wantShiny) maxAttempts *= 3;
79
+
80
+ console.log(`\n${DIM} Max attempts: ${(maxAttempts / 1e6).toFixed(0)}M${NC}`);
81
+
82
+ const startAns = await ask(`\n Start search? [Y/n]: `);
83
+ if (startAns.toLowerCase() === "n") { rl.close(); return; }
84
+
85
+ console.log(`\n${CYAN}→${NC} Searching...\n`);
86
+
87
+ const criteria: SearchCriteria = { species, rarity, wantShiny };
88
+ if (wantPeak) criteria.wantPeak = wantPeak;
89
+ if (wantDump) criteria.wantDump = wantDump;
90
+
91
+ const results = searchBuddy(criteria, maxAttempts, (checked, found) => {
92
+ process.stderr.write(`\r ${(checked / 1e6).toFixed(0)}M checked, ${found} matches `);
93
+ });
94
+
95
+ console.log("");
96
+
97
+ if (results.length === 0) {
98
+ console.log(`${RED}✗${NC} No matches found. Try less restrictive criteria.`);
99
+ rl.close();
100
+ return;
101
+ }
102
+
103
+ console.log(`${GREEN}✓${NC} ${results.length} matches found!\n`);
104
+
105
+ // Show top 5
106
+ const top = results.slice(0, 5);
107
+ for (let i = 0; i < top.length; i++) {
108
+ const r = top[i];
109
+ const stats = STAT_NAMES.map((n) => `${n.slice(0, 3)}:${r.bones.stats[n]}`).join(" ");
110
+ const shiny = r.bones.shiny ? "✨ " : " ";
111
+ console.log(` ${CYAN}${i + 1}${NC}) ${shiny}eye=${r.bones.eye} hat=${r.bones.hat} ${stats}`);
112
+ }
113
+
114
+ const pickAns = await ask(`\n Apply which? [1-${top.length}, q to cancel]: `);
115
+ if (pickAns === "q") { rl.close(); return; }
116
+
117
+ const pickIdx = parseInt(pickAns) - 1;
118
+ if (pickIdx < 0 || pickIdx >= top.length) {
119
+ console.log(`${RED}Invalid.${NC}`);
120
+ rl.close();
121
+ return;
122
+ }
123
+
124
+ const chosen = top[pickIdx];
125
+ console.log(`\n${renderBuddy(chosen.bones)}\n`);
126
+
127
+ // ─── Ask for a name ────────────────────────────────────────────────────────
128
+ const existing = new Set(listCompanionSlots().map((e) => slugify(e.companion.name)));
129
+ const suggested = unusedName();
130
+ console.log(`\n${DIM} Existing buddies: ${[...existing].join(", ") || "none"}${NC}`);
131
+
132
+ let chosenName = "";
133
+ while (true) {
134
+ const raw = await ask(
135
+ ` Name this buddy (Enter for "${suggested}"): `,
136
+ );
137
+ chosenName = raw.trim() || suggested;
138
+ const slot = slugify(chosenName);
139
+ if (existing.has(slot)) {
140
+ console.log(` ${YELLOW}⚠${NC} Slot "${slot}" already taken — pick another name.`);
141
+ } else {
142
+ break;
143
+ }
144
+ }
145
+
146
+ const slot = slugify(chosenName);
147
+ const companion = {
148
+ bones: chosen.bones,
149
+ name: chosenName,
150
+ personality: `A ${chosen.bones.rarity} ${chosen.bones.species} who watches code with quiet intensity.`,
151
+ hatchedAt: Date.now(),
152
+ userId: chosen.userId,
153
+ };
154
+
155
+ saveCompanionSlot(companion, slot);
156
+ saveActiveSlot(slot);
157
+ writeStatusState(companion, `*${chosenName} arrives*`);
158
+
159
+ console.log(`${GREEN}✓${NC} ${chosenName} saved to slot "${slot}" and set as active.`);
160
+ console.log(`\n${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}`);
161
+ console.log(`${GREEN} Done! Restart Claude Code to see your new buddy.${NC}`);
162
+ console.log(`${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n`);
163
+
164
+ rl.close();
165
+ }
166
+
167
+ main();
package/cli/index.ts ADDED
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * claude-buddy CLI
4
+ *
5
+ * Usage:
6
+ * npx claude-buddy Interactive install
7
+ * npx claude-buddy install Install MCP + skill + hooks + statusline
8
+ * npx claude-buddy show Show current buddy
9
+ * npx claude-buddy pick Interactive two-pane buddy picker (saved + search)
10
+ * npx claude-buddy hunt Search for a specific buddy (non-interactive)
11
+ * npx claude-buddy uninstall Remove all integrations
12
+ * npx claude-buddy verify Verify what buddy your ID produces
13
+ */
14
+
15
+ const args = process.argv.slice(2);
16
+ const command = args[0] || "install";
17
+
18
+ switch (command) {
19
+ case "install":
20
+ await import("./install.ts");
21
+ break;
22
+ case "show":
23
+ await import("./show.ts");
24
+ break;
25
+ case "pick":
26
+ await import("./pick.ts");
27
+ break;
28
+ case "hunt":
29
+ await import("./hunt.ts");
30
+ break;
31
+ case "uninstall":
32
+ await import("./uninstall.ts");
33
+ break;
34
+ case "verify":
35
+ await import("./verify.ts");
36
+ break;
37
+ case "doctor":
38
+ await import("./doctor.ts");
39
+ break;
40
+ case "test-statusline":
41
+ await import("./test-statusline.ts");
42
+ break;
43
+ case "backup":
44
+ await import("./backup.ts");
45
+ break;
46
+ case "settings":
47
+ await import("./settings.ts");
48
+ break;
49
+ case "disable":
50
+ await import("./disable.ts");
51
+ break;
52
+ case "enable":
53
+ await import("./install.ts");
54
+ break;
55
+ case "help":
56
+ case "--help":
57
+ case "-h":
58
+ showHelp();
59
+ break;
60
+ default:
61
+ console.error(`Unknown command: ${command}\n`);
62
+ showHelp();
63
+ process.exit(1);
64
+ }
65
+
66
+ function showHelp() {
67
+ console.log(`
68
+ claude-buddy — permanent coding companion for Claude Code
69
+
70
+ Setup:
71
+ install-buddy Set up MCP server, skill, hooks, and status line
72
+ enable Same as install-buddy (re-enable after disable)
73
+ disable Temporarily deactivate buddy (data preserved)
74
+ uninstall Remove all claude-buddy integrations
75
+
76
+ Buddy:
77
+ show Display your current buddy
78
+ pick Interactive two-pane buddy picker (browse saved + search)
79
+ hunt Search for a specific buddy (non-interactive)
80
+ verify Verify what buddy your current ID produces
81
+
82
+ Settings:
83
+ settings Show current settings
84
+ settings cooldown <n> Set comment cooldown (0-300 seconds)
85
+ settings ttl <n> Set reaction display duration (0-300s, 0 = permanent)
86
+
87
+ Diagnostics:
88
+ doctor Run diagnostic report (paste output in bug reports)
89
+ test-statusline Test status line rendering in Claude Code
90
+ backup Snapshot or restore all claude-buddy state
91
+
92
+ In Claude Code:
93
+ /buddy Show companion card with ASCII art + stats
94
+ /buddy pet Pet your companion
95
+ /buddy stats Detailed stat card
96
+ /buddy off Mute reactions
97
+ /buddy on Unmute reactions
98
+ /buddy rename Rename companion (1-14 chars)
99
+ /buddy personality Set custom personality text
100
+ /buddy summon Summon a saved buddy (omit slot for random)
101
+ /buddy save Save current buddy to a named slot
102
+ /buddy list List all saved buddies
103
+ /buddy dismiss Remove a saved buddy slot
104
+ /buddy pick Launch interactive TUI picker (! bun run pick)
105
+ /buddy frequency Show or set comment cooldown (tmux only)
106
+ /buddy style Show or set bubble style (tmux only)
107
+ /buddy position Show or set bubble position (tmux only)
108
+ /buddy rarity Show or hide rarity stars (tmux only)
109
+
110
+ Usage:
111
+ bun run <command> e.g. bun run show, bun run doctor
112
+ claude-buddy <command> if globally linked (bun link)
113
+ bun run help Show this help
114
+ `);
115
+ }
package/cli/install.ts ADDED
@@ -0,0 +1,335 @@
1
+ /**
2
+ * claude-buddy installer
3
+ *
4
+ * Registers: MCP server (in ~/.claude.json), skill, hooks, status line (in settings.json)
5
+ * Checks: bun, jq, ~/.claude/ directory
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, cpSync } from "fs";
9
+ import { execSync } from "child_process";
10
+ import { join, resolve, dirname } from "path";
11
+ import { homedir } from "os";
12
+
13
+ import { generateBones, renderBuddy, renderFace, RARITY_STARS } from "../server/engine.ts";
14
+ import { loadCompanion, saveCompanion, resolveUserId, writeStatusState } from "../server/state.ts";
15
+ import { generateFallbackName } from "../server/reactions.ts";
16
+
17
+ const CYAN = "\x1b[36m";
18
+ const GREEN = "\x1b[32m";
19
+ const YELLOW = "\x1b[33m";
20
+ const RED = "\x1b[31m";
21
+ const BOLD = "\x1b[1m";
22
+ const DIM = "\x1b[2m";
23
+ const NC = "\x1b[0m";
24
+
25
+ const CLAUDE_DIR = join(homedir(), ".claude");
26
+ const SETTINGS_FILE = join(CLAUDE_DIR, "settings.json");
27
+ const BUDDY_DIR = join(CLAUDE_DIR, "skills", "buddy");
28
+ const PROJECT_ROOT = resolve(dirname(import.meta.dir));
29
+
30
+ function banner() {
31
+ console.log(`
32
+ ${CYAN}╔══════════════════════════════════════════════════════════╗${NC}
33
+ ${CYAN}║${NC} ${BOLD}claude-buddy${NC} — permanent coding companion ${CYAN}║${NC}
34
+ ${CYAN}║${NC} ${DIM}MCP + Skill + StatusLine + Hooks${NC} ${CYAN}║${NC}
35
+ ${CYAN}╚══════════════════════════════════════════════════════════╝${NC}
36
+ `);
37
+ }
38
+
39
+ function ok(msg: string) { console.log(`${GREEN}✓${NC} ${msg}`); }
40
+ function info(msg: string) { console.log(`${CYAN}→${NC} ${msg}`); }
41
+ function warn(msg: string) { console.log(`${YELLOW}⚠${NC} ${msg}`); }
42
+ function err(msg: string) { console.log(`${RED}✗${NC} ${msg}`); }
43
+
44
+ // ─── Preflight checks ──────────────────────────────────────────────────────
45
+
46
+ function preflight(): boolean {
47
+ let pass = true;
48
+
49
+ // Check bun
50
+ try {
51
+ execSync("bun --version", { stdio: "ignore" });
52
+ ok("bun found");
53
+ } catch {
54
+ err("bun not found. Install: curl -fsSL https://bun.sh/install | bash");
55
+ pass = false;
56
+ }
57
+
58
+ // Check jq (needed for status line + hooks)
59
+ try {
60
+ execSync("jq --version", { stdio: "ignore" });
61
+ ok("jq found");
62
+ } catch {
63
+ warn("jq not found — installing...");
64
+ try {
65
+ execSync("sudo apt-get install -y jq 2>/dev/null || brew install jq 2>/dev/null", { stdio: "ignore" });
66
+ ok("jq installed");
67
+ } catch {
68
+ err("Could not install jq. Install manually: apt install jq / brew install jq");
69
+ pass = false;
70
+ }
71
+ }
72
+
73
+ // Check ~/.claude/ exists
74
+ if (!existsSync(CLAUDE_DIR)) {
75
+ err("~/.claude/ not found. Start Claude Code once first, then re-run.");
76
+ pass = false;
77
+ } else {
78
+ ok("~/.claude/ found");
79
+ }
80
+
81
+ // Check ~/.claude.json exists
82
+ const claudeJson = join(homedir(), ".claude.json");
83
+ if (!existsSync(claudeJson)) {
84
+ err("~/.claude.json not found. Start Claude Code once first, then re-run.");
85
+ pass = false;
86
+ } else {
87
+ ok("~/.claude.json found");
88
+ }
89
+
90
+ return pass;
91
+ }
92
+
93
+ // ─── Load / update settings.json ────────────────────────────────────────────
94
+
95
+ function loadSettings(): Record<string, any> {
96
+ try {
97
+ return JSON.parse(readFileSync(SETTINGS_FILE, "utf8"));
98
+ } catch {
99
+ return {};
100
+ }
101
+ }
102
+
103
+ function saveSettings(settings: Record<string, any>) {
104
+ mkdirSync(CLAUDE_DIR, { recursive: true });
105
+ writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
106
+ }
107
+
108
+ // ─── Step 1: Register MCP server (in ~/.claude.json) ────────────────────────
109
+
110
+ function installMcp() {
111
+ const serverPath = join(PROJECT_ROOT, "server", "index.ts");
112
+ const claudeJsonPath = join(homedir(), ".claude.json");
113
+
114
+ let claudeJson: Record<string, any> = {};
115
+ try {
116
+ claudeJson = JSON.parse(readFileSync(claudeJsonPath, "utf8"));
117
+ } catch { /* fresh config */ }
118
+
119
+ if (!claudeJson.mcpServers) claudeJson.mcpServers = {};
120
+
121
+ claudeJson.mcpServers["claude-buddy"] = {
122
+ command: "bun",
123
+ args: [serverPath],
124
+ cwd: PROJECT_ROOT,
125
+ };
126
+
127
+ writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
128
+ ok("MCP server registered in ~/.claude.json");
129
+ }
130
+
131
+ // ─── Step 2: Install skill ──────────────────────────────────────────────────
132
+
133
+ function installSkill() {
134
+ const srcSkill = join(PROJECT_ROOT, "skills", "buddy", "SKILL.md");
135
+ mkdirSync(BUDDY_DIR, { recursive: true });
136
+ cpSync(srcSkill, join(BUDDY_DIR, "SKILL.md"), { force: true });
137
+ ok("Skill installed: ~/.claude/skills/buddy/SKILL.md");
138
+ }
139
+
140
+ // ─── Step 3: Configure status line (with animation refresh) ─────────────────
141
+
142
+ function installStatusLine(settings: Record<string, any>) {
143
+ const statusScript = join(PROJECT_ROOT, "statusline", "buddy-status.sh");
144
+
145
+ settings.statusLine = {
146
+ type: "command",
147
+ command: statusScript,
148
+ padding: 1,
149
+ refreshInterval: 1, // 1 second — drives the buddy animation
150
+ };
151
+
152
+ ok("Status line configured (with animation refresh)");
153
+ }
154
+
155
+ // ─── Step 3b: Configure tmux popup mode (if in tmux) ────────────────────────
156
+
157
+ function detectTmux(): boolean {
158
+ if (!process.env.TMUX) return false;
159
+ try {
160
+ const ver = execSync("tmux -V 2>/dev/null", { encoding: "utf8" }).trim();
161
+ const match = ver.match(/(\d+)\.(\d+)/);
162
+ if (!match) return false;
163
+ const major = parseInt(match[1]), minor = parseInt(match[2]);
164
+ return major > 3 || (major === 3 && minor >= 2);
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ function installPopupHooks(settings: Record<string, any>) {
171
+ const popupManager = join(PROJECT_ROOT, "popup", "popup-manager.sh");
172
+
173
+ if (!settings.hooks) settings.hooks = {};
174
+
175
+ // SessionStart: open popup
176
+ if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
177
+ settings.hooks.SessionStart = settings.hooks.SessionStart.filter(
178
+ (h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("claude-buddy")),
179
+ );
180
+ settings.hooks.SessionStart.push({
181
+ hooks: [{ type: "command", command: `${popupManager} start` }],
182
+ });
183
+
184
+ // SessionEnd: close popup
185
+ if (!settings.hooks.SessionEnd) settings.hooks.SessionEnd = [];
186
+ settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter(
187
+ (h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("claude-buddy")),
188
+ );
189
+ settings.hooks.SessionEnd.push({
190
+ hooks: [{ type: "command", command: `${popupManager} stop` }],
191
+ });
192
+
193
+ ok("Popup hooks registered: SessionStart + SessionEnd");
194
+ }
195
+
196
+ // ─── Step 4: Register hooks ─────────────────────────────────────────────────
197
+
198
+ function installHooks(settings: Record<string, any>) {
199
+ const reactHook = join(PROJECT_ROOT, "hooks", "react.sh");
200
+ const commentHook = join(PROJECT_ROOT, "hooks", "buddy-comment.sh");
201
+ const nameHook = join(PROJECT_ROOT, "hooks", "name-react.sh");
202
+
203
+ if (!settings.hooks) settings.hooks = {};
204
+
205
+ // PostToolUse: detect errors/test failures/successes in Bash output
206
+ if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
207
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
208
+ (h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("claude-buddy")),
209
+ );
210
+ settings.hooks.PostToolUse.push({
211
+ matcher: "Bash",
212
+ hooks: [{ type: "command", command: reactHook }],
213
+ });
214
+
215
+ // Stop: extract <!-- buddy: --> comment from Claude's response
216
+ if (!settings.hooks.Stop) settings.hooks.Stop = [];
217
+ settings.hooks.Stop = settings.hooks.Stop.filter(
218
+ (h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("claude-buddy")),
219
+ );
220
+ settings.hooks.Stop.push({
221
+ hooks: [{ type: "command", command: commentHook }],
222
+ });
223
+
224
+ // UserPromptSubmit: detect buddy's name in user message → instant status line reaction
225
+ if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = [];
226
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
227
+ (h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("claude-buddy")),
228
+ );
229
+ settings.hooks.UserPromptSubmit.push({
230
+ hooks: [{ type: "command", command: nameHook }],
231
+ });
232
+
233
+ ok("Hooks registered: PostToolUse + Stop + UserPromptSubmit");
234
+ }
235
+
236
+ // ─── Step 5: Ensure MCP tools are allowed ───────────────────────────────────
237
+
238
+ function ensurePermissions(settings: Record<string, any>) {
239
+ if (!settings.permissions) settings.permissions = {};
240
+ if (!settings.permissions.allow) settings.permissions.allow = [];
241
+
242
+ const allow: string[] = settings.permissions.allow;
243
+ if (!allow.includes("mcp__*") && !allow.some((p: string) => p.startsWith("mcp__claude_buddy"))) {
244
+ allow.push("mcp__claude_buddy__*");
245
+ ok("Permission added: mcp__claude_buddy__*");
246
+ } else {
247
+ ok("MCP permissions already configured");
248
+ }
249
+ }
250
+
251
+ // ─── Step 6: Initialize companion ───────────────────────────────────────────
252
+
253
+ function initCompanion() {
254
+ let companion = loadCompanion();
255
+ if (companion) {
256
+ info(`Existing companion found: ${companion.name} (${companion.bones.rarity} ${companion.bones.species})`);
257
+ return companion;
258
+ }
259
+
260
+ const userId = resolveUserId();
261
+ info(`Generating companion from user ID: ${userId.slice(0, 12)}...`);
262
+
263
+ const bones = generateBones(userId);
264
+ companion = {
265
+ bones,
266
+ name: generateFallbackName(),
267
+ personality: `A ${bones.rarity} ${bones.species} who watches code with quiet intensity.`,
268
+ hatchedAt: Date.now(),
269
+ userId,
270
+ };
271
+
272
+ saveCompanion(companion);
273
+ writeStatusState(companion);
274
+ ok(`Companion hatched: ${companion.name}`);
275
+
276
+ return companion;
277
+ }
278
+
279
+ // ─── Main ───────────────────────────────────────────────────────────────────
280
+
281
+ banner();
282
+
283
+ info("Checking requirements...\n");
284
+ if (!preflight()) {
285
+ console.log(`\n${RED}Installation aborted. Fix the issues above and retry.${NC}\n`);
286
+ process.exit(1);
287
+ }
288
+
289
+ console.log("");
290
+ info("Installing claude-buddy...\n");
291
+
292
+ const settings = loadSettings();
293
+
294
+ installMcp();
295
+ installSkill();
296
+
297
+ const useTmuxPopup = detectTmux();
298
+ if (useTmuxPopup) {
299
+ info("tmux detected (>= 3.2) -- using popup overlay mode");
300
+ installPopupHooks(settings);
301
+ // Disable status line to avoid duplicate buddy rendering
302
+ if (settings.statusLine?.command?.includes("buddy")) {
303
+ delete settings.statusLine;
304
+ ok("Status line disabled (popup replaces it)");
305
+ }
306
+ } else {
307
+ installStatusLine(settings);
308
+ }
309
+
310
+ installHooks(settings);
311
+ ensurePermissions(settings);
312
+ saveSettings(settings);
313
+
314
+ console.log("");
315
+ const companion = initCompanion();
316
+
317
+ console.log("");
318
+ console.log(renderBuddy(companion.bones));
319
+ console.log("");
320
+ console.log(` ${BOLD}${companion.name}${NC} -- ${companion.personality}`);
321
+ console.log("");
322
+
323
+ const modeMsg = useTmuxPopup ? "popup overlay" : "status line";
324
+ console.log(`${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}`);
325
+ console.log(`${GREEN} Done! Restart Claude Code and type /buddy${NC}`);
326
+ console.log(`${GREEN} Display mode: ${modeMsg}${NC}`);
327
+ console.log(`${GREEN} Your companion is now permanent -- survives any update.${NC}`);
328
+ console.log(`${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}`);
329
+ console.log("");
330
+ console.log(`${DIM} /buddy show your companion`);
331
+ console.log(` /buddy pet pet your companion`);
332
+ console.log(` /buddy stats detailed stat card`);
333
+ console.log(` /buddy off mute reactions`);
334
+ console.log(` /buddy on unmute reactions${NC}`);
335
+ console.log("");