Updated: 2026-04-25 Scope: How Crab resolves the effective
Configfrom files, environment variables, and CLI flags. Covers source precedence, merge semantics, file formats, and the separation between the persistedfilelayer and the ephemeralruntimelayer.
Crab uses a two-layer configuration model:
runtime layer (in-memory, not persisted)
env (non-secret) < CLI flag (field-level)
|
v
file layer (persisted, file-driven)
defaults < plugin < user < project < local < --config <file>
Why two layers. The file layer expresses the user's persisted intent (editable, diffable, exportable); the runtime layer is a transient override for a single invocation (env injection, CLI flag). They are loaded independently and merged only at resolve time, ensuring runtime overrides never leak into persisted files.
| # | Layer | Source | Path / Form | Format |
|---|---|---|---|---|
| 1 | file | defaults | Compiled into the binary | Rust code |
| 2 | file | plugin | $CRAB_CONFIG_DIR/plugins/<name>/config.json (every enabled plugin, merged in alphabetical order of <name>) |
JSON |
| 3 | file | user | $CRAB_CONFIG_DIR/config.toml (defaults to ~/.crab/config.toml) |
TOML |
| 4 | file | project | $PWD/.crab/config.toml (intended to be committed) |
TOML |
| 5 | file | local | $PWD/.crab/config.local.toml (auto-added to .gitignore) |
TOML |
| 6 | file | --config <path> |
Whole-file injection from CLI | TOML |
| 7 | runtime | env (non-secret) | CRAB_MODEL, CRAB_API_PROVIDER, CRAB_BASE_URL / ANTHROPIC_BASE_URL / OPENAI_BASE_URL / DEEPSEEK_BASE_URL (mutually exclusive β highest wins) |
OS env β partial Value |
| 8 | runtime | CLI flag | --model, --permission-mode, -c key.path=value |
clap β partial Value |
- Gating: the
enabledPluginsfield in the mainconfig.tomldetermines which plugins'config.jsonfiles get loaded. Schema (aligned with CCBtypes.ts:566-574):enabledPlugins: HashMap<String, Value> // key format: "plugin-id@marketplace-id" // (e.g., "superpowers@claude-plugins-official") // value variants: true (enabled) / false (explicitly disabled) / Vec<String> (version constraints) // An explicit `false` is equivalent to omission; it exists only to override a // plugin that would otherwise be enabled by default (e.g., a bundled plugin). - Load timing: at process startup, every enabled plugin's
config.jsonis loaded once and merged into the file layer. Skill/command/subagent invocations never re-trigger config merging. "Plugin enabled" (contributes config) and "plugin used" (runs skill/wasm) are independent events. - Format: JSON β same format as
plugin.jsonmanifest. Everything under a plugin directory is machine-distributed and not hand-edited, so JSON is the appropriate format. The loader parses each file asserde_json::Value, then converts totoml::Valueto join the main merge chain. - Order: plugins merged alphabetically by
<name>. This differs from CCB's registration-order behavior β alphabetical order is more deterministic. - Optional: a plugin is not required to ship a
config.json. Most existing CCB plugins contribute settings via code registration; Crab plugins use a static file because WASM/Skill plugins cannot cleanly call host-side registration APIs. - Security: plugins cannot set the
envfield viaconfig.json(this would let them silently inject secrets or proxies). The schema enforces a reduced subset for plugin contributions. - First version stance: the directory layout borrows from Anthropic's plugin convention (
plugins/<name>/plugin.json+skills/+commands/+agents/) so Crab can consume the Anthropic plugin repo. Theconfig.jsoncontribution file is Crab's own addition.
Loader root. CRAB_CONFIG_DIR overrides the user-level root (defaults to ~/.crab/). It is not a configuration layer β it only relocates source 2 and the auth/ directory. Useful for containers, integration tests, and multi-identity setups.
Project directory lookup. Crab only looks at $PWD/.crab/; it does not walk up to the git root. This keeps each setting's origin predictable. If monorepo support becomes necessary later, it can be added behind an opt-in flag.
The following values never participate in the file/runtime merge chain, never appear in the resolved Config, are never printed by crab config show, and are never diffed against other layers.
| Kind | Location | Purpose |
|---|---|---|
| OAuth tokens + subscription info | $CRAB_CONFIG_DIR/auth/tokens.json (mode 0600) |
Written during OAuth flow; machine-written and machine-read, hence JSON. The auth/ directory is reserved for future per-provider credential files |
| Secret env vars | ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, OPENAI_API_KEY, DEEPSEEK_API_KEY, CRAB_API_KEY |
Read directly from OS environment by the auth module at request time |
| System keychain / credential manager | macOS Keychain, Windows Credential Manager (future), Linux Secret Service (future) | Optional secure storage for API keys |
apiKeyHelper script output |
Script path declared in config.toml; stdout consumed as the key |
The path is configuration; the returned secret never enters Config |
The chain is provider-aware β only env vars semantically tied to the active provider are consulted, with CRAB_API_KEY as a universal override:
CRAB_API_KEY (universal, any provider)
β [provider = anthropic | unset] ANTHROPIC_AUTH_TOKEN β ANTHROPIC_API_KEY
β [provider = openai] OPENAI_API_KEY
β [provider = deepseek] DEEPSEEK_API_KEY β OPENAI_API_KEY
β cfg.api_key (config-stored direct key; lower priority than env)
β apiKeyHelper script (config-declared path; stdout consumed)
β system keychain
β auth/tokens.json (OAuth, per provider)
β error
Critical invariants:
CRAB_API_KEYis the universal escape hatch β set it when you want crab to use one key regardless of provider routing.ANTHROPIC_AUTH_TOKENnever flows to non-anthropic providers. CCB users routinely have it set in their shell environment; without provider gating, configuringprovider = "deepseek"inconfig.tomlwould silently leak the Anthropic token to deepseek's endpoint and produce a 401.
This chain is orthogonal to the file-layer merge chain. A user may configure apiKeyHelper in config.toml, but the secret it returns never round-trips through Config.
All merging happens at the toml::Value layer before deserialization. Following the OpenAI Codex CLI approach, a single recursive function defines the semantics for every layer:
fn merge_toml_values(base: &mut Value, overlay: Value) {
match (base, overlay) {
(Table(b), Table(o)) => {
for (k, v) in o {
merge_toml_values(b.entry(k).or_insert(Value::Null), v);
}
}
(Array(b), Array(o)) => {
b.extend(o);
dedup_preserving_order(b);
}
(b @ Value::Null, o) => *b = o,
(b, o) => *b = o,
}
}| Field kind | Behavior |
|---|---|
Table / object (permissions, hooks, mcpServers, β¦) |
Deep merge β recurse into nested keys |
Array (permissions.allow, additionalDirectories, β¦) |
Concatenate + deduplicate (insertion order preserved) |
| Scalar (string, number, bool) | Later wins |
Value::Null in overlay |
Skipped (does not clear an existing value) |
The concat+dedup rule ensures that, for example, permissions.allow accumulates entries across user/project/local layers instead of each layer replacing the previous one wholesale.
After permissions.allow and permissions.deny have been merged (concat+dedup), tool-invocation authorization follows this order (aligned with CCB permissions.ts:1169-1297):
- Check deny first: if any deny rule matches, return denial immediately β subsequent allow rules are never evaluated.
- Check allow: a match grants permission.
- No match: fall through to
permission-mode(plan/acceptEdits/ask/dontAsk/bypassPermissions).
Within a single rule category (allow/deny/ask), source precedence is:
user < project < local < policy < cliArg < command < session
Within a single source, tool-level rules (no content qualifier, e.g. Bash) are matched before content-level rules (with pattern, e.g. Bash(git:*)).
Invariant: no allow rule can ever override a deny rule. If a user denies a tool in ~/.crab/config.toml, no project-level, local-level, or CLI-level allow can bypass it. This is a security boundary.
mcpServers is a table keyed by server name; it follows the general deep-merge rule, meaning fields inside the same-named server are merged recursively rather than replaced wholesale.
# ~/.crab/config.toml (user layer)
[mcpServers.github]
url = "https://mcp.github.com"
# $PWD/.crab/config.toml (project layer)
[mcpServers.github]
auth = "oauth"Merge result:
[mcpServers.github]
url = "https://mcp.github.com"
auth = "oauth"When the same field appears in multiple layers (e.g., both set url), later source wins β the project layer wins over the user layer. Array fields (such as args) follow concat+dedup.
pub fn resolve() -> Result<Config> {
// file layer: accumulate from lowest to highest priority
let mut config_value = defaults_as_value();
// plugin layer: merge each enabled plugin's config.json in alphabetical order.
// JSON β toml::Value conversion happens inside enabled_plugin_configs().
for plugin_cfg in enabled_plugin_configs()? {
merge_toml_values(&mut config_value, plugin_cfg);
}
for source in [user, project, local, cli_config_file] {
if let Some(v) = source.load()? {
merge_toml_values(&mut config_value, v);
}
}
// runtime layer: env first, then CLI flags
let mut runtime_value = Value::Table(Default::default());
merge_toml_values(&mut runtime_value, env_to_value()?);
merge_toml_values(&mut runtime_value, cli_flags_to_value()?);
// cross-layer: runtime overrides file
merge_toml_values(&mut config_value, runtime_value);
// single deserialization into the business struct
Ok(config_value.try_into()?)
}Business code consumes a flat Config; it does not know which layer any given field came from. Observability tooling (e.g. crab config show --source) can track per-key origin in an auxiliary map during the merge β the hot path still produces a single value.
Not every CLI flag belongs in the runtime layer. Crab classifies flags by semantics:
| Flag pattern | Example | Treated as |
|---|---|---|
| Whole-file injection | --config /path/to.toml |
file layer, position 6 (above local) |
| Field override | --model opus, --permission-mode acceptEdits |
runtime layer, mapped to the corresponding Value path |
| Dotted override | -c permissions.allow='["Bash(git:*)"]' |
runtime layer, parsed as TOML and inserted at the nested path |
| Loader control | --config-dir <dir>, --cwd <dir> |
Not merged β changes which files are loaded |
The dotted override form mirrors codex -c key.path=value and lets users tweak any field without writing a full config file. Values are parsed as TOML first and fall back to strings.
The Config struct carries an env: HashMap<String, String> field intended to mirror Claude Code's behavior:
# ~/.crab/config.toml
[env]
ANTHROPIC_BASE_URL = "https://proxy.corp.internal/api"
HTTP_PROXY = "http://proxy:8080"Intended semantics. The env field declares environment variables to inject into child processes Crab spawns (such as MCP server stdio children), and to set on Crab's own process at startup. It is not a business field read by the agent β it is a small startup-script fragment expressed as data.
Current implementation status. As of this writing the field is parsed and validated but runtime injection is not wired up β the workspace forbids unsafe_code, which Rust 2024 requires for std::env::set_var. The honest contract today: putting values under [env] does not make them visible to the auth resolver or to child processes; the field is reserved for forward-compatibility. To inject env vars in the meantime, set them in the parent shell.
Plugin-layer security constraint. Plugin config.json files are forbidden from setting an env field (plugin_loader.rs::reject_forbidden_keys), even before runtime injection lands β so plugins cannot quietly hijack secrets or proxy targets even in future.
What is still removed. Any direct secret field on Config (e.g., the historical apiKey: Option<String>) is removed. The settings-level hook into authentication is the apiKeyHelper pointer; secret env vars (CRAB_API_KEY, ANTHROPIC_AUTH_TOKEN, OPENAI_API_KEY, DEEPSEEK_API_KEY, ANTHROPIC_API_KEY) are read by the auth module from the process environment, not from Config.
| File | Format | Rationale |
|---|---|---|
config.toml, config.local.toml |
TOML | Human-edited, supports comments, aligns with -c key=value grammar, matches codex |
auth/tokens.json |
JSON | Machine-written (OAuth flow) and machine-read; no comments needed; discourages hand-editing; compact |
plugins/<name>/plugin.json, plugins/<name>/config.json |
JSON | Everything under a plugin directory is machine-distributed and not hand-edited; keeping it the same format as the manifest is consistent |
This asymmetry is deliberate. TOML's ergonomics are valuable for files users edit; they are irrelevant for credential blobs and plugin artifacts the agent never asks humans to open.
Real today (consumed by current code):
$CRAB_CONFIG_DIR/ (default: ~/.crab/)
config.toml # user layer (chain position 3)
auth/
tokens.json # OAuth token (independent, 0600)
sessions/ # conversation history (used by session crate)
agents/ # user-level subagent definitions (used by agent crate)
logs/ # local-only telemetry (used by telemetry crate)
plugins/ # installed plugins (Anthropic-compatible layout)
<plugin-name>/
plugin.json # plugin manifest (identity, version, capabilities)
config.json # plugin's contributed defaults (optional, chain position 2, JSON)
skills/ # plugin-provided skills (markdown)
commands/ # plugin-provided slash commands
agents/ # plugin-provided subagents
$PWD/.crab/
config.toml # project layer (chain position 4, committed)
config.local.toml # local layer (chain position 5, gitignored)
$PWD/
AGENTS.md # project-level memory (committed; CLAUDE.md
# also recognized for CC migrators)
AGENTS.local.md # per-checkout private memory (gitignored;
# appended after AGENTS.md when present)
Caches live in the OS-standard per-user cache directory (Linux: $XDG_CACHE_HOME/crab/ or ~/.cache/crab/; macOS: ~/Library/Caches/crab/; Windows: %LOCALAPPDATA%\crab\cache\). They are not under $CRAB_CONFIG_DIR because the cache is safe to delete and configuration is not.
Reading the merge chain from bottom to top:
runtime layer (this invocation only)
+------------------------------+
| CLI flag (--model, -c x=y) | runtime highest
| env (CRAB_*, non-secret) |
+------------------------------+
^
file layer (persisted)
+--------------------------------------+
| --config <file> | file highest
| .crab/config.local.toml |
| .crab/config.toml |
| ~/.crab/config.toml |
| plugins/<name>/config.json (alpha) |
| defaults (compiled-in) | lowest overall
+--------------------------------------+
Within-layer rule: later source wins; tables deep-merge; arrays concat+deduplicate.
Cross-layer rule: runtime overrides file.
Out-of-chain: auth credentials, secret env vars, keychain entries, and apiKeyHelper output are read by the auth module independently and never appear in the merged Config.
Crab's philosophy aligns with CCB: fall back gracefully, never crash on malformed config. Bad data is logged as a warning; the user gets a running tool instead of a dead one.
| Failure | Behavior | Reference (CCB) |
|---|---|---|
config.toml has a TOML/JSON parse error |
That source returns nothing (treated as empty). Warning logged. Subsequent layers unaffected. | settings.ts:213, json.ts:31-40 |
| A single invalid permission rule | Only the bad rule is filtered out; the rest of that layer is applied. | validation.ts:224-265 |
A plugin's config.json is unreadable or fails schema |
That plugin is skipped with a warning; other plugins continue to load. | (same pattern as above) |
| Schema validation fails on a specific field | The field is discarded with a warning; unrelated valid fields in the same file are retained. | β |
No crash, no hard error on startup. The crab process always starts with at least the compiled-in defaults, even if every file on disk is corrupted.
- On a fresh system (no
~/.crab/config.toml), Crab runs with the compiled-in defaults. - Crab does not auto-create any config file and does not prompt the user to create one.
- The
~/.crab/directory andconfig.tomlare created only when the user first runs a mutating command βcrab config set β¦,crab auth login, etc. - This matches CCB (
envUtils.ts:10,settings.ts:309-317): new installs cost the user zero actions until they actually want to change something.
Crab uses the same path convention on all platforms (aligned with CCB envUtils.ts:7-14):
$CRAB_CONFIG_DIR ?? join(homedir(), ".crab")
- Linux / macOS:
~/.crab/ - Windows:
C:\Users\<Name>\.crab\(fromhomedir(), which returns%USERPROFILE%β not%APPDATA%)
Rationale: cross-platform consistency. Users migrating between machines or OSes find their config in the same relative location. CRAB_CONFIG_DIR overrides everywhere.
Two project-local files are intentionally per-checkout and must never be committed. Crab keeps them out of git automatically:
| File | Trigger |
|---|---|
.crab/config.local.toml |
First write via crab config set --local |
AGENTS.local.md |
First time the file is observed during memory loading |
The check is two-layered for both (aligned with CCB gitignore.ts:62-83):
- Run
git check-ignore <path>β if Git already ignores the file (via any.gitignorein the repo, or via a global~/.config/git/ignorerule), skip. - Inspect the global gitignore for a matching entry (
**/config.local.tomlor**/AGENTS.local.md); if present, skip.
Only when both checks pass does Crab append /.crab/config.local.toml or /AGENTS.local.md to the project's .gitignore. Repeated triggers are idempotent.
Crab can mutate persisted config via crab config set <key> <value> (and analogous commands). The mechanism mirrors CCB's updateSettingsForSource() (settings.ts:416-524) with one upgrade.
| Aspect | Behavior |
|---|---|
| Writable layers | user, project, local |
| Non-writable layers | defaults, plugin, --config (ephemeral), runtime (env/flag) |
| Target layer selection | --global β user; --local β local; default β project |
| Comment preservation | Yes, via toml_edit β original comments, key order, and formatting are preserved through round-trip. This is strictly better than CCB, whose JSON serializer discards comments. |
| Directory creation | .crab/ created on demand if absent |
.gitignore hook |
Triggered automatically when first writing config.local.toml (see 10.4) |
| Post-write validation | The resulting file is re-parsed and re-validated against the schema; if validation fails, the write is rolled back and an error is surfaced β this prevents leaving a broken config on disk |
Secret fields rejected at write time. crab config set apiKey β¦ (or any path the schema marks as secret-adjacent) is rejected. Secrets must go through the auth module or [env], never directly into a persisted Config field.
- Asset path:
crates/config/assets/config.schema.json - Maintenance: hand-written, with schemars as an optional scaffolder. Humans maintain
description,examples,default,pattern, etc. For large structural changes, runningcargo run --example gen-schema > .raw(seecrates/config/examples/gen-schema.rs, which depends on#[derive(JsonSchema)]onConfig) produces a mechanical skeleton that a human then ports into the real schema file. The.rawfile is not committed. Not part of CI, not auto-committed. - Embedding:
include_str!("../assets/config.schema.json")at compile time. - Distribution: committed to git; external tools (IDEs, CI, third-party validators) can reference it directly.
- Defaults: every leaf field in the schema carries the JSON Schema
defaultkeyword, kept in sync withConfig::default(). Rust is the runtime source of truth; the schema'sdefaultserves as documentation and IDE hints only (jsonschemacrate does not automatically apply defaults, only validates). Dynamic defaults (e.g.,env::current_dir(), platform detection) are not declared in the schema β Rust computes them. Drift is caught by therust_defaults_match_schema_defaultstest (see 12.5).
Reasons for hand-written over auto-generated:
- Schema changes infrequently (only when fields are added/removed); manual cost is below tooling cost.
- Hand-written schemas deliver better
description/enum/examples/defaultquality thanschemarsderive. - Schema is a user-facing contract and should live independently of the Rust struct's internal structure.
- Matches Rust CLI ecosystem convention (helix, alacritty, wrangler, etc.).
use jsonschema::JSONSchema;
const SCHEMA: &str = include_str!("../assets/config.schema.json");
fn load_and_validate(path: &Path) -> Result<Config> {
// 1. Parse TOML
let raw: toml::Value = toml::from_str(&fs::read_to_string(path)?)?;
// 2. Convert to serde_json::Value (accepted by the jsonschema crate)
let json: serde_json::Value = serde_json::to_value(&raw)?;
// 3. Validate against schema; on failure, emit errors with JSON Pointer paths
let schema: serde_json::Value = serde_json::from_str(SCHEMA)?;
let compiled = JSONSchema::compile(&schema)?;
if let Err(errors) = compiled.validate(&json) {
bail!(format_errors(errors, path));
}
// 4. Deserialize only after validation passes
Ok(raw.try_into()?)
}| Source | Catches |
|---|---|
serde deserialization |
Type mismatches, missing required fields, unknown fields (in strict mode) |
jsonschema validation |
Illegal enum values, pattern mismatches, array length constraints, numeric ranges, compound constraints (oneOf/allOf) |
Example: permissions.allow = ["Bash(git:*)"] passes serde (it's just a String), but the schema's pattern constraint rejects malformed rules like permissions.allow = ["Bash git:*"].
| Crate | Status | Recommendation |
|---|---|---|
jsonschema |
Mature; supports draft 7 / 2019-09 / 2020-12 | Preferred |
boon |
Newer, reportedly faster | Alternative |
valico |
Maintenance inactive | Not recommended |
No automated CI diff is needed. A handful of representative config.toml examples live under crates/config/tests/fixtures/; tests require them to pass the schema:
#[test]
fn example_configs_conform_to_schema() {
for path in glob("tests/fixtures/config_examples/*.toml") {
load_and_validate(&path).expect("example config must pass schema");
}
}
#[test]
fn rust_defaults_match_schema_defaults() {
let from_rust = serde_json::to_value(&Config::default()).unwrap();
let from_schema = extract_defaults_from_schema(SCHEMA);
// Every `default` declared in the schema must equal Rust's default value.
// Rust may have "derived" default fields that the schema does not declare
// (unidirectional consistency).
assert_schema_defaults_match(&from_rust, &from_schema);
}When new fields are added, the fixtures and schema defaults are updated in the same commit β any oversight is caught by these tests.
Add a pragma at the top of config.toml (taplo, VSCode's Even Better TOML extension, helix, and others recognize it):
#:schema https://raw.githubusercontent.com/<org>/crab/main/crates/config/assets/config.schema.json
provider = "anthropic"
model = "claude-opus-4-7"For local-only installs, point at a local path:
#:schema ~/.crab/config.schema.json(Crab may copy the schema to ~/.crab/config.schema.json on crab auth login or first run as a convenience.)
- Two explicit layers, not a flat N-source chain. Persisted intent and ephemeral overrides have different lifecycles; merging them separately makes the model honest.
- Merge at the
toml::Valuelayer, not the struct layer. Adding a field requires no merge-logic change; array concat+dedup is defined once. - Codex-style
merge_toml_valuesinstead offigment/config-rs. Crab's required semantics (array concat+dedup, runtime/file split) would need custom providers and customizers in those frameworks anyway; a 20-line recursive function is smaller and more transparent. - Secrets never enter
Config. The only settings-level paths into authentication are the indirectenvchannel and theapiKeyHelperpointer; direct secret fields are rejected at write time. - TOML for human-edited files, JSON for machine-written files. Format follows the reader, not uniformity.
CRAB_CONFIG_DIRsupported from day one. Enables containers, tests, and multi-identity scenarios without layer-rule changes.- Hand-written schema, not auto-generated. Higher quality, low churn, matches ecosystem convention; drift is caught by fixture tests.
- Plugin layer borrows the Anthropic directory layout. First version follows CCB's convention (
plugins/<name>/plugin.json+skills/+commands/+agents/), enabling direct consumption of the Anthropic plugin repo. Theconfig.jsoncontribution channel is Crab's own addition because Crab's WASM/Skill plugin model cannot register settings via host-side code the way CCB's TypeScript plugins can. - Plugin directories use JSON; the main chain uses TOML. Plugin content is machine-distributed, so JSON (consistent with
plugin.json) is the right format; the main chain is human-edited, so TOML wins. At load time, plugin JSON is converted totoml::Valueto join the main merge chain. Plugins merge in alphabetical order (more deterministic than CCB's registration order) and cannot set[env](security constraint). - The schema is both a contract and a defaults document.
Config::default()is the runtime source; the schema'sdefaultkeyword on every leaf field lets IDEs, documentation, and users see the same defaults β drift is guarded by therust_defaults_match_schema_defaultstest. Dynamic defaults stay Rust-only; they are not declared in the schema. - Graceful-degradation error handling. Malformed files, bad plugin configs, and schema-invalid fields are logged and skipped, not fatal. The user always gets a running tool, even with everything on disk corrupted. Aligns with CCB.
- Permissions: deny always wins. No allow rule from any source, at any level, can override a deny. This is a non-negotiable security boundary.
- Zero-config first run. Nothing is created on disk until the user first mutates config. No auto-generated templates, no prompts.
~/.crab/on all platforms. Windows does not follow%APPDATA%convention; consistency with Linux/macOS wins.CRAB_CONFIG_DIRoverrides for edge cases.- Write-back preserves TOML comments. Via
toml_edit, Crab retains user-authored comments and key order across mutations β strictly better than CCB's JSON-serializer round-trip which drops them.