diff --git a/apps/cli/src/list-cmd.test.ts b/apps/cli/src/list-cmd.test.ts index 145fd62..7b26105 100644 --- a/apps/cli/src/list-cmd.test.ts +++ b/apps/cli/src/list-cmd.test.ts @@ -61,7 +61,63 @@ describe('runPluginsCommand', () => { const out = sink(); const code = await runPluginsCommand(['frob'], { cwd, home, output: out.stream }); expect(code).toBe(2); - expect(out.text()).toMatch(/Usage: deepcode plugins list/); + expect(out.text()).toMatch(/Usage: deepcode plugins/); + }); + + it('installs a local plugin and then lists it as trusted', async () => { + // A local plugin source dir. + const src = join(cwd, 'my-plugin'); + await fs.mkdir(src, { recursive: true }); + await fs.writeFile( + join(src, 'plugin.json'), + JSON.stringify({ name: 'localdemo', version: '0.1.0', description: 'local one' }), + ); + const out = sink(); + const code = await runPluginsCommand(['install', src], { cwd, home, output: out.stream }); + expect(code).toBe(0); + expect(out.text()).toMatch(/Installed localdemo@0\.1\.0/); + + // Now it's installed + trusted → appears in the loaded list (not "Not loaded"). + const list = sink(); + await runPluginsCommand(['list'], { cwd, home, output: list.stream, json: true }); + const parsed = JSON.parse(list.text()) as { plugins: Array<{ name: string }> }; + expect(parsed.plugins.some((p) => p.name === 'localdemo')).toBe(true); + }); + + it('uninstalls an installed plugin', async () => { + const src = join(cwd, 'p2'); + await fs.mkdir(src, { recursive: true }); + await fs.writeFile(join(src, 'plugin.json'), JSON.stringify({ name: 'p2', version: '1.0.0' })); + await runPluginsCommand(['install', src], { cwd, home, output: sink().stream }); + + const out = sink(); + const code = await runPluginsCommand(['uninstall', 'p2'], { cwd, home, output: out.stream }); + expect(code).toBe(0); + expect(out.text()).toMatch(/Uninstalled p2/); + + const missing = sink(); + const code2 = await runPluginsCommand(['uninstall', 'p2'], { + cwd, + home, + output: missing.stream, + }); + expect(code2).toBe(1); + expect(missing.text()).toMatch(/No plugin named/); + }); + + it('install with no spec → usage exit 2; bad gh spec → error exit 1', async () => { + const noSpec = sink(); + expect(await runPluginsCommand(['install'], { cwd, home, errOutput: noSpec.stream })).toBe(2); + expect(noSpec.text()).toMatch(/Usage: deepcode plugins install/); + + const badGh = sink(); + const code = await runPluginsCommand(['install', 'gh:not-a-valid-spec!!'], { + cwd, + home, + errOutput: badGh.stream, + }); + expect(code).toBe(1); + expect(badGh.text()).toMatch(/Install failed.*Invalid GitHub spec/); }); }); diff --git a/apps/cli/src/list-cmd.ts b/apps/cli/src/list-cmd.ts index 311bfea..a4c6e5b 100644 --- a/apps/cli/src/list-cmd.ts +++ b/apps/cli/src/list-cmd.ts @@ -3,7 +3,16 @@ // scripting and the desktop app. // Spec: docs/DEVELOPMENT_PLAN.md §3.13 (skills) / §3.14 (plugins) -import { discoverPlugins, loadSettings, loadSkills } from '@deepcode/core'; +import { + discoverPlugins, + installFromGithub, + installFromNpm, + installLocal, + loadSettings, + loadSkills, + uninstallPlugin, +} from '@deepcode/core'; +import { resolve } from 'node:path'; import type { Writable } from 'node:stream'; import { resolveBuiltinSkillsDir } from './builtin-skills.js'; @@ -71,10 +80,16 @@ export async function listSkills(deps: ListCmdDeps): Promise { export async function runPluginsCommand(sub: string[], deps: ListCmdDeps): Promise { const out = deps.output ?? process.stdout; - if (sub[0] && sub[0] !== 'list') { - out.write('Usage: deepcode plugins list [--json]\n'); + const err = deps.errOutput ?? process.stderr; + const cmd = sub[0]; + + if (cmd === 'install') return pluginInstall(sub.slice(1), deps, out, err); + if (cmd === 'uninstall' || cmd === 'remove') return pluginUninstall(sub[1], deps, out, err); + if (cmd && cmd !== 'list') { + out.write('Usage: deepcode plugins [list [--json] | install | uninstall ]\n'); return 2; } + const { rows, issues } = await listPlugins(deps); if (deps.json || sub.includes('--json')) { out.write(JSON.stringify({ plugins: rows, issues }, null, 2) + '\n'); @@ -98,6 +113,50 @@ export async function runPluginsCommand(sub: string[], deps: ListCmdDeps): Promi return 0; } +async function pluginInstall( + args: string[], + deps: ListCmdDeps, + out: Writable, + err: Writable, +): Promise { + const spec = args[0]; + if (!spec) { + err.write( + 'Usage: deepcode plugins install @npm | ./local/path>\n', + ); + return 2; + } + try { + const installed = spec.startsWith('gh:') + ? await installFromGithub(spec, { home: deps.home }) + : /@npm$/.test(spec) + ? await installFromNpm(spec, { home: deps.home }) + : await installLocal({ sourcePath: resolve(deps.cwd, spec), home: deps.home }); + out.write( + `✓ Installed ${installed.manifest.name}@${installed.manifest.version} (trusted: user).\n`, + ); + return 0; + } catch (e) { + err.write(`Install failed: ${(e as Error).message}\n`); + return 1; + } +} + +async function pluginUninstall( + name: string | undefined, + deps: ListCmdDeps, + out: Writable, + err: Writable, +): Promise { + if (!name) { + err.write('Usage: deepcode plugins uninstall \n'); + return 2; + } + const removed = await uninstallPlugin(name, deps.home); + out.write(removed ? `✓ Uninstalled ${name}.\n` : `No plugin named "${name}".\n`); + return removed ? 0 : 1; +} + export async function runSkillsCommand(sub: string[], deps: ListCmdDeps): Promise { const out = deps.output ?? process.stdout; if (sub[0] && sub[0] !== 'list') { diff --git a/apps/cli/src/parse-args.ts b/apps/cli/src/parse-args.ts index 14b163f..ca7b58d 100644 --- a/apps/cli/src/parse-args.ts +++ b/apps/cli/src/parse-args.ts @@ -278,6 +278,8 @@ USAGE deepcode mcp serve Expose DeepCode tools as an MCP server (stdio) deepcode trust [--plan-only] Trust this directory's project config (hooks/MCP/...) deepcode plugins list [--json] List installed plugins + deepcode plugins install Install a plugin (gh:owner/repo | name@npm | ./path) + deepcode plugins uninstall Remove an installed plugin deepcode skills list [--json] List available skills MODE