ccc is a launcher for Claude Code that lets you configure prompts, commands, agents, hooks, and MCPs from a single place, in a layered way.
What you get
- Dynamic Configuration: Generate system/user prompts, commands, agents dynamically.
- Layered config: Merge global configuration with presets and project overrides.
- Lowβeffort extensibility: write hooks & MCPs in TypeScript with tiny helpers.

Not affiliated with Anthropic. Uses the official
@anthropic-ai/claude-code
CLI.
Warning: Not tested on Windows, open an issue if you run into problems.
# clone this repo somewhere you'd like to keep going to edit your configuration
git clone https://github.com/3rd/ccc.git ~/my-claude-launcher
cd ~/my-claude-launcher
# install dependencies and link `ccc`
bun install
bun link
# install tsx globally (required for runtime interception)
bun add -g tsx
Note: Claude Code (@anthropic-ai/claude-code
) is included as a dependency.
To update it to the latest version do a bun update
.
Your configuration lives in the ./config
directory, which includes some examples by default.
~/my-claude-launcher/ # Your copy of this repository
βββ config/
βββ global/ # Global configuration
β βββ prompts/ # System (output style) / user (CLAUDE.md) prompts
β βββ commands/ # Your commands
β βββ agents/ # Your sub-agents
β βββ hooks.ts # Your hooks
β βββ mcps.ts # Your MCPs
βββ presets/ # Your language/framework/whatever-specific configs
β βββ typescript/ # Example: TypeScript-specific settings
βββ projects/ # Your project-specific overrides
βββ myapp/ # Example: Settings for your 'myapp' project
Development Mode: If a ./dev-config
directory exists, it will be used instead of ./config
. This allows you to keep the example configuration in ./config
(committed to git) while using ./dev-config
for your actual development configuration.
Your workflow:
- Edit your config in
~/my-claude-launcher/config/
- Run
ccc
instead ofclaude
from anywhere - Your config is dynamically built and loaded
ccc # wrap and launch claude
ccc --continue # all the arguments you pass will be passed through to claude
# except these special cases used for debugging (they don't launch claude)
ccc --doctor
ccc --print-config
ccc --print-system-prompt
ccc --print-user-prompt
ccc
loads configurations in layers (later overrides earlier):
- Global β
config/global/
- Base configuration for all projects - Presets β
config/presets/
- Auto-detected based on project type - Projects β
config/projects/
- Specific project overrides
Each layer can define:
settings.ts
- Settings that will go into Claude Code'ssettings.json
prompts/user.{md,ts}
- User instructions (CLAUDE.md)prompts/system.{md,ts}
- Output stylecommands/*.{md,ts}
- Custom slash commandsagents/*.{md,ts}
- Custom sub-agentshooks.ts
- Custom hooksmcps.ts
- Custom MCPs
ccc
injects configurations using a virtual filesystem overlay. Your actual Claude installation remains untouched.
Configurations are injected at runtime through Node.js module interception.
The launcher:
- Discovers and merges configurations from all layers
- Generates a vfs with the merged config
- Intercepts Node's modules to serve virtual files
- Launches Claude with the injected configuration
Global β
Preset βββΊ merge ββΊ "virtual overlay" ββΊ Claude Code
Project β
Some settings will still be read from your global ~/.claude.json
:
# things like these:
claude config set -g autocheckpointingEnabled true
claude config set -g diffTool delta
claude config set -g supervisorMode true
claude config set -g autoCompactEnabled true
claude config set --global preferredNotifChannel terminal_bell
claude config set -g verbose true
Controls how Claude responds and behaves.
Static (Markdown) (config/global/prompts/system.md
):
You are a helpful coding assistant.
Write clean, maintainable code.
Follow best practices.
Dynamic (TypeScript) (config/global/prompts/system.ts
):
import { createPrompt } from "@/config/helpers";
export default createPrompt(
(context) => `
You are working in ${context.workingDirectory}
${context.isGitRepo() ? `Current branch: ${context.getGitBranch()}` : ""}
Write clean, maintainable code.
`,
);
Append Mode (adds to previous layers):
import { createAppendPrompt } from "@/config/helpers";
export default createAppendPrompt(
(context) => `
Additional instructions for this preset.
`,
);
You can also use Markdown files in append mode, just name them: <target>.append.md
Project-specific instructions and context. See config/global/prompts/user.ts
for a full example:
import { createPrompt } from "@/config/helpers";
export default createPrompt(
(context) => `
# CRITICAL RULES
Do exactly what the user asks. No alternatives, no "better" solutions...
Working in: ${context.workingDirectory}
Git branch: ${context.getGitBranch()}
`,
);
Custom slash commands available in Claude. See config/global/commands/
for examples:
Static (Markdown) (config/global/commands/review.md
):
# Review
Review: "$ARGUMENTS"
You are conducting a code review...
Dynamic (TypeScript):
import { createCommand } from "@/config/helpers";
export default createCommand(
(context) => `
# Custom Command
Working in ${context.workingDirectory}
Current branch: ${context.getGitBranch()}
Your command instructions here...
`,
);
Append to existing command:
import { createAppendCommand } from "@/config/helpers";
export default createAppendCommand(
(context) => `
Additional instructions for TypeScript projects...
`,
);
Event handlers that run at specific Claude events. See config/global/hooks.ts
for examples:
Examples for global hooks:
import p from "picocolors";
import { createHook } from "@/hooks/hook-generator";
import { createConfigHooks } from "@/config/helpers";
const bashDenyList = [
{
match: /^\bgit\bcheckout/,
message: "You are not allowed to do checkouts or resets",
},
{
match: /^\bgrep\b(?!.*\|)/,
message: "Use 'rg' (ripgrep) instead of 'grep' for better performance",
},
];
const sessionStartHook = createHook("SessionStart", (input) => {
const timestamp = new Date().toISOString();
console.log(p.dim("π"));
console.log(
`π Session started from ${p.yellow(input.source)} at ${p.blue(timestamp)}`,
);
console.log(`π Working directory: ${p.yellow(process.cwd())}`);
console.log(`π§ Node version: ${p.yellow(process.version)}`);
console.log(p.dim("π"));
});
const preBashValidationHook = createHook("PreToolUse", (input) => {
const command = input.tool_input.command as string;
if (input.tool_name !== "Bash" || !command) return;
const firstMatchingRule = bashDenyList.find((rule) =>
command.match(rule.match),
);
if (!firstMatchingRule) return;
return {
continue: true,
decision: "block",
reason: firstMatchingRule?.message,
};
});
export default createConfigHooks({
SessionStart: [{ hooks: [sessionStartHook] }],
PreToolUse: [{ hooks: [preBashValidationHook] }],
});
TypeScript Validation Example (config/presets/typescript/hooks.ts
):
import { $ } from "zx";
import { createHook } from "@/hooks/hook-generator";
import { createConfigHooks } from "@/config/helpers";
export default createConfigHooks({
Stop: [
{
hooks: [
createHook("Stop", async () => {
const result = await $`tsc --noEmit`;
if (result.exitCode !== 0) {
return {
continue: true,
decision: "block",
reason: `Failed tsc --noEmit:\n${result.text()}`,
};
}
return { suppressOutput: true };
}),
],
},
],
});
Specialized sub-agents for specific tasks. See config/global/agents/
for examples:
Static (Markdown) (config/global/agents/code-reviewer.md
):
---
name: code-reviewer
description: Reviews code for quality and best practices
tools: [Read, Grep, Glob, Bash]
---
# Code Reviewer Agent
You are a specialized code review agent conducting **SYSTEMATIC, EVIDENCE-FIRST CODE REVIEWS**.
## Core Principles
**EVIDENCE BEFORE OPINION** - Always provide file:line references...
Dynamic (TypeScript):
import { createAgent } from "@/config/helpers";
export default createAgent(
(context) => `
---
name: debugger
description: Debug issues in ${context.project.name}
tools: [Read, Edit, Bash, Grep, Glob]
---
# Debugger Agent
You are debugging code in ${context.workingDirectory}
Current branch: ${context.getGitBranch()}
`,
);
Model Context Protocol servers for extending Claude's capabilities. See config/global/mcps/
for examples:
import { createConfigMCPs } from "@/config/helpers";
export default createConfigMCPs({
filesystem: {
command: "npx",
args: ["@modelcontextprotocol/server-filesystem"],
env: { FS_ROOT: "/home/user" },
},
});
You can easily define custom MCPs in your config using FastMCP.
import { FastMCP } from "fastmcp";
import { z } from "zod";
import { createConfigMCPs, createMCP } from "@/config/helpers";
const customTools = createMCP((context) => {
const server = new FastMCP({
name: "custom-tools",
version: "1.0.0",
});
server.addTool({
name: "getProjectInfo",
description: "Get current project information",
parameters: z.object({}),
execute: async () => {
return JSON.stringify(
{
directory: context.workingDirectory,
branch: context.getGitBranch(),
isGitRepo: context.isGitRepo(),
},
null,
2,
);
},
});
return server;
});
export default createConfigMCPs({
"custom-tools": customTools,
});
Customize the Claude statusline with a simple configuration-based approach.
config/global/statusline.ts
- If this file exists, it will be executed withbun
settings.statusLine
- Otherwise, use the statusLine configuration from settings- None - If neither is configured, no statusline is displayed
Create config/global/statusline.ts
:
import { createStatusline } from "@/config/helpers";
import type { StatusLineInput } from "@/types/statusline";
export default createStatusline(async (data: StatusLineInput) => {
const modelIcon = data.model?.id?.includes("opus") ? "π¦" : "π";
const components = [];
// Model and icon
components.push(`${modelIcon} ${data.model.display_name }`);
// Working directory
if (data.workspace) {
const dir = data.workspace.project_dir || data.workspace.current_dir;
const shortDir = dir.split("/").slice(-2).join("/");
components.push(`π ${shortDir}`);
}
// Hook event (if present)
if (data.hook_event_name) {
components.push(`β‘ ${data.hook_event_name}`);
}
console.log(components.join(" β "));
});
You can also integrate external statusline tools:
import { createStatusline } from "@/config/helpers";
import { $ } from "bun";
export default createStatusline(async (data) => {
// Use external ccstatusline tool
const output = await $`echo ${JSON.stringify(data)} | bunx ccstatusline`.text();
const modelIcon = data.model?.id?.includes("opus") ? "π¦" : "π";
console.log(`${modelIcon} ${output.trim()}`);
});
The statusline function receives a StatusLineInput
object with:
model.id
- Model identifier (e.g., "claude-3-opus-20240229")model.display_name
- Human-readable model nameworkspace.current_dir
- Current working directoryworkspace.project_dir
- Project root directoryhook_event_name
- Current hook event being executedsession_id
- Current session identifiertranscript_path
- Path to the transcript filecwd
- Current working directoryoutput_style
- Output style configuration
Alternatively, configure a custom statusline command in settings.ts
:
export default createConfigSettings({
statusLine: {
type: "command",
command: "/path/to/your/statusline-script",
},
});
Note: Unlike other configuration types, statuslines do NOT support layering or merging. Only the global configuration or settings are used.
Use ccc --doctor
to print a diagnostic report of your merged configuration without launching Claude:
ccc --doctor
ccc --doctor --json
The report shows:
- Presets detected and project configuration in use
- Layering traces (override/append) for system/user prompts
- Per-command and per-agent layering traces across global/presets/project
- MCP servers and their transport type
Create a project-specific configuration:
// config/projects/myapp/project.ts
export default {
name: "myapp",
root: "/path/to/myapp",
disableParentClaudeMds: false, // optional, will disable Claude's behavior of loading upper CLAUDE.md files
};
// config/projects/myapp/settings.ts
import { createConfigSettings } from "@/config/helpers";
export default createConfigSettings({
env: {
NODE_ENV: "development",
API_URL: "http://localhost:3000",
},
});
All dynamic configurations receive a context object with a few utilities:
{
workingDirectory: string; // Current working directory
launcherDirectory: string; // Path to launcher installation
instanceId: string; // Unique instance identifier
project: Project; // Project instance with config
mcpServers?: Record<string, ClaudeMCPConfig>; // Processed MCP configs for this run
isGitRepo(): boolean; // Check if in git repository
getGitBranch(): string; // Current git branch
getGitStatus(): string; // Git status (porcelain)
getGitRecentCommits(n): string; // Recent commit history
getDirectoryTree(): string; // Directory structure
getPlatform(): string; // OS platform
getOsVersion(): string; // OS version info
getCurrentDateTime(): string; // ISO timestamp
hasMCP(name: string): boolean; // True if MCP with name is configured
}
- ?
MIT License. See LICENSE
for details.