From fbcbc60e85cefb70e7ade7bb92f9bec23dc81b79 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 10:07:31 +0100 Subject: [PATCH] feat: unify skills config --- CHANGELOG.md | 7 ++++ docs/agent.md | 2 +- docs/configuration.md | 73 +++++++++++++++------------------ docs/index.md | 1 + docs/mac/skills.md | 8 ++-- docs/skills-config.md | 58 +++++++++++++++++++++++++++ docs/skills.md | 47 ++++++++++++++-------- src/agents/skills-status.ts | 7 ++++ src/agents/skills.test.ts | 80 +++++++++++++++++++++++++++++++++---- src/agents/skills.ts | 36 +++++++++++++++-- src/config/config.ts | 58 ++++++++++++++++----------- src/gateway/server.ts | 8 ++-- 12 files changed, 287 insertions(+), 98 deletions(-) create mode 100644 docs/skills-config.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 128c13e34..e49ecf125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## 2.0.0-beta5 — Unreleased +### Breaking +- Skills config schema moved under `skills.*`: + - `skillsLoad.extraDirs` → `skills.load.extraDirs` + - `skillsInstall.*` → `skills.install.*` + - per-skill config map moved to `skills.entries` (e.g. `skills.peekaboo.enabled` → `skills.entries.peekaboo.enabled`) + - new optional bundled allowlist: `skills.allowBundled` (only affects bundled skills) + ### Features - Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech. - UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android). diff --git a/docs/agent.md b/docs/agent.md index ede88e0c9..60ce61c3f 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -39,7 +39,7 @@ Clawdis loads skills from three locations (workspace wins on name conflict): - Managed/local: `~/.clawdis/skills` - Workspace: `/skills` -Skills can be gated by config/env (see `skills.*` in `docs/configuration.md`). +Skills can be gated by config/env (see `skills` in `docs/configuration.md`). ## p-mono integration diff --git a/docs/configuration.md b/docs/configuration.md index cd7801fba..978d662fa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -390,11 +390,21 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto } ``` -### `skills` (skill config/env) +### `skills` (skills config) -Configure skill toggles and env injection. Applies to **bundled** skills and `~/.clawdis/skills` (workspace skills still win on name conflicts). +Controls bundled allowlist, install preferences, extra skill folders, and per-skill +overrides. Applies to **bundled** skills and `~/.clawdis/skills` (workspace skills +still win on name conflicts). -Common fields per skill: +Fields: +- `allowBundled`: optional allowlist for **bundled** skills only. If set, only those + bundled skills are eligible (managed/workspace skills unaffected). +- `load.extraDirs`: additional skill directories to scan (lowest precedence). +- `install.preferBrew`: prefer brew installers when available (default: true). +- `install.nodeManager`: node installer preference (`npm` | `pnpm` | `yarn`, default: npm). +- `entries.`: per-skill config overrides. + +Per-skill fields: - `enabled`: set `false` to disable a skill even if it’s bundled/installed. - `env`: environment variables injected for the agent run (only if not already set). - `apiKey`: optional convenience for skills that declare a primary env var (e.g. `nano-banana-pro` → `GEMINI_API_KEY`). @@ -404,44 +414,27 @@ Example: ```json5 { skills: { - "nano-banana-pro": { - apiKey: "GEMINI_KEY_HERE", - env: { - GEMINI_API_KEY: "GEMINI_KEY_HERE" - } + allowBundled: ["brave-search", "gemini"], + load: { + extraDirs: [ + "~/Projects/agent-scripts/skills", + "~/Projects/oss/some-skill-pack/skills" + ] }, - peekaboo: { enabled: true }, - sag: { enabled: false } - } -} -``` - -### `skillsInstall` (installer preference) - -Controls which installer is surfaced by the macOS Skills UI when a skill offers -multiple install options. Defaults to **brew when available** and **npm** for -node installs. - -```json5 -{ - skillsInstall: { - preferBrew: true, - nodeManager: "npm" // npm | pnpm | yarn - } -} -``` - -### `skillsLoad` - -Additional skill directories to scan (lowest precedence). This is useful if you keep skills in a separate repo but want Clawdis to pick them up without copying them into the workspace. - -```json5 -{ - skillsLoad: { - extraDirs: [ - "~/Projects/agent-scripts/skills", - "~/Projects/oss/some-skill-pack/skills" - ] + install: { + preferBrew: true, + nodeManager: "npm" + }, + entries: { + "nano-banana-pro": { + apiKey: "GEMINI_KEY_HERE", + env: { + GEMINI_API_KEY: "GEMINI_KEY_HERE" + } + }, + peekaboo: { enabled: true }, + sag: { enabled: false } + } } } ``` diff --git a/docs/index.md b/docs/index.md index ff4852755..98bbc0e71 100644 --- a/docs/index.md +++ b/docs/index.md @@ -119,6 +119,7 @@ Example: - [Nix mode](./nix.md) - [Clawd personal assistant setup](./clawd.md) - [Skills](./skills.md) + - [Skills config](./skills-config.md) - [Workspace templates](./templates/AGENTS.md) - [Gateway runbook](./gateway.md) - [Nodes (iOS/Android)](./nodes.md) diff --git a/docs/mac/skills.md b/docs/mac/skills.md index 4ab81ca8a..8188be402 100644 --- a/docs/mac/skills.md +++ b/docs/mac/skills.md @@ -9,16 +9,18 @@ read_when: The macOS app surfaces Clawdis skills via the gateway; it does not parse skills locally. ## Data source -- `skills.status` (gateway) returns all skills plus eligibility and missing requirements. +- `skills.status` (gateway) returns all skills plus eligibility and missing requirements + (including allowlist blocks for bundled skills). - Requirements are derived from `metadata.clawdis.requires` in each `SKILL.md`. ## Install actions - `metadata.clawdis.install` defines install options (brew/node/go/uv). - The app calls `skills.install` to run installers on the gateway host. -- The gateway surfaces only one preferred installer when multiple are provided (brew when available, otherwise node manager from `skillsInstall`, default npm). +- The gateway surfaces only one preferred installer when multiple are provided + (brew when available, otherwise node manager from `skills.install`, default npm). ## Env/API keys -- The app stores keys in `~/.clawdis/clawdis.json` under `skills.`. +- The app stores keys in `~/.clawdis/clawdis.json` under `skills.entries.`. - `skills.update` patches `enabled`, `apiKey`, and `env`. ## Remote mode diff --git a/docs/skills-config.md b/docs/skills-config.md new file mode 100644 index 000000000..657c14fb4 --- /dev/null +++ b/docs/skills-config.md @@ -0,0 +1,58 @@ +--- +summary: "Skills config schema and examples" +read_when: + - Adding or modifying skills config + - Adjusting bundled allowlist or install behavior +--- +# Skills Config + +All skills-related configuration lives under `skills` in `~/.clawdis/clawdis.json`. + +```json5 +{ + skills: { + allowBundled: ["brave-search", "gemini"], + load: { + extraDirs: [ + "~/Projects/agent-scripts/skills", + "~/Projects/oss/some-skill-pack/skills" + ] + }, + install: { + preferBrew: true, + nodeManager: "npm" // npm | pnpm | yarn + }, + entries: { + "nano-banana-pro": { + enabled: true, + apiKey: "GEMINI_KEY_HERE", + env: { + GEMINI_API_KEY: "GEMINI_KEY_HERE" + } + }, + peekaboo: { enabled: true }, + sag: { enabled: false } + } + } +} +``` + +## Fields + +- `allowBundled`: optional allowlist for **bundled** skills only. When set, only + bundled skills in the list are eligible (managed/workspace skills unaffected). +- `load.extraDirs`: additional skill directories to scan (lowest precedence). +- `install.preferBrew`: prefer brew installers when available (default: true). +- `install.nodeManager`: node installer preference (`npm` | `pnpm` | `yarn`, default: npm). +- `entries.`: per-skill overrides. + +Per-skill fields: +- `enabled`: set `false` to disable a skill even if it’s bundled/installed. +- `env`: environment variables injected for the agent run (only if not already set). +- `apiKey`: optional convenience for skills that declare a primary env var. + +## Notes + +- Keys under `entries` map to the skill name by default. If a skill defines + `metadata.clawdis.skillKey`, use that key instead. +- Changes to skills are picked up on the next new session. diff --git a/docs/skills.md b/docs/skills.md index 3bccd5028..48783665e 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -21,7 +21,8 @@ If a skill name conflicts, precedence is: `/skills` (highest) → `~/.clawdis/skills` → bundled skills (lowest) -Additionally, you can configure extra skill folders (lowest precedence) via `skillsLoad.extraDirs` in `~/.clawdis/clawdis.json`. +Additionally, you can configure extra skill folders (lowest precedence) via +`skills.load.extraDirs` in `~/.clawdis/clawdis.json`. ## Format (AgentSkills + Pi-compatible) @@ -61,7 +62,7 @@ Fields under `metadata.clawdis`: - `requires.bins` — list; each must exist on `PATH`. - `requires.env` — list; env var must exist **or** be provided in config. - `requires.config` — list of `clawdis.json` paths that must be truthy. -- `primaryEnv` — env var name associated with `skills..apiKey`. +- `primaryEnv` — env var name associated with `skills.entries..apiKey`. - `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv). Installer example: @@ -76,9 +77,10 @@ metadata: {"clawdis":{"emoji":"♊️","requires":{"bins":["gemini"]},"install": Notes: - If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node). -- Node installs honor `skillsInstall.nodeManager` in `clawdis.json` (default: npm; options: npm/pnpm/yarn). +- Node installs honor `skills.install.nodeManager` in `clawdis.json` (default: npm; options: npm/pnpm/yarn). -If no `metadata.clawdis` is present, the skill is always eligible (unless disabled in config). +If no `metadata.clawdis` is present, the skill is always eligible (unless +disabled in config or blocked by `skills.allowBundled` for bundled skills). ## Config overrides (`~/.clawdis/clawdis.json`) @@ -87,33 +89,39 @@ Bundled/managed skills can be toggled and supplied with env values: ```json5 { skills: { - "nano-banana-pro": { - enabled: true, - apiKey: "GEMINI_KEY_HERE", - env: { - GEMINI_API_KEY: "GEMINI_KEY_HERE" - } - }, - peekaboo: { enabled: true }, - sag: { enabled: false } + entries: { + "nano-banana-pro": { + enabled: true, + apiKey: "GEMINI_KEY_HERE", + env: { + GEMINI_API_KEY: "GEMINI_KEY_HERE" + } + }, + peekaboo: { enabled: true }, + sag: { enabled: false } + } } } ``` Note: if the skill name contains hyphens, quote the key (JSON5 allows quoted keys). -Config keys match the **skill name**. We don’t require a custom `skillKey`. +Config keys match the **skill name** by default. If a skill defines +`metadata.clawdis.skillKey`, use that key under `skills.entries`. Rules: - `enabled: false` disables the skill even if it’s bundled/installed. - `env`: injected **only if** the variable isn’t already set in the process. - `apiKey`: convenience for skills that declare `metadata.clawdis.primaryEnv`. +- `allowBundled`: optional allowlist for **bundled** skills only. If set, only + bundled skills in the list are eligible (managed/workspace skills unaffected). ## Environment injection (per agent run) When an agent run starts, Clawdis: 1) Reads skill metadata. -2) Applies any `skills..env` or `skills..apiKey` to `process.env`. +2) Applies any `skills.entries..env` or `skills.entries..apiKey` to + `process.env`. 3) Builds the system prompt with **eligible** skills. 4) Restores the original environment after the run ends. @@ -125,7 +133,14 @@ Clawdis snapshots the eligible skills **when a session starts** and reuses that ## Managed skills lifecycle -Clawdis ships a baseline set of skills as **bundled skills** as part of the install (npm package or Clawdis.app). `~/.clawdis/skills` exists for local overrides (for example, pinning/patching a skill without changing the bundled copy). Workspace skills are user-owned and override both on name conflicts. +Clawdis ships a baseline set of skills as **bundled skills** as part of the +install (npm package or Clawdis.app). `~/.clawdis/skills` exists for local +overrides (for example, pinning/patching a skill without changing the bundled +copy). Workspace skills are user-owned and override both on name conflicts. + +## Config reference + +See `docs/skills-config.md` for the full configuration schema. --- diff --git a/src/agents/skills-status.ts b/src/agents/skills-status.ts index af0eee27d..367aadad4 100644 --- a/src/agents/skills-status.ts +++ b/src/agents/skills-status.ts @@ -6,9 +6,11 @@ import { hasBinary, isConfigPathTruthy, loadWorkspaceSkillEntries, + resolveBundledAllowlist, resolveConfigPath, resolveSkillConfig, resolveSkillsInstallPreferences, + isBundledSkillAllowed, type SkillEntry, type SkillInstallSpec, type SkillsInstallPreferences, @@ -39,6 +41,7 @@ export type SkillStatusEntry = { homepage?: string; always: boolean; disabled: boolean; + blockedByAllowlist: boolean; eligible: boolean; requirements: { bins: string[]; @@ -132,6 +135,8 @@ function buildSkillStatus( const skillKey = resolveSkillKey(entry); const skillConfig = resolveSkillConfig(config, skillKey); const disabled = skillConfig?.enabled === false; + const allowBundled = resolveBundledAllowlist(config); + const blockedByAllowlist = !isBundledSkillAllowed(entry, allowBundled); const always = entry.clawdis?.always === true; const emoji = entry.clawdis?.emoji ?? entry.frontmatter.emoji; const homepageRaw = @@ -173,6 +178,7 @@ function buildSkillStatus( : { bins: missingBins, env: missingEnv, config: missingConfig }; const eligible = !disabled && + !blockedByAllowlist && (always || (missing.bins.length === 0 && missing.env.length === 0 && @@ -190,6 +196,7 @@ function buildSkillStatus( homepage, always, disabled, + blockedByAllowlist, eligible, requirements: { bins: requiredBins, diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index 503ca50e0..13b4f9feb 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -102,7 +102,7 @@ describe("buildWorkspaceSkillsPrompt", () => { const prompt = buildWorkspaceSkillsPrompt(workspaceDir, { bundledSkillsDir: bundledDir, managedSkillsDir: managedDir, - config: { skillsLoad: { extraDirs: [extraDir] } }, + config: { skills: { load: { extraDirs: [extraDir] } } }, }); expect(prompt).toContain("Workspace version"); @@ -148,13 +148,15 @@ describe("buildWorkspaceSkillsPrompt", () => { const missingPrompt = buildWorkspaceSkillsPrompt(workspaceDir, { managedSkillsDir: path.join(workspaceDir, ".managed"), - config: { skills: { "nano-banana-pro": { apiKey: "" } } }, + config: { skills: { entries: { "nano-banana-pro": { apiKey: "" } } } }, }); expect(missingPrompt).not.toContain("nano-banana-pro"); const enabledPrompt = buildWorkspaceSkillsPrompt(workspaceDir, { managedSkillsDir: path.join(workspaceDir, ".managed"), - config: { skills: { "nano-banana-pro": { apiKey: "test-key" } } }, + config: { + skills: { entries: { "nano-banana-pro": { apiKey: "test-key" } } }, + }, }); expect(enabledPrompt).toContain("nano-banana-pro"); } finally { @@ -252,7 +254,7 @@ describe("buildWorkspaceSkillsPrompt", () => { managedSkillsDir: path.join(workspaceDir, ".managed"), config: { browser: { enabled: false }, - skills: { "env-skill": { apiKey: "ok" } }, + skills: { entries: { "env-skill": { apiKey: "ok" } } }, }, }); expect(gatedPrompt).toContain("bin-skill"); @@ -276,10 +278,39 @@ describe("buildWorkspaceSkillsPrompt", () => { const prompt = buildWorkspaceSkillsPrompt(workspaceDir, { managedSkillsDir: path.join(workspaceDir, ".managed"), - config: { skills: { alias: { enabled: false } } }, + config: { skills: { entries: { alias: { enabled: false } } } }, }); expect(prompt).not.toContain("alias-skill"); }); + + it("applies bundled allowlist without affecting workspace skills", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); + const bundledDir = path.join(workspaceDir, ".bundled"); + const bundledSkillDir = path.join(bundledDir, "peekaboo"); + const workspaceSkillDir = path.join(workspaceDir, "skills", "demo-skill"); + + await writeSkill({ + dir: bundledSkillDir, + name: "peekaboo", + description: "Capture UI", + body: "# Peekaboo\n", + }); + await writeSkill({ + dir: workspaceSkillDir, + name: "demo-skill", + description: "Workspace version", + body: "# Workspace\n", + }); + + const prompt = buildWorkspaceSkillsPrompt(workspaceDir, { + bundledSkillsDir: bundledDir, + managedSkillsDir: path.join(workspaceDir, ".managed"), + config: { skills: { allowBundled: ["missing-skill"] } }, + }); + + expect(prompt).toContain("Workspace version"); + expect(prompt).not.toContain("peekaboo"); + }); }); describe("loadWorkspaceSkillEntries", () => { @@ -337,6 +368,39 @@ describe("buildWorkspaceSkillStatus", () => { expect(skill?.missing.config).toContain("browser.enabled"); expect(skill?.install[0]?.id).toBe("brew"); }); + + it("marks bundled skills blocked by allowlist", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); + const bundledDir = path.join(workspaceDir, ".bundled"); + const bundledSkillDir = path.join(bundledDir, "peekaboo"); + const originalBundled = process.env.CLAWDIS_BUNDLED_SKILLS_DIR; + + await writeSkill({ + dir: bundledSkillDir, + name: "peekaboo", + description: "Capture UI", + body: "# Peekaboo\n", + }); + + try { + process.env.CLAWDIS_BUNDLED_SKILLS_DIR = bundledDir; + const report = buildWorkspaceSkillStatus(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + config: { skills: { allowBundled: ["other-skill"] } }, + }); + const skill = report.skills.find((entry) => entry.name === "peekaboo"); + + expect(skill).toBeDefined(); + expect(skill?.blockedByAllowlist).toBe(true); + expect(skill?.eligible).toBe(false); + } finally { + if (originalBundled === undefined) { + delete process.env.CLAWDIS_BUNDLED_SKILLS_DIR; + } else { + process.env.CLAWDIS_BUNDLED_SKILLS_DIR = originalBundled; + } + } + }); }); describe("applySkillEnvOverrides", () => { @@ -360,7 +424,7 @@ describe("applySkillEnvOverrides", () => { const restore = applySkillEnvOverrides({ skills: entries, - config: { skills: { "env-skill": { apiKey: "injected" } } }, + config: { skills: { entries: { "env-skill": { apiKey: "injected" } } } }, }); try { @@ -388,7 +452,7 @@ describe("applySkillEnvOverrides", () => { const snapshot = buildWorkspaceSkillSnapshot(workspaceDir, { managedSkillsDir: path.join(workspaceDir, ".managed"), - config: { skills: { "env-skill": { apiKey: "snap-key" } } }, + config: { skills: { entries: { "env-skill": { apiKey: "snap-key" } } } }, }); const originalEnv = process.env.ENV_KEY; @@ -396,7 +460,7 @@ describe("applySkillEnvOverrides", () => { const restore = applySkillEnvOverridesFromSnapshot({ snapshot, - config: { skills: { "env-skill": { apiKey: "snap-key" } } }, + config: { skills: { entries: { "env-skill": { apiKey: "snap-key" } } } }, }); try { diff --git a/src/agents/skills.ts b/src/agents/skills.ts index 9a041b638..0e83066a6 100644 --- a/src/agents/skills.ts +++ b/src/agents/skills.ts @@ -173,7 +173,7 @@ const DEFAULT_CONFIG_VALUES: Record = { export function resolveSkillsInstallPreferences( config?: ClawdisConfig, ): SkillsInstallPreferences { - const raw = config?.skillsInstall; + const raw = config?.skills?.install; const preferBrew = raw?.preferBrew ?? true; const managerRaw = typeof raw?.nodeManager === "string" ? raw.nodeManager.trim() : ""; @@ -213,13 +213,36 @@ export function resolveSkillConfig( config: ClawdisConfig | undefined, skillKey: string, ): SkillConfig | undefined { - const skills = config?.skills; + const skills = config?.skills?.entries; if (!skills || typeof skills !== "object") return undefined; const entry = (skills as Record)[skillKey]; if (!entry || typeof entry !== "object") return undefined; return entry; } +function normalizeAllowlist(input: unknown): string[] | undefined { + if (!input) return undefined; + if (!Array.isArray(input)) return undefined; + const normalized = input + .map((entry) => String(entry).trim()) + .filter(Boolean); + return normalized.length > 0 ? normalized : undefined; +} + +function isBundledSkill(entry: SkillEntry): boolean { + return entry.skill.source === "clawdis-bundled"; +} + +export function isBundledSkillAllowed( + entry: SkillEntry, + allowlist?: string[], +): boolean { + if (!allowlist || allowlist.length === 0) return true; + if (!isBundledSkill(entry)) return true; + const key = resolveSkillKey(entry.skill, entry); + return allowlist.includes(key) || allowlist.includes(entry.skill.name); +} + export function hasBinary(bin: string): boolean { const pathEnv = process.env.PATH ?? ""; const parts = pathEnv.split(path.delimiter).filter(Boolean); @@ -298,8 +321,10 @@ function shouldIncludeSkill(params: { const { entry, config } = params; const skillKey = resolveSkillKey(entry.skill, entry); const skillConfig = resolveSkillConfig(config, skillKey); + const allowBundled = normalizeAllowlist(config?.skills?.allowBundled); if (skillConfig?.enabled === false) return false; + if (!isBundledSkillAllowed(entry, allowBundled)) return false; if (entry.clawdis?.always === true) { return true; } @@ -442,7 +467,7 @@ function loadSkillEntries( opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills"); const workspaceSkillsDir = path.join(workspaceDir, "skills"); const bundledSkillsDir = opts?.bundledSkillsDir ?? resolveBundledSkillsDir(); - const extraDirsRaw = opts?.config?.skillsLoad?.extraDirs ?? []; + const extraDirsRaw = opts?.config?.skills?.load?.extraDirs ?? []; const extraDirs = extraDirsRaw .map((d) => (typeof d === "string" ? d.trim() : "")) .filter(Boolean); @@ -548,3 +573,8 @@ export function filterWorkspaceSkillEntries( ): SkillEntry[] { return filterSkillEntries(entries, config); } +export function resolveBundledAllowlist( + config?: ClawdisConfig, +): string[] | undefined { + return normalizeAllowlist(config?.skills?.allowBundled); +} diff --git a/src/config/config.ts b/src/config/config.ts index f82230531..fe898c89d 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -307,6 +307,14 @@ export type SkillsInstallConfig = { nodeManager?: "npm" | "pnpm" | "yarn"; }; +export type SkillsConfig = { + /** Optional bundled-skill allowlist (only affects bundled skills). */ + allowBundled?: string[]; + load?: SkillsLoadConfig; + install?: SkillsInstallConfig; + entries?: Record; +}; + export type ModelApi = | "openai-completions" | "openai-responses" @@ -364,8 +372,7 @@ export type ClawdisConfig = { /** Accent color for Clawdis UI chrome (hex). */ seamColor?: string; }; - skillsLoad?: SkillsLoadConfig; - skillsInstall?: SkillsInstallConfig; + skills?: SkillsConfig; models?: ModelsConfig; agent?: { /** Model id (provider/model), e.g. "anthropic/claude-opus-4-5". */ @@ -424,7 +431,7 @@ export type ClawdisConfig = { canvasHost?: CanvasHostConfig; talk?: TalkConfig; gateway?: GatewayConfig; - skills?: Record; + skills?: SkillsConfig; }; /** @@ -880,30 +887,33 @@ const ClawdisSchema = z.object({ .optional(), }) .optional(), - skillsLoad: z - .object({ - extraDirs: z.array(z.string()).optional(), - }) - .optional(), - skillsInstall: z - .object({ - preferBrew: z.boolean().optional(), - nodeManager: z - .union([z.literal("npm"), z.literal("pnpm"), z.literal("yarn")]) - .optional(), - }) - .optional(), skills: z - .record( - z.string(), - z + .object({ + allowBundled: z.array(z.string()).optional(), + load: z .object({ - enabled: z.boolean().optional(), - apiKey: z.string().optional(), - env: z.record(z.string(), z.string()).optional(), + extraDirs: z.array(z.string()).optional(), }) - .passthrough(), - ) + .optional(), + install: z + .object({ + preferBrew: z.boolean().optional(), + nodeManager: z + .union([z.literal("npm"), z.literal("pnpm"), z.literal("yarn")]) + .optional(), + }) + .optional(), + entries: z.record( + z.string(), + z + .object({ + enabled: z.boolean().optional(), + apiKey: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), + }) + .passthrough(), + ).optional(), + }) .optional(), }); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 2d7a885af..c0cc55de2 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -4821,8 +4821,9 @@ export async function startGatewayServer( }; const cfg = loadConfig(); const skills = cfg.skills ? { ...cfg.skills } : {}; - const current = skills[p.skillKey] - ? { ...skills[p.skillKey] } + const entries = skills.entries ? { ...skills.entries } : {}; + const current = entries[p.skillKey] + ? { ...entries[p.skillKey] } : {}; if (typeof p.enabled === "boolean") { current.enabled = p.enabled; @@ -4843,7 +4844,8 @@ export async function startGatewayServer( } current.env = nextEnv; } - skills[p.skillKey] = current; + entries[p.skillKey] = current; + skills.entries = entries; const nextConfig: ClawdisConfig = { ...cfg, skills,