Skip to content

Commit 5bc6d97

Browse files
bespoyasovclaude
andauthored
feat(cli): introduce plugin commands (#102)
* feat(core): extend integrations with uninstall plugin capability * feat(cli): introduce `plugin` commands * feat(core): extend plugin installation with `--scope` * feat(cli): declare `--scope` option for `plugin` commands * chore: update error message * test: update tests * fix(core): use detectPlugin to choose uninstall path The try/finally approach always attempted the CLI uninstall, causing download-only installs to report failure even after skills were removed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(core): only fall back to download when CLI binary is missing Bare catch blocks swallowed all CLI errors (network, already-installed) and silently dropped project-level skills even for --scope global/local. Now only ENOENT/ENOEXEC triggers the download fallback, and only for project scope. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(core): scope-aware skill removal on plugin uninstall Only remove local downloaded skills when the scope is project (or unset), or when detectPlugin returned 'download'. Previously --scope global/local would also delete the project's downloaded skills. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(core): remove Cursor marketplace entry on plugin uninstall installPlugin adds the marketplace repo before throwing, but uninstallPlugin was a no-op — leaving the entry on the machine. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: phrasing and validation * chore: fix merge artifacts * fix(core): always fall back to download when plugin install/update fails The guard was too strict — it only allowed download fallback for ENOENT/ENOEXEC, blocking the fallback when the CLI binary exists but the command itself fails (e.g. exit code 1). Remove the guard entirely so any install/update error triggers the download path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 318a585 commit 5bc6d97

34 files changed

Lines changed: 489 additions & 54 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { resolve } from 'node:path';
2+
import { prepareAuthTokens } from '@spotify-confidence/testing/auth';
3+
import { pluginCommand } from '@commands/plugin.js';
4+
import { captureOutput } from '../helpers/capture.js';
5+
import { createMockIntegration } from '../helpers/mock-integration.js';
6+
import { createRunner } from '../helpers/run-command.js';
7+
8+
const mockIntegration = createMockIntegration();
9+
10+
const mockInstallPlugin = vi.fn().mockResolvedValue('cli');
11+
const mockUpdatePlugin = vi.fn().mockResolvedValue('cli');
12+
const mockUninstallPlugin = vi.fn().mockResolvedValue(undefined);
13+
14+
vi.mock('@spotify-confidence/core', async (importOriginal) => {
15+
const actual = await importOriginal<typeof import('@spotify-confidence/core')>();
16+
return {
17+
...actual,
18+
getIntegration: () => mockIntegration,
19+
getIntegrations: () => [mockIntegration],
20+
installPlugin: (...args: unknown[]) => mockInstallPlugin(...args),
21+
updatePlugin: (...args: unknown[]) => mockUpdatePlugin(...args),
22+
uninstallPlugin: (...args: unknown[]) => mockUninstallPlugin(...args),
23+
};
24+
});
25+
26+
beforeEach(() => {
27+
vi.clearAllMocks();
28+
});
29+
30+
const run = createRunner(pluginCommand);
31+
32+
describe('plugin install', () => {
33+
it('installs the plugin for the specified IDE with default project scope', async () => {
34+
using _auth = prepareAuthTokens('none');
35+
using _output = captureOutput();
36+
37+
await run(['plugin', 'install', '--ide', 'claude']);
38+
39+
expect(mockInstallPlugin).toHaveBeenCalledWith('claude', process.cwd(), 'project');
40+
});
41+
42+
it('passes --dir as project directory', async () => {
43+
using _auth = prepareAuthTokens('none');
44+
using _output = captureOutput();
45+
46+
await run(['plugin', 'install', '--ide', 'claude', '--dir', 'some/relative/path']);
47+
48+
expect(mockInstallPlugin).toHaveBeenCalledWith(
49+
'claude',
50+
resolve('some/relative/path'),
51+
'project',
52+
);
53+
});
54+
55+
it('passes --scope to the installer', async () => {
56+
using _auth = prepareAuthTokens('none');
57+
using _output = captureOutput();
58+
59+
await run(['plugin', 'install', '--ide', 'claude', '--scope', 'global']);
60+
61+
expect(mockInstallPlugin).toHaveBeenCalledWith('claude', process.cwd(), 'global');
62+
});
63+
64+
it('reports failure to stderr', async () => {
65+
using _auth = prepareAuthTokens('none');
66+
using output = captureOutput();
67+
mockInstallPlugin.mockRejectedValueOnce(new Error('install failed'));
68+
69+
await run(['plugin', 'install', '--ide', 'claude']);
70+
71+
expect(output.stderr).toContain('install failed');
72+
});
73+
});
74+
75+
describe('plugin update', () => {
76+
it('updates the plugin for the specified IDE with default project scope', async () => {
77+
using _auth = prepareAuthTokens('none');
78+
using _output = captureOutput();
79+
80+
await run(['plugin', 'update', '--ide', 'claude']);
81+
82+
expect(mockUpdatePlugin).toHaveBeenCalledWith('claude', process.cwd(), 'project');
83+
});
84+
85+
it('passes --dir as project directory', async () => {
86+
using _auth = prepareAuthTokens('none');
87+
using _output = captureOutput();
88+
89+
await run(['plugin', 'update', '--ide', 'claude', '--dir', '/tmp/my-project']);
90+
91+
expect(mockUpdatePlugin).toHaveBeenCalledWith('claude', '/tmp/my-project', 'project');
92+
});
93+
94+
it('passes --scope to the updater', async () => {
95+
using _auth = prepareAuthTokens('none');
96+
using _output = captureOutput();
97+
98+
await run(['plugin', 'update', '--ide', 'claude', '--scope', 'local']);
99+
100+
expect(mockUpdatePlugin).toHaveBeenCalledWith('claude', process.cwd(), 'local');
101+
});
102+
103+
it('reports failure to stderr', async () => {
104+
using _auth = prepareAuthTokens('none');
105+
using output = captureOutput();
106+
mockUpdatePlugin.mockRejectedValueOnce(new Error('update failed'));
107+
108+
await run(['plugin', 'update', '--ide', 'claude']);
109+
110+
expect(output.stderr).toContain('update failed');
111+
});
112+
});
113+
114+
describe('plugin uninstall', () => {
115+
it('uninstalls the plugin for the specified IDE with default project scope', async () => {
116+
using _auth = prepareAuthTokens('none');
117+
using _output = captureOutput();
118+
119+
await run(['plugin', 'uninstall', '--ide', 'claude']);
120+
121+
expect(mockUninstallPlugin).toHaveBeenCalledWith('claude', process.cwd(), 'project');
122+
});
123+
124+
it('passes --dir as project directory', async () => {
125+
using _auth = prepareAuthTokens('none');
126+
using _output = captureOutput();
127+
128+
await run(['plugin', 'uninstall', '--ide', 'claude', '--dir', '/tmp/my-project']);
129+
130+
expect(mockUninstallPlugin).toHaveBeenCalledWith('claude', '/tmp/my-project', 'project');
131+
});
132+
133+
it('passes --scope to the uninstaller', async () => {
134+
using _auth = prepareAuthTokens('none');
135+
using _output = captureOutput();
136+
137+
await run(['plugin', 'uninstall', '--ide', 'claude', '--scope', 'global']);
138+
139+
expect(mockUninstallPlugin).toHaveBeenCalledWith('claude', process.cwd(), 'global');
140+
});
141+
142+
it('reports failure to stderr', async () => {
143+
using _auth = prepareAuthTokens('none');
144+
using output = captureOutput();
145+
mockUninstallPlugin.mockRejectedValueOnce(new Error('uninstall failed'));
146+
147+
await run(['plugin', 'uninstall', '--ide', 'claude']);
148+
149+
expect(output.stderr).toContain('uninstall failed');
150+
});
151+
});

‎packages/cli/__tests__/features/mcp/resolve-ide.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import select from '@inquirer/select';
1010

1111
describe('resolveIde', () => {
1212
async function loadResolveIde() {
13-
const mod = await import('@features/mcp/resolve-ide.js');
13+
const mod = await import('@features/ide/resolve-ide.js');
1414
return mod.resolveIde;
1515
}
1616

‎packages/cli/__tests__/helpers/mock-integration.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export function createMockIntegration(overrides?: Partial<IdeIntegration>): IdeI
1111
detectPlugin: vi.fn().mockResolvedValue(null),
1212
installPlugin: vi.fn().mockResolvedValue(undefined),
1313
updatePlugin: vi.fn().mockResolvedValue(undefined),
14+
uninstallPlugin: vi.fn().mockResolvedValue(undefined),
1415
detectMcpStatuses: vi.fn().mockResolvedValue({}),
1516
connectMcpServer: vi.fn().mockResolvedValue(undefined),
1617
disconnectMcpServer: vi.fn().mockResolvedValue(undefined),

‎packages/cli/bin/cli.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
quickstartCommand,
1414
mcpCommand,
1515
updateCommand,
16+
pluginCommand,
1617
migrateCommand,
1718
} from '../src/commands/index.js';
1819
import { APP_NAME, CLI_VERSION } from '@meta';
@@ -72,6 +73,7 @@ const cli = yargs(hideBin(process.argv))
7273
.command(quickstartCommand)
7374
.command(mcpCommand)
7475
.command(updateCommand)
76+
.command(pluginCommand)
7577
.command(migrateCommand)
7678
.example('$0 login', 'Sign in to Confidence')
7779
.example('$0 flags setup', 'Set up feature flags in your project')

‎packages/cli/src/commands/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export { flagsCommand } from './flags.js';
77
export { recordingsCommand } from './recordings.js';
88
export { quickstartCommand } from './quickstart.js';
99
export { mcpCommand } from './mcp.js';
10+
export { pluginCommand } from './plugin.js';
1011
export { updateCommand } from './update.js';
1112
export { migrateCommand } from './migrate.js';
1213
export type { GlobalFlags } from './types.js';

‎packages/cli/src/commands/mcp.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import type { Argv } from 'yargs';
22
import { print, fail, extractFlags } from '@output/print.js';
33
import { getAvailableMcpServers, type McpServerStatus } from '@spotify-confidence/core';
4+
import { resolveIde, resolveFlag, resolveProjectDir } from '@features/ide/index.js';
45
import {
5-
resolveIde,
6-
resolveFlag,
7-
resolveProjectDir,
86
installMcpServers,
97
uninstallMcpServers,
108
getMcpStatuses,
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { Argv } from 'yargs';
2+
import {
3+
resolveIde,
4+
resolveFlag,
5+
resolveProjectDir,
6+
resolveScope,
7+
requireClaudeForScope,
8+
} from '@features/ide/index.js';
9+
import {
10+
installPluginForIde,
11+
uninstallPluginForIde,
12+
updatePluginForIde,
13+
} from '@features/plugin/index.js';
14+
import { safely } from '@utils/safely.js';
15+
16+
export const pluginCommand = {
17+
command: 'plugin <action>',
18+
describe: 'Manage the Confidence AI plugin for your IDE',
19+
builder(yargs: Argv) {
20+
return yargs
21+
.option('dir', {
22+
type: 'string',
23+
describe: 'Target project directory',
24+
})
25+
.option('ide', {
26+
type: 'string',
27+
choices: ['claude', 'cursor', 'codex'] as const,
28+
describe: 'AI coding agent to configure',
29+
})
30+
.option('scope', {
31+
type: 'string',
32+
choices: ['project', 'local', 'global'] as const,
33+
describe: 'Installation scope (only supported for Claude Code)',
34+
})
35+
.command(
36+
'install',
37+
'Install the Confidence AI plugin for your AI coding agent',
38+
() => {},
39+
safely(installCmd),
40+
)
41+
.command(
42+
'update',
43+
'Update the Confidence AI plugin to the latest version',
44+
() => {},
45+
safely(updateCmd),
46+
)
47+
.command(
48+
'uninstall',
49+
'Remove the Confidence AI plugin from your AI coding agent',
50+
() => {},
51+
safely(uninstallCmd),
52+
)
53+
.demandCommand(1, 'Run "confidence plugin --help" to see available actions.')
54+
.strict();
55+
},
56+
handler() {},
57+
};
58+
59+
async function installCmd(argv: Record<string, unknown>): Promise<void> {
60+
const ideId = await resolveIde(resolveFlag('ide', argv));
61+
const scope = resolveScope(argv);
62+
requireClaudeForScope(ideId, scope);
63+
await installPluginForIde(ideId, resolveProjectDir(argv), scope);
64+
}
65+
66+
async function updateCmd(argv: Record<string, unknown>): Promise<void> {
67+
const ideId = await resolveIde(resolveFlag('ide', argv));
68+
const scope = resolveScope(argv);
69+
requireClaudeForScope(ideId, scope);
70+
await updatePluginForIde(ideId, resolveProjectDir(argv), scope);
71+
}
72+
73+
async function uninstallCmd(argv: Record<string, unknown>): Promise<void> {
74+
const ideId = await resolveIde(resolveFlag('ide', argv));
75+
const scope = resolveScope(argv);
76+
requireClaudeForScope(ideId, scope);
77+
await uninstallPluginForIde(ideId, resolveProjectDir(argv), scope);
78+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { resolve } from 'node:path';
2+
import type { IdeId, PluginScope } from '@spotify-confidence/shared-kernel';
3+
4+
export function resolveFlag(name: string, argv: Record<string, unknown>): string | undefined {
5+
return argv[name] as string | undefined;
6+
}
7+
8+
export function resolveProjectDir(argv: Record<string, unknown>): string {
9+
const dir = resolveFlag('dir', argv);
10+
return dir ? resolve(dir) : process.cwd();
11+
}
12+
13+
export function resolveScope(argv: Record<string, unknown>): PluginScope {
14+
return (resolveFlag('scope', argv) as PluginScope) ?? 'project';
15+
}
16+
17+
export function requireClaudeForScope(ideId: IdeId, scope: PluginScope): void {
18+
if (scope !== 'project' && ideId !== 'claude') {
19+
throw new Error(`--scope is only supported for Claude Code (got ${ideId}).`);
20+
}
21+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { resolveIde } from './resolve-ide.js';
2+
export { resolveFlag, resolveProjectDir, resolveScope, requireClaudeForScope } from './argv.js';

packages/cli/src/features/mcp/resolve-ide.ts renamed to packages/cli/src/features/ide/resolve-ide.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ const VALID_IDE_LIST = [...VALID_IDE_IDS].join(', ');
1313
export async function resolveIde(explicit?: string): Promise<IdeId> {
1414
if (explicit) return validateIdeId(explicit);
1515

16-
const saved = getConfigValue('ide') as IdeId | undefined;
17-
if (saved) return saved;
16+
const saved = getConfigValue('ide') as string | undefined;
17+
if (saved) return validateIdeId(saved);
1818

1919
return promptForIde();
2020
}

0 commit comments

Comments
 (0)