Skip to content
Draft
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
6 changes: 4 additions & 2 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@
"mcpServers": {
"claude-buddy": {
"type": "stdio",
"command": "${CLAUDE_PLUGIN_ROOT}/server/mcp-launcher.sh",
"args": []
"command": "bun",
"args": [
"${CLAUDE_PLUGIN_ROOT}/server/index.ts"
]
}
}
}
57 changes: 35 additions & 22 deletions adapters/shared/file-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,22 @@ import {
import { spawn } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { FileBuddyStorage } from "./file-storage.ts";
import type { BuddyBones } from "../../core/engine.ts";
import type { Companion } from "../../core/model.ts";
/**
* Import specifier for a module the generated worker scripts below pull in.
*
* The workers are built as source text, so a bare path is pasted straight into
* a string literal. On Windows every backslash in C:\Users\... is then an
* escape sequence, and bun resolves what survives — C:Usersile-storage.ts —
* instead of the file. A file:// URL has no backslashes on any platform.
*/
function moduleSpecifier(relativePath: string): string {
return pathToFileURL(join(import.meta.dir, relativePath)).href;
}

const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
Expand All @@ -34,7 +47,7 @@ describe("cross-process locking", () => {
const storage = new FileBuddyStorage(stateDir);

const workerPath = join(parent, "worker.ts");
const workerCode = `import { FileBuddyStorage } from "${join(import.meta.dir, "file-storage.ts")}";\n` +
const workerCode = `import { FileBuddyStorage } from "${moduleSpecifier("file-storage.ts")}";\n` +
"const stateDir = process.argv[2];\n" +
"const count = Number(process.argv[3]);\n" +
"const storage = new FileBuddyStorage(stateDir);\n" +
Expand Down Expand Up @@ -72,7 +85,7 @@ describe("cross-process locking", () => {
mkdirSync(ompRoot, { recursive: true });

const workerPath = join(parent, "worker.ts");
const workerCode = `import { FileBuddyStorage } from "${join(import.meta.dir, "file-storage.ts")}";\n` +
const workerCode = `import { FileBuddyStorage } from "${moduleSpecifier("file-storage.ts")}";\n` +
"const stateDir = process.argv[2];\n" +
"const count = Number(process.argv[3]);\n" +
"const storage = new FileBuddyStorage(stateDir);\n" +
Expand Down Expand Up @@ -133,10 +146,10 @@ describe("cross-process locking", () => {
const holderPath = join(parent, "fresh-lock-holder.ts");
const holderCode =
`import { existsSync, mkdirSync, rmdirSync, watch, writeFileSync } from "node:fs";\n` +
`const lockDir = "${lockDir}";\n` +
`const readyFile = "${readyFile}";\n` +
`const releaseFile = "${releaseFile}";\n` +
`const parentDir = "${parent}";\n` +
`const lockDir = ${JSON.stringify(lockDir)};\n` +
`const readyFile = ${JSON.stringify(readyFile)};\n` +
`const releaseFile = ${JSON.stringify(releaseFile)};\n` +
`const parentDir = ${JSON.stringify(parent)};\n` +
`mkdirSync(lockDir, { recursive: true });\n` +
`let watcher;\n` +
`function releaseLock() {\n` +
Expand All @@ -152,15 +165,15 @@ describe("cross-process locking", () => {
writeFileSync(holderPath, holderCode, "utf8");

const incrementerPath = join(parent, "fresh-lock-incrementer.ts");
const fileStoragePath = join(import.meta.dir, "file-storage.ts");
const fileStoragePath = moduleSpecifier("file-storage.ts");
const startedFile = join(parent, "started");
const doneFile = join(parent, "done.json");
const incrementerCode =
`import { FileBuddyStorage } from "${fileStoragePath}";\n` +
`import { writeFileSync } from "node:fs";\n` +
`const stateDir = "${stateDir}";\n` +
`const startedFile = "${startedFile}";\n` +
`const doneFile = "${doneFile}";\n` +
`const stateDir = ${JSON.stringify(stateDir)};\n` +
`const startedFile = ${JSON.stringify(startedFile)};\n` +
`const doneFile = ${JSON.stringify(doneFile)};\n` +
`writeFileSync(startedFile, "");\n` +
`new FileBuddyStorage(stateDir).increment("commands_run");\n` +
`writeFileSync(doneFile, JSON.stringify({ commands_run: new FileBuddyStorage(stateDir).loadCounters().commands_run }));\n`;
Expand Down Expand Up @@ -214,9 +227,9 @@ describe("cross-process locking", () => {
mkdirSync(stateDir, { recursive: true });
const storage = new FileBuddyStorage(stateDir);

const fileStoragePath = join(import.meta.dir, "file-storage.ts");
const modelPath = join(import.meta.dir, "../../core/model.ts");
const enginePath = join(import.meta.dir, "../../core/engine.ts");
const fileStoragePath = moduleSpecifier("file-storage.ts");
const modelPath = moduleSpecifier("../../core/model.ts");
const enginePath = moduleSpecifier("../../core/engine.ts");

const workerPath = join(parent, "slot-worker.ts");
const workerCode =
Expand Down Expand Up @@ -269,9 +282,9 @@ describe("cross-process locking", () => {
mkdirSync(stateDir, { recursive: true });
const storage = new FileBuddyStorage(stateDir);

const fileStoragePath = join(import.meta.dir, "file-storage.ts");
const modelPath = join(import.meta.dir, "../../core/model.ts");
const enginePath = join(import.meta.dir, "../../core/engine.ts");
const fileStoragePath = moduleSpecifier("file-storage.ts");
const modelPath = moduleSpecifier("../../core/model.ts");
const enginePath = moduleSpecifier("../../core/engine.ts");

const workerPath = join(parent, "save-active-worker.ts");
const workerCode =
Expand Down Expand Up @@ -318,7 +331,7 @@ describe("cross-process locking", () => {
const stateDir = join(parent, "state");
mkdirSync(stateDir, { recursive: true });

const fileStoragePath = join(import.meta.dir, "file-storage.ts");
const fileStoragePath = moduleSpecifier("file-storage.ts");
const workerPath = join(parent, "identity-worker.ts");
const workerCode =
`import { FileBuddyStorage } from "${fileStoragePath}";\n` +
Expand Down Expand Up @@ -363,7 +376,7 @@ describe("cross-process locking", () => {
mkdirSync(stateDir, { recursive: true });

const workerPath = join(parent, "cleanup-worker.ts");
const workerCode = `import { FileBuddyStorage } from "${join(import.meta.dir, "file-storage.ts")}";\n` +
const workerCode = `import { FileBuddyStorage } from "${moduleSpecifier("file-storage.ts")}";\n` +
"const stateDir = process.argv[2];\n" +
"const count = Number(process.argv[3]);\n" +
"const storage = new FileBuddyStorage(stateDir);\n" +
Expand Down Expand Up @@ -401,9 +414,9 @@ describe("cross-process locking", () => {
const stateDir = join(parent, "state");
mkdirSync(stateDir, { recursive: true });
const storage = new FileBuddyStorage(stateDir);
const fileStoragePath = join(import.meta.dir, "file-storage.ts");
const enginePath = join(import.meta.dir, "../../core/engine.ts");
const modelPath = join(import.meta.dir, "../../core/model.ts");
const fileStoragePath = moduleSpecifier("file-storage.ts");
const enginePath = moduleSpecifier("../../core/engine.ts");
const modelPath = moduleSpecifier("../../core/model.ts");

const workerPath = join(parent, "ensure-companion-worker.ts");
const workerCode =
Expand Down Expand Up @@ -456,7 +469,7 @@ describe("cross-process locking", () => {
const stateDir = join(parent, "state");
mkdirSync(stateDir, { recursive: true });
const storage = new FileBuddyStorage(stateDir);
const fileStoragePath = join(import.meta.dir, "file-storage.ts");
const fileStoragePath = moduleSpecifier("file-storage.ts");

const powerUser = ACHIEVEMENTS.find((a) => a.id === "power_user");
expect(powerUser).toBeDefined();
Expand Down
5 changes: 5 additions & 0 deletions cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { join, resolve, dirname } from "path";
import {
buddyStateDir,
claudeConfigDir,
findGitBash,
claudeSettingsPath,
claudeSkillDir,
claudeUserConfigPath,
Expand Down Expand Up @@ -80,6 +81,10 @@ row("OS", tryExec("uname -srm"));
row("Hostname", tryExec("uname -n"));
row("User shell", process.env.SHELL ?? "(unset)");
row("Bash version", tryExec("bash --version | head -1"));
if (process.platform === "win32") {
// What Claude Code runs hooks and the status line through; without it, PowerShell.
row("Git Bash (hooks)", findGitBash() ?? "(not found — hooks and status line cannot run)");
}
row("Bun version", tryExec("bun --version"));
row("Node version", tryExec("node --version", "(not installed)"));
row("jq version", tryExec("jq --version", "(not installed)"));
Expand Down
36 changes: 30 additions & 6 deletions cli/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { resolve, dirname, join } from "path";
import { generateBones, renderBuddy, renderFace, RARITY_STARS } from "../core/engine.ts"
import {
claudeConfigDir,
findGitBash,
buddyStateDir,
claudeSettingsPath,
claudeSkillDir,
Expand Down Expand Up @@ -83,13 +84,36 @@ function preflight(): boolean {
execSync("jq --version", { stdio: "ignore" });
ok("jq found");
} catch {
warn("jq not found — installing...");
try {
execSync("sudo apt-get install -y jq 2>/dev/null || brew install jq 2>/dev/null", { stdio: "ignore" });
ok("jq installed");
} catch {
err("Could not install jq. Install manually: apt install jq / brew install jq / windows: install from https://github.com/jqlang/jq/releases/latest and add to PATH");
if (process.platform === "win32") {
// apt-get and brew don't exist here; the attempt below could only fail.
err("jq not found. Install: winget install jqlang.jq — then open a new terminal so PATH picks it up");
pass = false;
} else {
warn("jq not found — installing...");
try {
execSync("sudo apt-get install -y jq 2>/dev/null || brew install jq 2>/dev/null", { stdio: "ignore" });
ok("jq installed");
} catch {
err("Could not install jq. Install manually: apt install jq / brew install jq");
pass = false;
}
}
}

// Windows: Claude Code runs hooks and the status line through Git Bash, and
// falls back to PowerShell when it finds none — where every hook and the
// status line registered below are .sh scripts that cannot run. The MCP
// server and /buddy still work, so this warns instead of aborting.
if (process.platform === "win32") {
const gitBash = findGitBash();
if (gitBash) {
ok(`Git Bash found (${gitBash})`);
} else {
warn(
"Git Bash not found — Claude Code will run hooks and the status line through PowerShell, " +
"where they cannot work. Install Git for Windows (https://git-scm.com/download/win), " +
"or set CLAUDE_CODE_GIT_BASH_PATH if bash.exe lives somewhere else.",
);
}
}

Expand Down
15 changes: 9 additions & 6 deletions cli/runtime-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ afterEach(() => {

describe("stable runtime app", () => {
test("derives all registrations below the per-user app directory", () => {
const paths = stableRuntimePaths("/home/user/.claude-buddy/app");
const appDir = "/home/user/.claude-buddy/app";
const paths = stableRuntimePaths(appDir);

expect(paths.mcpServer).toBe("/home/user/.claude-buddy/app/server/index.ts");
expect(paths.mcpLauncher).toBe("/home/user/.claude-buddy/app/server/mcp-launcher.sh");
expect(paths.statusline).toBe("/home/user/.claude-buddy/app/statusline/buddy-status.sh");
expect(paths.combinedStatusline).toBe("/home/user/.claude-buddy/app/statusline/combined-status.sh");
expect(paths.hooks).toBe("/home/user/.claude-buddy/app/hooks");
// join(), not a literal: stableRuntimePaths builds with the platform
// separator, so a "/"-spelled literal only matches on POSIX.
expect(paths.mcpServer).toBe(join(appDir, "server", "index.ts"));
expect(paths.mcpLauncher).toBe(join(appDir, "server", "mcp-launcher.sh"));
expect(paths.statusline).toBe(join(appDir, "statusline", "buddy-status.sh"));
expect(paths.combinedStatusline).toBe(join(appDir, "statusline", "combined-status.sh"));
expect(paths.hooks).toBe(join(appDir, "hooks"));
});

test("refreshes the copy and removes stale runtime files", () => {
Expand Down
3 changes: 2 additions & 1 deletion cli/uninstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ try {
}
// Clean up all session-scoped files
const patterns = ["popup-stop.", "popup-resize.", "popup-env.", "popup-scroll.",
"reaction.", ".last_reaction.", ".last_comment."];
"reaction.", ".last_reaction.", ".last_comment.",
".last_stop_hook."];
for (const f of readdirSync(STATE_DIR)) {
if (patterns.some(p => f.startsWith(p))) {
rmSync(join(STATE_DIR, f), { force: true });
Expand Down
12 changes: 6 additions & 6 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/react.sh",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/react.sh",
"timeout": 15
}
]
Expand All @@ -16,7 +16,7 @@
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/file-type-react.sh",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/file-type-react.sh",
"timeout": 15
}
]
Expand All @@ -27,7 +27,7 @@
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/buddy-comment.sh",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/buddy-comment.sh",
"timeout": 15
}
]
Expand All @@ -36,7 +36,7 @@
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/suggest.sh",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/suggest.sh",
"timeout": 15
}
]
Expand All @@ -47,7 +47,7 @@
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/name-react.sh",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/name-react.sh",
"timeout": 15
}
]
Expand All @@ -56,7 +56,7 @@
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/mood-react.sh",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/mood-react.sh",
"timeout": 15
}
]
Expand Down
35 changes: 35 additions & 0 deletions server/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,41 @@ describe("shipped plugin manifests", () => {
for (const timeout of timeouts) expect(timeout).toBe(15);
});

// Claude Code substitutes ${CLAUDE_PLUGIN_ROOT} as a plain string and runs
// the command through bash — Git Bash on Windows. Unquoted, the backslashes
// in a Windows plugin root are eaten as escapes (C:\Users\... becomes
// C:Users...) and the same happens to a space in a POSIX root, so the hook
// silently never runs. See the Windows notes in the hooks and statusline docs.
test("hooks/hooks.json: every hook command quotes ${CLAUDE_PLUGIN_ROOT}", () => {
const manifest = JSON.parse(
readFileSync(join(REPO_ROOT, "hooks", "hooks.json"), "utf8"),
);

const commands: string[] = [];
for (const hookType of Object.keys(manifest.hooks ?? {})) {
for (const entry of manifest.hooks[hookType]) {
for (const hook of entry.hooks ?? []) {
if (hook.type === "command") commands.push(hook.command);
}
}
}

expect(commands.length).toBeGreaterThan(0);
for (const cmd of commands) {
expect(cmd.replaceAll('"${CLAUDE_PLUGIN_ROOT}"', "")).not.toContain("${CLAUDE_PLUGIN_ROOT}");
}
});

// An MCP stdio server is spawned directly, with no shell in between, and
// Windows cannot spawn a .sh file on its own: the launcher script failed
// there with CONNECTION_CLOSED.
test(".claude-plugin/plugin.json: MCP server is not launched through a shell script", () => {
const manifest = JSON.parse(
readFileSync(join(REPO_ROOT, ".claude-plugin", "plugin.json"), "utf8"),
);
expect(manifest.mcpServers["claude-buddy"].command).not.toMatch(/\.sh$/);
});

test(".claude-plugin/plugin.json: MCP server command resolves plugin-root-absolute", () => {
const manifest = JSON.parse(
readFileSync(join(REPO_ROOT, ".claude-plugin", "plugin.json"), "utf8"),
Expand Down
Loading