diff --git a/docs/cli/config.md b/docs/cli/config.md new file mode 100644 index 000000000..4e9045ba8 --- /dev/null +++ b/docs/cli/config.md @@ -0,0 +1,41 @@ +--- +summary: "CLI reference for `clawdbot config` (get/set/unset config values)" +read_when: + - You want to read or edit config non-interactively +--- + +# `clawdbot config` + +Config helpers: get/set/unset values by path. Run without a subcommand to open +the configure wizard (same as `clawdbot configure`). + +## Examples + +```bash +clawdbot config get browser.executablePath +clawdbot config set browser.executablePath "/usr/bin/google-chrome" +clawdbot config set agents.defaults.heartbeat.every "2h" +clawdbot config unset tools.web.search.apiKey +``` + +## Paths + +Paths use dot or bracket notation: + +```bash +clawdbot config get agents.defaults.workspace +clawdbot config get agents.list[0].id +``` + +## Values + +Values are parsed as JSON5 when possible; otherwise they are treated as strings. +Use `--json` to require JSON5 parsing. + +```bash +clawdbot config set agents.defaults.heartbeat.every "0m" +clawdbot config set gateway.port 19001 --json +clawdbot config set channels.whatsapp.groups '["*"]' --json +``` + +Restart the gateway after edits. diff --git a/docs/cli/configure.md b/docs/cli/configure.md index 9679a5e4b..f16421ec1 100644 --- a/docs/cli/configure.md +++ b/docs/cli/configure.md @@ -1,15 +1,19 @@ --- -summary: "CLI reference for `clawdbot configure` / `clawdbot config` (interactive configuration prompts)" +summary: "CLI reference for `clawdbot configure` (interactive configuration prompts)" read_when: - You want to tweak credentials, devices, or agent defaults interactively --- -# `clawdbot configure` (alias: `config`) +# `clawdbot configure` Interactive prompt to set up credentials, devices, and agent defaults. +Tip: `clawdbot config` without a subcommand opens the same wizard. Use +`clawdbot config get|set|unset` for non-interactive edits. + Related: - Gateway configuration reference: [Configuration](/gateway/configuration) +- Config CLI: [Config](/cli/config) Notes: - Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need. diff --git a/docs/cli/index.md b/docs/cli/index.md index 4c9bf67b6..e03257b08 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -13,7 +13,8 @@ This page describes the current CLI behavior. If commands change, update this do - [`setup`](/cli/setup) - [`onboard`](/cli/onboard) -- [`configure`](/cli/configure) (alias: `config`) +- [`configure`](/cli/configure) +- [`config`](/cli/config) - [`doctor`](/cli/doctor) - [`dashboard`](/cli/dashboard) - [`reset`](/cli/reset) @@ -83,7 +84,11 @@ Palette source of truth: `src/terminal/palette.ts` (aka “lobster seam”). clawdbot [--dev] [--profile ] setup onboard - configure (alias: config) + configure + config + get + set + unset doctor security audit @@ -310,9 +315,18 @@ Options: - `--node-manager ` (pnpm recommended; bun not recommended for Gateway runtime) - `--json` -### `configure` / `config` +### `configure` Interactive configuration wizard (models, channels, skills, gateway). +### `config` +Non-interactive config helpers (get/set/unset). Running `clawdbot config` with no +subcommand launches the wizard. + +Subcommands: +- `config get `: print a config value (dot/bracket path). +- `config set `: set a value (JSON5 or raw string). +- `config unset `: remove a value. + ### `doctor` Health checks + quick fixes (config + gateway + legacy services). diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts new file mode 100644 index 000000000..c23c7b65e --- /dev/null +++ b/src/cli/config-cli.ts @@ -0,0 +1,297 @@ +import JSON5 from "json5"; +import type { Command } from "commander"; + +import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; +import { danger, info } from "../globals.js"; +import { defaultRuntime } from "../runtime.js"; +import { formatDocsLink } from "../terminal/links.js"; +import { theme } from "../terminal/theme.js"; + +type PathSegment = string; + +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: { json?: boolean }): unknown { + const trimmed = raw.trim(); + if (opts.json) { + try { + return JSON5.parse(trimmed); + } catch (err) { + throw new Error(`Failed to parse JSON5 value: ${String(err)}`); + } + } + + try { + return JSON5.parse(trimmed); + } catch { + return raw; + } +} + +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 (!(segment in record)) 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 = record[segment]; + 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 (!(segment in record)) 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 (!(last in record)) return false; + delete record[last]; + return true; +} + +async function loadValidConfig() { + const snapshot = await readConfigFileSnapshot(); + if (snapshot.valid) return snapshot; + defaultRuntime.error(`Config invalid at ${snapshot.path}.`); + for (const issue of snapshot.issues) { + defaultRuntime.error(`- ${issue.path || ""}: ${issue.message}`); + } + defaultRuntime.error("Run `clawdbot doctor` to repair, then retry."); + defaultRuntime.exit(1); + return snapshot; +} + +export function registerConfigCli(program: Command) { + const cmd = program + .command("config") + .description("Config helpers (get/set/unset). Run without subcommand for the wizard.") + .addHelpText( + "after", + () => + `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/config", "docs.clawd.bot/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 { CONFIGURE_WIZARD_SECTIONS, configureCommand, configureCommandWithSections } = + await import("../commands/configure.js"); + const sections: string[] = Array.isArray(opts.section) + ? opts.section + .map((value: unknown) => (typeof value === "string" ? value.trim() : "")) + .filter(Boolean) + : []; + if (sections.length === 0) { + await configureCommand(defaultRuntime); + return; + } + + const invalid = sections.filter((s) => !CONFIGURE_WIZARD_SECTIONS.includes(s as never)); + if (invalid.length > 0) { + defaultRuntime.error( + `Invalid --section: ${invalid.join(", ")}. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}.`, + ); + defaultRuntime.exit(1); + return; + } + + await configureCommandWithSections(sections as never, 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) => { + try { + const parsedPath = parsePath(path); + if (parsedPath.length === 0) { + throw new Error("Path is empty."); + } + const snapshot = await loadValidConfig(); + const res = getAtPath(snapshot.config, parsedPath); + if (!res.found) { + defaultRuntime.error(danger(`Config path not found: ${path}`)); + defaultRuntime.exit(1); + return; + } + if (opts.json) { + defaultRuntime.log(JSON.stringify(res.value ?? null, null, 2)); + return; + } + if (typeof res.value === "string" || typeof res.value === "number" || typeof res.value === "boolean") { + defaultRuntime.log(String(res.value)); + return; + } + defaultRuntime.log(JSON.stringify(res.value ?? null, null, 2)); + } catch (err) { + defaultRuntime.error(danger(String(err))); + defaultRuntime.exit(1); + } + }); + + 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("--json", "Parse value as JSON5 (required)", false) + .action(async (path: string, value: string, opts) => { + try { + const parsedPath = parsePath(path); + if (parsedPath.length === 0) throw new Error("Path is empty."); + const parsedValue = parseValue(value, opts); + const snapshot = await loadValidConfig(); + const next = snapshot.config as Record; + 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) => { + try { + const parsedPath = parsePath(path); + if (parsedPath.length === 0) throw new Error("Path is empty."); + const snapshot = await loadValidConfig(); + const next = snapshot.config as Record; + const removed = unsetAtPath(next, parsedPath); + if (!removed) { + defaultRuntime.error(danger(`Config path not found: ${path}`)); + defaultRuntime.exit(1); + return; + } + await writeConfigFile(next); + defaultRuntime.log(info(`Removed ${path}. Restart the gateway to apply.`)); + } catch (err) { + defaultRuntime.error(danger(String(err))); + defaultRuntime.exit(1); + } + }); +} diff --git a/src/cli/program/build-program.ts b/src/cli/program/build-program.ts index 4e9ced7b1..cf8b06bf8 100644 --- a/src/cli/program/build-program.ts +++ b/src/cli/program/build-program.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { registerBrowserCli } from "../browser-cli.js"; +import { registerConfigCli } from "../config-cli.js"; import { createProgramContext } from "./context.js"; import { configureProgramHelp } from "./help.js"; import { registerPreActionHooks } from "./preaction.js"; @@ -22,6 +23,7 @@ export function buildProgram() { registerSetupCommand(program); registerOnboardCommand(program); registerConfigureCommand(program); + registerConfigCli(program); registerMaintenanceCommands(program); registerMessageCommands(program, ctx); registerAgentCommands(program, { diff --git a/src/cli/program/register.configure.ts b/src/cli/program/register.configure.ts index 1856e8b95..0a6d6fd4b 100644 --- a/src/cli/program/register.configure.ts +++ b/src/cli/program/register.configure.ts @@ -9,54 +9,45 @@ import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; export function registerConfigureCommand(program: Command) { - const register = (name: "configure" | "config") => { - program - .command(name) - .description( - name === "config" - ? "Alias for `clawdbot configure`" - : "Interactive prompt to set up credentials, devices, and agent defaults", - ) - .addHelpText( - "after", - () => - `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/configure", "docs.clawd.bot/cli/configure")}\n`, - ) - .option( - "--section
", - `Configuration sections (repeatable). Options: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}`, - (value: string, previous: string[]) => [...previous, value], - [] as string[], - ) - .action(async (opts) => { - try { - const sections: string[] = Array.isArray(opts.section) - ? opts.section - .map((value: unknown) => (typeof value === "string" ? value.trim() : "")) - .filter(Boolean) - : []; - if (sections.length === 0) { - await configureCommand(defaultRuntime); - return; - } - - const invalid = sections.filter((s) => !CONFIGURE_WIZARD_SECTIONS.includes(s as never)); - if (invalid.length > 0) { - defaultRuntime.error( - `Invalid --section: ${invalid.join(", ")}. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}.`, - ); - defaultRuntime.exit(1); - return; - } - - await configureCommandWithSections(sections as never, defaultRuntime); - } catch (err) { - defaultRuntime.error(String(err)); - defaultRuntime.exit(1); + program + .command("configure") + .description("Interactive prompt to set up credentials, devices, and agent defaults") + .addHelpText( + "after", + () => + `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/configure", "docs.clawd.bot/cli/configure")}\n`, + ) + .option( + "--section
", + `Configuration sections (repeatable). Options: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}`, + (value: string, previous: string[]) => [...previous, value], + [] as string[], + ) + .action(async (opts) => { + try { + const sections: string[] = Array.isArray(opts.section) + ? opts.section + .map((value: unknown) => (typeof value === "string" ? value.trim() : "")) + .filter(Boolean) + : []; + if (sections.length === 0) { + await configureCommand(defaultRuntime); + return; } - }); - }; - register("configure"); - register("config"); + const invalid = sections.filter((s) => !CONFIGURE_WIZARD_SECTIONS.includes(s as never)); + if (invalid.length > 0) { + defaultRuntime.error( + `Invalid --section: ${invalid.join(", ")}. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}.`, + ); + defaultRuntime.exit(1); + return; + } + + await configureCommandWithSections(sections as never, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); }