feat: add /config chat config updates
This commit is contained in:
28
src/config/config-paths.test.ts
Normal file
28
src/config/config-paths.test.ts
Normal file
@@ -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<string, unknown> = {};
|
||||
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();
|
||||
});
|
||||
});
|
||||
88
src/config/config-paths.ts
Normal file
88
src/config/config-paths.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
type PathNode = Record<string, unknown>;
|
||||
|
||||
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<string, unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.prototype.toString.call(value) === "[object Object]"
|
||||
);
|
||||
}
|
||||
@@ -1,68 +1,14 @@
|
||||
import type { ClawdbotConfig } from "./types.js";
|
||||
import {
|
||||
parseConfigPath,
|
||||
setConfigValueAtPath,
|
||||
unsetConfigValueAtPath,
|
||||
} from "./config-paths.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 };
|
||||
@@ -73,6 +19,15 @@ function mergeOverrides(base: unknown, override: unknown): unknown {
|
||||
return next;
|
||||
}
|
||||
|
||||
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]"
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user