Merge pull request #36 from multica-ai/feat/skills-system
feat(skills): complete skills system implementation
This commit is contained in:
commit
20b45c0bcf
20 changed files with 5655 additions and 92 deletions
|
|
@ -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
269
src/agent/autocomplete.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
539
src/agent/skills-cli.ts
Normal 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
422
src/agent/skills/README.md
Normal 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.
|
||||
422
src/agent/skills/README.zh-CN.md
Normal file
422
src/agent/skills/README.zh-CN.md
Normal 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`)调用 skills,AI 模型也可以自动调用。
|
||||
|
||||
### 用户调用
|
||||
|
||||
在交互式 CLI 中,输入 `/` 加上 skill 名称来调用:
|
||||
|
||||
```
|
||||
You: /pdf analyze report.pdf
|
||||
```
|
||||
|
||||
**Tab 补全**:输入 `/p` 然后按 Tab 键查看匹配的 skills,如 `/pdf`。
|
||||
|
||||
**列出可用 skills**:输入 `/help` 查看所有可用的 skill 命令。
|
||||
|
||||
### 调用控制
|
||||
|
||||
使用 frontmatter 字段控制 skill 的调用方式:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: My Skill
|
||||
user-invocable: true # 可通过 /command 调用(默认:true)
|
||||
disable-model-invocation: false # 包含在 AI 提示词中(默认:false)
|
||||
---
|
||||
```
|
||||
|
||||
| 字段 | 默认值 | 描述 |
|
||||
|------|--------|------|
|
||||
| `user-invocable` | `true` | 在 CLI 中启用 `/command` 调用 |
|
||||
| `disable-model-invocation` | `false` | 如果为 `true`,skill 对 AI 的系统提示词隐藏 |
|
||||
|
||||
**使用场景:**
|
||||
|
||||
- **仅用户 skill**(`disable-model-invocation: true`):用户可通过 `/command` 调用,但 AI 不会自动使用
|
||||
- **仅 AI skill**(`user-invocable: false`):AI 可使用,但没有 `/command` 可用
|
||||
- **禁用 skill**(两者都为 `false`):对用户和 AI 都隐藏
|
||||
|
||||
### 命令分发
|
||||
|
||||
对于高级集成,skills 可以直接分发到工具:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: PDF Tool
|
||||
command-dispatch: tool
|
||||
command-tool: pdf-processor
|
||||
command-arg-mode: raw
|
||||
---
|
||||
```
|
||||
|
||||
| 字段 | 描述 |
|
||||
|------|------|
|
||||
| `command-dispatch` | 设置为 `tool` 启用工具分发 |
|
||||
| `command-tool` | 要调用的工具名称 |
|
||||
| `command-arg-mode` | 参数传递方式(`raw` = 原样传递) |
|
||||
|
||||
### 命令名称规范化
|
||||
|
||||
Skill 名称会被规范化以用作命令:
|
||||
|
||||
- 转换为小写
|
||||
- 特殊字符替换为下划线
|
||||
- 截断至最多 32 个字符
|
||||
- 重复名称添加数字后缀(如 `pdf_2`)
|
||||
|
||||
---
|
||||
|
||||
## 加载与优先级
|
||||
|
||||
Skills 从多个来源加载,优先级从低到高:
|
||||
|
||||
| 优先级 | 来源 | 路径 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| 1 | bundled | `<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 # 详细模式带提示
|
||||
```
|
||||
|
||||
输出显示:
|
||||
- 总计/符合条件/不符合条件计数
|
||||
- 按问题类型分组的不符合条件 skills(binary、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
571
src/agent/skills/add.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
663
src/agent/skills/install.ts
Normal 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
318
src/agent/skills/invoke.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
412
src/agent/skills/plugin.ts
Normal 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);
|
||||
}
|
||||
210
src/agent/skills/serialize.ts
Normal file
210
src/agent/skills/serialize.ts
Normal 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;
|
||||
|
|
@ -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
264
src/agent/skills/watcher.ts
Normal 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 ?? [];
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue