About Ccc
Ccc is an MCP server published by 3rd in the Developer Tools category: custom launcher for Claude Code, supporting dynamic prompts, layered configuration and easy custom hooks and MCPs.
The repository has 20 stars and 2 forks, with the last commit 2 days ago. Recent commit activity is the strongest signal that reported issues will get attention.
Install
git clone https://github.com/3rd/cccThis server has no ConduID identity, so agent calls to it are not receipted. Pin the version you install and review the source before granting it credentials.
Ask AI
Ask AI about Ccc
Powered by Claude · Grounded in docs
Security checks
Static checks derived from the repository just now. Heuristics on the source, not an audit.
- ✓README presentThe repository ships documentation.
- ✓License declaredUsable under a stated license.
- ✓Tests presentA test suite exists in the repository.
- ✓Dependencies pinnedA lockfile pins dependency versions.
- !No dynamic code executionSource scan found eval or shell execution. Review how tool inputs reach it before granting credentials.
- !Scoped permissionsDoesn't declare a permission scope. Assume it can do anything its process can.
README
CCC is a launcher for Claude Code that lets you configure prompts, commands, agents, skills, hooks, MCPs, and plugins from a single place, in a layered way.
- Dynamic configuration: generate prompts, commands, agents, and skills in TypeScript with access to the current project.
- Layered config: merge global configuration with auto-detected presets and project overrides.
- Low‑effort extensibility: write hooks, MCP servers, statuslines, and workflows in TypeScript with tiny helpers.
Not affiliated with Anthropic. Uses the official
@anthropic-ai/claude-codeCLI.
Warning: Not tested on Windows, open an issue if you run into problems.
Getting Started
1. Setup
# 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
Requirements:
- Bun on
PATH.cccruns under Bun, and so do config loading, hooks, inline MCPs, and the statusline. - Node.js 24 or newer on
PATH, or setCCC_NODEto a Node binary. Claude Code itself runs under Node. - Optional, Linux only:
unshare(util-linux 2.38+) and a C compiler (cc). With them, child processes such as Bash tool commands also see your generated config (see How It Works). Without them, CCC skips that step and still works.
Claude Code version: @anthropic-ai/claude-code is pinned to an exact version in package.json, so bun update leaves it unchanged. To try another release, run bun add @anthropic-ai/claude-code@<version>; at startup, CCC lists any runtime patch that no longer matches as stale. To run a different Claude Code executable without changing the dependency, set CLAUDE_PATH. CCC supports Claude Code 2.1.242 and later.
2. Customize your config
Your configuration lives in ./config, which ships with examples:
~/my-claude-launcher/ # Your copy of this repository
└── config/
├── global/ # Applies everywhere
│ ├── prompts/ # system.ts (system prompt) and user.ts (CLAUDE.md)
│ ├── commands/ # Slash commands
│ ├── agents/ # Sub-agents
│ ├── settings.ts # Claude Code settings plus CCC-only keys
│ ├── hooks.ts # Hooks
│ ├── mcps.ts # MCP servers
│ └── statusline.ts # Statusline
└── presets/ # Language/framework/whatever-specific configs
├── golang/
├── rust/
└── typescript/
Add more as you need it: skills/, rules/, output-styles/, workflows/, and plugins.ts in any layer; projects/<name>/ for project overrides; plugins/ for CCC plugins; and claude-plugins/ for local Claude plugins.
CCC picks the config directory in this order:
CCC_CONFIG_DIR, if set (absolute, or relative to the launcher directory)../dev-config, if it exists. Keep the committed examples in./configand your real configuration in./dev-config../config.
3. Use it
- Edit your config.
- Run
cccinstead ofclaude, from anywhere. - CCC builds your config and launches Claude Code with it.
ccc # launch Claude Code
ccc --continue # every argument is passed through to claude
ccc --profile <name> # apply a settings profile (see Profiles); not passed to claude
ccc --plugin-dir <path> # use this Claude plugin dir instead of the configured pluginDirs
ccc --doru --continue # run through `npx doru --ui` to inspect network traffic
--doru is a launcher-only flag: put it before any Claude args. It opens doru's live UI; the first run may download doru, which needs npx and Node.js 22+.
To inspect your config without launching Claude, see Debugging.
How It Works
CCC injects your configuration through a virtual filesystem instead of writing it to disk. It never modifies your Claude Code installation, and your real ~/.claude/settings.json and ~/.claude/CLAUDE.md keep their contents.
Global ┐
Preset ├─► merge ─► virtual ~/.claude ─► Claude Code (patched, under Node)
Project ┘
- CCC loads and merges every layer (global, presets, project) plus enabled CCC plugins.
- It extracts the JavaScript module graph from the native Claude Code binary and applies runtime patches.
- It starts Claude Code under Node with the
fsmodule intercepted. Reads of~/.claude.json,~/.claude/settings.json,~/.claude/CLAUDE.md, and the~/.claude/{commands,agents,skills,rules,output-styles,workflows}directories return the merged config. - On Linux, the session runs in a private user and mount namespace, where each of those directories that has generated content is a session-private tmpfs mount. Child processes, such as Bash tool commands and sub-agent CLIs, then see the same content.
sudodoes not work inside the namespace; setCCC_NS_VFS=0to turn the namespace off.
Settings changes made during a session, such as /model, last for that session only.
Configuration Layers
CCC loads configuration in layers; later layers override earlier ones:
- Global →
config/global/ - Presets →
config/presets/<name>/: every preset whose matcher accepts the current project, in alphabetical order - Project →
config/projects/<name>/: the project whose root contains the working directory
Each layer can contain:
settings.ts- Claude Code settings plus CCC-only keysprompts/system.{md,append.md,ts}- system promptprompts/user.{md,append.md,ts}- user prompt (~/.claude/CLAUDE.md)commands/*.{md,append.md,ts}- slash commandsagents/*.{md,append.md,ts}- sub-agentsskills/<name>/SKILL.{md,ts}- skills and their supporting filesrules/**/*.md- rulesoutput-styles/*.md- output stylesworkflows/*.{ts,js}- workflowshooks.ts- hooksmcps.ts- MCP serversplugins.ts- Claude plugins and CCC plugins
statusline.ts is read from global/ only. Presets and projects also need an index.ts that identifies them.
How layers combine:
- Settings: a later layer replaces each top-level key it sets, except
envandfeatureFlags, which merge key by key. - Prompts, commands, agents: a
.mdfile orcreatePromptreplaces earlier layers; an.append.mdfile orcreateAppendPromptadds to them. When one name has several files,.tswins over.append.md, which wins over.md. - Skills: a later layer replaces a skill with the same name, unless it uses
mode: "append". - Output styles, workflows, MCPs: a later layer replaces an entry with the same name.
- Rules: every layer's rules apply together.
- Hooks: hooks from every layer run.
- Plugins:
cccandclaude.enabledPluginsentries merge by name; the last layer that setsclaude.pluginDirswins.
Presets
A preset applies to every project its matcher accepts:
// config/presets/typescript/index.ts
import { createPreset } from "@/config/helpers";
export default createPreset({
name: "typescript",
matcher: ({ project }) => project.hasFile("tsconfig.json"),
});
Keep name equal to the folder name; CCC reads the preset's other files from config/presets/<name>/.
The project root is the nearest directory, from the working directory upward, that contains .root, .git, package.json, go.mod, Cargo.toml, or pyproject.toml. project.hasFile() and project.hasDirectory() accept a relative path or a glob pattern, and also check nested sub-projects (directories with one of those markers) up to 4 levels below the root.
Projects
A project applies when the working directory is inside its root; if several roots match, the longest wins:
// config/projects/myapp/index.ts
import { createProject } from "@/config/helpers";
export default createProject({
name: "myapp",
root: "~/code/myapp", // supports ~/ and $ENV_VARS
disableParentClaudeMds: false, // optional: hide CLAUDE.md files from parent directories
});
// config/projects/myapp/settings.ts
import { createConfigSettings } from "@/config/helpers";
export default createConfigSettings({
env: {
NODE_ENV: "development",
API_URL: "http://localhost:3000",
},
});
Keep name equal to the folder name; CCC reads the project's other files from config/projects/<name>/.
Disabling Entries
Most entries accept enabled: false, which leaves them out of the generated config without deleting them:
- Prompts, commands, and agents written in TypeScript:
createPrompt({ handler, enabled: false }), and the same for the other prompt helpers - Presets:
createPreset({ ..., enabled: false }) - Skills:
enabled: falseinSKILL.ts - MCP entries, hook definitions, and individual hooks:
enabled: false - CCC plugins:
falseinplugins.ts, orenabled: falsein the plugin definition
Settings
settings.ts holds Claude Code settings, which CCC writes to the virtual ~/.claude/settings.json. CCC validates them against its schema (src/config/schema.ts) and drops keys the schema doesn't know. Setting model: "auto" removes the model key so Claude Code uses its default.
These CCC-only keys are read by CCC and never written to settings.json:
cli- Claude Code CLI flagsprofiles- named setting groups selected with--profilepatches- runtime patchesfeatureFlags- feature flag overridesclaudeState- keys for~/.claude.json
Variables already set in your shell win over env: CCC leaves out any env key that already exists in its own environment.
Using Models from Other Vendors
Any vendor with an Anthropic-compatible API works through env:
// config/global/settings.ts
import { createConfigSettings } from "@/config/helpers";
export default createConfigSettings({
env: {
// Z.ai (GLM)
ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic",
ANTHROPIC_AUTH_TOKEN: "<z.ai API key>",
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.2[1m]",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-5.2[1m]",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.7",
// Moonshot (Kimi)
// ANTHROPIC_BASE_URL: "https://api.moonshot.ai/anthropic",
// ANTHROPIC_AUTH_TOKEN: "<Moonshot API key>",
// ANTHROPIC_MODEL: "kimi-k2.6",
// DeepSeek
// ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic",
// ANTHROPIC_AUTH_TOKEN: "<DeepSeek API key>",
// ANTHROPIC_MODEL: "deepseek-v4-pro[1m]",
// ANTHROPIC_DEFAULT_HAIKU_MODEL: "deepseek-v4-flash",
},
});
ANTHROPIC_BASE_URL- the vendor's Anthropic-compatible endpointANTHROPIC_AUTH_TOKEN- your API key for the vendorANTHROPIC_MODEL- the main modelANTHROPIC_DEFAULT_OPUS_MODEL,ANTHROPIC_DEFAULT_SONNET_MODEL,ANTHROPIC_DEFAULT_HAIKU_MODEL- the models behind theopus,sonnet, andhaikualiases; Claude Code also uses the haiku model for background tasks
Vendors rename models often, so check the vendor's Claude Code guide for current names. To switch vendors per launch, put these variables in a profile.
Profiles
Profiles are named groups of settings selected at launch:
// config/global/settings.ts
import { createConfigSettings } from "@/config/helpers";
export default createConfigSettings({
profiles: {
glm: {
env: {
ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic",
ANTHROPIC_AUTH_TOKEN: "<z.ai API key>",
},
},
planning: {
cli: { permissionMode: "plan" },
},
},
});
ccc --profile glm
- Profiles with the same name merge across layers.
- The selected profile merges over the merged settings, with the same rules as layers.
- A profile's
envis also exported to the process environment before Claude Code starts, so authentication tokens in a profile take effect. - An unknown profile name stops the launch and lists the available profiles;
ccc --print-configlists them too.
Claude State
CCC serves a virtual ~/.claude.json built from your real one, with the workspace trust prompt already accepted for the project root and working directory. To force top-level ~/.claude.json keys, set them in claudeState:
export default createConfigSettings({
claudeState: {
leftArrowOpensAgents: false,
},
});
Keys in claudeState win over your real ~/.claude.json; everything else is still read from that file.
Feature Flags
featureFlags overrides Claude Code's GrowthBook feature flags:
export default createConfigSettings({
featureFlags: {
some_flag_name: true,
},
});
CCC writes the values into the virtual ~/.claude.json feature cache and patches Claude Code's flag readers so these values win. Set CCC_DEBUG_FEATURE_FLAGS=1 to log each override as Claude Code reads it.
CLI Argument Settings
settings.cli holds Claude Code command-line flags, which CCC passes on every launch. See the Claude Code CLI reference for details.
// config/global/settings.ts
import { createConfigSettings } from "@/config/helpers";
export default createConfigSettings({
cli: {
disallowedTools: ["WebSearch", "WebFetch"],
allowedTools: ["Read", "Glob", "Grep"],
addDir: ["/path/to/shared/libs"],
permissionMode: "plan",
debug: "api,hooks",
agent: "code-reviewer",
},
});
A flag you pass to ccc, as --flag value or --flag=value, replaces the matching settings.cli value, for example ccc --permission-mode plan.
tools(string[]or"default",--tools "Tool1,Tool2"): Available tools ("default"for all,[]to disable)disallowedTools(string[],--disallowedTools "Tool1,Tool2"): Tools removed from model context entirelyallowedTools(string[],--allowedTools "Tool1,Tool2"): Tools that execute without permission promptsaddDir(string[],--add-dir path, one flag per entry): Additional directories Claude can accesspermissionMode(enum,--permission-mode mode): Start mode:default,acceptEdits,auto,plan,bypassPermissions,dontAskdangerouslySkipPermissions(boolean,--dangerously-skip-permissions): Bypass all permission checksallowDangerouslySkipPermissions(boolean,--allow-dangerously-skip-permissions): Allow bypassing permissions without starting in that modepermissionPromptTool(string,--permission-prompt-tool name): MCP tool that answers permission prompts in non-interactive modemodel(string,--model name): Model for the sessionfallbackModel(string,--fallback-model name): Fallback model when primary is overloadedeffort(enum,--effort level): Effort level:low,medium,high,xhigh,maxthinking(enum,--thinking mode): Thinking mode:enabled,adaptive,disabledbetas(string[],--betas "beta1,beta2"): Beta headers for API requests (API key users only)agent(string,--agent name): Default agent for the sessionagents(Record<string, AgentDef>,--agents JSON): Custom subagent definitionssystemPrompt(string,--system-prompt text): Replace Claude Code's system promptsystemPromptFile(string,--system-prompt-file path): Replace Claude Code's system prompt with a file's contentsappendSystemPrompt(string,--append-system-prompt text): Text appended after the CCC system promptappendSystemPromptFile(string,--append-system-prompt-file path): Append a file's contents to the system promptappendSubagentSystemPrompt(string,--append-subagent-system-prompt text): Text appended to every subagent's system prompt, instead of the CCC system promptappendSubagentSystemPromptFile(string,--append-subagent-system-prompt-file path): Same as above, read from a filesettingSources(string[],--setting-sources "user,project,local"): Config sources to usesettings(string,--settings path-or-json): Load additional settings from a file or JSON stringstrictMcpConfig(boolean,--strict-mcp-config): Only use specified MCP configchannels(string[],--channels server, one flag per entry): MCP servers whose channel notifications register this sessiondangerouslyLoadDevelopmentChannels(string[],--dangerously-load-development-channels server, one flag per entry): Load channel servers that are not on the approved allowlist (local development only)disableSlashCommands(boolean,--disable-slash-commands): Disable slash commandschrome(boolean,--chrome/--no-chrome): Enable/disable Chrome browser integrationide(boolean,--ide): Auto-connect to IDE on startupworktree(booleanorstring,--worktree [name]): Run the session in a new git worktreetmux(booleanorstring,--tmuxor--tmux=<value>): Create a tmux session for the worktree;"classic"uses plain tmux instead of iTerm2 panesteammateMode(enum,--teammate-mode mode): Agent team display:auto,in-process,iterm2,tmuxforkSession(boolean,--fork-session): Create new session ID when resumingsessionId(string,--session-id id): Use a specific session IDfromPr(numberorstring,--from-pr pr): Resume the session linked to a PR number or URLinit(boolean,--init): Run Setup hooks with the init trigger, then continueinitOnly(boolean,--init-only): Run Setup and SessionStart hooks, then exitmaintenance(boolean,--maintenance): Run Setup hooks with the maintenance trigger, then continuefile(string[],--file spec, one flag per entry): File resources to download at startup (file_id:relative_path)verbose(boolean,--verbose): Enable verbose loggingdebug(booleanorstring,--debug [filter]): Enable debug mode, optionally with category filter; also enables CCC's debug logdebugFile(string,--debug-file path): Write debug logs to a fileenableLspLogging(boolean,--enable-lsp-logging): Enable verbose LSP loggingoutputFormat(enum,--output-format format): Print mode output:text,json,stream-jsoninputFormat(enum,--input-format format): Print mode input:text,stream-jsonjsonSchema(string,--json-schema schema): Validate print mode output against a JSON schemamaxTurns(number,--max-turns n): Limit agentic turns (print mode only)maxBudgetUsd(number,--max-budget-usd n): Maximum dollar amount to spend on API calls (print mode only)noSessionPersistence(boolean,--no-session-persistence): Don't save the session (print mode only)includePartialMessages(boolean,--include-partial-messages): Include partial streaming events (print mode withstream-json)replayUserMessages(boolean,--replay-user-messages): Re-emit stdin user messages on stdout (stream-jsoninput and output)
Prompts
Prompts, commands, and agents share one format. A .md file is static. A .ts file builds the content from the context object, with createPrompt, createCommand, or createAgent to replace earlier layers, or createAppendPrompt, createAppendCommand, or createAppendAgent to add to them. A <name>.append.md file is the static form of append.
System Prompt
The system prompt is appended to Claude Code's default system prompt with --append-system-prompt. CCC also appends it to every subagent's system prompt, unless you set cli.appendSubagentSystemPrompt or cli.appendSubagentSystemPromptFile, or pass either flag yourself. It is separate from Claude Code's output styles.
// 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.
`,
);
// config/presets/typescript/prompts/system.ts
import { createAppendPrompt } from "@/config/helpers";
export default createAppendPrompt(
() => `
Additional instructions for TypeScript projects.
`,
);
User Prompt (CLAUDE.md)
The user prompt is served as ~/.claude/CLAUDE.md. The example config ships config/global/prompts/user.ts:
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()}
`,
);
Commands
Each file in commands/ is a slash command named after the file; review.md becomes /review. The example config ships commit.md and template.md.
config/global/commands/review.md:
# Review
Review: "$ARGUMENTS"
You are conducting a code review...
// config/global/commands/branch-status.ts
import { createCommand } from "@/config/helpers";
export default createCommand(
(context) => `
# Branch Status
Working in ${context.workingDirectory}
Current branch: ${context.getGitBranch()}
`,
);
Commands in the working directory's .claude/commands/*.md are added too, and replace a layered command with the same name.
Agents
Each file in agents/ defines a sub-agent. config/global/agents/code-reviewer.md:
---
name: code-reviewer
description: Reviews code for quality and best practices
tools: [Read, Grep, Glob, Bash]
---
You are a senior code reviewer ensuring high standards of code quality and security.
When invoked:
1. Run git diff to see recent changes
2. Focus on modified files
3. Begin review immediately
// config/global/agents/debugger.ts
import { basename } from "node:path";
import { createAgent } from "@/config/helpers";
export default createAgent(
(context) => `
---
name: debugger
description: Debug issues in ${basename(context.project.rootDirectory)}
tools: [Read, Edit, Bash, Grep, Glob]
---
You are debugging code in ${context.workingDirectory}
Current branch: ${context.getGitBranch()}
`,
);
Agents in the working directory's .claude/agents/*.md are added too, and replace a layered agent with the same name.
Skills
Skills are reusable instruction bundles that Claude invokes with its Skill tool. Each skill is a folder under skills/ with a SKILL.md (static) or SKILL.ts (structured), plus any supporting files such as references/*.md.
config/global/skills/my-skill/SKILL.md:
---
name: my-skill
description: Quick checks for the current repo
allowed-tools:
- Read
- Grep
---
Use this skill to run quick repository checks and summarize findings.
// config/global/skills/my-skill/SKILL.ts
import { basename } from "node:path";
import { createSkill } from "@/config/helpers";
export default createSkill((context) => ({
description: `Checks for ${basename(context.project.rootDirectory)}`,
content: `
Run targeted analysis for ${context.workingDirectory}.
Use $ARGUMENTS to accept parameters.
`,
allowedTools: ["Read", "Grep"],
userInvocable: true,
disableModelInvocation: false,
hooks: {
PreToolUse: [
{
matcher: "Bash",
hooks: [{ type: "command", command: "echo 'Skill hook'" }],
},
],
},
}));
- The skill name defaults to the folder name.
SKILL.tsalso supportsmodel,context: "fork",agent,disallowedTools,isolation,background,effort, extrafrontmatter, and generatedfiles; seeSkillDefinitioninsrc/types/skills.ts.- Skill layers resolve global, then presets, then project, then CCC plugins. A later skill with the same name replaces the earlier one, unless it sets
mode: "append": then its body and description are appended, its files are added, and the earlier skill's other metadata is kept. An append-mode skill with no earlier skill must have its own description.
Rules
Markdown files under rules/, including subfolders, are served from ~/.claude/rules/. Global rules keep their relative path; preset rules are placed under <preset-name>/ and project rules under project/, so every layer's rules apply together.
Output Styles
Markdown files in output-styles/ are served from ~/.claude/output-styles/; a later layer replaces a file with the same name. Select a style with outputStyle, using its frontmatter name, or the file name without .md when there is no name:
export default createConfigSettings({
outputStyle: "concise",
});
Hooks
Hooks run TypeScript handlers on Claude Code events. createHook handlers run in bun processes that Claude Code starts for each event. Hook lists can also contain plain Claude Code hook entries such as { type: "command", command: "..." }. See config/global/hooks.ts for more examples.
// config/global/hooks.ts
import p from "picocolors";
import { createHook } from "@/hooks/hook-generator";
import { createConfigHooks } from "@/config/helpers";
const bashDenyList = [
{
match: /^git\s+(checkout|reset)\b/,
message: "You are not allowed to do checkouts or resets",
},
{
match: /^grep\b(?!.*\|)/,
message: "Use 'rg' (ripgrep) instead of 'grep' for better performance",
},
];
const sessionStartHook = createHook({
event: "SessionStart",
id: "global-session-start",
handler: (input) => {
console.log(`🚀 Session started from ${p.yellow(input.source)} in ${p.yellow(process.cwd())}`);
},
});
const preBashValidationHook = createHook({
event: "PreToolUse",
id: "bash-deny-list",
handler: (input) => {
const command = input.tool_input.command;
if (input.tool_name !== "Bash" || typeof command !== "string") return;
const firstMatchingRule = bashDenyList.find((rule) => rule.match.test(command));
if (!firstMatchingRule) return;
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: firstMatchingRule.message,
},
};
},
});
export default createConfigHooks({
SessionStart: [{ hooks: [sessionStartHook] }],
PreToolUse: [{ matcher: "Bash", hooks: [preBashValidationHook] }],
});
A preset can block the end of a turn until the project typechecks:
// 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({
event: "Stop",
id: "typescript-validation",
handler: async () => {
const result = await $({ nothrow: true })`tsc --noEmit`;
if (result.exitCode !== 0) {
return {
decision: "block",
reason: `Failed tsc --noEmit:\n${result.text()}`,
};
}
return { suppressOutput: true };
},
}),
],
},
],
});
createHook options:
timeout- timeout in secondsonce- run only the first time in a sessionscope-"main"(default) skips events fired inside subagents;"all"runs for them toobatchable- run together with other batchable hooks for the same event in onebunprocess; a hook joins a batch only when it sets notimeoutoronce
getSessionContext() from @/config/helpers returns the session ID and the hook events recorded so far; the example config/global/mcps.ts uses it from an inline MCP.
MCPs
mcps.ts defines MCP servers. Servers can be stdio (command), type: "http", type: "sse", or inline TypeScript servers. See config/global/mcps.ts for examples.
// config/global/mcps.ts
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 () =>
JSON.stringify({ directory: context.workingDirectory, branch: context.getGitBranch() }, null, 2),
});
return server;
});
export default createConfigMCPs({
"local-server": {
command: "/path/to/mcp-server",
args: ["--stdio"],
env: { SERVER_ROOT: "/home/user" },
filter: (tool) => tool.name !== "unwanted_tool",
},
"remote-server": {
type: "http",
url: "https://mcp.example.com/mcp",
},
"custom-tools": customTools,
});
- Inline servers are built with FastMCP; each one runs as its own
bunprocess. filterreceives each tool'snameanddescription; returnfalseto hide the tool. CCC serves a filtered server through a local stdio proxy, so filtering works for stdio, HTTP, and SSE servers.
Workflows
Workflows are deterministic scripts that orchestrate multiple subagents. Claude Code runs them with its Workflow tool.
// config/global/workflows/find-flaky-tests.ts
import { z } from "zod/v4";
import { createWorkflow } from "@/config/helpers";
const argsSchema = z.object({
pattern: z.string().describe("Test name, file glob, or failure pattern to investigate"),
});
export default createWorkflow({
name: "find-flaky-tests",
description: "Find flaky tests and propose fixes",
schema: argsSchema,
phases: [{ title: "Scan" }, { title: "Verify", model: "haiku" }],
handler: async ({ args, agent, phase, log }) => {
phase("Scan");
const flaky = await agent<string[]>(`Find flaky tests matching ${args.pattern}`, {
schema: { type: "array", items: { type: "string" } },
});
log(`found ${flaky.length}`);
return flaky;
},
});
CCC bundles each workflow and its imports into ~/.claude/workflows/<name>.js.
nameanddescriptionare required;whenToUse,schema, andphasesare optional.- Metadata must be literal values written directly in the
createWorkflowcall. schemamust be azod/v4schema. CCC infers the type ofargsfrom it and appends its JSON Schema towhenToUse.- The handler receives
agent,parallel,pipeline,phase,log,workflow,args, andbudget. - Claude Code rejects workflow files larger than 512 KiB, and workflows that call
Date.now(),new Date(), orMath.random(), because they break resume. - Claude Code enables workflows by default except on the Pro plan. Set
CLAUDE_CODE_WORKFLOWS: "1"inenvto force them on.
Statusline
CCC picks the statusline in this order:
config/global/statusline.ts, run withbunsettings.statusLine, for example{ type: "command", command: "/path/to/statusline-script" }- none
Statuslines don't layer: only the global file or the merged statusLine setting is used.
// 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 dir = data.workspace.project_dir || data.workspace.current_dir;
const components = [`${modelIcon} ${data.model.display_name}`, `📁 ${dir.split("/").slice(-2).join("/")}`];
if (data.context_window.used_percentage !== null) {
components.push(`🧠 ${Math.round(data.context_window.used_percentage)}%`);
}
console.log(components.join(" │ "));
});
You can also pipe the input to another statusline command:
import { createStatusline } from "@/config/helpers";
import { $ } from "bun";
export default createStatusline(async (data) => {
const output = await $`echo ${JSON.stringify(data)} | /path/to/statusline-command`.text();
console.log(output.trim());
});
The handler receives the JSON Claude Code sends to statusline commands. Commonly used fields:
model.id,model.display_name- the current modelworkspace.current_dir,workspace.project_dir,cwd- directoriessession_id,transcript_path,version- session detailsoutput_style.name- active output stylecost- session cost, duration, and lines changedcontext_window- token counts and context usage percentagerate_limits- usage limit windows, when availableeffort,thinking,fast_mode,vim,agent,worktree,pr- session modes
See src/types/statusline.ts for every field.
Claude Plugins
plugins.ts configures Claude Code's own plugins under the claude key, in any layer.
To enable an installed plugin, install it with /plugin, find its key in ~/.claude/plugins/installed_plugins.json, and add it to enabledPlugins. LSP plugins work the same way.
// config/global/plugins.ts
import { createConfigPlugins } from "@/config/helpers";
export default createConfigPlugins({
claude: {
enabledPlugins: {
"typescript-lsp@claude-plugins-official": true,
"gopls-lsp@claude-plugins-official": true,
},
extraKnownMarketplaces: {
"my-marketplace": { source: "github", repo: "owner/repo" },
"local-marketplace": { source: "local", path: "/path/to/marketplace" },
},
pluginDirs: [
"./claude-plugins/my-plugin", // relative paths resolve against the config directory
],
},
});
- Local plugins in
config/claude-plugins/<name>/are discovered automatically; each needs a.claude-plugin/plugin.json. ccc --plugin-dir <path>replaces the configuredpluginDirsfor that launch.
CCC Plugins
CCC plugins are local TypeScript modules that bundle commands, skills, agents, workflows, MCPs, hooks, and prompts, with typed settings and optional persistent state. Unlike Claude plugins, they are built per launch with access to the context object.
Structure
config/plugins/my-plugin/
├── plugin.json # manifest
└── index.ts # definition, using createPlugin() (or index.js)
{
"name": "my-plugin",
"version": "1.0.0",
"description": "A custom CCC plugin",
"author": "Your Name",
"license": "MIT",
"homepage": "https://example.com/my-plugin",
"repository": "https://github.com/user/my-plugin",
"dependencies": ["base-plugin"]
}
name (kebab-case), version (semver), and description are required.
CCC looks for plugins in <launcher>/plugins/, ~/.ccc/plugins/, <project root>/.ccc/plugins/, and <config>/plugins/, in that order; when two plugins share a name, the first one found wins. A plugin loads only when it is enabled in a plugins.ts layer:
// config/global/plugins.ts
import { createConfigPlugins } from "@/config/helpers";
export default createConfigPlugins({
ccc: {
"my-plugin": true,
"another-plugin": false,
"configurable-plugin": {
enabled: true,
settings: { maxItems: 50, mode: "fast" },
},
},
});
Definition
createPlugin takes these optional fields. Each component function receives the plugin context and returns entries keyed by name:
commands-{ content, mode }per commandskills- the same fields asSKILL.tsagents-{ content, mode }per agentworkflows- a workflow source path, relative to the plugin foldermcps- the same entries asmcps.tshooks- the same shape ashooks.tsprompts-{ system?, user? }, each a string or{ content, mode }; always appended after the layered promptssettingsSchema- a Zod schema for the plugin's settingsstateType- wherecontext.stateis storedonLoad- a callback run when the plugin loadsenabled-falseto disable the plugin
Plugin commands, skills, agents, workflows, and MCPs are namespaced with the plugin name, such as /task-tracker:track in the example below. A config layer's workflow wins over a plugin workflow with the same name.
// config/plugins/task-tracker/index.ts
import { FastMCP } from "fastmcp";
import { z } from "zod";
import { createMCP, createPlugin } from "@/config/helpers";
import { createHook } from "@/hooks/hook-generator";
export default createPlugin({
stateType: "project",
commands: () => ({
track: {
content: `
# Task Tracker
Track a new task: "$ARGUMENTS"
Use the task_add MCP tool to add this task.
`.trim(),
mode: "override",
},
}),
mcps: (context) => ({
"task-tracker": createMCP(() => {
const server = new FastMCP({ name: "task-tracker", version: "1.0.0" });
server.addTool({
name: "task_add",
description: "Add a new task",
parameters: z.object({
title: z.string(),
priority: z.enum(["low", "medium", "high"]).default("medium"),
}),
execute: async (args) => {
context.state.update((state) => {
const tasks = state.get<string[]>("tasks") ?? [];
state.set("tasks", [...tasks, `[${args.priority}] ${args.title}`]);
});
return `Added: ${args.title}`;
},
});
server.addTool({
name: "task_list",
description: "List all tasks",
parameters: z.object({}),
execute: async () => {
const tasks = context.state.get<string[]>("tasks") ?? [];
return tasks.length > 0 ? tasks.join("\n") : "No tasks";
},
});
return server;
}),
}),
hooks: () => ({
SessionStart: [
{
hooks: [
createHook({
event: "SessionStart",
id: "task-tracker-init",
handler: () => {
console.log("📋 Task Tracker plugin loaded");
},
}),
],
},
],
}),
});
ccc --print-config lists loaded plugins and their components:
CCC Plugins:
task-tracker (v1.0.0) [enabled]
Commands: task-tracker:track
MCPs: task-tracker:task-tracker
Hooks: SessionStart(1)
Plugin Context
Component functions and onLoad receive a PluginContext: every context object member, plus:
{
plugin: {
name: string; // from plugin.json
version: string; // from plugin.json
root: string; // plugin directory
settings: S; // validated plugin settings
};
state: PluginState; // see Plugin State
getPlugin(name: string): PluginContext | undefined; // another loaded plugin's context
}
Plugin Settings
Define settings with a Zod schema; context.plugin.settings is typed from it:
// config/plugins/configurable-plugin/index.ts
import { z } from "zod";
import { createPlugin } from "@/config/helpers";
const settingsSchema = z.object({
maxItems: z.number().default(100),
mode: z.enum(["fast", "balanced", "thorough"]).default("balanced"),
});
export default createPlugin({
settingsSchema,
onLoad: (context) => {
const { maxItems, mode } = context.plugin.settings;
console.log(`loaded with maxItems=${maxItems} mode=${mode}`);
},
});
Pass values with settings in plugins.ts, as shown in Structure. CCC validates them when the plugin loads; if validation fails, CCC logs the error and continues without the plugin.
Plugin State
context.state is a key-value store:
context.state.get<T>(key) // read a value
context.state.set(key, value) // write a value
context.state.getAll() // read all values
context.state.clear() // remove all values
context.state.update((state) => { /* get and set */ }) // atomic read-modify-write
stateType selects where it lives:
-
"none"(default) - in memory only, for state that doesn't need to persist -
"temp"-/tmp/ccc-plugin-{name}-{instanceId}.json, per-session state cleared on reboot -
"project"-{working directory}/.ccc/state/plugins/{name}.json, persistent state for one project -
"user"-~/.ccc/state/plugins/{name}.json, persistent state shared across projects -
The instance ID is the
CCC_INSTANCE_IDof the current CCC session. -
Hooks and inline MCPs run in their own processes, so they share state only through a file-backed
stateType. -
update()runs the change under a file lock, and throws aPluginStateErrorwith codebusyif another process holds the lock. -
onLoadruns in every process that loads the plugin: once at launch, and again in each hook or inline MCP process that needs it.
Plugin Dependencies
dependencies in plugin.json load before the plugins that list them. A dependency is not enabled automatically, so enable it in plugins.ts too. A circular dependency stops all CCC plugins from loading.
Runtime Patches
CCC patches Claude Code at runtime and never modifies node_modules. It extracts the JavaScript module graph embedded in the Claude Code binary to ~/.cache/ccc/claude-cli/<version>/, applies the patches, caches the result in ~/.cache/ccc/claude-cli-patched/, and runs Claude Code from there. Both paths move under $XDG_CACHE_HOME when it is set.
Built-in patches, applied on every launch:
- Disable the
/security-reviewcommand - Make
featureFlagsoverride Claude Code's feature flags - Stop the Bash tool from replacing
findandgrepwith the native binary's built-in versions, which do not work when Claude Code runs under Node - Load Claude Code's built-in plugins from memory, as the native binary does, because the folder path they otherwise use only exists under Bun
User-defined Patches
export default createConfigSettings({
patches: [
{ find: "ultrathink", replace: "uuu" },
{ name: "my-patch", fn: (source) => source.replace("old text", "new text") },
],
});
- A
find/replacepatch replaces every occurrence; afnpatch receives the whole CLI source and returns the patched source. - User patches run after the built-in patches.
- At startup, CCC reports patches that matched nothing as
stale. - The patched copy is cached by the extracted Claude Code build, CCC's built-in patches, and your patches' content. A
fnpatch is identified by its source text, so if it reads values from outside its body, run once withCCC_PATCH_CACHE=0after changing them.CCC_PATCH_CACHE=verifyrebuilds the patched copy and compares it with the cache.
Debugging
These commands print and exit without launching Claude:
ccc --print-config # merged settings, profiles, plugins, prompt previews, and every generated item
ccc --print-system-prompt
ccc --print-user-prompt
ccc --doctor # where each piece of config came from
ccc --doctor --json
ccc --dump-config # write the generated config to disk
ccc --debug-mcp <mcp-name> # open an MCP server in the MCP Inspector
ccc --timing # how long each config-building step took
Doctor
ccc --doctor reports:
- the presets detected and the project configuration in use
- layering traces (override/append) for the system and user prompts
- per-item layering traces for commands, agents, skills, rules, output styles, workflows, and hooks
- MCP servers, their transport type, and the layer that defined them
- CCC plugins and Claude plugin settings (enabled plugins, plugin dirs, marketplaces)
- profiles
Dump Configuration
ccc --dump-config writes the generated config to .config-dump/<timestamp>/ in the current directory:
system.md,user.md- the system prompt andCLAUDE.mdsettings.json- the generatedsettings.jsonclaude.json- the parts of~/.claude.jsonCCC manages:cachedGrowthBookFeaturesand yourclaudeStatekeyscommands/,agents/,skills/,rules/,output-styles/,workflows/- the generated filesmcps.json- the MCP server configuration passed to Claudemetadata.json- context, paths, and file counts
The dump leaves out your account details and project history. Values in env and MCP headers whose names look secret (containing token, key, secret, password, auth, credential, or cookie) are replaced with [redacted]. CCC also writes a .gitignore into .config-dump/, so git ignores dumps in any project.
Debug MCPs
ccc --debug-mcp <mcp-name> opens the server in the MCP Inspector, where you can list its tools, resources, and prompts, call tools, and inspect payloads. It works with stdio and inline servers, not HTTP or SSE; a filtered server shows its tools after filtering.
Logs
Passing --debug (or -d), setting DEBUG, or setting cli.debug enables CCC's debug log. CCC prints the log paths at startup and writes the logs to <launcher>/.cache/<instance-id>/ (log and hooks.jsonl).
Reference
Context Object
Dynamic configuration receives a context object:
{
workingDirectory: string; // current working directory
launcherDirectory: string; // launcher installation
configDirectory: string; // config directory in use
instanceId: string; // unique instance identifier
project: Project; // rootDirectory, presets, projectConfig, hasFile(), hasDirectory()
mcpServers?: Record<string, ClaudeMCPConfig>; // processed MCP configs for this run
loadedPlugins: LoadedPlugin[]; // CCC plugins loaded for this run
isGitRepo(): boolean;
getGitBranch(): string;
getGitStatus(): string; // porcelain
getGitRecentCommits(n?): string; // default 5
getGitRemoteUrl(remote?): string; // default "origin"
getGitCommitHash(short?): string; // HEAD
getDirectoryTree(): string;
getPackageJson(): Record<string, unknown> | null; // package.json in the working directory
getProjectRelativePath(path): string; // relative to the working directory
getEnv(key, default?): string | undefined;
isCI(): boolean;
getPlatform(): string;
getOsVersion(): string;
getCurrentDateTime(): string; // ISO timestamp
hasMCP(name: string): boolean; // true if an MCP with that name is configured
}
Environment Variables
CCC_CONFIG_DIR- config directory (absolute, or relative to the launcher directory)CLAUDE_PATH- Claude Code executable to run instead of the bundled dependency (2.1.242+)CCC_NODE- Node.js binary to run Claude Code with (default:nodeonPATH)DEBUG- enable CCC's debug logCCC_NS_VFS=0- turn off the Linux mount namespaceCCC_PATCH_CACHE-0to turn off the patched CLI cache,verifyto check it against a fresh buildCCC_COMPILE_CACHE=0- turn off Node's compile cache (default location:~/.cache/ccc/v8-compile-cache)CCC_DEBUG_FEATURE_FLAGS=1- logfeatureFlagsoverrides as Claude Code reads them
License
MIT License. See LICENSE for details.
README fetched from the repository just now. The original is authoritative.