multica/src/agent/skills/loader.ts
Jiayuan Zhang c165783e05 feat(skills): clean up removed bundled skills on update
Track bundled skill IDs in a .bundled-manifest.json file. On each
initialization, remove managed skills that were previously bundled
but are no longer present in the bundle directory. User-installed
skills are not affected since they are never recorded in the manifest.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 02:18:00 +08:00

280 lines
8.3 KiB
TypeScript

/**
* Skills Loader
*
* Two-source loading with precedence handling:
* 1. managed - ~/.super-multica/skills/ (global skills)
* 2. profile - ~/.super-multica/agent-profiles/<id>/skills/ (profile-specific)
*/
import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, cpSync, rmSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
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";
/**
* Compare two semver version strings
* Returns: 1 if a > b, -1 if a < b, 0 if equal
*/
function compareVersions(a: string | undefined, b: string | undefined): number {
if (!a && !b) return 0;
if (!a) return -1;
if (!b) return 1;
const partsA = a.split(".").map((n) => parseInt(n, 10) || 0);
const partsB = b.split(".").map((n) => parseInt(n, 10) || 0);
const maxLen = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLen; i++) {
const numA = partsA[i] ?? 0;
const numB = partsB[i] ?? 0;
if (numA > numB) return 1;
if (numA < numB) return -1;
}
return 0;
}
const __dirname = dirname(fileURLToPath(import.meta.url));
/** Default profile base directory */
const DEFAULT_PROFILE_BASE_DIR = join(DATA_DIR, "agent-profiles");
/** Bundled skills directory (relative to package, used for initialization) */
const BUNDLED_DIR = join(__dirname, "../../../skills");
/** Managed skills directory (global user skills) */
const MANAGED_DIR = join(DATA_DIR, "skills");
/** Manifest file tracking which skills were synced from the bundle */
const BUNDLED_MANIFEST = join(MANAGED_DIR, ".bundled-manifest.json");
/**
* Read the bundled skills manifest
* Returns a set of skill IDs that were last synced from the bundle
*/
function readBundledManifest(): Set<string> {
try {
if (!existsSync(BUNDLED_MANIFEST)) return new Set();
const data = JSON.parse(readFileSync(BUNDLED_MANIFEST, "utf-8"));
if (Array.isArray(data)) return new Set(data as string[]);
return new Set();
} catch {
return new Set();
}
}
/**
* Write the bundled skills manifest
*/
function writeBundledManifest(skillIds: Set<string>): void {
writeFileSync(BUNDLED_MANIFEST, JSON.stringify([...skillIds].sort(), null, 2) + "\n");
}
/**
* 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, maxDepth: number = 3): string[] {
if (!existsSync(baseDir)) {
return [];
}
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(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);
}
} catch {
// Skip inaccessible directories
}
}
} catch {
// Skip inaccessible directories
}
}
scan(baseDir, 0);
return results;
}
/**
* Load all skills from a source directory
*
* @param baseDir - Base directory containing skill subdirectories
* @param source - Source type for loaded skills
* @returns Array of loaded skills
*/
function loadSkillsFromSource(baseDir: string, source: SkillSource): Skill[] {
const skillDirs = discoverSkillDirs(baseDir);
const skills: Skill[] = [];
for (const dir of skillDirs) {
const skillId = dir.split("/").pop();
if (!skillId) continue;
const filePath = join(dir, SKILL_FILE);
const skill = parseSkillFile(filePath, skillId, source);
if (skill) {
skills.push(skill);
}
}
return skills;
}
/**
* Get profile skills directory path
*
* @param profileId - Agent profile ID
* @param profileBaseDir - Profile base directory
* @returns Path to profile skills directory
*/
export function getProfileSkillsDir(profileId: string, profileBaseDir?: string): string {
const baseDir = profileBaseDir ?? DEFAULT_PROFILE_BASE_DIR;
return join(baseDir, profileId, "skills");
}
/**
* Initialize managed skills directory with bundled skills
* Copies bundled skills to ~/.super-multica/skills/ if not already present
* Updates existing skills if bundled version is higher
*
* This should be called once during application startup.
*/
export function initializeManagedSkills(): void {
// Create managed dir if not exists
if (!existsSync(MANAGED_DIR)) {
mkdirSync(MANAGED_DIR, { recursive: true });
}
// Skip if bundled dir doesn't exist (e.g., in production builds)
if (!existsSync(BUNDLED_DIR)) {
return;
}
const previouslyBundled = readBundledManifest();
const currentlyBundled = new Set<string>();
// Sync each bundled skill to managed directory
try {
const entries = readdirSync(BUNDLED_DIR);
for (const skillName of entries) {
// Skip hidden directories
if (skillName.startsWith(".")) continue;
const src = join(BUNDLED_DIR, skillName);
const dest = join(MANAGED_DIR, skillName);
// Skip if not a directory
if (!statSync(src).isDirectory()) continue;
currentlyBundled.add(skillName);
// Check if skill exists in managed
if (!existsSync(dest)) {
// Skill doesn't exist, copy it
cpSync(src, dest, { recursive: true });
continue;
}
// Skill exists, check versions
const bundledSkill = parseSkillFile(join(src, SKILL_FILE), skillName, "bundled");
const managedSkill = parseSkillFile(join(dest, SKILL_FILE), skillName, "bundled");
if (!bundledSkill) continue; // Invalid bundled skill, skip
const bundledVersion = bundledSkill.frontmatter.version;
const managedVersion = managedSkill?.frontmatter.version;
// Update if bundled version is higher
if (compareVersions(bundledVersion, managedVersion) > 0) {
// Remove old and copy new
rmSync(dest, { recursive: true });
cpSync(src, dest, { recursive: true });
}
}
// Remove managed skills that were previously bundled but no longer in the bundle
for (const skillName of previouslyBundled) {
if (!currentlyBundled.has(skillName)) {
const dest = join(MANAGED_DIR, skillName);
if (existsSync(dest)) {
rmSync(dest, { recursive: true });
}
}
}
// Persist updated manifest
writeBundledManifest(currentlyBundled);
} catch {
// Ignore errors during initialization
}
}
/**
* Get path to managed skills directory
*/
export function getManagedSkillsDir(): string {
return MANAGED_DIR;
}
/**
* Load all skills from all sources, applying precedence
* Higher precedence sources override skills with the same ID
*
* Loading order (lowest to highest precedence):
* 1. managed - ~/.super-multica/skills/ (global skills)
* 2. profile - ~/.super-multica/agent-profiles/<profileId>/skills/
*
* @param options - Loader options
* @returns Map of skill ID to Skill
*/
export function loadAllSkills(options: SkillManagerOptions = {}): Map<string, Skill> {
// Initialize managed skills on first load (copies bundled skills if needed)
initializeManagedSkills();
const skillMap = new Map<string, Skill>();
// 1. Load managed skills (lower precedence)
const managedSkills = loadSkillsFromSource(MANAGED_DIR, "bundled");
for (const skill of managedSkills) {
skillMap.set(skill.id, skill);
}
// 2. Load profile skills if profileId is provided (higher precedence)
if (options.profileId) {
const profileSkillsDir = getProfileSkillsDir(options.profileId, options.profileBaseDir);
const profileSkills = loadSkillsFromSource(profileSkillsDir, "profile");
for (const skill of profileSkills) {
skillMap.set(skill.id, skill); // Override managed
}
}
return skillMap;
}