feat: unify skills config
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user