Skip to content

Commit cdce610

Browse files
oratisclaude
andauthored
feat(plugins): wire contributed skills / commands / agents / mcpServers (#132)
Plugins could contribute 7 things but only hooks were wired into the live session; contributed skills/commands/agents/mcpServers were parsed and ignored. Now the trusted+enabled plugins' contributions load: core: - collectPluginContributions({home,disabled}) → { dirs, mcpServers } for the trusted+enabled plugins (untrusted ones excluded — same security gate as discoverPlugins). One place both hosts reuse. - loadSlashCommands gains `pluginDirs` (+ a 'plugin' command source); plugin `<dir>/commands/*.md` load, overridable by user/project. - runAgent gains `pluginDirs`, threaded into the Task tool's loadSubAgents so plugin-bundled sub-agents resolve. (loadSkills already supported pluginDirs.) cli (repl.ts + headless.ts): - collect contributions early; pass pluginDirs to loadSkills + loadSlashCommands + runAgent; merge plugin-contributed mcpServers into the MCP connect set (user settings win on a name clash). Now wired: hooks + skills + commands + sub-agents + mcpServers (5 of 7). statusLines + modes remain (they need live registries that don't exist yet). Tests: +3 — loadSlashCommands plugin source (+ project override), collectPluginContributions returns dirs+mcpServers for trusted / excludes untrusted. Core 604 green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6538d5f commit cdce610

9 files changed

Lines changed: 117 additions & 11 deletions

File tree

apps/cli/src/headless.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
resolveCredentials,
4242
runAgent,
4343
wirePlugins,
44+
collectPluginContributions,
4445
type AgentEvent,
4546
type Effort,
4647
type McpClientHandle,
@@ -145,10 +146,16 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
145146
maxBytes: (settings.memoryLoadCapKB ?? 100) * 1024,
146147
});
147148
const builtinSkillsDir = await resolveBuiltinSkillsDir();
149+
// Trusted+enabled plugins contribute skills / sub-agents (dirs) + MCP servers.
150+
const pluginContrib = await collectPluginContributions({
151+
home: opts.home,
152+
disabled: settings.disabledPlugins,
153+
});
148154
const skills = await loadSkills({
149155
cwd,
150156
home: opts.home,
151157
builtinDir: builtinSkillsDir,
158+
pluginDirs: pluginContrib.dirs,
152159
overrides: settings.skillOverrides,
153160
});
154161
const styles = await loadOutputStyles({ cwd, home: opts.home });
@@ -157,8 +164,9 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
157164

158165
// ─── MCP ─────────────────────────────────────────────────────────────
159166
let mcpServers: McpClientHandle[] = [];
160-
if (settings.mcpServers && Object.keys(settings.mcpServers).length > 0) {
161-
const r = await connectAllMcpServers(settings.mcpServers, {
167+
const allMcpServers = { ...pluginContrib.mcpServers, ...(settings.mcpServers ?? {}) };
168+
if (Object.keys(allMcpServers).length > 0) {
169+
const r = await connectAllMcpServers(allMcpServers, {
162170
enabledOnly: settings.enabledMcpjsonServers,
163171
disabled: settings.disabledMcpjsonServers ?? [],
164172
});
@@ -276,6 +284,7 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
276284
mode,
277285
permissions: settings.permissions,
278286
hooks,
287+
pluginDirs: pluginContrib.dirs,
279288
autoCompact: { contextWindow: contextWindowFor(model), threshold: 0.8 },
280289
autoMode: settings.autoMode,
281290
sandboxConfig: settings.sandbox,

apps/cli/src/repl.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
runAgent,
3939
settingsPaths,
4040
wirePlugins,
41+
collectPluginContributions,
4142
type Effort,
4243
type McpClientHandle,
4344
type Mode,
@@ -146,8 +147,18 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
146147
tools = new ToolRegistry();
147148
}
148149
const commands = new CommandRegistry();
149-
// Custom prompt-template commands from .deepcode/commands/*.md (user + project).
150-
const customCommands = await loadSlashCommands({ cwd, home: opts.home });
150+
// Trusted+enabled plugins contribute skills / sub-agents / commands (their
151+
// dirs) + MCP servers. Hooks are merged separately by wirePlugins.
152+
const pluginContrib = await collectPluginContributions({
153+
home: opts.home,
154+
disabled: settings.disabledPlugins,
155+
});
156+
// Custom prompt-template commands from plugin + user + project commands dirs.
157+
const customCommands = await loadSlashCommands({
158+
cwd,
159+
home: opts.home,
160+
pluginDirs: pluginContrib.dirs,
161+
});
151162

152163
// M5: load memory, skills, output style — assemble final system prompt
153164
const memory = await loadMemory({
@@ -161,6 +172,7 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
161172
cwd,
162173
home: opts.home,
163174
builtinDir: builtinSkillsDir,
175+
pluginDirs: pluginContrib.dirs,
164176
overrides: settings.skillOverrides,
165177
});
166178
const styles = await loadOutputStyles({ cwd, home: opts.home });
@@ -180,10 +192,12 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
180192
const elicitHolder: { fn?: McpElicitHandler } = {};
181193
const elicitForServers: McpElicitHandler = (req) =>
182194
elicitHolder.fn ? elicitHolder.fn(req) : Promise.resolve({ action: 'cancel' });
183-
if (settings.mcpServers && Object.keys(settings.mcpServers).length > 0) {
195+
// Plugin-contributed MCP servers + the user's settings (user wins on a clash).
196+
const allMcpServers = { ...pluginContrib.mcpServers, ...(settings.mcpServers ?? {}) };
197+
if (Object.keys(allMcpServers).length > 0) {
184198
const enabled = settings.enabledMcpjsonServers;
185199
const disabled = settings.disabledMcpjsonServers ?? [];
186-
const result = await connectAllMcpServers(settings.mcpServers, {
200+
const result = await connectAllMcpServers(allMcpServers, {
187201
enabledOnly: enabled,
188202
disabled,
189203
elicit: elicitForServers,
@@ -438,6 +452,7 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
438452
mode: ctx.mode as Mode,
439453
permissions: settings.permissions,
440454
hooks,
455+
pluginDirs: pluginContrib.dirs,
441456
autoCompact: { contextWindow: contextWindowFor(ctx.model), threshold: 0.8 },
442457
autoMode: settings.autoMode,
443458
sandboxConfig: settings.sandbox,

packages/core/src/agent.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ export interface RunAgentOptions {
8888
* Sub-agents run at depth 1 and are NOT given a runSubAgent, so they can't
8989
* spawn further sub-agents. */
9090
subAgentDepth?: number;
91+
/** Installed-plugin directories — so the Task tool can resolve plugin-bundled
92+
* sub-agents (`<dir>/agents/*.md`) in addition to user/project ones. */
93+
pluginDirs?: string[];
9194
}
9295

9396
/** 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<RunAgentResult> {
219222
const { loadSubAgents, findSubAgent } = (await import(
220223
mod
221224
)) as typeof import('./sub-agents/index.js');
222-
const agents = await loadSubAgents({ cwd: opts.cwd });
225+
const agents = await loadSubAgents({ cwd: opts.cwd, pluginDirs: opts.pluginDirs });
223226
const found = agentType ? findSubAgent(agents, agentType) : undefined;
224227
if (agentType && !found) {
225228
const names = agents.map((a) => a.qualifiedName).join(', ') || '(none)';

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ export {
229229
export {
230230
installLocal,
231231
discoverPlugins,
232+
collectPluginContributions,
232233
readManifest,
233234
computeSourceHash,
234235
loadTrustState,

packages/core/src/plugins/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
export {
2424
installLocal,
2525
discoverPlugins,
26+
collectPluginContributions,
2627
readManifest,
2728
computeSourceHash,
2829
loadTrustState,

packages/core/src/plugins/manifest.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
44
import { join } from 'node:path';
55
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
66
import {
7+
collectPluginContributions,
78
computeSourceHash,
89
discoverPlugins,
910
installLocal,
@@ -154,4 +155,34 @@ describe('plugin manifest', () => {
154155
expect(r.plugins).toHaveLength(0);
155156
expect(r.hashMismatches[0]).toMatch(/not in trust manifest/);
156157
});
158+
159+
it('collectPluginContributions returns dirs + mcpServers for trusted plugins', async () => {
160+
await fakePlugin(src, {
161+
name: 'contrib',
162+
version: '1.0.0',
163+
contributes: { mcpServers: { svc: { command: 'node', args: ['s.js'] } } },
164+
});
165+
const installed = await installLocal({ sourcePath: src, home });
166+
167+
const { dirs, mcpServers } = await collectPluginContributions({ home });
168+
expect(dirs).toContain(installed.path);
169+
expect(mcpServers.svc).toEqual({ command: 'node', args: ['s.js'] });
170+
});
171+
172+
it('collectPluginContributions excludes untrusted plugins', async () => {
173+
// Plugin on disk but never installed/trusted → not contributed.
174+
const dir = join(home, '.deepcode', 'plugins', 'untrusted');
175+
await fs.mkdir(dir, { recursive: true });
176+
await fs.writeFile(
177+
join(dir, 'plugin.json'),
178+
JSON.stringify({
179+
name: 'untrusted',
180+
version: '1.0.0',
181+
contributes: { mcpServers: { x: {} } },
182+
}),
183+
);
184+
const { dirs, mcpServers } = await collectPluginContributions({ home });
185+
expect(dirs).toEqual([]);
186+
expect(mcpServers).toEqual({});
187+
});
157188
});

packages/core/src/plugins/manifest.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { promises as fs } from 'node:fs';
1414
import { createHash } from 'node:crypto';
1515
import { homedir } from 'node:os';
1616
import { join } from 'node:path';
17+
import type { McpServerConfig } from '../config/types.js';
1718

1819
export interface PluginManifest {
1920
name: string;
@@ -208,6 +209,26 @@ export async function discoverPlugins(opts: DiscoverOptions = {}): Promise<{
208209
return { plugins: out, hashMismatches };
209210
}
210211

212+
/**
213+
* Collect the live contributions of trusted+enabled plugins for the host to
214+
* wire in: their directories (for skill / sub-agent / command loaders, which
215+
* read `<dir>/{skills,agents,commands}`) and their contributed `mcpServers`.
216+
* Hooks are merged separately by wirePlugins (it needs the live dispatcher).
217+
*/
218+
export async function collectPluginContributions(
219+
opts: { home?: string; disabled?: string[] } = {},
220+
): Promise<{ dirs: string[]; mcpServers: Record<string, McpServerConfig> }> {
221+
const { plugins } = await discoverPlugins({ home: opts.home, disabled: opts.disabled });
222+
const enabled = plugins.filter((p) => p.enabled);
223+
const dirs = enabled.map((p) => p.path);
224+
const mcpServers: Record<string, McpServerConfig> = {};
225+
for (const p of enabled) {
226+
const contributed = p.manifest.contributes?.mcpServers;
227+
if (contributed) Object.assign(mcpServers, contributed as Record<string, McpServerConfig>);
228+
}
229+
return { dirs, mcpServers };
230+
}
231+
211232
async function copyDirectory(src: string, dest: string): Promise<void> {
212233
await fs.mkdir(dest, { recursive: true });
213234
const entries = await fs.readdir(src, { withFileTypes: true });

packages/core/src/slash-commands/index.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,23 @@ describe('loadSlashCommands', () => {
7878
const cmds = await loadSlashCommands({ cwd, home });
7979
expect(cmds.map((c) => c.name)).toEqual(['/real']);
8080
});
81+
82+
it('loads plugin-contributed commands (overridable by user/project)', async () => {
83+
const pluginDir = await mkdtemp(join(tmpdir(), 'dc-plug-'));
84+
try {
85+
await mkdir(join(pluginDir, 'commands'), { recursive: true });
86+
await writeFile(join(pluginDir, 'commands', 'pcmd.md'), 'plugin body');
87+
await writeFile(join(pluginDir, 'commands', 'shared.md'), 'plugin shared');
88+
// A project command of the same name as a plugin one overrides it.
89+
await mkdir(join(cwd, '.deepcode', 'commands'), { recursive: true });
90+
await writeFile(join(cwd, '.deepcode', 'commands', 'shared.md'), 'project shared');
91+
92+
const cmds = await loadSlashCommands({ cwd, home, pluginDirs: [pluginDir] });
93+
expect(findCustomCommand(cmds, '/pcmd')?.source).toBe('plugin');
94+
// project wins on the name clash
95+
expect(findCustomCommand(cmds, '/shared')?.source).toBe('project');
96+
} finally {
97+
await rm(pluginDir, { recursive: true, force: true });
98+
}
99+
});
81100
});

packages/core/src/slash-commands/index.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,30 @@ export interface CustomCommand {
2020
body: string;
2121
/** Hint shown in help, e.g. "<file>". */
2222
argumentHint?: string;
23-
source: 'user' | 'project';
23+
source: 'user' | 'project' | 'plugin';
2424
path: string;
2525
}
2626

2727
export interface LoadSlashCommandsOpts {
2828
cwd: string;
2929
/** Override HOME (tests). */
3030
home?: string;
31+
/** Installed-plugin directories; each contributes `<dir>/commands/*.md`. */
32+
pluginDirs?: string[];
3133
}
3234

3335
/**
34-
* Load custom commands from `~/.deepcode/commands/*.md` (user) then
35-
* `<cwd>/.deepcode/commands/*.md` (project). Project commands override user
36-
* commands of the same name.
36+
* Load custom commands from plugin `<dir>/commands/*.md`, then
37+
* `~/.deepcode/commands/*.md` (user), then `<cwd>/.deepcode/commands/*.md`
38+
* (project). Precedence ascends plugin → user → project (later wins on a name
39+
* clash) so a user/project command can override a plugin's.
3740
*/
3841
export async function loadSlashCommands(opts: LoadSlashCommandsOpts): Promise<CustomCommand[]> {
3942
const home = opts.home ?? homedir();
4043
const collected: CustomCommand[] = [];
44+
for (const dir of opts.pluginDirs ?? []) {
45+
await loadFromDir(join(dir, 'commands'), 'plugin', collected);
46+
}
4147
await loadFromDir(join(home, '.deepcode', 'commands'), 'user', collected);
4248
await loadFromDir(join(opts.cwd, '.deepcode', 'commands'), 'project', collected);
4349
// De-dupe by name; later (project) wins.

0 commit comments

Comments
 (0)