refactor(skills): simplify loading to managed + profile sources

- Remove bundled, extraDirs, and plugin skill sources
- Skills now load from only two sources:
  1. managed (~/.super-multica/skills/) - global skills
  2. profile (~/.super-multica/agent-profiles/<id>/skills/) - profile-specific
- Add initializeManagedSkills() to copy bundled skills on first run
- Delete plugin.ts (multica.plugin.json discovery)
- Update documentation and tests

This simplifies the architecture by treating bundled skills as
managed skills that are initialized once, rather than maintaining
multiple loading sources with complex precedence.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jiayuan 2026-01-31 18:16:16 +08:00
parent 96c93cf958
commit dafe1085b4
11 changed files with 126 additions and 639 deletions

View file

@ -157,12 +157,11 @@ Skills are modular capabilities that extend agent functionality through `SKILL.m
### Key Features
- **Multi-source loading** - Bundled, user-installed, plugin-based, and profile-specific skills
- **Two-source loading** - Global skills (`~/.super-multica/skills/`) and profile-specific skills
- **GitHub installation** - `pnpm skills:cli add owner/repo` to install from GitHub
- **Slash command invocation** - `/skill-name args` in interactive mode
- **Eligibility filtering** - Auto-filter by platform, binaries, and environment
- **Hot reload** - File watcher for development
- **Plugin system** - Auto-discover skills from npm packages with `multica.plugin.json`
### Quick Start

View file

@ -147,16 +147,9 @@ 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: extraDirs.length > 0 ? extraDirs : undefined,
config: options.skills,
});

View file

@ -190,40 +190,18 @@ Skill names are normalized for command use:
## Loading & Precedence
Skills load from multiple sources with precedence (lowest to highest):
Skills load from two 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 |
| 1 | managed | `~/.super-multica/skills/` | Global skills (CLI-installed + bundled) |
| 2 | profile | `~/.super-multica/agent-profiles/<id>/skills/` | Profile-specific skills |
Higher priority sources override skills with the same ID.
### Plugin System (npm packages)
### Initialization
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.
On first run, bundled skills are automatically copied to the managed directory (`~/.super-multica/skills/`). This makes them editable and allows users to customize or remove them.
### Eligibility Filtering

View file

@ -190,40 +190,18 @@ Skill 名称会被规范化以用作命令:
## 加载与优先级
Skills 从个来源加载,优先级从低到高:
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/` | 配置文件特定 |
| 1 | managed | `~/.super-multica/skills/` | 全局 skillsCLI 安装 + 内置) |
| 2 | profile | `~/.super-multica/agent-profiles/<id>/skills/` | Profile 专属 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 会自动复制到 managed 目录(`~/.super-multica/skills/`)。这使得用户可以编辑或删除它们。
### 资格过滤

View file

@ -6,7 +6,7 @@
*/
import type { Skill, SkillManagerOptions, SkillsConfig, SkillCommandSpec, SkillInvocationResult } from "./types.js";
import { loadAllSkills, getBundledSkillsDir, getProfileSkillsDir } from "./loader.js";
import { loadAllSkills, getProfileSkillsDir, initializeManagedSkills, getManagedSkillsDir } from "./loader.js";
import {
filterEligibleSkills,
checkEligibility,
@ -72,7 +72,7 @@ export {
} from "./eligibility.js";
export { parseFrontmatter, parseSkillFile } from "./parser.js";
export { loadAllSkills, getBundledSkillsDir, getProfileSkillsDir } from "./loader.js";
export { loadAllSkills, getProfileSkillsDir, initializeManagedSkills, getManagedSkillsDir } from "./loader.js";
// Export install module
export {
@ -129,19 +129,6 @@ export {
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
*
@ -215,7 +202,8 @@ export class SkillManager {
// 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,
profileId: this.options.profileId,
profileBaseDir: this.options.profileBaseDir,
debounceMs: this.options.config?.load?.watchDebounceMs,
enabled: watchEnabled,
});

View file

@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { getProfileSkillsDir, loadAllSkills, getBundledSkillsDir } from "./loader.js";
import { getProfileSkillsDir, loadAllSkills, initializeManagedSkills, getManagedSkillsDir } from "./loader.js";
describe("loader", () => {
const testBaseDir = join(tmpdir(), `multica-skills-test-${Date.now()}`);
@ -35,13 +35,20 @@ describe("loader", () => {
});
});
describe("getBundledSkillsDir", () => {
it("should return path to bundled skills", () => {
const result = getBundledSkillsDir();
describe("getManagedSkillsDir", () => {
it("should return path to managed skills", () => {
const result = getManagedSkillsDir();
expect(result).toContain(".super-multica");
expect(result).toContain("skills");
});
});
describe("initializeManagedSkills", () => {
it("should not throw when called", () => {
expect(() => initializeManagedSkills()).not.toThrow();
});
});
describe("loadAllSkills", () => {
function createSkillDir(baseDir: string, skillId: string, name: string) {
const skillDir = join(baseDir, skillId);
@ -57,19 +64,6 @@ Instructions for ${name}
);
}
it("should load skills from extra directories", () => {
const extraDir = join(testBaseDir, "extra-skills");
mkdirSync(extraDir, { recursive: true });
createSkillDir(extraDir, "custom-skill", "Custom Skill");
const skills = loadAllSkills({ extraDirs: [extraDir] });
expect(skills.has("custom-skill")).toBe(true);
const skill = skills.get("custom-skill");
expect(skill?.frontmatter.name).toBe("Custom Skill");
expect(skill?.source).toBe("bundled");
});
it("should load skills from profile directory", () => {
const profileDir = join(testBaseDir, "profiles", "test-profile", "skills");
mkdirSync(profileDir, { recursive: true });
@ -86,46 +80,20 @@ Instructions for ${name}
expect(skill?.source).toBe("profile");
});
it("should apply precedence: profile overrides bundled", () => {
const extraDir = join(testBaseDir, "extra");
mkdirSync(extraDir, { recursive: true });
createSkillDir(extraDir, "same-id", "Bundled Version");
const profileDir = join(testBaseDir, "profiles", "test-profile", "skills");
mkdirSync(profileDir, { recursive: true });
createSkillDir(profileDir, "same-id", "Profile Version");
const skills = loadAllSkills({
extraDirs: [extraDir],
profileId: "test-profile",
profileBaseDir: join(testBaseDir, "profiles"),
});
expect(skills.has("same-id")).toBe(true);
const skill = skills.get("same-id");
expect(skill?.frontmatter.name).toBe("Profile Version");
expect(skill?.source).toBe("profile");
});
it("should return empty map when no skills found", () => {
const emptyDir = join(testBaseDir, "empty");
mkdirSync(emptyDir, { recursive: true });
const skills = loadAllSkills({ extraDirs: [emptyDir] });
// May contain bundled skills, but the empty extra dir shouldn't cause issues
it("should return map when no profile provided", () => {
const skills = loadAllSkills({});
expect(skills).toBeInstanceOf(Map);
});
it("should skip invalid skill files", () => {
const extraDir = join(testBaseDir, "with-invalid");
mkdirSync(extraDir, { recursive: true });
const profileDir = join(testBaseDir, "profiles", "test-profile", "skills");
mkdirSync(profileDir, { recursive: true });
// Create valid skill
createSkillDir(extraDir, "valid-skill", "Valid Skill");
createSkillDir(profileDir, "valid-skill", "Valid Skill");
// Create invalid skill (no name in frontmatter)
const invalidDir = join(extraDir, "invalid-skill");
const invalidDir = join(profileDir, "invalid-skill");
mkdirSync(invalidDir, { recursive: true });
writeFileSync(
join(invalidDir, "SKILL.md"),
@ -136,48 +104,49 @@ Invalid skill
`
);
const skills = loadAllSkills({ extraDirs: [extraDir] });
const skills = loadAllSkills({
profileId: "test-profile",
profileBaseDir: join(testBaseDir, "profiles"),
});
expect(skills.has("valid-skill")).toBe(true);
expect(skills.has("invalid-skill")).toBe(false);
});
it("should skip directories without SKILL.md", () => {
const extraDir = join(testBaseDir, "partial");
mkdirSync(extraDir, { recursive: true });
const profileDir = join(testBaseDir, "profiles", "test-profile", "skills");
mkdirSync(profileDir, { recursive: true });
// Directory without SKILL.md
const noSkillDir = join(extraDir, "not-a-skill");
const noSkillDir = join(profileDir, "not-a-skill");
mkdirSync(noSkillDir, { recursive: true });
writeFileSync(join(noSkillDir, "README.md"), "Just a readme");
// Valid skill
createSkillDir(extraDir, "real-skill", "Real Skill");
createSkillDir(profileDir, "real-skill", "Real Skill");
const skills = loadAllSkills({ extraDirs: [extraDir] });
const skills = loadAllSkills({
profileId: "test-profile",
profileBaseDir: join(testBaseDir, "profiles"),
});
expect(skills.has("real-skill")).toBe(true);
expect(skills.has("not-a-skill")).toBe(false);
});
it("should handle non-existent directories gracefully", () => {
it("should load multiple skills from profile directory", () => {
const profileDir = join(testBaseDir, "profiles", "test-profile", "skills");
mkdirSync(profileDir, { recursive: true });
createSkillDir(profileDir, "skill-a", "Skill A");
createSkillDir(profileDir, "skill-b", "Skill B");
createSkillDir(profileDir, "skill-c", "Skill C");
const skills = loadAllSkills({
extraDirs: ["/non/existent/path"],
profileId: "test-profile",
profileBaseDir: join(testBaseDir, "profiles"),
});
expect(skills).toBeInstanceOf(Map);
});
it("should load multiple skills from same directory", () => {
const extraDir = join(testBaseDir, "multi");
mkdirSync(extraDir, { recursive: true });
createSkillDir(extraDir, "skill-a", "Skill A");
createSkillDir(extraDir, "skill-b", "Skill B");
createSkillDir(extraDir, "skill-c", "Skill C");
const skills = loadAllSkills({ extraDirs: [extraDir] });
expect(skills.has("skill-a")).toBe(true);
expect(skills.has("skill-b")).toBe(true);
expect(skills.has("skill-c")).toBe(true);

View file

@ -1,28 +1,28 @@
/**
* Skills Loader
*
* Multi-source loading with precedence handling
* Supports bundled skills, user-installed skills, profile skills, and plugin skills
* 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, statSync } from "node:fs";
import { existsSync, readdirSync, statSync, mkdirSync, cpSync } 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";
import { resolvePluginSkillDirs } from "./plugin.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) */
/** Bundled skills directory (relative to package, used for initialization) */
const BUNDLED_DIR = join(__dirname, "../../../skills");
/** Managed skills directory (user-installed via `skills add`) */
/** Managed skills directory (global user skills) */
const MANAGED_DIR = join(DATA_DIR, "skills");
/**
@ -113,16 +113,57 @@ export function getProfileSkillsDir(profileId: string, profileBaseDir?: string):
return join(baseDir, profileId, "skills");
}
/**
* Initialize managed skills directory with bundled skills
* Copies bundled skills to ~/.super-multica/skills/ if not already present
*
* 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;
}
// Copy each bundled skill if not already in managed
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);
// Only copy directories that don't already exist
if (!existsSync(dest) && statSync(src).isDirectory()) {
cpSync(src, dest, { recursive: true });
}
}
} 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. bundled - Package bundled skills
* 2. extra - User-configured extra directories
* 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/
* 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
@ -130,50 +171,20 @@ 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)
if (options.profileId) {
const profileSkillsDir = getProfileSkillsDir(options.profileId, options.profileBaseDir);
sources.push([profileSkillsDir, "profile"]);
// 1. Load managed skills (lower precedence)
const managedSkills = loadSkillsFromSource(MANAGED_DIR, "bundled");
for (const skill of managedSkills) {
skillMap.set(skill.id, skill);
}
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);
}
// 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;
}
/**
* Get path to bundled skills directory
*/
export function getBundledSkillsDir(): string {
return BUNDLED_DIR;
}

View file

@ -1,412 +0,0 @@
/**
* Plugin System
*
* Discovers and loads skills from npm packages that contain a multica.plugin.json manifest.
* This enables users to install skill packages via npm and have them automatically discovered.
*
* Design inspired by OpenClaw's plugin system.
*/
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, dirname, resolve } from "node:path";
// ============================================================================
// Types
// ============================================================================
/**
* Plugin manifest file name
*/
export const PLUGIN_MANIFEST_FILENAME = "multica.plugin.json";
/**
* Plugin manifest schema
* Stored in multica.plugin.json at the package root
*/
export interface PluginManifest {
/** Unique plugin identifier (required) */
id: string;
/** Human-readable plugin name */
name?: string | undefined;
/** Plugin description */
description?: string | undefined;
/** Plugin version */
version?: string | undefined;
/** Relative paths to skill directories within the package */
skills?: string[] | undefined;
}
/**
* Loaded plugin record with resolved paths
*/
export interface PluginRecord {
/** Plugin ID from manifest */
id: string;
/** Plugin name */
name?: string | undefined;
/** Plugin description */
description?: string | undefined;
/** Plugin version */
version?: string | undefined;
/** Absolute path to package root */
rootDir: string;
/** Absolute path to manifest file */
manifestPath: string;
/** Resolved absolute paths to skill directories */
skillDirs: string[];
/** Source of discovery */
source: "node_modules" | "custom";
}
/**
* Plugin discovery diagnostic
*/
export interface PluginDiagnostic {
level: "error" | "warn" | "info";
pluginId?: string | undefined;
source: string;
message: string;
}
/**
* Plugin registry result
*/
export interface PluginRegistry {
plugins: PluginRecord[];
diagnostics: PluginDiagnostic[];
}
// ============================================================================
// Manifest Loading
// ============================================================================
/**
* Check if a value is a plain object
*/
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
/**
* Normalize a string array from unknown input
*/
function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean);
}
/**
* Load and parse a plugin manifest from a directory
*
* @param rootDir - Package root directory
* @returns Parsed manifest or error
*/
export function loadPluginManifest(
rootDir: string,
): { ok: true; manifest: PluginManifest; manifestPath: string } | { ok: false; error: string; manifestPath: string } {
const manifestPath = join(rootDir, PLUGIN_MANIFEST_FILENAME);
if (!existsSync(manifestPath)) {
return { ok: false, error: `manifest not found: ${manifestPath}`, manifestPath };
}
let raw: unknown;
try {
raw = JSON.parse(readFileSync(manifestPath, "utf-8")) as unknown;
} catch (err) {
return {
ok: false,
error: `failed to parse manifest: ${String(err)}`,
manifestPath,
};
}
if (!isRecord(raw)) {
return { ok: false, error: "manifest must be an object", manifestPath };
}
const id = typeof raw.id === "string" ? raw.id.trim() : "";
if (!id) {
return { ok: false, error: "manifest requires id field", manifestPath };
}
const manifest: PluginManifest = {
id,
name: typeof raw.name === "string" ? raw.name.trim() : undefined,
description: typeof raw.description === "string" ? raw.description.trim() : undefined,
version: typeof raw.version === "string" ? raw.version.trim() : undefined,
skills: normalizeStringList(raw.skills),
};
return { ok: true, manifest, manifestPath };
}
// ============================================================================
// Plugin Discovery
// ============================================================================
/**
* Find all node_modules directories to search
* Walks up from workspaceDir to find all node_modules in the tree
*/
function findNodeModulesDirs(workspaceDir: string): string[] {
const dirs: string[] = [];
let current = resolve(workspaceDir);
const root = dirname(current);
while (current !== root) {
const nodeModules = join(current, "node_modules");
if (existsSync(nodeModules) && statSync(nodeModules).isDirectory()) {
dirs.push(nodeModules);
}
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
return dirs;
}
/**
* Discover plugin packages in a node_modules directory
*
* @param nodeModulesDir - Path to node_modules
* @returns Array of package directories containing plugin manifests
*/
function discoverPluginsInNodeModules(nodeModulesDir: string): string[] {
const candidates: string[] = [];
try {
const entries = readdirSync(nodeModulesDir);
for (const entry of entries) {
// Skip hidden and special directories
if (entry.startsWith(".") || entry === "node_modules") continue;
const entryPath = join(nodeModulesDir, entry);
try {
const stat = statSync(entryPath);
if (!stat.isDirectory()) continue;
// Handle scoped packages (@org/package)
if (entry.startsWith("@")) {
const scopedEntries = readdirSync(entryPath);
for (const scopedEntry of scopedEntries) {
if (scopedEntry.startsWith(".")) continue;
const scopedPath = join(entryPath, scopedEntry);
if (existsSync(join(scopedPath, PLUGIN_MANIFEST_FILENAME))) {
candidates.push(scopedPath);
}
}
} else {
// Regular package
if (existsSync(join(entryPath, PLUGIN_MANIFEST_FILENAME))) {
candidates.push(entryPath);
}
}
} catch {
// Skip inaccessible directories
}
}
} catch {
// Skip inaccessible node_modules
}
return candidates;
}
/**
* Build a plugin record from a manifest and candidate
*/
function buildPluginRecord(params: {
manifest: PluginManifest;
manifestPath: string;
rootDir: string;
source: "node_modules" | "custom";
}): PluginRecord {
const { manifest, manifestPath, rootDir, source } = params;
// Resolve skill directories
const skillDirs: string[] = [];
for (const skillPath of manifest.skills ?? []) {
const resolved = resolve(rootDir, skillPath);
if (existsSync(resolved)) {
skillDirs.push(resolved);
}
}
return {
id: manifest.id,
name: manifest.name,
description: manifest.description,
version: manifest.version,
rootDir,
manifestPath,
skillDirs,
source,
};
}
// ============================================================================
// Plugin Registry
// ============================================================================
/**
* Discover and load all plugins
*
* @param options - Discovery options
* @returns Plugin registry with all discovered plugins
*/
export function loadPluginRegistry(options: {
/** Workspace directory to start search from */
workspaceDir?: string;
/** Additional directories to search for plugins */
extraPaths?: string[];
/** Skip node_modules scanning */
skipNodeModules?: boolean;
}): PluginRegistry {
const { workspaceDir, extraPaths = [], skipNodeModules = false } = options;
const plugins: PluginRecord[] = [];
const diagnostics: PluginDiagnostic[] = [];
const seenIds = new Set<string>();
// Discover plugins in node_modules
if (!skipNodeModules && workspaceDir) {
const nodeModulesDirs = findNodeModulesDirs(workspaceDir);
for (const nodeModulesDir of nodeModulesDirs) {
const candidates = discoverPluginsInNodeModules(nodeModulesDir);
for (const candidate of candidates) {
const result = loadPluginManifest(candidate);
if (!result.ok) {
diagnostics.push({
level: "error",
source: result.manifestPath,
message: result.error,
});
continue;
}
const { manifest, manifestPath } = result;
if (seenIds.has(manifest.id)) {
diagnostics.push({
level: "warn",
pluginId: manifest.id,
source: manifestPath,
message: `duplicate plugin id; earlier instance takes precedence`,
});
continue;
}
seenIds.add(manifest.id);
plugins.push(
buildPluginRecord({
manifest,
manifestPath,
rootDir: candidate,
source: "node_modules",
}),
);
}
}
}
// Load plugins from extra paths
for (const extraPath of extraPaths) {
if (!existsSync(extraPath)) {
diagnostics.push({
level: "warn",
source: extraPath,
message: "extra plugin path does not exist",
});
continue;
}
const result = loadPluginManifest(extraPath);
if (!result.ok) {
diagnostics.push({
level: "error",
source: result.manifestPath,
message: result.error,
});
continue;
}
const { manifest, manifestPath } = result;
if (seenIds.has(manifest.id)) {
diagnostics.push({
level: "warn",
pluginId: manifest.id,
source: manifestPath,
message: `duplicate plugin id; earlier instance takes precedence`,
});
continue;
}
seenIds.add(manifest.id);
plugins.push(
buildPluginRecord({
manifest,
manifestPath,
rootDir: extraPath,
source: "custom",
}),
);
}
return { plugins, diagnostics };
}
// ============================================================================
// Skill Directory Resolution
// ============================================================================
/**
* Get all skill directories from discovered plugins
*
* This function is the main integration point with SkillManager.
* It discovers plugins and returns their skill directories.
*
* @param options - Discovery options
* @returns Array of absolute paths to skill directories
*/
export function resolvePluginSkillDirs(options: {
workspaceDir?: string;
extraPaths?: string[];
}): string[] {
const registry = loadPluginRegistry(options);
const dirs: string[] = [];
const seen = new Set<string>();
for (const plugin of registry.plugins) {
for (const skillDir of plugin.skillDirs) {
if (!seen.has(skillDir)) {
seen.add(skillDir);
dirs.push(skillDir);
}
}
}
return dirs;
}
/**
* Get plugin registry with diagnostics for CLI/debugging
*
* @param options - Discovery options
* @returns Full registry with plugins and diagnostics
*/
export function getPluginRegistry(options: {
workspaceDir?: string;
extraPaths?: string[];
}): PluginRegistry {
return loadPluginRegistry(options);
}

View file

@ -174,8 +174,6 @@ export interface SkillConfig {
* 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) */
@ -218,16 +216,10 @@ export interface SkillManagerOptions {
profileId?: string | undefined;
/** Profile base directory, defaults to ~/.super-multica/agent-profiles */
profileBaseDir?: string | undefined;
/** Additional directories to search for skills */
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;
}
/**

View file

@ -14,10 +14,10 @@ import { DATA_DIR } from "../../shared/index.js";
// ============================================================================
export interface SkillsWatcherOptions {
/** Workspace directory to watch (for <workspace>/skills) */
workspaceDir?: string | undefined;
/** Additional directories to watch */
extraDirs?: string[] | undefined;
/** Profile ID (for profile-specific skills watching) */
profileId?: string | undefined;
/** Profile base directory */
profileBaseDir?: string | undefined;
/** Debounce delay in milliseconds (default: 250) */
debounceMs?: number | undefined;
/** Whether watching is enabled (default: true) */
@ -128,25 +128,18 @@ const IGNORED_PATTERNS = [
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);
// Profile skills (~/.super-multica/agent-profiles/<id>/skills)
if (options.profileId) {
const profileBaseDir = options.profileBaseDir ?? join(DATA_DIR, "agent-profiles");
const profileSkills = join(profileBaseDir, options.profileId, "skills");
if (existsSync(profileSkills)) {
paths.push(profileSkills);
}
}

View file

@ -58,8 +58,6 @@ export type AgentOptions = {
// === Skills Configuration ===
/** Enable skills system (default: true) */
enableSkills?: boolean | undefined;
/** Additional directories to search for skills */
extraSkillDirs?: string[] | undefined;
/** Full skills configuration */
skills?: SkillsConfig | undefined;