2026-01-16 06:57:16 +00:00
|
|
|
|
import type { Command } from "commander";
|
2026-02-01 10:03:47 +09:00
|
|
|
|
import JSON5 from "json5";
|
2026-01-16 06:57:16 +00:00
|
|
|
|
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
|
2026-03-02 13:45:51 +08:00
|
|
|
|
import { CONFIG_PATH } from "../config/paths.js";
|
2026-02-22 23:51:13 -08:00
|
|
|
|
import { isBlockedObjectKey } from "../config/prototype-keys.js";
|
2026-02-22 19:37:33 -05:00
|
|
|
|
import { redactConfigObject } from "../config/redact-snapshot.js";
|
2026-03-02 13:45:51 +08:00
|
|
|
|
import { danger, info, success } from "../globals.js";
|
2026-02-18 01:34:35 +00:00
|
|
|
|
import type { RuntimeEnv } from "../runtime.js";
|
2026-01-16 06:57:16 +00:00
|
|
|
|
import { defaultRuntime } from "../runtime.js";
|
|
|
|
|
|
import { formatDocsLink } from "../terminal/links.js";
|
|
|
|
|
|
import { theme } from "../terminal/theme.js";
|
2026-01-23 03:43:32 +00:00
|
|
|
|
import { shortenHomePath } from "../utils.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
|
import { formatCliCommand } from "./command-format.js";
|
2026-01-16 06:57:16 +00:00
|
|
|
|
|
|
|
|
|
|
type PathSegment = string;
|
2026-02-20 05:09:17 +04:00
|
|
|
|
type ConfigSetParseOpts = {
|
|
|
|
|
|
strictJson?: boolean;
|
|
|
|
|
|
};
|
2026-03-02 13:45:51 +08:00
|
|
|
|
type ConfigIssue = {
|
|
|
|
|
|
path: string;
|
|
|
|
|
|
message: string;
|
|
|
|
|
|
};
|
2026-01-16 06:57:16 +00:00
|
|
|
|
|
2026-02-27 23:35:57 -08:00
|
|
|
|
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";
|
|
|
|
|
|
|
2026-01-16 06:57:16 +00:00
|
|
|
|
function isIndexSegment(raw: string): boolean {
|
|
|
|
|
|
return /^[0-9]+$/.test(raw);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function parsePath(raw: string): PathSegment[] {
|
|
|
|
|
|
const trimmed = raw.trim();
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (!trimmed) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
2026-01-16 06:57:16 +00:00
|
|
|
|
const parts: string[] = [];
|
|
|
|
|
|
let current = "";
|
|
|
|
|
|
let i = 0;
|
|
|
|
|
|
while (i < trimmed.length) {
|
|
|
|
|
|
const ch = trimmed[i];
|
|
|
|
|
|
if (ch === "\\") {
|
|
|
|
|
|
const next = trimmed[i + 1];
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (next) {
|
|
|
|
|
|
current += next;
|
|
|
|
|
|
}
|
2026-01-16 06:57:16 +00:00
|
|
|
|
i += 2;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (ch === ".") {
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (current) {
|
|
|
|
|
|
parts.push(current);
|
|
|
|
|
|
}
|
2026-01-16 06:57:16 +00:00
|
|
|
|
current = "";
|
|
|
|
|
|
i += 1;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (ch === "[") {
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (current) {
|
|
|
|
|
|
parts.push(current);
|
|
|
|
|
|
}
|
2026-01-16 06:57:16 +00:00
|
|
|
|
current = "";
|
|
|
|
|
|
const close = trimmed.indexOf("]", i);
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (close === -1) {
|
|
|
|
|
|
throw new Error(`Invalid path (missing "]"): ${raw}`);
|
|
|
|
|
|
}
|
2026-01-16 06:57:16 +00:00
|
|
|
|
const inside = trimmed.slice(i + 1, close).trim();
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (!inside) {
|
|
|
|
|
|
throw new Error(`Invalid path (empty "[]"): ${raw}`);
|
|
|
|
|
|
}
|
2026-01-16 06:57:16 +00:00
|
|
|
|
parts.push(inside);
|
|
|
|
|
|
i = close + 1;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
current += ch;
|
|
|
|
|
|
i += 1;
|
|
|
|
|
|
}
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (current) {
|
|
|
|
|
|
parts.push(current);
|
|
|
|
|
|
}
|
2026-01-16 06:57:16 +00:00
|
|
|
|
return parts.map((part) => part.trim()).filter(Boolean);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 05:09:17 +04:00
|
|
|
|
function parseValue(raw: string, opts: ConfigSetParseOpts): unknown {
|
2026-01-16 06:57:16 +00:00
|
|
|
|
const trimmed = raw.trim();
|
2026-02-20 05:09:17 +04:00
|
|
|
|
if (opts.strictJson) {
|
2026-01-16 06:57:16 +00:00
|
|
|
|
try {
|
|
|
|
|
|
return JSON5.parse(trimmed);
|
|
|
|
|
|
} catch (err) {
|
2026-01-31 16:03:28 +09:00
|
|
|
|
throw new Error(`Failed to parse JSON5 value: ${String(err)}`, { cause: err });
|
2026-01-16 06:57:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
return JSON5.parse(trimmed);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return raw;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 23:51:13 -08:00
|
|
|
|
function hasOwnPathKey(value: Record<string, unknown>, key: string): boolean {
|
|
|
|
|
|
return Object.prototype.hasOwnProperty.call(value, key);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 13:45:51 +08:00
|
|
|
|
function normalizeConfigIssues(issues: ReadonlyArray<ConfigIssue>): ConfigIssue[] {
|
|
|
|
|
|
return issues.map((issue) => ({
|
|
|
|
|
|
path: issue.path || "<root>",
|
|
|
|
|
|
message: issue.message,
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatConfigIssueLines(issues: ReadonlyArray<ConfigIssue>, marker: string): string[] {
|
|
|
|
|
|
return normalizeConfigIssues(issues).map((issue) => `${marker} ${issue.path}: ${issue.message}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatDoctorHint(message: string): string {
|
|
|
|
|
|
return `Run \`${formatCliCommand("openclaw doctor")}\` ${message}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 23:51:13 -08:00
|
|
|
|
function validatePathSegments(path: PathSegment[]): void {
|
|
|
|
|
|
for (const segment of path) {
|
|
|
|
|
|
if (!isIndexSegment(segment) && isBlockedObjectKey(segment)) {
|
|
|
|
|
|
throw new Error(`Invalid path segment: ${segment}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 06:57:16 +00:00
|
|
|
|
function getAtPath(root: unknown, path: PathSegment[]): { found: boolean; value?: unknown } {
|
|
|
|
|
|
let current: unknown = root;
|
|
|
|
|
|
for (const segment of path) {
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (!current || typeof current !== "object") {
|
|
|
|
|
|
return { found: false };
|
|
|
|
|
|
}
|
2026-01-16 06:57:16 +00:00
|
|
|
|
if (Array.isArray(current)) {
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (!isIndexSegment(segment)) {
|
|
|
|
|
|
return { found: false };
|
|
|
|
|
|
}
|
2026-01-16 06:57:16 +00:00
|
|
|
|
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<string, unknown>;
|
2026-02-22 23:51:13 -08:00
|
|
|
|
if (!hasOwnPathKey(record, segment)) {
|
2026-01-31 16:19:20 +09:00
|
|
|
|
return { found: false };
|
|
|
|
|
|
}
|
2026-01-16 06:57:16 +00:00
|
|
|
|
current = record[segment];
|
|
|
|
|
|
}
|
|
|
|
|
|
return { found: true, value: current };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setAtPath(root: Record<string, unknown>, 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<string, unknown>;
|
2026-02-22 23:51:13 -08:00
|
|
|
|
const existing = hasOwnPathKey(record, segment) ? record[segment] : undefined;
|
2026-01-16 06:57:16 +00:00
|
|
|
|
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<string, unknown>)[last] = value;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function unsetAtPath(root: Record<string, unknown>, path: PathSegment[]): boolean {
|
|
|
|
|
|
let current: unknown = root;
|
|
|
|
|
|
for (let i = 0; i < path.length - 1; i += 1) {
|
|
|
|
|
|
const segment = path[i];
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (!current || typeof current !== "object") {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-01-16 06:57:16 +00:00
|
|
|
|
if (Array.isArray(current)) {
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (!isIndexSegment(segment)) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-01-16 06:57:16 +00:00
|
|
|
|
const index = Number.parseInt(segment, 10);
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (!Number.isFinite(index) || index < 0 || index >= current.length) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-01-16 06:57:16 +00:00
|
|
|
|
current = current[index];
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
const record = current as Record<string, unknown>;
|
2026-02-22 23:51:13 -08:00
|
|
|
|
if (!hasOwnPathKey(record, segment)) {
|
2026-01-31 16:19:20 +09:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-01-16 06:57:16 +00:00
|
|
|
|
current = record[segment];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const last = path[path.length - 1];
|
|
|
|
|
|
if (Array.isArray(current)) {
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (!isIndexSegment(last)) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-01-16 06:57:16 +00:00
|
|
|
|
const index = Number.parseInt(last, 10);
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (!Number.isFinite(index) || index < 0 || index >= current.length) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-01-16 06:57:16 +00:00
|
|
|
|
current.splice(index, 1);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (!current || typeof current !== "object") {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-01-16 06:57:16 +00:00
|
|
|
|
const record = current as Record<string, unknown>;
|
2026-02-22 23:51:13 -08:00
|
|
|
|
if (!hasOwnPathKey(record, last)) {
|
2026-01-31 16:19:20 +09:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-01-16 06:57:16 +00:00
|
|
|
|
delete record[last];
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 00:27:30 +00:00
|
|
|
|
async function loadValidConfig(runtime: RuntimeEnv = defaultRuntime) {
|
2026-01-16 06:57:16 +00:00
|
|
|
|
const snapshot = await readConfigFileSnapshot();
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (snapshot.valid) {
|
|
|
|
|
|
return snapshot;
|
|
|
|
|
|
}
|
2026-02-14 00:27:30 +00:00
|
|
|
|
runtime.error(`Config invalid at ${shortenHomePath(snapshot.path)}.`);
|
2026-03-02 13:45:51 +08:00
|
|
|
|
for (const line of formatConfigIssueLines(snapshot.issues, "-")) {
|
|
|
|
|
|
runtime.error(line);
|
2026-01-16 06:57:16 +00:00
|
|
|
|
}
|
2026-03-02 13:45:51 +08:00
|
|
|
|
runtime.error(formatDoctorHint("to repair, then retry."));
|
2026-02-14 00:27:30 +00:00
|
|
|
|
runtime.exit(1);
|
2026-01-16 06:57:16 +00:00
|
|
|
|
return snapshot;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 00:27:30 +00:00
|
|
|
|
function parseRequiredPath(path: string): PathSegment[] {
|
|
|
|
|
|
const parsedPath = parsePath(path);
|
|
|
|
|
|
if (parsedPath.length === 0) {
|
|
|
|
|
|
throw new Error("Path is empty.");
|
|
|
|
|
|
}
|
2026-02-22 23:51:13 -08:00
|
|
|
|
validatePathSegments(parsedPath);
|
2026-02-14 00:27:30 +00:00
|
|
|
|
return parsedPath;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-27 23:35:57 -08:00
|
|
|
|
function pathEquals(path: PathSegment[], expected: PathSegment[]): boolean {
|
|
|
|
|
|
return (
|
|
|
|
|
|
path.length === expected.length && path.every((segment, index) => segment === expected[index])
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ensureValidOllamaProviderForApiKeySet(
|
|
|
|
|
|
root: Record<string, unknown>,
|
|
|
|
|
|
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: [],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 00:27:30 +00:00
|
|
|
|
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);
|
2026-02-22 19:37:33 -05:00
|
|
|
|
const redacted = redactConfigObject(snapshot.config);
|
|
|
|
|
|
const res = getAtPath(redacted, parsedPath);
|
2026-02-14 00:27:30 +00:00
|
|
|
|
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<string, unknown>;
|
|
|
|
|
|
const removed = unsetAtPath(next, parsedPath);
|
|
|
|
|
|
if (!removed) {
|
|
|
|
|
|
runtime.error(danger(`Config path not found: ${opts.path}`));
|
|
|
|
|
|
runtime.exit(1);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-21 21:07:50 -08:00
|
|
|
|
await writeConfigFile(next, { unsetPaths: [parsedPath] });
|
2026-02-14 00:27:30 +00:00
|
|
|
|
runtime.log(info(`Removed ${opts.path}. Restart the gateway to apply.`));
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
runtime.error(danger(String(err)));
|
|
|
|
|
|
runtime.exit(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 12:33:20 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 13:45:51 +08:00
|
|
|
|
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("×"))) {
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 06:57:16 +00:00
|
|
|
|
export function registerConfigCli(program: Command) {
|
|
|
|
|
|
const cmd = program
|
|
|
|
|
|
.command("config")
|
2026-02-16 22:06:25 +01:00
|
|
|
|
.description(
|
2026-03-02 13:45:51 +08:00
|
|
|
|
"Non-interactive config helpers (get/set/unset/file/validate). Run without subcommand for the setup wizard.",
|
2026-02-16 22:06:25 +01:00
|
|
|
|
)
|
2026-01-16 06:57:16 +00:00
|
|
|
|
.addHelpText(
|
|
|
|
|
|
"after",
|
|
|
|
|
|
() =>
|
2026-01-30 03:15:10 +01:00
|
|
|
|
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/config", "docs.openclaw.ai/cli/config")}\n`,
|
2026-01-16 06:57:16 +00:00
|
|
|
|
)
|
|
|
|
|
|
.option(
|
|
|
|
|
|
"--section <section>",
|
|
|
|
|
|
"Configure wizard sections (repeatable). Use with no subcommand.",
|
|
|
|
|
|
(value: string, previous: string[]) => [...previous, value],
|
|
|
|
|
|
[] as string[],
|
|
|
|
|
|
)
|
|
|
|
|
|
.action(async (opts) => {
|
2026-02-15 14:20:06 +00:00
|
|
|
|
const { configureCommandFromSectionsArg } = await import("../commands/configure.js");
|
|
|
|
|
|
await configureCommandFromSectionsArg(opts.section, defaultRuntime);
|
2026-01-16 06:57:16 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
cmd
|
|
|
|
|
|
.command("get")
|
|
|
|
|
|
.description("Get a config value by dot path")
|
|
|
|
|
|
.argument("<path>", "Config path (dot or bracket notation)")
|
|
|
|
|
|
.option("--json", "Output JSON", false)
|
|
|
|
|
|
.action(async (path: string, opts) => {
|
2026-02-14 00:27:30 +00:00
|
|
|
|
await runConfigGet({ path, json: Boolean(opts.json) });
|
2026-01-16 06:57:16 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
cmd
|
|
|
|
|
|
.command("set")
|
|
|
|
|
|
.description("Set a config value by dot path")
|
|
|
|
|
|
.argument("<path>", "Config path (dot or bracket notation)")
|
|
|
|
|
|
.argument("<value>", "Value (JSON5 or raw string)")
|
2026-02-20 05:09:17 +04:00
|
|
|
|
.option("--strict-json", "Strict JSON5 parsing (error instead of raw string fallback)", false)
|
|
|
|
|
|
.option("--json", "Legacy alias for --strict-json", false)
|
2026-01-16 06:57:16 +00:00
|
|
|
|
.action(async (path: string, value: string, opts) => {
|
|
|
|
|
|
try {
|
2026-02-22 23:51:13 -08:00
|
|
|
|
const parsedPath = parseRequiredPath(path);
|
2026-02-20 05:09:17 +04:00
|
|
|
|
const parsedValue = parseValue(value, {
|
|
|
|
|
|
strictJson: Boolean(opts.strictJson || opts.json),
|
|
|
|
|
|
});
|
2026-01-16 06:57:16 +00:00
|
|
|
|
const snapshot = await loadValidConfig();
|
2026-02-08 10:59:12 -03:00
|
|
|
|
// Use snapshot.resolved (config after $include and ${ENV} resolution, but BEFORE runtime defaults)
|
|
|
|
|
|
// instead of snapshot.config (runtime-merged with defaults).
|
2026-02-08 01:26:37 -03:00
|
|
|
|
// This prevents runtime defaults from leaking into the written config file (issue #6070)
|
2026-02-08 10:59:12 -03:00
|
|
|
|
const next = structuredClone(snapshot.resolved) as Record<string, unknown>;
|
2026-02-27 23:35:57 -08:00
|
|
|
|
ensureValidOllamaProviderForApiKeySet(next, parsedPath);
|
2026-01-16 06:57:16 +00:00
|
|
|
|
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("<path>", "Config path (dot or bracket notation)")
|
|
|
|
|
|
.action(async (path: string) => {
|
2026-02-14 00:27:30 +00:00
|
|
|
|
await runConfigUnset({ path });
|
2026-01-16 06:57:16 +00:00
|
|
|
|
});
|
2026-03-02 12:33:20 +08:00
|
|
|
|
|
|
|
|
|
|
cmd
|
|
|
|
|
|
.command("file")
|
|
|
|
|
|
.description("Print the active config file path")
|
|
|
|
|
|
.action(async () => {
|
|
|
|
|
|
await runConfigFile({});
|
|
|
|
|
|
});
|
2026-03-02 13:45:51 +08:00
|
|
|
|
|
|
|
|
|
|
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) });
|
|
|
|
|
|
});
|
2026-01-16 06:57:16 +00:00
|
|
|
|
}
|