Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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<string, string>;
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.
Expand Down
146 changes: 146 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { EOL } from 'node:os';
import chalk from 'chalk';

import {
CustomTool,
RemoteConfig,
Settings,
Theme,
Expand Down Expand Up @@ -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<CustomTool>;

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:
Expand Down Expand Up @@ -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);
}
Comment on lines +364 to +374
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject reserved and duplicate tool names during normalization.

Manual or remote configs can still load names that writeCustomTools() rejects later. One built-in-name collision or duplicate currently survives readConfigFile(), then causes the entire custom-tools patch to fail at apply time instead of being filtered here with the other invalid entries.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.ts` around lines 364 - 374, The current normalization in
readConfigFile leaves built-in-name collisions and duplicate custom tool names
that later cause writeCustomTools to fail; update the normalization pipeline
that uses normalizeCustomTool and the handling of config.settings.customTools to
additionally reject entries whose name is in the reserved/built-in set or that
duplicate an earlier accepted custom tool name: compute or import the
reservedNames (built-in tool names), iterate config.settings.customTools calling
normalizeCustomTool(tool, index) but then drop any nulls and also drop tools
whose name is in reservedNames or alreadySeenNames (track seen names as you
accept them) so only unique, non-reserved CustomTool objects remain before
returning from readConfigFile.


// In v3.2.0 userMessageDisplay was restructured from prefix/message to a single format string.
migrateUserMessageDisplayToV320(config);

Expand Down
1 change: 1 addition & 0 deletions src/defaultSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,7 @@ export const DEFAULT_SETTINGS: Settings = {
toolsets: [],
defaultToolset: null,
planModeToolset: null,
customTools: [],
subagentModels: {
plan: null,
explore: null,
Expand Down
Loading