Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
9 changes: 2 additions & 7 deletions hooks/hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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). */
Expand Down
33 changes: 26 additions & 7 deletions hooks/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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] : []),
]
}
38 changes: 35 additions & 3 deletions tests/hints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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' }))
Expand Down Expand Up @@ -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')
Expand Down
Loading