summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/config
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-20 15:04:26 +0900
committerAdam Malczewski <[email protected]>2026-05-20 15:04:26 +0900
commitadc8bd185b54935e7a31aae04da3175b7989927a (patch)
tree031fa53009a4708ce0cc15c0e3dcb2f2a73cf2d9 /packages/core/src/config
parent52af4ca33b1f83b8f863a6267d447944f55e729d (diff)
downloaddispatch-adc8bd185b54935e7a31aae04da3175b7989927a.tar.gz
dispatch-adc8bd185b54935e7a31aae04da3175b7989927a.zip
feat: phase 3 — config, skills, model groups, task list, and sidebar UI
- Config system: TOML-based dispatch.toml with hot-reload via chokidar - Model/key resolution: tag-based model selection, key fallback chains - Skills system: directory loader with TOML frontmatter, agent mappings - Task list tool: add/update/list/get operations with WebSocket events - API routes: GET /config, /skills, /skills/:name, /models, /models/resolve - Frontend: sidebar with model status, task list, config viewer, skills browser, permission log - Sliding sidebar animation using CSS transitions (not Svelte transitions)
Diffstat (limited to 'packages/core/src/config')
-rw-r--r--packages/core/src/config/index.ts3
-rw-r--r--packages/core/src/config/loader.ts119
-rw-r--r--packages/core/src/config/schema.ts235
-rw-r--r--packages/core/src/config/watcher.ts53
4 files changed, 313 insertions, 97 deletions
diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts
new file mode 100644
index 0000000..5b128c4
--- /dev/null
+++ b/packages/core/src/config/index.ts
@@ -0,0 +1,3 @@
+export { configToRuleset, loadConfig } 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 0e58ad2..3b4d733 100644
--- a/packages/core/src/config/loader.ts
+++ b/packages/core/src/config/loader.ts
@@ -1,25 +1,12 @@
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 } from "../types/index.js";
+import { validateConfig } from "./schema.js";
-// Strip inline comments that appear outside of quoted strings.
-// Handles both single- and double-quoted values.
-function stripInlineComment(line: string): string {
- // Walk character by character; track whether we're inside quotes.
- let inQuote: string | null = null;
- for (let i = 0; i < line.length; i++) {
- const ch = line[i];
- if (inQuote) {
- if (ch === inQuote) inQuote = null;
- } else if (ch === '"' || ch === "'") {
- inQuote = ch;
- } else if (ch === "#") {
- return line.slice(0, i).trimEnd();
- }
- }
- return line;
-}
+const DEFAULT_CONFIG: DispatchConfig = { permissions: {} };
const VALID_ACTIONS = new Set(["allow", "deny", "ask"]);
@@ -29,89 +16,27 @@ function validateAction(raw: string): "allow" | "deny" | "ask" {
return "ask";
}
-export interface DispatchConfig {
- permissions: Record<string, string | Record<string, string>>;
-}
-
-// Load dispatch.yaml from the given directory
+// Load dispatch.toml from the given directory
export function loadConfig(dir: string): DispatchConfig {
- const yamlPath = join(dir, "dispatch.yaml");
+ const tomlPath = join(dir, "dispatch.toml");
+ let raw: unknown;
try {
- const content = readFileSync(yamlPath, "utf-8");
- return parseYaml(content);
- } catch {
- return { permissions: {} };
- }
-}
-
-function expandHome(value: string): string {
- const home = homedir();
- return value.replace(/^\$HOME(?=[\/\\]|$)/, home).replace(/^~(?=[\/\\]|$)/, home);
-}
-
-function stripQuotes(s: string): string {
- if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
- return s.slice(1, -1);
- }
- return s;
-}
-
-// Parse simple YAML for the permissions structure
-function parseYaml(content: string): DispatchConfig {
- const permissions: Record<string, string | Record<string, string>> = {};
- const lines = content.split("\n");
-
- let inPermissions = false;
- let currentKey: string | null = null;
-
- for (const raw of lines) {
- // Skip comments and blank lines
- const trimmed = raw.trimEnd();
- const stripped = trimmed.trimStart();
- if (stripped === "" || stripped.startsWith("#")) continue;
-
- const indent = trimmed.length - stripped.length;
-
- if (indent === 0) {
- // Top-level key
- inPermissions = stripped.startsWith("permissions:");
- currentKey = null;
- continue;
- }
-
- if (!inPermissions) continue;
-
- if (indent === 2) {
- // permission key line: " key: value" or " key:"
- const colonIdx = stripped.indexOf(":");
- if (colonIdx === -1) continue;
- const key = stripQuotes(stripped.slice(0, colonIdx).trim());
- const valueRaw = stripInlineComment(stripped.slice(colonIdx + 1).trim()).trim();
- if (valueRaw === "" || valueRaw === null) {
- // nested map follows
- currentKey = key;
- permissions[currentKey] = {};
- } else {
- // inline value
- const value = stripQuotes(valueRaw);
- permissions[key] = value;
- currentKey = null;
- }
- continue;
- }
-
- if (indent >= 4 && currentKey !== null) {
- // sub-key line: " "pattern": action"
- const colonIdx = stripped.indexOf(":");
- if (colonIdx === -1) continue;
- const pattern = expandHome(stripQuotes(stripped.slice(0, colonIdx).trim()));
- const actionRaw = stripQuotes(stripInlineComment(stripped.slice(colonIdx + 1).trim()).trim());
- const action = validateAction(actionRaw);
- (permissions[currentKey] as Record<string, string>)[pattern] = action;
+ 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;
}
+ console.warn(`dispatch: failed to parse dispatch.toml: ${err instanceof Error ? err.message : String(err)}`);
+ throw err;
}
- return { permissions };
+ const { config, errors } = validateConfig(raw);
+ for (const e of errors) {
+ console.warn(`dispatch: config warning at ${e.path}: ${e.message}`);
+ }
+ return config;
}
// Convert the config's permission block to a Ruleset
@@ -126,8 +51,8 @@ export function configToRuleset(config: DispatchConfig): Ruleset {
} else {
for (const [rawPattern, rawAction] of Object.entries(value)) {
const pattern = rawPattern
- .replace(/^\$HOME(?=[\/\\]|$)/, home)
- .replace(/^~(?=[\/\\]|$)/, home);
+ .replace(/^\$HOME(?=[/\\]|$)/, home)
+ .replace(/^~(?=[/\\]|$)/, home);
const action = validateAction(rawAction);
rules.push({ permission, pattern, action });
}
diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts
new file mode 100644
index 0000000..d3eea98
--- /dev/null
+++ b/packages/core/src/config/schema.ts
@@ -0,0 +1,235 @@
+import type {
+ AgentTemplate,
+ ConfigError,
+ DispatchConfig,
+ KeyDefinition,
+ ModelDefinition,
+} from "../types/index.js";
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function isStringRecord(value: unknown): value is Record<string, string> {
+ if (!isRecord(value)) return false;
+ return Object.values(value).every((v) => typeof v === "string");
+}
+
+function isValidAction(value: string): boolean {
+ return value === "allow" || value === "deny" || value === "ask";
+}
+
+function isPermissionsValue(value: unknown): value is string | Record<string, string> {
+ return typeof value === "string" || isStringRecord(value);
+}
+
+function validatePermissions(
+ raw: unknown,
+ path: string,
+ errors: ConfigError[],
+): Record<string, string | Record<string, string>> {
+ if (!isRecord(raw)) {
+ errors.push({ path, message: "must be an object" });
+ return {};
+ }
+ const result: Record<string, string | Record<string, string>> = {};
+ for (const [key, value] of Object.entries(raw)) {
+ if (!isPermissionsValue(value)) {
+ errors.push({ path: `${path}.${key}`, message: "must be a string or a flat string-keyed object" });
+ continue;
+ }
+ if (typeof value === "string") {
+ if (!isValidAction(value)) {
+ errors.push({ path: `${path}.${key}`, message: `invalid action "${value}"; must be "allow", "deny", or "ask"` });
+ continue;
+ }
+ } else {
+ let hasError = false;
+ for (const [pattern, action] of Object.entries(value)) {
+ if (!isValidAction(action)) {
+ errors.push({ path: `${path}.${key}.${pattern}`, message: `invalid action "${action}"; must be "allow", "deny", or "ask"` });
+ hasError = true;
+ }
+ }
+ if (hasError) continue;
+ }
+ result[key] = value;
+ }
+ return result;
+}
+
+function validateAgentTemplate(
+ raw: unknown,
+ path: string,
+ errors: ConfigError[],
+): AgentTemplate | null {
+ if (!isRecord(raw)) {
+ errors.push({ path, message: "must be an object" });
+ return null;
+ }
+
+ const requiredStrings = ["name", "description", "system_prompt", "model_tag"] as const;
+ for (const field of requiredStrings) {
+ if (typeof raw[field] !== "string") {
+ errors.push({ path: `${path}.${field}`, message: "must be a string" });
+ return null;
+ }
+ }
+
+ if (!Array.isArray(raw["tools"]) || !raw["tools"].every((t) => typeof t === "string")) {
+ errors.push({ path: `${path}.tools`, message: "must be an array of strings" });
+ return null;
+ }
+
+ const perms = validatePermissions(raw["permissions"] ?? {}, `${path}.permissions`, errors);
+
+ return {
+ name: raw["name"] as string,
+ description: raw["description"] as string,
+ system_prompt: raw["system_prompt"] as string,
+ tools: raw["tools"] as string[],
+ model_tag: raw["model_tag"] as string,
+ permissions: perms,
+ };
+}
+
+function validateModel(raw: unknown, path: string, errors: ConfigError[]): ModelDefinition | null {
+ if (!isRecord(raw)) {
+ errors.push({ path, message: "must be an object" });
+ return null;
+ }
+ if (typeof raw["id"] !== "string") {
+ errors.push({ path: `${path}.id`, message: "must be a string" });
+ return null;
+ }
+ if (typeof raw["provider"] !== "string") {
+ errors.push({ path: `${path}.provider`, message: "must be a string" });
+ return null;
+ }
+ if (
+ !Array.isArray(raw["tags"]) ||
+ raw["tags"].length === 0 ||
+ !raw["tags"].every((t) => typeof t === "string")
+ ) {
+ errors.push({ path: `${path}.tags`, message: "must be a non-empty array of strings" });
+ return null;
+ }
+ return {
+ id: raw["id"] as string,
+ provider: raw["provider"] as string,
+ tags: raw["tags"] as string[],
+ };
+}
+
+function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefinition | null {
+ if (!isRecord(raw)) {
+ errors.push({ path, message: "must be an object" });
+ return null;
+ }
+ for (const field of ["id", "provider", "env", "base_url"] as const) {
+ if (typeof raw[field] !== "string") {
+ errors.push({ path: `${path}.${field}`, message: "must be a string" });
+ return null;
+ }
+ }
+ return {
+ id: raw["id"] as string,
+ provider: raw["provider"] as string,
+ env: raw["env"] as string,
+ base_url: raw["base_url"] as string,
+ };
+}
+
+export function validateConfig(raw: unknown): { config: DispatchConfig; errors: ConfigError[] } {
+ const errors: ConfigError[] = [];
+
+ if (!isRecord(raw)) {
+ errors.push({ path: "", message: "config must be an object" });
+ return { config: { permissions: {} }, errors };
+ }
+
+ // permissions (required, but can be empty)
+ const permissions = validatePermissions(raw["permissions"] ?? {}, "permissions", errors);
+
+ // agents (optional)
+ let agents: Record<string, AgentTemplate> | undefined;
+ if (raw["agents"] !== undefined) {
+ if (!isRecord(raw["agents"])) {
+ errors.push({ path: "agents", message: "must be an object" });
+ } else {
+ agents = {};
+ for (const [key, value] of Object.entries(raw["agents"])) {
+ const agent = validateAgentTemplate(value, `agents.${key}`, errors);
+ if (agent) agents[key] = agent;
+ }
+ }
+ }
+
+ // models (optional)
+ let models: ModelDefinition[] | undefined;
+ if (raw["models"] !== undefined) {
+ if (!Array.isArray(raw["models"])) {
+ errors.push({ path: "models", message: "must be an array" });
+ } else {
+ models = [];
+ for (let i = 0; i < raw["models"].length; i++) {
+ const model = validateModel(raw["models"][i], `models[${i}]`, errors);
+ if (model) models.push(model);
+ }
+ }
+ }
+
+ // keys (optional)
+ let keys: KeyDefinition[] | undefined;
+ if (raw["keys"] !== undefined) {
+ if (!Array.isArray(raw["keys"])) {
+ errors.push({ path: "keys", message: "must be an array" });
+ } else {
+ keys = [];
+ for (let i = 0; i < raw["keys"].length; i++) {
+ const key = validateKey(raw["keys"][i], `keys[${i}]`, errors);
+ if (key) keys.push(key);
+ }
+ }
+ }
+
+ // fallback (optional)
+ let fallback: string[] | undefined;
+ if (raw["fallback"] !== undefined) {
+ if (!Array.isArray(raw["fallback"]) || !raw["fallback"].every((f) => typeof f === "string")) {
+ errors.push({ path: "fallback", message: "must be an array of strings" });
+ } else {
+ fallback = raw["fallback"] as string[];
+ // Validate that referenced key IDs exist
+ const keyIds = new Set((keys ?? []).map((k) => k.id));
+ for (const id of fallback) {
+ if (!keyIds.has(id)) {
+ errors.push({ path: "fallback", message: `key id "${id}" not found in keys` });
+ }
+ }
+ }
+ }
+
+ // Warn if agent model_tags don't match any model
+ if (agents && models) {
+ const allTags = new Set(models.flatMap((m) => m.tags));
+ for (const [name, agent] of Object.entries(agents)) {
+ if (!allTags.has(agent.model_tag)) {
+ errors.push({
+ path: `agents.${name}.model_tag`,
+ message: `no model found with tag "${agent.model_tag}"`,
+ });
+ }
+ }
+ }
+
+ const config: DispatchConfig = {
+ permissions,
+ ...(agents !== undefined && { agents }),
+ ...(models !== undefined && { models }),
+ ...(keys !== undefined && { keys }),
+ ...(fallback !== undefined && { fallback }),
+ };
+
+ return { config, errors };
+}
diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts
new file mode 100644
index 0000000..42b2f87
--- /dev/null
+++ b/packages/core/src/config/watcher.ts
@@ -0,0 +1,53 @@
+import { watch } from "chokidar";
+import { join } from "node:path";
+import type { DispatchConfig } from "../types/index.js";
+import { loadConfig } from "./loader.js";
+
+export function createConfigWatcher(
+ dir: string,
+ onChange: (config: DispatchConfig) => void,
+): { close(): void } {
+ const tomlPath = join(dir, "dispatch.toml");
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
+
+ const watcher = watch(tomlPath, {
+ ignoreInitial: true,
+ persistent: false,
+ });
+
+ const handleChange = () => {
+ if (debounceTimer !== null) {
+ clearTimeout(debounceTimer);
+ }
+ debounceTimer = setTimeout(() => {
+ debounceTimer = null;
+ console.log(`dispatch: reloading config from ${tomlPath}`);
+ try {
+ const config = loadConfig(dir);
+ onChange(config);
+ } catch (err) {
+ console.warn(`dispatch: retaining last known config due to parse error: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ }, 300);
+ };
+
+ watcher.on("change", handleChange);
+ watcher.on("add", handleChange);
+ watcher.on("unlink", handleChange);
+
+ watcher.on("error", (err) => {
+ console.warn(`dispatch: config watcher error: ${err instanceof Error ? err.message : String(err)}`);
+ });
+
+ return {
+ close() {
+ if (debounceTimer !== null) {
+ clearTimeout(debounceTimer);
+ debounceTimer = null;
+ }
+ watcher.close().catch((err) => {
+ console.warn(`dispatch: error closing config watcher: ${err instanceof Error ? err.message : String(err)}`);
+ });
+ },
+ };
+}