From dafe1085b4a70d46c629d51f33c18c83c3b7587e Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Sat, 31 Jan 2026 18:16:16 +0800 Subject: [PATCH] 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//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 --- README.md | 3 +- src/agent/runner.ts | 7 - src/agent/skills/README.md | 32 +-- src/agent/skills/README.zh-CN.md | 32 +-- src/agent/skills/index.ts | 20 +- src/agent/skills/loader.test.ts | 109 +++----- src/agent/skills/loader.ts | 113 +++++---- src/agent/skills/plugin.ts | 412 ------------------------------- src/agent/skills/types.ts | 8 - src/agent/skills/watcher.ts | 27 +- src/agent/types.ts | 2 - 11 files changed, 126 insertions(+), 639 deletions(-) delete mode 100644 src/agent/skills/plugin.ts diff --git a/README.md b/README.md index 0754c63a..1843707b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 5017b8cb..1ccb3f7d 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -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, }); diff --git a/src/agent/skills/README.md b/src/agent/skills/README.md index 9ab65c37..5a8e51f0 100644 --- a/src/agent/skills/README.md +++ b/src/agent/skills/README.md @@ -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 | `/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//skills/` | Profile-specific | +| 1 | managed | `~/.super-multica/skills/` | Global skills (CLI-installed + bundled) | +| 2 | profile | `~/.super-multica/agent-profiles//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 diff --git a/src/agent/skills/README.zh-CN.md b/src/agent/skills/README.zh-CN.md index 1a2f1976..fd72617e 100644 --- a/src/agent/skills/README.zh-CN.md +++ b/src/agent/skills/README.zh-CN.md @@ -190,40 +190,18 @@ Skill 名称会被规范化以用作命令: ## 加载与优先级 -Skills 从多个来源加载,优先级从低到高: +Skills 从两个来源加载,优先级从低到高: | 优先级 | 来源 | 路径 | 描述 | |--------|------|------|------| -| 1 | bundled | `/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//skills/` | 配置文件特定 | +| 1 | managed | `~/.super-multica/skills/` | 全局 skills(CLI 安装 + 内置) | +| 2 | profile | `~/.super-multica/agent-profiles//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/`)。这使得用户可以编辑或删除它们。 ### 资格过滤 diff --git a/src/agent/skills/index.ts b/src/agent/skills/index.ts index 7ea7115c..186f4790 100644 --- a/src/agent/skills/index.ts +++ b/src/agent/skills/index.ts @@ -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, }); diff --git a/src/agent/skills/loader.test.ts b/src/agent/skills/loader.test.ts index 6ddae295..375b62ce 100644 --- a/src/agent/skills/loader.test.ts +++ b/src/agent/skills/loader.test.ts @@ -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); diff --git a/src/agent/skills/loader.ts b/src/agent/skills/loader.ts index 7201c376..99876cd6 100644 --- a/src/agent/skills/loader.ts +++ b/src/agent/skills/loader.ts @@ -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//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//skills/ + * 1. managed - ~/.super-multica/skills/ (global skills) + * 2. profile - ~/.super-multica/agent-profiles//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 { const skillMap = new Map(); - // 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; -} diff --git a/src/agent/skills/plugin.ts b/src/agent/skills/plugin.ts deleted file mode 100644 index 6dbcec22..00000000 --- a/src/agent/skills/plugin.ts +++ /dev/null @@ -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 { - 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(); - - // 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(); - - 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); -} diff --git a/src/agent/skills/types.ts b/src/agent/skills/types.ts index 9e9aacb1..a679f039 100644 --- a/src/agent/skills/types.ts +++ b/src/agent/skills/types.ts @@ -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; } /** diff --git a/src/agent/skills/watcher.ts b/src/agent/skills/watcher.ts index 067aa4da..f7342f65 100644 --- a/src/agent/skills/watcher.ts +++ b/src/agent/skills/watcher.ts @@ -14,10 +14,10 @@ import { DATA_DIR } from "../../shared/index.js"; // ============================================================================ export interface SkillsWatcherOptions { - /** Workspace directory to watch (for /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//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); } } diff --git a/src/agent/types.ts b/src/agent/types.ts index 4a1cf9d7..75e53ad1 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -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;