multica/src/agent/skills/loader.ts
Jiang Bohan da3bb254ec fix(skills): support nested skill directories and managed skills
- Add managed skills directory (~/.super-multica/skills/) to loader
- Change discoverSkillDirs to recursively scan up to 3 levels deep
- Skip hidden directories during scan
- Fixes skills installed via `skills add` not being discovered

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:36:31 +08:00

168 lines
4.9 KiB
TypeScript

/**
* Skills Loader
*
* Multi-source loading with precedence handling
*/
import { existsSync, readdirSync, statSync } 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";
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) */
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, 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");
}
/**
* Load all skills from all sources, applying precedence
* Higher precedence sources override skills with the same ID
*
* Loading order (lowest to highest precedence):
* 1. bundled - Package bundled skills
* 2. extra - User-configured extra directories
* 3. managed - ~/.super-multica/skills/ (user-installed via `skills add`)
* 4. 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> {
const skillMap = new Map<string, Skill>();
// 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"]),
// Managed skills (user-installed via `skills add`)
[MANAGED_DIR, "profile"],
];
// Add profile skills if profileId is provided (highest precedence)
if (options.profileId) {
const profileSkillsDir = getProfileSkillsDir(options.profileId, options.profileBaseDir);
sources.push([profileSkillsDir, "profile"]);
}
for (const [dir, source] of sources) {
const skills = loadSkillsFromSource(dir, source);
for (const skill of skills) {
const existing = skillMap.get(skill.id);
// Higher precedence overwrites lower
if (
!existing ||
SKILL_SOURCE_PRECEDENCE[source] > SKILL_SOURCE_PRECEDENCE[existing.source]
) {
skillMap.set(skill.id, skill);
}
}
}
return skillMap;
}
/**
* Get path to bundled skills directory
*/
export function getBundledSkillsDir(): string {
return BUNDLED_DIR;
}