feat: unify skills config

This commit is contained in:
Peter Steinberger
2026-01-01 10:07:31 +01:00
parent 0a9f06d60f
commit fbcbc60e85
12 changed files with 287 additions and 98 deletions

View File

@@ -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,

View File

@@ -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 {

View File

@@ -173,7 +173,7 @@ const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
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<string, SkillConfig | undefined>)[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);
}

View File

@@ -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<string, SkillConfig>;
};
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<string, SkillConfig>;
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(),
});

View File

@@ -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,