From 0b30b0ef1031fc326d120bc4681ebb1dce6c7d51 Mon Sep 17 00:00:00 2001 From: y0usaf Date: Thu, 30 Apr 2026 11:26:20 -0400 Subject: [PATCH] feat: add custom shell-command tools --- README.md | 66 +++ src/config.ts | 146 +++++ src/defaultSettings.ts | 1 + src/patches/customTools.test.ts | 660 +++++++++++++++++++++ src/patches/customTools.ts | 458 +++++++++++++++ src/patches/helpers.ts | 62 ++ src/patches/index.ts | 16 + src/types.ts | 20 + src/ui/App.tsx | 4 + src/ui/components/CustomToolEditView.tsx | 711 +++++++++++++++++++++++ src/ui/components/CustomToolsView.tsx | 188 ++++++ src/ui/components/MainMenu.tsx | 4 + 12 files changed, 2336 insertions(+) create mode 100644 src/patches/customTools.test.ts create mode 100644 src/patches/customTools.ts create mode 100644 src/ui/components/CustomToolEditView.tsx create mode 100644 src/ui/components/CustomToolsView.tsx diff --git a/README.md b/README.md index b1ef860e..514c90ce 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ With tweakcc, you can - Customize all of Claude Code's **system prompts** (**NEW:** also see all of [**Claude Code's system prompts**](https://github.com/Piebald-AI/claude-code-system-prompts)) - Create custom **toolsets** that can be used in Claude Code with the new **`/toolset`** command +- Create custom **shell-command tools** that Claude Code can call - **Highlight** custom patterns while you type in the CC input box with custom colors and styling, like how `ultrathink` used to be rainbow-highlighted. - Manually name **sessions** in Claude Code with `/title my chat name` or `/rename` (see [**our blog post**](https://piebald.ai/blog/messages-as-commits-claude-codes-git-like-dag-of-conversations) for implementation details) - Create **custom themes** with a graphical HSL/RGB color picker @@ -113,6 +114,7 @@ $ pnpm dlx tweakcc - [API](#api) - [System prompts](#system-prompts) - [Toolsets](#toolsets) +- [Custom tools](#custom-tools) - [**Features**](#features) - [System prompts](#system-prompts) - Themes @@ -135,6 +137,7 @@ $ pnpm dlx tweakcc - Session memory - `/remember` skill - [Toolsets](#toolsets) + - [Custom tools](#custom-tools) - User message display customization - Token indicator display - [Add support for dangerously bypassing permissions in sudo](#feature-bypass-permissions-check-in-sudo) @@ -663,6 +666,69 @@ Toolsets can be helpful both for using Claude in different modes, e.g. a researc To create a toolset, run `npx tweakcc`, go to `Toolsets`, and hit `n` to create a new toolset. Set a name and enable/disable some tools, run `tweakcc --apply` to apply your customizations, and then run `claude`. If you marked a toolset as the default in tweakcc, it will be automatically selected. +## Custom tools + +Custom tools let you register your own shell-command tools alongside Claude Code's built-in tools. Each custom tool declares a name, description, parameter schema, and command template. When Claude calls the tool, tweakcc substitutes `{{parameterName}}` placeholders into the command, executes it in a shell, and returns stdout, stderr, and exit code back to Claude. + +You can create them in the tweakcc UI by going to `Custom tools`, or by editing `settings.customTools` in [`config.json`](#configuration-directory) directly. After changing them, run `tweakcc --apply`. + +> **Shell safety (accepted trade-off):** `{{parameter}}` placeholders are inserted verbatim into the command string — tweakcc does not shell-quote them. A string parameter containing `;`, `&`, `|`, or `$(...)` will be executed as-is by the shell. This is intentional: quoting every parameter would break tools that deliberately pass flags or expressions through a parameter. As the tool author, you are responsible for quoting parameters that need it (e.g. write `"{{path}}"` in the command template, not `{{path}}`). Each invocation goes through Claude Code's normal Bash permissions check, so the full interpolated command is shown to the user before execution. + +Example: + +```json +"customTools": [ + { + "name": "RipgrepTodo", + "description": "Search for TODO comments under a path", + "parameters": { + "path": { + "type": "string", + "description": "Path to search", + "required": true + } + }, + "command": "rg -n TODO \"{{path}}\"", + "shell": "bash", + "timeout": 5000, + "workingDir": "/home/user/project", + "env": { + "RG_COLORS": "match:fg:yellow" + } + } +] +``` + +Schema: + +```typescript +type CustomTool = { + name: string; + description: string; + parameters: Record< + string, + { + type: 'string' | 'number' | 'boolean'; + description: string; + required?: boolean; + } + >; + command: string; + shell?: string; + timeout?: number; + workingDir?: string; + env?: Record; + prompt?: string; +}; +``` + +Notes: + +- `prompt` is optional. If omitted, tweakcc generates a prompt from the description, parameters, and command template. +- Custom tool names must be unique and must not collide with built-in Claude Code tool names such as `Bash`, `Read`, or `Write`. +- Invalid custom tool entries are dropped when tweakcc loads the config. +- Custom tools are currently appended after built-in toolset filtering, so they remain available even when a toolset is active. + ## Feature: Thinking verbs customization Customize the thinking verbs that appear while Claude is generating responses, along with the format string. You can change from the default `"Thinking… "` format to something more fun like `"Claude is {verb}ing..."` or anything else you prefer. diff --git a/src/config.ts b/src/config.ts index 80830a02..ef73e417 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,7 @@ import { EOL } from 'node:os'; import chalk from 'chalk'; import { + CustomTool, RemoteConfig, Settings, Theme, @@ -166,6 +167,139 @@ const createDefaultConfig = (): TweakccConfig => ({ settings: DEFAULT_SETTINGS, }); +const normalizeCustomTool = ( + tool: unknown, + index: number +): CustomTool | null => { + const invalidKeys: string[] = []; + + if (!tool || typeof tool !== 'object' || Array.isArray(tool)) { + console.warn( + `config: customTools: dropping invalid tool at index ${index} (expected object)` + ); + return null; + } + + const candidate = tool as Partial; + + if (typeof candidate.name !== 'string' || candidate.name.trim() === '') { + invalidKeys.push('name'); + } + if ( + typeof candidate.description !== 'string' || + candidate.description.trim() === '' + ) { + invalidKeys.push('description'); + } + if ( + typeof candidate.command !== 'string' || + candidate.command.trim() === '' + ) { + invalidKeys.push('command'); + } + + const rawParameters = candidate.parameters; + if ( + !rawParameters || + typeof rawParameters !== 'object' || + Array.isArray(rawParameters) + ) { + invalidKeys.push('parameters'); + } else { + for (const [paramName, param] of Object.entries(rawParameters)) { + if ( + paramName.trim() === '' || + !param || + typeof param !== 'object' || + Array.isArray(param) + ) { + invalidKeys.push('parameters'); + break; + } + + const typedParam = param as { + type?: unknown; + description?: unknown; + required?: unknown; + }; + + if ( + typedParam.type !== 'string' && + typedParam.type !== 'number' && + typedParam.type !== 'boolean' + ) { + invalidKeys.push('parameters'); + break; + } + + if ( + typeof typedParam.description !== 'string' || + typedParam.description.trim() === '' + ) { + invalidKeys.push('parameters'); + break; + } + + if ( + typedParam.required !== undefined && + typeof typedParam.required !== 'boolean' + ) { + invalidKeys.push('parameters'); + break; + } + } + } + + if ( + candidate.shell !== undefined && + (typeof candidate.shell !== 'string' || candidate.shell.trim() === '') + ) { + invalidKeys.push('shell'); + } + + if ( + candidate.timeout !== undefined && + (!Number.isInteger(candidate.timeout) || candidate.timeout <= 0) + ) { + invalidKeys.push('timeout'); + } + + if ( + candidate.workingDir !== undefined && + (typeof candidate.workingDir !== 'string' || + candidate.workingDir.trim() === '') + ) { + invalidKeys.push('workingDir'); + } + + if ( + candidate.env !== undefined && + (!candidate.env || + typeof candidate.env !== 'object' || + Array.isArray(candidate.env) || + Object.entries(candidate.env).some( + ([key, value]) => key.trim() === '' || typeof value !== 'string' + )) + ) { + invalidKeys.push('env'); + } + + if (candidate.prompt !== undefined && typeof candidate.prompt !== 'string') { + invalidKeys.push('prompt'); + } + + if (invalidKeys.length > 0) { + const name = + typeof candidate.name === 'string' ? ` "${candidate.name}"` : ''; + console.warn( + `config: customTools: dropping invalid tool at index ${index}${name} (invalid/missing: ${Array.from(new Set(invalidKeys)).join(', ')})` + ); + return null; + } + + return candidate as CustomTool; +}; + /** * Applies migrations and normalizations to a parsed config object. * This handles: @@ -227,6 +361,18 @@ 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 + .map((tool, index) => normalizeCustomTool(tool, index)) + .filter((tool): tool is CustomTool => tool !== null); + } + // 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 9240c211..ce48be96 100644 --- a/src/defaultSettings.ts +++ b/src/defaultSettings.ts @@ -723,6 +723,7 @@ export const DEFAULT_SETTINGS: Settings = { toolsets: [], defaultToolset: null, planModeToolset: null, + customTools: [], subagentModels: { plan: null, explore: null, diff --git a/src/patches/customTools.test.ts b/src/patches/customTools.test.ts new file mode 100644 index 00000000..01983610 --- /dev/null +++ b/src/patches/customTools.test.ts @@ -0,0 +1,660 @@ +import { EventEmitter } from 'node:events'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { writeCustomTools } from './customTools'; +import { + findBuildToolFunc, + getCwdFuncName, + getRequireFuncName, + clearReactVarCache, + clearRequireFuncNameCache, +} from './helpers'; +import type { CustomTool } from '../types'; + +// ============================================================================ +// SHARED FIXTURES +// ============================================================================ + +// Minimal synthetic minified bundle satisfying all helpers writeCustomTools uses. +// Each piece is crafted to match exactly one helper's detection pattern. +const MOCK_BASE = + // getModuleLoaderFunction (NPM bundle): shortest 3-param arrow function + 'var T=(H,$,A)=>{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; +} + +interface MockChild extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; +} + +const createMockChild = ({ + stdout = '', + stderr = '', + exitCode = 0, + error, +}: { + stdout?: string; + stderr?: string; + exitCode?: number | null; + error?: Error; +} = {}): MockChild => { + const child = new EventEmitter() as MockChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + + queueMicrotask(() => { + if (error) { + child.emit('error', error); + return; + } + if (stdout.length > 0) { + child.stdout.emit('data', stdout); + } + if (stderr.length > 0) { + child.stderr.emit('data', stderr); + } + child.emit('close', exitCode); + }); + + return child; +}; + +const buildGeneratedTool = ( + tool: CustomTool +): { tool: GeneratedTool; spawn: 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 spawn = vi.fn(() => createMockChild()); + 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 { spawn }; + } + + throw new Error(`Unexpected module: ${moduleName}`); + }, + () => '/cwd' + ) as unknown as GeneratedTool[]; + + return { tool: tools[0], spawn }; + } 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('normalizes built-in tool names before collision checks', () => { + 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(); + } + }); + + it('normalizes duplicate custom tool names before collision checks', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const result = writeCustomTools(MOCK_STRATEGY_B, [ + MINIMAL_TOOL, + { ...MINIMAL_TOOL, name: ' mytool ' }, + ]); + 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 the matching shell permission tool', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const shResult = writeCustomTools(MOCK_STRATEGY_B, [MINIMAL_TOOL])!; + expect(shResult).toContain('const permissionToolNames=["Bash"]'); + expect(shResult).toContain('permissionTool.checkPermissions('); + + const pwshResult = writeCustomTools(MOCK_STRATEGY_B, [ + { ...MINIMAL_TOOL, shell: 'pwsh' }, + ])!; + expect(pwshResult).toContain( + 'const permissionToolNames=["PowerShell"]' + ); + } finally { + warn.mockRestore(); + } + }); + + it('emits input schema properties for __proto__ parameters', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = writeCustomTools(MOCK_STRATEGY_B, [ + { + ...MINIMAL_TOOL, + parameters: { + ['__proto__']: { + type: 'string', + description: 'Prototype-like key', + required: true, + }, + }, + command: 'echo {{__proto__}}', + }, + ])!; + expect(result).toContain( + '"__proto__":{"type":"string","description":"Prototype-like key"}' + ); + } 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, spawn } = buildGeneratedTool(MINIMAL_TOOL); + const value = "$& $$ $` $'"; + + await tool.call({ msg: value }); + + expect(spawn).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, spawn } = buildGeneratedTool(MINIMAL_TOOL); + + expect(tool.toAutoClassifierInput(undefined)).toBe('echo '); + await expect(tool.call(undefined)).resolves.toEqual({ + data: { stdout: '', stderr: '', exitCode: 0 }, + }); + expect(spawn).toHaveBeenCalledWith( + 'sh', + ['-c', 'echo '], + expect.objectContaining({ cwd: '/cwd' }) + ); + }); + + it('falls back to the default timeout for non-finite timeout values', async () => { + const { tool, spawn } = buildGeneratedTool({ + ...MINIMAL_TOOL, + timeout: Number.NaN, + }); + + await tool.call({ msg: 'ok' }); + + expect(spawn).toHaveBeenCalledWith( + 'sh', + ['-c', 'echo ok'], + expect.objectContaining({ timeout: 30000 }) + ); + }); + + it('returns stderr and exitCode -1 when spawn emits an error', async () => { + const { tool, spawn } = buildGeneratedTool(MINIMAL_TOOL); + spawn.mockReturnValueOnce( + createMockChild({ error: new Error('ETIMEDOUT') }) + ); + + await expect(tool.call(undefined)).resolves.toEqual({ + data: { stdout: '', stderr: 'ETIMEDOUT', exitCode: -1 }, + }); + expect(spawn).toHaveBeenCalledWith( + 'sh', + ['-c', 'echo '], + expect.objectContaining({ cwd: '/cwd' }) + ); + }); + + it('escapes regex-special parameter names in substitutions', async () => { + const { tool, spawn } = buildGeneratedTool(SPECIAL_PARAM_TOOL); + + await tool.call({ + 'a.b': 'dot', + $count: 'dollar', + 'foo/bar': 'slash', + }); + + expect(spawn).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..cbf25b45 --- /dev/null +++ b/src/patches/customTools.ts @@ -0,0 +1,458 @@ +// 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', + ].map(name => name.toLowerCase()) +); + +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 permissionToolNamesJson = JSON.stringify( + shellBasename === 'powershell' || shellBasename === 'pwsh' + ? ['PowerShell'] + : shellBasename === 'cmd' + ? ['Cmd', 'Command Prompt'] + : ['Bash'] + ); + const workingDirExpr = tool.workingDir + ? JSON.stringify(tool.workingDir) + : cwdFunc + ? `${cwdFunc}()` + : 'process.cwd()'; + const extraEnvJson = JSON.stringify(tool.env ?? {}); + + // Build inputJSONSchema from parameters + const properties = Object.create(null) as Record< + string, + { type: string; description: string } + >; + 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 permissionToolNames=${permissionToolNamesJson}; + const permissionTool=context.options.tools.find(t=>permissionToolNames.includes(t.name)); + if(permissionTool)return permissionTool.checkPermissions({command:cmd,timeout:${timeoutVal}},context); + return Promise.resolve({behavior:"passthrough",message:"Permission required to run "+${nameJson}+" with "+${shellJson}}); +}, +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 {spawn}=${requireFunc}("child_process"); + return await new Promise(resolve=>{ + const child=spawn(${shellJson},[${shellFlagJson},cmd],{ + encoding:"utf8", + timeout:${timeoutVal}, + cwd:${workingDirExpr}, + env:{...process.env,...${extraEnvJson}}, + stdio:["ignore","pipe","pipe"] + }); + let stdout="",stderr="",settled=false; + const finish=data=>{if(settled)return;settled=true;resolve({data})}; + if(child.stdout)child.stdout.on("data",d=>{stdout+=String(d)}); + if(child.stderr)child.stderr.on("data",d=>{stderr+=String(d)}); + child.on("error",err=>finish({stdout:"",stderr:err.message,exitCode:-1})); + child.on("close",code=>finish({stdout:stdout.trimEnd(),stderr:stderr.trimEnd(),exitCode:code??-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) { + const normalizedName = tool.name.trim().toLowerCase(); + if (BUILTIN_TOOL_NAMES.has(normalizedName)) { + console.error( + `patch: customTools: tool "${tool.name}" collides with a built-in CC tool name — rename it` + ); + return null; + } + if (seenToolNames.has(normalizedName)) { + console.error( + `patch: customTools: duplicate custom tool name "${tool.name}" — rename one of them` + ); + return null; + } + seenToolNames.add(normalizedName); + } + + 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 0ef72683..5ea8d266 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 a1c69861..47730d00 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'; @@ -92,6 +93,8 @@ export { clearRequireFuncNameCache, findTextComponent, findBoxComponent, + getCwdFuncName, + findBuildToolFunc, } from './helpers'; export interface LocationResult { @@ -386,6 +389,13 @@ 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', @@ -844,6 +854,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/types.ts b/src/types.ts index 0aa23747..f258fcbd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -155,6 +155,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; @@ -171,6 +189,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 @@ -234,6 +253,7 @@ export enum MainMenuItem { INPUT_PATTERN_HIGHLIGHTERS = 'Input pattern highlighters', MISC = 'Misc', TOOLSETS = 'Toolsets', + CUSTOM_TOOLS = 'Custom tools', SUBAGENT_MODELS = 'Subagent models', CLAUDE_MD_ALT_NAMES = 'CLAUDE.md alternative names', VIEW_SYSTEM_PROMPTS = 'View system prompts', diff --git a/src/ui/App.tsx b/src/ui/App.tsx index b870ea54..7d694ced 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -8,6 +8,7 @@ import { UserMessageDisplayView } from './components/UserMessageDisplayView'; import { InputPatternHighlightersView } from './components/InputPatternHighlightersView'; import { MiscView } from './components/MiscView'; import { ToolsetsView } from './components/ToolsetsView'; +import { CustomToolsView } from './components/CustomToolsView'; import { SubagentModelsView } from './components/SubagentModelsView'; import { ClaudeMdAltNamesView } from './components/ClaudeMdAltNamesView'; import { @@ -124,6 +125,7 @@ Please reapply your changes by running \`${invocationCommand} --apply\`.`, case MainMenuItem.INPUT_PATTERN_HIGHLIGHTERS: case MainMenuItem.MISC: case MainMenuItem.TOOLSETS: + case MainMenuItem.CUSTOM_TOOLS: case MainMenuItem.SUBAGENT_MODELS: case MainMenuItem.CLAUDE_MD_ALT_NAMES: setCurrentView(item); @@ -198,6 +200,8 @@ Please reapply your changes by running \`${invocationCommand} --apply\`.`, ) : currentView === MainMenuItem.TOOLSETS ? ( + ) : currentView === MainMenuItem.CUSTOM_TOOLS ? ( + ) : currentView === MainMenuItem.SUBAGENT_MODELS ? ( ) : currentView === MainMenuItem.CLAUDE_MD_ALT_NAMES ? ( diff --git a/src/ui/components/CustomToolEditView.tsx b/src/ui/components/CustomToolEditView.tsx new file mode 100644 index 00000000..53ff8e60 --- /dev/null +++ b/src/ui/components/CustomToolEditView.tsx @@ -0,0 +1,711 @@ +import { useContext, useEffect, useState } from 'react'; +import { Box, Text, useInput } from 'ink'; + +import { CustomToolParameter } from '@/types'; + +import { SettingsContext } from '../App'; +import Header from './Header'; + +interface CustomToolEditViewProps { + toolIndex: number; + onBack: () => void; +} + +type FieldName = + | 'name' + | 'description' + | 'command' + | 'shell' + | 'timeout' + | 'workingDir' + | 'prompt' + | 'parameters' + | 'env'; + +const FIELD_ORDER: FieldName[] = [ + 'name', + 'description', + 'command', + 'shell', + 'timeout', + 'workingDir', + 'prompt', + 'parameters', + 'env', +]; + +const FIELD_LABELS: Record = { + name: 'Name', + description: 'Description', + command: 'Command template', + shell: 'Shell', + timeout: 'Timeout (ms)', + workingDir: 'Working directory', + prompt: 'Prompt override', + parameters: 'Parameters JSON', + env: 'Env JSON', +}; + +const FIELD_HINTS: Record = { + name: 'Must be unique among custom tools.', + description: 'Shown to Claude and used for the generated prompt.', + command: 'Use placeholders like {{path}} for parameter substitution.', + shell: 'Optional. Leave empty to use sh.', + timeout: 'Optional. Leave empty to use the default 30000ms timeout.', + workingDir: 'Optional. Leave empty to use Claude Code’s current cwd.', + prompt: 'Optional. Leave empty to auto-generate the tool prompt.', + parameters: + 'Example: {"path":{"type":"string","description":"File path","required":true}}', + env: 'Example: {"API_KEY":"value","MODE":"strict"}', +}; + +const RESERVED_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', + ].map(name => name.toLowerCase()) +); + +const normalizeToolName = (value: string): string => value.trim().toLowerCase(); + +const truncate = (value: string, max = 100): string => + value.length <= max ? value : `${value.slice(0, max - 3)}...`; + +const parseTimeoutInput = ( + input: string +): { value: number | undefined; error: string | null } => { + const trimmed = input.trim(); + + if (trimmed.length === 0) { + return { value: undefined, error: null }; + } + + const parsed = Number(trimmed); + + if (!Number.isInteger(parsed) || parsed <= 0) { + return { + value: undefined, + error: 'Timeout must be a positive integer number of milliseconds.', + }; + } + + return { value: parsed, error: null }; +}; + +const parseParametersInput = ( + input: string +): { + value: Record; + error: string | null; +} => { + const trimmed = input.trim(); + const raw = trimmed.length === 0 ? {} : JSON.parse(trimmed); + + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { + value: {}, + error: 'Parameters must be a JSON object keyed by parameter name.', + }; + } + + for (const [name, param] of Object.entries(raw)) { + if (name.trim().length === 0) { + return { + value: {}, + error: 'Parameter names must not be empty.', + }; + } + + if (!param || typeof param !== 'object' || Array.isArray(param)) { + return { + value: {}, + error: `Parameter "${name}" must be an object.`, + }; + } + + const typedParam = param as { + type?: unknown; + description?: unknown; + required?: unknown; + }; + + if ( + typedParam.type !== 'string' && + typedParam.type !== 'number' && + typedParam.type !== 'boolean' + ) { + return { + value: {}, + error: `Parameter "${name}" must declare type "string", "number", or "boolean".`, + }; + } + + if ( + typeof typedParam.description !== 'string' || + typedParam.description.trim().length === 0 + ) { + return { + value: {}, + error: `Parameter "${name}" must include a non-empty description.`, + }; + } + + if ( + typedParam.required !== undefined && + typeof typedParam.required !== 'boolean' + ) { + return { + value: {}, + error: `Parameter "${name}" has invalid required flag.`, + }; + } + } + + return { + value: raw as Record, + error: null, + }; +}; + +const parseEnvInput = ( + input: string +): { value: Record; error: string | null } => { + const trimmed = input.trim(); + const raw = trimmed.length === 0 ? {} : JSON.parse(trimmed); + + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { + value: {}, + error: 'Env must be a JSON object keyed by environment variable name.', + }; + } + + for (const [name, value] of Object.entries(raw)) { + if (name.trim().length === 0) { + return { + value: {}, + error: 'Environment variable names must not be empty.', + }; + } + + if (typeof value !== 'string') { + return { + value: {}, + error: `Environment variable "${name}" must be a string.`, + }; + } + } + + return { + value: raw as Record, + error: null, + }; +}; + +export function CustomToolEditView({ + toolIndex, + onBack, +}: CustomToolEditViewProps) { + const { settings, updateSettings } = useContext(SettingsContext); + const tool = settings.customTools[toolIndex]; + + const [name, setName] = useState(tool?.name || 'New Tool'); + const [description, setDescription] = useState( + tool?.description || 'Describe what this tool does' + ); + const [command, setCommand] = useState(tool?.command || 'echo hello'); + const [shell, setShell] = useState(tool?.shell || ''); + const [timeoutInput, setTimeoutInput] = useState( + tool?.timeout?.toString() || '' + ); + const [workingDir, setWorkingDir] = useState(tool?.workingDir || ''); + const [prompt, setPrompt] = useState(tool?.prompt || ''); + const [parametersInput, setParametersInput] = useState( + JSON.stringify(tool?.parameters || {}) + ); + const [envInput, setEnvInput] = useState(JSON.stringify(tool?.env || {})); + + const [selectedFieldIndex, setSelectedFieldIndex] = useState(0); + const [editingText, setEditingText] = useState(false); + const [fieldSnapshot, setFieldSnapshot] = useState(''); + + const [timeoutError, setTimeoutError] = useState(null); + + const [parsedParameters, setParsedParameters] = useState< + Record + >(tool?.parameters || {}); + const [parametersError, setParametersError] = useState(null); + + const [parsedEnv, setParsedEnv] = useState>( + tool?.env || {} + ); + const [envError, setEnvError] = useState(null); + + const selectedField = FIELD_ORDER[selectedFieldIndex]; + const normalizedName = normalizeToolName(name); + const duplicateName = settings.customTools.some( + (candidate, index) => + index !== toolIndex && + normalizeToolName(candidate.name) === normalizedName + ); + const reservedName = RESERVED_TOOL_NAMES.has(normalizedName); + + useEffect(() => { + const result = parseTimeoutInput(timeoutInput); + setTimeoutError(result.error); + }, [timeoutInput]); + + useEffect(() => { + try { + const result = parseParametersInput(parametersInput); + setParametersError(result.error); + if (!result.error) { + setParsedParameters(result.value); + } + } catch (error) { + setParametersError((error as Error).message); + } + }, [parametersInput]); + + useEffect(() => { + try { + const result = parseEnvInput(envInput); + setEnvError(result.error); + if (!result.error) { + setParsedEnv(result.value); + } + } catch (error) { + setEnvError((error as Error).message); + } + }, [envInput]); + + const persistTool = (): boolean => { + if (!tool) { + return false; + } + + const timeoutResult = parseTimeoutInput(timeoutInput); + setTimeoutError(timeoutResult.error); + if (timeoutResult.error) { + return false; + } + const nextTimeout = timeoutResult.value; + let nextParameters: Record; + let nextEnv: Record; + + try { + const parametersResult = parseParametersInput(parametersInput); + setParametersError(parametersResult.error); + if (parametersResult.error) { + return false; + } + nextParameters = parametersResult.value; + } catch (error) { + setParametersError((error as Error).message); + return false; + } + + try { + const envResult = parseEnvInput(envInput); + setEnvError(envResult.error); + if (envResult.error) { + return false; + } + nextEnv = envResult.value; + } catch (error) { + setEnvError((error as Error).message); + return false; + } + + if ( + name.trim().length === 0 || + description.trim().length === 0 || + command.trim().length === 0 || + duplicateName || + reservedName + ) { + return false; + } + + setParsedParameters(nextParameters); + setParsedEnv(nextEnv); + + updateSettings(currentSettings => { + const currentTool = currentSettings.customTools[toolIndex]; + if (!currentTool) { + return; + } + + currentTool.name = name.trim(); + currentTool.description = description.trim(); + currentTool.command = command.trim(); + + const shellValue = shell.trim(); + if (shellValue.length > 0) { + currentTool.shell = shellValue; + } else { + delete currentTool.shell; + } + + if (nextTimeout !== undefined) { + currentTool.timeout = nextTimeout; + } else { + delete currentTool.timeout; + } + + const workingDirValue = workingDir.trim(); + if (workingDirValue.length > 0) { + currentTool.workingDir = workingDirValue; + } else { + delete currentTool.workingDir; + } + + const promptValue = prompt.trim(); + if (promptValue.length > 0) { + currentTool.prompt = prompt; + } else { + delete currentTool.prompt; + } + + currentTool.parameters = nextParameters; + + if (Object.keys(nextEnv).length > 0) { + currentTool.env = nextEnv; + } else { + delete currentTool.env; + } + }); + + return true; + }; + + const getRawFieldValue = (): string => { + switch (selectedField) { + case 'name': + return name; + case 'description': + return description; + case 'command': + return command; + case 'shell': + return shell; + case 'timeout': + return timeoutInput; + case 'workingDir': + return workingDir; + case 'prompt': + return prompt; + case 'parameters': + return parametersInput; + case 'env': + return envInput; + } + }; + + const resetSelectedField = (snapshot: string) => { + switch (selectedField) { + case 'name': + setName(snapshot); + break; + case 'description': + setDescription(snapshot); + break; + case 'command': + setCommand(snapshot); + break; + case 'shell': + setShell(snapshot); + break; + case 'timeout': + setTimeoutInput(snapshot); + break; + case 'workingDir': + setWorkingDir(snapshot); + break; + case 'prompt': + setPrompt(snapshot); + break; + case 'parameters': + setParametersInput(snapshot); + break; + case 'env': + setEnvInput(snapshot); + break; + } + }; + + const appendCharacter = (input: string) => { + switch (selectedField) { + case 'name': + setName(prev => prev + input); + break; + case 'description': + setDescription(prev => prev + input); + break; + case 'command': + setCommand(prev => prev + input); + break; + case 'shell': + setShell(prev => prev + input); + break; + case 'timeout': + setTimeoutInput(prev => prev + input); + break; + case 'workingDir': + setWorkingDir(prev => prev + input); + break; + case 'prompt': + setPrompt(prev => prev + input); + break; + case 'parameters': + setParametersInput(prev => prev + input); + break; + case 'env': + setEnvInput(prev => prev + input); + break; + } + }; + + const deleteCharacter = () => { + switch (selectedField) { + case 'name': + setName(prev => prev.slice(0, -1)); + break; + case 'description': + setDescription(prev => prev.slice(0, -1)); + break; + case 'command': + setCommand(prev => prev.slice(0, -1)); + break; + case 'shell': + setShell(prev => prev.slice(0, -1)); + break; + case 'timeout': + setTimeoutInput(prev => prev.slice(0, -1)); + break; + case 'workingDir': + setWorkingDir(prev => prev.slice(0, -1)); + break; + case 'prompt': + setPrompt(prev => prev.slice(0, -1)); + break; + case 'parameters': + setParametersInput(prev => prev.slice(0, -1)); + break; + case 'env': + setEnvInput(prev => prev.slice(0, -1)); + break; + } + }; + + useInput((input, key) => { + if (!tool) { + if (key.escape) { + onBack(); + } + return; + } + if (editingText) { + if (key.return) { + if (persistTool()) { + setEditingText(false); + } + } else if (key.escape) { + resetSelectedField(fieldSnapshot); + setEditingText(false); + } else if (key.backspace || key.delete) { + deleteCharacter(); + } else if (input.length === 1 && !key.tab && !key.ctrl && !key.meta) { + appendCharacter(input); + } + return; + } + + if (key.escape) { + if (!hasBlockingErrors && persistTool()) { + onBack(); + } + } else if (key.upArrow) { + setSelectedFieldIndex(prev => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setSelectedFieldIndex(prev => Math.min(FIELD_ORDER.length - 1, prev + 1)); + } else if (key.return) { + setFieldSnapshot(getRawFieldValue()); + setEditingText(true); + } + }); + + if (!tool) { + return ( + + Custom tool not found + + ); + } + + const validationMessages: string[] = []; + + if (name.trim().length === 0) { + validationMessages.push('Name is required.'); + } + if (description.trim().length === 0) { + validationMessages.push('Description is required.'); + } + if (command.trim().length === 0) { + validationMessages.push('Command template is required.'); + } + if (duplicateName) { + validationMessages.push('Tool name must be unique among custom tools.'); + } + if (reservedName) { + validationMessages.push('Tool name must not match a built-in Claude tool.'); + } + if (timeoutError) { + validationMessages.push(timeoutError); + } + if (parametersError) { + validationMessages.push(`Parameters JSON: ${parametersError}`); + } + if (envError) { + validationMessages.push(`Env JSON: ${envError}`); + } + + const hasBlockingErrors = + name.trim().length === 0 || + description.trim().length === 0 || + command.trim().length === 0 || + duplicateName || + reservedName || + timeoutError !== null || + parametersError !== null || + envError !== null; + + const fieldValues: Record = { + name, + description, + command, + shell: shell.length > 0 ? shell : '(default: sh)', + timeout: timeoutInput.length > 0 ? timeoutInput : '(default: 30000)', + workingDir: + workingDir.length > 0 ? workingDir : '(default: Claude Code cwd)', + prompt: prompt.length > 0 ? prompt : '(auto-generated from description)', + parameters: + parametersInput.length > 0 ? parametersInput : JSON.stringify({}), + env: envInput.length > 0 ? envInput : JSON.stringify({}), + }; + + return ( + +
Edit Custom Tool
+ + + + {'enter to edit/save selected field · '} + {hasBlockingErrors ? ( + fix errors before going back (esc) + ) : ( + esc to go back + )} + + + While editing, esc restores the saved value for the selected field. + + + + + {FIELD_ORDER.map((field, index) => { + const isSelected = selectedFieldIndex === index; + + return ( + + + {isSelected ? '❯ ' : ' '} + {FIELD_LABELS[field]} + {isSelected && editingText ? ' [editing]' : ''} + + + + {truncate(fieldValues[field])} + + + + ); + })} + + + + {FIELD_HINTS[selectedField]} + + + + + + Parameters:{' '} + {Object.keys(parsedParameters).length} + + + Env vars:{' '} + {Object.keys(parsedEnv).length} + + + Active shell: {shell.trim() || 'sh'} + + + Prompt mode:{' '} + {prompt.trim() ? 'custom' : 'generated'} + + + + + {validationMessages.length > 0 && ( + + {validationMessages.map(message => ( + + {message} + + ))} + + )} +
+ ); +} diff --git a/src/ui/components/CustomToolsView.tsx b/src/ui/components/CustomToolsView.tsx new file mode 100644 index 00000000..53ea23cc --- /dev/null +++ b/src/ui/components/CustomToolsView.tsx @@ -0,0 +1,188 @@ +import { useContext, useState } from 'react'; +import { Box, Text, useInput } from 'ink'; + +import { CustomTool } from '@/types'; + +import { SettingsContext } from '../App'; +import { CustomToolEditView } from './CustomToolEditView'; +import Header from './Header'; + +interface CustomToolsViewProps { + onBack: () => void; +} + +const summarizeTool = (tool: CustomTool): string => { + const parts = [ + `${Object.keys(tool.parameters).length} param${Object.keys(tool.parameters).length === 1 ? '' : 's'}`, + tool.shell ?? 'sh', + ]; + + if (tool.timeout !== undefined) { + parts.push(`${tool.timeout}ms`); + } + + if (tool.env && Object.keys(tool.env).length > 0) { + parts.push(`${Object.keys(tool.env).length} env`); + } + + return parts.join(' · '); +}; + +const truncate = (value: string, max = 88): string => + value.length <= max ? value : `${value.slice(0, max - 3)}...`; + +export function CustomToolsView({ onBack }: CustomToolsViewProps) { + const { + settings: { customTools }, + updateSettings, + } = useContext(SettingsContext); + const [selectedIndex, setSelectedIndex] = useState(0); + const [editingToolIndex, setEditingToolIndex] = useState(null); + const [inputActive, setInputActive] = useState(true); + + const handleCreateTool = () => { + const existingNames = new Set(customTools.map(t => t.name)); + let candidateName = 'New Tool'; + let counter = 2; + while (existingNames.has(candidateName)) { + candidateName = `New Tool ${counter++}`; + } + + const newTool: CustomTool = { + name: candidateName, + description: 'Describe what this tool does', + parameters: {}, + command: 'echo hello', + }; + + updateSettings(settings => { + settings.customTools.push(newTool); + }); + + setEditingToolIndex(customTools.length); + setInputActive(false); + }; + + const handleDeleteTool = (index: number) => { + updateSettings(settings => { + settings.customTools.splice(index, 1); + }); + + if (selectedIndex >= customTools.length - 1) { + setSelectedIndex(Math.max(0, customTools.length - 2)); + } + }; + + const handleMoveUp = (index: number) => { + if (index <= 0) return; + + updateSettings(settings => { + const tool = settings.customTools[index]; + settings.customTools.splice(index, 1); + settings.customTools.splice(index - 1, 0, tool); + }); + + setSelectedIndex(index - 1); + }; + + const handleMoveDown = (index: number) => { + if (index >= customTools.length - 1) return; + + updateSettings(settings => { + const tool = settings.customTools[index]; + settings.customTools.splice(index, 1); + settings.customTools.splice(index + 1, 0, tool); + }); + + setSelectedIndex(index + 1); + }; + + useInput( + (input, key) => { + if (key.escape) { + onBack(); + } else if (key.upArrow) { + setSelectedIndex(prev => Math.max(0, prev - 1)); + } else if (key.downArrow && customTools.length > 0) { + setSelectedIndex(prev => Math.min(customTools.length - 1, prev + 1)); + } else if (key.return && customTools.length > 0) { + setEditingToolIndex(selectedIndex); + setInputActive(false); + } else if (input === 'n') { + handleCreateTool(); + } else if (input === 'x' && customTools.length > 0) { + handleDeleteTool(selectedIndex); + } else if (input === 'u' && customTools.length > 0 && selectedIndex > 0) { + handleMoveUp(selectedIndex); + } else if ( + input === 'd' && + customTools.length > 0 && + selectedIndex < customTools.length - 1 + ) { + handleMoveDown(selectedIndex); + } + }, + { isActive: inputActive } + ); + + if (editingToolIndex !== null) { + return ( + { + setEditingToolIndex(null); + setInputActive(true); + }} + /> + ); + } + + return ( + +
Custom Tools
+ + + + Define shell-command tools that are injected into Claude Code. + + + Use placeholders like {'{{path}}'} inside the + command template. + + + + + n to create a new tool + {customTools.length > 0 && ( + u/d to move tool up/down + )} + {customTools.length > 0 && x to delete a tool} + {customTools.length > 0 && enter to edit tool} + esc to go back + + + {customTools.length === 0 ? ( + No custom tools created yet. Press n to create one. + ) : ( + + {customTools.map((tool, index) => { + const isSelected = selectedIndex === index; + + return ( + + + {isSelected ? '❯ ' : ' '} + {tool.name} + ({summarizeTool(tool)}) + + + {truncate(tool.command)} + + + ); + })} + + )} +
+ ); +} diff --git a/src/ui/components/MainMenu.tsx b/src/ui/components/MainMenu.tsx index c4adb35d..575e4458 100644 --- a/src/ui/components/MainMenu.tsx +++ b/src/ui/components/MainMenu.tsx @@ -32,6 +32,10 @@ const baseMenuItems: SelectItem[] = [ name: MainMenuItem.TOOLSETS, desc: 'Manage toolsets to control which tools are available', }, + { + name: MainMenuItem.CUSTOM_TOOLS, + desc: 'Create shell-command tools that Claude Code can call', + }, { name: MainMenuItem.SUBAGENT_MODELS, desc: 'Configure which Claude model each subagent uses (Plan, Explore, etc.)',