From 8b579c91a5556e167b948c6a7b00cd5ee878c890 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 03:00:24 +0100 Subject: [PATCH] feat: add /config chat config updates --- CHANGELOG.md | 1 + docs/cli/index.md | 1 + docs/tools/slash-commands.md | 18 +++ src/auto-reply/commands-registry.ts | 7 + src/auto-reply/reply/commands.ts | 130 +++++++++++++++++++ src/auto-reply/reply/config-commands.test.ts | 23 ++++ src/auto-reply/reply/config-commands.ts | 62 +++++++++ src/auto-reply/reply/config-value.ts | 37 ++++++ src/auto-reply/reply/debug-commands.ts | 40 +----- src/auto-reply/status.ts | 2 +- src/config/config-paths.test.ts | 28 ++++ src/config/config-paths.ts | 88 +++++++++++++ src/config/runtime-overrides.ts | 92 ++++--------- 13 files changed, 421 insertions(+), 108 deletions(-) create mode 100644 src/auto-reply/reply/config-commands.test.ts create mode 100644 src/auto-reply/reply/config-commands.ts create mode 100644 src/auto-reply/reply/config-value.ts create mode 100644 src/config/config-paths.test.ts create mode 100644 src/config/config-paths.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c6eadeb07..d86aebd39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,6 +138,7 @@ - Onboarding: clarify WhatsApp owner number prompt and label pairing phone number. - Auto-reply: normalize routed replies to drop NO_REPLY and apply response prefixes. - Commands: add /debug for runtime config overrides (memory-only). +- Commands: add /config to persist config changes from chat (owner-only). - Daemon runtime: remove Bun from selection options. - CLI: restore hidden `gateway-daemon` alias for legacy launchd configs. - Onboarding/Configure: add OpenAI API key flow that stores in shared `~/.clawdbot/.env` for launchd; simplify Anthropic token prompt order. diff --git a/docs/cli/index.md b/docs/cli/index.md index a2cbb59e3..177234287 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -153,6 +153,7 @@ Chat messages support `/...` commands (text and native). See [/tools/slash-comma Highlights: - `/status` for quick diagnostics. +- `/config` for persisted config changes. - `/debug` for runtime-only config overrides (memory, not disk). ## Setup + onboarding diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 3c5c19aae..855cda85c 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -39,6 +39,7 @@ Text + native (when enabled): - `/status` - `/status` (show current status; includes a short usage line when available) - `/usage` (alias: `/status`) +- `/config show|set|unset` (persist config to disk, owner-only) - `/debug show|set|unset|reset` (runtime overrides, owner-only) - `/cost on|off` (toggle per-response usage line) - `/stop` @@ -82,6 +83,23 @@ Notes: - Overrides apply immediately to new config reads, but do **not** write to `clawdbot.json`. - Use `/debug reset` to clear all overrides and return to the on-disk config. +## Config updates + +`/config` writes to your on-disk config (`clawdbot.json`). Owner-only. + +Examples: + +``` +/config show +/config show messages.responsePrefix +/config set messages.responsePrefix="[clawdbot]" +/config unset messages.responsePrefix +``` + +Notes: +- Config is validated before write; invalid changes are rejected. +- `/config` updates persist across restarts. + ## Surface notes - **Text commands** run in the normal chat session (DMs share `main`, groups have their own session). diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 6078e85c4..10c1e8367 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -136,6 +136,13 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { description: "Show current status.", textAlias: "/status", }), + defineChatCommand({ + key: "config", + nativeName: "config", + description: "Show or set config values.", + textAlias: "/config", + acceptsArgs: true, + }), defineChatCommand({ key: "debug", nativeName: "debug", diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 3cd6a060f..525b284ec 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -20,6 +20,17 @@ import { waitForEmbeddedPiRunEnd, } from "../../agents/pi-embedded.js"; import type { ClawdbotConfig } from "../../config/config.js"; +import { + readConfigFileSnapshot, + validateConfigObject, + writeConfigFile, +} from "../../config/config.js"; +import { + getConfigValueAtPath, + parseConfigPath, + setConfigValueAtPath, + unsetConfigValueAtPath, +} from "../../config/config-paths.js"; import { getConfigOverrides, resetConfigOverrides, @@ -72,6 +83,7 @@ import type { } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; import { isAbortTrigger, setAbortMemory } from "./abort.js"; +import { parseConfigCommand } from "./config-commands.js"; import { parseDebugCommand } from "./debug-commands.js"; import type { InlineDirectives } from "./directive-handling.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; @@ -628,6 +640,124 @@ export async function handleCommands(params: { return { shouldContinue: false, reply }; } + const configCommand = allowTextCommands + ? parseConfigCommand(command.commandBodyNormalized) + : null; + if (configCommand) { + if (!command.isAuthorizedSender) { + logVerbose( + `Ignoring /config from unauthorized sender: ${command.senderE164 || ""}`, + ); + return { shouldContinue: false }; + } + if (configCommand.action === "error") { + return { + shouldContinue: false, + reply: { text: `⚠️ ${configCommand.message}` }, + }; + } + const snapshot = await readConfigFileSnapshot(); + if (!snapshot.valid || !snapshot.parsed || typeof snapshot.parsed !== "object") { + return { + shouldContinue: false, + reply: { text: "⚠️ Config file is invalid; fix it before using /config." }, + }; + } + const parsedBase = structuredClone( + snapshot.parsed as Record, + ); + + if (configCommand.action === "show") { + const pathRaw = configCommand.path?.trim(); + if (pathRaw) { + const parsedPath = parseConfigPath(pathRaw); + if (!parsedPath.ok || !parsedPath.path) { + return { + shouldContinue: false, + reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` }, + }; + } + const value = getConfigValueAtPath(parsedBase, parsedPath.path); + const rendered = JSON.stringify(value ?? null, null, 2); + return { + shouldContinue: false, + reply: { + text: `⚙️ Config ${pathRaw}:\n\`\`\`json\n${rendered}\n\`\`\``, + }, + }; + } + const json = JSON.stringify(parsedBase, null, 2); + return { + shouldContinue: false, + reply: { text: `⚙️ Config (raw):\n\`\`\`json\n${json}\n\`\`\`` }, + }; + } + + if (configCommand.action === "unset") { + const parsedPath = parseConfigPath(configCommand.path); + if (!parsedPath.ok || !parsedPath.path) { + return { + shouldContinue: false, + reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` }, + }; + } + const removed = unsetConfigValueAtPath(parsedBase, parsedPath.path); + if (!removed) { + return { + shouldContinue: false, + reply: { text: `⚙️ No config value found for ${configCommand.path}.` }, + }; + } + const validated = validateConfigObject(parsedBase); + if (!validated.ok) { + const issue = validated.issues[0]; + return { + shouldContinue: false, + reply: { + text: `⚠️ Config invalid after unset (${issue.path}: ${issue.message}).`, + }, + }; + } + await writeConfigFile(validated.config); + return { + shouldContinue: false, + reply: { text: `⚙️ Config updated: ${configCommand.path} removed.` }, + }; + } + + if (configCommand.action === "set") { + const parsedPath = parseConfigPath(configCommand.path); + if (!parsedPath.ok || !parsedPath.path) { + return { + shouldContinue: false, + reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` }, + }; + } + setConfigValueAtPath(parsedBase, parsedPath.path, configCommand.value); + const validated = validateConfigObject(parsedBase); + if (!validated.ok) { + const issue = validated.issues[0]; + return { + shouldContinue: false, + reply: { + text: `⚠️ Config invalid after set (${issue.path}: ${issue.message}).`, + }, + }; + } + await writeConfigFile(validated.config); + const valueLabel = + typeof configCommand.value === "string" + ? `"${configCommand.value}"` + : JSON.stringify(configCommand.value); + return { + shouldContinue: false, + reply: { + text: `⚙️ Config updated: ${configCommand.path}=${valueLabel ?? "null"}`, + }, + }; + } + } + const debugCommand = allowTextCommands ? parseDebugCommand(command.commandBodyNormalized) : null; diff --git a/src/auto-reply/reply/config-commands.test.ts b/src/auto-reply/reply/config-commands.test.ts new file mode 100644 index 000000000..ca35d3365 --- /dev/null +++ b/src/auto-reply/reply/config-commands.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { parseConfigCommand } from "./config-commands.js"; + +describe("parseConfigCommand", () => { + it("parses show/unset", () => { + expect(parseConfigCommand("/config")).toEqual({ action: "show" }); + expect(parseConfigCommand("/config show")).toEqual({ action: "show", path: undefined }); + expect(parseConfigCommand("/config show foo.bar")).toEqual({ + action: "show", + path: "foo.bar", + }); + expect(parseConfigCommand("/config unset foo.bar")).toEqual({ + action: "unset", + path: "foo.bar", + }); + }); + + it("parses set with JSON", () => { + const cmd = parseConfigCommand('/config set foo={"a":1}'); + expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } }); + }); +}); diff --git a/src/auto-reply/reply/config-commands.ts b/src/auto-reply/reply/config-commands.ts new file mode 100644 index 000000000..22defd83c --- /dev/null +++ b/src/auto-reply/reply/config-commands.ts @@ -0,0 +1,62 @@ +import { parseConfigValue } from "./config-value.js"; + +export type ConfigCommand = + | { action: "show"; path?: string } + | { action: "set"; path: string; value: unknown } + | { action: "unset"; path: string } + | { action: "error"; message: string }; + +export function parseConfigCommand(raw: string): ConfigCommand | null { + const trimmed = raw.trim(); + if (!trimmed.toLowerCase().startsWith("/config")) return null; + const rest = trimmed.slice("/config".length).trim(); + if (!rest) return { action: "show" }; + + const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/); + if (!match) return { action: "error", message: "Invalid /config syntax." }; + const action = match[1].toLowerCase(); + const args = (match[2] ?? "").trim(); + + switch (action) { + case "show": + return { action: "show", path: args || undefined }; + case "unset": { + if (!args) + return { action: "error", message: "Usage: /config unset path" }; + return { action: "unset", path: args }; + } + case "set": { + if (!args) { + return { + action: "error", + message: "Usage: /config set path=value", + }; + } + const eqIndex = args.indexOf("="); + if (eqIndex <= 0) { + return { + action: "error", + message: "Usage: /config set path=value", + }; + } + const path = args.slice(0, eqIndex).trim(); + const rawValue = args.slice(eqIndex + 1); + if (!path) { + return { + action: "error", + message: "Usage: /config set path=value", + }; + } + const parsed = parseConfigValue(rawValue); + if (parsed.error) { + return { action: "error", message: parsed.error }; + } + return { action: "set", path, value: parsed.value }; + } + default: + return { + action: "error", + message: "Usage: /config show|set|unset", + }; + } +} diff --git a/src/auto-reply/reply/config-value.ts b/src/auto-reply/reply/config-value.ts new file mode 100644 index 000000000..0c3037975 --- /dev/null +++ b/src/auto-reply/reply/config-value.ts @@ -0,0 +1,37 @@ +export function parseConfigValue( + raw: string, +): { value?: unknown; error?: string } { + const trimmed = raw.trim(); + if (!trimmed) return { error: "Missing value." }; + + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return { value: JSON.parse(trimmed) }; + } catch (err) { + return { error: `Invalid JSON: ${String(err)}` }; + } + } + + if (trimmed === "true") return { value: true }; + if (trimmed === "false") return { value: false }; + if (trimmed === "null") return { value: null }; + + if (/^-?\d+(\.\d+)?$/.test(trimmed)) { + const num = Number(trimmed); + if (Number.isFinite(num)) return { value: num }; + } + + if ( + (trimmed.startsWith("\"") && trimmed.endsWith("\"")) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + try { + return { value: JSON.parse(trimmed) }; + } catch { + const unquoted = trimmed.slice(1, -1); + return { value: unquoted }; + } + } + + return { value: trimmed }; +} diff --git a/src/auto-reply/reply/debug-commands.ts b/src/auto-reply/reply/debug-commands.ts index e8104fae4..9c9ecde46 100644 --- a/src/auto-reply/reply/debug-commands.ts +++ b/src/auto-reply/reply/debug-commands.ts @@ -1,3 +1,5 @@ +import { parseConfigValue } from "./config-value.js"; + export type DebugCommand = | { action: "show" } | { action: "reset" } @@ -5,42 +7,6 @@ export type DebugCommand = | { action: "unset"; path: string } | { action: "error"; message: string }; -function parseDebugValue(raw: string): { value?: unknown; error?: string } { - const trimmed = raw.trim(); - if (!trimmed) return { error: "Missing value." }; - - if (trimmed.startsWith("{") || trimmed.startsWith("[")) { - try { - return { value: JSON.parse(trimmed) }; - } catch (err) { - return { error: `Invalid JSON: ${String(err)}` }; - } - } - - if (trimmed === "true") return { value: true }; - if (trimmed === "false") return { value: false }; - if (trimmed === "null") return { value: null }; - - if (/^-?\d+(\.\d+)?$/.test(trimmed)) { - const num = Number(trimmed); - if (Number.isFinite(num)) return { value: num }; - } - - if ( - (trimmed.startsWith('"') && trimmed.endsWith('"')) || - (trimmed.startsWith("'") && trimmed.endsWith("'")) - ) { - try { - return { value: JSON.parse(trimmed) }; - } catch { - const unquoted = trimmed.slice(1, -1); - return { value: unquoted }; - } - } - - return { value: trimmed }; -} - export function parseDebugCommand(raw: string): DebugCommand | null { const trimmed = raw.trim(); if (!trimmed.toLowerCase().startsWith("/debug")) return null; @@ -84,7 +50,7 @@ export function parseDebugCommand(raw: string): DebugCommand | null { message: "Usage: /debug set path=value", }; } - const parsed = parseDebugValue(rawValue); + const parsed = parseConfigValue(rawValue); if (parsed.error) { return { action: "error", message: parsed.error }; } diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 6b9345274..d3c7e93c0 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -360,7 +360,7 @@ export function buildHelpMessage(): string { return [ "ℹ️ Help", "Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)", - "Options: /think | /verbose on|off | /reasoning on|off | /elevated on|off | /model | /cost on|off | /debug show", + "Options: /think | /verbose on|off | /reasoning on|off | /elevated on|off | /model | /cost on|off | /config show | /debug show", "More: /commands for all slash commands", ].join("\n"); } diff --git a/src/config/config-paths.test.ts b/src/config/config-paths.test.ts new file mode 100644 index 000000000..9effad799 --- /dev/null +++ b/src/config/config-paths.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { + getConfigValueAtPath, + parseConfigPath, + setConfigValueAtPath, + unsetConfigValueAtPath, +} from "./config-paths.js"; + +describe("config paths", () => { + it("rejects empty and blocked paths", () => { + expect(parseConfigPath("")).toEqual({ + ok: false, + error: "Invalid path. Use dot notation (e.g. foo.bar).", + }); + expect(parseConfigPath("__proto__.polluted").ok).toBe(false); + }); + + it("sets, gets, and unsets nested values", () => { + const root: Record = {}; + const parsed = parseConfigPath("foo.bar"); + if (!parsed.ok || !parsed.path) throw new Error("path parse failed"); + setConfigValueAtPath(root, parsed.path, 123); + expect(getConfigValueAtPath(root, parsed.path)).toBe(123); + expect(unsetConfigValueAtPath(root, parsed.path)).toBe(true); + expect(getConfigValueAtPath(root, parsed.path)).toBeUndefined(); + }); +}); diff --git a/src/config/config-paths.ts b/src/config/config-paths.ts new file mode 100644 index 000000000..73eebb698 --- /dev/null +++ b/src/config/config-paths.ts @@ -0,0 +1,88 @@ +type PathNode = Record; + +const BLOCKED_KEYS = new Set(["__proto__", "prototype", "constructor"]); + +export function parseConfigPath(raw: string): { + ok: boolean; + path?: string[]; + error?: string; +} { + const trimmed = raw.trim(); + if (!trimmed) { + return { ok: false, error: "Invalid path. Use dot notation (e.g. foo.bar)." }; + } + const parts = trimmed.split(".").map((part) => part.trim()); + if (parts.some((part) => !part)) { + return { ok: false, error: "Invalid path. Use dot notation (e.g. foo.bar)." }; + } + if (parts.some((part) => BLOCKED_KEYS.has(part))) { + return { ok: false, error: "Invalid path segment." }; + } + return { ok: true, path: parts }; +} + +export function setConfigValueAtPath( + root: PathNode, + path: string[], + value: unknown, +): void { + let cursor: PathNode = root; + for (let idx = 0; idx < path.length - 1; idx += 1) { + const key = path[idx]; + const next = cursor[key]; + if (!isPlainObject(next)) { + cursor[key] = {}; + } + cursor = cursor[key] as PathNode; + } + cursor[path[path.length - 1]] = value; +} + +export function unsetConfigValueAtPath( + root: PathNode, + path: string[], +): boolean { + const stack: Array<{ node: PathNode; key: string }> = []; + let cursor: PathNode = root; + for (let idx = 0; idx < path.length - 1; idx += 1) { + const key = path[idx]; + const next = cursor[key]; + if (!isPlainObject(next)) return false; + stack.push({ node: cursor, key }); + cursor = next; + } + const leafKey = path[path.length - 1]; + if (!(leafKey in cursor)) return false; + delete cursor[leafKey]; + for (let idx = stack.length - 1; idx >= 0; idx -= 1) { + const { node, key } = stack[idx]; + const child = node[key]; + if (isPlainObject(child) && Object.keys(child).length === 0) { + delete node[key]; + } else { + break; + } + } + return true; +} + +export function getConfigValueAtPath( + root: PathNode, + path: string[], +): unknown { + let cursor: unknown = root; + for (const key of path) { + if (!isPlainObject(cursor)) return undefined; + cursor = cursor[key]; + } + return cursor; +} + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.prototype.toString.call(value) === "[object Object]" + ); +} diff --git a/src/config/runtime-overrides.ts b/src/config/runtime-overrides.ts index 98a3081a0..347c589c2 100644 --- a/src/config/runtime-overrides.ts +++ b/src/config/runtime-overrides.ts @@ -1,68 +1,14 @@ import type { ClawdbotConfig } from "./types.js"; +import { + parseConfigPath, + setConfigValueAtPath, + unsetConfigValueAtPath, +} from "./config-paths.js"; type OverrideTree = Record; let overrides: OverrideTree = {}; -function isPlainObject(value: unknown): value is Record { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.prototype.toString.call(value) === "[object Object]" - ); -} - -function parsePath(raw: string): string[] | null { - const trimmed = raw.trim(); - if (!trimmed) return null; - const parts = trimmed.split(".").map((part) => part.trim()); - if (parts.some((part) => !part)) return null; - return parts; -} - -function setOverrideAtPath( - root: OverrideTree, - path: string[], - value: unknown, -): void { - let cursor: OverrideTree = root; - for (let idx = 0; idx < path.length - 1; idx += 1) { - const key = path[idx]; - const next = cursor[key]; - if (!isPlainObject(next)) { - cursor[key] = {}; - } - cursor = cursor[key] as OverrideTree; - } - cursor[path[path.length - 1]] = value; -} - -function unsetOverrideAtPath(root: OverrideTree, path: string[]): boolean { - const stack: Array<{ node: OverrideTree; key: string }> = []; - let cursor: OverrideTree = root; - for (let idx = 0; idx < path.length - 1; idx += 1) { - const key = path[idx]; - const next = cursor[key]; - if (!isPlainObject(next)) return false; - stack.push({ node: cursor, key }); - cursor = next; - } - const leafKey = path[path.length - 1]; - if (!(leafKey in cursor)) return false; - delete cursor[leafKey]; - for (let idx = stack.length - 1; idx >= 0; idx -= 1) { - const { node, key } = stack[idx]; - const child = node[key]; - if (isPlainObject(child) && Object.keys(child).length === 0) { - delete node[key]; - } else { - break; - } - } - return true; -} - function mergeOverrides(base: unknown, override: unknown): unknown { if (!isPlainObject(base) || !isPlainObject(override)) return override; const next: OverrideTree = { ...base }; @@ -73,6 +19,15 @@ function mergeOverrides(base: unknown, override: unknown): unknown { return next; } +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.prototype.toString.call(value) === "[object Object]" + ); +} + export function getConfigOverrides(): OverrideTree { return overrides; } @@ -88,14 +43,11 @@ export function setConfigOverride( ok: boolean; error?: string; } { - const path = parsePath(pathRaw); - if (!path) { - return { - ok: false, - error: "Invalid path. Use dot notation (e.g. foo.bar).", - }; + const parsed = parseConfigPath(pathRaw); + if (!parsed.ok || !parsed.path) { + return { ok: false, error: parsed.error ?? "Invalid path." }; } - setOverrideAtPath(overrides, path, value); + setConfigValueAtPath(overrides, parsed.path, value); return { ok: true }; } @@ -104,11 +56,11 @@ export function unsetConfigOverride(pathRaw: string): { removed: boolean; error?: string; } { - const path = parsePath(pathRaw); - if (!path) { - return { ok: false, removed: false, error: "Invalid path." }; + const parsed = parseConfigPath(pathRaw); + if (!parsed.ok || !parsed.path) { + return { ok: false, removed: false, error: parsed.error ?? "Invalid path." }; } - const removed = unsetOverrideAtPath(overrides, path); + const removed = unsetConfigValueAtPath(overrides, parsed.path); return { ok: true, removed }; }