feat(skills): add extraDirs load paths

This commit is contained in:
Peter Steinberger
2025-12-20 12:26:58 +00:00
parent ff6a918e7e
commit 973bf67683
5 changed files with 88 additions and 2 deletions

View File

@@ -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) ### `browser` (clawd-managed Chrome)
Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server. Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server.

View File

@@ -21,6 +21,8 @@ If a skill name conflicts, precedence is:
`<workspace>/skills` (highest) → `~/.clawdis/skills` → bundled skills (lowest) `<workspace>/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) ## Format (AgentSkills + Pi-compatible)
`SKILL.md` must include at least: `SKILL.md` must include at least:

View File

@@ -67,6 +67,49 @@ describe("buildWorkspaceSkillsPrompt", () => {
expect(prompt).toContain(path.join(bundledSkillDir, "SKILL.md")); 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 () => { it("loads skills from workspace skills/", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
const skillDir = path.join(workspaceDir, "skills", "demo-skill"); const skillDir = path.join(workspaceDir, "skills", "demo-skill");

View File

@@ -9,7 +9,7 @@ import {
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
import type { ClawdisConfig, SkillConfig } from "../config/config.js"; import type { ClawdisConfig, SkillConfig } from "../config/config.js";
import { CONFIG_DIR } from "../utils.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js";
export type SkillInstallSpec = { export type SkillInstallSpec = {
id?: string; id?: string;
@@ -414,6 +414,10 @@ function loadSkillEntries(
opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills"); opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills");
const workspaceSkillsDir = path.join(workspaceDir, "skills"); const workspaceSkillsDir = path.join(workspaceDir, "skills");
const bundledSkillsDir = opts?.bundledSkillsDir ?? resolveBundledSkillsDir(); 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 const bundledSkills = bundledSkillsDir
? loadSkillsFromDir({ ? loadSkillsFromDir({
@@ -421,6 +425,13 @@ function loadSkillEntries(
source: "clawdis-bundled", source: "clawdis-bundled",
}) })
: []; : [];
const extraSkills = extraDirs.flatMap((dir) => {
const resolved = resolveUserPath(dir);
return loadSkillsFromDir({
dir: resolved,
source: "clawdis-extra",
});
});
const managedSkills = loadSkillsFromDir({ const managedSkills = loadSkillsFromDir({
dir: managedSkillsDir, dir: managedSkillsDir,
source: "clawdis-managed", source: "clawdis-managed",
@@ -431,7 +442,8 @@ function loadSkillEntries(
}); });
const merged = new Map<string, Skill>(); const merged = new Map<string, Skill>();
// 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 bundledSkills) merged.set(skill.name, skill);
for (const skill of managedSkills) 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); for (const skill of workspaceSkills) merged.set(skill.name, skill);

View File

@@ -127,6 +127,14 @@ export type SkillConfig = {
[key: string]: unknown; [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 = { export type ClawdisConfig = {
identity?: { identity?: {
name?: string; name?: string;
@@ -135,6 +143,7 @@ export type ClawdisConfig = {
}; };
logging?: LoggingConfig; logging?: LoggingConfig;
browser?: BrowserConfig; browser?: BrowserConfig;
skillsLoad?: SkillsLoadConfig;
inbound?: { inbound?: {
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
/** Agent working directory (preferred). Used as the default cwd for agent runs. */ /** Agent working directory (preferred). Used as the default cwd for agent runs. */
@@ -357,6 +366,11 @@ const ClawdisSchema = z.object({
.optional(), .optional(),
}) })
.optional(), .optional(),
skillsLoad: z
.object({
extraDirs: z.array(z.string()).optional(),
})
.optional(),
skills: z skills: z
.record( .record(
z.string(), z.string(),