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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
56 changes: 50 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,12 +84,55 @@ 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") {
// Same auto-install the POSIX branch below does, with the package
// manager Windows 10+ ships. apt-get and brew cannot work here.
warn("jq not found — installing with winget...");
try {
execSync(
"winget install --id jqlang.jq --exact --silent --accept-package-agreements --accept-source-agreements",
{ stdio: "ignore" },
);
// winget puts jq on PATH for new shells, not for this process.
ok("jq installed — reopen your terminal before using the status line");
} catch {
err("Could not install jq. Install manually: winget install --id jqlang.jq");
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 this installer registers is a .sh script that cannot run.
// Everything below would be dead config, so stop rather than write it.
// Git for Windows is not auto-installed like jq: it is a machine-wide
// install behind a UAC prompt, which an installer should not spring on
// anyone. Only the MCP server and /buddy survive without it, so
// CODING_BUDDY_SKIP_BASH_CHECK is there for whoever wants just those.
if (process.platform === "win32") {
const gitBash = findGitBash();
if (gitBash) {
ok(`Git Bash found (${gitBash})`);
} else if (process.env.CODING_BUDDY_SKIP_BASH_CHECK) {
warn("Git Bash not found — continuing anyway (CODING_BUDDY_SKIP_BASH_CHECK). Hooks and the status line will not run.");
} else {
err(
"Git Bash not found. Claude Code needs it to run the hooks and the status line on Windows.\n" +
" Install it: winget install --id Git.Git\n" +
" Then reopen your terminal and run install-buddy again.\n" +
" Already installed elsewhere? Point CLAUDE_CODE_GIT_BASH_PATH at bash.exe.\n" +
" Only want the MCP tools? Set CODING_BUDDY_SKIP_BASH_CHECK=1.",
);
pass = false;
}
}
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