diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 77df2b6..68c9c0d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "code-mode", - "version": "0.12.16", + "version": "0.12.17", "description": "Code mode for MCP: the model writes one JavaScript program that calls MCP tools in a sandbox, instead of one tool call per step.", "types": "./types/index.d.ts", "author": { diff --git a/hooks/hints.ts b/hooks/hints.ts index 3cfd79e..0c7b676 100644 --- a/hooks/hints.ts +++ b/hooks/hints.ts @@ -13,6 +13,7 @@ // its body is that one hint. A change proposal is a removal with the new // text of the hint in `replace`. +import { normalizePath } from './paths' import { splitToolName } from './protocol' export type HintScope = 'bundled' | 'user' | 'project' @@ -494,13 +495,7 @@ export const staleDecisionKeys = (keys: readonly string[], key: string, max = MA export const resolvePath = (path: string, home: string, cwd: string): string => { const full = path === '~' || path.startsWith('~/') ? home + path.slice(1) : path.startsWith('/') ? path : `${cwd}/${path}` - const parts: string[] = [] - for (const part of full.split('/')) { - if (part === '' || part === '.') continue - if (part === '..') parts.pop() - else parts.push(part) - } - return `/${parts.join('/')}` + return normalizePath(full) } /** True when `path` is `dir` or inside it (both absolute and normalized). */ diff --git a/hooks/paths.ts b/hooks/paths.ts index 87670f4..8d28a19 100644 --- a/hooks/paths.ts +++ b/hooks/paths.ts @@ -7,8 +7,23 @@ const APP = 'code-mode' const HINTS = 'hints' const FUNCTIONS = 'functions' -/** A path in code-mode's folder under `root`: HOME or the project root. */ -export const codeModePath = (root: string, ...parts: string[]): string => [root, '.claude', APP, ...parts].join('/') +/** An absolute path with empty, `.` and `..` parts resolved. */ +export const normalizePath = (path: string): string => { + const parts: string[] = [] + for (const part of path.split('/')) { + if (part === '' || part === '.') continue + if (part === '..') parts.pop() + else parts.push(part) + } + return `/${parts.join('/')}` +} + +/** + * A path in code-mode's folder under `root`: HOME or the project root. It is + * normalized, as isUnder needs: HOME can end in a slash or hold a double one. + */ +export const codeModePath = (root: string, ...parts: string[]): string => + normalizePath([root, '.claude', APP, ...parts].join('/')) /** The hint folder under HOME or the project root. */ export const hintsFolder = (root: string): string => codeModePath(root, HINTS) @@ -25,8 +40,12 @@ export const functionsFolder = (home: string): string => codeModePath(home, FUNC * where Claude Code installed it, under HOME. A folder loaded with --plugin-dir * is the person's own checkout, which they edit. */ -export const guardedDirs = (home: string, project: string, pluginRoot = ''): string[] => [ - ...(home ? [hintsFolder(home), functionsFolder(home)] : []), - ...(project ? [hintsFolder(project)] : []), - ...(home && pluginRoot.startsWith(`${home}/.claude/plugins/`) ? [pluginRoot] : []), -] +export const guardedDirs = (home: string, project: string, pluginRoot = ''): string[] => { + const plugin = normalizePath(pluginRoot) + const plugins = normalizePath(`${home}/.claude/plugins`) + return [ + ...(home ? [hintsFolder(home), functionsFolder(home)] : []), + ...(project ? [hintsFolder(project)] : []), + ...(home && plugin.startsWith(`${plugins}/`) ? [plugin] : []), + ] +} diff --git a/tests/hints.test.ts b/tests/hints.test.ts index 046a20d..4221ad9 100644 --- a/tests/hints.test.ts +++ b/tests/hints.test.ts @@ -266,6 +266,12 @@ describe('hints in the tools', () => { expect(text).toContain('Mail hint body.') }) + test('a user hint loads when HOME ends in a slash', async ($, on) => { + const { env } = await approvedHome(on, hintFile, { [MAIL]: 'claude.ai Mail' }) + env.HOME = `${HOME}/` + expect(textOf(await $.tool.call({ tool: TOOL.search_tools, query: 'send' }))).toContain('Mail hint body.') + }) + test('search_tools finds hints by identify when the server is named by UUID', async ($, on) => { await approvedHome( on, @@ -1526,9 +1532,9 @@ describe('the hint-file guard', () => { expect(isUnder('/a/bc', '/a/b')).toBe(false) }) - const session = (on: On) => { - fakeHome(on, {}) - on('session.root', () => ({ value: '/repo' })) + const session = (on: On, home = HOME, root = '/repo') => { + fakeHome(on, {}).env.HOME = home + on('session.root', () => ({ value: root })) on('session.cwd', () => ({ value: '/repo' })) // stands for the file tools: a call that gets past the guard is written on('tool.call', { tool: 'Write' }, () => ({ result: 'written' })) @@ -1571,6 +1577,32 @@ describe('the hint-file guard', () => { }) } + // HOME and the project root as the environment gives them, with a trailing + // or double slash; the folders do not exist yet, so no real path helps. + for (const home of ['/home/u/', '/', '/home//u']) { + test(`guards the folders when HOME is ${home}`, async ($, on) => { + session(on, home, '/repo/') + for (const file_path of [ + '~/.claude/code-mode/hints/x.md', + '~/.claude/code-mode/functions/x.md', + '/repo/.claude/code-mode/hints/x.md', + ]) + expect(textOf(await $.tool.call({ tool: 'Write', file_path, content: 'x' }))).toContain( + 'hint files and saved functions steer the model', + ) + }) + } + + test('guardedDirs normalizes its roots', () => { + const installed = '/home/u/.claude/plugins/cache/code-mode/1.0.0' + expect(guardedDirs('/home//u/', '/repo/', `${installed}/`)).toEqual([ + '/home/u/.claude/code-mode/hints', + '/home/u/.claude/code-mode/functions', + '/repo/.claude/code-mode/hints', + installed, + ]) + }) + test('lets other files through', async ($, on) => { session(on) expect(textOf(await $.tool.call({ tool: 'Write', file_path: '/repo/src/a.ts', content: 'x' }))).toBe('written')