diff options
| author | Adam Malczewski <[email protected]> | 2026-06-03 16:08:40 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-03 16:08:40 +0900 |
| commit | ebd68da7dfd6d4f2ef6c6b29a62ec848bbf15cef (patch) | |
| tree | 28de3a1dcd5e85a8fa9edb978ce0bad665ec722c /packages | |
| parent | 5af9bd021c206b9e4330ab6a549dc8d013d91537 (diff) | |
| download | dispatch-ebd68da7dfd6d4f2ef6c6b29a62ec848bbf15cef.tar.gz dispatch-ebd68da7dfd6d4f2ef6c6b29a62ec848bbf15cef.zip | |
feat(config): merge home-directory global dispatch.toml under project config
Load an optional global config at ~/.config/dispatch/dispatch.toml
(override via DISPATCH_GLOBAL_CONFIG) and deep-merge it underneath every
project/working-directory dispatch.toml, so machine-wide settings — most
notably globally available LSP servers — work in any repo without per-repo
config. Local always wins on conflicts.
- loader: add getGlobalConfigPath(), loadGlobalConfig(), mergeConfigs();
loadConfig(dir) now loads+merges global. [lsp] and [[keys]] merge by id;
[permissions] merge per-group with global patterns emitted first so local
rules win at evaluation time (findLast). A malformed global config is
downgraded to empty rather than breaking every repo.
- watcher: watch BOTH global and local dispatch.toml so hot-reload re-merges
on either change (dedupes when paths coincide).
- export new loader fns from config/index and core index.
- types/agent-manager: doc updates reflecting merged LSP resolution.
- dispatch.toml: document global-default merge behavior; activate biome and
typescript-language-server LSP entries.
- tests: merge precedence, lsp/keys merge-by-id, permissions merge,
filesystem integration, malformed-global resilience; isolate global path
in existing loader tests.
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/api/src/agent-manager.ts | 13 | ||||
| -rw-r--r-- | packages/core/src/config/index.ts | 8 | ||||
| -rw-r--r-- | packages/core/src/config/loader.ts | 154 | ||||
| -rw-r--r-- | packages/core/src/config/watcher.ts | 20 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 3 | ||||
| -rw-r--r-- | packages/core/src/types/index.ts | 11 | ||||
| -rw-r--r-- | packages/core/tests/config/loader.test.ts | 8 | ||||
| -rw-r--r-- | packages/core/tests/config/merge.test.ts | 206 |
8 files changed, 402 insertions, 21 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 7cfa03c..913fb15 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -390,11 +390,14 @@ export class AgentManager { /** * Resolve (and cache) the LSP servers configured for a working directory. * - * LSP config is project-scoped: it lives in the `dispatch.toml` of the - * tab's effective working directory, NOT the global config. We read that - * directory's config once and cache the resolved servers; the cache is - * cleared on config hot-reload. Returns `[]` when the directory has no - * `[lsp]` block (the common case). + * LSP config is resolved by `loadConfig`, which merges the HOME-directory + * global `dispatch.toml` (`~/.config/dispatch/dispatch.toml`) underneath the + * tab's effective working-directory `dispatch.toml` — local `[lsp.<id>]` + * entries override global ones sharing the same id, while global-only + * servers stay active in every repository. We read+merge that config once + * per directory and cache the resolved servers; the cache is cleared on + * config hot-reload. Returns `[]` when neither config declares an `[lsp]` + * block (the common case). */ private getLspServersForDir(dir: string): ResolvedLspServer[] { const cached = this.lspServersByDir.get(dir); diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 5b128c4..4806409 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -1,3 +1,9 @@ -export { configToRuleset, loadConfig } from "./loader.js"; +export { + configToRuleset, + getGlobalConfigPath, + loadConfig, + loadGlobalConfig, + mergeConfigs, +} from "./loader.js"; export { validateConfig } from "./schema.js"; export { createConfigWatcher } from "./watcher.js"; diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index bccbb8f..1e5f151 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -3,7 +3,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { parse } from "smol-toml"; import type { PermissionRule, Ruleset } from "../permission/index.js"; -import type { DispatchConfig } from "../types/index.js"; +import type { DispatchConfig, KeyDefinition, LspServerConfig } from "../types/index.js"; import { validateConfig } from "./schema.js"; const DEFAULT_CONFIG: DispatchConfig = { permissions: {} }; @@ -16,20 +16,40 @@ function validateAction(raw: string): "allow" | "deny" | "ask" { return "ask"; } -// Load dispatch.toml from the given directory -export function loadConfig(dir: string): DispatchConfig { - const tomlPath = join(dir, "dispatch.toml"); +/** + * Absolute path to the HOME-directory (global) `dispatch.toml`. + * + * Follows the same `~/.config/dispatch/` convention as global agents + * (`~/.config/dispatch/agents`). This file is OPTIONAL; when present its + * contents are merged underneath every project/working-directory config so + * machine-wide settings (e.g. globally available LSP servers) work in any + * repository without per-repo configuration. + * + * The path can be overridden with the `DISPATCH_GLOBAL_CONFIG` environment + * variable, which is primarily useful for tests (point it at a temp file) but + * also lets a user relocate the global config. + */ +export function getGlobalConfigPath(): string { + return ( + process.env.DISPATCH_GLOBAL_CONFIG ?? join(homedir(), ".config", "dispatch", "dispatch.toml") + ); +} + +// Parse + validate a single dispatch.toml. Returns null when the file does not +// exist. Re-throws TOML parse errors so a corrupt LOCAL config surfaces loudly +// (callers that must stay resilient, e.g. the global loader, catch it). +function readConfigFile(tomlPath: string): DispatchConfig | null { let raw: unknown; try { const content = readFileSync(tomlPath, "utf-8"); raw = parse(content); } catch (err: unknown) { if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT") { - // File doesn't exist — return empty default - return DEFAULT_CONFIG; + // File doesn't exist — signal "no config here". + return null; } console.warn( - `dispatch: failed to parse dispatch.toml: ${err instanceof Error ? err.message : String(err)}`, + `dispatch: failed to parse ${tomlPath}: ${err instanceof Error ? err.message : String(err)}`, ); throw err; } @@ -41,6 +61,126 @@ export function loadConfig(dir: string): DispatchConfig { return config; } +/** + * Load the HOME-directory global `dispatch.toml` (see {@link getGlobalConfigPath}). + * + * Always resilient: a missing file yields the empty default and a malformed + * file is logged but downgraded to the empty default rather than thrown. A + * broken global config must never break config loading for every repository on + * the machine. + */ +export function loadGlobalConfig(): DispatchConfig { + try { + return readConfigFile(getGlobalConfigPath()) ?? DEFAULT_CONFIG; + } catch (err) { + console.warn( + `dispatch: ignoring global config due to parse error: ${err instanceof Error ? err.message : String(err)}`, + ); + return DEFAULT_CONFIG; + } +} + +/** + * Load the effective config for `dir`: the global config MERGED with the + * project/working-directory `dispatch.toml`, where the LOCAL config takes + * precedence on conflicts (see {@link mergeConfigs}). A missing local file + * yields the global config as-is; a missing global file yields the local + * config as-is. + * + * Note: a malformed LOCAL config still throws (callers may surface it), while a + * malformed GLOBAL config is downgraded to empty by {@link loadGlobalConfig}. + */ +export function loadConfig(dir: string): DispatchConfig { + const global = loadGlobalConfig(); + const local = readConfigFile(join(dir, "dispatch.toml")); + if (local === null) return global; + return mergeConfigs(global, local); +} + +// ─── Merge ─────────────────────────────────────────────────────── + +/** + * Merge two permission blocks. Local takes precedence on conflicts. + * + * - A key present only in one side is carried over verbatim. + * - A key whose value is a string on either side: local replaces global. + * - A key that is a nested `{ pattern -> action }` object on BOTH sides is + * merged pattern-by-pattern (local patterns override global patterns of the + * same name; non-conflicting patterns from both survive). + * + * Global groups are emitted before local-only groups, and global patterns + * before local patterns within a shared group. This ordering matters because + * `configToRuleset` flattens in iteration order and `evaluate` uses `findLast` + * (last match wins) — so local rules naturally win. + */ +function mergePermissions( + global: DispatchConfig["permissions"], + local: DispatchConfig["permissions"], +): DispatchConfig["permissions"] { + const result: DispatchConfig["permissions"] = {}; + for (const [key, value] of Object.entries(global)) { + result[key] = value; + } + for (const [key, value] of Object.entries(local)) { + const existing = result[key]; + if (existing !== undefined && typeof existing !== "string" && typeof value !== "string") { + // Both nested objects — merge patterns, local wins on conflicts. + result[key] = { ...existing, ...value }; + } else { + // Local string, brand-new key, or a string/object type mismatch: + // local replaces global wholesale. + result[key] = value; + } + } + return result; +} + +/** + * Merge two key lists by `id`. Local keys override global keys sharing the same + * id; non-conflicting ids from both lists survive. Global keys keep their + * relative order (overridden in place) followed by local-only keys. + */ +function mergeKeys(global: KeyDefinition[], local: KeyDefinition[]): KeyDefinition[] { + const byId = new Map<string, KeyDefinition>(); + for (const key of global) byId.set(key.id, key); + for (const key of local) byId.set(key.id, key); + return Array.from(byId.values()); +} + +/** + * Merge two `[lsp]` blocks by server id. Local servers override global servers + * sharing the same id; non-conflicting ids from both sides remain active. This + * is what lets a global config provide LSP servers to every repository while a + * project can still override or add its own. + */ +function mergeLsp( + global: Record<string, LspServerConfig>, + local: Record<string, LspServerConfig>, +): Record<string, LspServerConfig> { + return { ...global, ...local }; +} + +/** + * Deep-merge a `global` config with a `local` (project/working-directory) + * config, with LOCAL taking precedence on every conflict. Pure function — does + * not touch the filesystem and never mutates its inputs. + */ +export function mergeConfigs(global: DispatchConfig, local: DispatchConfig): DispatchConfig { + const merged: DispatchConfig = { + permissions: mergePermissions(global.permissions, local.permissions), + }; + + if (global.keys !== undefined || local.keys !== undefined) { + merged.keys = mergeKeys(global.keys ?? [], local.keys ?? []); + } + + if (global.lsp !== undefined || local.lsp !== undefined) { + merged.lsp = mergeLsp(global.lsp ?? {}, local.lsp ?? {}); + } + + return merged; +} + // Convert the config's permission block to a Ruleset export function configToRuleset(config: DispatchConfig): Ruleset { const home = homedir(); diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts index 70821ed..9414ef6 100644 --- a/packages/core/src/config/watcher.ts +++ b/packages/core/src/config/watcher.ts @@ -1,16 +1,28 @@ import { join } from "node:path"; import { watch } from "chokidar"; import type { DispatchConfig } from "../types/index.js"; -import { loadConfig } from "./loader.js"; +import { getGlobalConfigPath, loadConfig } from "./loader.js"; +/** + * Watch BOTH the HOME-directory global `dispatch.toml` and the project/working- + * directory `dispatch.toml`. Either file changing triggers a reload that + * re-merges global + local (via {@link loadConfig}), so hot-reload works for + * global defaults and per-project overrides alike. + * + * When the global and local paths coincide (e.g. the working directory IS + * `~/.config/dispatch`, or `DISPATCH_GLOBAL_CONFIG` points at the local file) + * the duplicate is collapsed so chokidar only watches it once. + */ export function createConfigWatcher( dir: string, onChange: (config: DispatchConfig) => void, ): { close(): void } { - const tomlPath = join(dir, "dispatch.toml"); + const localPath = join(dir, "dispatch.toml"); + const globalPath = getGlobalConfigPath(); + const paths = globalPath === localPath ? [localPath] : [globalPath, localPath]; let debounceTimer: ReturnType<typeof setTimeout> | null = null; - const watcher = watch(tomlPath, { + const watcher = watch(paths, { ignoreInitial: true, persistent: false, }); @@ -21,7 +33,7 @@ export function createConfigWatcher( } debounceTimer = setTimeout(() => { debounceTimer = null; - console.log(`dispatch: reloading config from ${tomlPath}`); + console.log(`dispatch: reloading config (global + ${localPath})`); try { const config = loadConfig(dir); onChange(config); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 25cc909..b636cde 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -39,7 +39,10 @@ export { export { configToRuleset, createConfigWatcher, + getGlobalConfigPath, loadConfig, + loadGlobalConfig, + mergeConfigs, validateConfig, } from "./config/index.js"; // Credentials diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 4e3fa0b..e878813 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -508,10 +508,13 @@ export interface DispatchConfig { permissions: Record<string, string | Record<string, string>>; /** * Language Server Protocol servers, keyed by an arbitrary server id (e.g. - * `"luau-lsp"`). Project-scoped: read from the `dispatch.toml` in a tab's - * effective working directory and re-consulted when that directory (or the - * config) changes. Config-driven only — there is no builtin server registry - * and no auto-download; the declared `command[0]` must be on PATH. + * `"luau-lsp"`). Resolved by merging the HOME-directory global + * `dispatch.toml` (`~/.config/dispatch/dispatch.toml`) underneath the + * `dispatch.toml` in a tab's effective working directory — local entries + * override global ones sharing the same id, and global-only servers stay + * active in every repository. Re-consulted when either config (or the + * directory) changes. Config-driven only — there is no builtin server + * registry and no auto-download; the declared `command[0]` must be on PATH. */ lsp?: Record<string, LspServerConfig>; } diff --git a/packages/core/tests/config/loader.test.ts b/packages/core/tests/config/loader.test.ts index de025de..0d84d0b 100644 --- a/packages/core/tests/config/loader.test.ts +++ b/packages/core/tests/config/loader.test.ts @@ -6,12 +6,20 @@ import { configToRuleset, loadConfig } from "../../src/config/loader.js"; const TMP = join("/tmp/opencode", "dispatch-config-test"); +// Point the global config at a path that does not exist so these tests are +// hermetic — they must not pick up this machine's real +// ~/.config/dispatch/dispatch.toml. +const prevGlobal = process.env.DISPATCH_GLOBAL_CONFIG; + beforeEach(() => { mkdirSync(TMP, { recursive: true }); + process.env.DISPATCH_GLOBAL_CONFIG = join(TMP, "__no_such_global__.toml"); }); afterEach(() => { rmSync(TMP, { recursive: true, force: true }); + if (prevGlobal === undefined) delete process.env.DISPATCH_GLOBAL_CONFIG; + else process.env.DISPATCH_GLOBAL_CONFIG = prevGlobal; }); function writeToml(content: string): void { diff --git a/packages/core/tests/config/merge.test.ts b/packages/core/tests/config/merge.test.ts new file mode 100644 index 0000000..b362628 --- /dev/null +++ b/packages/core/tests/config/merge.test.ts @@ -0,0 +1,206 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + configToRuleset, + getGlobalConfigPath, + loadConfig, + loadGlobalConfig, + mergeConfigs, +} from "../../src/config/loader.js"; +import { evaluate } from "../../src/permission/evaluate.js"; +import type { DispatchConfig } from "../../src/types/index.js"; + +const TMP = join("/tmp/opencode", "dispatch-config-merge-test"); +const LOCAL_DIR = join(TMP, "project"); +const GLOBAL_PATH = join(TMP, "global", "dispatch.toml"); + +const prevGlobal = process.env.DISPATCH_GLOBAL_CONFIG; + +beforeEach(() => { + mkdirSync(LOCAL_DIR, { recursive: true }); + mkdirSync(join(TMP, "global"), { recursive: true }); + process.env.DISPATCH_GLOBAL_CONFIG = GLOBAL_PATH; +}); + +afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + if (prevGlobal === undefined) delete process.env.DISPATCH_GLOBAL_CONFIG; + else process.env.DISPATCH_GLOBAL_CONFIG = prevGlobal; +}); + +function writeGlobal(content: string): void { + writeFileSync(GLOBAL_PATH, content, "utf-8"); +} + +function writeLocal(content: string): void { + writeFileSync(join(LOCAL_DIR, "dispatch.toml"), content, "utf-8"); +} + +// ─── mergeConfigs (pure) ───────────────────────────────────────── + +describe("mergeConfigs — lsp by id", () => { + it("keeps non-conflicting servers from both global and local", () => { + const global: DispatchConfig = { + permissions: {}, + lsp: { biome: { command: ["biome"], extensions: [".ts"] } }, + }; + const local: DispatchConfig = { + permissions: {}, + lsp: { luau: { command: ["luau-lsp"], extensions: [".luau"] } }, + }; + const merged = mergeConfigs(global, local); + expect(Object.keys(merged.lsp ?? {}).sort()).toEqual(["biome", "luau"]); + }); + + it("local overrides global for the same server id", () => { + const global: DispatchConfig = { + permissions: {}, + lsp: { biome: { command: ["global-biome"], extensions: [".ts"] } }, + }; + const local: DispatchConfig = { + permissions: {}, + lsp: { biome: { command: ["local-biome"], extensions: [".ts", ".tsx"] } }, + }; + const merged = mergeConfigs(global, local); + expect(merged.lsp?.biome.command).toEqual(["local-biome"]); + expect(merged.lsp?.biome.extensions).toEqual([".ts", ".tsx"]); + }); + + it("omits lsp entirely when neither side declares one", () => { + const merged = mergeConfigs({ permissions: {} }, { permissions: {} }); + expect(merged.lsp).toBeUndefined(); + }); + + it("does not mutate inputs", () => { + const global: DispatchConfig = { + permissions: {}, + lsp: { biome: { command: ["g"], extensions: [".ts"] } }, + }; + const local: DispatchConfig = { + permissions: {}, + lsp: { biome: { command: ["l"], extensions: [".ts"] } }, + }; + mergeConfigs(global, local); + expect(global.lsp?.biome.command).toEqual(["g"]); + expect(local.lsp?.biome.command).toEqual(["l"]); + }); +}); + +describe("mergeConfigs — keys by id", () => { + it("merges keys by id, local overriding global", () => { + const global: DispatchConfig = { + permissions: {}, + keys: [ + { id: "a", provider: "x", base_url: "g-a" }, + { id: "b", provider: "x", base_url: "g-b" }, + ], + }; + const local: DispatchConfig = { + permissions: {}, + keys: [ + { id: "b", provider: "x", base_url: "l-b" }, + { id: "c", provider: "x", base_url: "l-c" }, + ], + }; + const merged = mergeConfigs(global, local); + const byId = Object.fromEntries((merged.keys ?? []).map((k) => [k.id, k.base_url])); + expect(byId).toEqual({ a: "g-a", b: "l-b", c: "l-c" }); + }); + + it("carries global keys through when local has none", () => { + const global: DispatchConfig = { + permissions: {}, + keys: [{ id: "a", provider: "x", base_url: "g-a" }], + }; + const merged = mergeConfigs(global, { permissions: {} }); + expect(merged.keys).toEqual([{ id: "a", provider: "x", base_url: "g-a" }]); + }); +}); + +describe("mergeConfigs — permissions", () => { + it("merges nested groups pattern-by-pattern with local winning", () => { + const global: DispatchConfig = { + permissions: { bash: { "git status": "allow", "*": "ask" } }, + }; + const local: DispatchConfig = { + permissions: { bash: { "*": "allow" } }, + }; + const merged = mergeConfigs(global, local); + const bash = merged.permissions.bash as Record<string, string>; + expect(bash["git status"]).toBe("allow"); + expect(bash["*"]).toBe("allow"); // local override + }); + + it("local string value replaces a global nested group", () => { + const global: DispatchConfig = { + permissions: { read: { "src/**": "allow" } }, + }; + const local: DispatchConfig = { permissions: { read: "deny" } }; + const merged = mergeConfigs(global, local); + expect(merged.permissions.read).toBe("deny"); + }); + + it("keeps global-only permission groups", () => { + const global: DispatchConfig = { permissions: { read: "allow" } }; + const local: DispatchConfig = { permissions: { edit: "ask" } }; + const merged = mergeConfigs(global, local); + expect(merged.permissions.read).toBe("allow"); + expect(merged.permissions.edit).toBe("ask"); + }); + + it("local wins at evaluation time (findLast ordering)", () => { + const merged = mergeConfigs( + { permissions: { bash: { "*": "ask" } } }, + { permissions: { bash: { "*": "allow" } } }, + ); + const ruleset = configToRuleset(merged); + expect(evaluate("bash", "anything", ruleset).action).toBe("allow"); + }); +}); + +// ─── loadConfig (filesystem integration) ───────────────────────── + +describe("loadConfig — global + local integration", () => { + it("returns global config when local dispatch.toml is missing", () => { + writeGlobal(`[lsp.biome]\ncommand = ["biome"]\nextensions = [".ts"]\n`); + const config = loadConfig(LOCAL_DIR); + expect(config.lsp?.biome.command).toEqual(["biome"]); + }); + + it("returns local config when global is missing", () => { + writeLocal(`[permissions]\nread = "allow"\n`); + const config = loadConfig(LOCAL_DIR); + expect(config.permissions.read).toBe("allow"); + expect(config.lsp).toBeUndefined(); + }); + + it("merges global LSP servers with local ones (local wins on id)", () => { + writeGlobal( + `[lsp.biome]\ncommand = ["global-biome"]\nextensions = [".ts"]\n\n[lsp.luau]\ncommand = ["luau-lsp"]\nextensions = [".luau"]\n`, + ); + writeLocal(`[lsp.biome]\ncommand = ["local-biome"]\nextensions = [".ts"]\n`); + const config = loadConfig(LOCAL_DIR); + expect(config.lsp?.biome.command).toEqual(["local-biome"]); + expect(config.lsp?.luau.command).toEqual(["luau-lsp"]); + }); + + it("a malformed global config is ignored, local still loads", () => { + writeGlobal("not valid toml [[["); + writeLocal(`[permissions]\nread = "allow"\n`); + const config = loadConfig(LOCAL_DIR); + expect(config.permissions.read).toBe("allow"); + }); +}); + +describe("loadGlobalConfig", () => { + it("returns empty default when the global file is missing", () => { + expect(loadGlobalConfig()).toEqual({ permissions: {} }); + }); + + it("loads the file at getGlobalConfigPath()", () => { + writeGlobal(`[permissions]\nedit = "deny"\n`); + expect(getGlobalConfigPath()).toBe(GLOBAL_PATH); + expect(loadGlobalConfig().permissions.edit).toBe("deny"); + }); +}); |
