@ramarivera/coding-buddy 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -2
- package/adapters/omp/renderers.ts +115 -19
- package/adapters/omp/ui.ts +2 -3
- package/cli/install.ts +41 -22
- package/cli/runtime-app.ts +49 -0
- package/hooks/buddy-comment.sh +10 -80
- package/hooks/file-type-react.sh +10 -402
- package/hooks/hooks.json +19 -5
- package/hooks/legacy/file-type-react.sh +403 -0
- package/hooks/legacy/mood-react.sh +210 -0
- package/hooks/legacy/name-react.sh +178 -0
- package/hooks/legacy/react.sh +1364 -0
- package/hooks/mood-react.sh +10 -209
- package/hooks/name-react.sh +10 -177
- package/hooks/react.sh +10 -1363
- package/hooks/suggest.sh +10 -54
- package/package.json +1 -1
- package/server/hooks/buddy-comment.ts +93 -0
- package/server/hooks/common.ts +95 -0
- package/server/hooks/legacy-bash.ts +34 -0
- package/server/hooks/suggest.ts +60 -0
- package/server/index.ts +21 -5
- package/server/path.ts +5 -0
- package/server/state.ts +15 -3
- package/statusline/buddy-status.sh +14 -6
- package/statusline/combined-status.sh +2 -2
- package/statusline/substatus.sh +66 -0
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi (requires bun: https://bun.sh/install)",
|
|
8
|
-
"version": "0.7.
|
|
8
|
+
"version": "0.7.2"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "coding-buddy",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi. MCP-based terminal pet with ASCII art, stats, reactions, and personality. Requires bun runtime (https://bun.sh/install).",
|
|
15
|
-
"version": "0.7.
|
|
15
|
+
"version": "0.7.2",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "ramarivera"
|
|
18
18
|
},
|
package/README.md
CHANGED
|
@@ -103,7 +103,7 @@ The `claude-buddy` package ended at **v0.5.2**.
|
|
|
103
103
|
npx -y @ramarivera/coding-buddy
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
-
This installs the MCP server, skill, hooks, and status line. Restart Claude Code and type `/buddy`.
|
|
106
|
+
This installs the MCP server, skill, hooks, and status line. Claude Code hooks are registered as thin bash wrappers with 15s timeouts; the wrappers locate Bun in bare hook environments and delegate behavior to TypeScript or the phase-2 legacy bash bridge. Restart Claude Code and type `/buddy`.
|
|
107
107
|
|
|
108
108
|
### Pi
|
|
109
109
|
|
|
@@ -255,7 +255,8 @@ MCP is an industry-standard protocol. Skills are Markdown files. Hooks and statu
|
|
|
255
255
|
coding-buddy/
|
|
256
256
|
├── server/ # MCP server — tools, engine, art, reactions, state
|
|
257
257
|
├── skills/buddy/ # /buddy slash command
|
|
258
|
-
├── hooks/ #
|
|
258
|
+
├── hooks/ # Thin Claude Code hook wrappers and phase-2 legacy bash hooks
|
|
259
|
+
├── server/hooks/ # Hook behavior implemented in TypeScript
|
|
259
260
|
├── statusline/ # Animated right-aligned buddy display
|
|
260
261
|
└── cli/ # install, show, hunt, verify, doctor, backup, uninstall
|
|
261
262
|
```
|
|
@@ -2,47 +2,143 @@ import { RARITY_STARS } from "../../core/engine.ts";
|
|
|
2
2
|
import { getArtFrame, HAT_ART } from "../../core/render-model.ts";
|
|
3
3
|
import type { Companion, ReactionState } from "../../core/model.ts";
|
|
4
4
|
import type { Achievement } from "../../core/achievements.ts";
|
|
5
|
+
import { getRarityColor } from "../../server/theme.ts";
|
|
6
|
+
|
|
7
|
+
export const OMP_WIDGET_MAX_LINES = 10;
|
|
8
|
+
|
|
9
|
+
const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
10
|
+
const RESET = "\x1b[0m";
|
|
11
|
+
const DIM_ITALIC = "\x1b[2;3m";
|
|
12
|
+
const ART_GAP = " ";
|
|
13
|
+
const DEFAULT_WIDGET_WIDTH = 78;
|
|
14
|
+
|
|
15
|
+
function stripAnsi(text: string): string {
|
|
16
|
+
return text.replace(ANSI_PATTERN, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function characterWidth(character: string, next?: string): number {
|
|
20
|
+
if (character === "\u200d" || /[\uFE00-\uFE0F]/u.test(character)) return 0;
|
|
21
|
+
if (next === "\uFE0F" && /\p{Emoji}/u.test(character)) return 2;
|
|
22
|
+
return /\p{Emoji_Presentation}/u.test(character) ? 2 : 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function displayWidth(text: string): number {
|
|
26
|
+
const characters = [...stripAnsi(text)];
|
|
27
|
+
let width = 0;
|
|
28
|
+
for (let index = 0; index < characters.length; index += 1) {
|
|
29
|
+
width += characterWidth(characters[index]!, characters[index + 1]);
|
|
30
|
+
}
|
|
31
|
+
return width;
|
|
32
|
+
}
|
|
5
33
|
|
|
6
34
|
function trimArt(line: string): string {
|
|
7
35
|
return line.replace(/\s+$/g, "");
|
|
8
36
|
}
|
|
9
37
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
38
|
+
function truncateToWidth(text: string, width: number): string {
|
|
39
|
+
if (width <= 0) return "";
|
|
40
|
+
let result = "";
|
|
41
|
+
let currentWidth = 0;
|
|
42
|
+
for (const character of [...stripAnsi(text)]) {
|
|
43
|
+
const nextWidth = characterWidth(character);
|
|
44
|
+
if (currentWidth + nextWidth > width) break;
|
|
45
|
+
result += character;
|
|
46
|
+
currentWidth += nextWidth;
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
15
49
|
}
|
|
16
50
|
|
|
17
|
-
|
|
51
|
+
function wrapReaction(reaction: string, width: number, maxLines: number): string[] {
|
|
52
|
+
const lines: string[] = [];
|
|
53
|
+
let current = "";
|
|
54
|
+
|
|
55
|
+
for (const word of `💬 ${reaction.trim()}`.split(/\s+/)) {
|
|
56
|
+
if (!word) continue;
|
|
57
|
+
if (displayWidth(word) > width) {
|
|
58
|
+
if (current) lines.push(current);
|
|
59
|
+
lines.push(`${truncateToWidth(word, Math.max(1, width - 1))}…`);
|
|
60
|
+
current = "";
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
65
|
+
if (displayWidth(candidate) > width) {
|
|
66
|
+
if (current) lines.push(current);
|
|
67
|
+
current = word;
|
|
68
|
+
} else {
|
|
69
|
+
current = candidate;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (current) lines.push(current);
|
|
74
|
+
if (lines.length <= maxLines) return lines;
|
|
75
|
+
|
|
76
|
+
const bounded = lines.slice(0, maxLines);
|
|
77
|
+
const last = bounded[maxLines - 1] ?? "";
|
|
78
|
+
bounded[maxLines - 1] = `${truncateToWidth(last, Math.max(1, width - 1))}…`;
|
|
79
|
+
return bounded;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getWidgetWidth(): number {
|
|
83
|
+
const columns = process.stdout.columns;
|
|
84
|
+
return columns && columns > 0 ? Math.max(24, columns - 2) : DEFAULT_WIDGET_WIDTH;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getFullArtFrame(companion: Companion, frame: number): string[] {
|
|
88
|
+
const art = getArtFrame(companion.bones.species, companion.bones.eye, frame);
|
|
89
|
+
if (companion.bones.hat !== "none" && !stripAnsi(art[0] ?? "").trim()) {
|
|
90
|
+
art[0] = HAT_ART[companion.bones.hat];
|
|
91
|
+
}
|
|
92
|
+
return art.map(trimArt);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderDetails(
|
|
18
96
|
companion: Companion,
|
|
19
|
-
reaction
|
|
20
|
-
achievements: Achievement[]
|
|
97
|
+
reaction: ReactionState | null | undefined,
|
|
98
|
+
achievements: Achievement[],
|
|
99
|
+
sideWidth: number,
|
|
21
100
|
): string[] {
|
|
22
|
-
const
|
|
23
|
-
const art = getArtFrame(companion.bones.species, companion.bones.eye, frame).map(trimArt);
|
|
24
|
-
const hat = companion.bones.hat === "none" ? [] : [trimArt(HAT_ART[companion.bones.hat])];
|
|
101
|
+
const color = getRarityColor(companion.bones.rarity);
|
|
25
102
|
const stars = RARITY_STARS[companion.bones.rarity];
|
|
26
103
|
const shiny = companion.bones.shiny ? " ✨" : "";
|
|
27
104
|
const lines = [
|
|
28
|
-
|
|
29
|
-
`${companion.bones.rarity} ${companion.bones.species}
|
|
30
|
-
"",
|
|
31
|
-
...hat,
|
|
32
|
-
...art,
|
|
105
|
+
`${color}${companion.name} ${stars}${shiny}${RESET}`,
|
|
106
|
+
`${companion.bones.rarity} ${companion.bones.species}`,
|
|
33
107
|
];
|
|
34
108
|
|
|
35
109
|
if (reaction?.reaction) {
|
|
36
|
-
|
|
110
|
+
const remainingLines = Math.max(1, OMP_WIDGET_MAX_LINES - lines.length);
|
|
111
|
+
lines.push(...wrapReaction(reaction.reaction, sideWidth, remainingLines).map((line) => `${DIM_ITALIC}${line}${RESET}`));
|
|
37
112
|
}
|
|
38
113
|
|
|
39
|
-
|
|
40
|
-
lines.
|
|
114
|
+
for (const achievement of achievements) {
|
|
115
|
+
if (lines.length >= OMP_WIDGET_MAX_LINES) break;
|
|
116
|
+
lines.push(`🏆 ${achievement.name}`);
|
|
41
117
|
}
|
|
42
118
|
|
|
43
119
|
return lines;
|
|
44
120
|
}
|
|
45
121
|
|
|
122
|
+
export function renderBuddyWidget(
|
|
123
|
+
companion: Companion,
|
|
124
|
+
reaction?: ReactionState | null,
|
|
125
|
+
achievements: Achievement[] = [],
|
|
126
|
+
width: number = getWidgetWidth(),
|
|
127
|
+
): string[] {
|
|
128
|
+
const art = getFullArtFrame(companion, Math.floor(Date.now() / 700));
|
|
129
|
+
const artWidth = Math.max(...art.map(displayWidth), 0);
|
|
130
|
+
const widgetWidth = Math.max(24, width);
|
|
131
|
+
const sideWidth = Math.max(8, widgetWidth - artWidth - ART_GAP.length);
|
|
132
|
+
const details = renderDetails(companion, reaction, achievements, sideWidth);
|
|
133
|
+
const rows = Math.max(art.length, details.length);
|
|
134
|
+
|
|
135
|
+
return Array.from({ length: Math.min(OMP_WIDGET_MAX_LINES, rows) }, (_, index) => {
|
|
136
|
+
const artLine = art[index] ?? "";
|
|
137
|
+
const detailLine = details[index] ?? "";
|
|
138
|
+
return `${detailLine}${" ".repeat(Math.max(0, sideWidth - displayWidth(detailLine)))}${ART_GAP}${artLine}${" ".repeat(Math.max(0, artWidth - displayWidth(artLine)))}`;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
46
142
|
export function renderBuddyStats(companion: Companion): string[] {
|
|
47
143
|
return [
|
|
48
144
|
`name: ${companion.name}`,
|
package/adapters/omp/ui.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { OmpBuddyUiContext } from "./context.ts";
|
|
2
2
|
import type { Achievement } from "../../core/achievements.ts";
|
|
3
3
|
import type { Companion, ReactionState } from "../../core/model.ts";
|
|
4
|
-
import { renderAchievementsSummary,
|
|
4
|
+
import { renderAchievementsSummary, renderBuddyWidget } from "./renderers.ts";
|
|
5
5
|
import { OmpBuddyStorage } from "./storage.ts";
|
|
6
6
|
|
|
7
7
|
export class OmpBuddyUI {
|
|
@@ -27,8 +27,7 @@ export class OmpBuddyUI {
|
|
|
27
27
|
): void {
|
|
28
28
|
const currentReaction = reaction ?? null;
|
|
29
29
|
const muted = this.storage.isMuted();
|
|
30
|
-
|
|
31
|
-
ctx.ui.setStatus("buddy", muted ? `${status} [muted]` : status);
|
|
30
|
+
ctx.ui.setStatus("buddy", undefined);
|
|
32
31
|
ctx.ui.setWidget(
|
|
33
32
|
"buddy",
|
|
34
33
|
renderBuddyWidget(companion, muted ? null : currentReaction, achievements),
|
package/cli/install.ts
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, cpSync } from "fs";
|
|
11
|
-
import { execSync } from "child_process";
|
|
11
|
+
import { execFileSync, execSync } from "child_process";
|
|
12
12
|
import { resolve, dirname, join } from "path";
|
|
13
13
|
|
|
14
14
|
import { generateBones, renderBuddy, renderFace, RARITY_STARS } from "../core/engine.ts"
|
|
15
15
|
import {
|
|
16
16
|
claudeConfigDir,
|
|
17
|
+
buddyStateDir,
|
|
17
18
|
claudeSettingsPath,
|
|
18
19
|
claudeSkillDir,
|
|
19
20
|
claudeUserConfigPath,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
} from "../server/path.ts";
|
|
22
23
|
import { loadCompanion, saveCompanion, resolveUserId, writeStatusState } from "../server/state.ts";
|
|
23
24
|
import { generateFallbackName } from "../core/reactions.ts"
|
|
25
|
+
import { copyRuntimeApp, stableRuntimePaths } from "./runtime-app.ts";
|
|
24
26
|
|
|
25
27
|
const CYAN = "\x1b[36m";
|
|
26
28
|
const GREEN = "\x1b[32m";
|
|
@@ -35,6 +37,7 @@ const SETTINGS_FILE = claudeSettingsPath();
|
|
|
35
37
|
const BUDDY_DIR = claudeSkillDir("buddy");
|
|
36
38
|
const CLAUDE_JSON_PATH = claudeUserConfigPath();
|
|
37
39
|
const PROJECT_ROOT = resolve(dirname(import.meta.dir));
|
|
40
|
+
const HOOK_TIMEOUT_SECONDS = 15;
|
|
38
41
|
|
|
39
42
|
function banner() {
|
|
40
43
|
console.log(`
|
|
@@ -115,8 +118,8 @@ function saveSettings(settings: Record<string, any>) {
|
|
|
115
118
|
|
|
116
119
|
// ─── Step 1: Register MCP server (in ~/.claude.json) ────────────────────────
|
|
117
120
|
|
|
118
|
-
function installMcp() {
|
|
119
|
-
const serverPath =
|
|
121
|
+
function installMcp(appDir: string) {
|
|
122
|
+
const serverPath = stableRuntimePaths(appDir).mcpServer;
|
|
120
123
|
|
|
121
124
|
let claudeJson: Record<string, any> = {};
|
|
122
125
|
try {
|
|
@@ -128,7 +131,7 @@ function installMcp() {
|
|
|
128
131
|
claudeJson.mcpServers["claude-buddy"] = {
|
|
129
132
|
command: "bun",
|
|
130
133
|
args: [toUnixPath(serverPath)],
|
|
131
|
-
cwd: toUnixPath(
|
|
134
|
+
cwd: toUnixPath(appDir),
|
|
132
135
|
};
|
|
133
136
|
|
|
134
137
|
writeFileSync(CLAUDE_JSON_PATH, JSON.stringify(claudeJson, null, 2));
|
|
@@ -146,8 +149,8 @@ function installSkill() {
|
|
|
146
149
|
|
|
147
150
|
// ─── Step 3: Configure status line (with animation refresh) ─────────────────
|
|
148
151
|
|
|
149
|
-
function installStatusLine(settings: Record<string, any
|
|
150
|
-
const statusScript =
|
|
152
|
+
function installStatusLine(settings: Record<string, any>, appDir: string) {
|
|
153
|
+
const statusScript = stableRuntimePaths(appDir).statusline;
|
|
151
154
|
|
|
152
155
|
settings.statusLine = {
|
|
153
156
|
type: "command",
|
|
@@ -180,13 +183,19 @@ function stripLegacyPopupHooks(settings: Record<string, any>) {
|
|
|
180
183
|
|
|
181
184
|
// ─── Step 4: Register hooks ─────────────────────────────────────────────────
|
|
182
185
|
|
|
183
|
-
function installHooks(settings: Record<string, any
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
const
|
|
186
|
+
function installHooks(settings: Record<string, any>, appDir: string) {
|
|
187
|
+
const hooksDir = stableRuntimePaths(appDir).hooks;
|
|
188
|
+
const reactHook = join(hooksDir, "react.sh");
|
|
189
|
+
const fileTypeHook = join(hooksDir, "file-type-react.sh");
|
|
190
|
+
const commentHook = join(hooksDir, "buddy-comment.sh");
|
|
191
|
+
const suggestHook = join(hooksDir, "suggest.sh");
|
|
192
|
+
const nameHook = join(hooksDir, "name-react.sh");
|
|
193
|
+
const moodHook = join(hooksDir, "mood-react.sh");
|
|
194
|
+
const commandHook = (command: string) => ({
|
|
195
|
+
type: "command",
|
|
196
|
+
command: toUnixPath(command),
|
|
197
|
+
timeout: HOOK_TIMEOUT_SECONDS,
|
|
198
|
+
});
|
|
190
199
|
|
|
191
200
|
if (!settings.hooks) settings.hooks = {};
|
|
192
201
|
|
|
@@ -198,11 +207,11 @@ function installHooks(settings: Record<string, any>) {
|
|
|
198
207
|
);
|
|
199
208
|
settings.hooks.PostToolUse.push({
|
|
200
209
|
matcher: "Bash",
|
|
201
|
-
hooks: [
|
|
210
|
+
hooks: [commandHook(reactHook)],
|
|
202
211
|
});
|
|
203
212
|
settings.hooks.PostToolUse.push({
|
|
204
213
|
matcher: "Write|Edit",
|
|
205
|
-
hooks: [
|
|
214
|
+
hooks: [commandHook(fileTypeHook)],
|
|
206
215
|
});
|
|
207
216
|
|
|
208
217
|
// Stop: extract <!-- buddy: --> comment from Claude's response
|
|
@@ -211,10 +220,10 @@ function installHooks(settings: Record<string, any>) {
|
|
|
211
220
|
(h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("coding-buddy") || hh.command?.includes("claude-buddy")),
|
|
212
221
|
);
|
|
213
222
|
settings.hooks.Stop.push({
|
|
214
|
-
hooks: [
|
|
223
|
+
hooks: [commandHook(commentHook)],
|
|
215
224
|
});
|
|
216
225
|
settings.hooks.Stop.push({
|
|
217
|
-
hooks: [
|
|
226
|
+
hooks: [commandHook(suggestHook)],
|
|
218
227
|
});
|
|
219
228
|
|
|
220
229
|
// UserPromptSubmit: detect buddy's name in user message → instant status line
|
|
@@ -224,15 +233,24 @@ function installHooks(settings: Record<string, any>) {
|
|
|
224
233
|
(h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("coding-buddy") || hh.command?.includes("claude-buddy")),
|
|
225
234
|
);
|
|
226
235
|
settings.hooks.UserPromptSubmit.push({
|
|
227
|
-
hooks: [
|
|
236
|
+
hooks: [commandHook(nameHook)],
|
|
228
237
|
});
|
|
229
238
|
settings.hooks.UserPromptSubmit.push({
|
|
230
|
-
hooks: [
|
|
239
|
+
hooks: [commandHook(moodHook)],
|
|
231
240
|
});
|
|
232
241
|
|
|
233
242
|
ok("Hooks registered: PostToolUse (Bash + Write/Edit) + Stop (comment + suggest) + UserPromptSubmit (name + mood)");
|
|
234
243
|
}
|
|
235
244
|
|
|
245
|
+
function installRuntimeApp(): string {
|
|
246
|
+
const appDir = copyRuntimeApp(PROJECT_ROOT, buddyStateDir());
|
|
247
|
+
const installArgs = ["install", "--production", "--ignore-scripts"];
|
|
248
|
+
if (existsSync(join(appDir, "bun.lock"))) installArgs.push("--frozen-lockfile");
|
|
249
|
+
execFileSync("bun", installArgs, { cwd: appDir, stdio: "ignore" });
|
|
250
|
+
ok(`Stable runtime installed: ${appDir}`);
|
|
251
|
+
return appDir;
|
|
252
|
+
}
|
|
253
|
+
|
|
236
254
|
// ─── Step 5: Ensure MCP tools are allowed ───────────────────────────────────
|
|
237
255
|
|
|
238
256
|
function ensurePermissions(settings: Record<string, any>) {
|
|
@@ -295,14 +313,15 @@ console.log("");
|
|
|
295
313
|
info("Installing coding-buddy...\n");
|
|
296
314
|
|
|
297
315
|
const settings = loadSettings();
|
|
316
|
+
const appDir = installRuntimeApp();
|
|
298
317
|
|
|
299
|
-
installMcp();
|
|
318
|
+
installMcp(appDir);
|
|
300
319
|
installSkill();
|
|
301
320
|
|
|
302
321
|
stripLegacyPopupHooks(settings);
|
|
303
|
-
installStatusLine(settings);
|
|
322
|
+
installStatusLine(settings, appDir);
|
|
304
323
|
|
|
305
|
-
installHooks(settings);
|
|
324
|
+
installHooks(settings, appDir);
|
|
306
325
|
ensurePermissions(settings);
|
|
307
326
|
saveSettings(settings);
|
|
308
327
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, rmSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
export const RUNTIME_DIRECTORIES = [
|
|
5
|
+
"core",
|
|
6
|
+
"server",
|
|
7
|
+
"hooks",
|
|
8
|
+
"statusline",
|
|
9
|
+
"scripts",
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
export const RUNTIME_FILES = ["package.json", "bun.lock"] as const;
|
|
13
|
+
|
|
14
|
+
export function stableRuntimeAppDir(stateDir: string): string {
|
|
15
|
+
return join(stateDir, "app");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function stableRuntimePaths(appDir: string) {
|
|
19
|
+
return {
|
|
20
|
+
mcpServer: join(appDir, "server", "index.ts"),
|
|
21
|
+
mcpLauncher: join(appDir, "server", "mcp-launcher.sh"),
|
|
22
|
+
statusline: join(appDir, "statusline", "buddy-status.sh"),
|
|
23
|
+
combinedStatusline: join(appDir, "statusline", "combined-status.sh"),
|
|
24
|
+
hooks: join(appDir, "hooks"),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Replace the stable runtime copy. The app directory is owned by the
|
|
30
|
+
* installer, so removing it first also removes files deleted by an upgrade.
|
|
31
|
+
*/
|
|
32
|
+
export function copyRuntimeApp(sourceRoot: string, stateDir: string): string {
|
|
33
|
+
const appDir = stableRuntimeAppDir(stateDir);
|
|
34
|
+
rmSync(appDir, { recursive: true, force: true });
|
|
35
|
+
mkdirSync(appDir, { recursive: true });
|
|
36
|
+
|
|
37
|
+
for (const directory of RUNTIME_DIRECTORIES) {
|
|
38
|
+
const source = join(sourceRoot, directory);
|
|
39
|
+
if (!existsSync(source)) throw new Error(`Missing runtime directory: ${source}`);
|
|
40
|
+
cpSync(source, join(appDir, directory), { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const file of RUNTIME_FILES) {
|
|
44
|
+
const source = join(sourceRoot, file);
|
|
45
|
+
if (existsSync(source)) cpSync(source, join(appDir, file), { force: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return appDir;
|
|
49
|
+
}
|
package/hooks/buddy-comment.sh
CHANGED
|
@@ -1,81 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
# Per-session ID resolved by paths.sh (CLAUDE_CODE_SESSION_ID > TMUX_PANE > default)
|
|
13
|
-
SID="$BUDDY_SID"
|
|
14
|
-
STATUS_FILE="$STATE_DIR/status.json"
|
|
15
|
-
COOLDOWN_FILE="$STATE_DIR/.last_comment.$SID"
|
|
16
|
-
CONFIG_FILE="$STATE_DIR/config.json"
|
|
17
|
-
EVENTS_FILE="$STATE_DIR/events.json"
|
|
18
|
-
|
|
19
|
-
[ -f "$STATUS_FILE" ] || exit 0
|
|
20
|
-
|
|
21
|
-
# Read cooldown from config (default 30s, 0 = disabled)
|
|
22
|
-
COOLDOWN=30
|
|
23
|
-
if [ -f "$CONFIG_FILE" ]; then
|
|
24
|
-
_cd=$(jq -r '.commentCooldown // 30' "$CONFIG_FILE" 2>/dev/null || echo 30)
|
|
25
|
-
# Accept any non-negative integer (including 0 to disable cooldown)
|
|
26
|
-
[[ "$_cd" =~ ^[0-9]+$ ]] && COOLDOWN=$_cd
|
|
27
|
-
fi
|
|
28
|
-
|
|
29
|
-
INPUT=$(cat)
|
|
30
|
-
|
|
31
|
-
# Extract last_assistant_message from hook input
|
|
32
|
-
MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""' 2>/dev/null)
|
|
33
|
-
[ -z "$MSG" ] && exit 0
|
|
34
|
-
|
|
35
|
-
# Extract <!-- buddy: ... --> comment (portable, no grep -P)
|
|
36
|
-
COMMENT=$(echo "$MSG" | sed -n 's/.*<!-- *buddy: *\(.*[^ ]\) *-->.*/\1/p' | tail -1)
|
|
37
|
-
[ -z "$COMMENT" ] && exit 0
|
|
38
|
-
|
|
39
|
-
# Cooldown: configurable (default 30s)
|
|
40
|
-
if [ -f "$COOLDOWN_FILE" ]; then
|
|
41
|
-
LAST=$(cat "$COOLDOWN_FILE" 2>/dev/null)
|
|
42
|
-
NOW=$(date +%s)
|
|
43
|
-
[ $(( NOW - ${LAST:-0} )) -lt "$COOLDOWN" ] && exit 0
|
|
44
|
-
fi
|
|
45
|
-
|
|
46
|
-
mkdir -p "$STATE_DIR"
|
|
47
|
-
date +%s > "$COOLDOWN_FILE"
|
|
48
|
-
|
|
49
|
-
# Write reaction to per-session file (read by the statusline via $SID)
|
|
50
|
-
jq -n --arg r "$COMMENT" --arg ts "$(date +%s)000" \
|
|
51
|
-
'{reaction: $r, timestamp: ($ts | tonumber), reason: "turn"}' \
|
|
52
|
-
> "$STATE_DIR/reaction.$SID.json"
|
|
53
|
-
|
|
54
|
-
# Increment achievement event counters and award XP
|
|
55
|
-
if command -v jq >/dev/null 2>&1; then
|
|
56
|
-
if [ ! -f "$EVENTS_FILE" ]; then
|
|
57
|
-
echo '{}' > "$EVENTS_FILE"
|
|
58
|
-
fi
|
|
59
|
-
TMP=$(mktemp)
|
|
60
|
-
jq '.turns = (.turns // 0 + 1)' "$EVENTS_FILE" > "$TMP" 2>/dev/null && mv "$TMP" "$EVENTS_FILE"
|
|
61
|
-
fi
|
|
62
|
-
|
|
63
|
-
# Award XP for turn (async, non-blocking)
|
|
64
|
-
if [ -x "$(command -v bun)" ]; then
|
|
65
|
-
PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
66
|
-
bun run "$PLUGIN_ROOT/server/award-xp.ts" "turn" >/dev/null 2>&1 &
|
|
67
|
-
fi
|
|
68
|
-
|
|
69
|
-
# Consolidate memory (async, non-blocking)
|
|
70
|
-
# Extract project, bug, and preference signals from conversation
|
|
71
|
-
if [ -x "$(command -v bun)" ]; then
|
|
72
|
-
PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
73
|
-
# Pass the assistant message for analysis (pass empty string for user prompt if unavailable)
|
|
74
|
-
USER_MSG=$(echo "$INPUT" | jq -r '.last_user_message // ""' 2>/dev/null)
|
|
75
|
-
bun run "$PLUGIN_ROOT/server/consolidate.ts" \
|
|
76
|
-
"$(echo "$MSG" | jq -Rs .)" \
|
|
77
|
-
"$(echo "$USER_MSG" | jq -Rs .)" \
|
|
78
|
-
>/dev/null 2>&1 &
|
|
79
|
-
fi
|
|
80
|
-
|
|
81
|
-
exit 0
|
|
2
|
+
set +e
|
|
3
|
+
SCRIPT_DIR="${BASH_SOURCE[0]%/*}"
|
|
4
|
+
ROOT="$(cd "$SCRIPT_DIR/.." 2>/dev/null && pwd)" || exit 0
|
|
5
|
+
[ -n "$ROOT" ] || exit 0
|
|
6
|
+
BUN="$(command -v bun 2>/dev/null || true)"
|
|
7
|
+
[ -x "$BUN" ] || BUN="$HOME/.bun/bin/bun"
|
|
8
|
+
[ -x "$BUN" ] || BUN="/opt/homebrew/bin/bun"
|
|
9
|
+
[ -x "$BUN" ] || BUN="/usr/local/bin/bun"
|
|
10
|
+
[ -x "$BUN" ] || exit 0
|
|
11
|
+
exec "$BUN" run "$ROOT/server/hooks/buddy-comment.ts" || exit 0
|