1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
|
import { readFileSync } from "node:fs";
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, KeyDefinition, LspServerConfig } from "../types/index.js";
import { validateConfig } from "./schema.js";
const DEFAULT_CONFIG: DispatchConfig = { permissions: {} };
const VALID_ACTIONS = new Set(["allow", "deny", "ask"]);
function validateAction(raw: string): "allow" | "deny" | "ask" {
if (VALID_ACTIONS.has(raw)) return raw as "allow" | "deny" | "ask";
console.warn(`dispatch: unrecognized action "${raw}", defaulting to "ask"`);
return "ask";
}
/**
* 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 — signal "no config here".
return null;
}
console.warn(
`dispatch: failed to parse ${tomlPath}: ${err instanceof Error ? err.message : String(err)}`,
);
throw err;
}
const { config, errors } = validateConfig(raw);
for (const e of errors) {
console.warn(`dispatch: config warning at ${e.path}: ${e.message}`);
}
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: global patterns the local block does NOT also
* define come first (original order), then EVERY local pattern is appended
* last (overriding any same-named global pattern).
*
* Emitting all local patterns after the global ones is essential, not
* cosmetic: `configToRuleset` flattens patterns in iteration order and
* `evaluate` uses `findLast` (last match wins). If an overridden pattern were
* updated in place, a more-general global pattern (e.g. "*") could remain AFTER
* it and silently shadow the local override. Appending local patterns last
* reproduces a clean "global rules then local rules" concatenation so local
* always wins.
*/
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 so that ALL local patterns
// are emitted AFTER the global ones. This matters because
// `configToRuleset` flattens patterns in insertion order and
// `evaluate` uses `findLast` (last match wins): a naive
// `{ ...existing, ...value }` would update an overridden pattern
// IN PLACE, leaving a more-general global pattern (e.g. "*") sitting
// AFTER it and silently shadowing the local override. We therefore
// drop any global pattern that the local block also defines, keep the
// remaining global patterns in their original order, then append every
// local pattern last — reproducing a clean "global rules then local
// rules" concatenation where local always wins.
const merged: Record<string, string> = {};
for (const [pattern, action] of Object.entries(existing)) {
if (!(pattern in value)) merged[pattern] = action;
}
for (const [pattern, action] of Object.entries(value)) {
merged[pattern] = action;
}
result[key] = merged;
} 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();
const rules: PermissionRule[] = [];
for (const [permission, value] of Object.entries(config.permissions)) {
if (typeof value === "string") {
const action = validateAction(value);
rules.push({ permission, pattern: "*", action });
} else {
for (const [rawPattern, rawAction] of Object.entries(value)) {
const pattern = rawPattern
.replace(/^\$HOME(?=[/\\]|$)/, home)
.replace(/^~(?=[/\\]|$)/, home);
const action = validateAction(rawAction);
rules.push({ permission, pattern, action });
}
}
}
return rules;
}
|