diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1fdc0ee..d9a05bc 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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" + ] } } } diff --git a/adapters/shared/file-storage.test.ts b/adapters/shared/file-storage.test.ts index 80e8551..34613ff 100644 --- a/adapters/shared/file-storage.test.ts +++ b/adapters/shared/file-storage.test.ts @@ -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)) { @@ -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" + @@ -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" + @@ -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` + @@ -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`; @@ -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 = @@ -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 = @@ -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` + @@ -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" + @@ -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 = @@ -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(); diff --git a/cli/doctor.ts b/cli/doctor.ts index 39ad467..7e339d6 100644 --- a/cli/doctor.ts +++ b/cli/doctor.ts @@ -14,6 +14,7 @@ import { join, resolve, dirname } from "path"; import { buddyStateDir, claudeConfigDir, + findGitBash, claudeSettingsPath, claudeSkillDir, claudeUserConfigPath, @@ -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)")); diff --git a/cli/install.ts b/cli/install.ts index d32514a..bfe801c 100644 --- a/cli/install.ts +++ b/cli/install.ts @@ -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, @@ -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; } } diff --git a/cli/runtime-app.test.ts b/cli/runtime-app.test.ts index b522fee..0baf2f8 100644 --- a/cli/runtime-app.test.ts +++ b/cli/runtime-app.test.ts @@ -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", () => { diff --git a/cli/uninstall.ts b/cli/uninstall.ts index 7bc109d..8d88ad8 100644 --- a/cli/uninstall.ts +++ b/cli/uninstall.ts @@ -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 }); diff --git a/hooks/hooks.json b/hooks/hooks.json index a198001..e1921cb 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/react.sh", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/react.sh", "timeout": 15 } ] @@ -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 } ] @@ -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 } ] @@ -36,7 +36,7 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/suggest.sh", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/suggest.sh", "timeout": 15 } ] @@ -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 } ] @@ -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 } ] diff --git a/server/manifest.test.ts b/server/manifest.test.ts index ce31d07..8c8f20d 100644 --- a/server/manifest.test.ts +++ b/server/manifest.test.ts @@ -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"), diff --git a/server/path.test.ts b/server/path.test.ts index a94ebca..c6c016d 100644 --- a/server/path.test.ts +++ b/server/path.test.ts @@ -16,6 +16,7 @@ import { tmpdir } from "os"; import { buddyStateDir, buddyAppDir, + findGitBash, claudeConfigDir, claudeSettingsPath, claudeSkillDir, @@ -53,8 +54,10 @@ describe("claudeSettingsPath / claudeSkillDir", () => { test("puts settings.json inside the active config dir", () => { process.env.CLAUDE_CONFIG_DIR = "/tmp/profile-a"; - expect(claudeSettingsPath()).toBe("/tmp/profile-a/settings.json"); - expect(claudeSkillDir("buddy")).toBe("/tmp/profile-a/skills/buddy"); + // join(), not a "/"-spelled literal: the resolvers build paths with the + // platform separator, so a literal only matches on POSIX. + expect(claudeSettingsPath()).toBe(join("/tmp/profile-a", "settings.json")); + expect(claudeSkillDir("buddy")).toBe(join("/tmp/profile-a", "skills", "buddy")); }); test("falls back to ~/.claude when CLAUDE_CONFIG_DIR is unset", () => { @@ -102,7 +105,7 @@ describe("buddyStateDir", () => { test("CLAUDE_CONFIG_DIR puts state inside the profile dir", () => { process.env.CLAUDE_CONFIG_DIR = "/tmp/profile"; - expect(buddyStateDir()).toBe("/tmp/profile/buddy-state"); + expect(buddyStateDir()).toBe(join("/tmp/profile", "buddy-state")); }); test("default is ~/.claude-buddy when CLAUDE_CONFIG_DIR is unset", () => { @@ -116,6 +119,34 @@ describe("buddyAppDir", () => { test("places the stable runtime copy below the profile state dir", () => { process.env.CLAUDE_CONFIG_DIR = "/tmp/profile"; - expect(buddyAppDir()).toBe("/tmp/profile/buddy-state/app"); + expect(buddyAppDir()).toBe(join("/tmp/profile", "buddy-state", "app")); + }); +}); + +/** + * Environment and the existence check are injected, so these run the same on + * Linux CI as on Windows. + */ +describe("findGitBash", () => { + const programFiles = String.raw`C:\Program Files`; + const gitBash = join(programFiles, "Git", "bin", "bash.exe"); + + test("finds the default Git for Windows install", () => { + expect(findGitBash({ ProgramFiles: programFiles }, (p) => p === gitBash)).toBe(gitBash); + }); + + test("CLAUDE_CODE_GIT_BASH_PATH wins, since Claude Code honours it too", () => { + const custom = String.raw`D:\tools\git\bin\bash.exe`; + expect(findGitBash({ CLAUDE_CODE_GIT_BASH_PATH: custom, ProgramFiles: programFiles }, () => true)).toBe(custom); + }); + + test("finds a per-user install under LOCALAPPDATA", () => { + const local = String.raw`C:\Users\dev\AppData\Local`; + const perUser = join(local, "Programs", "Git", "bin", "bash.exe"); + expect(findGitBash({ LOCALAPPDATA: local }, (p) => p === perUser)).toBe(perUser); + }); + + test("returns undefined when nothing is installed", () => { + expect(findGitBash({ ProgramFiles: programFiles }, () => false)).toBeUndefined(); }); }); diff --git a/server/path.ts b/server/path.ts index 5067fda..3149f68 100644 --- a/server/path.ts +++ b/server/path.ts @@ -9,6 +9,7 @@ // // The shell counterpart of (2) lives in scripts/paths.sh and MUST stay in sync. +import { existsSync } from "fs"; import { join } from "path"; import { homedir } from "os"; import { sharedStateDir } from "../core/identity.ts"; @@ -29,6 +30,28 @@ export function toUnixPath(p: string): string { return p.replace(/\\/g, "/"); } +/** + * Locate the Git Bash that Claude Code will use on Windows, or undefined. + * + * Claude Code runs hook and status line commands through Git Bash there, and + * falls back to PowerShell when it cannot find one — where none of buddy's + * `.sh` hooks or its status line can run. CLAUDE_CODE_GIT_BASH_PATH is + * Claude Code's own override, so it is checked first; then the locations + * Git for Windows installs to. A miss here only means a warning at install. + */ +export function findGitBash( + env: Record = process.env, + exists: (path: string) => boolean = existsSync, +): string | undefined { + const candidates = [ + env.CLAUDE_CODE_GIT_BASH_PATH, + join(env.ProgramFiles ?? "C:\\Program Files", "Git", "bin", "bash.exe"), + join(env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)", "Git", "bin", "bash.exe"), + env.LOCALAPPDATA ? join(env.LOCALAPPDATA, "Programs", "Git", "bin", "bash.exe") : undefined, + ]; + return candidates.find((candidate): candidate is string => Boolean(candidate) && exists(candidate!)); +} + // ─── (2) Claude config / state path resolvers ─────────────────────────────── // // Resolution rules: diff --git a/server/paths_sh.test.ts b/server/paths_sh.test.ts index a7c05dc..85f1bb9 100644 --- a/server/paths_sh.test.ts +++ b/server/paths_sh.test.ts @@ -13,6 +13,21 @@ import { join, resolve } from "path"; const PATHS_SH = resolve(import.meta.dir, "..", "scripts", "paths.sh"); +/** + * One notation for both sides of the comparison. + * + * `paths.sh` answers in the notation its bash uses. On Linux and macOS that is + * already what `join()` produces. On Windows, Git Bash says + * `/c/Users/dev/.claude` where Node says `C:\Users\dev\.claude` — the same + * directory, so compare them in the shell's notation rather than asserting + * that Windows is Linux. Identity on POSIX. + */ +function posixPath(path: string): string { + const slashed = path.replace(/\\/g, "/"); + const drive = slashed.match(/^([A-Za-z]):\//); + return drive ? `/${drive[1].toLowerCase()}/${slashed.slice(3)}` : slashed; +} + type Env = Record; /** Run `source paths.sh` in bash under the given env overrides and @@ -41,7 +56,7 @@ printf 'BUDDY_SID=%s\\n' "$BUDDY_SID"`; const parsed: Record = {}; for (const line of result.stdout.split("\n").filter(Boolean)) { const idx = line.indexOf("="); - parsed[line.slice(0, idx)] = line.slice(idx + 1); + parsed[line.slice(0, idx)] = posixPath(line.slice(idx + 1)); } return parsed; } @@ -50,19 +65,19 @@ describe("scripts/paths.sh (CLAUDE_CONFIG_DIR unset)", () => { const env = sourcePaths({ CLAUDE_CONFIG_DIR: null }); test("CLAUDE_CFG_DIR defaults to $HOME/.claude", () => { - expect(env.CLAUDE_CFG_DIR).toBe(join(homedir(), ".claude")); + expect(env.CLAUDE_CFG_DIR).toBe(posixPath(join(homedir(), ".claude"))); }); test("CLAUDE_SETTINGS_FILE points under the config dir", () => { - expect(env.CLAUDE_SETTINGS_FILE).toBe(join(homedir(), ".claude", "settings.json")); + expect(env.CLAUDE_SETTINGS_FILE).toBe(posixPath(join(homedir(), ".claude", "settings.json"))); }); test("CLAUDE_USER_CONFIG is $HOME/.claude.json", () => { - expect(env.CLAUDE_USER_CONFIG).toBe(join(homedir(), ".claude.json")); + expect(env.CLAUDE_USER_CONFIG).toBe(posixPath(join(homedir(), ".claude.json"))); }); test("BUDDY_STATE_DIR defaults to $HOME/.claude-buddy", () => { - expect(env.BUDDY_STATE_DIR).toBe(join(homedir(), ".claude-buddy")); + expect(env.BUDDY_STATE_DIR).toBe(posixPath(join(homedir(), ".claude-buddy"))); }); }); @@ -71,9 +86,9 @@ describe("scripts/paths.sh (CLAUDE_CONFIG_DIR set)", () => { const profile = mkdtempSync(join(tmpdir(), "claude-buddy-sh-")); try { const env = sourcePaths({ CLAUDE_CONFIG_DIR: profile }); - expect(env.CLAUDE_CFG_DIR).toBe(profile); - expect(env.CLAUDE_SETTINGS_FILE).toBe(join(profile, "settings.json")); - expect(env.BUDDY_STATE_DIR).toBe(join(profile, "buddy-state")); + expect(env.CLAUDE_CFG_DIR).toBe(posixPath(profile)); + expect(env.CLAUDE_SETTINGS_FILE).toBe(posixPath(join(profile, "settings.json"))); + expect(env.BUDDY_STATE_DIR).toBe(posixPath(join(profile, "buddy-state"))); } finally { rmSync(profile, { recursive: true, force: true }); } @@ -84,7 +99,7 @@ describe("scripts/paths.sh (CLAUDE_CONFIG_DIR set)", () => { try { writeFileSync(join(profile, ".claude.json"), "{}"); const env = sourcePaths({ CLAUDE_CONFIG_DIR: profile }); - expect(env.CLAUDE_USER_CONFIG).toBe(join(profile, ".claude.json")); + expect(env.CLAUDE_USER_CONFIG).toBe(posixPath(join(profile, ".claude.json"))); } finally { rmSync(profile, { recursive: true, force: true }); } @@ -97,7 +112,7 @@ describe("scripts/paths.sh (CLAUDE_CONFIG_DIR set)", () => { // enabling buddy in a profile could mutate the home-level file. try { const env = sourcePaths({ CLAUDE_CONFIG_DIR: profile }); - expect(env.CLAUDE_USER_CONFIG).toBe(join(profile, ".claude.json")); + expect(env.CLAUDE_USER_CONFIG).toBe(posixPath(join(profile, ".claude.json"))); } finally { rmSync(profile, { recursive: true, force: true }); } @@ -113,7 +128,7 @@ describe("scripts/paths.sh shared identity opt-in", () => { expect(env.CLAUDE_CFG_DIR).toBe("/tmp/profile"); expect(env.CLAUDE_SETTINGS_FILE).toBe("/tmp/profile/settings.json"); - expect(env.BUDDY_STATE_DIR).toBe(join(homedir(), ".coding-buddy", "shared")); + expect(env.BUDDY_STATE_DIR).toBe(posixPath(join(homedir(), ".coding-buddy", "shared"))); }); test("honors an explicit shared state root", () => { diff --git a/server/state.ts b/server/state.ts index 875b248..4340a2d 100644 --- a/server/state.ts +++ b/server/state.ts @@ -634,6 +634,7 @@ const TRANSIENT_PREFIXES = [ "reaction.", ".last_reaction.", ".last_comment.", + ".last_stop_hook.", ".substatus.", ]; diff --git a/server/uninstall.test.ts b/server/uninstall.test.ts index 13cf799..4fc8be3 100644 --- a/server/uninstall.test.ts +++ b/server/uninstall.test.ts @@ -66,6 +66,7 @@ describe("cleanupPluginState", () => { writeFileSync(join(stateDir, "reaction.default.json"), "{}"); writeFileSync(join(stateDir, ".last_reaction.pane-1"), "0"); writeFileSync(join(stateDir, ".last_comment.default"), "0"); + writeFileSync(join(stateDir, ".last_stop_hook.default"), "0"); writeFileSync(join(stateDir, "popup-stop.42"), ""); writeFileSync(join(stateDir, "popup-reopen-pid.42"), "99999"); writeFileSync(join(stateDir, "popup-env.42"), ""); @@ -77,7 +78,7 @@ describe("cleanupPluginState", () => { const result = cleanupPluginState(settingsPath, stateDir); - expect(result.transientFilesRemoved).toBe(9); + expect(result.transientFilesRemoved).toBe(10); const remaining = readdirSync(stateDir).sort(); expect(remaining).toEqual(["config.json", "menagerie.json", "status.json"]); }); diff --git a/statusline/buddy-status.sh b/statusline/buddy-status.sh index b8d1c15..5dd6c8b 100755 --- a/statusline/buddy-status.sh +++ b/statusline/buddy-status.sh @@ -307,6 +307,17 @@ _sweep_expired_reactions() { *) [ "$ts" -le "$cutoff_seconds" ] 2>/dev/null && rm -f "$file" 2>/dev/null ;; esac done + + # buddy-comment.ts stamps this once per Stop event, in the same epoch + # seconds as .last_comment, and nothing else ever removes it. + for file in "$BUDDY_STATE_DIR"/.last_stop_hook.*; do + [ -f "$file" ] || continue + ts=$(cat "$file" 2>/dev/null) + case "$ts" in + ''|*[!0-9]*) rm -f "$file" 2>/dev/null ;; + *) [ "$ts" -le "$cutoff_seconds" ] 2>/dev/null && rm -f "$file" 2>/dev/null ;; + esac + done } _sweep_expired_reactions diff --git a/statusline/buddy-status.test.ts b/statusline/buddy-status.test.ts index 3c840d9..a920915 100644 --- a/statusline/buddy-status.test.ts +++ b/statusline/buddy-status.test.ts @@ -317,11 +317,13 @@ describe("buddy statusline colors", () => { const { configDir, stateDir } = createStatuslineFixture({ reactionTTL: 30 }); const staleReaction = join(stateDir, "reaction.default.json"); const staleComment = join(stateDir, ".last_comment.default"); + const staleStopMarker = join(stateDir, ".last_stop_hook.default"); writeFileSync(staleReaction, JSON.stringify({ reaction: "stale reaction", timestamp: Date.now() - 60_000, })); writeFileSync(staleComment, String(Math.floor(Date.now() / 1000) - 60)); + writeFileSync(staleStopMarker, String(Math.floor(Date.now() / 1000) - 60)); const result = runStatusline(configDir); const output = result.stdout.toString(); @@ -330,6 +332,7 @@ describe("buddy statusline colors", () => { expect(output).not.toContain("stale reaction"); expect(existsSync(staleReaction)).toBe(false); expect(existsSync(staleComment)).toBe(false); + expect(existsSync(staleStopMarker)).toBe(false); expect(output).not.toMatch(/^ *\.[-]{12,}\.$/m); });