diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 3119b2b..a4b57a0 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -41,6 +41,7 @@ import { resolveCredentials, runAgent, wirePlugins, + collectPluginContributions, type AgentEvent, type Effort, type McpClientHandle, @@ -145,10 +146,16 @@ export async function runHeadless(opts: HeadlessOpts): Promise { maxBytes: (settings.memoryLoadCapKB ?? 100) * 1024, }); const builtinSkillsDir = await resolveBuiltinSkillsDir(); + // Trusted+enabled plugins contribute skills / sub-agents (dirs) + MCP servers. + const pluginContrib = await collectPluginContributions({ + home: opts.home, + disabled: settings.disabledPlugins, + }); const skills = await loadSkills({ cwd, home: opts.home, builtinDir: builtinSkillsDir, + pluginDirs: pluginContrib.dirs, overrides: settings.skillOverrides, }); const styles = await loadOutputStyles({ cwd, home: opts.home }); @@ -157,8 +164,9 @@ export async function runHeadless(opts: HeadlessOpts): Promise { // ─── MCP ───────────────────────────────────────────────────────────── let mcpServers: McpClientHandle[] = []; - if (settings.mcpServers && Object.keys(settings.mcpServers).length > 0) { - const r = await connectAllMcpServers(settings.mcpServers, { + const allMcpServers = { ...pluginContrib.mcpServers, ...(settings.mcpServers ?? {}) }; + if (Object.keys(allMcpServers).length > 0) { + const r = await connectAllMcpServers(allMcpServers, { enabledOnly: settings.enabledMcpjsonServers, disabled: settings.disabledMcpjsonServers ?? [], }); @@ -276,6 +284,7 @@ export async function runHeadless(opts: HeadlessOpts): Promise { mode, permissions: settings.permissions, hooks, + pluginDirs: pluginContrib.dirs, autoCompact: { contextWindow: contextWindowFor(model), threshold: 0.8 }, autoMode: settings.autoMode, sandboxConfig: settings.sandbox, diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index d7712a3..2969bf5 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -38,6 +38,7 @@ import { runAgent, settingsPaths, wirePlugins, + collectPluginContributions, type Effort, type McpClientHandle, type Mode, @@ -146,8 +147,18 @@ export async function startRepl(opts: ReplOpts): Promise { tools = new ToolRegistry(); } const commands = new CommandRegistry(); - // Custom prompt-template commands from .deepcode/commands/*.md (user + project). - const customCommands = await loadSlashCommands({ cwd, home: opts.home }); + // Trusted+enabled plugins contribute skills / sub-agents / commands (their + // dirs) + MCP servers. Hooks are merged separately by wirePlugins. + const pluginContrib = await collectPluginContributions({ + home: opts.home, + disabled: settings.disabledPlugins, + }); + // Custom prompt-template commands from plugin + user + project commands dirs. + const customCommands = await loadSlashCommands({ + cwd, + home: opts.home, + pluginDirs: pluginContrib.dirs, + }); // M5: load memory, skills, output style — assemble final system prompt const memory = await loadMemory({ @@ -161,6 +172,7 @@ export async function startRepl(opts: ReplOpts): Promise { cwd, home: opts.home, builtinDir: builtinSkillsDir, + pluginDirs: pluginContrib.dirs, overrides: settings.skillOverrides, }); const styles = await loadOutputStyles({ cwd, home: opts.home }); @@ -180,10 +192,12 @@ export async function startRepl(opts: ReplOpts): Promise { const elicitHolder: { fn?: McpElicitHandler } = {}; const elicitForServers: McpElicitHandler = (req) => elicitHolder.fn ? elicitHolder.fn(req) : Promise.resolve({ action: 'cancel' }); - if (settings.mcpServers && Object.keys(settings.mcpServers).length > 0) { + // Plugin-contributed MCP servers + the user's settings (user wins on a clash). + const allMcpServers = { ...pluginContrib.mcpServers, ...(settings.mcpServers ?? {}) }; + if (Object.keys(allMcpServers).length > 0) { const enabled = settings.enabledMcpjsonServers; const disabled = settings.disabledMcpjsonServers ?? []; - const result = await connectAllMcpServers(settings.mcpServers, { + const result = await connectAllMcpServers(allMcpServers, { enabledOnly: enabled, disabled, elicit: elicitForServers, @@ -438,6 +452,7 @@ export async function startRepl(opts: ReplOpts): Promise { mode: ctx.mode as Mode, permissions: settings.permissions, hooks, + pluginDirs: pluginContrib.dirs, autoCompact: { contextWindow: contextWindowFor(ctx.model), threshold: 0.8 }, autoMode: settings.autoMode, sandboxConfig: settings.sandbox, diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 766af67..8629728 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -88,6 +88,9 @@ export interface RunAgentOptions { * Sub-agents run at depth 1 and are NOT given a runSubAgent, so they can't * spawn further sub-agents. */ subAgentDepth?: number; + /** Installed-plugin directories — so the Task tool can resolve plugin-bundled + * sub-agents (`/agents/*.md`) in addition to user/project ones. */ + pluginDirs?: string[]; } /** Max sub-agent recursion: top-level (0) may spawn sub-agents (depth 1); those @@ -219,7 +222,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { const { loadSubAgents, findSubAgent } = (await import( mod )) as typeof import('./sub-agents/index.js'); - const agents = await loadSubAgents({ cwd: opts.cwd }); + const agents = await loadSubAgents({ cwd: opts.cwd, pluginDirs: opts.pluginDirs }); const found = agentType ? findSubAgent(agents, agentType) : undefined; if (agentType && !found) { const names = agents.map((a) => a.qualifiedName).join(', ') || '(none)'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 383d96e..e086c19 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -229,6 +229,7 @@ export { export { installLocal, discoverPlugins, + collectPluginContributions, readManifest, computeSourceHash, loadTrustState, diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index 6fae25d..b898e3c 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -23,6 +23,7 @@ export { installLocal, discoverPlugins, + collectPluginContributions, readManifest, computeSourceHash, loadTrustState, diff --git a/packages/core/src/plugins/manifest.test.ts b/packages/core/src/plugins/manifest.test.ts index 6723f3d..d5535b6 100644 --- a/packages/core/src/plugins/manifest.test.ts +++ b/packages/core/src/plugins/manifest.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + collectPluginContributions, computeSourceHash, discoverPlugins, installLocal, @@ -154,4 +155,34 @@ describe('plugin manifest', () => { expect(r.plugins).toHaveLength(0); expect(r.hashMismatches[0]).toMatch(/not in trust manifest/); }); + + it('collectPluginContributions returns dirs + mcpServers for trusted plugins', async () => { + await fakePlugin(src, { + name: 'contrib', + version: '1.0.0', + contributes: { mcpServers: { svc: { command: 'node', args: ['s.js'] } } }, + }); + const installed = await installLocal({ sourcePath: src, home }); + + const { dirs, mcpServers } = await collectPluginContributions({ home }); + expect(dirs).toContain(installed.path); + expect(mcpServers.svc).toEqual({ command: 'node', args: ['s.js'] }); + }); + + it('collectPluginContributions excludes untrusted plugins', async () => { + // Plugin on disk but never installed/trusted → not contributed. + const dir = join(home, '.deepcode', 'plugins', 'untrusted'); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + join(dir, 'plugin.json'), + JSON.stringify({ + name: 'untrusted', + version: '1.0.0', + contributes: { mcpServers: { x: {} } }, + }), + ); + const { dirs, mcpServers } = await collectPluginContributions({ home }); + expect(dirs).toEqual([]); + expect(mcpServers).toEqual({}); + }); }); diff --git a/packages/core/src/plugins/manifest.ts b/packages/core/src/plugins/manifest.ts index 7a551f4..4cbc004 100644 --- a/packages/core/src/plugins/manifest.ts +++ b/packages/core/src/plugins/manifest.ts @@ -14,6 +14,7 @@ import { promises as fs } from 'node:fs'; import { createHash } from 'node:crypto'; import { homedir } from 'node:os'; import { join } from 'node:path'; +import type { McpServerConfig } from '../config/types.js'; export interface PluginManifest { name: string; @@ -208,6 +209,26 @@ export async function discoverPlugins(opts: DiscoverOptions = {}): Promise<{ return { plugins: out, hashMismatches }; } +/** + * Collect the live contributions of trusted+enabled plugins for the host to + * wire in: their directories (for skill / sub-agent / command loaders, which + * read `/{skills,agents,commands}`) and their contributed `mcpServers`. + * Hooks are merged separately by wirePlugins (it needs the live dispatcher). + */ +export async function collectPluginContributions( + opts: { home?: string; disabled?: string[] } = {}, +): Promise<{ dirs: string[]; mcpServers: Record }> { + const { plugins } = await discoverPlugins({ home: opts.home, disabled: opts.disabled }); + const enabled = plugins.filter((p) => p.enabled); + const dirs = enabled.map((p) => p.path); + const mcpServers: Record = {}; + for (const p of enabled) { + const contributed = p.manifest.contributes?.mcpServers; + if (contributed) Object.assign(mcpServers, contributed as Record); + } + return { dirs, mcpServers }; +} + async function copyDirectory(src: string, dest: string): Promise { await fs.mkdir(dest, { recursive: true }); const entries = await fs.readdir(src, { withFileTypes: true }); diff --git a/packages/core/src/slash-commands/index.test.ts b/packages/core/src/slash-commands/index.test.ts index 52a15db..4aaf022 100644 --- a/packages/core/src/slash-commands/index.test.ts +++ b/packages/core/src/slash-commands/index.test.ts @@ -78,4 +78,23 @@ describe('loadSlashCommands', () => { const cmds = await loadSlashCommands({ cwd, home }); expect(cmds.map((c) => c.name)).toEqual(['/real']); }); + + it('loads plugin-contributed commands (overridable by user/project)', async () => { + const pluginDir = await mkdtemp(join(tmpdir(), 'dc-plug-')); + try { + await mkdir(join(pluginDir, 'commands'), { recursive: true }); + await writeFile(join(pluginDir, 'commands', 'pcmd.md'), 'plugin body'); + await writeFile(join(pluginDir, 'commands', 'shared.md'), 'plugin shared'); + // A project command of the same name as a plugin one overrides it. + await mkdir(join(cwd, '.deepcode', 'commands'), { recursive: true }); + await writeFile(join(cwd, '.deepcode', 'commands', 'shared.md'), 'project shared'); + + const cmds = await loadSlashCommands({ cwd, home, pluginDirs: [pluginDir] }); + expect(findCustomCommand(cmds, '/pcmd')?.source).toBe('plugin'); + // project wins on the name clash + expect(findCustomCommand(cmds, '/shared')?.source).toBe('project'); + } finally { + await rm(pluginDir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/core/src/slash-commands/index.ts b/packages/core/src/slash-commands/index.ts index 04bf19b..3f9f909 100644 --- a/packages/core/src/slash-commands/index.ts +++ b/packages/core/src/slash-commands/index.ts @@ -20,7 +20,7 @@ export interface CustomCommand { body: string; /** Hint shown in help, e.g. "". */ argumentHint?: string; - source: 'user' | 'project'; + source: 'user' | 'project' | 'plugin'; path: string; } @@ -28,16 +28,22 @@ export interface LoadSlashCommandsOpts { cwd: string; /** Override HOME (tests). */ home?: string; + /** Installed-plugin directories; each contributes `/commands/*.md`. */ + pluginDirs?: string[]; } /** - * Load custom commands from `~/.deepcode/commands/*.md` (user) then - * `/.deepcode/commands/*.md` (project). Project commands override user - * commands of the same name. + * Load custom commands from plugin `/commands/*.md`, then + * `~/.deepcode/commands/*.md` (user), then `/.deepcode/commands/*.md` + * (project). Precedence ascends plugin → user → project (later wins on a name + * clash) so a user/project command can override a plugin's. */ export async function loadSlashCommands(opts: LoadSlashCommandsOpts): Promise { const home = opts.home ?? homedir(); const collected: CustomCommand[] = []; + for (const dir of opts.pluginDirs ?? []) { + await loadFromDir(join(dir, 'commands'), 'plugin', collected); + } await loadFromDir(join(home, '.deepcode', 'commands'), 'user', collected); await loadFromDir(join(opts.cwd, '.deepcode', 'commands'), 'project', collected); // De-dupe by name; later (project) wins.