Skip to content

Commit 0b30b0e

Browse files
committed
feat: add custom shell-command tools
1 parent eb46694 commit 0b30b0e

12 files changed

Lines changed: 2336 additions & 0 deletions

README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ With tweakcc, you can
7171

7272
- 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))
7373
- Create custom **toolsets** that can be used in Claude Code with the new **`/toolset`** command
74+
- Create custom **shell-command tools** that Claude Code can call
7475
- **Highlight** custom patterns while you type in the CC input box with custom colors and styling, like how `ultrathink` used to be rainbow-highlighted.
7576
- 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)
7677
- Create **custom themes** with a graphical HSL/RGB color picker
@@ -113,6 +114,7 @@ $ pnpm dlx tweakcc
113114
- [API](#api)
114115
- [System prompts](#system-prompts)
115116
- [Toolsets](#toolsets)
117+
- [Custom tools](#custom-tools)
116118
- [**Features**](#features)
117119
- [System prompts](#system-prompts)
118120
- Themes
@@ -135,6 +137,7 @@ $ pnpm dlx tweakcc
135137
- Session memory
136138
- `/remember` skill
137139
- [Toolsets](#toolsets)
140+
- [Custom tools](#custom-tools)
138141
- User message display customization
139142
- Token indicator display
140143
- [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
663666

664667
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.
665668

669+
## Custom tools
670+
671+
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.
672+
673+
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`.
674+
675+
> **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.
676+
677+
Example:
678+
679+
```json
680+
"customTools": [
681+
{
682+
"name": "RipgrepTodo",
683+
"description": "Search for TODO comments under a path",
684+
"parameters": {
685+
"path": {
686+
"type": "string",
687+
"description": "Path to search",
688+
"required": true
689+
}
690+
},
691+
"command": "rg -n TODO \"{{path}}\"",
692+
"shell": "bash",
693+
"timeout": 5000,
694+
"workingDir": "/home/user/project",
695+
"env": {
696+
"RG_COLORS": "match:fg:yellow"
697+
}
698+
}
699+
]
700+
```
701+
702+
Schema:
703+
704+
```typescript
705+
type CustomTool = {
706+
name: string;
707+
description: string;
708+
parameters: Record<
709+
string,
710+
{
711+
type: 'string' | 'number' | 'boolean';
712+
description: string;
713+
required?: boolean;
714+
}
715+
>;
716+
command: string;
717+
shell?: string;
718+
timeout?: number;
719+
workingDir?: string;
720+
env?: Record<string, string>;
721+
prompt?: string;
722+
};
723+
```
724+
725+
Notes:
726+
727+
- `prompt` is optional. If omitted, tweakcc generates a prompt from the description, parameters, and command template.
728+
- Custom tool names must be unique and must not collide with built-in Claude Code tool names such as `Bash`, `Read`, or `Write`.
729+
- Invalid custom tool entries are dropped when tweakcc loads the config.
730+
- Custom tools are currently appended after built-in toolset filtering, so they remain available even when a toolset is active.
731+
666732
## Feature: Thinking verbs customization
667733

668734
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.

src/config.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { EOL } from 'node:os';
66
import chalk from 'chalk';
77

88
import {
9+
CustomTool,
910
RemoteConfig,
1011
Settings,
1112
Theme,
@@ -166,6 +167,139 @@ const createDefaultConfig = (): TweakccConfig => ({
166167
settings: DEFAULT_SETTINGS,
167168
});
168169

170+
const normalizeCustomTool = (
171+
tool: unknown,
172+
index: number
173+
): CustomTool | null => {
174+
const invalidKeys: string[] = [];
175+
176+
if (!tool || typeof tool !== 'object' || Array.isArray(tool)) {
177+
console.warn(
178+
`config: customTools: dropping invalid tool at index ${index} (expected object)`
179+
);
180+
return null;
181+
}
182+
183+
const candidate = tool as Partial<CustomTool>;
184+
185+
if (typeof candidate.name !== 'string' || candidate.name.trim() === '') {
186+
invalidKeys.push('name');
187+
}
188+
if (
189+
typeof candidate.description !== 'string' ||
190+
candidate.description.trim() === ''
191+
) {
192+
invalidKeys.push('description');
193+
}
194+
if (
195+
typeof candidate.command !== 'string' ||
196+
candidate.command.trim() === ''
197+
) {
198+
invalidKeys.push('command');
199+
}
200+
201+
const rawParameters = candidate.parameters;
202+
if (
203+
!rawParameters ||
204+
typeof rawParameters !== 'object' ||
205+
Array.isArray(rawParameters)
206+
) {
207+
invalidKeys.push('parameters');
208+
} else {
209+
for (const [paramName, param] of Object.entries(rawParameters)) {
210+
if (
211+
paramName.trim() === '' ||
212+
!param ||
213+
typeof param !== 'object' ||
214+
Array.isArray(param)
215+
) {
216+
invalidKeys.push('parameters');
217+
break;
218+
}
219+
220+
const typedParam = param as {
221+
type?: unknown;
222+
description?: unknown;
223+
required?: unknown;
224+
};
225+
226+
if (
227+
typedParam.type !== 'string' &&
228+
typedParam.type !== 'number' &&
229+
typedParam.type !== 'boolean'
230+
) {
231+
invalidKeys.push('parameters');
232+
break;
233+
}
234+
235+
if (
236+
typeof typedParam.description !== 'string' ||
237+
typedParam.description.trim() === ''
238+
) {
239+
invalidKeys.push('parameters');
240+
break;
241+
}
242+
243+
if (
244+
typedParam.required !== undefined &&
245+
typeof typedParam.required !== 'boolean'
246+
) {
247+
invalidKeys.push('parameters');
248+
break;
249+
}
250+
}
251+
}
252+
253+
if (
254+
candidate.shell !== undefined &&
255+
(typeof candidate.shell !== 'string' || candidate.shell.trim() === '')
256+
) {
257+
invalidKeys.push('shell');
258+
}
259+
260+
if (
261+
candidate.timeout !== undefined &&
262+
(!Number.isInteger(candidate.timeout) || candidate.timeout <= 0)
263+
) {
264+
invalidKeys.push('timeout');
265+
}
266+
267+
if (
268+
candidate.workingDir !== undefined &&
269+
(typeof candidate.workingDir !== 'string' ||
270+
candidate.workingDir.trim() === '')
271+
) {
272+
invalidKeys.push('workingDir');
273+
}
274+
275+
if (
276+
candidate.env !== undefined &&
277+
(!candidate.env ||
278+
typeof candidate.env !== 'object' ||
279+
Array.isArray(candidate.env) ||
280+
Object.entries(candidate.env).some(
281+
([key, value]) => key.trim() === '' || typeof value !== 'string'
282+
))
283+
) {
284+
invalidKeys.push('env');
285+
}
286+
287+
if (candidate.prompt !== undefined && typeof candidate.prompt !== 'string') {
288+
invalidKeys.push('prompt');
289+
}
290+
291+
if (invalidKeys.length > 0) {
292+
const name =
293+
typeof candidate.name === 'string' ? ` "${candidate.name}"` : '';
294+
console.warn(
295+
`config: customTools: dropping invalid tool at index ${index}${name} (invalid/missing: ${Array.from(new Set(invalidKeys)).join(', ')})`
296+
);
297+
return null;
298+
}
299+
300+
return candidate as CustomTool;
301+
};
302+
169303
/**
170304
* Applies migrations and normalizations to a parsed config object.
171305
* This handles:
@@ -227,6 +361,18 @@ const normalizeConfig = (config: TweakccConfig): void => {
227361
);
228362
}
229363

364+
// Validate each customTool entry — drop entries missing required fields.
365+
if (!Array.isArray(config.settings.customTools)) {
366+
console.warn(
367+
'config: customTools must be an array; ignoring invalid value'
368+
);
369+
config.settings.customTools = [];
370+
} else {
371+
config.settings.customTools = config.settings.customTools
372+
.map((tool, index) => normalizeCustomTool(tool, index))
373+
.filter((tool): tool is CustomTool => tool !== null);
374+
}
375+
230376
// In v3.2.0 userMessageDisplay was restructured from prefix/message to a single format string.
231377
migrateUserMessageDisplayToV320(config);
232378

src/defaultSettings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,7 @@ export const DEFAULT_SETTINGS: Settings = {
723723
toolsets: [],
724724
defaultToolset: null,
725725
planModeToolset: null,
726+
customTools: [],
726727
subagentModels: {
727728
plan: null,
728729
explore: null,

0 commit comments

Comments
 (0)