Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions apps/cli/src/headless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
resolveCredentials,
runAgent,
wirePlugins,
collectPluginContributions,
type AgentEvent,
type Effort,
type McpClientHandle,
Expand Down Expand Up @@ -145,10 +146,16 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
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 });
Expand All @@ -157,8 +164,9 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {

// ─── 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 ?? [],
});
Expand Down Expand Up @@ -276,6 +284,7 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
mode,
permissions: settings.permissions,
hooks,
pluginDirs: pluginContrib.dirs,
autoCompact: { contextWindow: contextWindowFor(model), threshold: 0.8 },
autoMode: settings.autoMode,
sandboxConfig: settings.sandbox,
Expand Down
23 changes: 19 additions & 4 deletions apps/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
runAgent,
settingsPaths,
wirePlugins,
collectPluginContributions,
type Effort,
type McpClientHandle,
type Mode,
Expand Down Expand Up @@ -146,8 +147,18 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
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({
Expand All @@ -161,6 +172,7 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
cwd,
home: opts.home,
builtinDir: builtinSkillsDir,
pluginDirs: pluginContrib.dirs,
overrides: settings.skillOverrides,
});
const styles = await loadOutputStyles({ cwd, home: opts.home });
Expand All @@ -180,10 +192,12 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
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,
Expand Down Expand Up @@ -438,6 +452,7 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
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,
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<dir>/agents/*.md`) in addition to user/project ones. */
pluginDirs?: string[];
}

/** Max sub-agent recursion: top-level (0) may spawn sub-agents (depth 1); those
Expand Down Expand Up @@ -219,7 +222,7 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
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)';
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export {
export {
installLocal,
discoverPlugins,
collectPluginContributions,
readManifest,
computeSourceHash,
loadTrustState,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
export {
installLocal,
discoverPlugins,
collectPluginContributions,
readManifest,
computeSourceHash,
loadTrustState,
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/plugins/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({});
});
});
21 changes: 21 additions & 0 deletions packages/core/src/plugins/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 `<dir>/{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<string, McpServerConfig> }> {
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<string, McpServerConfig> = {};
for (const p of enabled) {
const contributed = p.manifest.contributes?.mcpServers;
if (contributed) Object.assign(mcpServers, contributed as Record<string, McpServerConfig>);
}
return { dirs, mcpServers };
}

async function copyDirectory(src: string, dest: string): Promise<void> {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/slash-commands/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
});
14 changes: 10 additions & 4 deletions packages/core/src/slash-commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,30 @@ export interface CustomCommand {
body: string;
/** Hint shown in help, e.g. "<file>". */
argumentHint?: string;
source: 'user' | 'project';
source: 'user' | 'project' | 'plugin';
path: string;
}

export interface LoadSlashCommandsOpts {
cwd: string;
/** Override HOME (tests). */
home?: string;
/** Installed-plugin directories; each contributes `<dir>/commands/*.md`. */
pluginDirs?: string[];
}

/**
* Load custom commands from `~/.deepcode/commands/*.md` (user) then
* `<cwd>/.deepcode/commands/*.md` (project). Project commands override user
* commands of the same name.
* Load custom commands from plugin `<dir>/commands/*.md`, then
* `~/.deepcode/commands/*.md` (user), then `<cwd>/.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<CustomCommand[]> {
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.
Expand Down
Loading