JuliusBrussee/caveman · error
env must be a JSON object; refusing to overwrite it
Error message
${settingsPath} env must be a JSON object; refusing to overwrite it What it means
`claudeNativeMutations()` edits Claude's settings.json to inject Caveman env routing. Before writing, it validates that an existing `env` key is a plain JSON object; if `env` exists but is a non-object (string, number, array, null), the CLI refuses to overwrite it to avoid destroying user data. The user must fix the settings file manually.
Solutions
- Open the settings file reported in the message and change `env` to a JSON object (e.g. {} or your key/value map)
- Back up the file first: cp settings.json settings.json.bak, then fix and re-run the command
- If env holds meaningful data in a wrong shape, convert it to key/value form instead of deleting it
- Re-run `caveman setup` after fixing the file
Example fix
// before: ~/.claude/settings.json
{ "env": "ANTHROPIC_BASE_URL=https://example.com" }
// after
{ "env": { "ANTHROPIC_BASE_URL": "https://example.com" } } Defensive patterns
Strategy: type-guard
Validate before calling
// Before running setup, validate the Claude settings env shape:
const s = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
if (s.env !== undefined && (typeof s.env !== 'object' || s.env === null || Array.isArray(s.env))) {
console.error(`Fix ${settingsPath}: env must be a JSON object`);
} Type guard
function isValidEnvObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
} Try / catch
try {
await cavemanSetup(['claude']);
} catch (e) {
if (String(e.message).endsWith('env must be a JSON object; refusing to overwrite it')) {
// fix or back up settings.json manually, then retry
}
throw e;
} Prevention
- Keep `env` in Claude settings as a flat object of string key/value pairs
- Validate settings.json with a JSON schema editor plugin before saving
- Back up ~/.claude/settings.json before hand edits or tool-driven migrations
- Never hand-write env as a string; use a JSON object
When it happens
Trigger: Running a native Claude setup/mutation command when the parsed settings document has `env` defined but not a non-null plain object (`typeof settings.env !== "object" || settings.env === null || Array.isArray(settings.env)`).
Common situations: Hand-edited ~/.claude/settings.json where env was accidentally changed to a string or array; a tool or script wrote env as a flat string; corrupted settings from a failed merge; null env from a template placeholder that was never filled.
Related errors
- provider must be a JSON object; refusing to overwrite it
- mcpServers must be a JSON object; refusing to overwrite it
- mcp must be a JSON object; refusing to overwrite it
- could not install caveman-cloud MCP for
- could not remove caveman-cloud MCP for
AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20).
Data as JSON: /api/errors/f72d5a2ae2c695ac.
Report an issue: GitHub.
Appendix: source
Thrown at packages/cli/src/index.ts:7021
const invocation = portableInvocation(binary, ["--version"]);
const out = spawnSync(invocation.command, invocation.args, { encoding: "utf8", timeout: 3000 });
if (out.error) return { binary, launchable: false, version: null, error: boundedHookString(out.error.message, 240) ?? "version_probe_failed" };
const value = `${out.stdout ?? ""} ${out.stderr ?? ""}`.trim();
if (out.status !== 0) return { binary, launchable: false, version: value ? value.slice(0, 160) : null, error: `version_probe_exit_${out.status ?? "unknown"}` };
return { binary, launchable: true, version: value ? value.slice(0, 160) : null, error: null };
} catch {
return { binary, launchable: false, version: null, error: "version_probe_failed" };
}
}
function detectedAgentVersion(agent: AgentProfile): string | null { return nativeHostProbe(agent).version; }
function claudeNativeMutations(gw: string, mcpBinary: string): NativeMutation[] {
const settingsPath = claudeSettingsPath();
const settingsBefore = fileBytes(settingsPath);
const settings = parseJsonFileObject(settingsPath, settingsBefore);
if (settings.env !== undefined && (typeof settings.env !== "object" || settings.env === null || Array.isArray(settings.env))) {
throw new Error(`${settingsPath} env must be a JSON object; refusing to overwrite it`);
}
assertNativeHooksShape(settingsPath, settings, "claude");
const env = settings.env && typeof settings.env === "object" && !Array.isArray(settings.env)
? settings.env as Record<string, unknown>
: {};
const route = appendUrlPath(gw, "/w/claude");
const previousRoute = env.ANTHROPIC_BASE_URL;
env.ANTHROPIC_BASE_URL = route;
// Routing through the local proxy makes ANTHROPIC_BASE_URL non-first-party in
// Claude Code's eyes, which silently drops the 1M context / ~600k
// auto-compact window to 200k (#865). When the proxy's anthropic upstream is
// verifiably api.anthropic.com, assert first-party through Claude Code's own
// escape hatch. Never touches a value the user already carries in settings.
const assumeFirstParty = env[CLAUDE_ASSUME_FIRST_PARTY_ENV] === undefined
&& wrapMode(gw) === "local" && proxyAnthropicUpstreamIsFirstParty();
if (assumeFirstParty) env[CLAUDE_ASSUME_FIRST_PARTY_ENV] = "1";
// Native routing points every later turn at the same loopback gateway, so it
// needs the wrap path's proxy exemption too (#1001). Never clobber a value theView on GitHub (pinned to 3ee70a1026)