feat: add /debug runtime overrides
This commit is contained in:
@@ -68,6 +68,7 @@
|
|||||||
- Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker).
|
- Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker).
|
||||||
- Onboarding: clarify WhatsApp owner number prompt and label pairing phone number.
|
- Onboarding: clarify WhatsApp owner number prompt and label pairing phone number.
|
||||||
- Auto-reply: normalize routed replies to drop NO_REPLY and apply response prefixes.
|
- Auto-reply: normalize routed replies to drop NO_REPLY and apply response prefixes.
|
||||||
|
- Commands: add /debug for runtime config overrides (memory-only).
|
||||||
- Daemon runtime: remove Bun from selection options.
|
- Daemon runtime: remove Bun from selection options.
|
||||||
- CLI: restore hidden `gateway-daemon` alias for legacy launchd configs.
|
- 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.
|
- Onboarding/Configure: add OpenAI API key flow that stores in shared `~/.clawdbot/.env` for launchd; simplify Anthropic token prompt order.
|
||||||
|
|||||||
@@ -147,6 +147,14 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
tui
|
tui
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Chat slash commands
|
||||||
|
|
||||||
|
Chat messages support `/...` commands (text and native). See [/tools/slash-commands](/tools/slash-commands).
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
- `/status` for quick diagnostics.
|
||||||
|
- `/debug` for runtime-only config overrides (memory, not disk).
|
||||||
|
|
||||||
## Setup + onboarding
|
## Setup + onboarding
|
||||||
|
|
||||||
### `setup`
|
### `setup`
|
||||||
|
|||||||
@@ -11,6 +11,22 @@ read_when:
|
|||||||
This page covers debugging helpers for streaming output, especially when a
|
This page covers debugging helpers for streaming output, especially when a
|
||||||
provider mixes reasoning into normal text.
|
provider mixes reasoning into normal text.
|
||||||
|
|
||||||
|
## Runtime debug overrides
|
||||||
|
|
||||||
|
Use `/debug` in chat to set **runtime-only** config overrides (memory, not disk).
|
||||||
|
This is handy when you need to toggle obscure settings without editing `clawdbot.json`.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
/debug show
|
||||||
|
/debug set messages.responsePrefix="[clawdbot]"
|
||||||
|
/debug unset messages.responsePrefix
|
||||||
|
/debug reset
|
||||||
|
```
|
||||||
|
|
||||||
|
`/debug reset` clears all overrides and returns to the on-disk config.
|
||||||
|
|
||||||
## Gateway watch mode
|
## Gateway watch mode
|
||||||
|
|
||||||
For fast iteration, run the gateway under the file watcher:
|
For fast iteration, run the gateway under the file watcher:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe
|
|||||||
Text + native (when enabled):
|
Text + native (when enabled):
|
||||||
- `/help`
|
- `/help`
|
||||||
- `/status`
|
- `/status`
|
||||||
|
- `/debug show|set|unset|reset` (runtime overrides, owner-only)
|
||||||
- `/cost on|off` (toggle per-response usage line)
|
- `/cost on|off` (toggle per-response usage line)
|
||||||
- `/stop`
|
- `/stop`
|
||||||
- `/restart`
|
- `/restart`
|
||||||
@@ -59,6 +60,24 @@ Notes:
|
|||||||
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
||||||
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
|
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
|
||||||
|
|
||||||
|
## Debug overrides
|
||||||
|
|
||||||
|
`/debug` lets you set **runtime-only** config overrides (memory, not disk). Owner-only.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
/debug show
|
||||||
|
/debug set messages.responsePrefix="[clawdbot]"
|
||||||
|
/debug set whatsapp.allowFrom=["+1555","+4477"]
|
||||||
|
/debug unset messages.responsePrefix
|
||||||
|
/debug reset
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Surface notes
|
## Surface notes
|
||||||
|
|
||||||
- **Text commands** run in the normal chat session (DMs share `main`, groups have their own session).
|
- **Text commands** run in the normal chat session (DMs share `main`, groups have their own session).
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ describe("commands registry", () => {
|
|||||||
expect(detection.regex.test("/status:")).toBe(true);
|
expect(detection.regex.test("/status:")).toBe(true);
|
||||||
expect(detection.regex.test("/stop")).toBe(true);
|
expect(detection.regex.test("/stop")).toBe(true);
|
||||||
expect(detection.regex.test("/send:")).toBe(true);
|
expect(detection.regex.test("/send:")).toBe(true);
|
||||||
|
expect(detection.regex.test("/debug set foo=bar")).toBe(true);
|
||||||
expect(detection.regex.test("/models")).toBe(true);
|
expect(detection.regex.test("/models")).toBe(true);
|
||||||
expect(detection.regex.test("/models list")).toBe(true);
|
expect(detection.regex.test("/models list")).toBe(true);
|
||||||
expect(detection.regex.test("try /status")).toBe(false);
|
expect(detection.regex.test("try /status")).toBe(false);
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [
|
|||||||
description: "Show current status.",
|
description: "Show current status.",
|
||||||
textAliases: ["/status"],
|
textAliases: ["/status"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "debug",
|
||||||
|
nativeName: "debug",
|
||||||
|
description: "Set runtime debug overrides.",
|
||||||
|
textAliases: ["/debug"],
|
||||||
|
acceptsArgs: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "cost",
|
key: "cost",
|
||||||
nativeName: "cost",
|
nativeName: "cost",
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ import { enqueueSystemEvent } from "../../infra/system-events.js";
|
|||||||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||||
import { normalizeE164 } from "../../utils.js";
|
import { normalizeE164 } from "../../utils.js";
|
||||||
|
import {
|
||||||
|
getConfigOverrides,
|
||||||
|
resetConfigOverrides,
|
||||||
|
setConfigOverride,
|
||||||
|
unsetConfigOverride,
|
||||||
|
} from "../../config/runtime-overrides.js";
|
||||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||||
import {
|
import {
|
||||||
normalizeCommandBody,
|
normalizeCommandBody,
|
||||||
@@ -65,6 +71,7 @@ import type {
|
|||||||
} from "../thinking.js";
|
} from "../thinking.js";
|
||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
import { isAbortTrigger, setAbortMemory } from "./abort.js";
|
import { isAbortTrigger, setAbortMemory } from "./abort.js";
|
||||||
|
import { parseDebugCommand } from "./debug-commands.js";
|
||||||
import type { InlineDirectives } from "./directive-handling.js";
|
import type { InlineDirectives } from "./directive-handling.js";
|
||||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||||
import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js";
|
import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js";
|
||||||
@@ -609,6 +616,81 @@ export async function handleCommands(params: {
|
|||||||
return { shouldContinue: false, reply };
|
return { shouldContinue: false, reply };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const debugCommand = allowTextCommands
|
||||||
|
? parseDebugCommand(command.commandBodyNormalized)
|
||||||
|
: null;
|
||||||
|
if (debugCommand) {
|
||||||
|
if (!command.isAuthorizedSender) {
|
||||||
|
logVerbose(
|
||||||
|
`Ignoring /debug from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||||
|
);
|
||||||
|
return { shouldContinue: false };
|
||||||
|
}
|
||||||
|
if (debugCommand.action === "error") {
|
||||||
|
return { shouldContinue: false, reply: { text: `⚠️ ${debugCommand.message}` } };
|
||||||
|
}
|
||||||
|
if (debugCommand.action === "show") {
|
||||||
|
const overrides = getConfigOverrides();
|
||||||
|
const hasOverrides = Object.keys(overrides).length > 0;
|
||||||
|
if (!hasOverrides) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: "⚙️ Debug overrides: (none)" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const json = JSON.stringify(overrides, null, 2);
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: `⚙️ Debug overrides (memory-only):\n\`\`\`json\n${json}\n\`\`\`` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (debugCommand.action === "reset") {
|
||||||
|
resetConfigOverrides();
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: "⚙️ Debug overrides cleared; using config on disk." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (debugCommand.action === "unset") {
|
||||||
|
const result = unsetConfigOverride(debugCommand.path);
|
||||||
|
if (!result.ok) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: `⚠️ ${result.error ?? "Invalid path."}` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!result.removed) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: `⚙️ No debug override found for ${debugCommand.path}.` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: `⚙️ Debug override removed for ${debugCommand.path}.` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (debugCommand.action === "set") {
|
||||||
|
const result = setConfigOverride(debugCommand.path, debugCommand.value);
|
||||||
|
if (!result.ok) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: `⚠️ ${result.error ?? "Invalid override."}` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const valueLabel =
|
||||||
|
typeof debugCommand.value === "string"
|
||||||
|
? `"${debugCommand.value}"`
|
||||||
|
: JSON.stringify(debugCommand.value);
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: {
|
||||||
|
text: `⚙️ Debug override set: ${debugCommand.path}=${valueLabel ?? "null"}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const stopRequested = command.commandBodyNormalized === "/stop";
|
const stopRequested = command.commandBodyNormalized === "/stop";
|
||||||
if (allowTextCommands && stopRequested) {
|
if (allowTextCommands && stopRequested) {
|
||||||
if (!command.isAuthorizedSender) {
|
if (!command.isAuthorizedSender) {
|
||||||
|
|||||||
21
src/auto-reply/reply/debug-commands.test.ts
Normal file
21
src/auto-reply/reply/debug-commands.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { parseDebugCommand } from "./debug-commands.js";
|
||||||
|
|
||||||
|
describe("parseDebugCommand", () => {
|
||||||
|
it("parses show/reset", () => {
|
||||||
|
expect(parseDebugCommand("/debug")).toEqual({ action: "show" });
|
||||||
|
expect(parseDebugCommand("/debug show")).toEqual({ action: "show" });
|
||||||
|
expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses set with JSON", () => {
|
||||||
|
const cmd = parseDebugCommand('/debug set foo={"a":1}');
|
||||||
|
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses unset", () => {
|
||||||
|
const cmd = parseDebugCommand("/debug unset foo.bar");
|
||||||
|
expect(cmd).toEqual({ action: "unset", path: "foo.bar" });
|
||||||
|
});
|
||||||
|
});
|
||||||
98
src/auto-reply/reply/debug-commands.ts
Normal file
98
src/auto-reply/reply/debug-commands.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
export type DebugCommand =
|
||||||
|
| { action: "show" }
|
||||||
|
| { action: "reset" }
|
||||||
|
| { action: "set"; path: string; value: unknown }
|
||||||
|
| { 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;
|
||||||
|
const rest = trimmed.slice("/debug".length).trim();
|
||||||
|
if (!rest) return { action: "show" };
|
||||||
|
|
||||||
|
const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/);
|
||||||
|
if (!match) return { action: "error", message: "Invalid /debug syntax." };
|
||||||
|
const action = match[1].toLowerCase();
|
||||||
|
const args = (match[2] ?? "").trim();
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "show":
|
||||||
|
return { action: "show" };
|
||||||
|
case "reset":
|
||||||
|
return { action: "reset" };
|
||||||
|
case "unset": {
|
||||||
|
if (!args) return { action: "error", message: "Usage: /debug unset path" };
|
||||||
|
return { action: "unset", path: args };
|
||||||
|
}
|
||||||
|
case "set": {
|
||||||
|
if (!args) {
|
||||||
|
return {
|
||||||
|
action: "error",
|
||||||
|
message: "Usage: /debug set path=value",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const eqIndex = args.indexOf("=");
|
||||||
|
if (eqIndex <= 0) {
|
||||||
|
return {
|
||||||
|
action: "error",
|
||||||
|
message: "Usage: /debug set path=value",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const path = args.slice(0, eqIndex).trim();
|
||||||
|
const rawValue = args.slice(eqIndex + 1);
|
||||||
|
if (!path) {
|
||||||
|
return {
|
||||||
|
action: "error",
|
||||||
|
message: "Usage: /debug set path=value",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const parsed = parseDebugValue(rawValue);
|
||||||
|
if (parsed.error) {
|
||||||
|
return { action: "error", message: parsed.error };
|
||||||
|
}
|
||||||
|
return { action: "set", path, value: parsed.value };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
action: "error",
|
||||||
|
message: "Usage: /debug show|set|unset|reset",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -357,6 +357,6 @@ export function buildHelpMessage(): string {
|
|||||||
return [
|
return [
|
||||||
"ℹ️ Help",
|
"ℹ️ Help",
|
||||||
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
|
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
|
||||||
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off",
|
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off | /debug show",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export {
|
|||||||
} from "./io.js";
|
} from "./io.js";
|
||||||
export { migrateLegacyConfig } from "./legacy-migrate.js";
|
export { migrateLegacyConfig } from "./legacy-migrate.js";
|
||||||
export * from "./paths.js";
|
export * from "./paths.js";
|
||||||
|
export * from "./runtime-overrides.js";
|
||||||
export * from "./types.js";
|
export * from "./types.js";
|
||||||
export { validateConfigObject } from "./validation.js";
|
export { validateConfigObject } from "./validation.js";
|
||||||
export { ClawdbotSchema } from "./zod-schema.js";
|
export { ClawdbotSchema } from "./zod-schema.js";
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
resolveConfigPath,
|
resolveConfigPath,
|
||||||
resolveStateDir,
|
resolveStateDir,
|
||||||
} from "./paths.js";
|
} from "./paths.js";
|
||||||
|
import { applyConfigOverrides } from "./runtime-overrides.js";
|
||||||
import type {
|
import type {
|
||||||
ClawdbotConfig,
|
ClawdbotConfig,
|
||||||
ConfigFileSnapshot,
|
ConfigFileSnapshot,
|
||||||
@@ -195,7 +196,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg;
|
return applyConfigOverrides(cfg);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof DuplicateAgentDirError) {
|
if (err instanceof DuplicateAgentDirError) {
|
||||||
deps.logger.error(err.message);
|
deps.logger.error(err.message);
|
||||||
|
|||||||
43
src/config/runtime-overrides.test.ts
Normal file
43
src/config/runtime-overrides.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "./types.js";
|
||||||
|
import {
|
||||||
|
applyConfigOverrides,
|
||||||
|
getConfigOverrides,
|
||||||
|
resetConfigOverrides,
|
||||||
|
setConfigOverride,
|
||||||
|
unsetConfigOverride,
|
||||||
|
} from "./runtime-overrides.js";
|
||||||
|
|
||||||
|
describe("runtime overrides", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetConfigOverrides();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets and applies nested overrides", () => {
|
||||||
|
const cfg = {
|
||||||
|
messages: { responsePrefix: "[clawdbot]" },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
setConfigOverride("messages.responsePrefix", "[debug]");
|
||||||
|
const next = applyConfigOverrides(cfg);
|
||||||
|
expect(next.messages?.responsePrefix).toBe("[debug]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges object overrides without clobbering siblings", () => {
|
||||||
|
const cfg = {
|
||||||
|
whatsapp: { dmPolicy: "pairing", allowFrom: ["+1"] },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
setConfigOverride("whatsapp.dmPolicy", "open");
|
||||||
|
const next = applyConfigOverrides(cfg);
|
||||||
|
expect(next.whatsapp?.dmPolicy).toBe("open");
|
||||||
|
expect(next.whatsapp?.allowFrom).toEqual(["+1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unsets overrides and prunes empty branches", () => {
|
||||||
|
setConfigOverride("whatsapp.dmPolicy", "open");
|
||||||
|
const removed = unsetConfigOverride("whatsapp.dmPolicy");
|
||||||
|
expect(removed.ok).toBe(true);
|
||||||
|
expect(removed.removed).toBe(true);
|
||||||
|
expect(Object.keys(getConfigOverrides()).length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
112
src/config/runtime-overrides.ts
Normal file
112
src/config/runtime-overrides.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import type { ClawdbotConfig } from "./types.js";
|
||||||
|
|
||||||
|
type OverrideTree = Record<string, unknown>;
|
||||||
|
|
||||||
|
let overrides: OverrideTree = {};
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
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 };
|
||||||
|
for (const [key, value] of Object.entries(override)) {
|
||||||
|
if (value === undefined) continue;
|
||||||
|
next[key] = mergeOverrides((base as OverrideTree)[key], value);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfigOverrides(): OverrideTree {
|
||||||
|
return overrides;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetConfigOverrides(): void {
|
||||||
|
overrides = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setConfigOverride(pathRaw: string, value: unknown): {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
const path = parsePath(pathRaw);
|
||||||
|
if (!path) {
|
||||||
|
return { ok: false, error: "Invalid path. Use dot notation (e.g. foo.bar)." };
|
||||||
|
}
|
||||||
|
setOverrideAtPath(overrides, path, value);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unsetConfigOverride(pathRaw: string): {
|
||||||
|
ok: boolean;
|
||||||
|
removed: boolean;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
const path = parsePath(pathRaw);
|
||||||
|
if (!path) {
|
||||||
|
return { ok: false, removed: false, error: "Invalid path." };
|
||||||
|
}
|
||||||
|
const removed = unsetOverrideAtPath(overrides, path);
|
||||||
|
return { ok: true, removed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyConfigOverrides(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||||
|
if (!overrides || Object.keys(overrides).length === 0) return cfg;
|
||||||
|
return mergeOverrides(cfg, overrides) as ClawdbotConfig;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user