import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { formatSkillsForPrompt, loadSkillsFromDir, type Skill, } from "@mariozechner/pi-coding-agent"; import type { ClawdbotConfig, SkillConfig } from "../config/config.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; const fsp = fs.promises; const SKILLS_SYNC_QUEUE = new Map>(); async function serializeByKey(key: string, task: () => Promise) { const prev = SKILLS_SYNC_QUEUE.get(key) ?? Promise.resolve(); const next = prev.then(task, task); SKILLS_SYNC_QUEUE.set(key, next); try { return await next; } finally { if (SKILLS_SYNC_QUEUE.get(key) === next) SKILLS_SYNC_QUEUE.delete(key); } } export type SkillInstallSpec = { id?: string; kind: "brew" | "node" | "go" | "uv"; label?: string; bins?: string[]; formula?: string; package?: string; module?: string; }; export type ClawdbotSkillMetadata = { always?: boolean; skillKey?: string; primaryEnv?: string; emoji?: string; homepage?: string; os?: string[]; requires?: { bins?: string[]; anyBins?: string[]; env?: string[]; config?: string[]; }; install?: SkillInstallSpec[]; }; export type SkillsInstallPreferences = { preferBrew: boolean; nodeManager: "npm" | "pnpm" | "yarn" | "bun"; }; type ParsedSkillFrontmatter = Record; export type SkillEntry = { skill: Skill; frontmatter: ParsedSkillFrontmatter; clawdbot?: ClawdbotSkillMetadata; }; export type SkillSnapshot = { prompt: string; skills: Array<{ name: string; primaryEnv?: string }>; resolvedSkills?: Skill[]; }; function resolveBundledSkillsDir(): string | undefined { const override = process.env.CLAWDBOT_BUNDLED_SKILLS_DIR?.trim(); if (override) return override; // bun --compile: ship a sibling `skills/` next to the executable. try { const execDir = path.dirname(process.execPath); const sibling = path.join(execDir, "skills"); if (fs.existsSync(sibling)) return sibling; } catch { // ignore } // npm/dev: resolve `/skills` relative to this module. try { const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(moduleDir, "..", ".."); const candidate = path.join(root, "skills"); if (fs.existsSync(candidate)) return candidate; } catch { // ignore } return undefined; } function getFrontmatterValue( frontmatter: ParsedSkillFrontmatter, key: string, ): string | undefined { const raw = frontmatter[key]; return typeof raw === "string" ? raw : undefined; } function stripQuotes(value: string): string { if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { return value.slice(1, -1); } return value; } function parseFrontmatter(content: string): ParsedSkillFrontmatter { const frontmatter: ParsedSkillFrontmatter = {}; const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); if (!normalized.startsWith("---")) return frontmatter; const endIndex = normalized.indexOf("\n---", 3); if (endIndex === -1) return frontmatter; const block = normalized.slice(4, endIndex); for (const line of block.split("\n")) { const match = line.match(/^([\w-]+):\s*(.*)$/); if (!match) continue; const key = match[1]; const value = stripQuotes(match[2].trim()); if (!key || !value) continue; frontmatter[key] = value; } return frontmatter; } function normalizeStringList(input: unknown): string[] { if (!input) return []; if (Array.isArray(input)) { return input.map((value) => String(value).trim()).filter(Boolean); } if (typeof input === "string") { return input .split(",") .map((value) => value.trim()) .filter(Boolean); } return []; } function parseInstallSpec(input: unknown): SkillInstallSpec | undefined { if (!input || typeof input !== "object") return undefined; const raw = input as Record; const kindRaw = typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : ""; const kind = kindRaw.trim().toLowerCase(); if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv") { return undefined; } const spec: SkillInstallSpec = { kind: kind as SkillInstallSpec["kind"], }; if (typeof raw.id === "string") spec.id = raw.id; if (typeof raw.label === "string") spec.label = raw.label; const bins = normalizeStringList(raw.bins); if (bins.length > 0) spec.bins = bins; if (typeof raw.formula === "string") spec.formula = raw.formula; if (typeof raw.package === "string") spec.package = raw.package; if (typeof raw.module === "string") spec.module = raw.module; return spec; } function isTruthy(value: unknown): boolean { if (value === undefined || value === null) return false; if (typeof value === "boolean") return value; if (typeof value === "number") return value !== 0; if (typeof value === "string") return value.trim().length > 0; return true; } const DEFAULT_CONFIG_VALUES: Record = { "browser.enabled": true, }; export function resolveSkillsInstallPreferences( config?: ClawdbotConfig, ): SkillsInstallPreferences { const raw = config?.skills?.install; const preferBrew = raw?.preferBrew ?? true; const managerRaw = typeof raw?.nodeManager === "string" ? raw.nodeManager.trim() : ""; const manager = managerRaw.toLowerCase(); const nodeManager = manager === "pnpm" || manager === "yarn" || manager === "bun" || manager === "npm" ? (manager as SkillsInstallPreferences["nodeManager"]) : "npm"; return { preferBrew, nodeManager }; } export function resolveRuntimePlatform(): string { return process.platform; } export function resolveConfigPath( config: ClawdbotConfig | undefined, pathStr: string, ) { const parts = pathStr.split(".").filter(Boolean); let current: unknown = config; for (const part of parts) { if (typeof current !== "object" || current === null) return undefined; current = (current as Record)[part]; } return current; } export function isConfigPathTruthy( config: ClawdbotConfig | undefined, pathStr: string, ): boolean { const value = resolveConfigPath(config, pathStr); if (value === undefined && pathStr in DEFAULT_CONFIG_VALUES) { return DEFAULT_CONFIG_VALUES[pathStr] === true; } return isTruthy(value); } export function resolveSkillConfig( config: ClawdbotConfig | undefined, skillKey: string, ): SkillConfig | undefined { 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 === "clawdbot-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); for (const part of parts) { const candidate = path.join(part, bin); try { fs.accessSync(candidate, fs.constants.X_OK); return true; } catch { // keep scanning } } return false; } function resolveClawdbotMetadata( frontmatter: ParsedSkillFrontmatter, ): ClawdbotSkillMetadata | undefined { const raw = getFrontmatterValue(frontmatter, "metadata"); if (!raw) return undefined; try { const parsed = JSON.parse(raw) as { clawdbot?: unknown }; if (!parsed || typeof parsed !== "object") return undefined; const clawdbot = (parsed as { clawdbot?: unknown }).clawdbot; if (!clawdbot || typeof clawdbot !== "object") return undefined; const clawdbotObj = clawdbot as Record; const requiresRaw = typeof clawdbotObj.requires === "object" && clawdbotObj.requires !== null ? (clawdbotObj.requires as Record) : undefined; const installRaw = Array.isArray(clawdbotObj.install) ? (clawdbotObj.install as unknown[]) : []; const install = installRaw .map((entry) => parseInstallSpec(entry)) .filter((entry): entry is SkillInstallSpec => Boolean(entry)); const osRaw = normalizeStringList(clawdbotObj.os); return { always: typeof clawdbotObj.always === "boolean" ? clawdbotObj.always : undefined, emoji: typeof clawdbotObj.emoji === "string" ? clawdbotObj.emoji : undefined, homepage: typeof clawdbotObj.homepage === "string" ? clawdbotObj.homepage : undefined, skillKey: typeof clawdbotObj.skillKey === "string" ? clawdbotObj.skillKey : undefined, primaryEnv: typeof clawdbotObj.primaryEnv === "string" ? clawdbotObj.primaryEnv : undefined, os: osRaw.length > 0 ? osRaw : undefined, requires: requiresRaw ? { bins: normalizeStringList(requiresRaw.bins), anyBins: normalizeStringList(requiresRaw.anyBins), env: normalizeStringList(requiresRaw.env), config: normalizeStringList(requiresRaw.config), } : undefined, install: install.length > 0 ? install : undefined, }; } catch { return undefined; } } function resolveSkillKey(skill: Skill, entry?: SkillEntry): string { return entry?.clawdbot?.skillKey ?? skill.name; } function shouldIncludeSkill(params: { entry: SkillEntry; config?: ClawdbotConfig; }): boolean { const { entry, config } = params; const skillKey = resolveSkillKey(entry.skill, entry); const skillConfig = resolveSkillConfig(config, skillKey); const allowBundled = normalizeAllowlist(config?.skills?.allowBundled); const osList = entry.clawdbot?.os ?? []; if (skillConfig?.enabled === false) return false; if (!isBundledSkillAllowed(entry, allowBundled)) return false; if (osList.length > 0 && !osList.includes(resolveRuntimePlatform())) { return false; } if (entry.clawdbot?.always === true) { return true; } const requiredBins = entry.clawdbot?.requires?.bins ?? []; if (requiredBins.length > 0) { for (const bin of requiredBins) { if (!hasBinary(bin)) return false; } } const requiredAnyBins = entry.clawdbot?.requires?.anyBins ?? []; if (requiredAnyBins.length > 0) { const anyFound = requiredAnyBins.some((bin) => hasBinary(bin)); if (!anyFound) return false; } const requiredEnv = entry.clawdbot?.requires?.env ?? []; if (requiredEnv.length > 0) { for (const envName of requiredEnv) { if (process.env[envName]) continue; if (skillConfig?.env?.[envName]) continue; if (skillConfig?.apiKey && entry.clawdbot?.primaryEnv === envName) { continue; } return false; } } const requiredConfig = entry.clawdbot?.requires?.config ?? []; if (requiredConfig.length > 0) { for (const configPath of requiredConfig) { if (!isConfigPathTruthy(config, configPath)) return false; } } return true; } function filterSkillEntries( entries: SkillEntry[], config?: ClawdbotConfig, skillFilter?: string[], ): SkillEntry[] { let filtered = entries.filter((entry) => shouldIncludeSkill({ entry, config }), ); // If skillFilter is provided, only include skills in the filter list. if (skillFilter !== undefined) { const normalized = skillFilter .map((entry) => String(entry).trim()) .filter(Boolean); const label = normalized.length > 0 ? normalized.join(", ") : "(none)"; console.log(`[skills] Applying skill filter: ${label}`); filtered = normalized.length > 0 ? filtered.filter((entry) => normalized.includes(entry.skill.name)) : []; console.log( `[skills] After filter: ${filtered.map((entry) => entry.skill.name).join(", ")}`, ); } return filtered; } export function applySkillEnvOverrides(params: { skills: SkillEntry[]; config?: ClawdbotConfig; }) { const { skills, config } = params; const updates: Array<{ key: string; prev: string | undefined }> = []; for (const entry of skills) { const skillKey = resolveSkillKey(entry.skill, entry); const skillConfig = resolveSkillConfig(config, skillKey); if (!skillConfig) continue; if (skillConfig.env) { for (const [envKey, envValue] of Object.entries(skillConfig.env)) { if (!envValue || process.env[envKey]) continue; updates.push({ key: envKey, prev: process.env[envKey] }); process.env[envKey] = envValue; } } const primaryEnv = entry.clawdbot?.primaryEnv; if (primaryEnv && skillConfig.apiKey && !process.env[primaryEnv]) { updates.push({ key: primaryEnv, prev: process.env[primaryEnv] }); process.env[primaryEnv] = skillConfig.apiKey; } } return () => { for (const update of updates) { if (update.prev === undefined) delete process.env[update.key]; else process.env[update.key] = update.prev; } }; } export function applySkillEnvOverridesFromSnapshot(params: { snapshot?: SkillSnapshot; config?: ClawdbotConfig; }) { const { snapshot, config } = params; if (!snapshot) return () => {}; const updates: Array<{ key: string; prev: string | undefined }> = []; for (const skill of snapshot.skills) { const skillConfig = resolveSkillConfig(config, skill.name); if (!skillConfig) continue; if (skillConfig.env) { for (const [envKey, envValue] of Object.entries(skillConfig.env)) { if (!envValue || process.env[envKey]) continue; updates.push({ key: envKey, prev: process.env[envKey] }); process.env[envKey] = envValue; } } if ( skill.primaryEnv && skillConfig.apiKey && !process.env[skill.primaryEnv] ) { updates.push({ key: skill.primaryEnv, prev: process.env[skill.primaryEnv], }); process.env[skill.primaryEnv] = skillConfig.apiKey; } } return () => { for (const update of updates) { if (update.prev === undefined) delete process.env[update.key]; else process.env[update.key] = update.prev; } }; } function loadSkillEntries( workspaceDir: string, opts?: { config?: ClawdbotConfig; managedSkillsDir?: string; bundledSkillsDir?: string; }, ): SkillEntry[] { const loadSkills = (params: { dir: string; source: string }): Skill[] => { const loaded = loadSkillsFromDir(params); if (Array.isArray(loaded)) return loaded; if ( loaded && typeof loaded === "object" && "skills" in loaded && Array.isArray((loaded as { skills?: unknown }).skills) ) { return (loaded as { skills: Skill[] }).skills; } return []; }; const managedSkillsDir = opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills"); const workspaceSkillsDir = path.join(workspaceDir, "skills"); const bundledSkillsDir = opts?.bundledSkillsDir ?? resolveBundledSkillsDir(); const extraDirsRaw = opts?.config?.skills?.load?.extraDirs ?? []; const extraDirs = extraDirsRaw .map((d) => (typeof d === "string" ? d.trim() : "")) .filter(Boolean); const bundledSkills = bundledSkillsDir ? loadSkills({ dir: bundledSkillsDir, source: "clawdbot-bundled", }) : []; const extraSkills = extraDirs.flatMap((dir) => { const resolved = resolveUserPath(dir); return loadSkills({ dir: resolved, source: "clawdbot-extra", }); }); const managedSkills = loadSkills({ dir: managedSkillsDir, source: "clawdbot-managed", }); const workspaceSkills = loadSkills({ dir: workspaceSkillsDir, source: "clawdbot-workspace", }); const merged = new Map(); // 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); const skillEntries: SkillEntry[] = Array.from(merged.values()).map( (skill) => { let frontmatter: ParsedSkillFrontmatter = {}; try { const raw = fs.readFileSync(skill.filePath, "utf-8"); frontmatter = parseFrontmatter(raw); } catch { // ignore malformed skills } return { skill, frontmatter, clawdbot: resolveClawdbotMetadata(frontmatter), }; }, ); return skillEntries; } export function buildWorkspaceSkillSnapshot( workspaceDir: string, opts?: { config?: ClawdbotConfig; managedSkillsDir?: string; bundledSkillsDir?: string; entries?: SkillEntry[]; /** If provided, only include skills with these names */ skillFilter?: string[]; }, ): SkillSnapshot { const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts); const eligible = filterSkillEntries( skillEntries, opts?.config, opts?.skillFilter, ); const resolvedSkills = eligible.map((entry) => entry.skill); return { prompt: formatSkillsForPrompt(resolvedSkills), skills: eligible.map((entry) => ({ name: entry.skill.name, primaryEnv: entry.clawdbot?.primaryEnv, })), resolvedSkills, }; } export function buildWorkspaceSkillsPrompt( workspaceDir: string, opts?: { config?: ClawdbotConfig; managedSkillsDir?: string; bundledSkillsDir?: string; entries?: SkillEntry[]; /** If provided, only include skills with these names */ skillFilter?: string[]; }, ): string { const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts); const eligible = filterSkillEntries( skillEntries, opts?.config, opts?.skillFilter, ); return formatSkillsForPrompt(eligible.map((entry) => entry.skill)); } export function resolveSkillsPromptForRun(params: { skillsSnapshot?: SkillSnapshot; entries?: SkillEntry[]; config?: ClawdbotConfig; workspaceDir: string; }): string { const snapshotPrompt = params.skillsSnapshot?.prompt?.trim(); if (snapshotPrompt) return snapshotPrompt; if (params.entries && params.entries.length > 0) { const prompt = buildWorkspaceSkillsPrompt(params.workspaceDir, { entries: params.entries, config: params.config, }); return prompt.trim() ? prompt : ""; } return ""; } export function loadWorkspaceSkillEntries( workspaceDir: string, opts?: { config?: ClawdbotConfig; managedSkillsDir?: string; bundledSkillsDir?: string; }, ): SkillEntry[] { return loadSkillEntries(workspaceDir, opts); } export async function syncSkillsToWorkspace(params: { sourceWorkspaceDir: string; targetWorkspaceDir: string; config?: ClawdbotConfig; managedSkillsDir?: string; bundledSkillsDir?: string; }) { const sourceDir = resolveUserPath(params.sourceWorkspaceDir); const targetDir = resolveUserPath(params.targetWorkspaceDir); if (sourceDir === targetDir) return; await serializeByKey(`syncSkills:${targetDir}`, async () => { const targetSkillsDir = path.join(targetDir, "skills"); const entries = loadSkillEntries(sourceDir, { config: params.config, managedSkillsDir: params.managedSkillsDir, bundledSkillsDir: params.bundledSkillsDir, }); await fsp.rm(targetSkillsDir, { recursive: true, force: true }); await fsp.mkdir(targetSkillsDir, { recursive: true }); for (const entry of entries) { const dest = path.join(targetSkillsDir, entry.skill.name); try { await fsp.cp(entry.skill.baseDir, dest, { recursive: true, force: true, }); } catch (error) { const message = error instanceof Error ? error.message : JSON.stringify(error); console.warn( `[skills] Failed to copy ${entry.skill.name} to sandbox: ${message}`, ); } } }); } export function filterWorkspaceSkillEntries( entries: SkillEntry[], config?: ClawdbotConfig, ): SkillEntry[] { return filterSkillEntries(entries, config); } export function resolveBundledAllowlist( config?: ClawdbotConfig, ): string[] | undefined { return normalizeAllowlist(config?.skills?.allowBundled); }