@ramarivera/coding-buddy 0.4.0-alpha.2 → 0.4.0-alpha.3

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.
Files changed (52) hide show
  1. package/README.md +4 -5
  2. package/{hooks → adapters/claude/hooks}/hooks.json +3 -3
  3. package/{cli → adapters/claude/install}/backup.ts +3 -3
  4. package/{cli → adapters/claude/install}/disable.ts +22 -2
  5. package/{cli → adapters/claude/install}/doctor.ts +30 -23
  6. package/{cli → adapters/claude/install}/hunt.ts +4 -4
  7. package/{cli → adapters/claude/install}/install.ts +62 -26
  8. package/{cli → adapters/claude/install}/pick.ts +3 -3
  9. package/{cli → adapters/claude/install}/settings.ts +1 -1
  10. package/{cli → adapters/claude/install}/show.ts +2 -2
  11. package/{cli → adapters/claude/install}/uninstall.ts +22 -2
  12. package/{.claude-plugin → adapters/claude/plugin}/plugin.json +1 -1
  13. package/adapters/claude/popup/buddy-popup.sh +92 -0
  14. package/adapters/claude/popup/buddy-render.sh +540 -0
  15. package/adapters/claude/popup/popup-manager.sh +355 -0
  16. package/{server → adapters/claude/rendering}/art.ts +3 -115
  17. package/{server → adapters/claude/server}/index.ts +49 -71
  18. package/adapters/claude/server/instructions.ts +24 -0
  19. package/adapters/claude/server/resources.ts +38 -0
  20. package/adapters/claude/storage/achievements.ts +253 -0
  21. package/adapters/claude/storage/identity.ts +14 -0
  22. package/adapters/claude/storage/settings.ts +42 -0
  23. package/{server → adapters/claude/storage}/state.ts +3 -65
  24. package/adapters/pi/README.md +64 -0
  25. package/adapters/pi/commands.ts +173 -0
  26. package/adapters/pi/events.ts +150 -0
  27. package/adapters/pi/identity.ts +10 -0
  28. package/adapters/pi/index.ts +25 -0
  29. package/adapters/pi/renderers.ts +73 -0
  30. package/adapters/pi/storage.ts +295 -0
  31. package/adapters/pi/tools.ts +6 -0
  32. package/adapters/pi/ui.ts +39 -0
  33. package/cli/index.ts +11 -11
  34. package/cli/verify.ts +2 -2
  35. package/core/achievements.ts +203 -0
  36. package/core/art-data.ts +105 -0
  37. package/core/command-service.ts +338 -0
  38. package/core/model.ts +59 -0
  39. package/core/ports.ts +40 -0
  40. package/core/render-model.ts +10 -0
  41. package/package.json +23 -19
  42. package/server/achievements.ts +0 -445
  43. /package/{hooks → adapters/claude/hooks}/buddy-comment.sh +0 -0
  44. /package/{hooks → adapters/claude/hooks}/name-react.sh +0 -0
  45. /package/{hooks → adapters/claude/hooks}/react.sh +0 -0
  46. /package/{cli → adapters/claude/install}/test-statusline.sh +0 -0
  47. /package/{cli → adapters/claude/install}/test-statusline.ts +0 -0
  48. /package/{.claude-plugin → adapters/claude/plugin}/marketplace.json +0 -0
  49. /package/{skills → adapters/claude/skills}/buddy/SKILL.md +0 -0
  50. /package/{statusline → adapters/claude/statusline}/buddy-status.sh +0 -0
  51. /package/{server → core}/engine.ts +0 -0
  52. /package/{server → core}/reactions.ts +0 -0
package/README.md CHANGED
@@ -196,11 +196,10 @@ MCP is an industry-standard protocol. Skills are Markdown files. Hooks and statu
196
196
 
197
197
  ```
198
198
  claude-buddy/
199
- ├── server/ # MCP servertools, engine, art, reactions, state
200
- ├── skills/buddy/ # /buddy slash command
201
- ├── hooks/ # PostToolUse + Stop hooks (error & comment detection)
202
- ├── statusline/ # Animated right-aligned buddy display
203
- └── cli/ # install, show, hunt, verify, doctor, backup, uninstall
199
+ ├── core/ # shared buddy domain logic — engine, reactions, achievements, models
200
+ ├── adapters/claude/ # Claude adapter — MCP server, storage, skills, hooks, UI, installer
201
+ ├── adapters/pi/ # pi adapter native pi extension (planned)
202
+ └── cli/ # host-agnostic CLI entrypoints/utilities
204
203
  ```
205
204
 
206
205
  </details>
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "./hooks/react.sh"
9
+ "command": "./adapters/claude/hooks/react.sh"
10
10
  }
11
11
  ]
12
12
  }
@@ -16,7 +16,7 @@
16
16
  "hooks": [
17
17
  {
18
18
  "type": "command",
19
- "command": "./hooks/buddy-comment.sh"
19
+ "command": "./adapters/claude/hooks/buddy-comment.sh"
20
20
  }
21
21
  ]
22
22
  }
@@ -26,7 +26,7 @@
26
26
  "hooks": [
27
27
  {
28
28
  "type": "command",
29
- "command": "./hooks/name-react.sh"
29
+ "command": "./adapters/claude/hooks/name-react.sh"
30
30
  }
31
31
  ]
32
32
  }
@@ -73,7 +73,7 @@ function createBackup(): string {
73
73
  const dir = join(BACKUPS_DIR, ts);
74
74
  mkdirSync(dir, { recursive: true });
75
75
 
76
- const manifest: Record<string, any> = { timestamp: ts, files: [] };
76
+ const manifest: { timestamp: string; files: string[] } = { timestamp: ts, files: [] };
77
77
 
78
78
  // 1. settings.json
79
79
  const settings = tryRead(SETTINGS);
@@ -202,9 +202,9 @@ function restoreBackup(ts: string) {
202
202
  const mcpBak = join(dir, "mcpserver.json");
203
203
  if (existsSync(mcpBak)) {
204
204
  const ourMcp = JSON.parse(readFileSync(mcpBak, "utf8"));
205
- let claudeJson: Record<string, any> = {};
205
+ let claudeJson: { mcpServers?: Record<string, unknown> } = {};
206
206
  try {
207
- claudeJson = JSON.parse(readFileSync(CLAUDE_JSON, "utf8"));
207
+ claudeJson = JSON.parse(readFileSync(CLAUDE_JSON, "utf8")) as { mcpServers?: Record<string, unknown> };
208
208
  } catch { /* empty */ }
209
209
  if (!claudeJson.mcpServers) claudeJson.mcpServers = {};
210
210
  claudeJson.mcpServers["claude-buddy"] = ourMcp;
@@ -12,6 +12,26 @@ import { readFileSync, writeFileSync, existsSync } from "fs";
12
12
  import { join } from "path";
13
13
  import { homedir } from "os";
14
14
 
15
+ interface HookCommand {
16
+ type: "command";
17
+ command: string;
18
+ }
19
+
20
+ interface HookMatcherEntry {
21
+ matcher?: string;
22
+ hooks?: HookCommand[];
23
+ }
24
+
25
+ interface ClaudeSettings {
26
+ statusLine?: { command?: string };
27
+ hooks?: Record<string, HookMatcherEntry[]>;
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ function hasBuddyHook(entry: HookMatcherEntry): boolean {
32
+ return (entry.hooks ?? []).some((hook) => hook.command.includes("claude-buddy"));
33
+ }
34
+
15
35
  const GREEN = "\x1b[32m";
16
36
  const YELLOW = "\x1b[33m";
17
37
  const BOLD = "\x1b[1m";
@@ -44,7 +64,7 @@ try {
44
64
 
45
65
  // 2. Remove status line + hooks from settings.json
46
66
  try {
47
- const settings = JSON.parse(readFileSync(SETTINGS, "utf8"));
67
+ const settings = JSON.parse(readFileSync(SETTINGS, "utf8")) as ClaudeSettings;
48
68
  let changed = false;
49
69
 
50
70
  if (settings.statusLine?.command?.includes("buddy")) {
@@ -58,7 +78,7 @@ try {
58
78
  if (settings.hooks[hookType]) {
59
79
  const before = settings.hooks[hookType].length;
60
80
  settings.hooks[hookType] = settings.hooks[hookType].filter(
61
- (h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("claude-buddy")),
81
+ (h) => !hasBuddyHook(h),
62
82
  );
63
83
  if (settings.hooks[hookType].length < before) changed = true;
64
84
  if (settings.hooks[hookType].length === 0) delete settings.hooks[hookType];
@@ -10,12 +10,12 @@
10
10
 
11
11
  import { readFileSync, existsSync, statSync } from "fs";
12
12
  import { execSync } from "child_process";
13
- import { join, resolve, dirname } from "path";
13
+ import { join, resolve } from "path";
14
14
  import { homedir } from "os";
15
15
 
16
- const PROJECT_ROOT = resolve(dirname(import.meta.dir));
16
+ const PROJECT_ROOT = resolve(import.meta.dir, "../../..");
17
17
  const HOME = homedir();
18
- const STATUS_SCRIPT = join(PROJECT_ROOT, "statusline", "buddy-status.sh");
18
+ const STATUS_SCRIPT = join(PROJECT_ROOT, "adapters", "claude", "statusline", "buddy-status.sh");
19
19
 
20
20
  const RED = "\x1b[31m";
21
21
  const GREEN = "\x1b[32m";
@@ -49,9 +49,13 @@ function tryRead(path: string): string | null {
49
49
  try { return readFileSync(path, "utf8"); } catch { return null; }
50
50
  }
51
51
 
52
- function tryParseJson(text: string | null): any | null {
52
+ function tryParseJson(text: string | null): unknown {
53
53
  if (!text) return null;
54
- try { return JSON.parse(text); } catch { return null; }
54
+ try { return JSON.parse(text) as unknown; } catch { return null; }
55
+ }
56
+
57
+ function isRecord(value: unknown): value is Record<string, unknown> {
58
+ return typeof value === "object" && value !== null;
55
59
  }
56
60
 
57
61
  // ─── Header ─────────────────────────────────────────────────────────────────
@@ -103,18 +107,20 @@ section("claude-buddy state");
103
107
  const menagerie = tryParseJson(tryRead(join(HOME, ".claude-buddy", "menagerie.json")));
104
108
  const status = tryParseJson(tryRead(join(HOME, ".claude-buddy", "status.json")));
105
109
 
106
- if (menagerie) {
107
- const activeSlot = menagerie.active ?? "buddy";
108
- const companion = menagerie.companions?.[activeSlot];
110
+ if (isRecord(menagerie)) {
111
+ const activeSlot = typeof menagerie.active === "string" ? menagerie.active : "buddy";
112
+ const companions = isRecord(menagerie.companions) ? menagerie.companions : {};
113
+ const companion = companions[activeSlot];
109
114
  row("Active slot", activeSlot);
110
- row("Total slots", String(Object.keys(menagerie.companions ?? {}).length));
111
- if (companion) {
112
- row("Companion name", companion.name ?? "(none)");
113
- row("Species", companion.bones?.species ?? "(none)");
114
- row("Rarity", companion.bones?.rarity ?? "(none)");
115
- row("Hat", companion.bones?.hat ?? "(none)");
116
- row("Eye", companion.bones?.eye ?? "(none)");
117
- row("Shiny", String(companion.bones?.shiny ?? false));
115
+ row("Total slots", String(Object.keys(companions).length));
116
+ if (isRecord(companion)) {
117
+ const bones = isRecord(companion.bones) ? companion.bones : {};
118
+ row("Companion name", typeof companion.name === "string" ? companion.name : "(none)");
119
+ row("Species", typeof bones.species === "string" ? bones.species : "(none)");
120
+ row("Rarity", typeof bones.rarity === "string" ? bones.rarity : "(none)");
121
+ row("Hat", typeof bones.hat === "string" ? bones.hat : "(none)");
122
+ row("Eye", typeof bones.eye === "string" ? bones.eye : "(none)");
123
+ row("Shiny", String(typeof bones.shiny === "boolean" ? bones.shiny : false));
118
124
  } else {
119
125
  err(`No companion found in active slot "${activeSlot}"`);
120
126
  }
@@ -122,9 +128,9 @@ if (menagerie) {
122
128
  err("No manifest found at ~/.claude-buddy/menagerie.json");
123
129
  }
124
130
 
125
- if (status) {
126
- row("Status muted", String(status.muted ?? false));
127
- row("Current reaction", status.reaction || "(none)");
131
+ if (isRecord(status)) {
132
+ row("Status muted", String(typeof status.muted === "boolean" ? status.muted : false));
133
+ row("Current reaction", typeof status.reaction === "string" && status.reaction.length > 0 ? status.reaction : "(none)");
128
134
  } else {
129
135
  warn("No status state at ~/.claude-buddy/status.json");
130
136
  }
@@ -135,24 +141,25 @@ section("Claude Code config");
135
141
  const settings = tryParseJson(tryRead(join(HOME, ".claude", "settings.json")));
136
142
  const claudeJson = tryParseJson(tryRead(join(HOME, ".claude.json")));
137
143
 
138
- if (settings?.statusLine) {
144
+ if (isRecord(settings) && "statusLine" in settings && settings.statusLine !== undefined) {
139
145
  console.log(` ${DIM}statusLine:${NC}`);
140
146
  console.log(` ${JSON.stringify(settings.statusLine, null, 2).split("\n").join("\n ")}`);
141
147
  } else {
142
148
  warn("No statusLine in ~/.claude/settings.json");
143
149
  }
144
150
 
145
- if (settings?.hooks) {
151
+ if (isRecord(settings) && isRecord(settings.hooks)) {
146
152
  console.log(` ${DIM}hooks:${NC}`);
147
153
  for (const event of Object.keys(settings.hooks)) {
148
- const count = settings.hooks[event]?.length ?? 0;
154
+ const hookEntries = settings.hooks[event];
155
+ const count = Array.isArray(hookEntries) ? hookEntries.length : 0;
149
156
  row(` ${event}`, `${count} entr${count === 1 ? "y" : "ies"}`);
150
157
  }
151
158
  } else {
152
159
  warn("No hooks configured");
153
160
  }
154
161
 
155
- if (claudeJson?.mcpServers?.["claude-buddy"]) {
162
+ if (isRecord(claudeJson) && isRecord(claudeJson.mcpServers) && isRecord(claudeJson.mcpServers["claude-buddy"])) {
156
163
  ok("MCP server registered in ~/.claude.json");
157
164
  console.log(` ${JSON.stringify(claudeJson.mcpServers["claude-buddy"], null, 2).split("\n").join("\n ")}`);
158
165
  } else {
@@ -10,11 +10,11 @@
10
10
  import {
11
11
  searchBuddy, renderBuddy, SPECIES, RARITIES, STAT_NAMES,
12
12
  type Species, type Rarity, type StatName, type SearchCriteria,
13
- } from "../server/engine.ts";
13
+ } from "../../../core/engine.ts";
14
14
  import {
15
15
  saveCompanionSlot, saveActiveSlot, writeStatusState,
16
16
  slugify, unusedName, listCompanionSlots,
17
- } from "../server/state.ts";
17
+ } from "../storage/state.ts";
18
18
  import { createInterface } from "readline";
19
19
 
20
20
  const CYAN = "\x1b[36m";
@@ -67,8 +67,8 @@ ${CYAN}╚═══════════════════════
67
67
  const statsAns = await ask(`\n Configure stats? [Y/n]: `);
68
68
  if (statsAns.toLowerCase() !== "n") {
69
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);
70
+ const dumpOptions = STAT_NAMES.filter((s): s is Exclude<StatName, typeof wantPeak> => s !== wantPeak);
71
+ wantDump = await pickFromList("Dump stat (lowest):", dumpOptions);
72
72
  console.log(`${GREEN}✓${NC} peak=${wantPeak} dump=${wantDump}`);
73
73
  }
74
74
 
@@ -7,12 +7,13 @@
7
7
 
8
8
  import { readFileSync, writeFileSync, mkdirSync, existsSync, cpSync } from "fs";
9
9
  import { execSync } from "child_process";
10
- import { join, resolve, dirname } from "path";
10
+ import { join, resolve } from "path";
11
11
  import { homedir } from "os";
12
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";
13
+ import { generateBones, renderBuddy, renderFace, RARITY_STARS } from "../../../core/engine.ts";
14
+ import { loadCompanion, saveCompanion, writeStatusState } from "../storage/state.ts";
15
+ import { resolveUserId } from "../storage/identity.ts";
16
+ import { generateFallbackName } from "../../../core/reactions.ts";
16
17
 
17
18
  const CYAN = "\x1b[36m";
18
19
  const GREEN = "\x1b[32m";
@@ -25,7 +26,7 @@ const NC = "\x1b[0m";
25
26
  const CLAUDE_DIR = join(homedir(), ".claude");
26
27
  const SETTINGS_FILE = join(CLAUDE_DIR, "settings.json");
27
28
  const BUDDY_DIR = join(CLAUDE_DIR, "skills", "buddy");
28
- const PROJECT_ROOT = resolve(dirname(import.meta.dir));
29
+ const PROJECT_ROOT = resolve(import.meta.dir, "../../..");
29
30
 
30
31
  function banner() {
31
32
  console.log(`
@@ -92,15 +93,50 @@ function preflight(): boolean {
92
93
 
93
94
  // ─── Load / update settings.json ────────────────────────────────────────────
94
95
 
95
- function loadSettings(): Record<string, any> {
96
+ interface HookCommand {
97
+ type: "command";
98
+ command: string;
99
+ }
100
+
101
+ interface HookMatcherEntry {
102
+ matcher?: string;
103
+ hooks?: HookCommand[];
104
+ }
105
+
106
+ interface ClaudeStatusLineConfig {
107
+ type: "command";
108
+ command: string;
109
+ padding: number;
110
+ refreshInterval: number;
111
+ }
112
+
113
+ interface ClaudeSettings {
114
+ statusLine?: ClaudeStatusLineConfig;
115
+ hooks?: Partial<Record<"SessionStart" | "SessionEnd" | "PostToolUse" | "Stop" | "UserPromptSubmit", HookMatcherEntry[]>>;
116
+ permissions?: {
117
+ allow?: string[];
118
+ };
119
+ [key: string]: unknown;
120
+ }
121
+
122
+ interface ClaudeJsonConfig {
123
+ mcpServers?: Record<string, { command: string; args: string[]; cwd: string }>;
124
+ [key: string]: unknown;
125
+ }
126
+
127
+ function hasBuddyHook(entry: HookMatcherEntry): boolean {
128
+ return (entry.hooks ?? []).some((hook) => hook.command.includes("claude-buddy"));
129
+ }
130
+
131
+ function loadSettings(): ClaudeSettings {
96
132
  try {
97
- return JSON.parse(readFileSync(SETTINGS_FILE, "utf8"));
133
+ return JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as ClaudeSettings;
98
134
  } catch {
99
135
  return {};
100
136
  }
101
137
  }
102
138
 
103
- function saveSettings(settings: Record<string, any>) {
139
+ function saveSettings(settings: ClaudeSettings) {
104
140
  mkdirSync(CLAUDE_DIR, { recursive: true });
105
141
  writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
106
142
  }
@@ -108,12 +144,12 @@ function saveSettings(settings: Record<string, any>) {
108
144
  // ─── Step 1: Register MCP server (in ~/.claude.json) ────────────────────────
109
145
 
110
146
  function installMcp() {
111
- const serverPath = join(PROJECT_ROOT, "server", "index.ts");
147
+ const serverPath = join(PROJECT_ROOT, "adapters", "claude", "server", "index.ts");
112
148
  const claudeJsonPath = join(homedir(), ".claude.json");
113
149
 
114
- let claudeJson: Record<string, any> = {};
150
+ let claudeJson: ClaudeJsonConfig = {};
115
151
  try {
116
- claudeJson = JSON.parse(readFileSync(claudeJsonPath, "utf8"));
152
+ claudeJson = JSON.parse(readFileSync(claudeJsonPath, "utf8")) as ClaudeJsonConfig;
117
153
  } catch { /* fresh config */ }
118
154
 
119
155
  if (!claudeJson.mcpServers) claudeJson.mcpServers = {};
@@ -131,7 +167,7 @@ function installMcp() {
131
167
  // ─── Step 2: Install skill ──────────────────────────────────────────────────
132
168
 
133
169
  function installSkill() {
134
- const srcSkill = join(PROJECT_ROOT, "skills", "buddy", "SKILL.md");
170
+ const srcSkill = join(PROJECT_ROOT, "adapters", "claude", "skills", "buddy", "SKILL.md");
135
171
  mkdirSync(BUDDY_DIR, { recursive: true });
136
172
  cpSync(srcSkill, join(BUDDY_DIR, "SKILL.md"), { force: true });
137
173
  ok("Skill installed: ~/.claude/skills/buddy/SKILL.md");
@@ -139,8 +175,8 @@ function installSkill() {
139
175
 
140
176
  // ─── Step 3: Configure status line (with animation refresh) ─────────────────
141
177
 
142
- function installStatusLine(settings: Record<string, any>) {
143
- const statusScript = join(PROJECT_ROOT, "statusline", "buddy-status.sh");
178
+ function installStatusLine(settings: ClaudeSettings) {
179
+ const statusScript = join(PROJECT_ROOT, "adapters", "claude", "statusline", "buddy-status.sh");
144
180
 
145
181
  settings.statusLine = {
146
182
  type: "command",
@@ -167,15 +203,15 @@ function detectTmux(): boolean {
167
203
  }
168
204
  }
169
205
 
170
- function installPopupHooks(settings: Record<string, any>) {
171
- const popupManager = join(PROJECT_ROOT, "popup", "popup-manager.sh");
206
+ function installPopupHooks(settings: ClaudeSettings) {
207
+ const popupManager = join(PROJECT_ROOT, "adapters", "claude", "popup", "popup-manager.sh");
172
208
 
173
209
  if (!settings.hooks) settings.hooks = {};
174
210
 
175
211
  // SessionStart: open popup
176
212
  if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
177
213
  settings.hooks.SessionStart = settings.hooks.SessionStart.filter(
178
- (h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("claude-buddy")),
214
+ (h) => !hasBuddyHook(h),
179
215
  );
180
216
  settings.hooks.SessionStart.push({
181
217
  hooks: [{ type: "command", command: `${popupManager} start` }],
@@ -184,7 +220,7 @@ function installPopupHooks(settings: Record<string, any>) {
184
220
  // SessionEnd: close popup
185
221
  if (!settings.hooks.SessionEnd) settings.hooks.SessionEnd = [];
186
222
  settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter(
187
- (h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("claude-buddy")),
223
+ (h) => !hasBuddyHook(h),
188
224
  );
189
225
  settings.hooks.SessionEnd.push({
190
226
  hooks: [{ type: "command", command: `${popupManager} stop` }],
@@ -195,17 +231,17 @@ function installPopupHooks(settings: Record<string, any>) {
195
231
 
196
232
  // ─── Step 4: Register hooks ─────────────────────────────────────────────────
197
233
 
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");
234
+ function installHooks(settings: ClaudeSettings) {
235
+ const reactHook = join(PROJECT_ROOT, "adapters", "claude", "hooks", "react.sh");
236
+ const commentHook = join(PROJECT_ROOT, "adapters", "claude", "hooks", "buddy-comment.sh");
237
+ const nameHook = join(PROJECT_ROOT, "adapters", "claude", "hooks", "name-react.sh");
202
238
 
203
239
  if (!settings.hooks) settings.hooks = {};
204
240
 
205
241
  // PostToolUse: detect errors/test failures/successes in Bash output
206
242
  if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
207
243
  settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
208
- (h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("claude-buddy")),
244
+ (h) => !hasBuddyHook(h),
209
245
  );
210
246
  settings.hooks.PostToolUse.push({
211
247
  matcher: "Bash",
@@ -215,7 +251,7 @@ function installHooks(settings: Record<string, any>) {
215
251
  // Stop: extract <!-- buddy: --> comment from Claude's response
216
252
  if (!settings.hooks.Stop) settings.hooks.Stop = [];
217
253
  settings.hooks.Stop = settings.hooks.Stop.filter(
218
- (h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("claude-buddy")),
254
+ (h) => !hasBuddyHook(h),
219
255
  );
220
256
  settings.hooks.Stop.push({
221
257
  hooks: [{ type: "command", command: commentHook }],
@@ -224,7 +260,7 @@ function installHooks(settings: Record<string, any>) {
224
260
  // UserPromptSubmit: detect buddy's name in user message → instant status line reaction
225
261
  if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = [];
226
262
  settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
227
- (h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("claude-buddy")),
263
+ (h) => !hasBuddyHook(h),
228
264
  );
229
265
  settings.hooks.UserPromptSubmit.push({
230
266
  hooks: [{ type: "command", command: nameHook }],
@@ -235,7 +271,7 @@ function installHooks(settings: Record<string, any>) {
235
271
 
236
272
  // ─── Step 5: Ensure MCP tools are allowed ───────────────────────────────────
237
273
 
238
- function ensurePermissions(settings: Record<string, any>) {
274
+ function ensurePermissions(settings: ClaudeSettings) {
239
275
  if (!settings.permissions) settings.permissions = {};
240
276
  if (!settings.permissions.allow) settings.permissions.allow = [];
241
277
 
@@ -18,12 +18,12 @@
18
18
  import {
19
19
  loadActiveSlot, saveActiveSlot, listCompanionSlots,
20
20
  loadCompanionSlot, saveCompanionSlot, slugify, unusedName, writeStatusState,
21
- } from "../server/state.ts";
21
+ } from "../storage/state.ts";
22
22
  import {
23
23
  generateBones, SPECIES, RARITIES, STAT_NAMES, RARITY_STARS,
24
24
  type Species, type Rarity, type StatName, type BuddyBones, type Companion,
25
- } from "../server/engine.ts";
26
- import { renderCompanionCard } from "../server/art.ts";
25
+ } from "../../../core/engine.ts";
26
+ import { renderCompanionCard } from "../rendering/art.ts";
27
27
  import { randomBytes } from "crypto";
28
28
 
29
29
  // ─── ANSI ─────────────────────────────────────────────────────────────────────
@@ -7,7 +7,7 @@
7
7
  * bun run settings cooldown 0 Set comment cooldown (0-300 seconds)
8
8
  */
9
9
 
10
- import { loadConfig, saveConfig } from "../server/state.ts";
10
+ import { loadConfig, saveConfig } from "../storage/state.ts";
11
11
 
12
12
  const args = process.argv.slice(2);
13
13
  const key = args[0];
@@ -2,8 +2,8 @@
2
2
  * claude-buddy show — display current companion in terminal
3
3
  */
4
4
 
5
- import { renderBuddy, renderFace, RARITY_STARS } from "../server/engine.ts";
6
- import { loadCompanion, loadReaction } from "../server/state.ts";
5
+ import { renderBuddy, renderFace, RARITY_STARS } from "../../../core/engine.ts";
6
+ import { loadCompanion, loadReaction } from "../storage/state.ts";
7
7
 
8
8
  const BOLD = "\x1b[1m";
9
9
  const DIM = "\x1b[2m";
@@ -6,6 +6,26 @@ import { readFileSync, writeFileSync, existsSync, rmSync, readdirSync } from "fs
6
6
  import { join } from "path";
7
7
  import { homedir } from "os";
8
8
 
9
+ interface HookCommand {
10
+ type: "command";
11
+ command: string;
12
+ }
13
+
14
+ interface HookMatcherEntry {
15
+ matcher?: string;
16
+ hooks?: HookCommand[];
17
+ }
18
+
19
+ interface ClaudeSettings {
20
+ statusLine?: { command?: string };
21
+ hooks?: Record<string, HookMatcherEntry[]>;
22
+ [key: string]: unknown;
23
+ }
24
+
25
+ function hasBuddyHook(entry: HookMatcherEntry): boolean {
26
+ return (entry.hooks ?? []).some((hook) => hook.command.includes("claude-buddy"));
27
+ }
28
+
9
29
  const GREEN = "\x1b[32m";
10
30
  const YELLOW = "\x1b[33m";
11
31
  const NC = "\x1b[0m";
@@ -63,7 +83,7 @@ try {
63
83
 
64
84
  // Remove hooks and statusline from settings.json
65
85
  try {
66
- const settings = JSON.parse(readFileSync(SETTINGS_FILE, "utf8"));
86
+ const settings = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as ClaudeSettings;
67
87
  let changed = false;
68
88
 
69
89
  if (settings.statusLine?.command?.includes("buddy")) {
@@ -76,7 +96,7 @@ try {
76
96
  if (settings.hooks?.[hookType]) {
77
97
  const before = settings.hooks[hookType].length;
78
98
  settings.hooks[hookType] = settings.hooks[hookType].filter(
79
- (h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("claude-buddy")),
99
+ (h) => !hasBuddyHook(h),
80
100
  );
81
101
  if (settings.hooks[hookType].length < before) {
82
102
  ok(`${hookType} hooks removed`);
@@ -21,7 +21,7 @@
21
21
  "type": "stdio",
22
22
  "command": "bun",
23
23
  "args": [
24
- "server/index.ts"
24
+ "adapters/claude/server/index.ts"
25
25
  ]
26
26
  }
27
27
  }
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env bash
2
+ # claude-buddy popup entry point -- runs INSIDE the tmux popup
3
+ #
4
+ # Architecture:
5
+ # - Render loop runs in BACKGROUND (only writes to stdout, never reads stdin)
6
+ # - Input forwarder runs in FOREGROUND (owns stdin exclusively)
7
+ #
8
+ # The reopen loop in popup-manager.sh handles:
9
+ # - ESC forwarding (when tmux closes popup on ESC)
10
+ # - CC pane death detection
11
+ # - Dynamic resizing on reopen
12
+ #
13
+ # Env vars (set by popup-manager.sh via -e):
14
+ # CC_PANE -- tmux pane ID for Claude Code (e.g. %0)
15
+ # BUDDY_DIR -- ~/.claude-buddy
16
+ # BUDDY_SID -- session ID (sanitized pane number, e.g. "0")
17
+ # Args: $1 = SID (fallback for tmux < 3.4 without -e support)
18
+
19
+ set -uo pipefail
20
+
21
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
22
+
23
+ # Session ID: from env (tmux 3.4+), $1 arg, or "default"
24
+ BUDDY_SID="${BUDDY_SID:-${1:-default}}"
25
+
26
+ # On tmux 3.2-3.3, env vars are passed via file (no -e flag support)
27
+ ENV_FILE="${HOME}/.claude-buddy/popup-env.$BUDDY_SID"
28
+ if [ -z "${CC_PANE:-}" ] && [ -f "$ENV_FILE" ]; then
29
+ . "$ENV_FILE"
30
+ fi
31
+
32
+ if [ -z "${CC_PANE:-}" ]; then
33
+ echo "Error: CC_PANE not set" >&2
34
+ sleep 2
35
+ exit 1
36
+ fi
37
+
38
+ # ─── Cleanup on exit ─────────────────────────────────────────────────────────
39
+ cleanup() {
40
+ [ -n "${RENDER_PID:-}" ] && kill "$RENDER_PID" 2>/dev/null
41
+ tput cnorm 2>/dev/null
42
+ stty sane 2>/dev/null
43
+ }
44
+ trap cleanup EXIT INT TERM HUP
45
+
46
+ # Hide cursor
47
+ tput civis 2>/dev/null
48
+
49
+ # ─── Render loop in BACKGROUND (stdout only, no stdin) ───────────────────────
50
+ "$SCRIPT_DIR/buddy-render.sh" </dev/null &
51
+ RENDER_PID=$!
52
+
53
+ # ─── Input forwarder in FOREGROUND ───────────────────────────────────────────
54
+ # Raw mode: all bytes pass through without terminal interpretation.
55
+ # No SIGINT on Ctrl-C, no EOF on Ctrl-D, no CR-to-NL on Enter.
56
+ stty raw -echo 2>/dev/null
57
+
58
+ # Use perl for raw byte forwarding. bash's read -n1 internally overrides
59
+ # terminal settings on each call (saves/restores tty mode), which undoes
60
+ # stty raw and re-enables signal processing. perl's sysread doesn't touch
61
+ # the terminal at all -- it reads raw bytes from the file descriptor.
62
+ #
63
+ # Batching: first byte is a blocking read, then non-blocking drain of any
64
+ # remaining bytes (paste arrives as a burst). The batch is sent to CC in
65
+ # a single tmux send-keys call for efficiency.
66
+ exec perl -e '
67
+ use Fcntl qw(F_GETFL F_SETFL O_NONBLOCK);
68
+ my $pane = $ENV{CC_PANE};
69
+ while (1) {
70
+ my $buf;
71
+ my $n = sysread(STDIN, $buf, 1);
72
+ last unless $n;
73
+ # Non-blocking drain for paste batching
74
+ my $flags = fcntl(STDIN, F_GETFL, 0);
75
+ fcntl(STDIN, F_SETFL, $flags | O_NONBLOCK);
76
+ while (sysread(STDIN, my $more, 4096)) {
77
+ $buf .= $more;
78
+ }
79
+ fcntl(STDIN, F_SETFL, $flags);
80
+
81
+ # F12 (\e[24~) = close popup and enter scroll mode
82
+ if ($buf =~ /\e\[24~/) {
83
+ my $buddy_dir = $ENV{BUDDY_DIR} || "$ENV{HOME}/.claude-buddy";
84
+ my $sid = $ENV{BUDDY_SID} // "default";
85
+ open(my $fh, ">", "$buddy_dir/popup-scroll.$sid");
86
+ close($fh) if $fh;
87
+ exit 0;
88
+ }
89
+
90
+ system("tmux", "send-keys", "-t", $pane, "-l", "--", $buf);
91
+ }
92
+ '