Environment Configuration Guide
July 20, 2026 ยท View on GitHub
This guide covers how to create YAML configuration files for setting up complete Claude Code environments using the Claude Code Toolbox. Environment configurations let you define custom development setups with agents, MCP servers, slash commands, hooks, skills, and more -- all installable with a single command.
The setup script handles everything automatically -- it installs Claude Code, creates the necessary directories, downloads all configured resources, and registers global commands. No prior installation is required.
Supported platforms: Windows, macOS, and Linux.
Quick Start
Minimal Configuration
A working configuration needs just a few keys:
name: "My Environment"
command-names:
- "my-env"
base-url: "https://raw.githubusercontent.com/my-org/my-claude-configs/main"
command-defaults:
system-prompt: "prompts/my-prompt.md"
mode: "append"
This creates a global command my-env that launches Claude Code with a custom system prompt appended to the default development prompt. The base-url tells the setup where to find resources. The system-prompt path prompts/my-prompt.md resolves to https://raw.githubusercontent.com/my-org/my-claude-configs/main/prompts/my-prompt.md. To use this config, host it in your repository and run the one-liner command below.
How to Run
Run a single command that sets your configuration source and executes the setup script. The examples below show the one-liner format for each platform.
Windows
Public config URL
powershell -NoProfile -ExecutionPolicy Bypass -Command "`$env:CLAUDE_CODE_TOOLBOX_ENV_CONFIG='https://raw.githubusercontent.com/org/repo/main/config.yaml'; iex (irm 'https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/windows/setup-environment.ps1')"
Local file
$env:CLAUDE_CODE_TOOLBOX_ENV_CONFIG='./my-env.yaml'
iex (irm 'https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/windows/setup-environment.ps1')
Private GitLab repository
powershell -NoProfile -ExecutionPolicy Bypass -Command "`$env:CLAUDE_CODE_TOOLBOX_ENV_CONFIG='https://gitlab.company.com/namespace/project/-/raw/main/config.yaml'; `$env:GITLAB_TOKEN='glpat-<your-token>'; iex (irm 'https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/windows/setup-environment.ps1')"
Private GitHub repository
powershell -NoProfile -ExecutionPolicy Bypass -Command "`$env:CLAUDE_CODE_TOOLBOX_ENV_CONFIG='https://raw.githubusercontent.com/org/repo/main/config.yaml'; `$env:GITHUB_TOKEN='ghp_<your-token>'; iex (irm 'https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/windows/setup-environment.ps1')"
macOS
# Public config URL
export CLAUDE_CODE_TOOLBOX_ENV_CONFIG='https://raw.githubusercontent.com/org/repo/main/config.yaml' && curl -fsSL https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/macos/setup-environment.sh | bash
# Local file
export CLAUDE_CODE_TOOLBOX_ENV_CONFIG=./my-env.yaml && curl -fsSL https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/macos/setup-environment.sh | bash
# Private GitLab
export CLAUDE_CODE_TOOLBOX_ENV_CONFIG='https://gitlab.company.com/namespace/project/-/raw/main/config.yaml' && export GITLAB_TOKEN='glpat-<your-token>' && curl -fsSL https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/macos/setup-environment.sh | bash
# Private GitHub
export CLAUDE_CODE_TOOLBOX_ENV_CONFIG='https://raw.githubusercontent.com/org/repo/main/config.yaml' && export GITHUB_TOKEN='ghp_<your-token>' && curl -fsSL https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/macos/setup-environment.sh | bash
Linux
# Public config URL
export CLAUDE_CODE_TOOLBOX_ENV_CONFIG='https://raw.githubusercontent.com/org/repo/main/config.yaml' && curl -fsSL https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/linux/setup-environment.sh | bash
# Local file
export CLAUDE_CODE_TOOLBOX_ENV_CONFIG=./my-env.yaml && curl -fsSL https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/linux/setup-environment.sh | bash
# Private GitLab
export CLAUDE_CODE_TOOLBOX_ENV_CONFIG='https://gitlab.company.com/namespace/project/-/raw/main/config.yaml' && export GITLAB_TOKEN='glpat-<your-token>' && curl -fsSL https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/linux/setup-environment.sh | bash
# Private GitHub
export CLAUDE_CODE_TOOLBOX_ENV_CONFIG='https://raw.githubusercontent.com/org/repo/main/config.yaml' && export GITHUB_TOKEN='ghp_<your-token>' && curl -fsSL https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/linux/setup-environment.sh | bash
Important: Do not run the setup scripts as root or with
sudo. The scripts will request elevated permissions only when needed. For Docker or CI environments, setCLAUDE_CODE_TOOLBOX_ALLOW_ROOT=1.
CLI Flags
| Flag | Purpose |
|---|---|
--yes / -y | Auto-confirm installation (skip interactive prompt) |
--dry-run | Show installation plan and exit without installing |
Important: CLI flags like
--yes,--dry-run,--skip-install, and--no-admincannot be passed through piped invocations (iex (irm ...)on Windows,curl ... | bashon Linux/macOS). The piped execution pattern creates no parameter binding context, so flags are silently ignored. Use environment variables instead (see Non-interactive mode, Dry-run mode, Skip Claude Code installation, and Skip admin elevation (Windows) below).
Ready-Made Configurations
The claude-code-artifacts-public repository contains ready-made environment configurations that you can use directly.
To install a configuration from that repository, use its full raw URL as the config source:
Linux
export CLAUDE_CODE_TOOLBOX_ENV_CONFIG='https://raw.githubusercontent.com/alex-feel/claude-code-artifacts-public/main/environments/templates/basic-template.yaml' && curl -fsSL https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/linux/setup-environment.sh | bash
macOS
export CLAUDE_CODE_TOOLBOX_ENV_CONFIG='https://raw.githubusercontent.com/alex-feel/claude-code-artifacts-public/main/environments/templates/basic-template.yaml' && curl -fsSL https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/macos/setup-environment.sh | bash
Windows
powershell -NoProfile -ExecutionPolicy Bypass -Command "`$env:CLAUDE_CODE_TOOLBOX_ENV_CONFIG='https://raw.githubusercontent.com/alex-feel/claude-code-artifacts-public/main/environments/templates/basic-template.yaml'; iex (irm 'https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/windows/setup-environment.ps1')"
Browse the repository to discover available configurations and use them as starting points for your own.
Configuration Reference
Quick-reference table of all configuration keys. Each key links to its detailed documentation in the Configuration Keys section below.
| YAML Key | Type | Required | Default | Brief Description |
|---|---|---|---|---|
name | str | Yes | -- | Display name for the environment |
description | str | No | None | Config description (shown in summary) |
post-install-notes | str | No | None | Notes shown after successful installation |
version | str | No | None | Config version (semver) |
inherit | str | list | No | None | Parent config URL/path/name or list for composition chains |
merge-keys | list[str] | No | None | Keys to merge instead of replace |
command-names | list[str] | No* | [] | Command names and aliases |
base-url | str | No | None | Base URL for relative resource paths |
claude-code-version | str | No | None | Specific Claude Code version or "latest" |
install-nodejs | bool | No | None | Install Node.js LTS before dependencies |
link-projects-dir | bool | No* | None | Link isolated projects/ to base ~/.claude/projects/ |
dependencies | dict | No | {} | Platform-specific dependency commands |
agents | list[str] | No | [] | Agent markdown file paths |
slash-commands | list[str] | No | [] | Slash command file paths |
rules | list[str] | No | [] | Rule markdown file paths (user-scope) |
skills | list[Skill] | No | [] | Skill configurations |
files-to-download | list[FileToDownload] | No | [] | Files to download during setup |
global-config | GlobalConfig | No | None | Raw ~/.claude.json content (camelCase keys) |
hooks | Hooks | No | None | Hook configurations (files and events) |
mcp-servers | list[dict] | No | [] | MCP server configurations |
os-env-variables | dict | No | None | OS-level persistent environment variables |
command-defaults | CommandDefaults | No* | None | System prompt and mode |
user-settings | UserSettings | No | None | Raw settings.json content (camelCase keys) |
status-line | StatusLine | No | None | Status line script configuration |
command-namesandcommand-defaultshave a co-dependency: if one is specified, the other must also be specified.
link-projects-dirrequirescommand-names: settinglink-projects-dir: truewithoutcommand-namesproduces a validation error, because the projects link only applies to an isolated profile (created only whencommand-namesis present).
Configuration key naming
All configuration keys use kebab-case (hyphenated lowercase), for example mcp-servers, os-env-variables, files-to-download. Using underscores (os_env_variables, mcp_servers) will cause the key to be flagged as unknown during installation.
Sub-key naming conventions:
- Top-level keys (
hooks,mcp-servers,status-line, etc.): MUST be kebab-case (validated byKNOWN_CONFIG_KEYS) - Sub-keys in structured sections (
hooks.events[]): MUST be kebab-case (the toolbox translates to camelCase for Claude Code JSON output) - Sub-keys in free-form sections (
user-settings,global-config): MUST match Claude Code's native camelCase (pass-through, no translation)
Note: The Pydantic validation model (
EnvironmentConfig) usespopulate_by_name=Truefor testing convenience, which means CI validation accepts bothos_env_variablesandos-env-variables. However, the runtime setup script (setup_environment.py) usesconfig.get('os-env-variables')and will not recognize underscore variants. Always use kebab-case in your configuration files.
Configuration Keys
Core Settings
name
Display name for the environment, shown in the setup header and summary.
- Type:
str(required) - Inheritance: Standard override (child replaces parent)
- Example:
name: "Python Development"
description
Description of the environment configuration. Shown in the installation summary immediately after the configuration name, providing context about the environment's purpose.
- Type:
str | None - Default:
None - Multiline: Supported via YAML
|(literal block) or>(folded block) scalars - Display: In installation summary, after "Configuration:" and before "Source:", with 2-space indent per line. No "Description:" label prefix.
- Inheritance: Standard override (child replaces parent)
- Example:
description: |
A comprehensive development environment for AI-powered coding
with pre-configured MCP servers, custom agents, and debugging tools.
post-install-notes
Notes displayed after successful installation. Use for next steps, setup instructions, API key configuration, or any guidance the configuration author wants to communicate after the environment is installed.
- Type:
str | None - Default:
None - Multiline: Supported via YAML
|(literal block) or>(folded block) scalars - Display: After successful installation only (not on failure, not in dry-run). Rendered after the "Documentation:" section with a yellow header "Notes from the configuration author:" and 2-space indent per line.
- Inheritance: Standard override (child replaces parent)
- Example:
post-install-notes: |
Next steps:
1. Set your API key: export ANTHROPIC_API_KEY=sk-...
2. Start the environment: my-env
3. Run /help to see available commands
Documentation: https://docs.example.com/my-env
version
Configuration version for update checking. Extracted from the root config before inheritance resolution.
- Type:
str | None - Default:
None - Validation: Must be valid semver (
X.Y.Zformat, with optional pre-release and build metadata). Requirescommand-namesto be specified -- settingversionwithoutcommand-namesproduces a validation error because the version field controls update checking viamanifest.jsonand launcher scripts, which are only created whencommand-namesis present. - Inheritance: Not inherited. Extracted from the root config before inheritance resolution.
- Example:
version: "1.0.0"orversion: "2.1.0-beta.1"
command-names
Creates global shell commands that launch Claude Code with this environment configuration. The first name is the primary command (used for file naming), and subsequent entries are aliases.
- Type:
list[str] | None - Default:
[] - Validation:
- Cannot be empty or whitespace-only
- Cannot contain spaces
- Must be alphanumeric, hyphens, and underscores only
- Co-dependency: If specified,
command-defaultsmust also be specified (and vice versa) - Inheritance: Standard override (child replaces parent)
- Note: If empty or not specified, hooks are written to
~/.claude/settings.json(global scope) instead of a per-environmentconfig.json. Manifest, launcher, and command registration steps are skipped. The setup still processes other resources (agents, MCP servers, dependencies, and so on) but does not create a launchable command. - Example:
command-names:
- "my-env" # Primary (used for file names)
- "my-env-alias" # Alias
base-url
Base URL for resolving relative resource paths (agents, commands, skills, hooks, and other files).
- Type:
str | None - Default:
None - Validation: Must start with
http://orhttps:// - Inheritance: Standard override (child replaces parent). Each level's
base-urlapplies to that level's own resource paths during inheritance resolution. A childbase-urldoes not retroactively affect parent resource paths -- see Resource Path Resolution in Inheritance. - Example:
base-url: "https://raw.githubusercontent.com/org/repo/main"
inherit
URL, local path, or repository config name to inherit from. Accepts a single string or a list of strings/structured objects for composition chains.
- Type:
str | list[str | {config: str, merge-keys: list[str]}] | None - Default:
None - Single string: Standard recursive inheritance (child overrides parent). Use
merge-keysto selectively merge. - List of strings/objects: Flat composition chain (left-to-right). Each entry's own
inheritandmerge-keysare stripped. Per-entry merge-keys specified via structured{config: ..., merge-keys: [...]}entries in the leaf. See List Inherit (Composition Chains). - Validation: Cannot be empty, no null bytes. Lists must be non-empty with all entries as non-empty strings or valid structured objects.
- Max depth: 10 levels
- Circular dependency detection: Automatic
- Inheritance: Not applicable (structural meta-key consumed during resolution)
- Example:
# Single string (standard recursive inheritance)
inherit: "https://raw.githubusercontent.com/org/repo/main/base.yaml"
# or
inherit: "./base-config.yaml"
# or
inherit: "base-config" # fetched from artifacts-public repo
# List (composition chain)
inherit:
- base.yaml
- extensions.yaml
# List with per-entry merge-keys (structured entries)
inherit:
- base.yaml
- config: extensions.yaml
merge-keys:
- agents
- rules
See Configuration Inheritance for details.
merge-keys
List of top-level keys that should be merged (extended from parent) rather than replaced during inheritance resolution. Only effective when inherit is also specified.
- Type:
list[str] | None - Default:
None - Valid values:
dependencies,agents,slash-commands,rules,skills,files-to-download,hooks,mcp-servers,global-config,user-settings,os-env-variables - Validation: Non-eligible keys produce an error. Non-empty
merge-keyswithoutinheritproduces a validation error becausemerge-keyscontrols merge semantics during inheritance resolution and has no effect without a parent configuration to merge from. An emptymerge-keyslist withoutinheritis permitted (treated as a no-op). - Stripped from output: Yes (like
inherit) - Inheritance: Not applicable. Evaluated at each inheritance level independently; not inherited or accumulated across levels.
- Example:
inherit: base.yaml
merge-keys:
- agents
- mcp-servers
- dependencies
- hooks
See Selective Merge (merge-keys) for details.
Installation Control
claude-code-version
Specific Claude Code version to install.
- Type:
str | None - Default:
None - Special value:
"latest"(case-insensitive) installs the latest available version (same as the default behavior) - Validation: Must be
"latest"or valid semver (X.Y.Zwith optional pre-release and build metadata) - Note: Works with both native (via direct binary download from Google Cloud Storage) and npm installation methods. If the requested version is not found via GCS, the installer falls back to the native installer with the latest version
- Auto-update management: When a specific version is set, auto-update controls are automatically injected into multiple targets to prevent Claude Code from overwriting the pinned version. When
"latest"is used or the key is absent, stale auto-injected controls from prior pinned runs are automatically cleaned up while user-declared controls are preserved. See Automatic Auto-Update Management for details. - IDE extension management: When a specific version is set, IDE extension auto-install is disabled and the matching extension version is installed into detected VS Code family IDEs. See Automatic IDE Extension Version Management for details.
- Inheritance: Standard override (child replaces parent)
- Example:
claude-code-version: "1.0.128"orclaude-code-version: "latest"
install-nodejs
Install Node.js LTS before processing dependencies. Used when MCP servers or tools need Node.js but Claude Code itself was installed natively (without Node.js).
- Type:
bool | None - Default:
None - Note: When
true, only checks the minimum Node.js version (>= 18.0.0), not Claude Code npm compatibility - Inheritance: Standard override (child replaces parent)
- Example:
install-nodejs: true
link-projects-dir
Link the isolated profile's projects/ directory to the base ~/.claude/projects/ so that the isolated profile (for example, aegis) and the default Claude share the same session history. By default (None/false), the two are kept separate -- the isolated profile uses its own ~/.claude/{cmd}/projects/ and sees a different set of conversations than the default Claude.
- Type:
bool | None - Default:
None(no link; isolated and default Claude keep separateprojects/directories) - Requires:
command-names. Settinglink-projects-dir: truewithoutcommand-namesproduces a validation error, because the link only makes sense for an isolated profile. - Mechanism: Creates the base
~/.claude/projects/first if absent, then links~/.claude/{cmd}/projects/to it -- a symbolic link on Linux/macOS, a directory junction on Windows (created elevation-free via_winapi.CreateJunction, withmklink /Jas a fallback). - Idempotent and non-clobbering: An existing correct link is left as-is (no-op). A real, non-empty
projects/directory in the isolated profile is preserved and the link is skipped (with a warning) to avoid losing any session history already written there. A stale or incorrect link, or an empty real directory, is replaced. - Disabling the link: Setting
link-projects-dir: false(or removing the key) on a later run does NOT automatically tear down an existing link. To revert to separate directories, remove the link manually: delete~/.claude/{cmd}/projects/(on Windows, removing the junction withrmdirdeletes only the link, not the shared target's contents). - Inheritance: Standard override (child replaces parent)
- Example:
command-names:
- "aegis"
command-defaults:
system-prompt: "prompts/aegis.md"
mode: "append"
# Share session history with the default Claude
link-projects-dir: true
dependencies
Platform-specific shell commands to execute during setup.
- Type:
dict[str, list[str]] - Default:
{} - Valid platform keys:
common,windows,macos,linux - Behavior:
commonruns on all platforms- Platform-specific keys run only on the matching platform
- Invalid keys raise a
ValueError
- Global npm sudo fallback (Linux/macOS/WSL): When a
npm install -g ...dependency fails and the npm global prefix is not writable by the current user (probed vianpm config get prefixplus a write-access check on{prefix}/lib/node_modules), the setup automatically retries the command with sudo using a three-tier fallback: interactive TTY prompt, then cached credentials (sudo -n true), then a/dev/ttyprompt that works even in pipedcurl | bashruns. An informational note that sudo may be requested prints before the first attempt. The retry runs the parsed command arguments with a 600-second timeout. Dependencies containing shell control characters (;,&,|,<,>,$, backquote, newline) are never escalated -- compound user-authored shell strings do not run as root. When no sudo mechanism is available or the retry fails, the setup prints guidance (the manual sudo command,npm config set prefix ~/.npm-globalplus a PATH export, or reinstalling Node.js with a version manager) and records the dependency as failed. - Failure handling: Dependency execution continues after a failure (remaining dependencies still run), but every failed command is collected and listed in a dedicated "The following dependencies failed to install:" section of the "Setup Completed with Errors" block at the end of the run, and the setup exits with code 1. CI consumers see a nonzero exit code when any dependency fails.
- Inheritance: Standard override (child replaces parent) by default. When listed in
merge-keys: per-platform sub-key list concatenation with deduplication. Parent platform commands appear first; child commands are appended. Duplicates are removed by string equality. - Example:
dependencies:
common:
- "uv tool install ruff"
- "uv tool install ty"
windows:
- "winget install --id Git.Git --scope machine --accept-package-agreements --accept-source-agreements"
macos:
- "brew install shellcheck"
linux:
- "sudo apt-get install -y shellcheck"
Claude Code Resources
agents
Markdown files placed in ~/.claude/agents/ during setup. Values are URLs or relative paths resolved against the configuration source or base-url.
- Type:
list[str] | None - Default:
[] - Inheritance: Standard override (child replaces parent) by default. When listed in
merge-keys: parent and child lists are concatenated with deduplication by string equality. Parent items appear first; new child items are appended. - Example:
agents:
- "agents/code-reviewer.md"
- "https://example.com/agents/security-auditor.md"
slash-commands
Command files placed in ~/.claude/commands/ during setup. Uses the same path resolution as agents.
- Type:
list[str] | None - Default:
[] - Inheritance: Standard override (child replaces parent) by default. When listed in
merge-keys: parent and child lists are concatenated with deduplication by string equality. Parent items appear first; new child items are appended. - Example:
slash-commands:
- "commands/review.md"
- "commands/deploy.md"
rules
Rule files placed in ~/.claude/rules/ during setup. Claude Code loads .md files from this directory recursively as user-scope rules that apply across all projects.
- Type:
list[str] | None - Default:
[] - Scope: User-scope only (
~/.claude/rules/). Project-scope rules (.claude/rules/in the repository) should be committed directly to version control. - Note: Only
.mdfiles are recognized by Claude Code. Rules support optional YAML frontmatter withdescription:andpaths:for path-scoped rules (glob patterns). - Inheritance: Standard override (child replaces parent) by default. When listed in
merge-keys: parent and child lists are concatenated with deduplication by string equality. Parent items appear first; new child items are appended. - Example:
rules:
- "rules/coding-standards.md"
- "rules/security-policy.md"
skills
Skill configurations. Each skill is a set of files placed in ~/.claude/skills/{name}/.
- Type:
list[Skill] | None - Default:
[] - Inheritance: Standard override (child replaces parent) by default. When listed in
merge-keys: identity-based merge bynamefield. Child skills with the same name replace the parent skill in-position (at the parent's original index). New child skills are appended at the end. Duplicate names within one list are collapsed to the last entry with a warning. - Skill fields:
name(str, required): Skill identifierbase(str, required): Base URL or local path for skill filesfiles(list[str], required): List of files to download. Must includeSKILL.md.
- Example:
skills:
- name: "code-review"
base: "skills/"
files:
- "SKILL.md"
- "review-checklist.md"
files-to-download
Arbitrary files to download during setup. Each entry specifies a source and a destination path.
- Type:
list[FileToDownload] | None - Default:
[] - Fields:
source(str, required): URL or path to the source filedest(str, required): Destination path (supports~expansion)
- Validation: Paths cannot be empty or contain null bytes
- Security: Destinations matching sensitive path prefixes (for example,
~/.ssh/,~/.bashrc) are flagged with[!]in the installation summary - Inheritance: Standard override (child replaces parent) by default. When listed in
merge-keys: identity-based merge by the normalized final file path. Adestending with/or\is a directory destination, so its identity isdestplus the source filename (query parameters stripped; for GitLab API raw-file URLs the real filename is decoded from the URL-encoded path segment) -- the trailing-separator form is the only directory form the merge identity recognizes, and the normalization is purely lexical (no filesystem checks). The filename derivation is stable across source resolution, so a parent whose sources were already resolved to absolute URLs matches a child entry written with a relative source. Distinct files sharing a directory dest therefore keep distinct identities and all survive the merge. Child entries whose final file path matches a parent entry replace it in-position; new child entries are appended at the end. Duplicate identities within one list are collapsed to the last entry with a warning. - Download deduplication: After merging, entries that still resolve to the same final file (for example, a directory-form dest and an explicit file dest naming the same path) are deduplicated before the parallel download phase: the last entry wins and each skipped entry is reported with a warning. Files are written atomically (temp file plus rename), so an interrupted or concurrent write never leaves a partially-written destination.
- Example:
files-to-download:
- source: "configs/api-key-helper.py"
dest: "~/.claude/scripts/api-key-helper.py"
MCP Servers
MCP (Model Context Protocol) servers extend Claude Code with additional capabilities. The setup supports three transport types.
- Type:
list[dict] | None - Default:
[] - Note: Each server must have a
namefield - Inheritance: Standard override (child replaces parent) by default. When listed in
merge-keys: identity-based merge bynamefield. Child servers with the same name replace the parent server in-position (at the parent's original index). New child servers are appended at the end. Duplicate names within one list are collapsed to the last entry with a warning.
HTTP Transport
- Required fields:
name,transport: "http",url - Optional fields:
scope,header,env
mcp-servers:
- name: "my-api"
transport: "http"
url: "http://localhost:3000/api"
header: "Authorization: Bearer ${MY_TOKEN}"
Environment-variable header values (${VAR}): A ${VAR} (or ${VAR:-default}) reference inside header is preserved literally in the Claude Code configuration and expanded from the environment by Claude Code at runtime, when a session starts -- the secret itself is never written into any configuration file, only the placeholder is stored. The setup script preserves the placeholder verbatim when it registers the server (it does not expand it at install time), so the behavior is identical on every operating system and shell. This is the recommended way to configure an authenticated remote MCP server: each user sets the variable (for example MY_TOKEN) as a real environment variable on their own machine, and only this placeholder configuration is shared. Do not add env: "MY_TOKEN" for this purpose -- env configures variables passed to a server and is unrelated to where the header reads its value.
The variable must be set when Claude Code launches. If a
${VAR}reference has no value and no default, Claude Code fails to parse the MCP configuration. Ensure the variable is exported before launching Claude Code, or provide a fallback with${VAR:-default}.
SSE Transport
Uses the same fields as HTTP transport with transport: "sse".
mcp-servers:
- name: "my-events"
transport: "sse"
url: "http://localhost:3001/events"
header: "X-API-Key: my-secret"
Stdio Transport
- Required fields:
name,command - Optional fields:
scope,env,args - Note: On Windows, commands starting with
npxget automaticcmd /cwrapping
The args field provides an optional argument list for the command. When args is specified, the command field is treated as just the executable and args provides the arguments separately. This maps directly to the args array in the generated MCP configuration JSON, matching the Claude Code MCP server configuration format. When args is absent, the command string is parsed into command and arguments automatically.
mcp-servers:
# Without args (command string is parsed automatically)
- name: "memory-server"
command: "npx @modelcontextprotocol/server-memory"
env:
- "DEBUG=1"
# With explicit args (command + args kept separate)
- name: "python-server"
command: "python"
args: ["-m", "my_mcp_server"]
Scope Options
Controls where the MCP server configuration is written.
- Valid values:
user,local,project,profile - Default:
user - Combined scopes: Use a list format. Combined scopes must include
profilefor meaningful combination.
mcp-servers:
- name: "dual-scope-server"
scope:
- "user"
- "profile"
transport: "http"
url: "http://localhost:3000/api"
Isolated environments: When
command-namescreates an isolated environment,scope: userMCP servers are configured withCLAUDE_CONFIG_DIRpointing to the isolated directory. This ensuresclaude mcp add --scope userwrites to the isolated.claude.jsoninstead of the home-directory one. This per-call injection is one of the twoCLAUDE_CONFIG_DIRchannels described in Setup-TimeCLAUDE_CONFIG_DIRExport.
The env Field
Defines environment variables for the MCP server.
- String format: Single environment variable name
- List format: Multiple
KEY=VALUEpairs
# Single variable
env: "API_TOKEN"
# Multiple variables
env:
- "DEBUG=1"
- "LOG_LEVEL=debug"
The header Field
Sets an HTTP header for both http and sse transports.
- Format:
"Header-Name: value" - Example:
header: "Authorization: Bearer token123"
MCP Server Permissions
MCP servers are registered with Claude Code via claude mcp add (with scope-based routing). To pre-allow specific MCP tools without a per-use confirmation prompt, add mcp__servername (or mcp__servername__toolname) entries to permissions.allow under user-settings.
Environment Variables
os-env-variables
OS-level persistent environment variables written to the shell profile (Linux/macOS) or Windows registry.
- Type:
dict[str, str | None] | None - Default:
None - Special value: Set a value to
nullto delete an existing variable - Validation: Variable names must match
^[A-Za-z_][A-Za-z0-9_]*$ - Inheritance: Standard override (child replaces parent) by default. When listed in
merge-keys: shallow dictionary merge. Child keys override matching parent keys. Set a value tonullto delete a parent key (RFC 7396 semantics). - Example:
os-env-variables:
MY_TOOL_PATH: "/opt/my-tool/bin"
OLD_UNUSED_VAR: null # Deletes this variable
- Automatic string conversion: Non-string YAML values (integers, booleans, floats) in
os-env-variablesare automatically converted to strings by the setup script. For example,MY_TIMEOUT: 30000(YAML integer) becomes"30000"(string), andENABLE_FEATURE: true(YAML boolean) becomes"True"(string). To preserve exact string representation, quote values in YAML:ENABLE_FEATURE: "true". Anullvalue is never stringified -- it is a deletion request (see thenullspecial value above). - Current session guidance (Linux/macOS): When variables are deleted via
null, the setup script outputs shell-specificunsetcommands so the user can remove those variables from the running session without opening a new terminal:- Bash/Zsh:
unset VARNAMEfor each deleted variable - Fish (when installed):
set -e VARNAMEfor each deleted variable
- Bash/Zsh:
Environment Variable Loading
The setup script supports two distinct kinds of environment variables, each serving a different scope:
| Source | Scope | Storage | Available In |
|---|---|---|---|
user-settings.env | Claude Code internal | settings.json env key (or profile config.json) | Claude Code sessions only |
os-env-variables | OS-level persistent | Shell profiles + Windows registry | All processes (terminals, programs) |
Claude-session variables are declared under user-settings.env (raw settings.json content). See the user-settings section for the env value rules (string-only values, null as delete).
Env Loader Files
When os-env-variables are configured, the setup generates Rustup-style env loader files that can be sourced to load the variables into the current shell session. These files contain only os-env-variables (not user-settings.env, which Claude Code reads from settings.json/config.json).
Per-command files (generated when command-names is specified):
| File | Shell | Generated When |
|---|---|---|
~/.claude/{cmd}/env.sh | Bash/Zsh | Always |
~/.claude/{cmd}/env.fish | Fish | Fish installed |
~/.claude/{cmd}/env.ps1 | PowerShell | Windows only |
~/.claude/{cmd}/env.cmd | CMD batch | Windows only |
Loader files are toolbox-owned and rebuilt on every run: variables set to null (deletions) are excluded from the exports, and when no active variable remains the files are rewritten header-only so stale exports from a prior run stop being re-applied by the launcher at session start.
Automatic Loading via Launchers
When command-names is specified, the generated launcher scripts automatically source the per-command env loader file before starting Claude Code. No manual action is required -- running the command (for example, claude-python) loads all OS environment variables.
The source line is guarded by a file-existence check, so launchers work normally even when no os-env-variables are configured.
Applying OS Environment Variables
When os-env-variables are configured, the setup writes them to shell profile files (.bashrc, .zshrc, .profile, config.fish on Unix; Windows Registry on Windows). Open a new terminal to load the updated variables automatically.
Fish Dual-Mechanism
On systems with Fish shell installed, the setup uses two complementary mechanisms for OS environment variables:
set -gxinconfig.fish: Durable persistence. Variables are loaded when Fish starts. This is the primary mechanism.set -Ux(Universal Exported): Instant propagation. Variables are immediately visible in all running Fish sessions without requiringsourceor a new terminal. For deletions,set -Ueremoves the universal variable.
The config.fish write is always the authoritative source. The set -Ux call is a complementary enhancement that provides immediate availability.
User Interface
command-defaults
System prompt configuration for the environment command.
- Type:
CommandDefaults | None - Default:
None - Fields:
system-prompt(str) -- Path to the system prompt file (downloaded to~/.claude/prompts/)mode(str, default:"replace") -- How the prompt is applied:replace-- Completely replaces the default system prompt (--system-promptflag, added in Claude Code v2.0.14)append-- Appends to Claude's default development prompt (--append-system-promptflag, added in Claude Code v1.0.55)
- Co-dependency: If specified,
command-namesmust also be specified (and vice versa) - Inheritance: Standard override (child replaces parent)
- Example:
base-url: "https://raw.githubusercontent.com/my-org/my-configs/main"
command-defaults:
system-prompt: "prompts/my-prompt.md"
mode: "append"
user-settings
Raw settings.json content, using Claude Code's native camelCase key names exactly as they appear on disk. This is the single surface for every Claude Code settings.json setting -- model, permissions, env, effortLevel, and everything else. In non-isolated mode (command-names absent) it is deep-merged into ~/.claude/settings.json; in isolated mode (command-names present) it is built into the isolated profile's config.json and delivered via --settings. See Profile-Level Settings Routing for the end-to-end write contract.
The on-disk write uses deep merge with universal array union: every list at every depth is unioned with structural dedupe, matching Claude Code CLI's cross-scope merge semantics ("arrays are concatenated and deduplicated, not replaced").
- Type:
UserSettings | None - Default:
None - Excluded keys:
hooksandstatusLine(these require dedicated write logic with file download, path resolution, and type processing, and must be configured at the YAML root level via thehooksandstatus-linekeys) - Inheritance: Standard override (child replaces parent) by default. When listed in
merge-keys: deep recursive merge usingdeep_merge_settings()withDEFAULT_ARRAY_UNION_KEYS(permissions.allow,permissions.deny,permissions.askarrays are unioned with deduplication; other arrays use child-replaces-parent semantics in the YAML inheritance layer). Child keys override matching parent keys;nullvalues delete keys. Note: YAML inheritance semantics are intentionally separate from on-disk write semantics. The on-disk writer (write_user_settings()->_write_merged_json()) uses universal array union at every depth for all keys;DEFAULT_ARRAY_UNION_KEYSapplies only inside the YAML composition layer. - Example:
user-settings:
model: "opus"
effortLevel: "high"
alwaysThinkingEnabled: true
language: "english"
theme: "dark"
apiKeyHelper: "uv run --no-project --python 3.12 ~/.claude/scripts/api-key-helper.py"
permissions:
defaultMode: "default"
allow:
- "Read"
- "Glob"
- "Grep"
deny:
- "Bash(rm -rf)"
additionalDirectories:
- "/opt/project-data"
env:
PROJECT_TYPE: "python"
DISABLE_AUTOUPDATER: "1"
OLD_UNUSED_VAR: null # Deletes this variable
Built-in Key Reference (camelCase)
user-settings accepts any settings.json key. The keys below are the common ones and use Claude Code's native camelCase spelling. Because a misplaced or misspelled built-in key is silently ignored by Claude Code at runtime, the toolbox validates these keys fail-fast (setup exits with an error). Unknown keys pass through untouched for forward compatibility.
model-- Model alias or custom model name. Any non-empty string: Anthropic model names (claude-sonnet-4-20250514), built-in aliases (default,sonnet,opus,haiku,opus[1m],sonnet[1m],opusplan,best), or third-party / provider-prefixed identifiers (gpt-4o,openrouter/anthropic/claude-3.5-sonnet). Empty or whitespace-only strings are rejected.permissions-- Permission rules controlling which tools and actions are allowed, denied, or require confirmation. Sub-keys use camelCase:defaultMode,allow,deny,ask,additionalDirectories.defaultModemust be one ofacceptEdits,auto,bypassPermissions,default,delegate,dontAsk,plan;allow,deny,ask, andadditionalDirectoriesmust be lists of strings. The kebab-case spellingsdefault-modeandadditional-directoriesare rejected with the camelCase correction. To pre-allow an MCP server's tools, addmcp__servernameentries topermissions.allow(see MCP Server Permissions).env-- Claude-level environment variables available within Claude Code sessions. A mapping of variable names (matching^[A-Za-z_][A-Za-z0-9_]*$) to string values. A non-string value is rejected (user-settings.env.NAME must be a string (quote the value in YAML) or null to delete the variable.) -- quote the value in YAML to keep it a string. Anullvalue deletes the variable (see Key Deletion).attribution-- Commit and pull-request attribution. A mapping withcommitandprstring sub-keys; set a sub-key to an empty string to hide that attribution.alwaysThinkingEnabled-- Boolean enabling always-on extended thinking mode.companyAnnouncements-- List of announcement strings displayed to users.effortLevel-- Adaptive reasoning effort, one ofhigh,low,max,medium,xhigh. Thexhighlevel requiresmodelto be an Opus or Fable variant (the model name must containopusorfable, case-insensitive) or the exact aliasbest;maxadditionally accepts a Sonnet variant. Whenmodelis absent or outside the required families, the effort level is rejected. See effortLevel model requirements below.
effortLevel model requirements
The model gate matches family substrings because the free-form model value cannot resolve which version an alias points to; Claude Code gracefully downgrades an unsupported level to the highest supported level at runtime, but declaring an unsupported combination in the profile is almost always a mistake, so it is rejected. The alias best is accepted by exact match only (it always resolves to Fable 5 or the latest Opus model), so arbitrary model names that merely contain best are rejected.
# xhigh requires an Opus or Fable model
user-settings:
model: "claude-fable-5" # or "opus", "fable", "best"
effortLevel: "xhigh"
# max requires an Opus, Sonnet, or Fable model
user-settings:
model: "opus" # or "sonnet", "fable", "best"
effortLevel: "max"
# low, medium, and high work with any model
user-settings:
effortLevel: "high"
maxpersistence caveat: Per the official Claude Code documentation, persistedsettings.jsonfiles accept onlylow,medium,high, andxhighforeffortLevel;max(likeultracode) is session-only and, in a plainsettings.json, persists across sessions only via theCLAUDE_CODE_EFFORT_LEVELenvironment variable. The toolbox still acceptsmaxbecause, for isolated profiles, it deliversconfig.jsonthrough the--settingsflag on every launch, where the value applies per-session. The setup emits a warning whenuser-settings.effortLevelismax.ultracodeis intentionally not an acceptedeffortLevelvalue.
Fail-Fast Validation Rules
The toolbox validates user-settings against Claude Code's settings.json schema and rejects (exit 1) misconfigurations that Claude Code would otherwise ignore silently. A null value for any key is always allowed (a deletion request). Unknown keys not covered below pass through untouched. The rules are:
hooksandstatusLineare forbidden. They must be configured at the YAML root level viahooksandstatus-line. Blocked bycheck_excluded_keysin theUserSettingsPydantic model (USER_SETTINGS_EXCLUDED_KEYS = {'hooks', 'statusLine'}).- Root-level YAML keys are forbidden.
status-lineandos-env-variablesare YAML root keys, notsettings.jsonkeys, and are rejected with the messageKey '{key}' is not allowed in user-settings. It is a root-level YAML key, not a settings.json key. - Kebab-case spellings of built-in keys are rejected with the camelCase correction:
always-thinking-enabled->alwaysThinkingEnabled,company-announcements->companyAnnouncements,effort-level->effortLevel,env-variables->env; and insidepermissions,default-mode->defaultModeandadditional-directories->additionalDirectories. - Global-only keys are rejected. Keys that live in
~/.claude.json(autoUpdates,installMethod,autoConnectIde,autoInstallIdeExtension,externalEditorContext,teammateDefaultModel,oauthAccount) belong inglobal-config, notuser-settings, and are rejected with the messageKey '{key}' belongs in global-config (~/.claude.json), not in user-settings (settings.json).
CLAUDE_CONFIG_DIR override (isolated mode)
To override the auto-computed isolation directory, set CLAUDE_CONFIG_DIR under user-settings.env (only meaningful when command-names is present). The setup reads and then removes it before writing config.json -- the launcher's export CLAUDE_CONFIG_DIR remains the sole authoritative runtime source, so the value is not left in the profile's env block.
command-names:
- "my-env"
user-settings:
env:
CLAUDE_CONFIG_DIR: "~/.claude/my-custom-dir"
Preservation contract for user-settings
Keys that you put under user-settings: are preserved even when you re-run the setup with a different YAML that omits them, because in non-isolated mode write_user_settings() uses deep-merge semantics and never deletes keys unless you set them to null. List-valued keys (at any depth) accumulate additively across runs under the universal array-union contract, so elements you wrote in earlier runs are never silently deleted. This is the deliberate shared-file semantics: the toolbox does NOT surprise-delete anything from the shared ~/.claude/settings.json. In isolated mode the profile's config.json is rebuilt atomically each run, so removing a key from YAML cleanly removes it from config.json on the next run. See Profile-Level Settings Routing below for the full write semantics contract and the deferred stale-key behavior.
global-config
Raw ~/.claude.json content, merged into the Claude Code global configuration file. When command-names is present, additionally written to ~/.claude/{cmd}/.claude.json for isolated environments (Claude Code CLI resolves getGlobalClaudeFile() via CLAUDE_CONFIG_DIR with no fallback to the home directory). Uses deep merge with universal array union: every list at every depth is unioned with structural dedupe, matching Claude Code CLI's cross-scope merge semantics and preserving CLI-managed state at runtime (OAuth tokens, per-project trust decisions, user-scoped MCP server approvals via /mcp approve, enabledPlugins, enabledMcpjsonServers/disabledMcpjsonServers).
When command-names is present, the setup also propagates the machine's recorded installMethod from the base ~/.claude.json into the global-config write (auto-injected, even when the YAML has no global-config section; a user-declared installMethod in YAML wins with a warning when it differs; nothing is propagated when the base file or key is absent). The dual-write then carries the correct installation type into the isolated .claude.json, so isolated sessions report it correctly.
- Type:
GlobalConfig | None - Default:
None - Excluded keys:
oauthAccountcannot be set to non-null values (OAuth credentials must not appear in YAML configuration files). SetoauthAccount: nullto clear authentication state. - Settings-only keys rejected: Keys that live in
settings.json(model,permissions,env,attribution,alwaysThinkingEnabled,effortLevel,companyAnnouncements,statusLine,hooks,availableModels,enforceAvailableModels) are rejected inglobal-configbecause~/.claude.jsonis not a settings file and Claude Code would silently ignore them at runtime.modeland the othersettings.jsonkeys are rejected with the messageKey '{key}' is a settings.json key and is not valid in global-config (~/.claude.json). Move it to user-settings.;statusLineandhooksare instead directed to the root-levelstatus-lineandhooksYAML keys. Anullvalue is always allowed (a deletion request). - Inheritance: Standard override (child replaces parent) by default. When listed in
merge-keys: deep recursive merge usingdeep_merge_settings()witharray_union_keys=set()(arrays are replaced in the YAML inheritance layer for child-overrides-parent composition). Child keys override matching parent keys;nullvalues delete keys (RFC 7396). Note: YAML inheritance semantics are intentionally separate from on-disk write semantics. The on-disk writer (write_global_config()->_write_merged_json()) uses universal array union at every depth; theset()form applies only inside the YAML composition layer. - Example:
global-config:
autoConnectIde: true
editorMode: "vim"
showTurnDuration: true
Key Deletion (Null-as-Delete)
Both user-settings and global-config support key deletion via RFC 7396 JSON Merge Patch semantics. Set a key to null to remove it from the target JSON file.
user-settings:
theme: "dark"
staleKey: null # Removes staleKey from settings.json
global-config:
autoConnectIde: true
oldSetting: null # Removes oldSetting from ~/.claude.json
oauthAccount: null # Clears OAuth authentication state
Behavior:
- Setting a key to
nullremoves it from the target file - Setting a nonexistent key to
nullis a silent no-op - Nested deletion:
section: {key: null}removes onlykey, preservingsection - Top-level deletion:
section: nullremoves the entire section - Null inside arrays is NOT treated as deletion
- The
--dry-runsummary shows[DELETE]markers for null-valued keys
Warning: Bare YAML keys with no value (
key:) are equivalent tokey: null. This means accidentally omitting a value will DELETE that key rather than set it to an empty string. Always use explicit values:key: ""for empty strings,key: nullfor intentional deletion.
Profile-owned keys (status-line, hooks) in non-command-names mode: The two profile-owned keys support null-as-delete at the YAML root level via the deep-merge writer -- see Profile-Level Settings Routing. Both top-level and nested nulls are covered end-to-end:
- Top-level null (for example,
status-line: null,hooks: null): deletes the entire on-disk key from~/.claude/settings.json. - Nested null (for example,
hooks: {PreToolUse: null}): deletes only the nested sub-key while preserving the rest of the block (other hook event names).
The dict-membership construction in the data flow from YAML root to the writer preserves the distinction between "declared with explicit null" and "absent from YAML" -- only the former triggers deletion. OMITTING a profile-owned key from a subsequent YAML run does NOT delete it; see Deferred Stale-Key Behavior for the intentional preservation contract.
Per-variable nulls in user-settings.env and os-env-variables: a null value deletes the variable instead of setting a literal string. In non-isolated mode, user-settings: {env: {VAR: null}} deletes env.VAR from ~/.claude/settings.json via the deep-merge writer (this also cleans up any stale literal value a prior run may have written). In isolated mode, create_profile_config() strips null-valued members recursively before the atomic config.json write -- absence equals deletion under atomic rebuild -- so a JSON null is never written for an env entry. os-env-variables: {VAR: null} deletes the OS-level variable from shell profiles or the Windows registry and excludes it from the env loader files.
status-line
Status line script configuration. The script file and optional config file are downloaded to ~/.claude/hooks/.
- Type:
StatusLine | None - Default:
None - Inheritance: Standard override (child replaces parent)
- Fields:
file(str, required) -- Script file pathpadding(int, optional) -- Padding valueconfig(str, optional) -- Config file (appended as command argument)
- Note: Both
fileandconfig(if specified) must exist inhooks.files. Ifstatus-lineis configured, thehookskey must also be present. - Example:
status-line:
file: "hooks/statusline.py"
config: "configs/statusline-config.yaml"
padding: 0
Hooks
Event-driven hooks that run automatically during Claude Code sessions. Four hook types are supported: command, http, prompt, and agent.
- Type:
Hooks | None - Default:
None - Inheritance: Standard override (child replaces parent) by default. When listed in
merge-keys: composite merge.fileslists are concatenated with deduplication by full file path string equality.eventslists are concatenated without deduplication (each event is unique by its field combination). - Fields:
files(list[str]) -- Script files to download to~/.claude/hooks/. Only used by command hooks.events(list[HookEvent]) -- Event configurations
Hook Types
| Type | Description | Required Field |
|---|---|---|
command | Executes a shell command or script (default) | command |
http | Sends an HTTP POST request to a URL | url |
prompt | Single-turn LLM evaluation with no tool access | prompt |
agent | Spawns a subagent with tool access for evaluation | prompt |
Common Fields (All Hook Types)
These fields apply to all four hook types:
| Field | YAML Key | Type | Required | Description |
|---|---|---|---|---|
event | event | str | Yes | Event name (for example, PreToolUse, PostToolUse, Notification) |
matcher | matcher | str | No | Regex pattern for matching (default: "") |
type | type | str | No | Hook type: command, http, prompt, or agent (default: command) |
if | if | str | No | Permission rule syntax filter (for example, "Bash(git *)", "Edit(*.ts)") |
status-message | status-message | str | No | Custom spinner message displayed while the hook runs |
once | once | bool | No | If true, runs only once per session then is removed (skills only) |
timeout | timeout | int | No | Timeout in seconds (defaults vary by type: 600 for command, 30 for prompt, 60 for agent) |
Type-Specific Fields
Command Hook Fields
| Field | YAML Key | Type | Required | Description |
|---|---|---|---|---|
command | command | str | Yes | Script filename (must exist in hooks.files) |
config | config | str | No | Config file reference (must exist in hooks.files). Toolbox-specific: appended as argument |
async | async | bool | No | If true, runs the command in the background without blocking |
shell | shell | str | No | Shell to use: "bash" (default) or "powershell" |
HTTP Hook Fields
| Field | YAML Key | Type | Required | Description |
|---|---|---|---|---|
url | url | str | Yes | URL to send the HTTP POST request to |
headers | headers | dict[str,str] | No | Additional HTTP headers. Values support $VAR_NAME env var interpolation |
allowed-env-vars | allowed-env-vars | list[str] | No | Environment variable names permitted for interpolation into header values |
Prompt and Agent Hook Fields
| Field | YAML Key | Type | Required | Description |
|---|---|---|---|---|
prompt | prompt | str | Yes | Prompt text for LLM evaluation |
model | model | str | No | Model to use for the evaluation |
Field Matrix
Complete required/forbidden field matrix across all hook types:
| Field | command | http | prompt | agent |
|---|---|---|---|---|
command | REQUIRED | FORBIDDEN | FORBIDDEN | FORBIDDEN |
config | Optional | FORBIDDEN | FORBIDDEN | FORBIDDEN |
async | Optional | FORBIDDEN | FORBIDDEN | FORBIDDEN |
shell | Optional | FORBIDDEN | FORBIDDEN | FORBIDDEN |
url | FORBIDDEN | REQUIRED | FORBIDDEN | FORBIDDEN |
headers | FORBIDDEN | Optional | FORBIDDEN | FORBIDDEN |
allowed-env-vars | FORBIDDEN | Optional | FORBIDDEN | FORBIDDEN |
prompt | FORBIDDEN | FORBIDDEN | REQUIRED | REQUIRED |
model | FORBIDDEN | FORBIDDEN | Optional | Optional |
if | Optional | Optional | Optional | Optional |
status-message | Optional | Optional | Optional | Optional |
once | Optional | Optional | Optional | Optional |
timeout | Optional | Optional | Optional | Optional |
Setting a field marked FORBIDDEN on a hook type produces a validation error.
Command Hooks
Execute a script file when the event fires. The command field must reference a filename listed in hooks.files. The toolbox processes command paths by prepending the appropriate runtime (uv run for .py, node for .js/.mjs/.cjs).
The config field is a toolbox-specific extension: when set, the config file path is appended as an argument to the command. This field is not part of the official Claude Code hooks specification.
hooks:
files:
- "hooks/linter.py"
- "configs/linter-config.yaml"
events:
- event: "PostToolUse"
matcher: "Edit|MultiEdit|Write"
type: "command"
command: "linter.py"
config: "linter-config.yaml"
- event: "Notification"
type: "command"
command: "linter.py"
async: true
shell: "bash"
status-message: "Running notification handler..."
HTTP Hooks
Send an HTTP POST request to the specified URL when the event fires. No file processing is involved -- all fields are passed through to the profile configuration as-is.
hooks:
events:
- event: "PostToolUse"
matcher: "Write"
type: "http"
url: "http://localhost:8080/hooks/post-tool-use"
headers:
Authorization: "Bearer $MY_TOKEN"
Content-Type: "application/json"
allowed-env-vars:
- "MY_TOKEN"
timeout: 15
status-message: "Sending webhook notification..."
Prompt Hooks
Send a prompt to the LLM for single-turn evaluation when the event fires. No tool access is available.
hooks:
events:
- event: "PreToolUse"
matcher: "Bash"
type: "prompt"
prompt: "Check if this bash command is safe to execute"
model: "sonnet"
timeout: 30
Agent Hooks
Spawn a subagent with tool access for evaluation when the event fires. The subagent can use tools to perform its evaluation, unlike prompt hooks.
hooks:
events:
- event: "PreToolUse"
matcher: "Bash(rm *)"
type: "agent"
prompt: "Verify security implications of: $ARGUMENTS"
model: "sonnet"
timeout: 60
if: "Bash(rm *)"
once: true
File Consistency Rules
The setup validates hook file references for command hooks only. HTTP, prompt, and agent hooks do not use file references and are excluded from file consistency validation.
- Every file listed in
hooks.filesmust be used by at least one command hook event or thestatus-lineconfiguration - Every
commandin command hook events must exist inhooks.files - Every
configin command hook events must exist inhooks.files - If
status-lineis configured, itsfileandconfigmust exist inhooks.files - If
status-lineis configured buthooksis not defined, that is an error
Supported Script Types
For command hooks:
- Python:
.py - JavaScript:
.js,.mjs,.cjs
Pass-Through Architecture
The setup script processes hooks differently based on type:
| Hook Type | File Processing | Pass-Through Fields |
|---|---|---|
command | Yes (Python via uv run, JavaScript via node, other as-is) | async, shell + common fields |
http | No (pure pass-through) | url, headers, allowed-env-vars + common fields |
prompt | No (pure pass-through) | prompt, model + common fields |
agent | No (pure pass-through) | prompt, model + common fields |
Complete Hooks Example
hooks:
files:
- "hooks/linter.py"
- "hooks/security-check.js"
- "configs/linter-config.yaml"
events:
# Command hook with config file
- event: "PostToolUse"
matcher: "Edit|MultiEdit|Write"
type: "command"
command: "linter.py"
config: "linter-config.yaml"
# Command hook with async and shell
- event: "Notification"
type: "command"
command: "security-check.js"
async: true
shell: "bash"
status-message: "Running security check..."
# HTTP webhook
- event: "PostToolUse"
matcher: "Write"
type: "http"
url: "http://localhost:8080/hooks/write"
headers:
Authorization: "Bearer $API_TOKEN"
allowed-env-vars:
- "API_TOKEN"
timeout: 15
# Prompt hook for safety check
- event: "PreToolUse"
matcher: "Bash"
type: "prompt"
prompt: "Check if this bash command is safe to execute"
timeout: 30
# Agent hook for security review
- event: "PreToolUse"
matcher: "Bash(rm *)"
type: "agent"
prompt: "Verify security implications of: $ARGUMENTS"
model: "sonnet"
timeout: 60
if: "Bash(rm *)"
once: true
Hooks Routing
Hooks are routed to different target files based on whether command-names is specified. Both paths share the same pure builder _build_hooks_json() for the hooks key universe:
| Scenario | Target File | Write Mechanism | Hook Files Directory |
|---|---|---|---|
command-names present | ~/.claude/{cmd}/config.json | create_profile_config() (atomic overwrite) | ~/.claude/{cmd}/hooks/ |
command-names absent | ~/.claude/settings.json | write_profile_settings_to_settings() (deep-merge) | ~/.claude/hooks/ |
When command-names is absent, the setup writes hooks to the global ~/.claude/settings.json via write_profile_settings_to_settings() as part of the 2-key PROFILE_OWNED_KEYS delta. The writer delegates to _write_merged_json(), which deep-merges the hooks dict into the existing file: disjoint event names (in the delta but not on disk, and vice versa) compose additively, and the per-event matcher-group lists are unioned with structural dedupe across runs (every list at every depth is unioned under the universal array-union contract). All other keys -- user-managed keys, other profile-owned keys not in the current delta, and Step 14 user-settings contributions -- are preserved. See Profile-Level Settings Routing for the full contract.
Re-run behavior: When the YAML re-declares hooks, the deep-merge writer recurses into the existing hooks dict. Disjoint event names compose additively across runs. For the same event name, matcher groups accumulate: two matcher groups with the same matcher string but different inner handlers from different runs coexist as separate entries (naive structural dedupe -- they are not structurally equal, so neither is discarded). Structurally identical matcher groups collapse to one, making repeat runs with the same YAML idempotent. This matches Claude Code's native cross-scope merge semantics; at runtime, Claude Code deduplicates command hooks by command string and HTTP hooks by URL (per the Claude Code hooks documentation: "Command hooks are deduplicated by command string, and HTTP hooks are deduplicated by URL"), so on-disk consolidation is unnecessary. To fully clear stale events for a specific event name, set hooks: {EventName: null} to delete just that event list; declaring a new list under the same event name unions with existing entries rather than replacing them.
Deleting hooks: Setting hooks: null at YAML root level deletes the entire hooks key from ~/.claude/settings.json via RFC 7396 null-as-delete. Setting hooks: {EventName: null} deletes just that event list while preserving the other events under hooks. OMITTING hooks entirely from a subsequent YAML run does NOT delete it (per Deferred Stale-Key Behavior); the prior-run hooks content is preserved.
The installation summary distinguishes between the two routing targets:
- With
command-names:Hooks: N configured (in config.json) - Without
command-names:Hooks: N configured (in settings.json)
Advanced Topics
Configuration Inheritance
The inherit key allows a configuration to extend a parent configuration. It accepts a single string for standard recursive inheritance or a list of strings/structured objects for explicit composition chains (see List Inherit (Composition Chains)).
How Inheritance Works
- Child values completely replace parent values for the same top-level key by default
- Use
merge-keysto selectively merge (extend) specific keys instead of replacing them -- see Selective Merge (merge-keys) - Maximum inheritance depth is 10 levels
- Circular dependencies are detected automatically
- The
versionkey is extracted from the root config before inheritance resolution - Both
inheritandmerge-keysare stripped from the final merged configuration
Resource Path Resolution in Inheritance
When configurations are inherited across different sources (e.g., a GitHub-hosted parent and a local child), relative file paths in each config are resolved using that config's own source location and base-url. This ensures that files referenced by a parent config are found at the correct location regardless of where the child config is stored.
How it works:
- Each parent config's relative resource paths (agents, rules, slash-commands, hooks files, files-to-download sources, skill bases, system prompts, and status-line files) are resolved to absolute URLs or paths before merging with child values
- The resolution uses the parent config's own
config_source(where it was loaded from) andbase-url - Child (leaf) config paths continue to be resolved at validation time using the leaf's own source
Example: A GitHub-hosted parent with a local child:
# Parent (hosted at https://raw.githubusercontent.com/org/repo/main/parent.yaml)
agents:
- "agents/shared-agent.md" # Resolved to https://raw.githubusercontent.com/org/repo/main/agents/shared-agent.md
rules:
- "rules/coding-standards.md" # Resolved to https://raw.githubusercontent.com/org/repo/main/rules/coding-standards.md
# Child (local file: ~/my-project/config.yaml)
inherit: "https://raw.githubusercontent.com/org/repo/main/parent.yaml"
agents:
- "agents/local-agent.md" # Resolved locally from ~/my-project/ at validation time
After inheritance resolution, the merged config contains both the GitHub-resolved parent paths and the local child paths. Each is resolved from the correct source.
Key points:
- A child
base-urldoes not affect parent resource paths. Each config level'sbase-urlgoverns only its own resources. - Skills
basepaths ignorebase-urlby design -- they are resolved directly from the config source. - Already-absolute paths and full URLs pass through resolution unchanged.
Inheritance Path Resolution
The inherit value uses the same routing as config sources:
- URL: Starts with
http://orhttps://-- fetched directly - Local path: Contains path separators or starts with
.-- loaded from disk - Repository name: Everything else -- fetched from the artifacts-public repository
Example
# base.yaml
name: "Base Environment"
user-settings:
model: "sonnet"
agents:
- "agents/core-agent.md"
# child.yaml
inherit: "base.yaml"
name: "Extended Environment" # Overrides parent's name
agents: # Completely REPLACES parent's agents list
- "agents/core-agent.md"
- "agents/extra-agent.md"
user-settings: # Completely REPLACES parent's user-settings (not in merge-keys)
effortLevel: "high"
# The parent's user-settings.model is NOT inherited here, because user-settings
# uses replace semantics by default. Add 'merge-keys: [user-settings]' to deep-merge
# the parent's model with the child's effortLevel instead.
List Inherit (Composition Chains)
The inherit key also accepts a list of configuration paths for explicit composition chains. The list may contain plain strings (backward compatible) and structured objects with per-entry merge-keys. Four mandatory rules govern list inherit behavior:
Rule 1: Own inherit stripped
Each listed file's own inherit key is completely ignored in list composition mode. It does not participate in chain resolution. The user explicitly specifies the full chain in one place -- if additional parent files are needed, they must be added to the list in the correct order.
Rule 2: Equivalent to separate-file chains
inherit: [base.yaml, extensions.yaml] behaves identically to:
leaf.yamlsetsinherit: extensions.yamlextensions.yamlsetsinherit: base.yaml
Resolution order is left-to-right: the first entry is the base (lowest priority), subsequent entries override earlier ones, and the leaf config overrides everything.
Rule 3: Own merge-keys stripped, per-entry from leaf
Each listed file's own merge-keys key is stripped and ignored. Per-entry merge behavior is controlled by the leaf config using structured inherit entries: {config: ..., merge-keys: [...]}.
This design reflects the principle that merge-keys are a property of the relationship between the leaf and each listed entry, not an intrinsic property of the listed config. A config file's own merge-keys may have been written for a different inheritance context and should not leak into an unrelated composition chain.
Without structured entries, all composition steps use replace semantics (no merging between entries). To merge specific keys at a composition step, use a structured entry with merge-keys.
Rule 4: Leaf merge-keys for final step
The leaf config's top-level merge-keys applies to the final composition step (leaf on top of the accumulated base). This is orthogonal to per-entry merge-keys -- Rule 3 controls how listed entries compose with each other, while Rule 4 controls how the leaf merges on top.
Structured Inherit Entries
The inherit list accepts mixed entries: plain strings and structured objects.
Plain string entry (replace semantics at that step):
inherit:
- base.yaml
- extensions.yaml
Structured entry (per-entry merge-keys at that step):
inherit:
- base.yaml
- config: extensions.yaml
merge-keys:
- agents
- rules
Structured entries have the following fields:
| Field | Type | Required | Description |
|---|---|---|---|
config | str | Yes | Configuration source (URL, path, or repo name) |
merge-keys | list[str] | No | Keys to merge instead of replace at this composition step |
The merge-keys in a structured entry accepts the same values as the top-level merge-keys directive: dependencies, agents, slash-commands, rules, skills, files-to-download, hooks, mcp-servers, global-config, user-settings, os-env-variables.
Plain strings and structured entries can be mixed in the same list:
inherit:
- base.yaml # Plain string (replace semantics)
- config: extensions.yaml # Structured (merge agents and rules)
merge-keys:
- agents
- rules
- overrides.yaml # Plain string (replace semantics)
Note: Per-entry merge-keys on the first entry in the list is a no-op (there is no predecessor to merge with). The first entry always becomes the base.
Virtual Chain Equivalence
inherit: [A, B, C] is equivalent to creating a virtual chain of separate files:
A (base, no inherit)
B_virtual (inherits A, own inherit + merge-keys stripped, per-entry merge-keys from leaf applied)
C_virtual (inherits B_virtual, own inherit + merge-keys stripped, per-entry merge-keys from leaf applied)
leaf (inherits C_virtual, leaf's top-level merge-keys applied)
Single-Element List
inherit: ["x"](plain string) is normalized toinherit: "x"and uses the standard recursive single-string path. The filex.yaml's owninheritis recursively resolved.inherit: [{config: "x", merge-keys: [agents]}](structured entry) routes to composition mode. The file's owninheritandmerge-keysare stripped per Rules 1 and 3.
Example
# base.yaml
name: "Base"
agents:
- "agents/core-agent.md"
rules:
- "rules/base-rule.md"
user-settings:
model: "sonnet"
os-env-variables:
SHARED_VAR: "from_base"
BASE_VAR: "base_val"
# extensions.yaml (has own inherit and merge-keys that will be ignored in list mode)
name: "Extensions"
inherit: "some-parent.yaml" # IGNORED (Rule 1)
merge-keys: # IGNORED (Rule 3)
- agents
- rules
agents:
- "agents/extra-agent.md"
rules:
- "rules/extra-rule.md"
os-env-variables:
SHARED_VAR: "from_extensions"
EXT_VAR: "ext_val"
# leaf.yaml -- per-entry merge-keys specified in the leaf
inherit:
- base.yaml
- config: extensions.yaml
merge-keys:
- agents
- rules
name: "My Environment"
merge-keys:
- os-env-variables
user-settings:
model: "opus"
os-env-variables:
LEAF_VAR: "leaf_val"
Result:
agents:["agents/core-agent.md", "agents/extra-agent.md"]-- merged by the structured entry'smerge-keys(Rule 3)rules:["rules/base-rule.md", "rules/extra-rule.md"]-- merged by the structured entry'smerge-keys(Rule 3)user-settings:{"model": "opus"}--user-settingsis not in per-entry merge-keys and not in the leaf's top-levelmerge-keys, so it uses replace semantics: extensions declares none, then the leaf replaces with its ownuser-settingsname:"My Environment"-- leaf overridesos-env-variables:{"SHARED_VAR": "from_extensions", "EXT_VAR": "ext_val", "LEAF_VAR": "leaf_val"}-- extensions is not in per-entry merge-keys, so it REPLACES base'sos-env-variables(droppingBASE_VAR); then the leaf's top-levelmerge-keys: [os-env-variables]shallow-merges the leaf on top, addingLEAF_VARsome-parent.yamlreferenced in extensions.yaml's own inherit is never loaded (Rule 1)- extensions.yaml's own
merge-keys: [agents, rules]is ignored (Rule 3)
Selective Merge (merge-keys)
By default, child configurations completely replace parent values at the top level. The merge-keys directive enables selective extension: child values are merged with parent values for specified keys instead of replacing them.
Syntax
inherit: base-config.yaml
merge-keys:
- agents
- mcp-servers
- dependencies
Per-Level Evaluation
merge-keys is evaluated at each inheritance level independently. It is NOT inherited or accumulated across levels. A replace at level N resets the accumulated value; a merge at level N+1 extends from level N's resolved value only.
Example -- 4-level chain:
Level 1 (source): agents: [A, B]
Level 2 (merge): merge-keys: [agents], agents: [C] => [A, B, C]
Level 3 (replace): agents: [D] => [D]
Level 4 (merge): merge-keys: [agents], agents: [E] => [D, E]
If all levels 2-4 use merge: [A, B, C, D, E].
Merge Strategies by Key Type
| Type | Keys | Strategy |
|---|---|---|
| String list | agents, slash-commands, rules | Concatenate parent + child; deduplicate by string equality; parent items first |
Named list (by name) | mcp-servers, skills | Identity-based: child overrides parent in-position; new items appended |
| Named list (by final file path) | files-to-download | Identity-based: child overrides parent in-position; new items appended |
| Per-platform dict | dependencies | Per-platform sub-key list concatenation with deduplication |
| Composite | hooks | files: concat + dedup by full path; events: concat (no dedup) |
| Deep dict | global-config | deep_merge_settings() with array_union_keys=set() (YAML inheritance layer only) |
| Deep dict | user-settings | deep_merge_settings() with DEFAULT_ARRAY_UNION_KEYS (YAML inheritance layer only) |
| Shallow dict | os-env-variables | Shallow merge; child overrides; null deletes (RFC 7396) |
The files-to-download identity is the normalized final file path: a dest ending with / or \ is combined with the source filename (query parameters stripped) before matching, so distinct files sharing a directory dest keep distinct identities. See files-to-download for details.
Note: The
global-configanduser-settingsrows above describe the YAML inheritance layer only -- howmerge-keyscomposes parent and child configurations before the writer touches disk. Theuser-settingsdeep merge covers its nestedenvblock (Claude-session environment variables). The on-disk writers (write_global_config(),write_user_settings(),write_profile_settings_to_settings()) use the universal array-union contract: every list at every depth is unioned with structural dedupe, independent ofDEFAULT_ARRAY_UNION_KEYS. See Profile-Level Settings Routing for the on-disk write contract.
Non-Mergeable Keys
Keys not listed in the 11 mergeable keys (such as name, command-defaults, status-line) always use replace semantics, regardless of merge-keys.
Complete Merge Example
# base.yaml
name: "Base Environment"
agents:
- "agents/core-agent.md"
mcp-servers:
- name: "context-server"
transport: "http"
url: "http://localhost:8000/mcp"
dependencies:
common:
- "uv tool install ruff"
# child.yaml
inherit: "base.yaml"
merge-keys:
- agents
- mcp-servers
- dependencies
name: "Extended Environment" # Replaces (not in merge-keys)
agents:
- "agents/extra-agent.md" # Appended to parent's list
mcp-servers:
- name: "context-server" # Replaces parent's context-server in-position
transport: "http"
url: "http://localhost:9000/mcp"
- name: "new-server" # Appended (new identity)
command: "npx @example/new-mcp"
dependencies:
common:
- "uv tool install ty" # Appended to parent's common list
linux:
- "sudo apt-get install -y shellcheck" # New platform
Result after merge:
name:"Extended Environment"(replaced)agents:["agents/core-agent.md", "agents/extra-agent.md"](merged)mcp-servers: context-server with updated URL at index 0, new-server appended (merged)dependencies.common:["uv tool install ruff", "uv tool install ty"](merged)dependencies.linux:["sudo apt-get install -y shellcheck"](new platform from child)
Authentication for Private Repositories
When using configurations from private repositories, you need to provide authentication credentials.
Auth Precedence
Authentication is resolved in this order (highest priority first):
- CLI
--authparameter -- Format:"header:value","header=value", or plain token - URL-specific environment variables --
GITLAB_TOKENfor GitLab URLs,GITHUB_TOKENfor GitHub URLs - Generic token --
REPO_TOKENenvironment variable (auto-detects repository type) - Interactive prompt -- If a terminal is available and the repository type is detected
Variable Scopes
| Variable | Scope | Description |
|---|---|---|
GITHUB_TOKEN | Python-level (auto-detected from URL) | GitHub PAT with repo scope |
GITLAB_TOKEN | Python-level (auto-detected from URL) | GitLab PAT with read_repository scope |
REPO_TOKEN | Shell-level (passed as --auth) | Generic token, auto-detects repo type |
CLAUDE_CODE_TOOLBOX_ENV_AUTH | Shell-level (passed as --auth) | Custom header format: Header-Name:token-value |
URL Handling
- GitLab web URLs (
/-/raw/) are automatically converted to API format - GitHub raw URLs are automatically converted to API URLs
- Public access is attempted first; authentication is applied only on 401/403/404 responses
- GitHub Pages URLs (
*.github.io) are never treated as repository URLs -- no auth prompt is issued for them even on 404 responses - For GitHub 404 responses, the setup script probes
api.github.com/repos/{owner}/{repo}unauthenticated to distinguish missing files in public repositories (no auth prompt) from private or nonexistent repositories (auth prompt)
Automatic Auto-Update Management
When claude-code-version specifies a pinned version (any value other than "latest" or absent), the setup script automatically injects auto-update disable controls into three targets to prevent Claude Code from overwriting the pinned version. When the version is "latest" or absent, stale auto-injected controls from prior pinned runs are automatically cleaned up (re-enabling auto-updates) while user-declared controls are preserved.
Injection Targets
| Target | Key | Value | On-disk file (isolated) | On-disk file (non-isolated) |
|---|---|---|---|---|
global-config | autoUpdates | false | ~/.claude/{cmd}/.claude.json + ~/.claude.json | ~/.claude.json |
user-settings | env.DISABLE_AUTOUPDATER | "1" | ~/.claude/{cmd}/config.json (env key) | ~/.claude/settings.json (env key, deep-merge) |
os-env-variables | DISABLE_AUTOUPDATER | "1" | Shell profiles / Windows registry | Shell profiles / Windows registry |
All three targets are injected unconditionally regardless of whether command-names is present. The user-settings.env.DISABLE_AUTOUPDATER control follows the standard user-settings routing: in isolated mode it is built into ~/.claude/{cmd}/config.json (env key) at Step 18; in non-isolated mode it is deep-merged into ~/.claude/settings.json['env'] at Step 14. Deep-merge makes it additive with any user-declared environment variables: _merge_recursive() recurses into the env dict and preserves sub-keys not present in the delta. A pinned non-isolated run performs no Step 16 settings.json sweep (the base file is the run's own Step 14 write target), so the env-based control persists in the final base file.
Removal Behavior
When the version is "latest" or absent, nothing is auto-injected, so every auto-update control key present in the in-memory configuration comes from the user's YAML and is preserved -- the removal counterpart of the WARN-but-Respect write semantics. Two cleanup mechanisms remove stale artifacts from prior pinned runs instead:
- OS-level variable:
DISABLE_AUTOUPDATERhas no filesystem sweep, so a deletion entry is scheduled inos-env-variables(unless the user explicitly declares the variable there) and the OS environment writer removes any stale OS-level variable left by a prior pinned run. Deleting an absent variable is a safe no-op on all platforms. - On-disk files: Stale artifacts in
settings.jsonand.claude.jsonfiles are removed by the Step 16 filesystem sweep described below.
Write-remove symmetry: After all write operations, cleanup_stale_auto_update_controls() runs as a filesystem sweep pass (Step 16). When not pinned, it removes DISABLE_AUTOUPDATER from ALL settings.json files (~/.claude/settings.json and all ~/.claude/*/settings.json) -- unless the current YAML itself declares DISABLE_AUTOUPDATER in user-settings.env, in which case the settings.json sweep is skipped (the removal counterpart of the WARN-but-Respect write semantics) -- and removes autoUpdates: false from ALL .claude.json files (~/.claude.json and all ~/.claude/*/.claude.json). Removal of autoUpdates is value-conditional: only false (auto-injected) is removed, true (user preference) is preserved. When pinned, the sweep cleans ~/.claude/settings.json only for isolated runs (command-names present), to prevent bare sessions from inheriting isolated environment restrictions; a pinned non-isolated run performs no settings.json sweep, because the base file is the run's own write target.
Conflict Resolution (WARN-but-Respect)
If the user explicitly sets a value in the YAML configuration that contradicts the automatic intent, the user value is preserved and a warning is emitted:
- User value absent: Auto-inject (proceed silently)
- User value matches intent: No-op (no warning)
- User value contradicts intent: Respect user value, emit warning. For example, if the user sets
autoUpdates: trueinglobal-configwhile pinning a specific version, thetruevalue is preserved and a warning like"User set global-config.autoUpdates to True (auto-update intent is False for pinned version). Respecting user value."is displayed.
Injection is gated on key MEMBERSHIP, not on value: an explicit user null (a deletion request, legal in every target) is a user declaration that contradicts the intent, so it is respected with a warning and never overwritten.
[auto] Marker in Installation Summary
Auto-injected values are displayed in the installation summary (including --dry-run output) with a green [auto] marker, similar to the existing [?] (unknown keys) and [!] (sensitive paths) markers. This makes it clear which values were automatically added by the setup script rather than explicitly configured in the YAML.
Auto-injected settings (version pinning):
[auto] global-config.autoUpdates: false
[auto] user-settings.env.DISABLE_AUTOUPDATER: "1"
[auto] os-env-variables.DISABLE_AUTOUPDATER: "1"
Defense-in-Depth
The autoUpdates key in ~/.claude.json is considered deprecated by Anthropic (see issue #3479) and may stop working in future Claude Code releases. It is included as a defense-in-depth mechanism alongside the DISABLE_AUTOUPDATER environment variable, which is the primary auto-update control. The Claude Code auto-updater may also ignore disable settings in some versions (see issues #10764, #11263, #12564) -- covering all three targets provides the best protection.
Automatic IDE Extension Version Management
When claude-code-version specifies a pinned version, the setup script also automatically disables IDE extension auto-installation and installs the matching extension version into detected VS Code family IDEs. When the version is "latest" or absent, stale auto-injected IDE extension controls from prior pinned runs are automatically cleaned up while user-declared controls are preserved.
This feature mirrors the Automatic Auto-Update Management architecture exactly: same 3-target write matrix, same membership-gated WARN-but-Respect conflict resolution, same write-remove symmetry cleanup, and same unpinned removal semantics (user declarations preserved in memory, OS-level deletion scheduled, on-disk cleanup via the Step 16 sweep).
Injection Targets
| Target | Key | Value | On-disk file (isolated) | On-disk file (non-isolated) |
|---|---|---|---|---|
global-config | autoInstallIdeExtension | false | ~/.claude/{cmd}/.claude.json + ~/.claude.json | ~/.claude.json |
user-settings | env.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL | "1" | ~/.claude/{cmd}/config.json (env key) | ~/.claude/settings.json (env key, deep-merge) |
os-env-variables | CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL | "1" | Shell profiles / Windows registry | Shell profiles / Windows registry |
All three targets are injected unconditionally regardless of whether command-names is present, consistent with auto-update management behavior. The user-settings.env.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL control follows the standard user-settings routing: in isolated mode it is built into ~/.claude/{cmd}/config.json (env key); in non-isolated mode it is deep-merged into ~/.claude/settings.json['env']. Deep-merge recurses into the env dict so that the injected IDE control coexists with any user-declared environment variables in the same on-disk container.
Removal Behavior
When the version is "latest" or absent, nothing is auto-injected, so every IDE extension control key present in the in-memory configuration comes from the user's YAML and is preserved -- identical to the auto-update twin. Two cleanup mechanisms remove stale artifacts from prior pinned runs instead:
- OS-level variable:
CLAUDE_CODE_IDE_SKIP_AUTO_INSTALLhas no filesystem sweep, so a deletion entry is scheduled inos-env-variables(unless the user explicitly declares the variable there) and the OS environment writer removes any stale OS-level variable left by a prior pinned run. Deleting an absent variable is a safe no-op on all platforms. - On-disk files: Stale artifacts in
settings.jsonand.claude.jsonfiles are removed by the Step 16 filesystem sweep described below.
Write-remove symmetry: After all write operations, cleanup_stale_ide_extension_controls() runs alongside cleanup_stale_auto_update_controls() as a filesystem sweep pass (Step 16) with identical guards. When not pinned, it removes CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL from ALL settings.json files -- unless the current YAML itself declares the key in user-settings.env, in which case the settings.json sweep is skipped -- and removes autoInstallIdeExtension: false from ALL .claude.json files (value-conditional: user-set true is preserved). When pinned, the sweep cleans ~/.claude/settings.json only for isolated runs; a pinned non-isolated run performs no settings.json sweep.
Conflict Resolution (WARN-but-Respect)
Identical to auto-update management: if the user explicitly sets a value that contradicts the automatic intent, the user value is preserved and a warning is emitted.
[auto] Marker in Installation Summary
Auto-injected IDE extension values are displayed with the same green [auto] marker as auto-update values:
Auto-injected settings (version pinning):
[auto] global-config.autoInstallIdeExtension: false
[auto] user-settings.env.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1"
[auto] os-env-variables.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1"
VSIX Installation
When a version is pinned, the setup installs the matching Claude Code extension (anthropic.claude-code) into all detected VS Code family IDEs. The extension is platform-specific: the marketplace hosts a separate VSIX per OS/architecture pair (targetPlatform), so the setup first computes the host identifier as {os}-{arch} -- os is win32, darwin, linux, or alpine (when /etc/alpine-release exists), and arch is x64 or arm64. Installation then uses a three-tier fallback chain:
- Tier 1 -- Bundled VSIX: Check
~/.claude/local/node_modules/@anthropic-ai/claude-code/vendor/claude-code.vsix. The file is opened as a zip archive and used only when its embeddedextension/package.jsonversion equals the pinned version AND its manifest's declaredTargetPlatform(if any) matches the host. A version or platform mismatch, or an unreadable archive, falls through to Tier 2. No network download needed. - Tier 2 -- Marketplace CDN download: Download the platform-specific VSIX from the VS Code Marketplace CDN by appending
?targetPlatform={os}-{arch}to both the primary and fallback URLs (without the parameter, the marketplace serves an arbitrary platform's build of this platform-specific extension). Gzip-encoded response bodies (served by the fallback endpoint) are decompressed, and every payload is validated against the zip magic prefix before use. The VSIX is written to a temp file, installed via--install-extension <path> --force, then cleaned up. Skipped entirely (in favor of Tier 3) when the host OS or architecture has no known marketplace identifier. - Tier 3 -- Marketplace @version syntax: Use
anthropic.claude-code@{version}syntax directly; the IDE resolves its own targetPlatform. Emits a warning because VS Code may auto-update the extension despite version pinning. Also reached when a downloaded payload is invalid or the temp-file write fails.
Version-missing skip: When every Tier 2 download URL returns HTTP 404, the pinned Claude Code version has no matching extension in the marketplace for the host targetPlatform (extension version gaps are real). The setup prints a warning naming the version and targetPlatform, skips IDE extension installation, and the IDEs keep their current extension -- this counts as success, not failure, and Tier 3 is not attempted. The warning only fires after at least one IDE was detected. Non-404 download failures (network errors, 5xx) keep the Tier 3 fallback and its auto-update warning.
Installation is non-fatal: failures produce warnings but do not abort the setup.
VSIX Auto-Update Behavior
The three installation tiers have different auto-update implications for the installed extension:
| Tier | Method | Auto-Update Status |
|---|---|---|
| Tier 1 | Bundled VSIX | Disabled by default (VS Code v1.92+) |
| Tier 2 | Downloaded VSIX | Disabled by default (VS Code v1.92+) |
| Tier 3 | Marketplace @version | Active -- VS Code may update the extension |
Since VS Code v1.92, extensions installed via VSIX files (Tiers 1 and 2) have auto-update disabled by default. This is the primary defense mechanism for version pinning -- the installed extension stays at the pinned version without any additional IDE-level configuration.
Tier 3 (marketplace @version syntax) is a last-resort fallback that triggers when no usable bundled VSIX exists AND the marketplace CDN download is unavailable -- the host targetPlatform is unknown, the download fails for a non-404 reason, the payload is not a valid VSIX archive, or the temp-file write fails. (When all download URLs return 404, the setup skips installation with a warning instead of reaching Tier 3 -- see Version-missing skip above.) In the Tier 3 case, VS Code may auto-update the extension despite version pinning, so the setup emits a warning with instructions to manually disable auto-update for the extension:
In VS Code's Extensions view, right-click the Claude Code extension and set "Auto Update" to off.
This per-extension "Auto Update" toggle is the only targeted control available. There is no settings.json key for per-extension auto-update exceptions -- the only JSON setting (extensions.autoUpdate: false) disables auto-update for ALL extensions, which is too broad.
JetBrains IDEs: JetBrains IDEs use their own plugin ecosystem and do not support VSIX extensions. The Claude Code JetBrains plugin is versioned independently from the CLI, and there is no external mechanism to control per-plugin auto-update from outside the IDE. The existing autoInstallIdeExtension: false control is the only applicable protection for JetBrains.
VS Code Family IDE Detection
Detection runs in two passes. First, shutil.which() checks PATH for each CLI name: code, code-insiders, cursor, windsurf, codium. Then, for CLI names not found on PATH, well-known install locations are probed (results are deduplicated by CLI name, with a PATH hit always winning):
- macOS: App bundles under
/Applicationsand~/Applicationsfor all five IDEs (for example,Visual Studio Code.app/Contents/Resources/app/bin/code) -- drag-and-drop installs do not register thecodeCLI on PATH - Linux:
/usr/bin/code,/usr/share/code/bin/code,/snap/bin/code, and the system and per-user Flatpak exports ofcom.visualstudio.code - Windows:
%LOCALAPPDATA%\Programs\Microsoft VS Code\bin\code.cmd
The extension is installed into all detected IDEs. If no IDEs are detected, the step is a silent no-op.
JetBrains IDEs are excluded because they use their own plugin ecosystem and do not support VSIX extensions.
Process Environment Early-Set
When a version is pinned, CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL=1 is set in the process environment before Step 1 (Claude Code installation). This prevents the Claude Code CLI from auto-installing IDE extensions during the installation process itself.
Configuration Sources
The setup script determines the configuration source by checking in this order:
- URL: Starts with
http://orhttps://-- fetched directly from the web - Local file: Contains path separators (
/,\), starts with./or../, is an absolute path, or the file exists on disk -- loaded from the local filesystem - Repository config: Everything else --
.yamlis added if missing, then fetched fromhttps://raw.githubusercontent.com/alex-feel/claude-code-artifacts-public/main/{name}.yaml
Setup-Time CLAUDE_CONFIG_DIR Export
When command-names creates an isolated profile, the setup script exports CLAUDE_CONFIG_DIR into its own process environment (set to the isolated profile directory, for example ~/.claude/{cmd}) early in main(), before any resources are processed. Child processes spawned during setup -- dependency installers, npx-based tooling, claude mcp ... calls, and the IDE-extension installer -- inherit this value and therefore resolve against the isolated profile directory instead of the default ~/.claude/. For example, npx <skill-installer> launched during setup installs into the isolated profile rather than the home-directory Claude.
This setup-time export is transient and process-scoped: it lives only for the duration of the setup process and is deliberately never written to config.json or any other on-disk settings file. It exists purely so setup-time child processes target the correct directory.
The two CLAUDE_CONFIG_DIR channels are orthogonal and serve different lifetimes:
| Channel | When it applies | Scope | Source |
|---|---|---|---|
| Setup-time process export | During setup_environment | Child processes spawned by the setup script | os.environ['CLAUDE_CONFIG_DIR'] set in main() |
| Runtime launcher export | When you run the command | The launched Claude Code session and its tools | export CLAUDE_CONFIG_DIR in the generated launcher |
The runtime launcher export (documented in Cross-Shell Launcher Architecture) remains the sole authoritative runtime source. The setup-time export only governs processes started during installation. The per-call MCP CLAUDE_CONFIG_DIR injection (see the Isolated environments note under MCP Servers) is retained independently, because the Windows MCP code path builds a curated environment for child processes rather than inheriting the full os.environ.
Cross-Shell Command Registration (Windows)
On Windows, the setup creates global commands that work across all shells (PowerShell, CMD, Git Bash) through a set of launcher and wrapper scripts:
- Shared POSIX launcher (
~/.claude/{command}/launch.sh) -- the actual launcher executed by Git Bash - PowerShell wrapper (
~/.claude/{command}/start.ps1) -- invokes launch.sh via Git Bash - CMD wrapper (
~/.claude/{command}/start.cmd) -- invokes launch.sh via Git Bash - Global wrappers in
~/.local/bin/({command},{command}.ps1,{command}.cmd) -- entry points that delegate to the above
For the full technical architecture, see Cross-Shell Launcher Architecture.
What Happens When You Run Setup
Here is a conceptual overview of what the setup script does when you run it with a configuration:
- Install Claude Code -- Uses the native installer with npm fallback. Skipped with
--skip-install. - Install IDE extensions -- Installs the pinned-version Claude Code extension into detected VS Code family IDEs, selecting the VSIX build matching the host targetPlatform. Skipped if no version is pinned or
--skip-installis used. When the pinned version has no matching marketplace extension for the host platform (every download URL returns HTTP 404), the step prints a warning and skips installation, leaving each IDE's current extension in place. - Create directories -- Creates
~/.claude/agents/,commands/,rules/,prompts/,hooks/, andskills/directories. - Download custom files -- Processes
files-to-downloadentries. - Install Node.js -- If
install-nodejs: trueis set in the config. - Install dependencies -- Runs platform-specific dependency commands. Failed global npm installs are retried with sudo on Linux/macOS/WSL when the npm global prefix is not user-writable; every failed dependency is listed in the end-of-run error block and causes exit code 1.
- Set OS environment variables -- Writes persistent environment variables from
os-env-variables(anullvalue deletes the variable). Rebuilds the env loader files (env.sh,env.fish,env.ps1,env.cmd) for launcher auto-sourcing -- header-only when no active variable remains, so stale exports are cleared. - Process agents -- Downloads agent Markdown files to
~/.claude/agents/. - Process slash commands -- Downloads command files to
~/.claude/commands/. - Process rules -- Downloads rule Markdown files to
~/.claude/rules/. - Process skills -- Downloads skill file sets to
~/.claude/skills/{name}/. - Process system prompt -- Downloads the prompt file if configured.
- Configure MCP servers -- Sets up MCP servers with scope-based routing.
- Write user settings -- In non-isolated mode, deep-merges
user-settingsinto~/.claude/settings.json. In isolated mode, this write is skipped -- theuser-settingscontent is built into the profile'sconfig.jsonat Step 18. - Write global config -- Merges
global-configinto~/.claude.json. Withcommand-names, also propagates the machine's recordedinstallMethodfrom the base~/.claude.jsoninto the dual-written isolated.claude.json. - Cleanup stale controls -- Sweeps stale auto-update and IDE extension artifacts from prior configurations (all filesystem locations on unpinned runs, preserving
settings.jsonkeys the current YAML itself declares; on pinned runs, only the base~/.claude/settings.jsonand only for isolated environments). - Download hooks -- Downloads hook script files to
~/.claude/{cmd}/hooks/(withcommand-names) or~/.claude/hooks/(without). In non-command-names mode, Step 17 runs when ANY of the following are declared:hooks.eventsnon-empty,hooks.filesnon-empty, orstatus-line.fileset. - Write profile settings -- Writes the profile-owned keys (
statusLine,hooks) as camelCase keys on disk. Withcommand-names: writes~/.claude/{cmd}/config.jsonviacreate_profile_config(), merging theuser-settingscontent with the builtstatusLine/hooksentries (atomic overwrite -- fresh dict each run). Withoutcommand-names: writes to~/.claude/settings.jsonviawrite_profile_settings_to_settings(), which delegates to_write_merged_json()for deep-merge, universal array union at every depth, and RFC 7396 null-as-delete (preserves non-delta keys; see Profile-Level Settings Routing). - Write manifest -- Creates an installation tracking manifest. (Only if
command-namesis specified.) - Create launcher -- Creates the launcher script for the command. (Only if
command-namesis specified.) - Register commands -- Creates global command wrappers. (Only if
command-namesis specified.) - Link projects directory -- Links the isolated profile's
projects/directory to the base~/.claude/projects/. (Only ifcommand-namesis specified andlink-projects-dir: true.)
Step 17 is skipped if no hooks, hook files, or status-line file are configured. In non-isolated mode, Step 18 is a no-op if the profile delta is empty -- no status-line or hooks declared at YAML root level. Steps 19-22 are skipped if command-names is not specified. Step 22 additionally requires link-projects-dir: true.
Profile-Level Settings Routing
The setup script supports two modes of profile-settings routing, controlled by the presence of command-names: in the YAML configuration. This section documents how the profile-owned keys (status-line, hooks) and the user-settings content land on disk in each mode.
Profile-Owned Keys
Two YAML root-level keys are profile-owned -- they are extracted from YAML root, translated to camelCase, and written to disk by the profile-settings subsystem (_build_profile_settings() builder + one of two writers) because they require toolbox-side processing (file download, absolute-path command construction):
| YAML root key (kebab-case) | On-disk key (camelCase) |
|---|---|
status-line | statusLine |
hooks | hooks |
The shared pure builder _build_profile_settings() performs status-line command-string construction and delegates to _build_hooks_json() for the hooks universe. The 2-key set is declared as PROFILE_OWNED_KEYS = frozenset({'statusLine', 'hooks'}) in scripts/setup_environment.py, and the mapping of YAML kebab-case root keys to camelCase on-disk names is declared as the module-level constant _YAML_TO_CAMEL_PROFILE_KEYS. These keys match USER_SETTINGS_EXCLUDED_KEYS = {'hooks', 'statusLine'} exactly: they are forbidden inside user-settings because the toolbox owns their processing, while every other settings.json key is declared under user-settings (see user-settings).
Isolated Mode (command-names present)
When command-names is specified, the setup creates an isolated directory ~/.claude/{cmd}/. The config.json file carries the complete settings.json content -- the user-settings section plus the toolbox-built statusLine and hooks entries -- and the toolbox does not write the isolated settings.json:
| File | Priority (CLI) | Content | Writer | Step | Semantics |
|---|---|---|---|---|---|
config.json | 2 (flagSettings) | user-settings: content + built statusLine / hooks entries | create_profile_config() | 18 | Atomic overwrite (fresh dict each run; nulls stripped) |
The launcher script passes config.json via the --settings flag (command-line settings layer, priority 2) and sets CLAUDE_CONFIG_DIR to the isolated directory. Because --settings outranks a repository's project settings, the isolated profile's settings are enforced -- exactly what an isolated environment exists to provide. The user-settings section and the built statusLine/hooks entries are disjoint by construction, because statusLine and hooks are rejected inside user-settings. In isolated mode, stale-key accumulation is NOT a concern because create_profile_config() uses atomic overwrite: every run produces a fresh config.json containing only the currently-declared keys, so removing a key from YAML cleanly removes it from config.json on the next run. Null-valued dict members at every depth are stripped before the write (absence expresses deletion under atomic rebuild), so a literal JSON null is never written.
Non-Isolated Mode (command-names absent)
When command-names is ABSENT, the setup writes to the shared ~/.claude/ directory. BOTH Step 14 and Step 18 target the SAME file (~/.claude/settings.json) and BOTH use the same READ-MERGE-WRITE contract inherited from _write_merged_json():
| File | Content | Writer | Step | Semantics |
|---|---|---|---|---|
~/.claude/settings.json | YAML user-settings: (all non-excluded keys) | write_user_settings() | 14 | Deep merge + universal array union at every depth + RFC 7396 null-as-delete |
~/.claude/settings.json | statusLine/hooks delta from YAML root | write_profile_settings_to_settings() | 18 | Deep merge + universal array union at every depth + RFC 7396 null-as-delete |
- Step 14 deep-merges
user-settings:intosettings.json. Existing keys are preserved; for leaf scalar conflicts the new YAML values overwrite the existing values; every list at every depth is unioned with structural dedupe;nullvalues delete keys via RFC 7396. - Step 18 deep-merges the
statusLine/hooksdelta into the same file using the same semantics. Existing keys not in the delta are preserved; existing nested dicts are recursively merged with the delta; every list at every depth is unioned with structural dedupe across both steps; top-level or nestednullin the delta deletes keys.
Under this contract, the shared ~/.claude/settings.json is never scrubbed of keys the current YAML does not declare, and contributions from manual user edits, other YAML configurations, the Claude Code CLI itself, and the Step 14 user-settings write all survive the profile-settings write at Step 18. List-valued keys (such as permissions.allow/deny/ask/additionalDirectories, companyAnnouncements, hooks.<EventName> matcher-group lists, sandbox.filesystem.* path lists, disabledMcpjsonServers/enabledMcpjsonServers) accumulate additively across runs, matching Claude Code CLI's documented cross-scope merge semantics: "arrays are concatenated and deduplicated, not replaced".
Write Semantics Contract
Design principle: ~/.claude/settings.json is a SHARED/COMMON user-facing file. The toolbox treats it as a collaborative surface: other writers (the user, the CLI, other YAML configurations) contribute keys the current run knows nothing about, and the shared-settings writers preserve those contributions while still allowing explicit deletion via YAML-level null. Matches Claude Code CLI's documented cross-scope merge semantics: "arrays are concatenated and deduplicated, not replaced".
write_profile_settings_to_settings() delegates to _write_merged_json(), which implements the three-step READ-MERGE-WRITE process:
- READ the existing
~/.claude/settings.json(or start fresh with an empty dict if the file is missing, malformed, or has a non-dict top-level value; a warning is emitted in those cases). - DEEP MERGE the builder delta into the existing content via
_merge_recursive(), which handles:- Deep recursion into nested dicts (for example, a delta
hooks: {PostToolUse: [...]}updates only thePostToolUsesub-key ofhooks, leaving other event names intact if not in the delta). - Universal array union at every depth via Python structural equality -- existing and new arrays are combined, order-preserving (existing elements first), with duplicate elements removed. Applies to every list-valued key at any nesting level, matching Claude Code CLI's cross-scope merge semantics.
- RFC 7396 null-as-delete: any value of
Nonein the delta (top-level or nested) deletes the corresponding key from the target viatarget.pop(key, None). - Scalar overwrite on leaf conflicts (new value wins).
- Deep recursion into nested dicts (for example, a delta
- WRITE the merged result back to disk with a trailing newline for file-format consistency.
The builder _build_profile_settings() accepts a profile_config dict keyed by the camelCase on-disk names; dict membership encodes the YAML declaration state (present-with-value, present-with-null, or absent) end-to-end so that the downstream writer can apply RFC 7396 null-as-delete to the shared settings.json for both top-level and nested YAML nulls. main() constructs profile_config with a comprehension that iterates _YAML_TO_CAMEL_PROFILE_KEYS and includes a key when yaml_key in config, which preserves the distinction between "absent from YAML" (key omitted from profile_config, on-disk value preserved) and "declared with explicit null" (profile_config[camel_key] = None, on-disk value deleted).
Merge cases for each key in the delta:
| Case | Builder output | Writer on-disk effect |
|---|---|---|
YAML declares key: scalar | {'key': scalar} | Scalar overwrite (leaf wins) |
YAML declares key: {nested} | {'key': {nested}} | Deep-merge (recurse into sub-keys; nested lists also unioned at every depth) |
YAML declares key: [list] | {'key': [list]} | Union with structural dedupe at every depth (elements added additively, duplicates removed) |
YAML declares key: [] | {'key': []} | No-op under union semantics (empty list adds nothing; use null to clear) |
YAML declares key: null | {'key': None} | DELETE the key from the file (RFC 7396 null-as-delete) |
YAML omits key | key absent from delta | PRESERVE existing value unchanged |
Null-as-delete is supported for both profile-owned keys (status-line, hooks), and for every key written by the shared-settings writers, both at the top level (status-line: null, hooks: null) and nested (hooks: {PreToolUse: null}). The top-level and nested cases go through the same _merge_recursive() path inside the writer; the top-level path additionally requires the dict-membership threading in profile_config to survive main()'s YAML extraction.
Preservation coverage (what survives shared-settings writes):
Keys absent from the delta are preserved in ~/.claude/settings.json. This covers:
- Prior contributions from
write_profile_settings_to_settings()itself across other YAML configurations, including list-valued keys (which accumulate additively under the universal array-union contract). - Deep-merged contributions from Step 14
write_user_settings()(alluser-settingskeys --model,permissions,env,effortLevel, and everything else -- with list-valued keys unioned with structural dedupe across Step 14 and Step 18). - User-managed keys outside the toolbox's YAML schema (for example,
includeGitInstructions,apiKeyHelper,cleanupPeriodDays,outputStyle,autoMemoryDirectory,sandbox.*, user-managed array-valued keys likecompanyAnnouncementsorpermissions.additionalDirectories). - Auto-injected
env.DISABLE_AUTOUPDATER(auto-update) andenv.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL(IDE extension) controls, which are injected intouser-settings.envand written by Step 14: because deep-merge recurses into theenvdict, the injected controls coexist with any user-declared environment variables and survive the Step 18 write, which touches onlystatusLine/hooks. (Pinned non-isolated runs perform no Step 16settings.jsonsweep, so these controls also survive the cleanup pass -- see Automatic Auto-Update Management.) - Elements written to list-valued keys by any prior contributor (manual user edits, the Claude Code CLI, teammate YAMLs): new elements from the current YAML are unioned with the existing list rather than replacing it.
Empty-delta no-op: If neither status-line nor hooks is declared at YAML root level, the builder returns {} and write_profile_settings_to_settings() performs ZERO file I/O -- it neither creates nor touches ~/.claude/settings.json. A YAML with only user-settings:, global-config:, agents:, and so on will never have Step 18 modify settings.json; the user-settings content (including auto-injected env controls) reaches settings.json through Step 14 instead.
Malformed or non-dict existing content: If ~/.claude/settings.json contains invalid JSON, unreadable content, or a non-dict top-level value (for example, a bare list), _write_merged_json() emits a warning ("Existing ... is not a dict, starting fresh" or "Invalid JSON in ...") and starts fresh (treats the existing content as {}). The written file ends with a trailing newline for file-format consistency with write_user_settings() and write_global_config().
Null-as-Delete for Profile-Owned Keys (YAML contract)
Both profile-owned keys (status-line, hooks) support null-as-delete at the YAML root level in non-command-names mode. Every other settings.json key deletes the same way from under user-settings (see Key Deletion). Examples:
# Delete the entire hooks block from settings.json
hooks: null
# Delete just one event list (keep other event names)
hooks:
PreToolUse: null
# Delete the status-line key
status-line: null
# Delete a settings.json key managed under user-settings
user-settings:
model: null
permissions: null
Top-level null vs nested null:
- Top-level null (for example,
hooks: null): the entire top-level key is removed from~/.claude/settings.jsonviaexisting.pop('hooks', None). - Nested null (for example,
hooks: {PreToolUse: null}): deep-merge recurses into thehooksdict, and thePreToolUsesub-key is removed while other event names are preserved.
Both cases are handled by _merge_recursive() inside _write_merged_json(). The top-level case additionally requires the dict-membership construction in main() and the builder so that the profile_config can carry None for top-level YAML nulls -- a plain config.get('hooks') would otherwise erase the distinction between "absent" and "null".
Re-run semantics: After hooks: null has been applied, removing the hooks: null line from the YAML (so the key is absent) in a later run preserves the hooks key's then-current state (which is "absent from settings.json", unchanged by the new no-op delta). There is no auto-undelete -- once deleted, a key stays deleted until a subsequent YAML explicitly re-declares it with a non-null value.
Deferred Stale-Key Behavior (User-Facing Contract)
This is an INTENTIONAL user-facing contract, not a bug. Understanding this behavior is critical to using command-names-absent mode correctly.
Scenario: You had user-settings: {permissions: {allow: [Read]}} in one setup run. You then remove the entire permissions block from your user-settings and re-run setup.
Result: The permissions key in ~/.claude/settings.json retains its existing on-disk value ({allow: [Read]}). It is NOT deleted.
Why: ~/.claude/settings.json is a shared file. Removing a key from YAML is NOT a sufficient signal for the toolbox to delete that key from the shared file, because:
- The key might have been set by a setup run that used a different YAML configuration.
- The key might have been set manually by the user or by another tool.
- The key might have been set by a teammate's YAML that is managed separately.
- The profile-settings writer has no state-tracking sidecar to distinguish keys it wrote in prior runs from user-managed keys.
To remove a key, you have two explicit options:
- Set the key to
nullin YAML. Examples:
To delete only a nested sub-key, nest the null under the parent instead of nulling the whole block (the two forms are mutually exclusive alternatives for the same key):hooks: null # Delete hooks block (profile-owned, YAML root) status-line: null # Delete statusLine (profile-owned, YAML root) user-settings: permissions: null # Delete entire permissions block from settings.json model: null # Delete model key env: null # Delete env block from settings.jsonuser-settings: permissions: deny: null # Delete only the deny sub-key (keep allow/ask) - Manually delete the key from
~/.claude/settings.jsonusing a text editor.
Automated YAML-removal-triggered cleanup (a state-tracking sidecar approach, for example ~/.claude/toolbox-managed-keys.json recording which keys the toolbox wrote in the last run) is not implemented. The preservation behavior is the intended design.
Security framing: preventing silent destruction of user state. Deep-merge with universal array-union at every depth is the core mechanism that prevents silent destruction of security rules and user state in the shared ~/.claude/settings.json. The contract applies uniformly to every list-valued key at any depth: permissions.allow/deny/ask/additionalDirectories, companyAnnouncements, hooks.<EventName> matcher-group lists, sandbox.filesystem.* path lists, disabledMcpjsonServers/enabledMcpjsonServers, projects.<path>.allowedTools, and every other list-valued key. A narrower YAML declaration such as user-settings: {permissions: {allow: [Read]}} MUST NOT remove permissions.deny entries, permissions.additionalDirectories, companyAnnouncements entries, or any other user-managed state contributed by other writers (manual user edits, the Claude Code CLI, or teammate YAMLs). Under the unified deep-merge + universal-array-union contract, list entries accumulate additively across runs and explicit null is the only way to shrink them:
- To remove ALL entries under a list-valued sub-key: nest-null the sub-key (
user-settings: {permissions: {deny: null}}deletes just thedenysub-key) or top-level-null the parent (user-settings: {permissions: null}deletes the entirepermissionsblock). - To remove a specific element: edit
~/.claude/settings.jsonmanually, because array union only grows lists -- declaringuser-settings: {permissions: {deny: [X]}}in YAML unions[X]with the existing deny list, not replaces it.
Any rule or entry preserved on disk is the combined contribution of all writers; losing rules silently on re-run would be a critical security regression, so the toolbox instead requires explicit null to delete them. This matches Claude Code CLI's documented cross-scope merge semantics for shared settings files.
Note on hooks deletion and composition: Setting hooks: null at YAML root deletes the entire hooks key from ~/.claude/settings.json. Setting hooks: {EventName: null} deletes only that event list while preserving other event names. Under the universal array-union contract, per-event matcher lists are unioned across runs via Python structural equality: disjoint event names compose additively, and for the same event name, matcher groups accumulate. Two matcher groups with the same matcher string but different inner handlers from different contributions coexist as separate entries (naive structural dedupe -- they are not structurally equal, so neither is discarded); structurally identical matcher groups collapse to one (idempotent re-run). This matches Claude Code's native cross-scope merge behavior; at runtime, Claude Code deduplicates command hooks by command string and HTTP hooks by URL ("Command hooks are deduplicated by command string, and HTTP hooks are deduplicated by URL"), so on-disk consolidation is unnecessary.
Profile-Scoped MCP Servers in Non-Command-Names Mode (ERROR)
Profile-scoped MCP servers (scope: profile or scope: [user, profile]) CANNOT work without command-names because the launcher script that consumes --mcp-config is only created in isolated mode. In non-isolated mode, profile-scoped servers would have no launcher target and would be silently dropped at runtime -- a correctness risk.
This constraint is enforced at two levels: the EnvironmentConfig Pydantic model raises a ValueError during YAML validation (catching the issue at parse time), and the setup script enforces a hard validation error (exit 1) at the runtime validation phase as defense-in-depth, BEFORE any side effects (downloads, writes) occur:
[ERROR] MCP server 'my-server' declares scope: profile but command-names is not specified.
[ERROR] Profile-scoped MCP servers require a launcher script with --mcp-config flag, which
[ERROR] is only created when command-names is present in your YAML configuration.
[ERROR]
[ERROR] Fix one of:
[ERROR] 1. Add "command-names: [your-name]" to enable isolated environment (preferred)
[ERROR] 2. Change scope to "user" to install globally via ~/.claude.json
[ERROR] 3. Change scope to "local" to install in project-specific .mcp.json
[ERROR] 4. Change scope to "project" to install in shared project .mcp.json
The validation walks config.get('mcp-servers', []) and matches BOTH the string form (scope: profile) AND the list form (scope: [user, profile]) -- a combined [user, profile] scope without command-names also triggers the error because the profile portion of the list has no launcher target. Error output is written to sys.stderr (not stdout).
command-defaults.system-prompt in Non-Command-Names Mode (WARNING)
System prompts are applied by the launcher via --system-prompt or --append-system-prompt CLI flags. Without command-names, there is no launcher, so the prompt file cannot be passed to Claude at runtime. The setup script emits a non-fatal WARNING (setup continues):
[WARN] command-defaults.system-prompt is set to 'prompts/my-prompt.md' but command-names is not specified.
[WARN] System prompts are applied by the launcher; without command-names there is no launcher,
[WARN] so the system prompt will NOT be applied.
[WARN] Add 'command-names: [your-name]' to enable isolated environment with launcher-based system prompt injection.
Unlike profile-scoped MCP servers (which are a hard error because silently-dropped servers are a correctness risk), a silently-unused system prompt file is merely a configuration mistake -- a warning is sufficient. Warning output is written to stdout.
Four-Writer Architectural Model Summary
| Writer | YAML Source | Target | Key Universe | Semantics | Step |
|---|---|---|---|---|---|
write_user_settings() | user-settings: | ~/.claude/settings.json (non-isolated only) | All non-excluded settings.json keys | Deep merge + universal array union at every depth + RFC 7396 null-as-delete | 14 |
write_global_config() | global-config: | ~/.claude.json (+ ~/.claude/{cmd}/.claude.json isolated) | Free-form ~/.claude.json keys | Deep merge + universal array union at every depth + RFC 7396 null-as-delete | 15 |
create_profile_config() | user-settings: + status-line/hooks | ~/.claude/{cmd}/config.json | user-settings content + 2 PROFILE_OWNED_KEYS | Atomic overwrite (fresh dict each run, nulls stripped, fully toolbox-owned) | 18 (isolated) |
write_profile_settings_to_settings() | status-line/hooks (YAML root) | ~/.claude/settings.json | 2 PROFILE_OWNED_KEYS delta | Deep merge + universal array union at every depth + RFC 7396 null-as-delete | 18 (non-isolated) |
The two shared-file merge writers (write_user_settings() and write_global_config()) plus write_profile_settings_to_settings() delegate to the same _write_merged_json() helper with array_union_keys=None (the default), which gives them a single unified universal deep-merge contract: every list at every depth is unioned with structural dedupe, matching Claude Code CLI's cross-scope merge semantics ("arrays are concatenated and deduplicated, not replaced"). Both Step 18 writers (create_profile_config() and write_profile_settings_to_settings()) are fed by the shared pure builder _build_profile_settings(), which accepts a profile_config dict for the status-line/hooks keys and delegates to _build_hooks_json() for hook events. In isolated mode, create_profile_config() atomically rewrites ~/.claude/{cmd}/config.json from scratch on each run (fully toolbox-owned), merging the user-settings content with the built profile-owned entries and stripping null-valued members. In non-isolated mode, user-settings reaches ~/.claude/settings.json through write_user_settings() at Step 14, and write_profile_settings_to_settings() deep-merges the status-line/hooks delta into the same file at Step 18, preserving contributions from other writers and accumulating list elements additively across runs.
YAML inheritance layer is separate. The per-path whitelist mechanism in deep_merge_settings() (DEFAULT_ARRAY_UNION_KEYS and explicit set[str] arguments) applies only to the YAML composition layer (_resolve_single_key call sites): array_union_keys=set() for global-config inheritance (child replaces parent for arrays) and array_union_keys=DEFAULT_ARRAY_UNION_KEYS for user-settings inheritance (union only for permissions.allow/deny/ask, replace for other arrays). On-disk shared-file writers are decoupled from YAML composition by intent; they always use the universal default.
Complete Annotated Example
A realistic configuration demonstrating most keys:
# Python Development Environment Configuration
name: "Python Development"
version: "1.0.0"
description: |
Full-featured Python environment with linting, type checking,
and AI-powered MCP servers pre-configured.
post-install-notes: |
Next steps:
1. Run: claude-python
2. Try: /help to see available commands
command-names:
- "claude-python" # Primary command name
- "pydev" # Alias
base-url: "https://raw.githubusercontent.com/myorg/my-configs/main"
# Install Node.js for MCP servers that need npx
install-nodejs: true
# Share session history between this isolated profile and the default Claude
link-projects-dir: true
# Platform-specific dependencies
dependencies:
common:
- "uv tool install ruff"
- "uv tool install ty"
windows:
- "winget install --id Git.Git --scope machine --accept-package-agreements --accept-source-agreements"
macos:
- "brew install shellcheck"
linux:
- "sudo apt-get install -y shellcheck"
# Agent for code review
agents:
- "agents/python-reviewer.md"
# Custom slash commands
slash-commands:
- "commands/lint.md"
- "commands/test.md"
# User-scope rules (placed in ~/.claude/rules/)
rules:
- "rules/coding-standards.md"
# Skills
skills:
- name: "python-best-practices"
base: "skills/"
files:
- "SKILL.md"
- "python-patterns.md"
# MCP servers
mcp-servers:
- name: "context-server"
transport: "http"
url: "http://localhost:8000/mcp"
scope: "user"
- name: "code-search"
command: "npx @example/code-search-mcp"
scope: "profile"
# OS-level persistent environment variables
os-env-variables:
PYTHONDONTWRITEBYTECODE: "1"
# System prompt
command-defaults:
system-prompt: "prompts/python-system-prompt.md"
mode: "append"
# User settings -- raw settings.json content (camelCase keys)
user-settings:
# Use Opus model with maximum effort
model: "opus"
effortLevel: "max"
alwaysThinkingEnabled: true
language: "english"
# Permissions
permissions:
defaultMode: "default"
allow:
- "Read"
- "Glob"
- "Grep"
deny:
- "Bash(rm -rf)"
# Claude-level environment variables
env:
PROJECT_TYPE: "python"
COVERAGE_THRESHOLD: "80"
# Company announcements
companyAnnouncements:
- "Welcome to the Python development environment!"
- "Run /lint to check your code"
# Attribution
attribution:
commit: "Co-authored-by: Claude AI"
pr: "" # Hide PR attribution
# Global config -- raw ~/.claude.json content (camelCase keys)
global-config:
autoConnectIde: true
showTurnDuration: true
# Hooks for code quality and safety
hooks:
files:
- "hooks/python-linter.py"
- "configs/linter-config.yaml"
events:
- event: "PostToolUse"
matcher: "Edit|MultiEdit|Write"
type: "command"
command: "python-linter.py"
config: "linter-config.yaml"
- event: "PreToolUse"
matcher: "Bash"
type: "prompt"
prompt: "Check if this bash command is safe to execute"
timeout: 30
Environment Variables Reference
Configuration
| Variable | Purpose | Example |
|---|---|---|
CLAUDE_CODE_TOOLBOX_ENV_CONFIG | Configuration source (URL, path, or name) | python, ./my.yaml, https://... |
Workflow Control and Behavior
| Variable | Purpose | Accepted Values |
|---|---|---|
CLAUDE_CODE_TOOLBOX_CONFIRM_INSTALL | Auto-confirm installation (--yes) | Exact value 1 only |
CLAUDE_CODE_TOOLBOX_DRY_RUN | Preview installation plan (--dry-run) | Exact value 1 only |
CLAUDE_CODE_TOOLBOX_SKIP_INSTALL | Skip Claude Code installation (--skip-install) | Exact value 1 only |
CLAUDE_CODE_TOOLBOX_NO_ADMIN | Skip Windows admin elevation (--no-admin) | Exact value 1 only |
CLAUDE_CODE_TOOLBOX_ALLOW_ROOT | Allow running as root on Linux/macOS | Exact value 1 only |
CLAUDE_CODE_TOOLBOX_DEBUG | Enable verbose debug logging | 1, true, or yes |
CLAUDE_CODE_TOOLBOX_PARALLEL_WORKERS | Override concurrent download workers | Integer (default: 2) |
CLAUDE_CODE_TOOLBOX_SEQUENTIAL_MODE | Disable parallel downloads | 1, true, or yes |
CLAUDE_CODE_TOOLBOX_GIT_BASH_PATH | Override Git Bash executable path (Windows) | Path to bash.exe |
Authentication
| Variable | Scope | Purpose |
|---|---|---|
GITHUB_TOKEN | Python-level (auto-detected from URL) | GitHub PAT with repo scope |
GITLAB_TOKEN | Python-level (auto-detected from URL) | GitLab PAT with read_repository scope |
REPO_TOKEN | Shell-level (passed as --auth) | Generic token, auto-detects repo type |
CLAUDE_CODE_TOOLBOX_ENV_AUTH | Shell-level (passed as --auth) | Custom header: Header-Name:token-value |
CLI Flags and Environment Variable Equivalents
| Flag | Environment Variable | Purpose |
|---|---|---|
--yes / -y | CLAUDE_CODE_TOOLBOX_CONFIRM_INSTALL | Auto-confirm installation (skip interactive prompt) |
--dry-run | CLAUDE_CODE_TOOLBOX_DRY_RUN | Show installation plan and exit without installing |
--skip-install | CLAUDE_CODE_TOOLBOX_SKIP_INSTALL | Skip Claude Code installation |
--no-admin | CLAUDE_CODE_TOOLBOX_NO_ADMIN | Do not request admin elevation on Windows |
--auth | CLAUDE_CODE_TOOLBOX_ENV_AUTH | Authentication parameter: "token" or "header:value" |
CLI flags take precedence over environment variables. For piped invocations (curl | bash, iex(irm ...)), use environment variables since CLI flags cannot be passed.
Troubleshooting
No configuration specified
If you see an error about no configuration, ensure you set the CLAUDE_CODE_TOOLBOX_ENV_CONFIG variable inline with the bootstrap command. See Quick Start for examples.
Configuration not found in repository
If the named configuration is not found, verify the name matches a YAML file in the claude-code-artifacts-public repository. Browse the repository to see available configurations.
version requires command-names
The version field controls update checking via manifest.json and launcher scripts, which are only created when command-names is present. Either add command-names or remove version.
merge-keys requires inherit
The merge-keys directive controls merge semantics during inheritance resolution. Without inherit, there is no parent configuration to merge from. Either add inherit or remove merge-keys. Note: an empty merge-keys: [] without inherit is permitted.
command-defaults requires command-names
Both command-names and command-defaults must be specified together. Provide both or neither.
link-projects-dir requires command-names
The link-projects-dir flag links an isolated profile's projects/ directory to the base ~/.claude/projects/, and isolated profiles exist only when command-names is present. Either add command-names or remove link-projects-dir.
user-settings.effortLevel 'xhigh'/'max' requires a matching model
The xhigh and max effort levels require user-settings.model to be set to a supporting variant: xhigh requires an Opus or Fable model (the model name must contain opus or fable, case-insensitive) or the exact alias best; max additionally accepts a Sonnet model. The validation errors read user-settings.effortLevel '{level}' requires user-settings.model to be specified. This effort level is only available for {families}. (when model is missing) and user-settings.effortLevel '{level}' is only available for {families}, but model is set to '{model}'. Use 'low', 'medium', or 'high' for other models. (when the model is outside the required families), where {families} is "Opus and Fable models" for xhigh and "Opus, Sonnet, and Fable models" for max:
user-settings:
model: "claude-fable-5" # or "opus", "fable", "best"; "sonnet" also works for max
effortLevel: "max" # or "xhigh"
Invalid platform keys in dependencies
Use macos as the platform key:
dependencies:
macos: # Correct
- "brew install wget"
SKILL.md is required in the files list
Every skill must include SKILL.md in its files list:
skills:
- name: "my-skill"
base: "skills/"
files:
- "SKILL.md" # Required
- "other-file.md"
hooks.events command not found in hooks.files
Every command referenced in hook events must be listed in hooks.files. Ensure the filenames match exactly.
Key 'hooks' is not allowed in user-settings
The hooks and statusLine keys are profile-specific and must be configured at the root level of the YAML configuration, not inside user-settings.
Debug mode
Enable verbose logging to diagnose issues:
export CLAUDE_CODE_TOOLBOX_DEBUG=1
Permission errors on Linux/macOS
The setup refuses to run as root by default. For Docker or CI environments where root execution is necessary:
export CLAUDE_CODE_TOOLBOX_ALLOW_ROOT=1
Non-interactive mode
For automated environments where no interactive prompt is available:
Environment variable (all platforms, works in piped mode):
export CLAUDE_CODE_TOOLBOX_CONFIRM_INSTALL=1
CLI flag (direct invocation only, not piped):
./setup-environment.sh python --yes
Windows PowerShell (piped via iex):
$env:CLAUDE_CODE_TOOLBOX_CONFIRM_INSTALL='1'; $env:CLAUDE_CODE_TOOLBOX_ENV_CONFIG='python'; iex (irm 'https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/windows/setup-environment.ps1')
Linux/macOS (piped via curl):
export CLAUDE_CODE_TOOLBOX_CONFIRM_INSTALL=1
export CLAUDE_CODE_TOOLBOX_ENV_CONFIG=python
curl -fsSL https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/linux/setup-environment.sh | bash
Alternatively, on Linux/macOS you can pass flags through bash -s --:
curl -fsSL https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/linux/setup-environment.sh | bash -s -- --yes
Dry-run mode
To preview the installation plan without making any changes:
Environment variable (all platforms, works in piped mode):
export CLAUDE_CODE_TOOLBOX_DRY_RUN=1
CLI flag (direct invocation only, not piped):
./setup-environment.sh python --dry-run
Windows PowerShell (piped via iex):
$env:CLAUDE_CODE_TOOLBOX_DRY_RUN='1'; $env:CLAUDE_CODE_TOOLBOX_ENV_CONFIG='python'; iex (irm 'https://raw.githubusercontent.com/alex-feel/claude-code-toolbox/main/scripts/windows/setup-environment.ps1')
Skip Claude Code installation
To skip the Claude Code installation step (useful when Claude Code is already installed):
Environment variable (all platforms, works in piped mode):
export CLAUDE_CODE_TOOLBOX_SKIP_INSTALL=1
CLI flag (direct invocation only, not piped):
./setup-environment.sh python --skip-install
Skip admin elevation (Windows)
To prevent the setup from requesting Windows admin elevation:
Environment variable (all platforms, works in piped mode):
export CLAUDE_CODE_TOOLBOX_NO_ADMIN=1
CLI flag (direct invocation only, not piped):
./setup-environment.sh python --no-admin
Security Considerations
Trust Levels
Configuration sources have different trust levels:
- Repository configs (from claude-code-artifacts-public) -- Community-reviewed configurations
- Local files -- Under your direct control. May contain API keys and other sensitive data.
- Remote URLs -- The setup displays a warning when loading from remote URLs. Always verify the source before proceeding.
Sensitive Path Detection
Destinations in files-to-download are checked against sensitive path prefixes (for example, ~/.ssh/, ~/.bashrc). Sensitive paths are flagged with [!] in the installation summary so you can review them before confirming.
Token Handling
Never commit authentication tokens to repositories. Use environment variables (GITHUB_TOKEN, GITLAB_TOKEN, REPO_TOKEN) instead.
Protected Configuration Keys
The global-config key blocks non-null oauthAccount values to prevent OAuth credentials from appearing in YAML configuration files. Setting oauthAccount: null is allowed to support clearing authentication state (useful for account switching and auth recovery).
Installation Confirmation
By default, the setup requires explicit confirmation before installing. Use --dry-run to preview the installation plan without making changes. Unknown configuration keys are flagged with [?] in the installation summary to help you identify potential typos or unsupported keys.