import type { Command } from "commander"; import JSON5 from "json5"; import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { formatConfigIssueLines, normalizeConfigIssues } from "../config/issue-format.js"; import { CONFIG_PATH } from "../config/paths.js"; import { isBlockedObjectKey } from "../config/prototype-keys.js"; import { redactConfigObject } from "../config/redact-snapshot.js"; import { danger, info, success } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; import { shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; type PathSegment = string; type ConfigSetParseOpts = { strictJson?: boolean; }; const OLLAMA_API_KEY_PATH: PathSegment[] = ["models", "providers", "ollama", "apiKey"]; const OLLAMA_PROVIDER_PATH: PathSegment[] = ["models", "providers", "ollama"]; const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434"; function isIndexSegment(raw: string): boolean { return /^[0-9]+$/.test(raw); } function parsePath(raw: string): PathSegment[] { const trimmed = raw.trim(); if (!trimmed) { return []; } const parts: string[] = []; let current = ""; let i = 0; while (i < trimmed.length) { const ch = trimmed[i]; if (ch === "\\") { const next = trimmed[i + 1]; if (next) { current += next; } i += 2; continue; } if (ch === ".") { if (current) { parts.push(current); } current = ""; i += 1; continue; } if (ch === "[") { if (current) { parts.push(current); } current = ""; const close = trimmed.indexOf("]", i); if (close === -1) { throw new Error(`Invalid path (missing "]"): ${raw}`); } const inside = trimmed.slice(i + 1, close).trim(); if (!inside) { throw new Error(`Invalid path (empty "[]"): ${raw}`); } parts.push(inside); i = close + 1; continue; } current += ch; i += 1; } if (current) { parts.push(current); } return parts.map((part) => part.trim()).filter(Boolean); } function parseValue(raw: string, opts: ConfigSetParseOpts): unknown { const trimmed = raw.trim(); if (opts.strictJson) { try { return JSON5.parse(trimmed); } catch (err) { throw new Error(`Failed to parse JSON5 value: ${String(err)}`, { cause: err }); } } try { return JSON5.parse(trimmed); } catch { return raw; } } function hasOwnPathKey(value: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); } function formatDoctorHint(message: string): string { return `Run \`${formatCliCommand("openclaw doctor")}\` ${message}`; } function validatePathSegments(path: PathSegment[]): void { for (const segment of path) { if (!isIndexSegment(segment) && isBlockedObjectKey(segment)) { throw new Error(`Invalid path segment: ${segment}`); } } } function getAtPath(root: unknown, path: PathSegment[]): { found: boolean; value?: unknown } { let current: unknown = root; for (const segment of path) { if (!current || typeof current !== "object") { return { found: false }; } if (Array.isArray(current)) { if (!isIndexSegment(segment)) { return { found: false }; } const index = Number.parseInt(segment, 10); if (!Number.isFinite(index) || index < 0 || index >= current.length) { return { found: false }; } current = current[index]; continue; } const record = current as Record; if (!hasOwnPathKey(record, segment)) { return { found: false }; } current = record[segment]; } return { found: true, value: current }; } function setAtPath(root: Record, path: PathSegment[], value: unknown): void { let current: unknown = root; for (let i = 0; i < path.length - 1; i += 1) { const segment = path[i]; const next = path[i + 1]; const nextIsIndex = Boolean(next && isIndexSegment(next)); if (Array.isArray(current)) { if (!isIndexSegment(segment)) { throw new Error(`Expected numeric index for array segment "${segment}"`); } const index = Number.parseInt(segment, 10); const existing = current[index]; if (!existing || typeof existing !== "object") { current[index] = nextIsIndex ? [] : {}; } current = current[index]; continue; } if (!current || typeof current !== "object") { throw new Error(`Cannot traverse into "${segment}" (not an object)`); } const record = current as Record; const existing = hasOwnPathKey(record, segment) ? record[segment] : undefined; if (!existing || typeof existing !== "object") { record[segment] = nextIsIndex ? [] : {}; } current = record[segment]; } const last = path[path.length - 1]; if (Array.isArray(current)) { if (!isIndexSegment(last)) { throw new Error(`Expected numeric index for array segment "${last}"`); } const index = Number.parseInt(last, 10); current[index] = value; return; } if (!current || typeof current !== "object") { throw new Error(`Cannot set "${last}" (parent is not an object)`); } (current as Record)[last] = value; } function unsetAtPath(root: Record, path: PathSegment[]): boolean { let current: unknown = root; for (let i = 0; i < path.length - 1; i += 1) { const segment = path[i]; if (!current || typeof current !== "object") { return false; } if (Array.isArray(current)) { if (!isIndexSegment(segment)) { return false; } const index = Number.parseInt(segment, 10); if (!Number.isFinite(index) || index < 0 || index >= current.length) { return false; } current = current[index]; continue; } const record = current as Record; if (!hasOwnPathKey(record, segment)) { return false; } current = record[segment]; } const last = path[path.length - 1]; if (Array.isArray(current)) { if (!isIndexSegment(last)) { return false; } const index = Number.parseInt(last, 10); if (!Number.isFinite(index) || index < 0 || index >= current.length) { return false; } current.splice(index, 1); return true; } if (!current || typeof current !== "object") { return false; } const record = current as Record; if (!hasOwnPathKey(record, last)) { return false; } delete record[last]; return true; } async function loadValidConfig(runtime: RuntimeEnv = defaultRuntime) { const snapshot = await readConfigFileSnapshot(); if (snapshot.valid) { return snapshot; } runtime.error(`Config invalid at ${shortenHomePath(snapshot.path)}.`); for (const line of formatConfigIssueLines(snapshot.issues, "-", { normalizeRoot: true })) { runtime.error(line); } runtime.error(formatDoctorHint("to repair, then retry.")); runtime.exit(1); return snapshot; } function parseRequiredPath(path: string): PathSegment[] { const parsedPath = parsePath(path); if (parsedPath.length === 0) { throw new Error("Path is empty."); } validatePathSegments(parsedPath); return parsedPath; } function pathEquals(path: PathSegment[], expected: PathSegment[]): boolean { return ( path.length === expected.length && path.every((segment, index) => segment === expected[index]) ); } function ensureValidOllamaProviderForApiKeySet( root: Record, path: PathSegment[], ): void { if (!pathEquals(path, OLLAMA_API_KEY_PATH)) { return; } const existing = getAtPath(root, OLLAMA_PROVIDER_PATH); if (existing.found) { return; } setAtPath(root, OLLAMA_PROVIDER_PATH, { baseUrl: OLLAMA_DEFAULT_BASE_URL, api: "ollama", models: [], }); } export async function runConfigGet(opts: { path: string; json?: boolean; runtime?: RuntimeEnv }) { const runtime = opts.runtime ?? defaultRuntime; try { const parsedPath = parseRequiredPath(opts.path); const snapshot = await loadValidConfig(runtime); const redacted = redactConfigObject(snapshot.config); const res = getAtPath(redacted, parsedPath); if (!res.found) { runtime.error(danger(`Config path not found: ${opts.path}`)); runtime.exit(1); return; } if (opts.json) { runtime.log(JSON.stringify(res.value ?? null, null, 2)); return; } if ( typeof res.value === "string" || typeof res.value === "number" || typeof res.value === "boolean" ) { runtime.log(String(res.value)); return; } runtime.log(JSON.stringify(res.value ?? null, null, 2)); } catch (err) { runtime.error(danger(String(err))); runtime.exit(1); } } export async function runConfigUnset(opts: { path: string; runtime?: RuntimeEnv }) { const runtime = opts.runtime ?? defaultRuntime; try { const parsedPath = parseRequiredPath(opts.path); const snapshot = await loadValidConfig(runtime); // Use snapshot.resolved (config after $include and ${ENV} resolution, but BEFORE runtime defaults) // instead of snapshot.config (runtime-merged with defaults). // This prevents runtime defaults from leaking into the written config file (issue #6070) const next = structuredClone(snapshot.resolved) as Record; const removed = unsetAtPath(next, parsedPath); if (!removed) { runtime.error(danger(`Config path not found: ${opts.path}`)); runtime.exit(1); return; } await writeConfigFile(next, { unsetPaths: [parsedPath] }); runtime.log(info(`Removed ${opts.path}. Restart the gateway to apply.`)); } catch (err) { runtime.error(danger(String(err))); runtime.exit(1); } } export async function runConfigFile(opts: { runtime?: RuntimeEnv }) { const runtime = opts.runtime ?? defaultRuntime; try { const snapshot = await readConfigFileSnapshot(); runtime.log(shortenHomePath(snapshot.path)); } catch (err) { runtime.error(danger(String(err))); runtime.exit(1); } } export async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv } = {}) { const runtime = opts.runtime ?? defaultRuntime; let outputPath = CONFIG_PATH ?? "openclaw.json"; try { const snapshot = await readConfigFileSnapshot(); outputPath = snapshot.path; const shortPath = shortenHomePath(outputPath); if (!snapshot.exists) { if (opts.json) { runtime.log(JSON.stringify({ valid: false, path: outputPath, error: "file not found" })); } else { runtime.error(danger(`Config file not found: ${shortPath}`)); } runtime.exit(1); return; } if (!snapshot.valid) { const issues = normalizeConfigIssues(snapshot.issues); if (opts.json) { runtime.log(JSON.stringify({ valid: false, path: outputPath, issues }, null, 2)); } else { runtime.error(danger(`Config invalid at ${shortPath}:`)); for (const line of formatConfigIssueLines(issues, danger("×"), { normalizeRoot: true })) { runtime.error(` ${line}`); } runtime.error(""); runtime.error(formatDoctorHint("to repair, or fix the keys above manually.")); } runtime.exit(1); return; } if (opts.json) { runtime.log(JSON.stringify({ valid: true, path: outputPath })); } else { runtime.log(success(`Config valid: ${shortPath}`)); } } catch (err) { if (opts.json) { runtime.log(JSON.stringify({ valid: false, path: outputPath, error: String(err) })); } else { runtime.error(danger(`Config validation error: ${String(err)}`)); } runtime.exit(1); } } export function registerConfigCli(program: Command) { const cmd = program .command("config") .description( "Non-interactive config helpers (get/set/unset/file/validate). Run without subcommand for the setup wizard.", ) .addHelpText( "after", () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/config", "docs.openclaw.ai/cli/config")}\n`, ) .option( "--section
", "Configure wizard sections (repeatable). Use with no subcommand.", (value: string, previous: string[]) => [...previous, value], [] as string[], ) .action(async (opts) => { const { configureCommandFromSectionsArg } = await import("../commands/configure.js"); await configureCommandFromSectionsArg(opts.section, defaultRuntime); }); cmd .command("get") .description("Get a config value by dot path") .argument("", "Config path (dot or bracket notation)") .option("--json", "Output JSON", false) .action(async (path: string, opts) => { await runConfigGet({ path, json: Boolean(opts.json) }); }); cmd .command("set") .description("Set a config value by dot path") .argument("", "Config path (dot or bracket notation)") .argument("", "Value (JSON5 or raw string)") .option("--strict-json", "Strict JSON5 parsing (error instead of raw string fallback)", false) .option("--json", "Legacy alias for --strict-json", false) .action(async (path: string, value: string, opts) => { try { const parsedPath = parseRequiredPath(path); const parsedValue = parseValue(value, { strictJson: Boolean(opts.strictJson || opts.json), }); const snapshot = await loadValidConfig(); // Use snapshot.resolved (config after $include and ${ENV} resolution, but BEFORE runtime defaults) // instead of snapshot.config (runtime-merged with defaults). // This prevents runtime defaults from leaking into the written config file (issue #6070) const next = structuredClone(snapshot.resolved) as Record; ensureValidOllamaProviderForApiKeySet(next, parsedPath); setAtPath(next, parsedPath, parsedValue); await writeConfigFile(next); defaultRuntime.log(info(`Updated ${path}. Restart the gateway to apply.`)); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }); cmd .command("unset") .description("Remove a config value by dot path") .argument("", "Config path (dot or bracket notation)") .action(async (path: string) => { await runConfigUnset({ path }); }); cmd .command("file") .description("Print the active config file path") .action(async () => { await runConfigFile({}); }); cmd .command("validate") .description("Validate the current config against the schema without starting the gateway") .option("--json", "Output validation result as JSON", false) .action(async (opts) => { await runConfigValidate({ json: Boolean(opts.json) }); }); }