Merge pull request #36 from multica-ai/feat/skills-system

feat(skills): complete skills system implementation
This commit is contained in:
Bohan Jiang 2026-01-30 17:07:12 +08:00 committed by GitHub
commit 20b45c0bcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 5655 additions and 92 deletions

View file

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

269
src/agent/autocomplete.ts Normal file
View file

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

View file

@ -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<string>;
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;
}
}

View file

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

539
src/agent/skills-cli.ts Normal file
View file

@ -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 <id> Install skill dependencies
* pnpm skills:cli add <source> Add skill from GitHub
* pnpm skills:cli remove <name> 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 <command> [options]
Commands:
list List all available skills
status [id] Show detailed status of a skill (or all skills)
install <id> Install dependencies for a skill
add <source> Add skill from GitHub (owner/repo or owner/repo/skill)
remove <name> 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<string, string[]> = 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<string, string> = {
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 <skill-id>' 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<string, string> = {
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<string, boolean>,
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<string, boolean> {
const result = new Map<string, boolean>();
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<string, boolean> {
const result = new Map<string, boolean>();
for (const env of envs) {
result.set(env, env in process.env);
}
return result;
}
async function cmdInstall(manager: SkillManager, skillId: string, installId?: string): Promise<void> {
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} <install-id>`);
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<void> {
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<void> {
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<void> {
const skills = await listInstalledSkills();
if (skills.length === 0) {
console.log("\nNo skills installed in ~/.super-multica/skills/");
console.log("Use 'pnpm skills:cli add <source>' 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<void> {
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 <source> [--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 <skill-name>");
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 <skill-id> [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);
});

422
src/agent/skills/README.md Normal file
View file

@ -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 | `<project>/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/<id>/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 <id> # 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 <name> # Remove installed skill
pnpm skills:cli remove # List installed skills
```
### Install Dependencies
```bash
pnpm skills:cli install <id> # Install skill dependencies
pnpm skills:cli install <id> <install-id> # 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 <skill-id>
```
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.<id>.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 <skill-id>` 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.

View file

@ -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`)调用 skillsAI 模型也可以自动调用。
### 用户调用
在交互式 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 | `<project>/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/<id>/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 <id> # 特定 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 <name> # 移除已安装的 skill
pnpm skills:cli remove # 列出已安装的 skills
```
### 安装依赖
```bash
pnpm skills:cli install <id> # 安装 skill 依赖
pnpm skills:cli install <id> <install-id> # 特定安装选项
```
---
## 状态诊断
`status` 命令提供详细的诊断信息,帮助了解 skills 为何符合或不符合条件。
### 汇总状态
```bash
pnpm skills:cli status # 显示按问题类型分组的汇总
pnpm skills:cli status -v # 详细模式带提示
```
输出显示:
- 总计/符合条件/不符合条件计数
- 按问题类型分组的不符合条件 skillsbinary、env、platform 等)
### 详细 Skill 状态
```bash
pnpm skills:cli status <skill-id>
```
输出包括:
- 基本 skill 信息(名称、版本、来源、路径)
- **资格状态**及详细诊断
- **要求检查表**显示哪些二进制文件/环境变量存在
- **安装选项**及可用性状态
- **快速操作**及可操作的提示
### 诊断类型
| 类型 | 描述 | 示例提示 |
|------|------|----------|
| `disabled` | Skill 在配置中禁用 | 通过 `skills.<id>.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-id>` 查看详细诊断及可操作的提示。
**覆盖内置 skill**
`~/.super-multica/skills/` 或配置文件 skills 目录中创建具有相同 ID 的 skill。
**热重载不工作?**
确保安装了 `chokidar``pnpm add chokidar`
**并发操作导致问题?**
所有 add/remove/install 操作都会自动序列化。如果你在构建自定义集成,请使用 `serialize()` 函数并使用适当的键。

571
src/agent/skills/add.ts Normal file
View file

@ -0,0 +1,571 @@
/**
* Skills Add Module
*
* Add skills from GitHub repositories
*
* Supports formats:
* - owner/repo Clone entire repo to ~/.super-multica/skills/<repo>
* - 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<string[]> {
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<boolean> {
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<SkillAddResult> {
// 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<SkillAddResult> {
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<SkillAddResult> {
return serialize(SerializeKeys.skillRemove(name), () => removeSkillInternal(name));
}
/**
* Internal implementation of removeSkill (serialized)
*/
async function removeSkillInternal(name: string): Promise<SkillAddResult> {
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<string[]> {
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 [];
}
}

View file

@ -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<SkillFrontmatter> & { 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<string, Skill>();
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<string, Skill>([
["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);
});
});
});

View file

@ -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<string, unknown> | 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<string, unknown>)[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<string, unknown> | 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<string, unknown> | 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<string, Record<string, string>> = {
// 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}=<value>`);
}
}
// 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<string, string> = {
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<string, Skill>,
platform?: NodeJS.Platform,
context: EligibilityContext = {},
): Map<string, Skill> {
const eligible = new Map<string, Skill>();
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 });
}

View file

@ -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<string, Skill> | undefined;
private eligibleSkills: Map<string, Skill> | 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<void> {
// 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<void> {
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<string> }): 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<string, Skill>();
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");
}
}

663
src/agent/skills/install.ts Normal file
View file

@ -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<typeof Readable.fromWeb>[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<SkillInstallResult> {
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<SkillInstallResult> {
// 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<SkillInstallResult> {
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,
};
});
}

318
src/agent/skills/invoke.ts Normal file
View file

@ -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>): 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<string, Skill>,
options?: {
/** Reserved command names to avoid */
reservedNames?: Set<string>;
/** Only include skills matching these IDs */
skillFilter?: string[];
},
): SkillCommandSpec[] {
const used = new Set<string>();
// 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<string, Skill>,
): 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 <name> <args> 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;
}

View file

@ -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/<profileId>/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/<profileId>/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<string, Skill> {
const skillMap = new Map<string, Skill>();
// 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)

View file

@ -89,9 +89,61 @@ function validateFrontmatter(raw: Record<string, unknown>): 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
*

412
src/agent/skills/plugin.ts Normal file
View file

@ -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<string, unknown> {
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<string>();
// 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<string>();
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);
}

View file

@ -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<T> = () => Promise<T>;
interface QueuedOperation {
operation: AsyncOperation<unknown>;
resolve: (value: unknown) => void;
reject: (error: unknown) => void;
}
// ============================================================================
// Serialization Queue
// ============================================================================
/**
* Global map of operation queues keyed by identifier
*/
const operationQueues = new Map<string, QueuedOperation[]>();
/**
* Set of keys currently being processed
*/
const processingKeys = new Set<string>();
/**
* Process the next operation in the queue for a given key
*/
async function processQueue(key: string): Promise<void> {
// 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<T>(key: string, operation: AsyncOperation<T>): Promise<T> {
return new Promise((resolve, reject) => {
let queue = operationQueues.get(key);
if (!queue) {
queue = [];
operationQueues.set(key, queue);
}
queue.push({
operation: operation as AsyncOperation<unknown>,
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<TArgs extends unknown[], TResult>(
keyFn: (...args: TArgs) => string,
fn: (...args: TArgs) => Promise<TResult>,
): (...args: TArgs) => Promise<TResult> {
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<void> {
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<void> {
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;

View file

@ -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/<skillKey>) */
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.<skillKey>
*/
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<string, string> | undefined;
/** Custom per-skill configuration */
config?: Record<string, unknown> | 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<string, SkillConfig> | 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 ?? [];
}

264
src/agent/skills/watcher.ts Normal file
View file

@ -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 <workspace>/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<SkillsChangeListener>();
/** Active watcher instance */
let watcherInstance: {
close: () => Promise<void>;
paths: string[];
} | null = null;
/** Debounce timer */
let debounceTimer: ReturnType<typeof setTimeout> | 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<void>> {
// 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<void> {
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 ?? [];
}

View file

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