diff --git a/docs/configuration.md b/docs/configuration.md index 1e89aa242..61189b03a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -157,6 +157,21 @@ Example: } ``` +### `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" + ] + } +} +``` + ### `browser` (clawd-managed Chrome) Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server. diff --git a/docs/skills.md b/docs/skills.md index 40d830ded..3b9e8c19e 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -21,6 +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`. + ## Format (AgentSkills + Pi-compatible) `SKILL.md` must include at least: diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index 9a0fa6221..df858de9b 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -67,6 +67,49 @@ describe("buildWorkspaceSkillsPrompt", () => { expect(prompt).toContain(path.join(bundledSkillDir, "SKILL.md")); }); + it("loads extra skill folders from config (lowest precedence)", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); + const extraDir = path.join(workspaceDir, ".extra"); + const bundledDir = path.join(workspaceDir, ".bundled"); + const managedDir = path.join(workspaceDir, ".managed"); + + await writeSkill({ + dir: path.join(extraDir, "demo-skill"), + name: "demo-skill", + description: "Extra version", + body: "# Extra\n", + }); + await writeSkill({ + dir: path.join(bundledDir, "demo-skill"), + name: "demo-skill", + description: "Bundled version", + body: "# Bundled\n", + }); + await writeSkill({ + dir: path.join(managedDir, "demo-skill"), + name: "demo-skill", + description: "Managed version", + body: "# Managed\n", + }); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "demo-skill"), + name: "demo-skill", + description: "Workspace version", + body: "# Workspace\n", + }); + + const prompt = buildWorkspaceSkillsPrompt(workspaceDir, { + bundledSkillsDir: bundledDir, + managedSkillsDir: managedDir, + config: { skillsLoad: { extraDirs: [extraDir] } }, + }); + + expect(prompt).toContain("Workspace version"); + expect(prompt).not.toContain("Managed version"); + expect(prompt).not.toContain("Bundled version"); + expect(prompt).not.toContain("Extra version"); + }); + it("loads skills from workspace skills/", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); const skillDir = path.join(workspaceDir, "skills", "demo-skill"); diff --git a/src/agents/skills.ts b/src/agents/skills.ts index 90a0a1051..f097273fd 100644 --- a/src/agents/skills.ts +++ b/src/agents/skills.ts @@ -9,7 +9,7 @@ import { } from "@mariozechner/pi-coding-agent"; import type { ClawdisConfig, SkillConfig } from "../config/config.js"; -import { CONFIG_DIR } from "../utils.js"; +import { CONFIG_DIR, resolveUserPath } from "../utils.js"; export type SkillInstallSpec = { id?: string; @@ -414,6 +414,10 @@ 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 extraDirs = extraDirsRaw + .map((d) => (typeof d === "string" ? d.trim() : "")) + .filter(Boolean); const bundledSkills = bundledSkillsDir ? loadSkillsFromDir({ @@ -421,6 +425,13 @@ function loadSkillEntries( source: "clawdis-bundled", }) : []; + const extraSkills = extraDirs.flatMap((dir) => { + const resolved = resolveUserPath(dir); + return loadSkillsFromDir({ + dir: resolved, + source: "clawdis-extra", + }); + }); const managedSkills = loadSkillsFromDir({ dir: managedSkillsDir, source: "clawdis-managed", @@ -431,7 +442,8 @@ function loadSkillEntries( }); const merged = new Map(); - // Precedence: bundled < managed < workspace + // Precedence: extra < bundled < managed < workspace + for (const skill of extraSkills) merged.set(skill.name, skill); for (const skill of bundledSkills) merged.set(skill.name, skill); for (const skill of managedSkills) merged.set(skill.name, skill); for (const skill of workspaceSkills) merged.set(skill.name, skill); diff --git a/src/config/config.ts b/src/config/config.ts index 2804d07d5..3ca35b56d 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -127,6 +127,14 @@ export type SkillConfig = { [key: string]: unknown; }; +export type SkillsLoadConfig = { + /** + * Additional skill folders to scan (lowest precedence). + * Each directory should contain skill subfolders with `SKILL.md`. + */ + extraDirs?: string[]; +}; + export type ClawdisConfig = { identity?: { name?: string; @@ -135,6 +143,7 @@ export type ClawdisConfig = { }; logging?: LoggingConfig; browser?: BrowserConfig; + skillsLoad?: SkillsLoadConfig; inbound?: { allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) /** Agent working directory (preferred). Used as the default cwd for agent runs. */ @@ -357,6 +366,11 @@ const ClawdisSchema = z.object({ .optional(), }) .optional(), + skillsLoad: z + .object({ + extraDirs: z.array(z.string()).optional(), + }) + .optional(), skills: z .record( z.string(),