diff --git a/CHANGELOG.md b/CHANGELOG.md index d4a7ac352..3ea1fd8c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests. - Docs: add plugins doc + cross-links from tools/skills/gateway config. - Tests: add Docker plugin loader smoke test. +- Config: add `$include` directive for modular config files. (#731) — thanks @pasogott. - Build: set pnpm minimum release age to 2880 minutes (2 days). (#718) — thanks @dan-dr. - macOS: prompt to install the global `clawdbot` CLI when missing in local mode; install via `clawd.bot/install-cli.sh` (no onboarding) and use external launchd/CLI instead of the embedded gateway runtime. - Docs: add gog calendar event color IDs from `gog calendar colors`. (#715) — thanks @mjrussell. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 9a0da11d7..06ceec2f7 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -124,6 +124,7 @@ Split your config into multiple files using the `$include` directive. This is us - **Single file**: Replaces the object containing `$include` - **Array of files**: Deep-merges files in order (later files override earlier ones) - **With sibling keys**: Sibling keys are merged after includes (override included values) +- **Sibling keys + arrays/primitives**: Not supported (included content must be an object) ```json5 // Sibling keys override included values diff --git a/src/config/includes.test.ts b/src/config/includes.test.ts index fb78d23ea..f8f85a8c6 100644 --- a/src/config/includes.test.ts +++ b/src/config/includes.test.ts @@ -100,6 +100,15 @@ describe("resolveConfigIncludes", () => { expect(resolve(obj, files)).toEqual({ a: 1, b: 99 }); }); + it("throws when sibling keys are used with non-object includes", () => { + const files = { "/config/list.json": ["a", "b"] }; + const obj = { $include: "./list.json", extra: true }; + expect(() => resolve(obj, files)).toThrow(ConfigIncludeError); + expect(() => resolve(obj, files)).toThrow( + /Sibling keys require included content to be an object/, + ); + }); + it("resolves nested includes", () => { const files = { "/config/level1.json": { nested: { $include: "./level2.json" } }, @@ -123,10 +132,12 @@ describe("resolveConfigIncludes", () => { parseJson: JSON.parse, }; const obj = { $include: "./bad.json" }; - expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver)) - .toThrow(ConfigIncludeError); - expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver)) - .toThrow(/Failed to parse include file/); + expect(() => + resolveConfigIncludes(obj, "/config/clawdbot.json", resolver), + ).toThrow(ConfigIncludeError); + expect(() => + resolveConfigIncludes(obj, "/config/clawdbot.json", resolver), + ).toThrow(/Failed to parse include file/); }); it("throws CircularIncludeError for circular includes", () => { @@ -143,10 +154,12 @@ describe("resolveConfigIncludes", () => { parseJson: JSON.parse, }; const obj = { $include: "./a.json" }; - expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver)) - .toThrow(CircularIncludeError); - expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver)) - .toThrow(/Circular include detected/); + expect(() => + resolveConfigIncludes(obj, "/config/clawdbot.json", resolver), + ).toThrow(CircularIncludeError); + expect(() => + resolveConfigIncludes(obj, "/config/clawdbot.json", resolver), + ).toThrow(/Circular include detected/); }); it("throws ConfigIncludeError for invalid $include value type", () => { @@ -185,7 +198,9 @@ describe("resolveConfigIncludes", () => { it("resolves parent directory references", () => { const files = { "/shared/common.json": { shared: true } }; const obj = { $include: "../../shared/common.json" }; - expect(resolve(obj, files, "/config/sub/clawdbot.json")).toEqual({ shared: true }); + expect(resolve(obj, files, "/config/sub/clawdbot.json")).toEqual({ + shared: true, + }); }); }); @@ -194,14 +209,25 @@ describe("real-world config patterns", () => { const files = { "/config/clients/mueller.json": { agents: [ - { id: "mueller-screenshot", workspace: "~/clients/mueller/screenshot" }, - { id: "mueller-transcribe", workspace: "~/clients/mueller/transcribe" }, + { + id: "mueller-screenshot", + workspace: "~/clients/mueller/screenshot", + }, + { + id: "mueller-transcribe", + workspace: "~/clients/mueller/transcribe", + }, ], - broadcast: { "group-mueller": ["mueller-screenshot", "mueller-transcribe"] }, + broadcast: { + "group-mueller": ["mueller-screenshot", "mueller-transcribe"], + }, }, "/config/clients/schmidt.json": { agents: [ - { id: "schmidt-screenshot", workspace: "~/clients/schmidt/screenshot" }, + { + id: "schmidt-screenshot", + workspace: "~/clients/schmidt/screenshot", + }, ], broadcast: { "group-schmidt": ["schmidt-screenshot"] }, }, diff --git a/src/config/includes.ts b/src/config/includes.ts index f3a74d332..3fa4f0185 100644 --- a/src/config/includes.ts +++ b/src/config/includes.ts @@ -73,9 +73,8 @@ export function deepMerge(target: unknown, source: unknown): unknown { if (isPlainObject(target) && isPlainObject(source)) { const result: Record = { ...target }; for (const key of Object.keys(source)) { - result[key] = key in result - ? deepMerge(result[key], source[key]) - : source[key]; + result[key] = + key in result ? deepMerge(result[key], source[key]) : source[key]; } return result; } @@ -130,6 +129,13 @@ class IncludeProcessor { return included; } + if (!isPlainObject(included)) { + throw new ConfigIncludeError( + "Sibling keys require included content to be an object", + typeof includeValue === "string" ? includeValue : INCLUDE_KEY, + ); + } + // Merge included content with sibling keys const rest: Record = {}; for (const key of otherKeys) { @@ -163,7 +169,7 @@ class IncludeProcessor { private loadFile(includePath: string): unknown { const resolvedPath = this.resolvePath(includePath); - + this.checkCircular(resolvedPath); this.checkDepth(includePath); @@ -207,7 +213,11 @@ class IncludeProcessor { } } - private parseFile(includePath: string, resolvedPath: string, raw: string): unknown { + private parseFile( + includePath: string, + resolvedPath: string, + raw: string, + ): unknown { try { return this.resolver.parseJson(raw); } catch (err) { diff --git a/src/config/io.ts b/src/config/io.ts index f05f9f19f..7b1ac6fb0 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -22,10 +22,7 @@ import { applySessionDefaults, applyTalkApiKey, } from "./defaults.js"; -import { - ConfigIncludeError, - resolveConfigIncludes, -} from "./includes.js"; +import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js"; import { findLegacyConfigIssues } from "./legacy.js"; import { resolveConfigPath, resolveStateDir } from "./paths.js"; import { applyConfigOverrides } from "./runtime-overrides.js"; diff --git a/src/postinstall-patcher.test.ts b/src/postinstall-patcher.test.ts index 44dfe2e01..855905eb5 100644 --- a/src/postinstall-patcher.test.ts +++ b/src/postinstall-patcher.test.ts @@ -16,13 +16,13 @@ describe("postinstall patcher", () => { fs.mkdirSync(target); const filePath = path.join(target, "main.js"); - const original = [ + const original = `${[ "var QRCode = require('./../vendor/QRCode'),", " QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel'),", ' black = "\\033[40m \\033[0m",', ' white = "\\033[47m \\033[0m",', " toCell = function (isBlack) {", - ].join("\n") + "\n"; + ].join("\n")}\n`; fs.writeFileSync(filePath, original, "utf-8"); const patchText = `diff --git a/lib/main.js b/lib/main.js @@ -43,13 +43,13 @@ index 0000000..1111111 100644 const updated = fs.readFileSync(filePath, "utf-8"); expect(updated).toBe( - [ + `${[ "var QRCode = require('./../vendor/QRCode/index.js'),", " QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel.js'),", ' black = "\\033[40m \\033[0m",', ' white = "\\033[47m \\033[0m",', " toCell = function (isBlack) {", - ].join("\n") + "\n", + ].join("\n")}\n`, ); fs.rmSync(dir, { recursive: true, force: true }); @@ -60,7 +60,7 @@ index 0000000..1111111 100644 const filePath = path.join(dir, "file.txt"); fs.writeFileSync( filePath, - ["alpha", "beta", "gamma", "delta", "epsilon"].join("\n") + "\n", + `${["alpha", "beta", "gamma", "delta", "epsilon"].join("\n")}\n`, "utf-8", );