diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..bb1d357e --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1772624091, + "narHash": "sha256-QKyJ0QGWBn6r0invrMAK8dmJoBYWoOWy7lN+UHzW1jc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "80bdc1e5ce51f56b19791b52b2901187931f5353", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/src/config.ts b/src/config.ts index 80830a02..eb93207c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -227,6 +227,42 @@ const normalizeConfig = (config: TweakccConfig): void => { ); } + // Validate each customTool entry — drop entries missing required fields. + if (!Array.isArray(config.settings.customTools)) { + console.warn('config: customTools must be an array; ignoring invalid value'); + config.settings.customTools = []; + } else { + config.settings.customTools = config.settings.customTools.filter( + (tool, index) => { + const invalidKeys: string[] = []; + if (typeof tool?.name !== 'string' || tool.name.trim() === '') + invalidKeys.push('name'); + if (typeof tool?.command !== 'string' || tool.command.trim() === '') + invalidKeys.push('command'); + if ( + typeof tool?.description !== 'string' || + tool.description.trim() === '' + ) + invalidKeys.push('description'); + if ( + !tool?.parameters || + typeof tool.parameters !== 'object' || + Array.isArray(tool.parameters) + ) + invalidKeys.push('parameters'); + if (invalidKeys.length > 0) { + const name = + typeof tool?.name === 'string' ? ` "${tool.name}"` : ''; + console.warn( + `config: customTools: dropping invalid tool at index ${index}${name} (invalid/missing: ${invalidKeys.join(', ')})` + ); + return false; + } + return true; + } + ); + } + // In v3.2.0 userMessageDisplay was restructured from prefix/message to a single format string. migrateUserMessageDisplayToV320(config); diff --git a/src/defaultSettings.ts b/src/defaultSettings.ts index c616bf9a..4c3e5f88 100644 --- a/src/defaultSettings.ts +++ b/src/defaultSettings.ts @@ -722,6 +722,7 @@ export const DEFAULT_SETTINGS: Settings = { toolsets: [], defaultToolset: null, planModeToolset: null, + customTools: [], subagentModels: { plan: null, explore: null, diff --git a/src/installationDetection.ts b/src/installationDetection.ts index 1e1da824..6bb70158 100644 --- a/src/installationDetection.ts +++ b/src/installationDetection.ts @@ -12,6 +12,7 @@ import { } from './utils'; import { extractClaudeJsFromNativeInstallation, + getNativeModuleLoadError, resolveNixBinaryWrapper, } from './nativeInstallationLoader'; import { CLIJS_SEARCH_PATHS, NATIVE_SEARCH_PATHS } from './installationPaths'; @@ -384,7 +385,17 @@ async function extractVersionFromNativeBinary( await extractClaudeJsFromNativeInstallation(binaryPath); if (!claudeJsBuffer) { - throw new Error(`Could not extract JS from native binary: ${binaryPath}`); + const loadErr = getNativeModuleLoadError(); + const nixNote = loadErr?.includes('cannot open shared object') + ? '\n\nOn NixOS with Bun, native module loading often fails due to missing system libraries.\n' + + 'Try running tweakcc with Node.js instead:\n' + + ' nix shell nixpkgs#nodejs -c npx tweakcc' + : ''; + throw new Error( + `Could not extract JS from native binary: ${binaryPath}` + + (loadErr ? `\nReason: node-lief failed to load: ${loadErr}` : '') + + nixNote + ); } const content = claudeJsBuffer.toString('utf8'); diff --git a/src/nativeInstallation.ts b/src/nativeInstallation.ts index 2ee3eb0f..15d0ef05 100644 --- a/src/nativeInstallation.ts +++ b/src/nativeInstallation.ts @@ -490,6 +490,85 @@ function extractBunDataFromSection(sectionData: Buffer): BunData { }; } +const ELF_MAGIC = Buffer.from([0x7f, 0x45, 0x4c, 0x46]); + +/** Returns true if the file at filePath begins with the ELF magic bytes. */ +function isELFFile(filePath: string): boolean { + let fd: number | null = null; + try { + fd = fs.openSync(filePath, 'r'); + const buf = Buffer.allocUnsafe(4); + const bytesRead = fs.readSync(fd, buf, 0, 4, 0); + return bytesRead === 4 && buf.equals(ELF_MAGIC); + } catch { + return false; + } finally { + if (fd !== null) fs.closeSync(fd); + } +} + +/** + * Extracts Bun data from an ELF binary by reading the file tail directly, + * without using LIEF. Uses BunOffsets.byteCount (from the offsets struct) as + * the authoritative data region size. The trailing u64 footer is not used. + */ +function extractBunDataFromELFRaw(filePath: string): BunData { + const fd = fs.openSync(filePath, 'r'); + try { + const { size: fileSize } = fs.fstatSync(fd); + const tailSize = SIZEOF_OFFSETS + BUN_TRAILER.length + 8; + + if (fileSize < tailSize) { + throw new Error('File too small to contain Bun data'); + } + + const tailBuffer = Buffer.allocUnsafe(tailSize); + fs.readSync(fd, tailBuffer, 0, tailSize, fileSize - tailSize); + + const trailerStart = tailSize - 8 - BUN_TRAILER.length; + const trailerBytes = tailBuffer.subarray( + trailerStart, + trailerStart + BUN_TRAILER.length + ); + if (!trailerBytes.equals(BUN_TRAILER)) { + throw new Error('BUN trailer not found in ELF file'); + } + + const offsetsBytes = tailBuffer.subarray(0, SIZEOF_OFFSETS); + const bunOffsets = parseOffsets(offsetsBytes); + const byteCount = + typeof bunOffsets.byteCount === 'bigint' + ? Number(bunOffsets.byteCount) + : bunOffsets.byteCount; + + if (byteCount <= 0 || byteCount >= fileSize) { + throw new Error(`ELF byteCount out of range: ${byteCount}`); + } + + const dataStart = + fileSize - 8 - BUN_TRAILER.length - SIZEOF_OFFSETS - byteCount; + if (dataStart < 0) { + throw new Error('ELF data region extends before start of file'); + } + + const dataBuffer = Buffer.allocUnsafe(byteCount); + fs.readSync(fd, dataBuffer, 0, byteCount, dataStart); + + const bunDataBlob = Buffer.concat([dataBuffer, offsetsBytes, trailerBytes]); + const moduleStructSize = detectModuleStructSize( + bunOffsets.modulesPtr.length + ); + + debug( + `extractBunDataFromELFRaw: byteCount=${byteCount}, moduleStructSize=${moduleStructSize}` + ); + + return { bunOffsets, bunData: bunDataBlob, moduleStructSize }; + } finally { + fs.closeSync(fd); + } +} + /** * New ELF format (Bun >= 1.3.x, post-PR#26923): * Bun data is stored in a .bun ELF section, using the same @@ -705,9 +784,24 @@ export function extractClaudeJsFromNativeInstallation( nativeInstallationPath: string ): Buffer | null { try { - LIEF.logging.disable(); - const binary = LIEF.parse(nativeInstallationPath); - const { bunOffsets, bunData, moduleStructSize } = getBunData(binary); + let extracted: BunData | null = null; + + if (isELFFile(nativeInstallationPath)) { + try { + extracted = extractBunDataFromELFRaw(nativeInstallationPath); + } catch { + debug( + 'extractClaudeJsFromNativeInstallation: raw ELF extraction failed, falling back to LIEF' + ); + } + } + if (!extracted) { + LIEF.logging.disable(); + const binary = LIEF.parse(nativeInstallationPath); + extracted = getBunData(binary); + } + + const { bunOffsets, bunData, moduleStructSize } = extracted; debug( `extractClaudeJsFromNativeInstallation: Got bunData, size=${bunData.length} bytes, moduleStructSize=${moduleStructSize}` @@ -1376,6 +1470,78 @@ function repackELFOverlay( } } +/** Repacks a modified Bun buffer into an ELF binary by rewriting the file overlay. */ +function repackELFRaw( + binPath: string, + newBunBuffer: Buffer, + outputPath: string +): void { + const originalData = fs.readFileSync(binPath); + const fileSize = originalData.length; + + const tailSize = SIZEOF_OFFSETS + BUN_TRAILER.length + 8; + if (fileSize < tailSize) { + throw new Error('repackELFRaw: file too small to contain Bun data'); + } + const offsetsBytes = originalData.subarray( + fileSize - tailSize, + fileSize - tailSize + SIZEOF_OFFSETS + ); + const existingOffsets = parseOffsets(offsetsBytes); + const byteCount = + typeof existingOffsets.byteCount === 'bigint' + ? Number(existingOffsets.byteCount) + : existingOffsets.byteCount; + + const overlaySize = byteCount + SIZEOF_OFFSETS + BUN_TRAILER.length + 8; + const elfSize = fileSize - overlaySize; + + if (elfSize <= 0 || elfSize >= fileSize) { + throw new Error(`repackELFRaw: computed ELF size out of range: ${elfSize}`); + } + + debug( + `repackELFRaw: elfSize=${elfSize}, byteCount=${byteCount}, newBunBuffer=${newBunBuffer.length}` + ); + + const newOverlay = Buffer.allocUnsafe(newBunBuffer.length + 8); + newBunBuffer.copy(newOverlay, 0); + newOverlay.writeBigUInt64LE(BigInt(newBunBuffer.length), newBunBuffer.length); + + const newBinary = Buffer.concat([ + originalData.subarray(0, elfSize), + newOverlay, + ]); + + const tempPath = outputPath + '.tmp'; + fs.writeFileSync(tempPath, newBinary); + const origStat = fs.statSync(binPath); + fs.chmodSync(tempPath, origStat.mode); + try { + fs.renameSync(tempPath, outputPath); + } catch (error) { + try { + fs.unlinkSync(tempPath); + } catch { + // ignore cleanup errors + } + if ( + error instanceof Error && + 'code' in error && + (error.code === 'ETXTBSY' || + error.code === 'EBUSY' || + error.code === 'EPERM') + ) { + throw new Error( + 'Cannot update the Claude executable while it is running.\n' + + 'Please close all Claude instances and try again.' + ); + } + throw error; + } + debug('repackELFRaw: Write completed successfully'); +} + /** * Repacks a modified claude.js back into the native installation binary. * @@ -1394,10 +1560,28 @@ export function repackNativeInstallation( modifiedClaudeJs: Buffer, outputPath: string ): void { + if (isELFFile(binPath)) { + try { + const { bunOffsets, bunData, moduleStructSize } = + extractBunDataFromELFRaw(binPath); + const newBuffer = rebuildBunData( + bunData, + bunOffsets, + modifiedClaudeJs, + moduleStructSize + ); + repackELFRaw(binPath, newBuffer, outputPath); + return; + } catch { + debug( + 'repackNativeInstallation: raw ELF repack failed, falling back to LIEF' + ); + } + } + LIEF.logging.disable(); const binary = LIEF.parse(binPath); - // Extract Bun data and rebuild with modified claude.js const { bunOffsets, bunData, sectionHeaderSize, moduleStructSize } = getBunData(binary); const newBuffer = rebuildBunData( diff --git a/src/nativeInstallationLoader.ts b/src/nativeInstallationLoader.ts index 4beab26e..28a921b8 100644 --- a/src/nativeInstallationLoader.ts +++ b/src/nativeInstallationLoader.ts @@ -21,6 +21,7 @@ interface NativeInstallationModule { } let cachedModule: NativeInstallationModule | null = null; +let loadError: string | null = null; /** * Attempts to load the nativeInstallation module. @@ -36,11 +37,11 @@ async function tryLoadNativeInstallationModule(): Promise{A=H!=null?' + + // getReactModuleNameNonBun: var X=Y((Z)=>{var W=Symbol.for("react.element") + 'var rM=X((Z)=>{var W=Symbol.for("react.element")' + + // getReactVar non-bun: [^$\w]R=T(rM(),1) — semicolon is the non-word prefix + ';R=T(rM(),1)' + + // findTextComponent: function NAME({color:A,backgroundColor:B,dimColor:C=!1,bold:D=!1,...}) + 'function Tx({color:a,backgroundColor:b,dimColor:c=!1,bold:d=!1}){}' + + // findBoxComponent method 2: function NAME({children:T,flexWrap:F...}){...createElement("ink-box"...} + 'function Bx({children:ch,flexWrap:fw}){return R.createElement("ink-box",null,ch)}' + + // getCwdFuncName three-step chain + 'var ST={cwd:"/tmp"};' + + 'function gCS(){return ST.cwd}' + + 'function pwdF(){return gCS()}' + + 'function getCwdFn(){try{return pwdF()}catch(e){return"/"}}' + + // findBuildToolFunc: function NAME(PARAM){return{...DEFAULTS,userFacingName:()=>PARAM.name,...PARAM}} + 'const DEF={isEnabled:()=>!0};function bT(D1){return{...DEF,userFacingName:()=>D1.name,...D1}}'; + +// Strategy B fixture: original one-liner tool aggregation +const MOCK_STRATEGY_B = MOCK_BASE + 'let TOOLS=agg(ctx,state.tools,opts),x=1;'; + +// Strategy A fixture: toolsets patch has already rewritten into if/else +const MOCK_STRATEGY_A = + MOCK_BASE + + 'if(ts){TOOLS=agg(ctx,state.tools,opts).filter(t=>ts.includes(t.name));' + + '} else {TOOLS=agg(ctx,state.tools,opts);}let REST=1;'; + +const MINIMAL_TOOL: CustomTool = { + name: 'MyTool', + description: 'A test tool', + parameters: { + msg: { type: 'string', description: 'The message', required: true }, + }, + command: 'echo {{msg}}', +}; + +const OPTIONAL_PARAM_TOOL: CustomTool = { + name: 'OptTool', + description: 'Tool with optional param', + parameters: { + flag: { type: 'boolean', description: 'A flag', required: false }, + }, + command: 'run --flag={{flag}}', + shell: 'bash', + timeout: 5000, + workingDir: '/tmp/work', + env: { MY_VAR: 'hello' }, +}; + +const SPECIAL_PARAM_TOOL: CustomTool = { + name: 'RegexTool', + description: 'Tool with regex-special parameter names', + parameters: { + 'a.b': { type: 'string', description: 'Dot param', required: true }, + '$count': { type: 'string', description: 'Dollar param', required: true }, + 'foo/bar': { type: 'string', description: 'Slash param', required: true }, + }, + command: 'echo {{a.b}} {{foo/bar}} {{$count}}', +}; + +interface GeneratedToolValidationResult { + result: boolean; + message?: string; + errorCode?: number; +} + +interface GeneratedToolCallResult { + data: { + stdout: string; + stderr: string; + exitCode: number; + }; +} + +interface GeneratedTool { + prompt(): Promise; + validateInput(input: unknown): Promise; + toAutoClassifierInput(input: unknown): string; + call(args: unknown): Promise; +} + +const buildGeneratedTool = ( + tool: CustomTool +): { tool: GeneratedTool; spawnSync: ReturnType } => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const result = writeCustomTools(MOCK_STRATEGY_B, [tool]); + expect(result).not.toBeNull(); + + const toolsMatch = result!.match( + /let TOOLS=\[\.\.\.agg\(ctx,state\.tools,opts\),\.\.\.(\[[\s\S]*\])\],x=1;/ + ); + expect(toolsMatch).not.toBeNull(); + + const buildToolFunc = findBuildToolFunc(MOCK_BASE); + const requireFunc = getRequireFuncName(MOCK_BASE); + const cwdFunc = getCwdFuncName(MOCK_BASE); + + expect(buildToolFunc).toBeDefined(); + expect(cwdFunc).toBeDefined(); + + const spawnSync = vi.fn(() => ({ stdout: '', stderr: '', status: 0 })); + const tools = new Function( + buildToolFunc!, + 'R', + 'Tx', + 'Bx', + requireFunc, + cwdFunc!, + `return ${toolsMatch![1]};` + )( + (definition: unknown) => definition, + { createElement: (...args: unknown[]) => ({ args }) }, + 'Tx', + 'Bx', + (moduleName: string) => { + if (moduleName === 'child_process') { + return { spawnSync }; + } + + throw new Error(`Unexpected module: ${moduleName}`); + }, + () => '/cwd' + ) as unknown as GeneratedTool[]; + + return { tool: tools[0], spawnSync }; + } finally { + warn.mockRestore(); + } +}; + +// ============================================================================ +// HELPER TESTS +// ============================================================================ + +describe('findBuildToolFunc', () => { + it('detects buildTool in the mock bundle', () => { + expect(findBuildToolFunc(MOCK_BASE)).toBe('bT'); + }); + + it('handles different variable names', () => { + const code = + 'function xY$(p1){return{...DEFS,userFacingName:()=>p1.name,...p1}}'; + expect(findBuildToolFunc(code)).toBe('xY$'); + }); + + it('returns undefined when absent', () => { + expect(findBuildToolFunc('const x=1;')).toBeUndefined(); + }); +}); + +describe('getCwdFuncName', () => { + it('detects the full three-step chain', () => { + expect(getCwdFuncName(MOCK_BASE)).toBe('getCwdFn'); + }); + + it('falls back to pwd when no try-catch wrapper exists', () => { + const code = + 'var ST={cwd:"/x"};function gCS(){return ST.cwd}function pwdF(){return gCS()}'; + expect(getCwdFuncName(code)).toBe('pwdF'); + }); + + it('falls back to getCwdState when no pwd wrapper exists either', () => { + const code = 'var ST={cwd:"/x"};function gCS(){return ST.cwd}'; + expect(getCwdFuncName(code)).toBe('gCS'); + }); + + it('returns undefined when getCwdState is absent', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + expect(getCwdFuncName('const x=1;')).toBeUndefined(); + } finally { + spy.mockRestore(); + } + }); +}); + +// ============================================================================ +// writeCustomTools TESTS +// ============================================================================ + +describe('writeCustomTools', () => { + beforeEach(() => { + clearReactVarCache(); + clearRequireFuncNameCache(); + }); + + afterEach(() => { + clearReactVarCache(); + clearRequireFuncNameCache(); + }); + + describe('no-op cases', () => { + it('returns the original file when customTools is empty', () => { + expect(writeCustomTools(MOCK_STRATEGY_B, [])).toBe(MOCK_STRATEGY_B); + }); + }); + + describe('collision guard', () => { + it('returns null and logs error for a built-in tool name', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const result = writeCustomTools(MOCK_STRATEGY_B, [ + { ...MINIMAL_TOOL, name: 'Bash' }, + ]); + expect(result).toBeNull(); + expect(err).toHaveBeenCalledWith(expect.stringContaining('"Bash"')); + } finally { + err.mockRestore(); + } + }); + + it('returns null and logs error for duplicate custom tool names', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const result = writeCustomTools(MOCK_STRATEGY_B, [ + MINIMAL_TOOL, + { ...MINIMAL_TOOL }, + ]); + expect(result).toBeNull(); + expect(err).toHaveBeenCalledWith( + expect.stringContaining('duplicate custom tool name "MyTool"') + ); + } finally { + err.mockRestore(); + } + }); + }); + + describe('missing helper patterns', () => { + it('returns null when buildTool is not found', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const noBuildTool = MOCK_STRATEGY_B.replace( + /function bT\(D1\)\{return\{\.\.\.DEF,userFacingName:\(\)=>D1\.name,\.\.\.D1\}\}/, + '' + ); + expect(writeCustomTools(noBuildTool, [MINIMAL_TOOL])).toBeNull(); + expect(err).toHaveBeenCalledWith(expect.stringContaining('buildTool')); + } finally { + err.mockRestore(); + warn.mockRestore(); + } + }); + + it('returns null when the tool aggregation pattern is not found', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const noAgg = MOCK_BASE + 'const x=1;'; + expect(writeCustomTools(noAgg, [MINIMAL_TOOL])).toBeNull(); + expect(err).toHaveBeenCalledWith( + expect.stringContaining('tool aggregation pattern') + ); + } finally { + err.mockRestore(); + warn.mockRestore(); + } + }); + }); + + describe('Strategy B — original code injection', () => { + it('produces a non-null result', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect( + writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL]) + ).not.toBeNull(); + } finally { + warn.mockRestore(); + } + }); + + it('spreads custom tools into the tool aggregation variable', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL])!; + expect(result).toContain( + 'let TOOLS=[...agg(ctx,state.tools,opts),...[' + ); + } finally { + warn.mockRestore(); + } + }); + + it('calls buildTool (bT) to construct the custom tool', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL])!; + expect(result).toContain('bT({'); + } finally { + warn.mockRestore(); + } + }); + + it('embeds the tool name in the generated object', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL])!; + expect(result).toContain('"MyTool"'); + } finally { + warn.mockRestore(); + } + }); + + it('uses React.createElement (R) with Text (Tx) and Box (Bx) for rendering', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL])!; + expect(result).toContain('R.createElement(Bx,'); + expect(result).toContain('R.createElement(Tx,'); + } finally { + warn.mockRestore(); + } + }); + + it('uses the detected cwd function for workingDir', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL])!; + expect(result).toContain('getCwdFn()'); + } finally { + warn.mockRestore(); + } + }); + + it('uses explicit workingDir when provided', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = writeCustomTools(MOCK_STRATEGY_B, [ + OPTIONAL_PARAM_TOOL, + ])!; + expect(result).toContain('"/tmp/work"'); + } finally { + warn.mockRestore(); + } + }); + + it('delegates checkPermissions to BashTool', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL])!; + expect(result).toContain( + 'context.options.tools.find(t=>t.name==="Bash")' + ); + expect(result).toContain('bashTool.checkPermissions('); + } finally { + warn.mockRestore(); + } + }); + + it('includes validateInput for required parameters', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL])!; + expect(result).toContain('"msg is required"'); + expect(result).toContain('"msg must be a string"'); + } finally { + warn.mockRestore(); + } + }); + + it('does not add required check for optional params', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = writeCustomTools(MOCK_STRATEGY_B, [ + OPTIONAL_PARAM_TOOL, + ])!; + expect(result).not.toContain('"flag is required"'); + expect(result).toContain('"flag must be a boolean"'); + } finally { + warn.mockRestore(); + } + }); + + it('injects the command template into the generated code', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL])!; + expect(result).toContain('"echo {{msg}}"'); + } finally { + warn.mockRestore(); + } + }); + + it('handles multiple tools', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const tool2: CustomTool = { + name: 'SecondTool', + description: 'Another tool', + parameters: {}, + command: 'ls', + }; + const result = writeCustomTools(MOCK_STRATEGY_B, [ + MINIMAL_TOOL, + tool2, + ])!; + expect(result).toContain('"MyTool"'); + expect(result).toContain('"SecondTool"'); + } finally { + warn.mockRestore(); + } + }); + }); + + describe('generated tool runtime', () => { + it('honors explicit empty prompt overrides', async () => { + const { tool } = buildGeneratedTool({ ...MINIMAL_TOOL, prompt: '' }); + await expect(tool.prompt()).resolves.toBe(''); + }); + + it('treats $ replacement sequences literally in parameter values', async () => { + const { tool, spawnSync } = buildGeneratedTool(MINIMAL_TOOL); + const value = "$& $$ $` $'"; + + await tool.call({ msg: value }); + + expect(spawnSync).toHaveBeenCalledWith( + 'sh', + ['-c', `echo ${value}`], + expect.objectContaining({ cwd: '/cwd' }) + ); + }); + + it('returns structured errors for non-object input', async () => { + const { tool } = buildGeneratedTool(MINIMAL_TOOL); + + await expect(tool.validateInput(null)).resolves.toEqual({ + result: false, + message: 'input must be an object', + errorCode: 1, + }); + }); + + it('normalizes non-object input and args before spawning', async () => { + const { tool, spawnSync } = buildGeneratedTool(MINIMAL_TOOL); + + expect(tool.toAutoClassifierInput(undefined)).toBe('echo '); + await expect(tool.call(undefined)).resolves.toEqual({ + data: { stdout: '', stderr: '', exitCode: 0 }, + }); + expect(spawnSync).toHaveBeenCalledWith( + 'sh', + ['-c', 'echo '], + expect.objectContaining({ cwd: '/cwd' }) + ); + }); + + it('falls back to the default timeout for non-finite timeout values', async () => { + const { tool, spawnSync } = buildGeneratedTool({ + ...MINIMAL_TOOL, + timeout: Number.NaN, + }); + + await tool.call({ msg: 'ok' }); + + expect(spawnSync).toHaveBeenCalledWith( + 'sh', + ['-c', 'echo ok'], + expect.objectContaining({ timeout: 30000 }) + ); + }); + + it('returns stderr and exitCode -1 when spawnSync reports an error', async () => { + const { tool, spawnSync } = buildGeneratedTool(MINIMAL_TOOL); + spawnSync.mockReturnValueOnce({ + error: new Error('ETIMEDOUT'), + stdout: null, + stderr: null, + status: null, + }); + + await expect(tool.call(undefined)).resolves.toEqual({ + data: { stdout: '', stderr: 'ETIMEDOUT', exitCode: -1 }, + }); + expect(spawnSync).toHaveBeenCalledWith( + 'sh', + ['-c', 'echo '], + expect.objectContaining({ cwd: '/cwd' }) + ); + }); + + it('escapes regex-special parameter names in substitutions', async () => { + const { tool, spawnSync } = buildGeneratedTool(SPECIAL_PARAM_TOOL); + + await tool.call({ + 'a.b': 'dot', + '$count': 'dollar', + 'foo/bar': 'slash', + }); + + expect(spawnSync).toHaveBeenCalledWith( + 'sh', + ['-c', 'echo dot slash dollar'], + expect.objectContaining({ cwd: '/cwd' }) + ); + }); + }); + + describe('Strategy A — post-toolsets injection', () => { + it('produces a non-null result', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect( + writeCustomTools(MOCK_STRATEGY_A, [MINIMAL_TOOL]) + ).not.toBeNull(); + } finally { + warn.mockRestore(); + } + }); + + it('appends custom tools to the toolset variable after the else block', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = writeCustomTools(MOCK_STRATEGY_A, [MINIMAL_TOOL])!; + // The injection code TOOLS=[...TOOLS,...[...]] should appear before `let REST` + expect(result).toContain('TOOLS=[...TOOLS,...['); + const injectionIdx = result.indexOf('TOOLS=[...TOOLS,...['); + const letRestIdx = result.indexOf('let REST'); + expect(injectionIdx).toBeLessThan(letRestIdx); + } finally { + warn.mockRestore(); + } + }); + + it('does NOT use the Strategy B pattern when Strategy A matches', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = writeCustomTools(MOCK_STRATEGY_A, [MINIMAL_TOOL])!; + // Strategy B would produce `let TOOLS=[...agg(...` — should not appear + expect(result).not.toContain('let TOOLS=[...agg('); + } finally { + warn.mockRestore(); + } + }); + + it('still uses buildTool in Strategy A', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = writeCustomTools(MOCK_STRATEGY_A, [MINIMAL_TOOL])!; + expect(result).toContain('bT({'); + } finally { + warn.mockRestore(); + } + }); + }); +}); diff --git a/src/patches/customTools.ts b/src/patches/customTools.ts new file mode 100644 index 00000000..76c2e304 --- /dev/null +++ b/src/patches/customTools.ts @@ -0,0 +1,438 @@ +// Please see the note about writing patches in ./index + +import { CustomTool } from '../types'; +import { + showDiff, + getRequireFuncName, + getCwdFuncName, + findBuildToolFunc, + getReactVar, + findTextComponent, + findBoxComponent, +} from './index'; + +// ============================================================================ +// BUILT-IN TOOL NAME COLLISION GUARD +// ============================================================================ + +const BUILTIN_TOOL_NAMES = new Set([ + 'Agent', + 'AskUserQuestion', + 'Bash', + 'Brief', + 'SendUserMessage', + 'Config', + 'CronCreate', + 'CronDelete', + 'CronList', + 'Edit', + 'EnterPlanMode', + 'EnterWorktree', + 'ExitPlanMode', + 'ExitWorktree', + 'Glob', + 'Grep', + 'LSP', + 'ListMcpResourcesTool', + 'NotebookEdit', + 'PowerShell', + 'REPL', + 'Read', + 'ReadMcpResource', + 'RemoteTrigger', + 'Skill', + 'Sleep', + 'SendMessage', + 'StructuredOutput', + 'Task', + 'TaskCreate', + 'TaskGet', + 'TaskList', + 'TaskOutput', + 'TaskStop', + 'TaskUpdate', + 'TeamCreate', + 'TeamDelete', + 'TodoWrite', + 'ToolSearch', + 'WebFetch', + 'WebSearch', + 'Write', +]); + +const DEFAULT_TIMEOUT_MS = 30000; +const MAX_RESULT_SIZE_CHARS = 100000; + +// ============================================================================ +// PROMPT GENERATION +// ============================================================================ + +const generatePromptString = (tool: CustomTool): string => { + if (tool.prompt !== undefined) { + return tool.prompt; + } + + const lines: string[] = [tool.description, '']; + + const paramEntries = Object.entries(tool.parameters); + if (paramEntries.length > 0) { + lines.push('Parameters:'); + for (const [name, param] of paramEntries) { + const req = param.required !== false ? 'required' : 'optional'; + lines.push(`- ${name} (${param.type}, ${req}): ${param.description}`); + } + lines.push(''); + } + + lines.push( + 'This tool executes a shell command and returns its output.', + `Command template: ${tool.command}`, + '', + 'Output is returned as plain text. Exit code, stderr, and stdout are all reported.' + ); + + if (tool.timeout !== undefined) { + lines.push(`Timeout: ${tool.timeout}ms`); + } + + return lines.join('\n'); +}; + +// ============================================================================ +// CODE GENERATION +// ============================================================================ + +/** + * Generate a buildTool({...}) call for a single custom tool. + * + * Mirrors how every native CC tool is built: Tool.ts's buildTool() spreads + * TOOL_DEFAULTS onto the definition and sets userFacingName to () => def.name. + * By calling the minified buildTool we automatically inherit any defaults CC + * adds in future versions without patching this code. + * + * inputSchema is a duck-typed passthrough satisfying the two call sites: + * toolExecution.ts: tool.inputSchema.safeParse(input) + * permissions.ts: tool.inputSchema.parse(input) + * Real type validation is done in validateInput where errors surface properly. + * + * renderToolUseMessage and renderToolResultMessage use React.createElement + * with the detected Text/Box components, matching the rendering approach of + * every other CC tool. + */ +const generateToolObject = ( + tool: CustomTool, + buildToolFunc: string, + reactVar: string, + textComponent: string, + boxComponent: string, + requireFunc: string, + cwdFunc: string | undefined +): string => { + const nameJson = JSON.stringify(tool.name); + const promptString = generatePromptString(tool); + const promptJson = JSON.stringify(promptString); + const descJson = JSON.stringify(tool.description); + const cmdJson = JSON.stringify(tool.command); + const shell = tool.shell ?? 'sh'; + const shellJson = JSON.stringify(shell); + const shellBasename = shell + .split(/[\\/]/) + .pop()! + .toLowerCase() + .replace(/\.exe$/, ''); + const shellFlagJson = + shellBasename === 'cmd' + ? JSON.stringify('/c') + : shellBasename === 'powershell' || shellBasename === 'pwsh' + ? JSON.stringify('-Command') + : JSON.stringify('-c'); + const timeoutVal = + typeof tool.timeout === 'number' && Number.isFinite(tool.timeout) + ? tool.timeout + : DEFAULT_TIMEOUT_MS; + const workingDirExpr = tool.workingDir + ? JSON.stringify(tool.workingDir) + : cwdFunc + ? `${cwdFunc}()` + : 'process.cwd()'; + const extraEnvJson = JSON.stringify(tool.env ?? {}); + + // Build inputJSONSchema from parameters + const properties: Record = {}; + const required: string[] = []; + for (const [paramName, param] of Object.entries(tool.parameters)) { + properties[paramName] = { + type: param.type, + description: param.description, + }; + if (param.required !== false) { + required.push(paramName); + } + } + const schemaJson = JSON.stringify({ type: 'object', properties, required }); + + const escapeForRegex = (s: string): string => + s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); + + const normalizeObjectExpr = (varName: string): string => + `${varName}=typeof ${varName}==="object"&&${varName}!==null&&!Array.isArray(${varName})?${varName}:{};`; + + const makeSubst = (varName: string): string => + Object.keys(tool.parameters) + .map( + k => + `cmd=cmd.replace(/\\{\\{${escapeForRegex(k)}\\}\\}/g,()=>String(${varName}[${JSON.stringify(k)}]??""));` + ) + .join(''); + const normalizeInput = normalizeObjectExpr('input'); + const normalizeArgs = normalizeObjectExpr('args'); + const argsSubst = makeSubst('args'); + const inputSubst = makeSubst('input'); + + // validateInput: type-check declared parameters + const paramValidations = Object.entries(tool.parameters) + .map(([k, p]) => { + const kJson = JSON.stringify(k); + const typeJson = JSON.stringify(p.type); + if (p.required !== false) { + return ( + `if(input[${kJson}]==null)return{result:false,message:${JSON.stringify(`${k} is required`)},errorCode:1};` + + `if(typeof input[${kJson}]!==${typeJson})return{result:false,message:${JSON.stringify(`${k} must be a ${p.type}`)},errorCode:1};` + ); + } + return `if(input[${kJson}]!=null&&typeof input[${kJson}]!==${typeJson})return{result:false,message:${JSON.stringify(`${k} must be a ${p.type}`)},errorCode:1};`; + }) + .join(''); + + const R = reactVar; + const T = textComponent; + const B = boxComponent; + + return `${buildToolFunc}({ +name:${nameJson}, +maxResultSizeChars:${MAX_RESULT_SIZE_CHARS}, +inputJSONSchema:${schemaJson}, +inputSchema:{safeParse:(i)=>({success:true,data:i}),parse:(i)=>i}, +async description(){return ${descJson}}, +async prompt(){return ${promptJson}}, +isConcurrencySafe(){return false}, +isReadOnly(){return false}, +toAutoClassifierInput(input){ + ${normalizeInput} + let cmd=${cmdJson}; + ${inputSubst} + return cmd; +}, +checkPermissions(input,context){ + ${normalizeInput} + let cmd=${cmdJson}; + ${inputSubst} + const bashTool=context.options.tools.find(t=>t.name==="Bash"); + if(bashTool)return bashTool.checkPermissions({command:cmd,timeout:${timeoutVal}},context); + return Promise.resolve({behavior:"passthrough",message:"Permission required to run "+${nameJson}}); +}, +async validateInput(input){ + if(typeof input!=="object"||input===null||Array.isArray(input))return{result:false,message:"input must be an object",errorCode:1}; + ${paramValidations} + return{result:true}; +}, +renderToolUseMessage(input){ + ${normalizeInput} + let cmd=${cmdJson}; + ${inputSubst} + return ${R}.createElement(${B},{flexDirection:"column"}, + ${R}.createElement(${T},{bold:true},${nameJson}), + ${R}.createElement(${T},{dimColor:true},cmd) + ); +}, +renderToolResultMessage(content){ + const c=typeof content==="object"&&content!==null?content:{stdout:String(content),stderr:"",exitCode:0}; + const parts=[]; + if(c.stdout)parts.push(${R}.createElement(${T},null,c.stdout)); + if(c.stderr)parts.push(${R}.createElement(${T},{color:"warning"},"[stderr]\\n"+c.stderr)); + if(c.exitCode!==0&&c.exitCode!=null)parts.push(${R}.createElement(${T},{color:"error"},"[exit code: "+c.exitCode+"]")); + if(!parts.length)parts.push(${R}.createElement(${T},{dimColor:true},"(no output)")); + return ${R}.createElement(${B},{flexDirection:"column"},...parts); +}, +async call(args){ + ${normalizeArgs} + let cmd=${cmdJson}; + ${argsSubst} + const {spawnSync}=${requireFunc}("child_process"); + const result=spawnSync(${shellJson},[${shellFlagJson},cmd],{ + encoding:"utf8", + timeout:${timeoutVal}, + cwd:${workingDirExpr}, + env:{...process.env,...${extraEnvJson}}, + stdio:["ignore","pipe","pipe"] + }); + if(result.error)return{data:{stdout:"",stderr:result.error.message,exitCode:-1}}; + return{data:{stdout:(result.stdout||"").trimEnd(),stderr:(result.stderr||"").trimEnd(),exitCode:result.status??-1}}; +}, +mapToolResultToToolResultBlockParam(content,toolUseID){ + const c=typeof content==="object"&&content!==null?content:{stdout:String(content),stderr:"",exitCode:0}; + const parts=[]; + if(c.stdout)parts.push(c.stdout); + if(c.stderr)parts.push("[stderr]\\n"+c.stderr); + if(c.exitCode!==0&&c.exitCode!=null)parts.push("[exit code: "+c.exitCode+"]"); + return{type:"tool_result",tool_use_id:toolUseID,content:parts.join("\\n")||"(no output)"}; +} +})`; +}; + +const generateCustomToolsArray = ( + tools: CustomTool[], + buildToolFunc: string, + reactVar: string, + textComponent: string, + boxComponent: string, + requireFunc: string, + cwdFunc: string | undefined +): string => { + const toolObjects = tools.map(t => + generateToolObject( + t, + buildToolFunc, + reactVar, + textComponent, + boxComponent, + requireFunc, + cwdFunc + ) + ); + return `[${toolObjects.join(',')}]`; +}; + +// ============================================================================ +// PATCH +// ============================================================================ + +/** + * Inject custom tools into Claude Code's tool list. + * + * Two injection strategies depending on whether the toolsets patch ran first: + * + * Strategy A — toolsets patch was already applied: + * Appends custom tools to the toolset-filtered variable after the else block, + * so all branches (filtered or not) receive the custom tools. + * + * Strategy B — original code (no toolsets patch): + * Spreads custom tools into the tool aggregation array directly. + */ +export const writeCustomTools = ( + oldFile: string, + customTools: CustomTool[] +): string | null => { + if (!customTools || customTools.length === 0) { + return oldFile; + } + + const seenToolNames = new Set(); + for (const tool of customTools) { + if (BUILTIN_TOOL_NAMES.has(tool.name)) { + console.error( + `patch: customTools: tool "${tool.name}" collides with a built-in CC tool name — rename it` + ); + return null; + } + if (seenToolNames.has(tool.name)) { + console.error( + `patch: customTools: duplicate custom tool name "${tool.name}" — rename one of them` + ); + return null; + } + seenToolNames.add(tool.name); + } + + const buildToolFunc = findBuildToolFunc(oldFile); + if (!buildToolFunc) { + console.error('patch: customTools: failed to find buildTool function'); + return null; + } + + const reactVar = getReactVar(oldFile); + if (!reactVar) { + console.error('patch: customTools: failed to find React variable'); + return null; + } + + const textComponent = findTextComponent(oldFile); + if (!textComponent) { + console.error('patch: customTools: failed to find Text component'); + return null; + } + + const boxComponent = findBoxComponent(oldFile); + if (!boxComponent) { + console.error('patch: customTools: failed to find Box component'); + return null; + } + + const requireFunc = getRequireFuncName(oldFile); + const cwdFunc = getCwdFuncName(oldFile); + if (!cwdFunc) { + console.warn( + 'patch: customTools: could not detect session cwd function; falling back to process.cwd()' + ); + } + + const toolsArrayCode = generateCustomToolsArray( + customTools, + buildToolFunc, + reactVar, + textComponent, + boxComponent, + requireFunc, + cwdFunc + ); + + // ------------------------------------------------------------------ + // Strategy A: toolsets patch was already applied. + // Pattern: } else {\n VAR = assembleCall;\n}let + // Insert VAR=[...VAR,...customTools]; right before the trailing `let `. + // ------------------------------------------------------------------ + const toolsetsPattern = + /\}\s*else\s*\{\s*([$\w]+)\s*=\s*([$\w]+\([$\w]+,[$\w]+\.tools,[$\w]+\))\s*;\s*\}let /; + const toolsetsMatch = oldFile.match(toolsetsPattern); + + if (toolsetsMatch && toolsetsMatch.index !== undefined) { + const toolAggVar = toolsetsMatch[1]; + const insertAt = + toolsetsMatch.index + toolsetsMatch[0].length - 'let '.length; + const injectionCode = `${toolAggVar}=[...${toolAggVar},...${toolsArrayCode}];`; + + const newFile = + oldFile.slice(0, insertAt) + injectionCode + oldFile.slice(insertAt); + + showDiff(oldFile, newFile, injectionCode, insertAt, insertAt); + return newFile; + } + + // ------------------------------------------------------------------ + // Strategy B: original code (no toolsets patch). + // Pattern: let VAR=assembleCall(a,b.tools,c), + // ------------------------------------------------------------------ + const originalPattern = + /let ([$\w]+)=([$\w]+\([$\w]+,[$\w]+\.tools,[$\w]+\)),/; + const originalMatch = oldFile.match(originalPattern); + + if (!originalMatch || originalMatch.index === undefined) { + console.error( + 'patch: customTools: failed to find tool aggregation pattern' + ); + return null; + } + + const toolAggVar = originalMatch[1]; + const toolAggCode = originalMatch[2]; + const startIndex = originalMatch.index; + const endIndex = startIndex + originalMatch[0].length; + + const replacement = `let ${toolAggVar}=[...${toolAggCode},...${toolsArrayCode}],`; + + const newFile = + oldFile.slice(0, startIndex) + replacement + oldFile.slice(endIndex); + + showDiff(oldFile, newFile, replacement, startIndex, endIndex); + return newFile; +}; diff --git a/src/patches/helpers.ts b/src/patches/helpers.ts index 94e2fbe1..75d8afb2 100644 --- a/src/patches/helpers.ts +++ b/src/patches/helpers.ts @@ -288,6 +288,68 @@ export const clearCaches = (): void => { clearRequireFuncNameCache(); }; +/** + * Find the getCwd function variable name (no caching — cheap regex, called once). + * + * Claude Code tracks the session working directory in module-level state + * (STATE.cwd) with optional per-async-context overrides. process.cwd() is + * wrong: it does not reflect `cd` commands, worktree switches, or subagent + * cwd overrides made during a session. + * + * Detection strategy (three-step chain mirroring cwd.ts): + * 1. getCwdState: function X(){return STATE.cwd} → pattern: return Y.cwd + * 2. pwd: function X(){return getCwdState()} → wraps step 1 + * 3. getCwd: try{return pwd()}catch{return ...} → wraps step 2 in try-catch + */ +export const getCwdFuncName = (fileContents: string): string | undefined => { + const getCwdStatePattern = /function ([$\w]+)\(\)\{return ([$\w]+)\.cwd\}/; + const getCwdStateMatch = fileContents.match(getCwdStatePattern); + if (!getCwdStateMatch) { + console.log('patch: getCwdFuncName: failed to find getCwdState'); + return undefined; + } + const getCwdStateName = getCwdStateMatch[1]; + + const pwdPattern = new RegExp( + `function ([$\\w]+)\\(\\)\\{return ${escapeIdent(getCwdStateName)}\\(\\)\\}` + ); + const pwdMatch = fileContents.match(pwdPattern); + const pwdName = pwdMatch?.[1] ?? getCwdStateName; + + const getCwdPattern = new RegExp( + `function ([$\\w]+)\\(\\)\\{try\\{return ${escapeIdent(pwdName)}\\(\\)\\}catch` + ); + const getCwdMatch = fileContents.match(getCwdPattern); + if (getCwdMatch) { + return getCwdMatch[1]; + } + + return pwdName; +}; + +/** + * Find the buildTool function variable name. + * + * buildTool() is Claude Code's tool factory (Tool.ts). It spreads TOOL_DEFAULTS + * onto the provided definition, then overrides userFacingName with a closure + * that returns def.name. The pattern in the minified bundle is highly specific: + * + * function NAME(PARAM){return{...DEFAULTS,userFacingName:()=>PARAM.name,...PARAM}} + * + * Using buildTool ensures custom tools inherit all CC default method implementations + * automatically rather than manually specifying every optional method. + */ +export const findBuildToolFunc = (fileContents: string): string | undefined => { + const pattern = + /function ([$\w]+)\(([$\w]+)\)\{return\{\.{3}[$\w]+,userFacingName:\(\)=>\2\.name,\.\.\.\2\}\}/; + const match = fileContents.match(pattern); + if (!match) { + console.log('patch: findBuildToolFunc: failed to find buildTool function'); + return undefined; + } + return match[1]; +}; + /** * Find the Text component variable name from Ink */ diff --git a/src/patches/index.ts b/src/patches/index.ts index 9df6c879..fd0836cf 100644 --- a/src/patches/index.ts +++ b/src/patches/index.ts @@ -50,6 +50,7 @@ import { writePatchesAppliedIndication } from './patchesAppliedIndication'; import { applySystemPrompts } from './systemPrompts'; import { writeFixLspSupport } from './fixLspSupport'; import { writeToolsets } from './toolsets'; +import { writeCustomTools } from './customTools'; import { writeTableFormat } from './tableFormat'; import { writeConversationTitle } from './conversationTitle'; import { writeHideStartupBanner } from './hideStartupBanner'; @@ -91,6 +92,8 @@ export { clearRequireFuncNameCache, findTextComponent, findBoxComponent, + getCwdFuncName, + findBuildToolFunc, } from './helpers'; export interface LocationResult { @@ -385,6 +388,12 @@ const PATCH_DEFINITIONS = [ group: PatchGroup.FEATURES, description: 'Custom toolsets will be registered', }, + { + id: 'custom-tools', + name: 'Custom tools', + group: PatchGroup.FEATURES, + description: 'User-defined shell-command tools will be injected into the tool list', + }, { id: 'mcp-non-blocking', name: 'MCP non-blocking', @@ -836,6 +845,12 @@ export const applyCustomization = async ( config.settings.toolsets && config.settings.toolsets.length > 0 ), }, + 'custom-tools': { + fn: c => writeCustomTools(c, config.settings.customTools!), + condition: !!( + config.settings.customTools && config.settings.customTools.length > 0 + ), + }, 'mcp-non-blocking': { fn: c => writeMcpNonBlocking(c), condition: !!config.settings.misc?.mcpConnectionNonBlocking, diff --git a/src/tests/config.test.ts b/src/tests/config.test.ts index 58436ab8..1e0dff0e 100644 --- a/src/tests/config.test.ts +++ b/src/tests/config.test.ts @@ -31,6 +31,7 @@ vi.mock('node:child_process', () => ({ })); vi.mock('../nativeInstallationLoader', () => ({ extractClaudeJsFromNativeInstallation: vi.fn(), + getNativeModuleLoadError: vi.fn().mockReturnValue(null), repackNativeInstallation: vi.fn(), resolveNixBinaryWrapper: vi.fn().mockResolvedValue(null), })); @@ -1523,6 +1524,143 @@ describe('config.ts', () => { findClaudeCodeInstallation(mockConfig, { interactive: true }) ).rejects.toThrow('Could not extract JS from native binary'); }); + + it('should include node-lief load reason in error when getNativeModuleLoadError returns a message', async () => { + const mockConfig = { + ccInstallationPath: null, + changesApplied: false, + ccVersion: '', + lastModified: '', + settings: DEFAULT_SETTINGS, + }; + + const nativeBinaryPath = '/usr/local/bin/claude'; + + vi.spyOn(fs, 'stat').mockImplementation(async filePath => { + if (filePath === nativeBinaryPath) return {} as Stats; + throw createEnoent(); + }); + vi.mocked(whichMock).mockResolvedValue(nativeBinaryPath); + lstatSpy.mockImplementation(async filePath => { + if (filePath === nativeBinaryPath) return createRegularStats(); + throw createEnoent(); + }); + vi.spyOn(fs, 'realpath').mockResolvedValue(nativeBinaryPath); + vi.spyOn(fs, 'open').mockResolvedValue({ + read: async ({ buffer }: { buffer: Buffer }) => { + const contentBuffer = Buffer.from('fake binary content'); + contentBuffer.copy(buffer); + return { bytesRead: contentBuffer.length, buffer }; + }, + close: async () => {}, + } as unknown as fs.FileHandle); + mockMagicInstance.detect.mockReturnValue('application/octet-stream'); + vi.spyOn( + nativeInstallation, + 'extractClaudeJsFromNativeInstallation' + ).mockResolvedValue(null); + vi.spyOn(nativeInstallation, 'getNativeModuleLoadError').mockReturnValue( + 'libstdc++.so.6: cannot open shared object file: No such file or directory' + ); + + await expect( + findClaudeCodeInstallation(mockConfig, { interactive: true }) + ).rejects.toThrow( + 'Reason: node-lief failed to load: libstdc++.so.6' + ); + }); + + it('should include NixOS hint in error when load error contains shared object message', async () => { + const mockConfig = { + ccInstallationPath: null, + changesApplied: false, + ccVersion: '', + lastModified: '', + settings: DEFAULT_SETTINGS, + }; + + const nativeBinaryPath = '/usr/local/bin/claude'; + + vi.spyOn(fs, 'stat').mockImplementation(async filePath => { + if (filePath === nativeBinaryPath) return {} as Stats; + throw createEnoent(); + }); + vi.mocked(whichMock).mockResolvedValue(nativeBinaryPath); + lstatSpy.mockImplementation(async filePath => { + if (filePath === nativeBinaryPath) return createRegularStats(); + throw createEnoent(); + }); + vi.spyOn(fs, 'realpath').mockResolvedValue(nativeBinaryPath); + vi.spyOn(fs, 'open').mockResolvedValue({ + read: async ({ buffer }: { buffer: Buffer }) => { + const contentBuffer = Buffer.from('fake binary content'); + contentBuffer.copy(buffer); + return { bytesRead: contentBuffer.length, buffer }; + }, + close: async () => {}, + } as unknown as fs.FileHandle); + mockMagicInstance.detect.mockReturnValue('application/octet-stream'); + vi.spyOn( + nativeInstallation, + 'extractClaudeJsFromNativeInstallation' + ).mockResolvedValue(null); + vi.spyOn(nativeInstallation, 'getNativeModuleLoadError').mockReturnValue( + 'libstdc++.so.6: cannot open shared object file: No such file or directory' + ); + + await expect( + findClaudeCodeInstallation(mockConfig, { interactive: true }) + ).rejects.toThrow('On NixOS with Bun'); + }); + + it('should include load reason but not NixOS hint for non-shared-object load errors', async () => { + const mockConfig = { + ccInstallationPath: null, + changesApplied: false, + ccVersion: '', + lastModified: '', + settings: DEFAULT_SETTINGS, + }; + + const nativeBinaryPath = '/usr/local/bin/claude'; + + vi.spyOn(fs, 'stat').mockImplementation(async filePath => { + if (filePath === nativeBinaryPath) return {} as Stats; + throw createEnoent(); + }); + vi.mocked(whichMock).mockResolvedValue(nativeBinaryPath); + lstatSpy.mockImplementation(async filePath => { + if (filePath === nativeBinaryPath) return createRegularStats(); + throw createEnoent(); + }); + vi.spyOn(fs, 'realpath').mockResolvedValue(nativeBinaryPath); + vi.spyOn(fs, 'open').mockResolvedValue({ + read: async ({ buffer }: { buffer: Buffer }) => { + const contentBuffer = Buffer.from('fake binary content'); + contentBuffer.copy(buffer); + return { bytesRead: contentBuffer.length, buffer }; + }, + close: async () => {}, + } as unknown as fs.FileHandle); + mockMagicInstance.detect.mockReturnValue('application/octet-stream'); + vi.spyOn( + nativeInstallation, + 'extractClaudeJsFromNativeInstallation' + ).mockResolvedValue(null); + vi.spyOn(nativeInstallation, 'getNativeModuleLoadError').mockReturnValue( + 'unexpected header in node-lief' + ); + + await expect( + findClaudeCodeInstallation(mockConfig, { interactive: true }) + ).rejects.toThrow( + 'node-lief failed to load: unexpected header in node-lief' + ); + + await expect( + findClaudeCodeInstallation(mockConfig, { interactive: true }) + ).rejects.not.toThrow('On NixOS with Bun'); + }); }); describe('startupCheck', () => { diff --git a/src/types.ts b/src/types.ts index b81f1e7b..df0b5498 100644 --- a/src/types.ts +++ b/src/types.ts @@ -154,6 +154,24 @@ export interface Toolset { allowedTools: string[] | '*'; } +export interface CustomToolParameter { + type: 'string' | 'number' | 'boolean'; + description: string; + required?: boolean; +} + +export interface CustomTool { + name: string; + description: string; + parameters: Record; + command: string; + shell?: string; + timeout?: number; + workingDir?: string; + env?: Record; + prompt?: string; +} + export interface SubagentModelsConfig { plan: string | null; explore: string | null; @@ -170,6 +188,7 @@ export interface Settings { toolsets: Toolset[]; defaultToolset: string | null; planModeToolset: string | null; + customTools: CustomTool[]; subagentModels: SubagentModelsConfig; inputPatternHighlighters: InputPatternHighlighter[]; inputPatternHighlightersTestText: string; // Global test text for previewing highlighters