Skip to content

Commit 7506356

Browse files
bespoyasovclaude
andauthored
feat(cli): introduce sdk commands (#106)
* feat(cli): introduce `sdk` commands * feat(core): allow specifying prompt kind for chat sessions * chore(cli): start chat with user message * fix: correctly pass user prompt for Claude * test: update tests * fix(cli): add workspace root flags for pnpm and yarn in sdk install pnpm and yarn classic error when `add` runs at a workspace root without an explicit flag (`-w` / `-W`). Detect workspace markers and inject the flag automatically so `confidence sdk install` works from monorepo roots. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(cli): split sdk install into per-ecosystem modules Extract Node, Python, JVM, and native install builders into dedicated files so each ecosystem's PM detection and command construction is self-contained. Python now detects poetry/uv/pipenv and falls back to a manual install for bare pip (avoids PEP 668 and global-install issues). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: utilize project dir scaffolding * fix(core): type FrameworkConfig.id as a FrameworkId union The `id` field was typed as `string`, making the `as never` cast in exhaustive switches a no-op — new frameworks compiled without errors and threw at runtime. Now `FrameworkId` is a union of known IDs so the compiler catches missing cases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): use resolveProjectDir in sdk install Align --dir handling with sdk setup by using the shared resolveProjectDir helper instead of reading argv.dir directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): derive Maven groupId/artifactId from sdkPackage The Maven snippet hardcoded groupId and artifactId and used an inconsistent VERSION placeholder. Split sdkPackage on ':' so the snippet stays in sync if the package coordinates change. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): point sdk setup prompt at confidence-docs MCP tool The prompt referenced the analyze-project skill, which proposes feature flag changes — contradicting the "only set up the SDK" instruction. Use getCodeSnippetAndSdkIntegrationTips from confidence-docs instead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): don't assume app/ module in Kotlin install snippet 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 6d191c9 commit 7506356

21 files changed

Lines changed: 520 additions & 14 deletions

File tree

‎packages/cli/__tests__/commands/migrate.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ describe('migrate <provider>', () => {
8989

9090
expect(mockIntegration.launchChat).toHaveBeenCalledWith(
9191
expect.objectContaining({
92-
prompt: expect.stringContaining('Statsig'),
92+
userPrompt: expect.stringContaining('Statsig'),
9393
cwd: process.cwd(),
9494
}),
9595
);
@@ -101,9 +101,9 @@ describe('migrate <provider>', () => {
101101

102102
await run(['migrate', 'statsig', '--ide', 'claude']);
103103

104-
const { prompt } = vi.mocked(mockIntegration.launchChat).mock.calls[0][0];
105-
expect(prompt).toContain('migrate-statsig');
106-
expect(prompt).toContain('(statsig)');
104+
const { userPrompt } = vi.mocked(mockIntegration.launchChat).mock.calls[0][0];
105+
expect(userPrompt).toContain('migrate-statsig');
106+
expect(userPrompt).toContain('(statsig)');
107107
});
108108

109109
it('fails when plugin is not installed', async () => {
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { writeFileSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
import type { FrameworkConfig } from '@spotify-confidence/core';
4+
import { createProjectDir } from '@spotify-confidence/testing/scaffold';
5+
import { runSdkInstall } from '@features/sdk/install.js';
6+
7+
const mockExecFile = vi.fn<() => Promise<void>>();
8+
const mockDetectFramework = vi.fn<() => Promise<FrameworkConfig | null>>();
9+
const mockMessage = vi.fn();
10+
11+
vi.mock('ora', () => ({
12+
default: () => ({ start: () => ({ succeed: vi.fn(), fail: vi.fn() }) }),
13+
}));
14+
15+
vi.mock('@spotify-confidence/core', async (importOriginal) => {
16+
const actual = await importOriginal<typeof import('@spotify-confidence/core')>();
17+
return {
18+
...actual,
19+
detectFramework: (...args: unknown[]) => mockDetectFramework(...(args as [])),
20+
execFile: (...args: unknown[]) => mockExecFile(...(args as [])),
21+
};
22+
});
23+
24+
vi.mock('@output/print.js', () => ({
25+
message: (...args: unknown[]) => mockMessage(...args),
26+
fail: vi.fn(),
27+
}));
28+
29+
const REACT_FRAMEWORK: FrameworkConfig = {
30+
id: 'react',
31+
name: 'React',
32+
docsUrl: 'https://confidence.spotify.com/docs/sdk/react',
33+
sdkPackage: '@spotify-confidence/sdk',
34+
detect: async () => true,
35+
};
36+
37+
const PYTHON_FRAMEWORK: FrameworkConfig = {
38+
id: 'python',
39+
name: 'Python',
40+
docsUrl: 'https://confidence.spotify.com/docs/sdk/python',
41+
sdkPackage: 'spotify-confidence-sdk',
42+
detect: async () => true,
43+
};
44+
45+
beforeEach(() => {
46+
mockExecFile.mockResolvedValue(undefined);
47+
mockDetectFramework.mockResolvedValue(REACT_FRAMEWORK);
48+
});
49+
50+
describe('runSdkInstall workspace root handling', () => {
51+
it('passes -w when pnpm-workspace.yaml is present', async () => {
52+
using project = createProjectDir('empty');
53+
writeFileSync(join(project.path, 'pnpm-lock.yaml'), '');
54+
writeFileSync(join(project.path, 'pnpm-workspace.yaml'), 'packages:\n - packages/*');
55+
56+
await runSdkInstall({ dir: project.path });
57+
58+
expect(mockExecFile).toHaveBeenCalledWith(
59+
'pnpm',
60+
['add', '-w', '@spotify-confidence/sdk'],
61+
expect.objectContaining({ cwd: project.path }),
62+
);
63+
});
64+
65+
it('omits -w for pnpm without workspace config', async () => {
66+
using project = createProjectDir('empty');
67+
writeFileSync(join(project.path, 'pnpm-lock.yaml'), '');
68+
69+
await runSdkInstall({ dir: project.path });
70+
71+
expect(mockExecFile).toHaveBeenCalledWith(
72+
'pnpm',
73+
['add', '@spotify-confidence/sdk'],
74+
expect.objectContaining({ cwd: project.path }),
75+
);
76+
});
77+
78+
it('passes -W for yarn classic with workspaces', async () => {
79+
using project = createProjectDir('empty');
80+
writeFileSync(join(project.path, 'yarn.lock'), '');
81+
writeFileSync(
82+
join(project.path, 'package.json'),
83+
JSON.stringify({ workspaces: ['packages/*'] }),
84+
);
85+
86+
await runSdkInstall({ dir: project.path });
87+
88+
expect(mockExecFile).toHaveBeenCalledWith(
89+
'yarn',
90+
['add', '-W', '@spotify-confidence/sdk'],
91+
expect.objectContaining({ cwd: project.path }),
92+
);
93+
});
94+
95+
it('omits -W for yarn berry with workspaces', async () => {
96+
using project = createProjectDir('empty');
97+
writeFileSync(join(project.path, 'yarn.lock'), '');
98+
writeFileSync(
99+
join(project.path, 'package.json'),
100+
JSON.stringify({ workspaces: ['packages/*'] }),
101+
);
102+
writeFileSync(join(project.path, '.yarnrc.yml'), 'nodeLinker: node-modules');
103+
104+
await runSdkInstall({ dir: project.path });
105+
106+
expect(mockExecFile).toHaveBeenCalledWith(
107+
'yarn',
108+
['add', '@spotify-confidence/sdk'],
109+
expect.objectContaining({ cwd: project.path }),
110+
);
111+
});
112+
113+
it('omits -W for yarn without workspaces', async () => {
114+
using project = createProjectDir('empty');
115+
writeFileSync(join(project.path, 'yarn.lock'), '');
116+
117+
await runSdkInstall({ dir: project.path });
118+
119+
expect(mockExecFile).toHaveBeenCalledWith(
120+
'yarn',
121+
['add', '@spotify-confidence/sdk'],
122+
expect.objectContaining({ cwd: project.path }),
123+
);
124+
});
125+
126+
it('does not add flags for npm at a workspace root', async () => {
127+
using project = createProjectDir('empty');
128+
writeFileSync(
129+
join(project.path, 'package.json'),
130+
JSON.stringify({ workspaces: ['packages/*'] }),
131+
);
132+
133+
await runSdkInstall({ dir: project.path });
134+
135+
expect(mockExecFile).toHaveBeenCalledWith(
136+
'npm',
137+
['add', '@spotify-confidence/sdk'],
138+
expect.objectContaining({ cwd: project.path }),
139+
);
140+
});
141+
142+
it('does not add flags for bun at a workspace root', async () => {
143+
using project = createProjectDir('empty');
144+
writeFileSync(join(project.path, 'bun.lockb'), '');
145+
writeFileSync(
146+
join(project.path, 'package.json'),
147+
JSON.stringify({ workspaces: ['packages/*'] }),
148+
);
149+
150+
await runSdkInstall({ dir: project.path });
151+
152+
expect(mockExecFile).toHaveBeenCalledWith(
153+
'bun',
154+
['add', '@spotify-confidence/sdk'],
155+
expect.objectContaining({ cwd: project.path }),
156+
);
157+
});
158+
});
159+
160+
describe('runSdkInstall Python package manager detection', () => {
161+
beforeEach(() => {
162+
mockDetectFramework.mockResolvedValue(PYTHON_FRAMEWORK);
163+
});
164+
165+
it('uses poetry add when poetry.lock is present', async () => {
166+
using project = createProjectDir('empty');
167+
writeFileSync(join(project.path, 'poetry.lock'), '');
168+
169+
await runSdkInstall({ dir: project.path });
170+
171+
expect(mockExecFile).toHaveBeenCalledWith(
172+
'poetry',
173+
['add', 'spotify-confidence-sdk'],
174+
expect.objectContaining({ cwd: project.path }),
175+
);
176+
});
177+
178+
it('uses uv add when uv.lock is present', async () => {
179+
using project = createProjectDir('empty');
180+
writeFileSync(join(project.path, 'uv.lock'), '');
181+
182+
await runSdkInstall({ dir: project.path });
183+
184+
expect(mockExecFile).toHaveBeenCalledWith(
185+
'uv',
186+
['add', 'spotify-confidence-sdk'],
187+
expect.objectContaining({ cwd: project.path }),
188+
);
189+
});
190+
191+
it('uses pipenv install when Pipfile is present', async () => {
192+
using project = createProjectDir('empty');
193+
writeFileSync(join(project.path, 'Pipfile'), '');
194+
195+
await runSdkInstall({ dir: project.path });
196+
197+
expect(mockExecFile).toHaveBeenCalledWith(
198+
'pipenv',
199+
['install', 'spotify-confidence-sdk'],
200+
expect.objectContaining({ cwd: project.path }),
201+
);
202+
});
203+
204+
it('falls back to manual install when no Python PM is detected', async () => {
205+
using project = createProjectDir('empty');
206+
207+
await runSdkInstall({ dir: project.path });
208+
209+
expect(mockExecFile).not.toHaveBeenCalled();
210+
expect(mockMessage).toHaveBeenCalledWith(expect.stringContaining('pip install'));
211+
});
212+
});

‎packages/cli/bin/cli.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
eventsCommand,
1212
flagsCommand,
1313
recordingsCommand,
14+
sdkCommand,
1415
quickstartCommand,
1516
mcpCommand,
1617
updateCommand,
@@ -72,6 +73,7 @@ const cli = yargs(hideBin(process.argv))
7273
.command(eventsCommand)
7374
.command(flagsCommand)
7475
.command(recordingsCommand)
76+
.command(sdkCommand)
7577
.command(quickstartCommand)
7678
.command(mcpCommand)
7779
.command(updateCommand)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export { docsCommand } from './docs.js';
66
export { eventsCommand } from './events.js';
77
export { flagsCommand } from './flags.js';
88
export { recordingsCommand } from './recordings.js';
9+
export { sdkCommand } from './sdk.js';
910
export { quickstartCommand } from './quickstart.js';
1011
export { mcpCommand } from './mcp.js';
1112
export { pluginCommand } from './plugin.js';

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { Argv } from 'yargs';
2+
import { runSdkInstall, runSdkSetup } from '@features/sdk/index.js';
3+
import { safely } from '../utils/safely.js';
4+
5+
export const sdkCommand = {
6+
command: 'sdk <action>',
7+
describe: 'Manage SDK installation and setup',
8+
builder(yargs: Argv) {
9+
return yargs
10+
.option('dir', {
11+
type: 'string',
12+
describe: 'Target project directory',
13+
normalize: true,
14+
})
15+
.command(
16+
'install',
17+
'Detect framework and install the Confidence SDK',
18+
() => {},
19+
safely(runSdkInstall),
20+
)
21+
.command(
22+
'setup',
23+
'Set up the Confidence SDK in your project',
24+
(y: Argv) =>
25+
y
26+
.option('ide', {
27+
type: 'string',
28+
choices: ['claude', 'cursor', 'codex'] as const,
29+
describe: 'AI coding agent to use',
30+
})
31+
.option('profile', {
32+
type: 'string',
33+
describe: 'Auth profile to use',
34+
}),
35+
safely(runSdkSetup),
36+
)
37+
.demandCommand(1, 'Run "confidence sdk --help" to see available actions.')
38+
.strict();
39+
},
40+
handler() {},
41+
};

‎packages/cli/src/features/migrate/launch.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,6 @@ export async function launchMigration(
3535
const token = await resolveAuthToken({ profile: resolveFlag('profile', argv) });
3636
if (!token) return;
3737

38-
const prompt = buildMigrationPrompt(provider);
39-
integration.launchChat({ prompt, cwd: projectDir, token });
38+
const userPrompt = buildMigrationPrompt(provider);
39+
integration.launchChat({ userPrompt, cwd: projectDir, token });
4040
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { runSdkInstall } from './install.js';
2+
export { runSdkSetup } from './setup.js';
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { InstallCommand } from './install-types.js';
2+
3+
export function buildKotlinInstall(pkg: string): InstallCommand {
4+
return {
5+
type: 'manual',
6+
snippet:
7+
`Add the following to your module's build.gradle.kts:\n\n` +
8+
` dependencies {\n` +
9+
` implementation("${pkg}:<version>")\n` +
10+
` }`,
11+
};
12+
}
13+
14+
export function buildJavaInstall(pkg: string): InstallCommand {
15+
const [groupId, artifactId] = pkg.split(':');
16+
return {
17+
type: 'manual',
18+
snippet:
19+
`Add the following to your build.gradle.kts or pom.xml:\n\n` +
20+
` Gradle:\n` +
21+
` implementation("${pkg}:<version>")\n\n` +
22+
` Maven:\n` +
23+
` <dependency>\n` +
24+
` <groupId>${groupId}</groupId>\n` +
25+
` <artifactId>${artifactId}</artifactId>\n` +
26+
` <version><version></version>\n` +
27+
` </dependency>`,
28+
};
29+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { InstallCommand } from './install-types.js';
2+
3+
export function buildGoInstall(pkg: string): InstallCommand {
4+
return { type: 'auto', cmd: 'go', args: ['get', pkg] };
5+
}
6+
7+
export function buildSwiftInstall(pkg: string): InstallCommand {
8+
return {
9+
type: 'manual',
10+
snippet:
11+
`Add the following to your Package.swift dependencies:\n\n` +
12+
` .package(\n` +
13+
` url: "https://github.com/spotify/${pkg}.git",\n` +
14+
` from: "<version>"\n` +
15+
` )`,
16+
};
17+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { existsSync, readFileSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
import type { InstallCommand } from './install-types.js';
4+
5+
type NodePM = 'npm' | 'pnpm' | 'yarn' | 'bun';
6+
7+
function detectNodePM(dir: string): NodePM {
8+
if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
9+
if (existsSync(join(dir, 'yarn.lock'))) return 'yarn';
10+
if (existsSync(join(dir, 'bun.lockb')) || existsSync(join(dir, 'bun.lock'))) return 'bun';
11+
return 'npm';
12+
}
13+
14+
function hasPackageJsonWorkspaces(dir: string): boolean {
15+
const pkgPath = join(dir, 'package.json');
16+
if (!existsSync(pkgPath)) return false;
17+
18+
try {
19+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
20+
return Array.isArray(pkg.workspaces) || typeof pkg.workspaces === 'object';
21+
} catch {
22+
return false;
23+
}
24+
}
25+
26+
function workspaceRootArgs(pm: NodePM, dir: string): string[] {
27+
if (pm === 'pnpm' && existsSync(join(dir, 'pnpm-workspace.yaml'))) return ['-w'];
28+
if (pm === 'yarn' && hasPackageJsonWorkspaces(dir) && !existsSync(join(dir, '.yarnrc.yml'))) {
29+
return ['-W'];
30+
}
31+
32+
return [];
33+
}
34+
35+
export function buildNodeInstall(pkg: string, dir: string): InstallCommand {
36+
const pm = detectNodePM(dir);
37+
return { type: 'auto', cmd: pm, args: ['add', ...workspaceRootArgs(pm, dir), pkg] };
38+
}

0 commit comments

Comments
 (0)