diff --git a/package.json b/package.json index 8070e86b..bf9b5bea 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "agent:cli": "tsx src/agent/cli.ts", "agent:interactive": "tsx src/agent/interactive-cli.ts", "agent:profile": "tsx src/agent/profile-cli.ts", + "skills:cli": "tsx src/agent/skills-cli.ts", "dev:gateway": "tsx --watch src/gateway/main.ts", "dev:console": "tsx --watch src/console/main.ts", "dev:web": "pnpm --filter @multica/web dev", diff --git a/src/agent/autocomplete.ts b/src/agent/autocomplete.ts new file mode 100644 index 00000000..63c76ac9 --- /dev/null +++ b/src/agent/autocomplete.ts @@ -0,0 +1,269 @@ +/** + * Autocomplete Input + * + * Real-time dropdown autocomplete for terminal input + * No external dependencies - uses raw terminal control + */ + +import * as readline from "readline"; + +export interface AutocompleteOption { + value: string; + label?: string; +} + +export interface AutocompleteConfig { + /** Function to get suggestions based on current input */ + getSuggestions: (input: string) => AutocompleteOption[]; + /** Prompt string */ + prompt?: string; + /** Max suggestions to show */ + maxSuggestions?: number; +} + +// ANSI escape codes +const ESC = "\x1b"; +const CLEAR_LINE = `${ESC}[2K`; +const CURSOR_UP = (n: number) => `${ESC}[${n}A`; +const CURSOR_DOWN = (n: number) => `${ESC}[${n}B`; +const CURSOR_TO_COL = (n: number) => `${ESC}[${n}G`; +const DIM = `${ESC}[2m`; +const RESET = `${ESC}[0m`; +const INVERSE = `${ESC}[7m`; +const HIDE_CURSOR = `${ESC}[?25l`; +const SHOW_CURSOR = `${ESC}[?25h`; + +/** + * Read a line with real-time autocomplete dropdown + */ +export function autocompleteInput(config: AutocompleteConfig): Promise { + return new Promise((resolve) => { + const { getSuggestions, prompt = "> ", maxSuggestions = 5 } = config; + + const stdin = process.stdin; + const stdout = process.stdout; + + let input = ""; + let cursorPos = 0; + let suggestions: AutocompleteOption[] = []; + let selectedIndex = -1; + let displayedLines = 0; + + // Enable raw mode + if (stdin.isTTY) { + stdin.setRawMode(true); + } + readline.emitKeypressEvents(stdin); + + const cleanup = () => { + clearSuggestions(); + stdout.write(SHOW_CURSOR); + if (stdin.isTTY) { + stdin.setRawMode(false); + } + stdin.removeListener("keypress", onKeypress); + }; + + const render = () => { + // Clear previous suggestions + clearSuggestions(); + + // Render input line + stdout.write(`\r${CLEAR_LINE}${prompt}${input}`); + + // Position cursor + const cursorCol = prompt.length + cursorPos + 1; + stdout.write(CURSOR_TO_COL(cursorCol)); + + // Get and display suggestions if input starts with / + if (input.startsWith("/") && input.length > 1) { + suggestions = getSuggestions(input).slice(0, maxSuggestions); + + if (suggestions.length > 0) { + // Ensure selectedIndex is valid + if (selectedIndex >= suggestions.length) { + selectedIndex = suggestions.length - 1; + } + + stdout.write("\n"); + displayedLines = suggestions.length; + + for (let i = 0; i < suggestions.length; i++) { + const opt = suggestions[i]!; + const isSelected = i === selectedIndex; + const prefix = isSelected ? `${INVERSE}` : `${DIM}`; + const suffix = RESET; + const label = opt.label ? ` ${DIM}${opt.label}${RESET}` : ""; + const line = `${prefix} ${opt.value}${suffix}${label}`; + + stdout.write(`${CLEAR_LINE}${line}`); + if (i < suggestions.length - 1) { + stdout.write("\n"); + } + } + + // Move cursor back up to input line + if (displayedLines > 0) { + stdout.write(CURSOR_UP(displayedLines)); + } + stdout.write(CURSOR_TO_COL(cursorCol)); + } + } else { + suggestions = []; + selectedIndex = -1; + } + }; + + const clearSuggestions = () => { + if (displayedLines > 0) { + // Move down and clear each line + for (let i = 0; i < displayedLines; i++) { + stdout.write(`\n${CLEAR_LINE}`); + } + // Move back up + stdout.write(CURSOR_UP(displayedLines)); + displayedLines = 0; + } + }; + + const submit = (value: string) => { + cleanup(); + stdout.write("\n"); + resolve(value); + }; + + const onKeypress = (_char: string, key: readline.Key) => { + if (!key) return; + + // Handle Ctrl+C + if (key.ctrl && key.name === "c") { + cleanup(); + process.exit(0); + } + + // Handle Ctrl+D (EOF) + if (key.ctrl && key.name === "d") { + cleanup(); + stdout.write("\n"); + resolve(""); + return; + } + + // Handle Enter + if (key.name === "return" || key.name === "enter") { + if (selectedIndex >= 0 && selectedIndex < suggestions.length) { + // Use selected suggestion + const selected = suggestions[selectedIndex]!; + submit(selected.value); + } else { + submit(input); + } + return; + } + + // Handle Tab - select first/next suggestion + if (key.name === "tab") { + if (suggestions.length > 0) { + if (key.shift) { + selectedIndex = selectedIndex <= 0 ? suggestions.length - 1 : selectedIndex - 1; + } else { + selectedIndex = selectedIndex >= suggestions.length - 1 ? 0 : selectedIndex + 1; + } + render(); + } + return; + } + + // Handle arrow keys + if (key.name === "up") { + if (suggestions.length > 0) { + selectedIndex = selectedIndex <= 0 ? suggestions.length - 1 : selectedIndex - 1; + render(); + } + return; + } + + if (key.name === "down") { + if (suggestions.length > 0) { + selectedIndex = selectedIndex >= suggestions.length - 1 ? 0 : selectedIndex + 1; + render(); + } + return; + } + + // Handle Escape - clear selection + if (key.name === "escape") { + selectedIndex = -1; + render(); + return; + } + + // Handle backspace + if (key.name === "backspace") { + if (cursorPos > 0) { + input = input.slice(0, cursorPos - 1) + input.slice(cursorPos); + cursorPos--; + selectedIndex = -1; + render(); + } + return; + } + + // Handle delete + if (key.name === "delete") { + if (cursorPos < input.length) { + input = input.slice(0, cursorPos) + input.slice(cursorPos + 1); + selectedIndex = -1; + render(); + } + return; + } + + // Handle left arrow + if (key.name === "left") { + if (cursorPos > 0) { + cursorPos--; + render(); + } + return; + } + + // Handle right arrow + if (key.name === "right") { + if (cursorPos < input.length) { + cursorPos++; + render(); + } + return; + } + + // Handle home + if (key.name === "home" || (key.ctrl && key.name === "a")) { + cursorPos = 0; + render(); + return; + } + + // Handle end + if (key.name === "end" || (key.ctrl && key.name === "e")) { + cursorPos = input.length; + render(); + return; + } + + // Handle printable characters + if (key.sequence && !key.ctrl && !key.meta) { + const char = key.sequence; + if (char.length === 1 && char.charCodeAt(0) >= 32) { + input = input.slice(0, cursorPos) + char + input.slice(cursorPos); + cursorPos++; + selectedIndex = -1; + render(); + } + } + }; + + stdin.on("keypress", onKeypress); + render(); + }); +} diff --git a/src/agent/interactive-cli.ts b/src/agent/interactive-cli.ts index d56a1fc8..6d6c9edb 100644 --- a/src/agent/interactive-cli.ts +++ b/src/agent/interactive-cli.ts @@ -2,6 +2,8 @@ import * as readline from "readline"; import { Agent } from "./runner.js"; import type { AgentOptions } from "./types.js"; +import { SkillManager } from "./skills/index.js"; +import { autocompleteInput, type AutocompleteOption } from "./autocomplete.js"; type CliOptions = { profile?: string | undefined; @@ -95,11 +97,24 @@ function printWelcome(sessionId: string) { console.log(""); } -function printHelp() { - console.log("\nAvailable commands:"); +function printHelp(skillManager?: SkillManager) { + console.log("\nBuilt-in commands:"); for (const [cmd, desc] of Object.entries(COMMANDS)) { console.log(` /${cmd.padEnd(12)} ${desc}`); } + + // Show skill commands if available + if (skillManager) { + const reservedNames = new Set(Object.keys(COMMANDS)); + const skillCommands = skillManager.getSkillCommands({ reservedNames }); + if (skillCommands.length > 0) { + console.log("\nSkill commands:"); + for (const cmd of skillCommands) { + console.log(` /${cmd.name.padEnd(12)} ${cmd.description}`); + } + } + } + console.log("\nJust type your message and press Enter to chat with the agent."); console.log(""); } @@ -111,11 +126,21 @@ class InteractiveCLI { private multilineMode = false; private multilineBuffer: string[] = []; private running = true; + private skillManager: SkillManager; + private reservedNames: Set; constructor(opts: CliOptions) { this.opts = opts; this.agent = this.createAgent(opts.session); + // Initialize SkillManager for tab completion + this.skillManager = new SkillManager({ + profileId: opts.profile, + }); + + // Build list of reserved command names (built-in CLI commands) + this.reservedNames = new Set(Object.keys(COMMANDS)); + this.rl = readline.createInterface({ input: process.stdin, output: process.stdout, @@ -129,6 +154,47 @@ class InteractiveCLI { }); } + /** + * Get autocomplete suggestions for input + */ + private getSuggestions(input: string): AutocompleteOption[] { + if (!input.startsWith("/")) { + return []; + } + + const prefix = input.slice(1).toLowerCase(); + const suggestions: AutocompleteOption[] = []; + + // Add built-in command suggestions + for (const [cmd, desc] of Object.entries(COMMANDS)) { + if (cmd.toLowerCase().startsWith(prefix)) { + suggestions.push({ + value: `/${cmd}`, + label: desc.slice(0, 40), + }); + } + } + + // Add skill command suggestions + const skillCommands = this.skillManager.getSkillCommands({ reservedNames: this.reservedNames }); + for (const cmd of skillCommands) { + if (cmd.name.toLowerCase().startsWith(prefix)) { + suggestions.push({ + value: `/${cmd.name}`, + label: cmd.description.slice(0, 40), + }); + } + } + + // Sort: shorter first, then alphabetically + suggestions.sort((a, b) => { + if (a.value.length !== b.value.length) return a.value.length - b.value.length; + return a.value.localeCompare(b.value); + }); + + return suggestions; + } + private createAgent(sessionId?: string): Agent { return new Agent({ profileId: this.opts.profile, @@ -155,10 +221,14 @@ class InteractiveCLI { private async loop() { while (this.running) { - const input = await this.readline(this.prompt()); - if (input === null) break; + let input: string; if (this.multilineMode) { + // Use simple readline for multiline mode + const lineInput = await this.readline(this.prompt()); + if (lineInput === null) break; + input = lineInput; + if (input === ".") { // End of multiline input const fullInput = this.multilineBuffer.join("\n"); @@ -173,6 +243,17 @@ class InteractiveCLI { continue; } + // Use autocomplete input for normal mode + try { + input = await autocompleteInput({ + prompt: this.prompt(), + getSuggestions: (text) => this.getSuggestions(text), + maxSuggestions: 8, + }); + } catch { + break; + } + const trimmed = input.trim(); if (!trimmed) continue; @@ -200,7 +281,7 @@ class InteractiveCLI { switch (cmd) { case "help": - printHelp(); + printHelp(this.skillManager); return true; case "exit": @@ -238,7 +319,17 @@ class InteractiveCLI { return true; default: - // Unknown command - let the agent handle it + // Check if it's a skill command + const invocation = this.skillManager.resolveCommand(input); + if (invocation) { + // Skill command found - send to agent with skill instructions as context + const skillPrompt = invocation.args + ? `[Skill: ${invocation.command.name}]\n\n${invocation.instructions}\n\nUser request: ${invocation.args}` + : `[Skill: ${invocation.command.name}]\n\n${invocation.instructions}`; + await this.handleInput(skillPrompt); + return true; + } + // Unknown command - let the agent handle it as-is return false; } } diff --git a/src/agent/runner.ts b/src/agent/runner.ts index eac01d30..bcea33b3 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -146,10 +146,17 @@ export class Agent { // Initialize SkillManager (enabled by default) if (options.enableSkills !== false) { + // Merge extraSkillDirs from options with config + const extraDirs = [ + ...(options.extraSkillDirs ?? []), + ...(options.skills?.load?.extraDirs ?? []), + ]; + this.skillManager = new SkillManager({ profileId: options.profileId, profileBaseDir: options.profileBaseDir, - extraDirs: options.extraSkillDirs, + extraDirs: extraDirs.length > 0 ? extraDirs : undefined, + config: options.skills, }); // Append skills prompt to system prompt diff --git a/src/agent/skills-cli.ts b/src/agent/skills-cli.ts new file mode 100644 index 00000000..ba3798d2 --- /dev/null +++ b/src/agent/skills-cli.ts @@ -0,0 +1,539 @@ +#!/usr/bin/env node +/** + * Skills CLI + * + * Command-line interface for managing skills + * + * Usage: + * pnpm skills:cli list List all skills + * pnpm skills:cli status [id] Show skill status + * pnpm skills:cli install Install skill dependencies + * pnpm skills:cli add Add skill from GitHub + * pnpm skills:cli remove Remove an installed skill + */ + +import { + SkillManager, + installSkill, + getInstallOptions, + addSkill, + removeSkill, + listInstalledSkills, + checkEligibilityDetailed, + type DiagnosticItem, +} from "./skills/index.js"; + +// ============================================================================ +// Types +// ============================================================================ + +type Command = "list" | "status" | "install" | "add" | "remove" | "help"; + +interface ParsedArgs { + command: Command; + args: string[]; + verbose: boolean; + force: boolean; +} + +// ============================================================================ +// Argument Parsing +// ============================================================================ + +function parseArgs(argv: string[]): ParsedArgs { + const args = [...argv]; + let verbose = false; + let force = false; + const positional: string[] = []; + + while (args.length > 0) { + const arg = args.shift(); + if (!arg) break; + + if (arg === "--verbose" || arg === "-v") { + verbose = true; + continue; + } + + if (arg === "--force" || arg === "-f") { + force = true; + continue; + } + + if (arg === "--help" || arg === "-h") { + return { command: "help", args: [], verbose, force }; + } + + positional.push(arg); + } + + const command = (positional[0] ?? "help") as Command; + const commandArgs = positional.slice(1); + + return { command, args: commandArgs, verbose, force }; +} + +// ============================================================================ +// Commands +// ============================================================================ + +function printHelp(): void { + console.log(` +Skills CLI - Manage super-multica skills + +Usage: + pnpm skills:cli [options] + +Commands: + list List all available skills + status [id] Show detailed status of a skill (or all skills) + install Install dependencies for a skill + add Add skill from GitHub (owner/repo or owner/repo/skill) + remove Remove an installed skill + +Options: + -v, --verbose Show more details + -f, --force Force overwrite existing skill + -h, --help Show this help + +Examples: + pnpm skills:cli list + pnpm skills:cli status commit + pnpm skills:cli install nano-pdf + pnpm skills:cli add vercel-labs/agent-skills + pnpm skills:cli add vercel-labs/agent-skills/perplexity + pnpm skills:cli remove agent-skills +`); +} + +function cmdList(manager: SkillManager, verbose: boolean): void { + const skills = manager.listAllSkillsWithStatus(); + + if (skills.length === 0) { + console.log("No skills found."); + return; + } + + console.log("\nAvailable Skills:\n"); + + for (const skill of skills) { + const status = skill.eligible ? "✓" : "✗"; + const statusColor = skill.eligible ? "\x1b[32m" : "\x1b[31m"; + const reset = "\x1b[0m"; + + console.log(` ${statusColor}${status}${reset} ${skill.emoji} ${skill.name} (${skill.id})`); + console.log(` ${skill.description}`); + console.log(` Source: ${skill.source}`); + + if (!skill.eligible && skill.reasons) { + for (const reason of skill.reasons) { + console.log(` ${statusColor}└ ${reason}${reset}`); + } + } + + if (verbose) { + console.log(); + } + } + + console.log(); + const eligibleCount = skills.filter((s) => s.eligible).length; + console.log(`Total: ${skills.length} skills (${eligibleCount} eligible)`); +} + +function cmdStatus(manager: SkillManager, skillId?: string, verbose?: boolean): void { + if (!skillId) { + // Show summary status with diagnostics + cmdStatusSummary(manager, verbose); + return; + } + + // Show specific skill status with detailed diagnostics + cmdStatusDetail(manager, skillId, verbose); +} + +function cmdStatusSummary(manager: SkillManager, verbose?: boolean): void { + const skills = manager.listAllSkillsWithStatus(); + const eligible = skills.filter((s) => s.eligible); + const ineligible = skills.filter((s) => !s.eligible); + + console.log("\nSkills Status Summary:\n"); + console.log(` Total: ${skills.length}`); + console.log(` \x1b[32mEligible: ${eligible.length}\x1b[0m`); + console.log(` \x1b[31mIneligible: ${ineligible.length}\x1b[0m`); + + if (ineligible.length > 0) { + console.log("\n─────────────────────────────────────────"); + console.log("Ineligible Skills:"); + + // Group by issue type + const byIssue: Map = new Map(); + for (const s of ineligible) { + const skill = manager.getSkillFromAll(s.id); + if (skill) { + const detailed = checkEligibilityDetailed(skill); + const mainIssue = detailed.diagnostics?.[0]?.type ?? "unknown"; + const existing = byIssue.get(mainIssue) ?? []; + existing.push(s.id); + byIssue.set(mainIssue, existing); + } + } + + // Print grouped issues + const issueLabels: Record = { + disabled: "Disabled in config", + not_in_allowlist: "Not in allowlist", + platform: "Platform mismatch", + binary: "Missing binaries", + any_binary: "Missing binaries (any)", + env: "Missing environment variables", + config: "Missing config values", + unknown: "Unknown issues", + }; + + for (const [issue, skillIds] of byIssue) { + const label = issueLabels[issue] ?? issue; + console.log(`\n \x1b[33m${label}:\x1b[0m`); + for (const id of skillIds) { + const skill = manager.getSkillFromAll(id); + if (skill && verbose) { + const detailed = checkEligibilityDetailed(skill); + const diag = detailed.diagnostics?.[0]; + console.log(` - ${id}`); + if (diag?.hint) { + console.log(` \x1b[36mHint: ${diag.hint}\x1b[0m`); + } + } else { + console.log(` - ${id}`); + } + } + } + + console.log("\n─────────────────────────────────────────"); + console.log(`\x1b[36mTip: Run 'pnpm skills:cli status ' for detailed diagnostics\x1b[0m`); + } +} + +function cmdStatusDetail(manager: SkillManager, skillId: string, verbose?: boolean): void { + const skill = manager.getSkillFromAll(skillId); + if (!skill) { + console.error(`Skill not found: ${skillId}`); + process.exit(1); + } + + const detailed = checkEligibilityDetailed(skill); + const metadata = skill.frontmatter.metadata; + + console.log(`\n${metadata?.emoji ?? "🔧"} ${skill.frontmatter.name}`); + console.log("═".repeat(50)); + console.log(`ID: ${skill.id}`); + console.log(`Description: ${skill.frontmatter.description ?? "N/A"}`); + console.log(`Version: ${skill.frontmatter.version ?? "N/A"}`); + console.log(`Source: ${skill.source}`); + console.log(`Path: ${skill.filePath}`); + console.log(`Homepage: ${skill.frontmatter.homepage ?? metadata?.homepage ?? "N/A"}`); + + console.log(); + console.log("─".repeat(50)); + console.log(`Status: ${detailed.eligible ? "\x1b[32m✓ ELIGIBLE\x1b[0m" : "\x1b[31m✗ NOT ELIGIBLE\x1b[0m"}`); + + // Show detailed diagnostics + if (!detailed.eligible && detailed.diagnostics) { + console.log("\nDiagnostics:"); + for (const diag of detailed.diagnostics) { + printDiagnostic(diag); + } + } + + // Show requirements summary + const requirements = metadata?.requires; + const hasBins = requirements?.bins?.length ?? metadata?.requiresBinaries?.length ?? 0; + const hasAnyBins = requirements?.anyBins?.length ?? 0; + const hasEnvs = requirements?.env?.length ?? metadata?.requiresEnv?.length ?? 0; + + if (hasBins > 0 || hasAnyBins > 0 || hasEnvs > 0) { + console.log("\n─".repeat(50)); + console.log("Requirements:"); + + if (hasBins > 0) { + const bins = requirements?.bins ?? metadata?.requiresBinaries ?? []; + printRequirementStatus("Binaries (all required)", bins, checkBinaries); + } + + if (hasAnyBins > 0) { + const anyBins = requirements?.anyBins ?? []; + printRequirementStatus("Binaries (any one)", anyBins, checkBinaries, true); + } + + if (hasEnvs > 0) { + const envs = requirements?.env ?? metadata?.requiresEnv ?? []; + printRequirementStatus("Environment vars", envs, checkEnvVars); + } + } + + // Show install options + const installOptions = getInstallOptions(skill); + if (installOptions.length > 0) { + console.log("\n─".repeat(50)); + console.log("Install Options:"); + for (const opt of installOptions) { + const status = opt.available ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m"; + console.log(` ${status} [${opt.id}] ${opt.label}`); + if (!opt.available && opt.reason) { + console.log(` └ ${opt.reason}`); + } + } + } + + // Show quick actions if not eligible + if (!detailed.eligible) { + console.log("\n─".repeat(50)); + console.log("\x1b[33mQuick Actions:\x1b[0m"); + + for (const diag of detailed.diagnostics ?? []) { + if (diag.hint) { + console.log(` → ${diag.hint}`); + } + } + + if (installOptions.length > 0) { + console.log(` → pnpm skills:cli install ${skillId}`); + } + } +} + +function printDiagnostic(diag: DiagnosticItem): void { + const typeColors: Record = { + disabled: "\x1b[33m", + not_in_allowlist: "\x1b[33m", + platform: "\x1b[35m", + binary: "\x1b[31m", + any_binary: "\x1b[31m", + env: "\x1b[34m", + config: "\x1b[36m", + }; + + const color = typeColors[diag.type] ?? "\x1b[37m"; + const reset = "\x1b[0m"; + + console.log(`\n ${color}[${diag.type.toUpperCase()}]${reset}`); + console.log(` ${diag.message}`); + + if (diag.values && diag.values.length > 0) { + console.log(` Values: ${diag.values.join(", ")}`); + } + + if (diag.hint) { + console.log(` \x1b[36m💡 ${diag.hint}${reset}`); + } +} + +function printRequirementStatus( + label: string, + items: string[], + checker: (items: string[]) => Map, + anyMode: boolean = false, +): void { + const status = checker(items); + const found = Array.from(status.entries()).filter(([, ok]) => ok).map(([name]) => name); + const missing = Array.from(status.entries()).filter(([, ok]) => !ok).map(([name]) => name); + + const allOk = anyMode ? found.length > 0 : missing.length === 0; + const statusIcon = allOk ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m"; + + console.log(`\n ${statusIcon} ${label}:`); + for (const [name, ok] of status) { + const icon = ok ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m"; + console.log(` ${icon} ${name}`); + } +} + +function checkBinaries(bins: string[]): Map { + const result = new Map(); + for (const bin of bins) { + try { + const cmd = process.platform === "win32" ? `where ${bin}` : `which ${bin}`; + require("child_process").execSync(cmd, { stdio: "ignore" }); + result.set(bin, true); + } catch { + result.set(bin, false); + } + } + return result; +} + +function checkEnvVars(envs: string[]): Map { + const result = new Map(); + for (const env of envs) { + result.set(env, env in process.env); + } + return result; +} + +async function cmdInstall(manager: SkillManager, skillId: string, installId?: string): Promise { + const skill = manager.getSkillFromAll(skillId); + if (!skill) { + console.error(`Skill not found: ${skillId}`); + process.exit(1); + } + + const installOptions = getInstallOptions(skill); + if (installOptions.length === 0) { + console.error(`Skill '${skillId}' has no install specifications.`); + process.exit(1); + } + + // Show available options if multiple + if (!installId && installOptions.length > 1) { + console.log(`\nMultiple install options available for '${skillId}':\n`); + for (const opt of installOptions) { + const status = opt.available ? "available" : `unavailable: ${opt.reason}`; + console.log(` [${opt.id}] ${opt.label} (${status})`); + } + console.log(`\nUse: pnpm skills:cli install ${skillId} `); + return; + } + + console.log(`\nInstalling dependencies for '${skillId}'...`); + + const result = await installSkill({ + skill, + installId, + }); + + if (result.ok) { + console.log(`\n\x1b[32m✓ ${result.message}\x1b[0m`); + } else { + console.error(`\n\x1b[31m✗ ${result.message}\x1b[0m`); + if (result.stderr) { + console.error("\nError output:"); + console.error(result.stderr); + } + process.exit(1); + } +} + +// ============================================================================ +// Add/Remove Commands +// ============================================================================ + +async function cmdAdd(source: string, force: boolean): Promise { + console.log(`\nAdding skill from '${source}'...`); + + const result = await addSkill({ + source, + force, + }); + + if (result.ok) { + console.log(`\n\x1b[32m✓ ${result.message}\x1b[0m`); + if (result.skills && result.skills.length > 1) { + console.log("\nSkills found:"); + for (const name of result.skills) { + console.log(` - ${name}`); + } + } + if (result.path) { + console.log(`\nInstalled to: ${result.path}`); + } + } else { + console.error(`\n\x1b[31m✗ ${result.message}\x1b[0m`); + process.exit(1); + } +} + +async function cmdRemove(name: string): Promise { + console.log(`\nRemoving skill '${name}'...`); + + const result = await removeSkill(name); + + if (result.ok) { + console.log(`\n\x1b[32m✓ ${result.message}\x1b[0m`); + } else { + console.error(`\n\x1b[31m✗ ${result.message}\x1b[0m`); + process.exit(1); + } +} + +async function cmdListInstalled(): Promise { + const skills = await listInstalledSkills(); + + if (skills.length === 0) { + console.log("\nNo skills installed in ~/.super-multica/skills/"); + console.log("Use 'pnpm skills:cli add ' to add skills."); + return; + } + + console.log("\nInstalled skills (~/.super-multica/skills/):\n"); + for (const name of skills) { + console.log(` - ${name}`); + } + console.log(`\nTotal: ${skills.length} installed`); +} + +// ============================================================================ +// Main +// ============================================================================ + +async function main(): Promise { + const { command, args, verbose, force } = parseArgs(process.argv.slice(2)); + + if (command === "help") { + printHelp(); + return; + } + + switch (command) { + case "add": + if (!args[0]) { + console.error("Usage: pnpm skills:cli add [--force]"); + console.error("\nSource formats:"); + console.error(" owner/repo Clone entire repository"); + console.error(" owner/repo/skill-name Clone single skill directory"); + console.error(" owner/repo@branch Clone specific branch/tag"); + process.exit(1); + } + await cmdAdd(args[0], force); + return; + + case "remove": + if (!args[0]) { + console.error("Usage: pnpm skills:cli remove "); + await cmdListInstalled(); + process.exit(1); + } + await cmdRemove(args[0]); + return; + } + + // Commands that need SkillManager + const manager = new SkillManager(); + + switch (command) { + case "list": + cmdList(manager, verbose); + break; + + case "status": + cmdStatus(manager, args[0], verbose); + break; + + case "install": + if (!args[0]) { + console.error("Usage: pnpm skills:cli install [install-id]"); + process.exit(1); + } + await cmdInstall(manager, args[0], args[1]); + break; + + default: + console.error(`Unknown command: ${command}`); + printHelp(); + process.exit(1); + } +} + +main().catch((err) => { + console.error(err?.stack || String(err)); + process.exit(1); +}); diff --git a/src/agent/skills/README.md b/src/agent/skills/README.md new file mode 100644 index 00000000..86abf3bc --- /dev/null +++ b/src/agent/skills/README.md @@ -0,0 +1,422 @@ +# Skills System + +Skills extend agent capabilities through `SKILL.md` definition files. + +## Table of Contents + +- [SKILL.md Specification](#skillmd-specification) +- [Skill Invocation](#skill-invocation) +- [Loading & Precedence](#loading--precedence) +- [CLI Commands](#cli-commands) + +--- + +## SKILL.md Specification + +Each skill is a directory containing a `SKILL.md` file with YAML frontmatter + Markdown content. + +### Basic Structure + +```markdown +--- +name: My Skill +version: 1.0.0 +description: What this skill does +metadata: + emoji: "🔧" + requires: + bins: [git] +--- + +# Instructions + +Detailed instructions injected into the agent's system prompt... +``` + +### Frontmatter Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Display name | +| `version` | string | No | Version number | +| `description` | string | No | Short description | +| `homepage` | string | No | Homepage URL | +| `metadata` | object | No | See below | +| `config` | object | No | See below | +| `install` | array | No | See below | + +### metadata.requires + +Defines eligibility requirements: + +```yaml +metadata: + emoji: "📝" + requires: + bins: [git, node] # All must exist + anyBins: [npm, pnpm] # At least one must exist + env: [API_KEY] # All must be set + platforms: [darwin, linux] # Current OS must match +``` + +| Field | Description | +|-------|-------------| +| `bins` | Required binaries (all must exist in PATH) | +| `anyBins` | Alternative binaries (at least one must exist) | +| `env` | Required environment variables | +| `platforms` | Supported platforms: `darwin`, `linux`, `win32` | + +### config + +Runtime configuration options: + +```yaml +config: + enabled: true + requiresConfig: ["skills.myskill.apiKey"] + options: + timeout: 30000 +``` + +### install + +Dependency installation specifications: + +```yaml +install: + - kind: brew + package: jq + + - kind: npm + package: typescript + global: true + + - kind: uv + package: requests + + - kind: go + package: github.com/example/tool@latest + + - kind: download + url: https://example.com/tool.tar.gz + archiveType: tar.gz + stripComponents: 1 +``` + +**Supported install kinds:** + +| Kind | Description | Key Fields | +|------|-------------|------------| +| `brew` | Homebrew | `package`, `cask` | +| `npm` | npm/pnpm/yarn | `package`, `global` | +| `uv` | Python uv | `package` | +| `go` | Go install | `package` | +| `download` | Download & extract | `url`, `archiveType` | + +**Common fields:** `id`, `label`, `platforms`, `when` + +--- + +## Skill Invocation + +Skills can be invoked by users via slash commands (`/skill-name`) or automatically by the AI model. + +### User Invocation + +In the interactive CLI, type `/` followed by a skill name to invoke it: + +``` +You: /pdf analyze report.pdf +``` + +**Tab completion**: Type `/p` then press Tab to see matching skills like `/pdf`. + +**List available skills**: Type `/help` to see all available skill commands. + +### Invocation Control + +Control how skills can be invoked using frontmatter fields: + +```yaml +--- +name: My Skill +user-invocable: true # Can be invoked via /command (default: true) +disable-model-invocation: false # Include in AI prompt (default: false) +--- +``` + +| Field | Default | Description | +|-------|---------|-------------| +| `user-invocable` | `true` | Enable `/command` invocation in CLI | +| `disable-model-invocation` | `false` | If `true`, skill is hidden from AI's system prompt | + +**Use cases:** + +- **User-only skill** (`disable-model-invocation: true`): User can invoke via `/command`, but AI won't use it automatically +- **AI-only skill** (`user-invocable: false`): AI can use it, but no `/command` available +- **Disabled skill** (both `false`): Hidden from both user and AI + +### Command Dispatch + +For advanced integrations, skills can dispatch directly to tools: + +```yaml +--- +name: PDF Tool +command-dispatch: tool +command-tool: pdf-processor +command-arg-mode: raw +--- +``` + +| Field | Description | +|-------|-------------| +| `command-dispatch` | Set to `tool` to enable tool dispatch | +| `command-tool` | Name of the tool to invoke | +| `command-arg-mode` | How arguments are passed (`raw` = as-is) | + +### Command Name Normalization + +Skill names are normalized for command use: + +- Converted to lowercase +- Special characters replaced with underscores +- Truncated to 32 characters max +- Duplicate names get numeric suffixes (e.g., `pdf_2`) + +--- + +## Loading & Precedence + +Skills load from multiple sources with precedence (lowest to highest): + +| Priority | Source | Path | Description | +|----------|--------|------|-------------| +| 1 | bundled | `/skills/` | Built-in skills | +| 2 | extraDirs | Configured | Additional directories | +| 3 | plugins | `node_modules/*/` | npm packages with `multica.plugin.json` | +| 4 | managed | `~/.super-multica/skills/` | CLI-installed skills | +| 5 | profile | `~/.super-multica/agent-profiles//skills/` | Profile-specific | + +Higher priority sources override skills with the same ID. + +### Plugin System (npm packages) + +For npm packages that provide skills, the plugin system auto-discovers them if they include a `multica.plugin.json` manifest: + +```json +{ + "id": "my-plugin", + "name": "My Skills Plugin", + "description": "A collection of useful skills", + "version": "1.0.0", + "skills": ["./skills/pdf", "./skills/image"] +} +``` + +**When to use plugins vs `add` command:** + +| Method | Use When | +|--------|----------| +| `pnpm skills:cli add owner/repo` | Installing from GitHub (recommended for most cases) | +| `npm install @company/plugin` | Package author provides `multica.plugin.json`, or you need npm's dependency management | + +> **Note:** Most third-party skills (like `vercel-labs/agent-skills`) are distributed via GitHub without `multica.plugin.json`. Use the `add` command for these. + +### Eligibility Filtering + +After loading, skills are filtered by: + +1. Platform check (`platforms`) +2. Binary check (`bins`, `anyBins`) +3. Environment check (`env`) +4. Config check (`requiresConfig`) +5. Enabled check (`config.enabled`) + +Only skills passing all checks are marked as eligible. + +--- + +## CLI Commands + +### List Skills + +```bash +pnpm skills:cli list # List all skills +pnpm skills:cli list -v # Verbose mode +pnpm skills:cli status # Summary status +pnpm skills:cli status # Specific skill status +``` + +### Install from GitHub + +**Example: Installing from [anthropics/skills](https://github.com/anthropics/skills)** + +The repository structure: +``` +anthropics/skills/ +├── skills/ +│ ├── algorithmic-art/ +│ │ └── SKILL.md +│ ├── brand-guidelines/ +│ │ └── SKILL.md +│ ├── pdf/ +│ │ └── SKILL.md +│ └── ... (16 skills total) +``` + +Install the entire repository (all 16 skills): +```bash +pnpm skills:cli add anthropics/skills +# Installs to: ~/.super-multica/skills/skills/ +# All skills available: algorithmic-art, brand-guidelines, pdf, etc. +``` + +Install a single skill only: +```bash +pnpm skills:cli add anthropics/skills/skills/pdf +# Installs to: ~/.super-multica/skills/pdf/ +# Only the pdf skill is installed +``` + +Install from a specific branch or tag: +```bash +pnpm skills:cli add anthropics/skills@main +``` + +Using full URL: +```bash +pnpm skills:cli add https://github.com/anthropics/skills +pnpm skills:cli add https://github.com/anthropics/skills/tree/main/skills/pdf +``` + +Force overwrite existing: +```bash +pnpm skills:cli add anthropics/skills --force +``` + +**Supported formats:** + +| Format | Example | Description | +|--------|---------|-------------| +| `owner/repo` | `anthropics/skills` | Clone entire repository | +| `owner/repo/path` | `anthropics/skills/skills/pdf` | Single directory (sparse checkout) | +| `owner/repo@ref` | `anthropics/skills@v1.0.0` | Specific branch or tag | +| Full URL | `https://github.com/anthropics/skills` | GitHub URL | +| Full URL + path | `https://github.com/.../tree/main/skills/pdf` | URL with specific path | + +### Remove Skills + +```bash +pnpm skills:cli remove # Remove installed skill +pnpm skills:cli remove # List installed skills +``` + +### Install Dependencies + +```bash +pnpm skills:cli install # Install skill dependencies +pnpm skills:cli install # Specific install option +``` + +--- + +## Status Diagnostics + +The `status` command provides detailed diagnostics for understanding why skills are or aren't eligible. + +### Summary Status + +```bash +pnpm skills:cli status # Show summary with grouping by issue type +pnpm skills:cli status -v # Verbose mode with hints +``` + +Output shows: +- Total/eligible/ineligible counts +- Ineligible skills grouped by issue type (binary, env, platform, etc.) + +### Detailed Skill Status + +```bash +pnpm skills:cli status +``` + +Output includes: +- Basic skill info (name, version, source, path) +- **Eligibility status** with detailed diagnostics +- **Requirements checklist** showing which binaries/env vars are present +- **Install options** with availability status +- **Quick actions** with actionable hints to resolve issues + +### Diagnostic Types + +| Type | Description | Example Hint | +|------|-------------|--------------| +| `disabled` | Skill disabled in config | Enable via `skills..enabled: true` | +| `not_in_allowlist` | Bundled skill not allowed | Add to `config.allowBundled` array | +| `platform` | Platform mismatch | "Only works on: darwin, linux" | +| `binary` | Missing required binary | "brew install git" | +| `any_binary` | No alternative binary found | "Install any of: npm, pnpm, yarn" | +| `env` | Missing environment variable | "export OPENAI_API_KEY=..." | +| `config` | Missing config value | "Set config path: browser.enabled" | + +--- + +## Async Serialization + +The skills system uses async serialization to prevent concurrent operations from corrupting files or causing race conditions. + +### How It Works + +Operations with the same key are executed sequentially: + +```typescript +import { serialize, SerializeKeys } from "./skills/index.js"; + +// These will execute sequentially, not in parallel +const p1 = serialize(SerializeKeys.skillAdd("my-skill"), () => addSkill(...)); +const p2 = serialize(SerializeKeys.skillAdd("my-skill"), () => addSkill(...)); + +// This runs in parallel (different key) +const p3 = serialize(SerializeKeys.skillAdd("other-skill"), () => addSkill(...)); +``` + +### Built-in Serialization + +The following operations are automatically serialized: +- `addSkill()` - by skill name +- `removeSkill()` - by skill name +- `installSkill()` - by skill ID + +### Utility Functions + +```typescript +import { + isProcessing, // Check if key is being processed + getQueueLength, // Get pending operations count + getActiveKeys, // Get all active operation keys + waitForKey, // Wait for key operations to complete + waitForAll, // Wait for all operations +} from "./skills/index.js"; +``` + +--- + +## Troubleshooting + +**Skill not showing as eligible?** + +Run `pnpm skills:cli status ` to see detailed diagnostics with actionable hints. + +**Override a bundled skill?** + +Create a skill with the same ID in `~/.super-multica/skills/` or profile skills directory. + +**Hot reload not working?** + +Ensure `chokidar` is installed: `pnpm add chokidar` + +**Concurrent operations causing issues?** + +All add/remove/install operations are automatically serialized. If you're building custom integrations, use the `serialize()` function with appropriate keys. diff --git a/src/agent/skills/README.zh-CN.md b/src/agent/skills/README.zh-CN.md new file mode 100644 index 00000000..d618d723 --- /dev/null +++ b/src/agent/skills/README.zh-CN.md @@ -0,0 +1,422 @@ +# Skills 系统 + +Skills 通过 `SKILL.md` 定义文件扩展 Agent 的能力。 + +## 目录 + +- [SKILL.md 规范](#skillmd-规范) +- [Skill 调用](#skill-调用) +- [加载与优先级](#加载与优先级) +- [CLI 命令](#cli-命令) + +--- + +## SKILL.md 规范 + +每个 skill 是一个包含 `SKILL.md` 文件的目录,文件包含 YAML frontmatter 和 Markdown 内容。 + +### 基本结构 + +```markdown +--- +name: My Skill +version: 1.0.0 +description: 这个 skill 的功能描述 +metadata: + emoji: "🔧" + requires: + bins: [git] +--- + +# 说明 + +注入到 agent 系统提示词中的详细说明... +``` + +### Frontmatter 字段 + +| 字段 | 类型 | 必需 | 描述 | +|------|------|------|------| +| `name` | string | 是 | 显示名称 | +| `version` | string | 否 | 版本号 | +| `description` | string | 否 | 简短描述 | +| `homepage` | string | 否 | 主页 URL | +| `metadata` | object | 否 | 见下文 | +| `config` | object | 否 | 见下文 | +| `install` | array | 否 | 见下文 | + +### metadata.requires + +定义资格要求: + +```yaml +metadata: + emoji: "📝" + requires: + bins: [git, node] # 全部必须存在 + anyBins: [npm, pnpm] # 至少一个必须存在 + env: [API_KEY] # 全部必须设置 + platforms: [darwin, linux] # 当前操作系统必须匹配 +``` + +| 字段 | 描述 | +|------|------| +| `bins` | 必需的二进制文件(全部必须存在于 PATH 中) | +| `anyBins` | 备选二进制文件(至少一个必须存在) | +| `env` | 必需的环境变量 | +| `platforms` | 支持的平台:`darwin`、`linux`、`win32` | + +### config + +运行时配置选项: + +```yaml +config: + enabled: true + requiresConfig: ["skills.myskill.apiKey"] + options: + timeout: 30000 +``` + +### install + +依赖安装规范: + +```yaml +install: + - kind: brew + package: jq + + - kind: npm + package: typescript + global: true + + - kind: uv + package: requests + + - kind: go + package: github.com/example/tool@latest + + - kind: download + url: https://example.com/tool.tar.gz + archiveType: tar.gz + stripComponents: 1 +``` + +**支持的安装类型:** + +| 类型 | 描述 | 关键字段 | +|------|------|----------| +| `brew` | Homebrew | `package`、`cask` | +| `npm` | npm/pnpm/yarn | `package`、`global` | +| `uv` | Python uv | `package` | +| `go` | Go install | `package` | +| `download` | 下载并解压 | `url`、`archiveType` | + +**通用字段:** `id`、`label`、`platforms`、`when` + +--- + +## Skill 调用 + +用户可以通过斜杠命令(`/skill-name`)调用 skills,AI 模型也可以自动调用。 + +### 用户调用 + +在交互式 CLI 中,输入 `/` 加上 skill 名称来调用: + +``` +You: /pdf analyze report.pdf +``` + +**Tab 补全**:输入 `/p` 然后按 Tab 键查看匹配的 skills,如 `/pdf`。 + +**列出可用 skills**:输入 `/help` 查看所有可用的 skill 命令。 + +### 调用控制 + +使用 frontmatter 字段控制 skill 的调用方式: + +```yaml +--- +name: My Skill +user-invocable: true # 可通过 /command 调用(默认:true) +disable-model-invocation: false # 包含在 AI 提示词中(默认:false) +--- +``` + +| 字段 | 默认值 | 描述 | +|------|--------|------| +| `user-invocable` | `true` | 在 CLI 中启用 `/command` 调用 | +| `disable-model-invocation` | `false` | 如果为 `true`,skill 对 AI 的系统提示词隐藏 | + +**使用场景:** + +- **仅用户 skill**(`disable-model-invocation: true`):用户可通过 `/command` 调用,但 AI 不会自动使用 +- **仅 AI skill**(`user-invocable: false`):AI 可使用,但没有 `/command` 可用 +- **禁用 skill**(两者都为 `false`):对用户和 AI 都隐藏 + +### 命令分发 + +对于高级集成,skills 可以直接分发到工具: + +```yaml +--- +name: PDF Tool +command-dispatch: tool +command-tool: pdf-processor +command-arg-mode: raw +--- +``` + +| 字段 | 描述 | +|------|------| +| `command-dispatch` | 设置为 `tool` 启用工具分发 | +| `command-tool` | 要调用的工具名称 | +| `command-arg-mode` | 参数传递方式(`raw` = 原样传递) | + +### 命令名称规范化 + +Skill 名称会被规范化以用作命令: + +- 转换为小写 +- 特殊字符替换为下划线 +- 截断至最多 32 个字符 +- 重复名称添加数字后缀(如 `pdf_2`) + +--- + +## 加载与优先级 + +Skills 从多个来源加载,优先级从低到高: + +| 优先级 | 来源 | 路径 | 描述 | +|--------|------|------|------| +| 1 | bundled | `/skills/` | 内置 skills | +| 2 | extraDirs | 已配置 | 额外目录 | +| 3 | plugins | `node_modules/*/` | 带有 `multica.plugin.json` 的 npm 包 | +| 4 | managed | `~/.super-multica/skills/` | CLI 安装的 skills | +| 5 | profile | `~/.super-multica/agent-profiles//skills/` | 配置文件特定 | + +高优先级来源会覆盖具有相同 ID 的 skills。 + +### 插件系统(npm 包) + +对于提供 skills 的 npm 包,如果包含 `multica.plugin.json` 清单,插件系统会自动发现: + +```json +{ + "id": "my-plugin", + "name": "My Skills Plugin", + "description": "一组有用的 skills", + "version": "1.0.0", + "skills": ["./skills/pdf", "./skills/image"] +} +``` + +**何时使用插件 vs `add` 命令:** + +| 方式 | 使用场景 | +|------|----------| +| `pnpm skills:cli add owner/repo` | 从 GitHub 安装(大多数情况下推荐) | +| `npm install @company/plugin` | 包作者提供了 `multica.plugin.json`,或需要 npm 的依赖管理 | + +> **注意:** 大多数第三方 skills(如 `vercel-labs/agent-skills`)通过 GitHub 分发,不包含 `multica.plugin.json`。对于这些请使用 `add` 命令。 + +### 资格过滤 + +加载后,skills 会按以下条件过滤: + +1. 平台检查(`platforms`) +2. 二进制文件检查(`bins`、`anyBins`) +3. 环境变量检查(`env`) +4. 配置检查(`requiresConfig`) +5. 启用检查(`config.enabled`) + +只有通过所有检查的 skills 才会被标记为符合条件。 + +--- + +## CLI 命令 + +### 列出 Skills + +```bash +pnpm skills:cli list # 列出所有 skills +pnpm skills:cli list -v # 详细模式 +pnpm skills:cli status # 汇总状态 +pnpm skills:cli status # 特定 skill 状态 +``` + +### 从 GitHub 安装 + +**示例:从 [anthropics/skills](https://github.com/anthropics/skills) 安装** + +仓库结构: +``` +anthropics/skills/ +├── skills/ +│ ├── algorithmic-art/ +│ │ └── SKILL.md +│ ├── brand-guidelines/ +│ │ └── SKILL.md +│ ├── pdf/ +│ │ └── SKILL.md +│ └── ... (共 16 个 skills) +``` + +安装整个仓库(所有 16 个 skills): +```bash +pnpm skills:cli add anthropics/skills +# 安装到:~/.super-multica/skills/skills/ +# 所有 skills 可用:algorithmic-art、brand-guidelines、pdf 等 +``` + +只安装单个 skill: +```bash +pnpm skills:cli add anthropics/skills/skills/pdf +# 安装到:~/.super-multica/skills/pdf/ +# 只安装 pdf skill +``` + +从特定分支或标签安装: +```bash +pnpm skills:cli add anthropics/skills@main +``` + +使用完整 URL: +```bash +pnpm skills:cli add https://github.com/anthropics/skills +pnpm skills:cli add https://github.com/anthropics/skills/tree/main/skills/pdf +``` + +强制覆盖现有: +```bash +pnpm skills:cli add anthropics/skills --force +``` + +**支持的格式:** + +| 格式 | 示例 | 描述 | +|------|------|------| +| `owner/repo` | `anthropics/skills` | 克隆整个仓库 | +| `owner/repo/path` | `anthropics/skills/skills/pdf` | 单个目录(稀疏检出) | +| `owner/repo@ref` | `anthropics/skills@v1.0.0` | 特定分支或标签 | +| 完整 URL | `https://github.com/anthropics/skills` | GitHub URL | +| 完整 URL + 路径 | `https://github.com/.../tree/main/skills/pdf` | 带特定路径的 URL | + +### 移除 Skills + +```bash +pnpm skills:cli remove # 移除已安装的 skill +pnpm skills:cli remove # 列出已安装的 skills +``` + +### 安装依赖 + +```bash +pnpm skills:cli install # 安装 skill 依赖 +pnpm skills:cli install # 特定安装选项 +``` + +--- + +## 状态诊断 + +`status` 命令提供详细的诊断信息,帮助了解 skills 为何符合或不符合条件。 + +### 汇总状态 + +```bash +pnpm skills:cli status # 显示按问题类型分组的汇总 +pnpm skills:cli status -v # 详细模式带提示 +``` + +输出显示: +- 总计/符合条件/不符合条件计数 +- 按问题类型分组的不符合条件 skills(binary、env、platform 等) + +### 详细 Skill 状态 + +```bash +pnpm skills:cli status +``` + +输出包括: +- 基本 skill 信息(名称、版本、来源、路径) +- **资格状态**及详细诊断 +- **要求检查表**显示哪些二进制文件/环境变量存在 +- **安装选项**及可用性状态 +- **快速操作**及可操作的提示 + +### 诊断类型 + +| 类型 | 描述 | 示例提示 | +|------|------|----------| +| `disabled` | Skill 在配置中禁用 | 通过 `skills..enabled: true` 启用 | +| `not_in_allowlist` | 内置 skill 不在允许列表中 | 添加到 `config.allowBundled` 数组 | +| `platform` | 平台不匹配 | "仅支持:darwin、linux" | +| `binary` | 缺少必需的二进制文件 | "brew install git" | +| `any_binary` | 未找到备选二进制文件 | "安装任一:npm、pnpm、yarn" | +| `env` | 缺少环境变量 | "export OPENAI_API_KEY=..." | +| `config` | 缺少配置值 | "设置配置路径:browser.enabled" | + +--- + +## 异步序列化 + +Skills 系统使用异步序列化来防止并发操作损坏文件或导致竞态条件。 + +### 工作原理 + +具有相同键的操作按顺序执行: + +```typescript +import { serialize, SerializeKeys } from "./skills/index.js"; + +// 这些将按顺序执行,而非并行 +const p1 = serialize(SerializeKeys.skillAdd("my-skill"), () => addSkill(...)); +const p2 = serialize(SerializeKeys.skillAdd("my-skill"), () => addSkill(...)); + +// 这个并行运行(不同的键) +const p3 = serialize(SerializeKeys.skillAdd("other-skill"), () => addSkill(...)); +``` + +### 内置序列化 + +以下操作自动序列化: +- `addSkill()` - 按 skill 名称 +- `removeSkill()` - 按 skill 名称 +- `installSkill()` - 按 skill ID + +### 工具函数 + +```typescript +import { + isProcessing, // 检查键是否正在处理 + getQueueLength, // 获取待处理操作数量 + getActiveKeys, // 获取所有活动操作键 + waitForKey, // 等待键操作完成 + waitForAll, // 等待所有操作 +} from "./skills/index.js"; +``` + +--- + +## 故障排除 + +**Skill 未显示为符合条件?** + +运行 `pnpm skills:cli status ` 查看详细诊断及可操作的提示。 + +**覆盖内置 skill?** + +在 `~/.super-multica/skills/` 或配置文件 skills 目录中创建具有相同 ID 的 skill。 + +**热重载不工作?** + +确保安装了 `chokidar`:`pnpm add chokidar` + +**并发操作导致问题?** + +所有 add/remove/install 操作都会自动序列化。如果你在构建自定义集成,请使用 `serialize()` 函数并使用适当的键。 diff --git a/src/agent/skills/add.ts b/src/agent/skills/add.ts new file mode 100644 index 00000000..c3b9bb8e --- /dev/null +++ b/src/agent/skills/add.ts @@ -0,0 +1,571 @@ +/** + * Skills Add Module + * + * Add skills from GitHub repositories + * + * Supports formats: + * - owner/repo → Clone entire repo to ~/.super-multica/skills/ + * - owner/repo/skill → Download single skill directory + * - https://github.com/owner/repo + */ + +import { mkdir, rm, readdir, stat, rename } from "node:fs/promises"; +import { join, basename } from "node:path"; +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; + +import { DATA_DIR } from "../../shared/index.js"; +import { binaryExists } from "./eligibility.js"; +import { bumpSkillsVersion } from "./watcher.js"; +import { serialize, SerializeKeys } from "./serialize.js"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface SkillAddRequest { + /** Source identifier (owner/repo, owner/repo/skill, or full URL) */ + source: string; + /** Custom name for the skill (defaults to repo or skill name) */ + name?: string | undefined; + /** Force overwrite if exists */ + force?: boolean | undefined; + /** Timeout in milliseconds (default: 60000) */ + timeoutMs?: number | undefined; +} + +export interface SkillAddResult { + /** Whether addition succeeded */ + ok: boolean; + /** Human-readable message */ + message: string; + /** Path where skill was installed */ + path?: string | undefined; + /** Skills found (for multi-skill repos) */ + skills?: string[] | undefined; +} + +interface ParsedSource { + /** GitHub owner */ + owner: string; + /** Repository name */ + repo: string; + /** Specific skill path within repo (optional) */ + skillPath?: string | undefined; + /** Branch/tag reference (optional) */ + ref?: string | undefined; +} + +// ============================================================================ +// Constants +// ============================================================================ + +/** Default timeout for git operations (60 seconds) */ +const DEFAULT_TIMEOUT_MS = 60_000; + +/** Skills directory: ~/.super-multica/skills */ +const SKILLS_DIR = join(DATA_DIR, "skills"); + +// ============================================================================ +// Source Parsing +// ============================================================================ + +/** + * Parse a source identifier into components + * + * Supported formats: + * - owner/repo + * - owner/repo/skill-name + * - owner/repo@ref + * - owner/repo/skill-name@ref + * - https://github.com/owner/repo + * - https://github.com/owner/repo/tree/main/skill-name + */ +export function parseSource(source: string): ParsedSource | null { + const trimmed = source.trim(); + + // Handle full GitHub URLs + if (trimmed.startsWith("https://") || trimmed.startsWith("http://")) { + return parseGitHubUrl(trimmed); + } + + // Handle owner/repo format + return parseShorthand(trimmed); +} + +function parseGitHubUrl(url: string): ParsedSource | null { + try { + const parsed = new URL(url); + + // Only support github.com + if (!parsed.hostname.includes("github.com")) { + return null; + } + + // Parse path: /owner/repo or /owner/repo/tree/branch/path + const parts = parsed.pathname.split("/").filter(Boolean); + + if (parts.length < 2) { + return null; + } + + const owner = parts[0]!; + // Remove .git suffix if present + const repo = parts[1]!.replace(/\.git$/, ""); + + // Simple case: /owner/repo + if (parts.length === 2) { + return { owner, repo }; + } + + // /owner/repo/tree/branch/path case + if (parts[2] === "tree" && parts.length >= 4) { + const ref = parts[3]; + const skillPath = parts.length > 4 ? parts.slice(4).join("/") : undefined; + return { owner, repo, ref, skillPath }; + } + + // /owner/repo/blob/... - not supported + if (parts[2] === "blob") { + return null; + } + + return { owner, repo }; + } catch { + return null; + } +} + +function parseShorthand(source: string): ParsedSource | null { + // Split off @ref if present + const [pathPart, ref] = source.split("@") as [string, string | undefined]; + + const parts = pathPart.split("/").filter(Boolean); + + if (parts.length < 2) { + return null; + } + + const owner = parts[0]!; + const repo = parts[1]!; + const skillPath = parts.length > 2 ? parts.slice(2).join("/") : undefined; + + return { owner, repo, skillPath, ref }; +} + +// ============================================================================ +// Git Operations +// ============================================================================ + +/** + * Run a git command with timeout + */ +async function runGit( + args: string[], + options: { + cwd?: string | undefined; + timeoutMs: number; + }, +): Promise<{ ok: boolean; stdout: string; stderr: string }> { + return new Promise((resolve) => { + const proc = spawn("git", args, { + cwd: options.cwd, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let killed = false; + + const timeout = setTimeout(() => { + killed = true; + proc.kill("SIGTERM"); + }, options.timeoutMs); + + proc.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + + proc.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + proc.on("close", (code: number | null) => { + clearTimeout(timeout); + if (killed) { + resolve({ ok: false, stdout, stderr: stderr + "\n[Timed out]" }); + } else { + resolve({ ok: code === 0, stdout, stderr }); + } + }); + + proc.on("error", (err: Error) => { + clearTimeout(timeout); + resolve({ ok: false, stdout, stderr: stderr + "\n" + err.message }); + }); + }); +} + +/** + * Clone a repository with sparse checkout for a specific path + */ +async function sparseClone(params: { + repoUrl: string; + targetDir: string; + sparsePath: string; + ref?: string | undefined; + timeoutMs: number; +}): Promise<{ ok: boolean; message: string }> { + const { repoUrl, targetDir, sparsePath, ref, timeoutMs } = params; + + // Initialize empty repo + let result = await runGit(["init", targetDir], { timeoutMs }); + if (!result.ok) { + return { ok: false, message: `git init failed: ${result.stderr}` }; + } + + // Add remote + result = await runGit(["remote", "add", "origin", repoUrl], { + cwd: targetDir, + timeoutMs, + }); + if (!result.ok) { + return { ok: false, message: `git remote add failed: ${result.stderr}` }; + } + + // Enable sparse checkout + result = await runGit(["config", "core.sparseCheckout", "true"], { + cwd: targetDir, + timeoutMs, + }); + if (!result.ok) { + return { ok: false, message: `git config failed: ${result.stderr}` }; + } + + // Set sparse checkout path + result = await runGit( + ["sparse-checkout", "set", "--no-cone", sparsePath], + { cwd: targetDir, timeoutMs }, + ); + if (!result.ok) { + return { ok: false, message: `git sparse-checkout failed: ${result.stderr}` }; + } + + // Fetch and checkout + const fetchRef = ref ?? "HEAD"; + result = await runGit(["fetch", "--depth=1", "origin", fetchRef], { + cwd: targetDir, + timeoutMs, + }); + if (!result.ok) { + return { ok: false, message: `git fetch failed: ${result.stderr}` }; + } + + result = await runGit(["checkout", "FETCH_HEAD"], { + cwd: targetDir, + timeoutMs, + }); + if (!result.ok) { + return { ok: false, message: `git checkout failed: ${result.stderr}` }; + } + + return { ok: true, message: "Sparse clone completed" }; +} + +/** + * Shallow clone an entire repository + */ +async function shallowClone(params: { + repoUrl: string; + targetDir: string; + ref?: string | undefined; + timeoutMs: number; +}): Promise<{ ok: boolean; message: string }> { + const { repoUrl, targetDir, ref, timeoutMs } = params; + + const args = ["clone", "--depth=1"]; + + if (ref) { + args.push("--branch", ref); + } + + args.push(repoUrl, targetDir); + + const result = await runGit(args, { timeoutMs }); + + if (!result.ok) { + return { ok: false, message: `git clone failed: ${result.stderr}` }; + } + + return { ok: true, message: "Clone completed" }; +} + +// ============================================================================ +// Skill Detection +// ============================================================================ + +/** + * Find SKILL.md files in a directory (recursively, max 2 levels) + */ +async function findSkillFiles( + dir: string, + maxDepth: number = 2, + currentDepth: number = 0, +): Promise { + const results: string[] = []; + + if (currentDepth > maxDepth) { + return results; + } + + try { + const entries = await readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + if (entry.isFile() && entry.name.toUpperCase() === "SKILL.MD") { + results.push(fullPath); + } else if (entry.isDirectory() && !entry.name.startsWith(".")) { + const nested = await findSkillFiles(fullPath, maxDepth, currentDepth + 1); + results.push(...nested); + } + } + } catch { + // Ignore read errors + } + + return results; +} + +/** + * Check if a directory is a valid skill (has SKILL.md) + */ +async function isSkillDirectory(dir: string): Promise { + const skillFile = join(dir, "SKILL.md"); + try { + const stats = await stat(skillFile); + return stats.isFile(); + } catch { + return false; + } +} + +// ============================================================================ +// Main Add Function +// ============================================================================ + +/** + * Add a skill from a GitHub repository + * + * Operations are serialized to prevent concurrent modifications + * to the same skill directory. + */ +export async function addSkill(request: SkillAddRequest): Promise { + // Parse source to determine the target name for serialization key + const parsed = parseSource(request.source); + const targetName = request.name ?? (parsed?.skillPath ? basename(parsed.skillPath) : parsed?.repo ?? "default"); + + // Serialize operations for the same target + return serialize(SerializeKeys.skillAdd(targetName), () => addSkillInternal(request)); +} + +/** + * Internal implementation of addSkill (serialized) + */ +async function addSkillInternal(request: SkillAddRequest): Promise { + const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + // Check git is available + if (!binaryExists("git")) { + return { + ok: false, + message: "git is not installed. Please install git first.", + }; + } + + // Parse source + const parsed = parseSource(request.source); + if (!parsed) { + return { + ok: false, + message: `Invalid source format: ${request.source}. Use owner/repo or owner/repo/skill-name`, + }; + } + + const { owner, repo, skillPath, ref } = parsed; + const repoUrl = `https://github.com/${owner}/${repo}.git`; + + // Determine target name + const targetName = request.name ?? (skillPath ? basename(skillPath) : repo); + const targetDir = join(SKILLS_DIR, targetName); + + // Check if exists + if (existsSync(targetDir) && !request.force) { + return { + ok: false, + message: `Skill '${targetName}' already exists at ${targetDir}. Use --force to overwrite.`, + }; + } + + // Ensure skills directory exists + await mkdir(SKILLS_DIR, { recursive: true }); + + // Remove existing if force + if (existsSync(targetDir)) { + await rm(targetDir, { recursive: true, force: true }); + } + + // Clone + let cloneResult: { ok: boolean; message: string }; + + if (skillPath) { + // Sparse checkout for specific skill path + cloneResult = await sparseClone({ + repoUrl, + targetDir, + sparsePath: skillPath, + ref, + timeoutMs, + }); + + if (cloneResult.ok) { + // Move skill contents up from nested path + const nestedPath = join(targetDir, skillPath); + if (existsSync(nestedPath)) { + // Create temp dir, move contents, swap + const tempDir = `${targetDir}_temp_${Date.now()}`; + await rename(nestedPath, tempDir); + await rm(targetDir, { recursive: true, force: true }); + await rename(tempDir, targetDir); + } + } + } else { + // Full shallow clone + cloneResult = await shallowClone({ + repoUrl, + targetDir, + ref, + timeoutMs, + }); + } + + if (!cloneResult.ok) { + // Clean up on failure + if (existsSync(targetDir)) { + await rm(targetDir, { recursive: true, force: true }); + } + return { + ok: false, + message: cloneResult.message, + }; + } + + // Remove .git directory to save space + const gitDir = join(targetDir, ".git"); + if (existsSync(gitDir)) { + await rm(gitDir, { recursive: true, force: true }); + } + + // Find skills in the downloaded content + const skillFiles = await findSkillFiles(targetDir); + + if (skillFiles.length === 0) { + // Check if this is a multi-skill repo + const isSkill = await isSkillDirectory(targetDir); + if (!isSkill) { + // Clean up - no valid skill found + await rm(targetDir, { recursive: true, force: true }); + return { + ok: false, + message: `No SKILL.md found in ${request.source}. Is this a valid skill repository?`, + }; + } + } + + // Bump version to trigger reload + bumpSkillsVersion("manual", targetDir); + + // Determine skill names found + const skillNames = skillFiles.map((f) => { + const dir = f.replace(/\/SKILL\.md$/i, ""); + return dir === targetDir ? targetName : basename(dir); + }); + + return { + ok: true, + message: + skillNames.length === 1 + ? `Added skill '${targetName}' to ${targetDir}` + : `Added ${skillNames.length} skills from ${owner}/${repo}`, + path: targetDir, + skills: skillNames.length > 0 ? skillNames : [targetName], + }; +} + +/** + * Remove an installed skill + * + * Operations are serialized to prevent concurrent modifications. + */ +export async function removeSkill(name: string): Promise { + return serialize(SerializeKeys.skillRemove(name), () => removeSkillInternal(name)); +} + +/** + * Internal implementation of removeSkill (serialized) + */ +async function removeSkillInternal(name: string): Promise { + const targetDir = join(SKILLS_DIR, name); + + if (!existsSync(targetDir)) { + return { + ok: false, + message: `Skill '${name}' not found at ${targetDir}`, + }; + } + + try { + await rm(targetDir, { recursive: true, force: true }); + bumpSkillsVersion("manual", targetDir); + + return { + ok: true, + message: `Removed skill '${name}'`, + path: targetDir, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + ok: false, + message: `Failed to remove skill: ${message}`, + }; + } +} + +/** + * List installed skills (in managed directory) + */ +export async function listInstalledSkills(): Promise { + if (!existsSync(SKILLS_DIR)) { + return []; + } + + try { + const entries = await readdir(SKILLS_DIR, { withFileTypes: true }); + const skills: string[] = []; + + for (const entry of entries) { + if (entry.isDirectory() && !entry.name.startsWith(".")) { + const hasSkill = await isSkillDirectory(join(SKILLS_DIR, entry.name)); + if (hasSkill) { + skills.push(entry.name); + } + } + } + + return skills; + } catch { + return []; + } +} diff --git a/src/agent/skills/eligibility.test.ts b/src/agent/skills/eligibility.test.ts index 39993ffc..08af1db1 100644 --- a/src/agent/skills/eligibility.test.ts +++ b/src/agent/skills/eligibility.test.ts @@ -1,21 +1,27 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { checkEligibility, filterEligibleSkills } from "./eligibility.js"; -import type { Skill, SkillFrontmatter, EligibilityResult } from "./types.js"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { checkEligibility, filterEligibleSkills, type EligibilityContext } from "./eligibility.js"; +import type { Skill, SkillFrontmatter } from "./types.js"; // Helper to create a skill for testing function createSkill( id: string, frontmatter: Partial & { name: string }, + source: "bundled" | "profile" = "bundled", ): Skill { return { id, frontmatter: frontmatter as SkillFrontmatter, instructions: "Test instructions", - source: "bundled", + source, filePath: `/path/to/${id}/SKILL.md`, }; } +// Helper to create context +function ctx(platform: NodeJS.Platform): EligibilityContext { + return { platform }; +} + describe("eligibility", () => { describe("checkEligibility", () => { describe("platform requirements", () => { @@ -24,12 +30,12 @@ describe("eligibility", () => { name: "Test Skill", }); - const result = checkEligibility(skill, "darwin"); + const result = checkEligibility(skill, ctx("darwin")); expect(result.eligible).toBe(true); expect(result.reasons).toBeUndefined(); }); - it("should be eligible when current platform matches", () => { + it("should be eligible when current platform matches (legacy platforms field)", () => { const skill = createSkill("test", { name: "Test Skill", metadata: { @@ -37,8 +43,20 @@ describe("eligibility", () => { }, }); - expect(checkEligibility(skill, "darwin").eligible).toBe(true); - expect(checkEligibility(skill, "linux").eligible).toBe(true); + expect(checkEligibility(skill, ctx("darwin")).eligible).toBe(true); + expect(checkEligibility(skill, ctx("linux")).eligible).toBe(true); + }); + + it("should be eligible when current platform matches (new os field)", () => { + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + os: ["darwin", "linux"], + }, + }); + + expect(checkEligibility(skill, ctx("darwin")).eligible).toBe(true); + expect(checkEligibility(skill, ctx("linux")).eligible).toBe(true); }); it("should be ineligible when platform does not match", () => { @@ -49,7 +67,7 @@ describe("eligibility", () => { }, }); - const result = checkEligibility(skill, "win32"); + const result = checkEligibility(skill, ctx("win32")); expect(result.eligible).toBe(false); expect(result.reasons).toContain( "Platform 'win32' not supported (requires: darwin)", @@ -64,13 +82,13 @@ describe("eligibility", () => { }, }); - const result = checkEligibility(skill, "darwin"); + const result = checkEligibility(skill, ctx("darwin")); expect(result.eligible).toBe(true); }); }); describe("binary requirements", () => { - it("should be eligible when required binary exists", () => { + it("should be eligible when required binary exists (legacy requiresBinaries)", () => { const skill = createSkill("test", { name: "Test Skill", metadata: { @@ -79,7 +97,21 @@ describe("eligibility", () => { }); // node should exist in the test environment - const result = checkEligibility(skill, "darwin"); + const result = checkEligibility(skill, ctx("darwin")); + expect(result.eligible).toBe(true); + }); + + it("should be eligible when required binary exists (new requires.bins)", () => { + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + requires: { + bins: ["node"], + }, + }, + }); + + const result = checkEligibility(skill, ctx("darwin")); expect(result.eligible).toBe(true); }); @@ -91,7 +123,7 @@ describe("eligibility", () => { }, }); - const result = checkEligibility(skill, "darwin"); + const result = checkEligibility(skill, ctx("darwin")); expect(result.eligible).toBe(false); expect(result.reasons).toContainEqual( expect.stringContaining("Required binary not found: nonexistent-binary-xyz-123"), @@ -106,7 +138,7 @@ describe("eligibility", () => { }, }); - const result = checkEligibility(skill, "darwin"); + const result = checkEligibility(skill, ctx("darwin")); expect(result.eligible).toBe(false); expect(result.reasons?.length).toBe(2); expect(result.reasons).toContainEqual( @@ -125,11 +157,44 @@ describe("eligibility", () => { }, }); - const result = checkEligibility(skill, "darwin"); + const result = checkEligibility(skill, ctx("darwin")); expect(result.eligible).toBe(true); }); }); + describe("anyBins requirements", () => { + it("should be eligible when at least one binary exists", () => { + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + requires: { + anyBins: ["nonexistent-1", "node", "nonexistent-2"], + }, + }, + }); + + const result = checkEligibility(skill, ctx("darwin")); + expect(result.eligible).toBe(true); + }); + + it("should be ineligible when none of anyBins exist", () => { + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + requires: { + anyBins: ["nonexistent-1", "nonexistent-2"], + }, + }, + }); + + const result = checkEligibility(skill, ctx("darwin")); + expect(result.eligible).toBe(false); + expect(result.reasons).toContainEqual( + expect.stringContaining("None of required binaries found"), + ); + }); + }); + describe("environment variable requirements", () => { const originalEnv = process.env; @@ -141,7 +206,7 @@ describe("eligibility", () => { process.env = originalEnv; }); - it("should be eligible when required env vars exist", () => { + it("should be eligible when required env vars exist (legacy requiresEnv)", () => { process.env.TEST_VAR = "value"; const skill = createSkill("test", { @@ -151,7 +216,23 @@ describe("eligibility", () => { }, }); - const result = checkEligibility(skill, "darwin"); + const result = checkEligibility(skill, ctx("darwin")); + expect(result.eligible).toBe(true); + }); + + it("should be eligible when required env vars exist (new requires.env)", () => { + process.env.TEST_VAR = "value"; + + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + requires: { + env: ["TEST_VAR"], + }, + }, + }); + + const result = checkEligibility(skill, ctx("darwin")); expect(result.eligible).toBe(true); }); @@ -165,7 +246,7 @@ describe("eligibility", () => { }, }); - const result = checkEligibility(skill, "darwin"); + const result = checkEligibility(skill, ctx("darwin")); expect(result.eligible).toBe(true); }); @@ -179,7 +260,7 @@ describe("eligibility", () => { }, }); - const result = checkEligibility(skill, "darwin"); + const result = checkEligibility(skill, ctx("darwin")); expect(result.eligible).toBe(false); expect(result.reasons).toContainEqual( expect.stringContaining("Required environment variable not set: MISSING_VAR"), @@ -198,10 +279,157 @@ describe("eligibility", () => { }, }); - const result = checkEligibility(skill, "darwin"); + const result = checkEligibility(skill, ctx("darwin")); expect(result.eligible).toBe(false); expect(result.reasons?.length).toBe(2); }); + + it("should be eligible when env var provided via skillConfig", () => { + delete process.env.API_KEY; + + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + requires: { + env: ["API_KEY"], + }, + }, + }); + + const result = checkEligibility(skill, { + platform: "darwin", + config: { + entries: { + test: { + env: { API_KEY: "secret" }, + }, + }, + }, + }); + expect(result.eligible).toBe(true); + }); + + it("should be eligible when env var provided via apiKey + primaryEnv", () => { + delete process.env.GEMINI_API_KEY; + + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + primaryEnv: "GEMINI_API_KEY", + requires: { + env: ["GEMINI_API_KEY"], + }, + }, + }); + + const result = checkEligibility(skill, { + platform: "darwin", + config: { + entries: { + test: { + apiKey: "my-api-key", + }, + }, + }, + }); + expect(result.eligible).toBe(true); + }); + }); + + describe("always flag", () => { + it("should be eligible when always is true regardless of other checks", () => { + const skill = createSkill("test", { + name: "Test Skill", + metadata: { + always: true, + requiresBinaries: ["nonexistent-binary"], + requiresEnv: ["NONEXISTENT_VAR"], + }, + }); + + const result = checkEligibility(skill, ctx("darwin")); + expect(result.eligible).toBe(true); + }); + }); + + describe("config disabled", () => { + it("should be ineligible when explicitly disabled in config", () => { + const skill = createSkill("test", { + name: "Test Skill", + }); + + const result = checkEligibility(skill, { + platform: "darwin", + config: { + entries: { + test: { + enabled: false, + }, + }, + }, + }); + expect(result.eligible).toBe(false); + expect(result.reasons).toContain("Skill disabled in configuration"); + }); + }); + + describe("bundled allowlist", () => { + it("should be ineligible when bundled skill not in allowlist", () => { + const skill = createSkill("test", { + name: "Test Skill", + }, "bundled"); + + const result = checkEligibility(skill, { + platform: "darwin", + config: { + allowBundled: ["other-skill"], + }, + }); + expect(result.eligible).toBe(false); + expect(result.reasons).toContain("Bundled skill not in allowlist"); + }); + + it("should be eligible when bundled skill in allowlist", () => { + const skill = createSkill("test", { + name: "Test Skill", + }, "bundled"); + + const result = checkEligibility(skill, { + platform: "darwin", + config: { + allowBundled: ["test", "other-skill"], + }, + }); + expect(result.eligible).toBe(true); + }); + + it("should allow all bundled skills when allowlist is empty", () => { + const skill = createSkill("test", { + name: "Test Skill", + }, "bundled"); + + const result = checkEligibility(skill, { + platform: "darwin", + config: { + allowBundled: [], + }, + }); + expect(result.eligible).toBe(true); + }); + + it("should not affect profile skills", () => { + const skill = createSkill("test", { + name: "Test Skill", + }, "profile"); + + const result = checkEligibility(skill, { + platform: "darwin", + config: { + allowBundled: ["other-skill"], + }, + }); + expect(result.eligible).toBe(true); + }); }); describe("combined requirements", () => { @@ -227,9 +455,11 @@ describe("eligibility", () => { }, }); - const result = checkEligibility(skill, "darwin"); + // Note: platform check fails first and returns early + const result = checkEligibility(skill, ctx("darwin")); expect(result.eligible).toBe(false); - expect(result.reasons?.length).toBe(3); + // Platform check returns early + expect(result.reasons?.length).toBe(1); }); it("should be eligible when all requirements met", () => { @@ -244,7 +474,7 @@ describe("eligibility", () => { }, }); - const result = checkEligibility(skill, "darwin"); + const result = checkEligibility(skill, ctx("darwin")); expect(result.eligible).toBe(true); expect(result.reasons).toBeUndefined(); }); @@ -258,7 +488,7 @@ describe("eligibility", () => { }, }); - // Call without platform argument + // Call without context const result = checkEligibility(skill); expect(result.eligible).toBe(true); }); @@ -290,7 +520,7 @@ describe("eligibility", () => { })], ]); - const eligible = filterEligibleSkills(skills, "darwin"); + const eligible = filterEligibleSkills(skills, ctx("darwin")); expect(eligible.size).toBe(2); expect(eligible.has("darwin-only")).toBe(true); @@ -306,7 +536,7 @@ describe("eligibility", () => { })], ]); - const eligible = filterEligibleSkills(skills, "darwin"); + const eligible = filterEligibleSkills(skills, ctx("darwin")); expect(eligible.size).toBe(0); }); @@ -318,15 +548,35 @@ describe("eligibility", () => { ["skill-3", createSkill("skill-3", { name: "Skill 3" })], ]); - const eligible = filterEligibleSkills(skills, "darwin"); + const eligible = filterEligibleSkills(skills, ctx("darwin")); expect(eligible.size).toBe(3); }); it("should handle empty input map", () => { const skills = new Map(); - const eligible = filterEligibleSkills(skills, "darwin"); + const eligible = filterEligibleSkills(skills, ctx("darwin")); expect(eligible.size).toBe(0); }); + + it("should respect config when filtering", () => { + const skills = new Map([ + ["enabled-skill", createSkill("enabled-skill", { name: "Enabled" })], + ["disabled-skill", createSkill("disabled-skill", { name: "Disabled" })], + ]); + + const eligible = filterEligibleSkills(skills, { + platform: "darwin", + config: { + entries: { + "disabled-skill": { enabled: false }, + }, + }, + }); + + expect(eligible.size).toBe(1); + expect(eligible.has("enabled-skill")).toBe(true); + expect(eligible.has("disabled-skill")).toBe(false); + }); }); }); diff --git a/src/agent/skills/eligibility.ts b/src/agent/skills/eligibility.ts index dab295dd..83e099d1 100644 --- a/src/agent/skills/eligibility.ts +++ b/src/agent/skills/eligibility.ts @@ -1,11 +1,57 @@ /** * Skill Eligibility Checker * - * Filter skills based on platform, binaries, and environment requirements + * Filter skills based on platform, binaries, environment, and configuration + * Compatible with OpenClaw eligibility rules + * + * Enhanced with detailed diagnostics and actionable hints */ import { execSync } from "node:child_process"; -import type { Skill, EligibilityResult } from "./types.js"; +import type { + Skill, + SkillsConfig, + EligibilityResult, +} from "./types.js"; +import { + getSkillKey, + getSkillConfig, + normalizeRequirements, + normalizePlatforms, +} from "./types.js"; + +// ============================================================================ +// Diagnostic Types +// ============================================================================ + +export type DiagnosticType = + | "disabled" + | "not_in_allowlist" + | "platform" + | "binary" + | "any_binary" + | "env" + | "config"; + +export interface DiagnosticItem { + /** Type of diagnostic issue */ + type: DiagnosticType; + /** Human-readable message */ + message: string; + /** Actionable hint to resolve the issue */ + hint?: string | undefined; + /** Related values (e.g., missing binary names) */ + values?: string[] | undefined; +} + +export interface DetailedEligibilityResult extends EligibilityResult { + /** Detailed diagnostics for each issue */ + diagnostics?: DiagnosticItem[] | undefined; +} + +// ============================================================================ +// Binary and Environment Checks +// ============================================================================ /** * Check if a binary exists in PATH @@ -13,7 +59,7 @@ import type { Skill, EligibilityResult } from "./types.js"; * @param binary - Binary name to check * @returns True if binary exists */ -function binaryExists(binary: string): boolean { +export function binaryExists(binary: string): boolean { try { // Use 'which' on Unix, 'where' on Windows const cmd = process.platform === "win32" ? `where ${binary}` : `which ${binary}`; @@ -34,73 +80,427 @@ function envExists(envVar: string): boolean { return envVar in process.env; } +// ============================================================================ +// Config Path Resolution +// ============================================================================ + /** - * Check if a skill is eligible based on its requirements + * Resolve a dot-separated config path + * + * @param config - Config object + * @param pathStr - Dot-separated path (e.g., "browser.enabled") + * @returns The value at the path, or undefined + */ +export function resolveConfigPath( + config: Record | undefined, + pathStr: string, +): unknown { + if (!config) return undefined; + + 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; +} + +/** + * Check if a config path is truthy + * + * @param config - Config object + * @param pathStr - Dot-separated path + * @returns True if the value at path is truthy + */ +export function isConfigPathTruthy( + config: Record | undefined, + pathStr: string, +): boolean { + const value = resolveConfigPath(config, pathStr); + 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; +} + +// ============================================================================ +// Bundled Skills Allowlist +// ============================================================================ + +const BUNDLED_SOURCES = new Set(["bundled"]); + +/** + * Check if a skill is from bundled source + */ +function isBundledSkill(skill: Skill): boolean { + return BUNDLED_SOURCES.has(skill.source); +} + +/** + * Check if a bundled skill is allowed by the allowlist * * @param skill - Skill to check - * @param platform - Platform to check against (defaults to current) + * @param allowlist - List of allowed skill keys (undefined = allow all) + * @returns True if allowed + */ +function isBundledSkillAllowed(skill: Skill, allowlist?: string[]): boolean { + // No allowlist = allow all + if (!allowlist || allowlist.length === 0) return true; + // Non-bundled skills are always allowed + if (!isBundledSkill(skill)) return true; + // Check if skill key or id is in allowlist + const key = getSkillKey(skill); + return allowlist.includes(key) || allowlist.includes(skill.id); +} + +// ============================================================================ +// Main Eligibility Check +// ============================================================================ + +export interface EligibilityContext { + /** Skills configuration */ + config?: SkillsConfig | undefined; + /** Platform to check against (defaults to current) */ + platform?: NodeJS.Platform | undefined; + /** Custom config object for config path checks */ + customConfig?: Record | undefined; +} + +/** + * Check if a skill is eligible based on its requirements and configuration + * + * Eligibility rules (in order): + * 1. If explicitly disabled in config → not eligible + * 2. If bundled and not in allowlist → not eligible + * 3. If platform not supported → not eligible + * 4. If metadata.always is true → eligible (skip remaining checks) + * 5. All required binaries must exist + * 6. At least one of anyBins must exist (if specified) + * 7. All required env vars must be set (or provided via config) + * 8. All required config paths must be truthy + * + * @param skill - Skill to check + * @param context - Eligibility context * @returns Eligibility result with reasons if ineligible */ export function checkEligibility( skill: Skill, - platform: NodeJS.Platform = process.platform, + context: EligibilityContext = {}, ): EligibilityResult { - const reasons: string[] = []; - const metadata = skill.frontmatter.metadata; + const result = checkEligibilityDetailed(skill, context); + // Return simple result for backward compatibility + return { + eligible: result.eligible, + reasons: result.reasons, + }; +} - // No metadata means no requirements - if (!metadata) { +/** + * Check eligibility with detailed diagnostics + * + * Same as checkEligibility but returns detailed diagnostics with hints + * + * @param skill - Skill to check + * @param context - Eligibility context + * @returns Detailed eligibility result with diagnostics + */ +export function checkEligibilityDetailed( + skill: Skill, + context: EligibilityContext = {}, +): DetailedEligibilityResult { + const { config, platform = process.platform, customConfig } = context; + const reasons: string[] = []; + const diagnostics: DiagnosticItem[] = []; + const metadata = skill.frontmatter.metadata; + const skillConfig = getSkillConfig(skill, config); + + // 1. Check if explicitly disabled in config + if (skillConfig?.enabled === false) { + const msg = "Skill disabled in configuration"; + reasons.push(msg); + diagnostics.push({ + type: "disabled", + message: msg, + hint: `Enable by setting skills.${getSkillKey(skill)}.enabled: true in config`, + }); + return { eligible: false, reasons, diagnostics }; + } + + // 2. Check bundled allowlist + if (!isBundledSkillAllowed(skill, config?.allowBundled)) { + const msg = "Bundled skill not in allowlist"; + reasons.push(msg); + diagnostics.push({ + type: "not_in_allowlist", + message: msg, + hint: `Add '${getSkillKey(skill)}' to config.allowBundled array`, + }); + return { eligible: false, reasons, diagnostics }; + } + + // 3. Platform check + const platforms = normalizePlatforms(metadata); + if (platforms.length > 0 && !platforms.includes(platform)) { + const msg = `Platform '${platform}' not supported (requires: ${platforms.join(", ")})`; + reasons.push(msg); + diagnostics.push({ + type: "platform", + message: msg, + hint: `This skill only works on: ${platforms.join(", ")}`, + values: platforms, + }); + return { eligible: false, reasons, diagnostics }; + } + + // 4. Always flag - skip remaining checks + if (metadata?.always === true) { return { eligible: true }; } - // Platform check - if (metadata.platforms && metadata.platforms.length > 0) { - if (!metadata.platforms.includes(platform)) { - reasons.push( - `Platform '${platform}' not supported (requires: ${metadata.platforms.join(", ")})`, - ); + // Get normalized requirements + const requirements = normalizeRequirements(metadata); + + // 5. Required binaries check (all must exist) + if (requirements.bins && requirements.bins.length > 0) { + const missingBins: string[] = []; + for (const bin of requirements.bins) { + if (!binaryExists(bin)) { + missingBins.push(bin); + reasons.push(`Required binary not found: ${bin}`); + } + } + if (missingBins.length > 0) { + diagnostics.push({ + type: "binary", + message: `Missing required binaries: ${missingBins.join(", ")}`, + hint: generateBinaryInstallHint(missingBins, skill), + values: missingBins, + }); } } - // Binary requirements check - if (metadata.requiresBinaries && metadata.requiresBinaries.length > 0) { - for (const binary of metadata.requiresBinaries) { - if (!binaryExists(binary)) { - reasons.push(`Required binary not found: ${binary}`); - } + // 6. Any binaries check (at least one must exist) + if (requirements.anyBins && requirements.anyBins.length > 0) { + const anyFound = requirements.anyBins.some((bin) => binaryExists(bin)); + if (!anyFound) { + const msg = `None of required binaries found: ${requirements.anyBins.join(", ")}`; + reasons.push(msg); + diagnostics.push({ + type: "any_binary", + message: msg, + hint: `Install any one of: ${requirements.anyBins.join(", ")}`, + values: requirements.anyBins, + }); } } - // Environment variable check - if (metadata.requiresEnv && metadata.requiresEnv.length > 0) { - for (const envVar of metadata.requiresEnv) { - if (!envExists(envVar)) { - reasons.push(`Required environment variable not set: ${envVar}`); + // 7. Environment variable check + const missingEnvVars: string[] = []; + if (requirements.env && requirements.env.length > 0) { + for (const envVar of requirements.env) { + // Check if env var exists + if (envExists(envVar)) continue; + + // Check if provided via skill config env + if (skillConfig?.env?.[envVar]) continue; + + // Check if provided via apiKey + primaryEnv match + if (skillConfig?.apiKey && metadata?.primaryEnv === envVar) continue; + + missingEnvVars.push(envVar); + reasons.push(`Required environment variable not set: ${envVar}`); + } + } + if (missingEnvVars.length > 0) { + diagnostics.push({ + type: "env", + message: `Missing environment variables: ${missingEnvVars.join(", ")}`, + hint: generateEnvHint(missingEnvVars, skill), + values: missingEnvVars, + }); + } + + // 8. Config path check + const missingConfigs: string[] = []; + if (requirements.config && requirements.config.length > 0) { + for (const configPath of requirements.config) { + if (!isConfigPathTruthy(customConfig, configPath)) { + missingConfigs.push(configPath); + reasons.push(`Required config path not truthy: ${configPath}`); } } } + if (missingConfigs.length > 0) { + diagnostics.push({ + type: "config", + message: `Missing config values: ${missingConfigs.join(", ")}`, + hint: `Set the following config paths: ${missingConfigs.join(", ")}`, + values: missingConfigs, + }); + } return { eligible: reasons.length === 0, reasons: reasons.length > 0 ? reasons : undefined, + diagnostics: diagnostics.length > 0 ? diagnostics : undefined, }; } +// ============================================================================ +// Hint Generation +// ============================================================================ + +/** + * Generate installation hints for missing binaries + */ +function generateBinaryInstallHint(binaries: string[], skill: Skill): string { + const hints: string[] = []; + + // Check if skill has install specs for these binaries + const installSpecs = skill.frontmatter.metadata?.install; + if (installSpecs && installSpecs.length > 0) { + hints.push(`Run: pnpm skills:cli install ${skill.id}`); + } + + // Generate platform-specific hints + const platform = process.platform; + + for (const bin of binaries) { + const installHint = getBinaryInstallHint(bin, platform); + if (installHint && !hints.includes(installHint)) { + hints.push(installHint); + } + } + + if (hints.length === 0) { + hints.push(`Install: ${binaries.join(", ")}`); + } + + return hints.join(" OR "); +} + +/** + * Get platform-specific install hint for a binary + */ +function getBinaryInstallHint(binary: string, platform: NodeJS.Platform): string | null { + const commonBinaries: Record> = { + // Package managers + brew: { darwin: "Install Homebrew: /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"" }, + npm: { darwin: "brew install node", linux: "apt install nodejs", win32: "Download from nodejs.org" }, + pnpm: { "*": "npm install -g pnpm" }, + yarn: { "*": "npm install -g yarn" }, + bun: { darwin: "brew install bun", linux: "curl -fsSL https://bun.sh/install | bash" }, + + // Common tools + git: { darwin: "brew install git", linux: "apt install git", win32: "Download from git-scm.com" }, + python: { darwin: "brew install python", linux: "apt install python3", win32: "Download from python.org" }, + python3: { darwin: "brew install python", linux: "apt install python3" }, + pip: { "*": "python -m ensurepip" }, + uv: { darwin: "brew install uv", linux: "curl -LsSf https://astral.sh/uv/install.sh | sh" }, + + // Development tools + go: { darwin: "brew install go", linux: "apt install golang-go" }, + rustc: { "*": "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" }, + cargo: { "*": "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" }, + java: { darwin: "brew install openjdk", linux: "apt install default-jdk" }, + + // PDF tools + pdftk: { darwin: "brew install pdftk-java", linux: "apt install pdftk" }, + qpdf: { darwin: "brew install qpdf", linux: "apt install qpdf" }, + gs: { darwin: "brew install ghostscript", linux: "apt install ghostscript" }, + magick: { darwin: "brew install imagemagick", linux: "apt install imagemagick" }, + + // Other common + ffmpeg: { darwin: "brew install ffmpeg", linux: "apt install ffmpeg" }, + jq: { darwin: "brew install jq", linux: "apt install jq" }, + curl: { darwin: "brew install curl", linux: "apt install curl" }, + wget: { darwin: "brew install wget", linux: "apt install wget" }, + }; + + const hints = commonBinaries[binary]; + if (!hints) return null; + + // Check for platform-specific hint + if (hints[platform]) { + return hints[platform]!; + } + + // Check for wildcard hint + if (hints["*"]) { + return hints["*"]; + } + + return null; +} + +/** + * Generate hints for missing environment variables + */ +function generateEnvHint(envVars: string[], skill: Skill): string { + const hints: string[] = []; + const skillKey = getSkillKey(skill); + + for (const envVar of envVars) { + // Check for well-known API key patterns + if (envVar.endsWith("_API_KEY") || envVar.endsWith("_KEY")) { + const service = envVar.replace(/_API_KEY$|_KEY$/, "").toLowerCase(); + hints.push(`Set ${envVar} in your environment or add to .env file`); + + // Add provider-specific hints + const providerHint = getApiKeyHint(envVar); + if (providerHint) { + hints.push(providerHint); + } + } else { + hints.push(`export ${envVar}=`); + } + } + + // Also suggest config-based approach + hints.push(`Or configure via: skills.${skillKey}.env.${envVars[0]}`); + + return hints.slice(0, 3).join(" OR "); +} + +/** + * Get hint for obtaining API keys + */ +function getApiKeyHint(envVar: string): string | null { + const keyHints: Record = { + OPENAI_API_KEY: "Get from: platform.openai.com/api-keys", + ANTHROPIC_API_KEY: "Get from: console.anthropic.com", + GOOGLE_API_KEY: "Get from: console.cloud.google.com", + PERPLEXITY_API_KEY: "Get from: perplexity.ai/settings/api", + DEEPSEEK_API_KEY: "Get from: platform.deepseek.com", + GROQ_API_KEY: "Get from: console.groq.com", + MISTRAL_API_KEY: "Get from: console.mistral.ai", + TOGETHER_API_KEY: "Get from: api.together.xyz", + }; + + return keyHints[envVar] ?? null; +} + /** * Filter skills by eligibility * * @param skills - Map of skills to filter - * @param platform - Platform to check against + * @param context - Eligibility context * @returns Map containing only eligible skills */ export function filterEligibleSkills( skills: Map, - platform?: NodeJS.Platform, + context: EligibilityContext = {}, ): Map { const eligible = new Map(); for (const [id, skill] of skills) { - const result = checkEligibility(skill, platform); + const result = checkEligibility(skill, context); if (result.eligible) { eligible.set(id, skill); } @@ -108,3 +508,17 @@ export function filterEligibleSkills( return eligible; } + +// ============================================================================ +// Legacy Compatibility +// ============================================================================ + +/** + * @deprecated Use checkEligibility with context instead + */ +export function checkEligibilityLegacy( + skill: Skill, + platform: NodeJS.Platform = process.platform, +): EligibilityResult { + return checkEligibility(skill, { platform }); +} diff --git a/src/agent/skills/index.ts b/src/agent/skills/index.ts index 776d983c..7ea7115c 100644 --- a/src/agent/skills/index.ts +++ b/src/agent/skills/index.ts @@ -2,11 +2,32 @@ * Skills Module * * Manages skill loading, eligibility filtering, and system prompt generation + * Compatible with OpenClaw/AgentSkills specification */ -import type { Skill, SkillManagerOptions } from "./types.js"; +import type { Skill, SkillManagerOptions, SkillsConfig, SkillCommandSpec, SkillInvocationResult } from "./types.js"; import { loadAllSkills, getBundledSkillsDir, getProfileSkillsDir } from "./loader.js"; -import { filterEligibleSkills, checkEligibility } from "./eligibility.js"; +import { + filterEligibleSkills, + checkEligibility, + type EligibilityContext, +} from "./eligibility.js"; +import { + startSkillsWatcher, + stopSkillsWatcher, + getSkillsVersion, + bumpSkillsVersion, + onSkillsChange, + isWatcherActive, + type SkillsChangeEvent, + type SkillsChangeListener, +} from "./watcher.js"; +import { + buildSkillCommands, + resolveSkillInvocation, + getCommandCompletions, + isModelInvocable, +} from "./invoke.js"; // Re-export types and utilities export type { @@ -15,36 +36,207 @@ export type { SkillMetadata, SkillSource, SkillManagerOptions, + SkillsConfig, + SkillConfig, + SkillsLoadConfig, + SkillsInstallConfig, + SkillInstallSpec, + SkillRequirements, EligibilityResult, + SkillInvocationPolicy, + SkillCommandSpec, + SkillCommandDispatch, + SkillInvocationResult, } from "./types.js"; -export { SKILL_FILE, SKILL_SOURCE_PRECEDENCE } from "./types.js"; -export { checkEligibility, filterEligibleSkills } from "./eligibility.js"; +export { + SKILL_FILE, + SKILL_SOURCE_PRECEDENCE, + getSkillKey, + getSkillConfig, + normalizeRequirements, + normalizePlatforms, +} from "./types.js"; + +export { + checkEligibility, + checkEligibilityDetailed, + filterEligibleSkills, + binaryExists, + resolveConfigPath, + isConfigPathTruthy, + type EligibilityContext, + type DiagnosticType, + type DiagnosticItem, + type DetailedEligibilityResult, +} from "./eligibility.js"; + export { parseFrontmatter, parseSkillFile } from "./parser.js"; export { loadAllSkills, getBundledSkillsDir, getProfileSkillsDir } from "./loader.js"; +// Export install module +export { + installSkill, + selectPreferredInstallSpec, + getInstallOptions, + type SkillInstallRequest, + type SkillInstallResult, +} from "./install.js"; + +// Export watcher module +export { + startSkillsWatcher, + stopSkillsWatcher, + getSkillsVersion, + bumpSkillsVersion, + onSkillsChange, + isWatcherActive, + type SkillsChangeEvent, + type SkillsChangeListener, +} from "./watcher.js"; + +// Export add module +export { + addSkill, + removeSkill, + listInstalledSkills, + parseSource, + type SkillAddRequest, + type SkillAddResult, +} from "./add.js"; + +// Export invoke module +export { + resolveInvocationPolicy, + isUserInvocable, + isModelInvocable, + sanitizeCommandName, + buildSkillCommands, + findSkillCommand, + resolveSkillInvocation, + getCommandCompletions, +} from "./invoke.js"; + +// Export serialize module +export { + serialize, + createSerialized, + isProcessing, + getQueueLength, + getActiveKeys, + waitForKey, + waitForAll, + SerializeKeys, +} from "./serialize.js"; + +// Export plugin module +export { + PLUGIN_MANIFEST_FILENAME, + loadPluginManifest, + loadPluginRegistry, + resolvePluginSkillDirs, + getPluginRegistry, + type PluginManifest, + type PluginRecord, + type PluginDiagnostic, + type PluginRegistry, +} from "./plugin.js"; + /** * SkillManager - Loads and manages skills * * Provides access to skills from multiple sources with precedence handling - * and eligibility filtering. + * and eligibility filtering based on configuration. + * + * Supports hot-reload via file watching when enabled. */ export class SkillManager { private readonly options: SkillManagerOptions; private skills: Map | undefined; private eligibleSkills: Map | undefined; + private loadedVersion: number = 0; + private unsubscribeWatcher: (() => void) | undefined; constructor(options: SkillManagerOptions = {}) { this.options = options; } + /** + * Get the eligibility context for filtering + */ + private getEligibilityContext(): EligibilityContext { + return { + config: this.options.config, + platform: this.options.platform, + }; + } + /** * Ensure skills are loaded (lazy loading) + * Also checks if reload is needed due to file changes */ private ensureLoaded(): void { + const currentVersion = getSkillsVersion(); + + // Reload if version changed (file watcher triggered) + if (this.skills && this.loadedVersion !== currentVersion) { + this.skills = undefined; + this.eligibleSkills = undefined; + } + if (this.skills) return; + this.skills = loadAllSkills(this.options); - this.eligibleSkills = filterEligibleSkills(this.skills, this.options.platform); + this.eligibleSkills = filterEligibleSkills( + this.skills, + this.getEligibilityContext(), + ); + this.loadedVersion = currentVersion; + } + + /** + * Start file watching for hot reload + * + * @returns Promise that resolves when watcher is started + */ + async startWatching(): Promise { + // Don't start if watching is disabled in config + if (this.options.config?.load?.watch === false) { + return; + } + + // Subscribe to changes for automatic reload + this.unsubscribeWatcher = onSkillsChange(() => { + // Just invalidate cache, reload happens on next access + this.skills = undefined; + this.eligibleSkills = undefined; + }); + + // Start the watcher (enabled by default unless explicitly set to false) + const watchEnabled = this.options.config?.load?.watch ?? true; + await startSkillsWatcher({ + extraDirs: this.options.extraDirs, + debounceMs: this.options.config?.load?.watchDebounceMs, + enabled: watchEnabled, + }); + } + + /** + * Stop file watching + */ + async stopWatching(): Promise { + if (this.unsubscribeWatcher) { + this.unsubscribeWatcher(); + this.unsubscribeWatcher = undefined; + } + await stopSkillsWatcher(); + } + + /** + * Check if file watching is active + */ + isWatching(): boolean { + return isWatcherActive(); } /** @@ -85,6 +277,18 @@ export class SkillManager { return this.skills!.get(skillId); } + /** + * Check eligibility for a specific skill + * + * @param skillId - Skill identifier + * @returns Eligibility result or undefined if skill not found + */ + checkSkillEligibility(skillId: string): { eligible: boolean; reasons?: string[] | undefined } | undefined { + const skill = this.getSkillFromAll(skillId); + if (!skill) return undefined; + return checkEligibility(skill, this.getEligibilityContext()); + } + /** * Reload skills from disk * Clears cache and reloads on next access @@ -92,6 +296,24 @@ export class SkillManager { reload(): void { this.skills = undefined; this.eligibleSkills = undefined; + bumpSkillsVersion("manual"); + } + + /** + * Update configuration and reload + * + * @param config - New skills configuration + */ + updateConfig(config: SkillsConfig): void { + (this.options as { config?: SkillsConfig }).config = config; + this.reload(); + } + + /** + * Get the current configuration + */ + getConfig(): SkillsConfig | undefined { + return this.options.config; } /** @@ -147,10 +369,22 @@ export class SkillManager { * * @returns Array of skill info for display */ - listSkills(): Array<{ id: string; name: string; emoji: string; description: string }> { + listSkills(): Array<{ + id: string; + name: string; + emoji: string; + description: string; + source: string; + }> { this.ensureLoaded(); - const result: Array<{ id: string; name: string; emoji: string; description: string }> = []; + const result: Array<{ + id: string; + name: string; + emoji: string; + description: string; + source: string; + }> = []; for (const [id, skill] of this.eligibleSkills!) { result.push({ @@ -158,9 +392,143 @@ export class SkillManager { name: skill.frontmatter.name, emoji: skill.frontmatter.metadata?.emoji ?? "🔧", description: skill.frontmatter.description ?? "No description", + source: skill.source, }); } return result; } + + /** + * List all skills with eligibility status + * + * @returns Array of skill info with eligibility status + */ + listAllSkillsWithStatus(): Array<{ + id: string; + name: string; + emoji: string; + description: string; + source: string; + eligible: boolean; + reasons?: string[] | undefined; + }> { + this.ensureLoaded(); + + const result: Array<{ + id: string; + name: string; + emoji: string; + description: string; + source: string; + eligible: boolean; + reasons?: string[] | undefined; + }> = []; + + for (const [id, skill] of this.skills!) { + const eligibility = checkEligibility(skill, this.getEligibilityContext()); + result.push({ + id, + name: skill.frontmatter.name, + emoji: skill.frontmatter.metadata?.emoji ?? "🔧", + description: skill.frontmatter.description ?? "No description", + source: skill.source, + eligible: eligibility.eligible, + reasons: eligibility.reasons, + }); + } + + return result; + } + + // ============================================================================ + // Invocation Methods + // ============================================================================ + + private cachedCommands: SkillCommandSpec[] | undefined; + private cachedCommandsVersion: number = 0; + + /** + * Get user-invocable skill commands + * + * @param options - Optional reserved names to avoid + * @returns Array of command specifications + */ + getSkillCommands(options?: { reservedNames?: Set }): SkillCommandSpec[] { + this.ensureLoaded(); + + const currentVersion = getSkillsVersion(); + if (this.cachedCommands && this.cachedCommandsVersion === currentVersion) { + return this.cachedCommands; + } + + this.cachedCommands = buildSkillCommands(this.eligibleSkills!, options); + this.cachedCommandsVersion = currentVersion; + return this.cachedCommands; + } + + /** + * Resolve a user command to a skill invocation + * + * @param input - User input (e.g., "/pdf edit file.pdf") + * @returns Invocation result or null if not a skill command + */ + resolveCommand(input: string): SkillInvocationResult | null { + this.ensureLoaded(); + const commands = this.getSkillCommands(); + return resolveSkillInvocation(input, commands, this.eligibleSkills!); + } + + /** + * Get command completions for a prefix + * + * @param prefix - Input prefix (e.g., "/p" or "p") + * @returns Matching command names with leading / + */ + getCompletions(prefix: string): string[] { + const commands = this.getSkillCommands(); + return getCommandCompletions(prefix, commands); + } + + /** + * Build skills prompt excluding user-only skills + * + * Only includes skills that are model-invocable (disableModelInvocation !== true) + * + * @returns Formatted skill documentation for AI system prompt + */ + buildModelSkillsPrompt(): string { + this.ensureLoaded(); + + const modelSkills = new Map(); + for (const [id, skill] of this.eligibleSkills!) { + if (isModelInvocable(skill)) { + modelSkills.set(id, skill); + } + } + + if (modelSkills.size === 0) { + return ""; + } + + const parts: string[] = []; + parts.push("# Available Skills\n"); + parts.push("You have access to the following skills:\n"); + + for (const [id, skill] of modelSkills) { + const emoji = skill.frontmatter.metadata?.emoji ?? "🔧"; + const name = skill.frontmatter.name; + const desc = skill.frontmatter.description ?? "No description provided"; + + parts.push(`## ${emoji} ${name} (${id})`); + parts.push(`${desc}\n`); + + if (skill.instructions) { + parts.push(skill.instructions); + parts.push(""); + } + } + + return parts.join("\n"); + } } diff --git a/src/agent/skills/install.ts b/src/agent/skills/install.ts new file mode 100644 index 00000000..40eeddce --- /dev/null +++ b/src/agent/skills/install.ts @@ -0,0 +1,663 @@ +/** + * Skills Install Module + * + * Handles installation of skill dependencies (brew, npm, uv, go, download) + */ + +import { spawn } from "node:child_process"; +import { createWriteStream, existsSync } from "node:fs"; +import { mkdir, stat, unlink } from "node:fs/promises"; +import { join, basename, dirname } from "node:path"; +import { pipeline } from "node:stream/promises"; +import { Readable } from "node:stream"; + +import { DATA_DIR } from "../../shared/index.js"; +import type { Skill, SkillInstallSpec, SkillsInstallConfig } from "./types.js"; +import { getSkillKey } from "./types.js"; +import { binaryExists } from "./eligibility.js"; +import { serialize, SerializeKeys } from "./serialize.js"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface SkillInstallRequest { + /** Skill to install dependencies for */ + skill: Skill; + /** Specific install spec ID (if skill has multiple) */ + installId?: string | undefined; + /** Timeout in milliseconds (default: 300000 = 5 min) */ + timeoutMs?: number | undefined; + /** Install preferences */ + prefs?: SkillsInstallConfig | undefined; +} + +export interface SkillInstallResult { + /** Whether installation succeeded */ + ok: boolean; + /** Human-readable message */ + message: string; + /** Command stdout */ + stdout: string; + /** Command stderr */ + stderr: string; + /** Exit code (null if not applicable) */ + code: number | null; +} + +// ============================================================================ +// Constants +// ============================================================================ + +/** Default timeout for install commands (5 minutes) */ +const DEFAULT_TIMEOUT_MS = 300_000; + +/** Maximum timeout (15 minutes) */ +const MAX_TIMEOUT_MS = 900_000; + +/** Tools directory: ~/.super-multica/tools */ +const TOOLS_DIR = join(DATA_DIR, "tools"); + +// ============================================================================ +// Command Building +// ============================================================================ + +/** + * Build the install command for a given spec + */ +function buildInstallCommand( + spec: SkillInstallSpec, + prefs: SkillsInstallConfig = {}, +): { argv: string[] | null; error?: string } { + switch (spec.kind) { + case "brew": { + if (!spec.formula) { + return { argv: null, error: "Missing brew formula" }; + } + return { argv: ["brew", "install", spec.formula] }; + } + + case "node": { + if (!spec.package) { + return { argv: null, error: "Missing node package" }; + } + const pkg = spec.package; + switch (prefs.nodeManager) { + case "pnpm": + return { argv: ["pnpm", "add", "-g", pkg] }; + case "yarn": + return { argv: ["yarn", "global", "add", pkg] }; + case "bun": + return { argv: ["bun", "add", "-g", pkg] }; + default: + return { argv: ["npm", "install", "-g", pkg] }; + } + } + + case "uv": { + if (!spec.package) { + return { argv: null, error: "Missing uv package" }; + } + return { argv: ["uv", "tool", "install", spec.package] }; + } + + case "go": { + if (!spec.module) { + return { argv: null, error: "Missing go module" }; + } + return { argv: ["go", "install", spec.module] }; + } + + case "download": { + // Download is handled separately + return { argv: null, error: "download_handled_separately" }; + } + + default: + return { argv: null, error: `Unsupported install kind: ${spec.kind}` }; + } +} + +/** + * Select the preferred install spec from a list + * + * Priority: + * 1. If preferBrew and brew spec exists → brew + * 2. uv (fast, isolated) + * 3. node + * 4. brew (if not preferred but available) + * 5. go + * 6. download (last resort) + */ +export function selectPreferredInstallSpec( + specs: SkillInstallSpec[], + prefs: SkillsInstallConfig = {}, +): SkillInstallSpec | undefined { + if (specs.length === 0) return undefined; + if (specs.length === 1) return specs[0]; + + const platform = process.platform; + + // Filter by platform + const eligible = specs.filter((s) => { + if (!s.os || s.os.length === 0) return true; + return s.os.includes(platform); + }); + + if (eligible.length === 0) return undefined; + if (eligible.length === 1) return eligible[0]; + + // Priority ordering + const byKind = (kind: SkillInstallSpec["kind"]) => + eligible.find((s) => s.kind === kind); + + if (prefs.preferBrew) { + const brew = byKind("brew"); + if (brew) return brew; + } + + return ( + byKind("uv") ?? + byKind("node") ?? + byKind("brew") ?? + byKind("go") ?? + byKind("download") ?? + eligible[0] + ); +} + +/** + * Find install spec by ID + */ +function findInstallSpec( + specs: SkillInstallSpec[], + installId: string, +): SkillInstallSpec | undefined { + for (let i = 0; i < specs.length; i++) { + const spec = specs[i]!; + const id = spec.id ?? `${spec.kind}-${i}`; + if (id === installId) return spec; + } + return undefined; +} + +// ============================================================================ +// Command Execution +// ============================================================================ + +/** + * Run a command with timeout + */ +async function runCommand( + argv: string[], + options: { timeoutMs: number; env?: NodeJS.ProcessEnv | undefined }, +): Promise<{ stdout: string; stderr: string; code: number | null }> { + const [cmd, ...args] = argv; + if (!cmd) { + return { stdout: "", stderr: "Empty command", code: null }; + } + + return new Promise((resolve) => { + const proc = spawn(cmd, args, { + env: { ...process.env, ...options.env }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let killed = false; + + const timeout = setTimeout(() => { + killed = true; + proc.kill("SIGTERM"); + }, options.timeoutMs); + + proc.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + + proc.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + proc.on("close", (code: number | null) => { + clearTimeout(timeout); + if (killed) { + resolve({ + stdout, + stderr: stderr + "\n[Timed out]", + code: null, + }); + } else { + resolve({ stdout, stderr, code }); + } + }); + + proc.on("error", (err: Error) => { + clearTimeout(timeout); + resolve({ + stdout, + stderr: stderr + "\n" + err.message, + code: null, + }); + }); + }); +} + +// ============================================================================ +// Download Support +// ============================================================================ + +/** + * Resolve the target directory for downloads + */ +function resolveDownloadTargetDir(skill: Skill, spec: SkillInstallSpec): string { + if (spec.targetDir?.trim()) { + // Expand ~ to home directory + const dir = spec.targetDir.replace(/^~/, process.env.HOME ?? ""); + return dir; + } + const key = getSkillKey(skill); + return join(TOOLS_DIR, key); +} + +/** + * Detect archive type from filename + */ +function detectArchiveType( + spec: SkillInstallSpec, + filename: string, +): string | undefined { + if (spec.archive) return spec.archive; + + const lower = filename.toLowerCase(); + if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) return "tar.gz"; + if (lower.endsWith(".tar.bz2") || lower.endsWith(".tbz2")) return "tar.bz2"; + if (lower.endsWith(".zip")) return "zip"; + return undefined; +} + +/** + * Download a file + */ +async function downloadFile( + url: string, + destPath: string, + timeoutMs: number, +): Promise<{ bytes: number }> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok || !response.body) { + throw new Error(`Download failed: ${response.status} ${response.statusText}`); + } + + await mkdir(dirname(destPath), { recursive: true }); + + const file = createWriteStream(destPath); + const readable = Readable.fromWeb(response.body as Parameters[0]); + await pipeline(readable, file); + + const stats = await stat(destPath); + return { bytes: stats.size }; + } finally { + clearTimeout(timeout); + } +} + +/** + * Extract an archive + */ +async function extractArchive(params: { + archivePath: string; + archiveType: string; + targetDir: string; + stripComponents?: number | undefined; + timeoutMs: number; +}): Promise<{ stdout: string; stderr: string; code: number | null }> { + const { archivePath, archiveType, targetDir, stripComponents, timeoutMs } = params; + + await mkdir(targetDir, { recursive: true }); + + if (archiveType === "zip") { + if (!binaryExists("unzip")) { + return { stdout: "", stderr: "unzip not found in PATH", code: null }; + } + return runCommand(["unzip", "-q", "-o", archivePath, "-d", targetDir], { + timeoutMs, + }); + } + + // tar.gz or tar.bz2 + if (!binaryExists("tar")) { + return { stdout: "", stderr: "tar not found in PATH", code: null }; + } + + const argv = ["tar", "xf", archivePath, "-C", targetDir]; + if (typeof stripComponents === "number" && stripComponents > 0) { + argv.push("--strip-components", String(Math.floor(stripComponents))); + } + + return runCommand(argv, { timeoutMs }); +} + +/** + * Install via download + */ +async function installDownload( + skill: Skill, + spec: SkillInstallSpec, + timeoutMs: number, +): Promise { + const url = spec.url?.trim(); + if (!url) { + return { + ok: false, + message: "Missing download URL", + stdout: "", + stderr: "", + code: null, + }; + } + + // Extract filename from URL + let filename: string; + try { + const parsed = new URL(url); + filename = basename(parsed.pathname) || "download"; + } catch { + filename = basename(url) || "download"; + } + + const targetDir = resolveDownloadTargetDir(skill, spec); + const archivePath = join(targetDir, filename); + + // Download + let bytes: number; + try { + const result = await downloadFile(url, archivePath, timeoutMs); + bytes = result.bytes; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + ok: false, + message: `Download failed: ${message}`, + stdout: "", + stderr: message, + code: null, + }; + } + + // Check if we should extract + const archiveType = detectArchiveType(spec, filename); + const shouldExtract = spec.extract ?? Boolean(archiveType); + + if (!shouldExtract) { + return { + ok: true, + message: `Downloaded to ${archivePath} (${bytes} bytes)`, + stdout: `downloaded=${bytes}`, + stderr: "", + code: 0, + }; + } + + if (!archiveType) { + return { + ok: false, + message: "Extract requested but archive type could not be detected", + stdout: "", + stderr: "", + code: null, + }; + } + + // Extract + const extractResult = await extractArchive({ + archivePath, + archiveType, + targetDir, + stripComponents: spec.stripComponents, + timeoutMs, + }); + + // Clean up archive after extraction + try { + await unlink(archivePath); + } catch { + // Ignore cleanup errors + } + + const success = extractResult.code === 0; + return { + ok: success, + message: success + ? `Downloaded and extracted to ${targetDir}` + : `Extraction failed: ${extractResult.stderr.trim() || "unknown error"}`, + stdout: extractResult.stdout.trim(), + stderr: extractResult.stderr.trim(), + code: extractResult.code, + }; +} + +// ============================================================================ +// Main Install Function +// ============================================================================ + +/** + * Check if required tool is available for install kind + */ +function checkInstallPrerequisites( + spec: SkillInstallSpec, +): { ok: true } | { ok: false; message: string } { + switch (spec.kind) { + case "brew": + if (!binaryExists("brew")) { + return { ok: false, message: "brew not installed. Install Homebrew first." }; + } + break; + case "uv": + if (!binaryExists("uv")) { + return { ok: false, message: "uv not installed. Run: brew install uv" }; + } + break; + case "go": + if (!binaryExists("go")) { + return { ok: false, message: "go not installed. Run: brew install go" }; + } + break; + case "node": { + const manager = spec.package ? "npm" : "npm"; + if (!binaryExists(manager)) { + return { ok: false, message: `${manager} not found in PATH` }; + } + break; + } + } + return { ok: true }; +} + +/** + * Install skill dependencies + * + * Operations are serialized to prevent concurrent installations + * of the same skill from interfering with each other. + * + * @param request - Install request + * @returns Install result + */ +export async function installSkill( + request: SkillInstallRequest, +): Promise { + // Serialize operations for the same skill + return serialize(SerializeKeys.skillInstall(request.skill.id), () => + installSkillInternal(request), + ); +} + +/** + * Internal implementation of installSkill (serialized) + */ +async function installSkillInternal( + request: SkillInstallRequest, +): Promise { + const { skill, installId, prefs } = request; + const timeoutMs = Math.min( + Math.max(request.timeoutMs ?? DEFAULT_TIMEOUT_MS, 1000), + MAX_TIMEOUT_MS, + ); + + // Get install specs from skill metadata + const specs = skill.frontmatter.metadata?.install ?? []; + if (specs.length === 0) { + return { + ok: false, + message: `Skill '${skill.id}' has no install specifications`, + stdout: "", + stderr: "", + code: null, + }; + } + + // Find the spec to use + let spec: SkillInstallSpec | undefined; + if (installId) { + spec = findInstallSpec(specs, installId); + if (!spec) { + return { + ok: false, + message: `Install spec '${installId}' not found for skill '${skill.id}'`, + stdout: "", + stderr: "", + code: null, + }; + } + } else { + spec = selectPreferredInstallSpec(specs, prefs); + if (!spec) { + return { + ok: false, + message: `No compatible install spec found for skill '${skill.id}' on ${process.platform}`, + stdout: "", + stderr: "", + code: null, + }; + } + } + + // Handle download separately + if (spec.kind === "download") { + return installDownload(skill, spec, timeoutMs); + } + + // Check prerequisites + const prereq = checkInstallPrerequisites(spec); + if (!prereq.ok) { + return { + ok: false, + message: (prereq as { ok: false; message: string }).message, + stdout: "", + stderr: "", + code: null, + }; + } + + // Build command + const command = buildInstallCommand(spec, prefs); + if (!command.argv) { + return { + ok: false, + message: command.error ?? "Failed to build install command", + stdout: "", + stderr: "", + code: null, + }; + } + + // Run command + const result = await runCommand(command.argv, { timeoutMs }); + const success = result.code === 0; + + return { + ok: success, + message: success + ? `Successfully installed via ${spec.kind}` + : `Install failed (exit ${result.code}): ${summarizeOutput(result.stderr) || summarizeOutput(result.stdout) || "unknown error"}`, + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + code: result.code, + }; +} + +/** + * Summarize output for error messages + */ +function summarizeOutput(text: string): string { + const lines = text.trim().split("\n").filter(Boolean); + if (lines.length === 0) return ""; + + // Look for error lines + const errorLine = + lines.find((l) => /^error\b/i.test(l)) ?? + lines.find((l) => /\b(err!|error:|failed)\b/i.test(l)) ?? + lines[lines.length - 1]; + + if (!errorLine) return ""; + + const normalized = errorLine.replace(/\s+/g, " ").trim(); + const maxLen = 150; + return normalized.length > maxLen + ? `${normalized.slice(0, maxLen - 1)}…` + : normalized; +} + +/** + * Get available install options for a skill + */ +export function getInstallOptions(skill: Skill): Array<{ + id: string; + kind: SkillInstallSpec["kind"]; + label: string; + available: boolean; + reason?: string; +}> { + const specs = skill.frontmatter.metadata?.install ?? []; + const platform = process.platform; + + return specs.map((spec, index) => { + const id = spec.id ?? `${spec.kind}-${index}`; + const label = spec.label ?? `Install via ${spec.kind}`; + + // Check platform compatibility + if (spec.os && spec.os.length > 0 && !spec.os.includes(platform)) { + return { + id, + kind: spec.kind, + label, + available: false, + reason: `Not available on ${platform}`, + }; + } + + // Check prerequisites + const prereq = checkInstallPrerequisites(spec); + if (!prereq.ok) { + return { + id, + kind: spec.kind, + label, + available: false, + reason: (prereq as { ok: false; message: string }).message, + }; + } + + return { + id, + kind: spec.kind, + label, + available: true, + }; + }); +} diff --git a/src/agent/skills/invoke.ts b/src/agent/skills/invoke.ts new file mode 100644 index 00000000..e78dea51 --- /dev/null +++ b/src/agent/skills/invoke.ts @@ -0,0 +1,318 @@ +/** + * Skills Invocation Module + * + * Handles user-invocable skill commands (/skill-name) + */ + +import type { + Skill, + SkillCommandSpec, + SkillCommandDispatch, + SkillInvocationPolicy, + SkillInvocationResult, +} from "./types.js"; + +// ============================================================================ +// Constants +// ============================================================================ + +/** Maximum length for command names */ +const COMMAND_MAX_LENGTH = 32; + +/** Fallback command name if normalization produces empty string */ +const COMMAND_FALLBACK = "skill"; + +// ============================================================================ +// Policy Resolution +// ============================================================================ + +/** + * Resolve invocation policy from skill frontmatter + * + * @param skill - Skill to check + * @returns Invocation policy with defaults applied + */ +export function resolveInvocationPolicy(skill: Skill): SkillInvocationPolicy { + return { + userInvocable: skill.frontmatter.userInvocable ?? true, + disableModelInvocation: skill.frontmatter.disableModelInvocation ?? false, + }; +} + +/** + * Check if a skill is user-invocable + */ +export function isUserInvocable(skill: Skill): boolean { + return resolveInvocationPolicy(skill).userInvocable; +} + +/** + * Check if a skill should be included in AI's system prompt + */ +export function isModelInvocable(skill: Skill): boolean { + return !resolveInvocationPolicy(skill).disableModelInvocation; +} + +// ============================================================================ +// Command Name Normalization +// ============================================================================ + +/** + * Sanitize a skill name into a valid command name + * - Lowercase + * - Replace non-alphanumeric chars with underscores + * - Collapse multiple underscores + * - Trim leading/trailing underscores + * - Truncate to max length + */ +export function sanitizeCommandName(raw: string): string { + const normalized = raw + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); + + const trimmed = normalized.slice(0, COMMAND_MAX_LENGTH); + return trimmed || COMMAND_FALLBACK; +} + +/** + * Resolve a unique command name, adding suffix if needed + */ +function resolveUniqueCommandName(base: string, used: Set): string { + const normalizedBase = base.toLowerCase(); + if (!used.has(normalizedBase)) return base; + + for (let i = 2; i < 1000; i++) { + const suffix = `_${i}`; + const maxBaseLength = Math.max(1, COMMAND_MAX_LENGTH - suffix.length); + const candidate = `${base.slice(0, maxBaseLength)}${suffix}`; + if (!used.has(candidate.toLowerCase())) return candidate; + } + + return `${base.slice(0, Math.max(1, COMMAND_MAX_LENGTH - 2))}_x`; +} + +// ============================================================================ +// Command Building +// ============================================================================ + +/** + * Resolve command dispatch from skill frontmatter + */ +function resolveCommandDispatch(skill: Skill): SkillCommandDispatch | undefined { + const kind = skill.frontmatter.commandDispatch; + if (kind !== "tool") return undefined; + + const toolName = skill.frontmatter.commandTool; + if (!toolName) return undefined; + + const argMode = skill.frontmatter.commandArgMode; + + return { + kind: "tool", + toolName, + argMode: argMode === "raw" ? "raw" : undefined, + }; +} + +/** + * Build skill command specifications from eligible skills + * + * @param skills - Map of skill ID to Skill + * @param options - Build options + * @returns Array of command specifications + */ +export function buildSkillCommands( + skills: Map, + options?: { + /** Reserved command names to avoid */ + reservedNames?: Set; + /** Only include skills matching these IDs */ + skillFilter?: string[]; + }, +): SkillCommandSpec[] { + const used = new Set(); + + // Add reserved names + for (const reserved of options?.reservedNames ?? []) { + used.add(reserved.toLowerCase()); + } + + const specs: SkillCommandSpec[] = []; + + for (const [id, skill] of skills) { + // Skip if not user-invocable + if (!isUserInvocable(skill)) continue; + + // Apply skill filter if provided + if (options?.skillFilter && !options.skillFilter.includes(id)) continue; + + // Sanitize command name + const base = sanitizeCommandName(skill.frontmatter.name); + const unique = resolveUniqueCommandName(base, used); + used.add(unique.toLowerCase()); + + // Build description (truncate if too long) + const rawDescription = skill.frontmatter.description?.trim() || skill.frontmatter.name; + const description = + rawDescription.length > 100 + ? rawDescription.slice(0, 99) + "…" + : rawDescription; + + specs.push({ + name: unique, + skillId: id, + description, + dispatch: resolveCommandDispatch(skill), + }); + } + + return specs; +} + +// ============================================================================ +// Command Matching +// ============================================================================ + +/** + * Normalize a command lookup string for matching + */ +function normalizeForLookup(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[\s_]+/g, "-"); +} + +/** + * Find a skill command by name + * + * Matches against: + * - Exact command name + * - Original skill ID + * - Normalized versions of both + */ +export function findSkillCommand( + commands: SkillCommandSpec[], + rawName: string, +): SkillCommandSpec | undefined { + const trimmed = rawName.trim(); + if (!trimmed) return undefined; + + const lowered = trimmed.toLowerCase(); + const normalized = normalizeForLookup(trimmed); + + return commands.find((cmd) => { + if (cmd.name.toLowerCase() === lowered) return true; + if (cmd.skillId.toLowerCase() === lowered) return true; + return ( + normalizeForLookup(cmd.name) === normalized || + normalizeForLookup(cmd.skillId) === normalized + ); + }); +} + +/** + * Parse a user command input and resolve to a skill invocation + * + * Supports formats: + * - /command-name args... + * - /skill command-name args... + * + * @param input - Raw user input + * @param commands - Available skill commands + * @param skills - Full skill map (for instructions) + * @returns Invocation result or null if not a skill command + */ +export function resolveSkillInvocation( + input: string, + commands: SkillCommandSpec[], + skills: Map, +): SkillInvocationResult | null { + const trimmed = input.trim(); + + // Must start with / + if (!trimmed.startsWith("/")) return null; + + // Parse command and args + const match = trimmed.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/); + if (!match) return null; + + const commandName = match[1]?.trim().toLowerCase(); + if (!commandName) return null; + + let command: SkillCommandSpec | undefined; + let args: string | undefined; + + // Check for /skill format + if (commandName === "skill") { + const remainder = match[2]?.trim(); + if (!remainder) return null; + + const skillMatch = remainder.match(/^([^\s]+)(?:\s+([\s\S]+))?$/); + if (!skillMatch) return null; + + command = findSkillCommand(commands, skillMatch[1] ?? ""); + args = skillMatch[2]?.trim(); + } else { + // Direct /command format + command = commands.find((c) => c.name.toLowerCase() === commandName); + args = match[2]?.trim(); + } + + if (!command) return null; + + // Get skill instructions + const skill = skills.get(command.skillId); + if (!skill) return null; + + return { + command, + args: args || undefined, + instructions: skill.instructions, + }; +} + +// ============================================================================ +// Completion Support +// ============================================================================ + +/** + * Get command completions for a prefix + * + * @param prefix - Input prefix (with or without leading /) + * @param commands - Available skill commands + * @returns Matching command names with leading / + */ +export function getCommandCompletions( + prefix: string, + commands: SkillCommandSpec[], +): string[] { + // Normalize prefix + const normalized = prefix.startsWith("/") ? prefix.slice(1) : prefix; + const lowered = normalized.toLowerCase(); + + if (!lowered) { + // Return all commands if empty prefix + return commands.map((c) => `/${c.name}`); + } + + // Find matching commands + const matches: string[] = []; + + for (const cmd of commands) { + const name = cmd.name.toLowerCase(); + if (name.startsWith(lowered)) { + matches.push(`/${cmd.name}`); + } + } + + // Sort by name length (shorter first) then alphabetically + matches.sort((a, b) => { + if (a.length !== b.length) return a.length - b.length; + return a.localeCompare(b); + }); + + return matches; +} diff --git a/src/agent/skills/loader.ts b/src/agent/skills/loader.ts index 875ad4f7..7201c376 100644 --- a/src/agent/skills/loader.ts +++ b/src/agent/skills/loader.ts @@ -2,6 +2,7 @@ * Skills Loader * * Multi-source loading with precedence handling + * Supports bundled skills, user-installed skills, profile skills, and plugin skills */ import { existsSync, readdirSync, statSync } from "node:fs"; @@ -11,6 +12,7 @@ import type { Skill, SkillSource, SkillManagerOptions } from "./types.js"; import { SKILL_FILE, SKILL_SOURCE_PRECEDENCE } from "./types.js"; import { parseSkillFile } from "./parser.js"; import { DATA_DIR } from "../../shared/index.js"; +import { resolvePluginSkillDirs } from "./plugin.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -20,35 +22,58 @@ const DEFAULT_PROFILE_BASE_DIR = join(DATA_DIR, "agent-profiles"); /** Bundled skills directory (relative to package) */ const BUNDLED_DIR = join(__dirname, "../../../skills"); +/** Managed skills directory (user-installed via `skills add`) */ +const MANAGED_DIR = join(DATA_DIR, "skills"); + /** * Discover skill directories in a given base path * A valid skill directory contains a SKILL.md file + * Searches up to maxDepth levels deep * * @param baseDir - Base directory to search + * @param maxDepth - Maximum depth to search (default: 3) * @returns Array of absolute paths to skill directories */ -function discoverSkillDirs(baseDir: string): string[] { +function discoverSkillDirs(baseDir: string, maxDepth: number = 3): string[] { if (!existsSync(baseDir)) { return []; } - try { - const entries = readdirSync(baseDir); - return entries - .map((name) => join(baseDir, name)) - .filter((path) => { + const results: string[] = []; + + function scan(dir: string, depth: number): void { + if (depth > maxDepth) return; + + try { + const entries = readdirSync(dir); + + for (const name of entries) { + // Skip hidden directories + if (name.startsWith(".")) continue; + + const fullPath = join(dir, name); + try { - if (!statSync(path).isDirectory()) { - return false; + if (!statSync(fullPath).isDirectory()) continue; + + // Check if this directory has SKILL.md + if (existsSync(join(fullPath, SKILL_FILE))) { + results.push(fullPath); + } else { + // Recurse into subdirectory + scan(fullPath, depth + 1); } - return existsSync(join(path, SKILL_FILE)); } catch { - return false; + // Skip inaccessible directories } - }); - } catch { - return []; + } + } catch { + // Skip inaccessible directories + } } + + scan(baseDir, 0); + return results; } /** @@ -95,7 +120,9 @@ export function getProfileSkillsDir(profileId: string, profileBaseDir?: string): * Loading order (lowest to highest precedence): * 1. bundled - Package bundled skills * 2. extra - User-configured extra directories - * 3. profile - ~/.super-multica/agent-profiles//skills/ + * 3. plugins - Skills from npm packages with multica.plugin.json + * 4. managed - ~/.super-multica/skills/ (user-installed via `skills add`) + * 5. profile - ~/.super-multica/agent-profiles//skills/ * * @param options - Loader options * @returns Map of skill ID to Skill @@ -103,12 +130,22 @@ export function getProfileSkillsDir(profileId: string, profileBaseDir?: string): export function loadAllSkills(options: SkillManagerOptions = {}): Map { const skillMap = new Map(); + // Discover plugin skill directories + const pluginSkillDirs = resolvePluginSkillDirs({ + workspaceDir: options.workspaceDir ?? process.cwd(), + extraPaths: options.pluginPaths ?? [], + }); + // Define sources in order of precedence (lowest first) const sources: Array<[string, SkillSource]> = [ // Bundled skills (lowest precedence) [BUNDLED_DIR, "bundled"], // Extra directories (treated as bundled) ...(options.extraDirs ?? []).map((d): [string, SkillSource] => [d, "bundled"]), + // Plugin skills (between extra and managed) + ...pluginSkillDirs.map((d): [string, SkillSource] => [d, "bundled"]), + // Managed skills (user-installed via `skills add`) + [MANAGED_DIR, "profile"], ]; // Add profile skills if profileId is provided (highest precedence) diff --git a/src/agent/skills/parser.ts b/src/agent/skills/parser.ts index 3205882e..78d4b57d 100644 --- a/src/agent/skills/parser.ts +++ b/src/agent/skills/parser.ts @@ -89,9 +89,61 @@ function validateFrontmatter(raw: Record): SkillFrontmatter | n }; } + // Parse invocation control fields + // Support both kebab-case and camelCase for compatibility + const userInvocableRaw = + raw["user-invocable"] ?? raw["userInvocable"] ?? raw["user_invocable"]; + if (typeof userInvocableRaw === "boolean") { + frontmatter.userInvocable = userInvocableRaw; + } else if (typeof userInvocableRaw === "string") { + frontmatter.userInvocable = parseBooleanString(userInvocableRaw); + } + + const disableModelRaw = + raw["disable-model-invocation"] ?? + raw["disableModelInvocation"] ?? + raw["disable_model_invocation"]; + if (typeof disableModelRaw === "boolean") { + frontmatter.disableModelInvocation = disableModelRaw; + } else if (typeof disableModelRaw === "string") { + frontmatter.disableModelInvocation = parseBooleanString(disableModelRaw); + } + + // Parse command dispatch fields + const dispatchRaw = + raw["command-dispatch"] ?? raw["commandDispatch"] ?? raw["command_dispatch"]; + if (typeof dispatchRaw === "string") { + frontmatter.commandDispatch = dispatchRaw.trim().toLowerCase(); + } + + const toolRaw = raw["command-tool"] ?? raw["commandTool"] ?? raw["command_tool"]; + if (typeof toolRaw === "string") { + frontmatter.commandTool = toolRaw.trim(); + } + + const argModeRaw = + raw["command-arg-mode"] ?? raw["commandArgMode"] ?? raw["command_arg_mode"]; + if (typeof argModeRaw === "string") { + frontmatter.commandArgMode = argModeRaw.trim().toLowerCase(); + } + return frontmatter; } +/** + * Parse boolean from string value + */ +function parseBooleanString(value: string): boolean | undefined { + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "yes" || normalized === "1") { + return true; + } + if (normalized === "false" || normalized === "no" || normalized === "0") { + return false; + } + return undefined; +} + /** * Parse a SKILL.md file into a Skill object * diff --git a/src/agent/skills/plugin.ts b/src/agent/skills/plugin.ts new file mode 100644 index 00000000..6dbcec22 --- /dev/null +++ b/src/agent/skills/plugin.ts @@ -0,0 +1,412 @@ +/** + * Plugin System + * + * Discovers and loads skills from npm packages that contain a multica.plugin.json manifest. + * This enables users to install skill packages via npm and have them automatically discovered. + * + * Design inspired by OpenClaw's plugin system. + */ + +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, dirname, resolve } from "node:path"; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Plugin manifest file name + */ +export const PLUGIN_MANIFEST_FILENAME = "multica.plugin.json"; + +/** + * Plugin manifest schema + * Stored in multica.plugin.json at the package root + */ +export interface PluginManifest { + /** Unique plugin identifier (required) */ + id: string; + /** Human-readable plugin name */ + name?: string | undefined; + /** Plugin description */ + description?: string | undefined; + /** Plugin version */ + version?: string | undefined; + /** Relative paths to skill directories within the package */ + skills?: string[] | undefined; +} + +/** + * Loaded plugin record with resolved paths + */ +export interface PluginRecord { + /** Plugin ID from manifest */ + id: string; + /** Plugin name */ + name?: string | undefined; + /** Plugin description */ + description?: string | undefined; + /** Plugin version */ + version?: string | undefined; + /** Absolute path to package root */ + rootDir: string; + /** Absolute path to manifest file */ + manifestPath: string; + /** Resolved absolute paths to skill directories */ + skillDirs: string[]; + /** Source of discovery */ + source: "node_modules" | "custom"; +} + +/** + * Plugin discovery diagnostic + */ +export interface PluginDiagnostic { + level: "error" | "warn" | "info"; + pluginId?: string | undefined; + source: string; + message: string; +} + +/** + * Plugin registry result + */ +export interface PluginRegistry { + plugins: PluginRecord[]; + diagnostics: PluginDiagnostic[]; +} + +// ============================================================================ +// Manifest Loading +// ============================================================================ + +/** + * Check if a value is a plain object + */ +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +/** + * Normalize a string array from unknown input + */ +function normalizeStringList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter(Boolean); +} + +/** + * Load and parse a plugin manifest from a directory + * + * @param rootDir - Package root directory + * @returns Parsed manifest or error + */ +export function loadPluginManifest( + rootDir: string, +): { ok: true; manifest: PluginManifest; manifestPath: string } | { ok: false; error: string; manifestPath: string } { + const manifestPath = join(rootDir, PLUGIN_MANIFEST_FILENAME); + + if (!existsSync(manifestPath)) { + return { ok: false, error: `manifest not found: ${manifestPath}`, manifestPath }; + } + + let raw: unknown; + try { + raw = JSON.parse(readFileSync(manifestPath, "utf-8")) as unknown; + } catch (err) { + return { + ok: false, + error: `failed to parse manifest: ${String(err)}`, + manifestPath, + }; + } + + if (!isRecord(raw)) { + return { ok: false, error: "manifest must be an object", manifestPath }; + } + + const id = typeof raw.id === "string" ? raw.id.trim() : ""; + if (!id) { + return { ok: false, error: "manifest requires id field", manifestPath }; + } + + const manifest: PluginManifest = { + id, + name: typeof raw.name === "string" ? raw.name.trim() : undefined, + description: typeof raw.description === "string" ? raw.description.trim() : undefined, + version: typeof raw.version === "string" ? raw.version.trim() : undefined, + skills: normalizeStringList(raw.skills), + }; + + return { ok: true, manifest, manifestPath }; +} + +// ============================================================================ +// Plugin Discovery +// ============================================================================ + +/** + * Find all node_modules directories to search + * Walks up from workspaceDir to find all node_modules in the tree + */ +function findNodeModulesDirs(workspaceDir: string): string[] { + const dirs: string[] = []; + let current = resolve(workspaceDir); + const root = dirname(current); + + while (current !== root) { + const nodeModules = join(current, "node_modules"); + if (existsSync(nodeModules) && statSync(nodeModules).isDirectory()) { + dirs.push(nodeModules); + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + + return dirs; +} + +/** + * Discover plugin packages in a node_modules directory + * + * @param nodeModulesDir - Path to node_modules + * @returns Array of package directories containing plugin manifests + */ +function discoverPluginsInNodeModules(nodeModulesDir: string): string[] { + const candidates: string[] = []; + + try { + const entries = readdirSync(nodeModulesDir); + + for (const entry of entries) { + // Skip hidden and special directories + if (entry.startsWith(".") || entry === "node_modules") continue; + + const entryPath = join(nodeModulesDir, entry); + + try { + const stat = statSync(entryPath); + if (!stat.isDirectory()) continue; + + // Handle scoped packages (@org/package) + if (entry.startsWith("@")) { + const scopedEntries = readdirSync(entryPath); + for (const scopedEntry of scopedEntries) { + if (scopedEntry.startsWith(".")) continue; + const scopedPath = join(entryPath, scopedEntry); + if (existsSync(join(scopedPath, PLUGIN_MANIFEST_FILENAME))) { + candidates.push(scopedPath); + } + } + } else { + // Regular package + if (existsSync(join(entryPath, PLUGIN_MANIFEST_FILENAME))) { + candidates.push(entryPath); + } + } + } catch { + // Skip inaccessible directories + } + } + } catch { + // Skip inaccessible node_modules + } + + return candidates; +} + +/** + * Build a plugin record from a manifest and candidate + */ +function buildPluginRecord(params: { + manifest: PluginManifest; + manifestPath: string; + rootDir: string; + source: "node_modules" | "custom"; +}): PluginRecord { + const { manifest, manifestPath, rootDir, source } = params; + + // Resolve skill directories + const skillDirs: string[] = []; + for (const skillPath of manifest.skills ?? []) { + const resolved = resolve(rootDir, skillPath); + if (existsSync(resolved)) { + skillDirs.push(resolved); + } + } + + return { + id: manifest.id, + name: manifest.name, + description: manifest.description, + version: manifest.version, + rootDir, + manifestPath, + skillDirs, + source, + }; +} + +// ============================================================================ +// Plugin Registry +// ============================================================================ + +/** + * Discover and load all plugins + * + * @param options - Discovery options + * @returns Plugin registry with all discovered plugins + */ +export function loadPluginRegistry(options: { + /** Workspace directory to start search from */ + workspaceDir?: string; + /** Additional directories to search for plugins */ + extraPaths?: string[]; + /** Skip node_modules scanning */ + skipNodeModules?: boolean; +}): PluginRegistry { + const { workspaceDir, extraPaths = [], skipNodeModules = false } = options; + const plugins: PluginRecord[] = []; + const diagnostics: PluginDiagnostic[] = []; + const seenIds = new Set(); + + // Discover plugins in node_modules + if (!skipNodeModules && workspaceDir) { + const nodeModulesDirs = findNodeModulesDirs(workspaceDir); + + for (const nodeModulesDir of nodeModulesDirs) { + const candidates = discoverPluginsInNodeModules(nodeModulesDir); + + for (const candidate of candidates) { + const result = loadPluginManifest(candidate); + + if (!result.ok) { + diagnostics.push({ + level: "error", + source: result.manifestPath, + message: result.error, + }); + continue; + } + + const { manifest, manifestPath } = result; + + if (seenIds.has(manifest.id)) { + diagnostics.push({ + level: "warn", + pluginId: manifest.id, + source: manifestPath, + message: `duplicate plugin id; earlier instance takes precedence`, + }); + continue; + } + + seenIds.add(manifest.id); + plugins.push( + buildPluginRecord({ + manifest, + manifestPath, + rootDir: candidate, + source: "node_modules", + }), + ); + } + } + } + + // Load plugins from extra paths + for (const extraPath of extraPaths) { + if (!existsSync(extraPath)) { + diagnostics.push({ + level: "warn", + source: extraPath, + message: "extra plugin path does not exist", + }); + continue; + } + + const result = loadPluginManifest(extraPath); + + if (!result.ok) { + diagnostics.push({ + level: "error", + source: result.manifestPath, + message: result.error, + }); + continue; + } + + const { manifest, manifestPath } = result; + + if (seenIds.has(manifest.id)) { + diagnostics.push({ + level: "warn", + pluginId: manifest.id, + source: manifestPath, + message: `duplicate plugin id; earlier instance takes precedence`, + }); + continue; + } + + seenIds.add(manifest.id); + plugins.push( + buildPluginRecord({ + manifest, + manifestPath, + rootDir: extraPath, + source: "custom", + }), + ); + } + + return { plugins, diagnostics }; +} + +// ============================================================================ +// Skill Directory Resolution +// ============================================================================ + +/** + * Get all skill directories from discovered plugins + * + * This function is the main integration point with SkillManager. + * It discovers plugins and returns their skill directories. + * + * @param options - Discovery options + * @returns Array of absolute paths to skill directories + */ +export function resolvePluginSkillDirs(options: { + workspaceDir?: string; + extraPaths?: string[]; +}): string[] { + const registry = loadPluginRegistry(options); + const dirs: string[] = []; + const seen = new Set(); + + for (const plugin of registry.plugins) { + for (const skillDir of plugin.skillDirs) { + if (!seen.has(skillDir)) { + seen.add(skillDir); + dirs.push(skillDir); + } + } + } + + return dirs; +} + +/** + * Get plugin registry with diagnostics for CLI/debugging + * + * @param options - Discovery options + * @returns Full registry with plugins and diagnostics + */ +export function getPluginRegistry(options: { + workspaceDir?: string; + extraPaths?: string[]; +}): PluginRegistry { + return loadPluginRegistry(options); +} diff --git a/src/agent/skills/serialize.ts b/src/agent/skills/serialize.ts new file mode 100644 index 00000000..cf1c896b --- /dev/null +++ b/src/agent/skills/serialize.ts @@ -0,0 +1,210 @@ +/** + * Async Operation Serialization + * + * Prevents concurrent operations from corrupting files by serializing + * operations that share the same key. + * + * Inspired by OpenClaw's serialize.ts pattern. + */ + +// ============================================================================ +// Types +// ============================================================================ + +type AsyncOperation = () => Promise; + +interface QueuedOperation { + operation: AsyncOperation; + resolve: (value: unknown) => void; + reject: (error: unknown) => void; +} + +// ============================================================================ +// Serialization Queue +// ============================================================================ + +/** + * Global map of operation queues keyed by identifier + */ +const operationQueues = new Map(); + +/** + * Set of keys currently being processed + */ +const processingKeys = new Set(); + +/** + * Process the next operation in the queue for a given key + */ +async function processQueue(key: string): Promise { + // If already processing this key, return + if (processingKeys.has(key)) { + return; + } + + const queue = operationQueues.get(key); + if (!queue || queue.length === 0) { + operationQueues.delete(key); + return; + } + + processingKeys.add(key); + + while (queue.length > 0) { + const item = queue.shift(); + if (!item) break; + + try { + const result = await item.operation(); + item.resolve(result); + } catch (error) { + item.reject(error); + } + } + + processingKeys.delete(key); + operationQueues.delete(key); +} + +/** + * Serialize an async operation by key + * + * Operations with the same key will be executed sequentially, + * preventing race conditions and file corruption. + * + * @param key - Unique identifier for the operation group + * @param operation - Async operation to execute + * @returns Promise resolving to the operation result + * + * @example + * ```typescript + * // Multiple concurrent calls to the same skill will be serialized + * await serialize('skill:pdf', async () => { + * await writeFile(path, content); + * return parseSkillFile(path); + * }); + * ``` + */ +export function serialize(key: string, operation: AsyncOperation): Promise { + return new Promise((resolve, reject) => { + let queue = operationQueues.get(key); + if (!queue) { + queue = []; + operationQueues.set(key, queue); + } + + queue.push({ + operation: operation as AsyncOperation, + resolve: resolve as (value: unknown) => void, + reject, + }); + + // Start processing if not already processing + void processQueue(key); + }); +} + +/** + * Create a serialized version of an async function + * + * @param keyFn - Function to generate key from arguments + * @param fn - Async function to wrap + * @returns Serialized version of the function + * + * @example + * ```typescript + * const serializedAddSkill = createSerialized( + * (req) => `skill:${req.name ?? 'default'}`, + * addSkill + * ); + * ``` + */ +export function createSerialized( + keyFn: (...args: TArgs) => string, + fn: (...args: TArgs) => Promise, +): (...args: TArgs) => Promise { + return (...args: TArgs) => { + const key = keyFn(...args); + return serialize(key, () => fn(...args)); + }; +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Check if an operation key is currently being processed + */ +export function isProcessing(key: string): boolean { + return processingKeys.has(key); +} + +/** + * Get the number of queued operations for a key + */ +export function getQueueLength(key: string): number { + return operationQueues.get(key)?.length ?? 0; +} + +/** + * Get all currently active operation keys + */ +export function getActiveKeys(): string[] { + return Array.from(processingKeys); +} + +/** + * Wait for all operations for a key to complete + */ +export async function waitForKey(key: string): Promise { + if (!processingKeys.has(key)) { + return; + } + + // Create a dummy operation that resolves immediately + // It will be queued after all current operations + return serialize(key, async () => {}); +} + +/** + * Wait for all pending operations to complete + */ +export async function waitForAll(): Promise { + const keys = Array.from(processingKeys); + await Promise.all(keys.map((key) => waitForKey(key))); +} + +// ============================================================================ +// Serialization Keys +// ============================================================================ + +/** + * Standard serialization key generators for common operations + */ +export const SerializeKeys = { + /** + * Key for skill add operations + */ + skillAdd: (name: string) => `skill:add:${name}`, + + /** + * Key for skill remove operations + */ + skillRemove: (name: string) => `skill:remove:${name}`, + + /** + * Key for skill install operations + */ + skillInstall: (skillId: string) => `skill:install:${skillId}`, + + /** + * Key for managed skills directory operations + */ + managedSkills: () => "skills:managed", + + /** + * Key for any file path operations + */ + file: (path: string) => `file:${path}`, +} as const; diff --git a/src/agent/skills/types.ts b/src/agent/skills/types.ts index 787e9eaf..9e9aacb1 100644 --- a/src/agent/skills/types.ts +++ b/src/agent/skills/types.ts @@ -2,22 +2,92 @@ * Skills Module Types * * Type definitions for the skills system + * Compatible with OpenClaw/AgentSkills specification */ +// ============================================================================ +// Install Specification Types +// ============================================================================ + +/** + * Skill install specification + * Defines how to install dependencies for a skill + */ +export interface SkillInstallSpec { + /** Unique identifier for this install option */ + id?: string | undefined; + /** Install method type */ + kind: "brew" | "node" | "go" | "uv" | "download"; + /** Human-readable label for UI */ + label?: string | undefined; + /** Binaries that will be installed */ + bins?: string[] | undefined; + /** Platforms this install option supports */ + os?: string[] | undefined; + /** Homebrew formula name (for kind: "brew") */ + formula?: string | undefined; + /** Package name (for kind: "node" or "uv") */ + package?: string | undefined; + /** Go module path (for kind: "go") */ + module?: string | undefined; + /** Download URL (for kind: "download") */ + url?: string | undefined; + /** Archive type: "tar.gz" | "tar.bz2" | "zip" (for kind: "download") */ + archive?: string | undefined; + /** Whether to extract the archive (for kind: "download") */ + extract?: boolean | undefined; + /** Strip N leading path components when extracting (for kind: "download") */ + stripComponents?: number | undefined; + /** Target directory for download (defaults to ~/.super-multica/tools/) */ + targetDir?: string | undefined; +} + +/** + * Skill requirements specification + * Defines what must be present for a skill to be eligible + */ +export interface SkillRequirements { + /** All listed binaries must exist in PATH */ + bins?: string[] | undefined; + /** At least one of listed binaries must exist in PATH */ + anyBins?: string[] | undefined; + /** All listed environment variables must be set (or provided via config) */ + env?: string[] | undefined; + /** All listed config paths must be truthy */ + config?: string[] | undefined; +} + /** * Skill metadata for eligibility and display + * Compatible with OpenClaw spec (metadata.openclaw or metadata.multica) */ export interface SkillMetadata { + /** Always include this skill (skip eligibility checks except explicit disable) */ + always?: boolean | undefined; + /** Custom key for config lookup (defaults to skill id) */ + skillKey?: string | undefined; + /** Primary environment variable for API key injection */ + primaryEnv?: string | undefined; /** Emoji for display (e.g., "📝") */ emoji?: string | undefined; - /** Required environment variables */ - requiresEnv?: string[] | undefined; - /** Required binaries in PATH */ - requiresBinaries?: string[] | undefined; + /** Homepage URL for documentation */ + homepage?: string | undefined; /** Supported platforms (darwin, linux, win32) */ - platforms?: string[] | undefined; + os?: string[] | undefined; + /** Skill requirements */ + requires?: SkillRequirements | undefined; + /** Install specifications */ + install?: SkillInstallSpec[] | undefined; /** Skill tags for categorization */ tags?: string[] | undefined; + + // Legacy fields (for backward compatibility with existing skills) + /** @deprecated Use requires.env instead */ + requiresEnv?: string[] | undefined; + /** @deprecated Use requires.bins instead */ + requiresBinaries?: string[] | undefined; + /** @deprecated Use os instead */ + platforms?: string[] | undefined; } /** @@ -36,6 +106,20 @@ export interface SkillFrontmatter { homepage?: string | undefined; /** Skill-specific metadata */ metadata?: SkillMetadata | undefined; + + // Invocation control fields + /** Whether users can invoke via /command (default: true) */ + userInvocable?: boolean | undefined; + /** Whether to exclude from AI system prompt (default: false) */ + disableModelInvocation?: boolean | undefined; + + // Command dispatch fields + /** Command dispatch mode (e.g., "tool") */ + commandDispatch?: string | undefined; + /** Tool name for dispatch (when commandDispatch: "tool") */ + commandTool?: string | undefined; + /** Argument mode for dispatch (default: "raw") */ + commandArgMode?: string | undefined; } /** @@ -67,6 +151,65 @@ export interface Skill { filePath: string; } +// ============================================================================ +// Configuration Types +// ============================================================================ + +/** + * Per-skill configuration + * Applied via skills.entries. + */ +export interface SkillConfig { + /** Explicitly enable/disable this skill */ + enabled?: boolean | undefined; + /** API key for skills with primaryEnv set */ + apiKey?: string | undefined; + /** Environment variables to inject */ + env?: Record | undefined; + /** Custom per-skill configuration */ + config?: Record | undefined; +} + +/** + * Skills loading configuration + */ +export interface SkillsLoadConfig { + /** Additional directories to search for skills */ + extraDirs?: string[] | undefined; + /** Enable file watching for hot reload (default: true) */ + watch?: boolean | undefined; + /** Watch debounce delay in ms (default: 250) */ + watchDebounceMs?: number | undefined; +} + +/** + * Skills install preferences + */ +export interface SkillsInstallConfig { + /** Prefer brew over other installers when available */ + preferBrew?: boolean | undefined; + /** Node package manager to use */ + nodeManager?: "npm" | "pnpm" | "yarn" | "bun" | undefined; +} + +/** + * Complete skills configuration + */ +export interface SkillsConfig { + /** Allowlist for bundled skills (if set, only these bundled skills are eligible) */ + allowBundled?: string[] | undefined; + /** Loading configuration */ + load?: SkillsLoadConfig | undefined; + /** Install preferences */ + install?: SkillsInstallConfig | undefined; + /** Per-skill configuration entries */ + entries?: Record | undefined; +} + +// ============================================================================ +// Manager Types +// ============================================================================ + /** * Skill Manager options */ @@ -79,6 +222,12 @@ export interface SkillManagerOptions { extraDirs?: string[] | undefined; /** Platform override (for testing) */ platform?: NodeJS.Platform | undefined; + /** Skills configuration */ + config?: SkillsConfig | undefined; + /** Workspace directory for plugin discovery (defaults to cwd) */ + workspaceDir?: string | undefined; + /** Additional paths to search for plugins (directories with multica.plugin.json) */ + pluginPaths?: string[] | undefined; } /** @@ -91,7 +240,108 @@ export interface EligibilityResult { reasons?: string[] | undefined; } +// ============================================================================ +// Invocation Types +// ============================================================================ + +/** + * Skill invocation policy + * Controls how a skill can be invoked + */ +export interface SkillInvocationPolicy { + /** Whether users can invoke this skill via /command (default: true) */ + userInvocable: boolean; + /** Whether to exclude from AI's system prompt (default: false) */ + disableModelInvocation: boolean; +} + +/** + * Command dispatch specification + * For skills that dispatch directly to a tool + */ +export interface SkillCommandDispatch { + /** Dispatch type */ + kind: "tool"; + /** Tool name to invoke */ + toolName: string; + /** How to pass arguments (default: "raw") */ + argMode?: "raw" | undefined; +} + +/** + * Skill command specification + * Represents a user-invocable skill command + */ +export interface SkillCommandSpec { + /** Normalized command name (e.g., "pdf" for /pdf) */ + name: string; + /** Original skill name/ID */ + skillId: string; + /** Command description */ + description: string; + /** Optional dispatch behavior */ + dispatch?: SkillCommandDispatch | undefined; +} + +/** + * Skill invocation result + */ +export interface SkillInvocationResult { + /** The matched command */ + command: SkillCommandSpec; + /** Arguments passed to the command */ + args?: string | undefined; + /** The skill instructions to inject */ + instructions: string; +} + /** * Filename constant for skill definition file */ export const SKILL_FILE = "SKILL.md"; + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Get the config key for a skill + * Uses metadata.skillKey if defined, otherwise falls back to skill id + */ +export function getSkillKey(skill: Skill): string { + return skill.frontmatter.metadata?.skillKey ?? skill.id; +} + +/** + * Get the skill config for a specific skill + */ +export function getSkillConfig( + skill: Skill, + config?: SkillsConfig, +): SkillConfig | undefined { + if (!config?.entries) return undefined; + const key = getSkillKey(skill); + return config.entries[key]; +} + +/** + * Normalize requirements from both new and legacy metadata formats + */ +export function normalizeRequirements(metadata?: SkillMetadata): SkillRequirements { + if (!metadata) return {}; + + return { + bins: metadata.requires?.bins ?? metadata.requiresBinaries ?? [], + anyBins: metadata.requires?.anyBins ?? [], + env: metadata.requires?.env ?? metadata.requiresEnv ?? [], + config: metadata.requires?.config ?? [], + }; +} + +/** + * Normalize platforms from both new and legacy metadata formats + */ +export function normalizePlatforms(metadata?: SkillMetadata): string[] { + if (!metadata) return []; + return metadata.os ?? metadata.platforms ?? []; +} diff --git a/src/agent/skills/watcher.ts b/src/agent/skills/watcher.ts new file mode 100644 index 00000000..067aa4da --- /dev/null +++ b/src/agent/skills/watcher.ts @@ -0,0 +1,264 @@ +/** + * Skills Watcher Module + * + * Watches skill directories for changes and triggers reload + */ + +import { join } from "node:path"; +import { existsSync } from "node:fs"; + +import { DATA_DIR } from "../../shared/index.js"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface SkillsWatcherOptions { + /** Workspace directory to watch (for /skills) */ + workspaceDir?: string | undefined; + /** Additional directories to watch */ + extraDirs?: string[] | undefined; + /** Debounce delay in milliseconds (default: 250) */ + debounceMs?: number | undefined; + /** Whether watching is enabled (default: true) */ + enabled?: boolean | undefined; +} + +export interface SkillsChangeEvent { + /** Reason for the change */ + reason: "watch" | "manual"; + /** Path that changed (if known) */ + changedPath?: string | undefined; +} + +export type SkillsChangeListener = (event: SkillsChangeEvent) => void; + +// ============================================================================ +// State +// ============================================================================ + +/** Current skills version (timestamp-based) */ +let currentVersion = Date.now(); + +/** Registered change listeners */ +const listeners = new Set(); + +/** Active watcher instance */ +let watcherInstance: { + close: () => Promise; + paths: string[]; +} | null = null; + +/** Debounce timer */ +let debounceTimer: ReturnType | null = null; + +/** Pending change path */ +let pendingChangePath: string | undefined; + +// ============================================================================ +// Version Management +// ============================================================================ + +/** + * Get the current skills version + */ +export function getSkillsVersion(): number { + return currentVersion; +} + +/** + * Bump the skills version + * + * @param reason - Reason for the bump + * @param changedPath - Path that changed (optional) + * @returns New version number + */ +export function bumpSkillsVersion( + reason: SkillsChangeEvent["reason"] = "manual", + changedPath?: string, +): number { + const now = Date.now(); + currentVersion = now > currentVersion ? now : currentVersion + 1; + + // Notify listeners + const event: SkillsChangeEvent = { reason, changedPath }; + for (const listener of listeners) { + try { + listener(event); + } catch { + // Ignore listener errors + } + } + + return currentVersion; +} + +// ============================================================================ +// Change Listeners +// ============================================================================ + +/** + * Register a change listener + * + * @param listener - Callback function + * @returns Unsubscribe function + */ +export function onSkillsChange(listener: SkillsChangeListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +// ============================================================================ +// Watcher Management +// ============================================================================ + +/** Paths to ignore when watching */ +const IGNORED_PATTERNS = [ + /(^|[\\/])\.git([\\/]|$)/, + /(^|[\\/])node_modules([\\/]|$)/, + /(^|[\\/])dist([\\/]|$)/, + /(^|[\\/])\.DS_Store$/, +]; + +/** + * Resolve paths to watch + */ +function resolveWatchPaths(options: SkillsWatcherOptions): string[] { + const paths: string[] = []; + + // Workspace skills + if (options.workspaceDir?.trim()) { + const workspaceSkills = join(options.workspaceDir, "skills"); + if (existsSync(workspaceSkills)) { + paths.push(workspaceSkills); + } + } + + // Managed skills (~/.super-multica/skills) + const managedSkills = join(DATA_DIR, "skills"); + if (existsSync(managedSkills)) { + paths.push(managedSkills); + } + + // Extra directories + for (const dir of options.extraDirs ?? []) { + const trimmed = dir.trim(); + if (trimmed && existsSync(trimmed)) { + paths.push(trimmed); + } + } + + return paths; +} + +/** + * Start watching skill directories + * + * @param options - Watcher options + * @returns Stop function + */ +export async function startSkillsWatcher( + options: SkillsWatcherOptions = {}, +): Promise<() => Promise> { + // Stop existing watcher if any + await stopSkillsWatcher(); + + if (options.enabled === false) { + return async () => {}; + } + + const debounceMs = options.debounceMs ?? 250; + const paths = resolveWatchPaths(options); + + if (paths.length === 0) { + return async () => {}; + } + + // Dynamically import chokidar (optional dependency) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let chokidar: any; + try { + // @ts-expect-error - chokidar is optional, dynamically loaded + chokidar = await import("chokidar"); + } catch { + // chokidar not installed, skip watching + console.warn("[skills] chokidar not installed, file watching disabled"); + return async () => {}; + } + + const watcher = chokidar.watch(paths, { + ignoreInitial: true, + ignored: IGNORED_PATTERNS, + awaitWriteFinish: { + stabilityThreshold: debounceMs, + pollInterval: 100, + }, + }); + + const scheduleUpdate = (changedPath?: string | undefined) => { + pendingChangePath = changedPath ?? pendingChangePath; + + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + debounceTimer = setTimeout(() => { + const path = pendingChangePath; + pendingChangePath = undefined; + debounceTimer = null; + bumpSkillsVersion("watch", path); + }, debounceMs); + }; + + watcher.on("add", (p: string) => scheduleUpdate(p)); + watcher.on("change", (p: string) => scheduleUpdate(p)); + watcher.on("unlink", (p: string) => scheduleUpdate(p)); + watcher.on("error", (err: Error) => { + console.error("[skills] watcher error:", err); + }); + + watcherInstance = { + close: async () => { + await watcher.close(); + }, + paths, + }; + + return stopSkillsWatcher; +} + +/** + * Stop the skills watcher + */ +export async function stopSkillsWatcher(): Promise { + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + pendingChangePath = undefined; + + if (watcherInstance) { + try { + await watcherInstance.close(); + } catch { + // Ignore close errors + } + watcherInstance = null; + } +} + +/** + * Check if watcher is currently active + */ +export function isWatcherActive(): boolean { + return watcherInstance !== null; +} + +/** + * Get currently watched paths + */ +export function getWatchedPaths(): string[] { + return watcherInstance?.paths ?? []; +} diff --git a/src/agent/types.ts b/src/agent/types.ts index 405f0cc7..3f1ebb1e 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -1,4 +1,5 @@ import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; +import type { SkillsConfig } from "./skills/types.js"; export type AgentRunResult = { text: string; @@ -58,6 +59,8 @@ export type AgentOptions = { enableSkills?: boolean | undefined; /** Additional directories to search for skills */ extraSkillDirs?: string[] | undefined; + /** Full skills configuration */ + skills?: SkillsConfig | undefined; }; export interface Message {