From 43c4111ee08d5f24ebb13364473d749a78fc323a Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Tue, 6 Oct 2026 10:48:19 +0200 Subject: [PATCH 01/13] feat(cli): introduce `sdk` commands --- packages/cli/bin/cli.ts | 2 + packages/cli/src/commands/index.ts | 1 + packages/cli/src/commands/sdk.ts | 41 ++++++++ packages/cli/src/features/sdk/index.ts | 2 + packages/cli/src/features/sdk/install.ts | 113 +++++++++++++++++++++++ packages/cli/src/features/sdk/setup.ts | 49 ++++++++++ 6 files changed, 208 insertions(+) create mode 100644 packages/cli/src/commands/sdk.ts create mode 100644 packages/cli/src/features/sdk/index.ts create mode 100644 packages/cli/src/features/sdk/install.ts create mode 100644 packages/cli/src/features/sdk/setup.ts diff --git a/packages/cli/bin/cli.ts b/packages/cli/bin/cli.ts index 9285e46..bc5f061 100644 --- a/packages/cli/bin/cli.ts +++ b/packages/cli/bin/cli.ts @@ -10,6 +10,7 @@ import { eventsCommand, flagsCommand, recordingsCommand, + sdkCommand, quickstartCommand, mcpCommand, updateCommand, @@ -70,6 +71,7 @@ const cli = yargs(hideBin(process.argv)) .command(eventsCommand) .command(flagsCommand) .command(recordingsCommand) + .command(sdkCommand) .command(quickstartCommand) .command(mcpCommand) .command(updateCommand) diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 6fb4489..1eeff33 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -5,6 +5,7 @@ export { configCommand } from './config.js'; export { eventsCommand } from './events.js'; export { flagsCommand } from './flags.js'; export { recordingsCommand } from './recordings.js'; +export { sdkCommand } from './sdk.js'; export { quickstartCommand } from './quickstart.js'; export { mcpCommand } from './mcp.js'; export { pluginCommand } from './plugin.js'; diff --git a/packages/cli/src/commands/sdk.ts b/packages/cli/src/commands/sdk.ts new file mode 100644 index 0000000..10ecb06 --- /dev/null +++ b/packages/cli/src/commands/sdk.ts @@ -0,0 +1,41 @@ +import type { Argv } from 'yargs'; +import { runSdkInstall, runSdkSetup } from '@features/sdk/index.js'; +import { safely } from '../utils/safely.js'; + +export const sdkCommand = { + command: 'sdk ', + describe: 'Manage SDK installation and setup', + builder(yargs: Argv) { + return yargs + .option('dir', { + type: 'string', + describe: 'Target project directory', + normalize: true, + }) + .command( + 'install', + 'Detect framework and install the Confidence SDK', + () => {}, + safely(runSdkInstall), + ) + .command( + 'setup', + 'Set up the Confidence SDK in your project', + (y: Argv) => + y + .option('ide', { + type: 'string', + choices: ['claude', 'cursor', 'codex'] as const, + describe: 'AI coding agent to use', + }) + .option('profile', { + type: 'string', + describe: 'Auth profile to use', + }), + safely(runSdkSetup), + ) + .demandCommand(1, 'Run "confidence sdk --help" to see available actions.') + .strict(); + }, + handler() {}, +}; diff --git a/packages/cli/src/features/sdk/index.ts b/packages/cli/src/features/sdk/index.ts new file mode 100644 index 0000000..4bc2f85 --- /dev/null +++ b/packages/cli/src/features/sdk/index.ts @@ -0,0 +1,2 @@ +export { runSdkInstall } from './install.js'; +export { runSdkSetup } from './setup.js'; diff --git a/packages/cli/src/features/sdk/install.ts b/packages/cli/src/features/sdk/install.ts new file mode 100644 index 0000000..1f48250 --- /dev/null +++ b/packages/cli/src/features/sdk/install.ts @@ -0,0 +1,113 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import ora from 'ora'; +import { + detectFramework, + execFile, + getFrameworks, + type FrameworkConfig, +} from '@spotify-confidence/core'; +import { message, fail } from '@output/print.js'; + +type ProjectPM = 'npm' | 'pnpm' | 'yarn' | 'bun'; + +type AutoInstall = { type: 'auto'; cmd: string; args: string[] }; +type ManualInstall = { type: 'manual'; snippet: string }; + +type InstallCommand = AutoInstall | ManualInstall; + +function detectProjectPM(dir: string): ProjectPM { + if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm'; + if (existsSync(join(dir, 'yarn.lock'))) return 'yarn'; + if (existsSync(join(dir, 'bun.lockb')) || existsSync(join(dir, 'bun.lock'))) return 'bun'; + return 'npm'; +} + +function buildInstallCommand(fw: FrameworkConfig, dir: string): InstallCommand { + switch (fw.id) { + case 'react': + case 'nextjs': + case 'node': { + const pm = detectProjectPM(dir); + return { type: 'auto', cmd: pm, args: ['add', fw.sdkPackage] }; + } + case 'python': + return { type: 'auto', cmd: 'pip', args: ['install', fw.sdkPackage] }; + case 'go': + return { type: 'auto', cmd: 'go', args: ['get', fw.sdkPackage] }; + case 'kotlin': + return { + type: 'manual', + snippet: + `Add the following to your app/build.gradle.kts:\n\n` + + ` dependencies {\n` + + ` implementation("${fw.sdkPackage}:")\n` + + ` }`, + }; + case 'java': + return { + type: 'manual', + snippet: + `Add the following to your build.gradle.kts or pom.xml:\n\n` + + ` Gradle:\n` + + ` implementation("${fw.sdkPackage}:")\n\n` + + ` Maven:\n` + + ` \n` + + ` com.spotify.confidence\n` + + ` openfeature-provider\n` + + ` VERSION\n` + + ` `, + }; + case 'swift': + return { + type: 'manual', + snippet: + `Add the following to your Package.swift dependencies:\n\n` + + ` .package(\n` + + ` url: "https://github.com/spotify/${fw.sdkPackage}.git",\n` + + ` from: ""\n` + + ` )`, + }; + default: { + const _exhaustive: never = fw.id as never; + throw new Error(`Unknown framework: ${_exhaustive}`); + } + } +} + +export async function runSdkInstall(argv: Record): Promise { + const dir = (argv.dir as string | undefined) ?? process.cwd(); + const spinner = ora('Detecting framework...').start(); + + const fw = await detectFramework(dir); + + if (!fw) { + spinner.fail('Could not detect a framework.'); + const names = getFrameworks().map((f) => f.name); + fail( + `Supported frameworks: ${names.join(', ')}.\n` + + `Make sure you are in a project directory, or use --dir to specify one.`, + ); + return; + } + + spinner.succeed(`Detected ${fw.name}.`); + + const install = buildInstallCommand(fw, dir); + + if (install.type === 'manual') { + message(`\n${install.snippet}\n\nDocs: ${fw.docsUrl}`); + return; + } + + const installSpinner = ora(`Installing ${fw.sdkPackage}...`).start(); + + try { + await execFile(install.cmd, install.args, { cwd: dir }); + installSpinner.succeed(`Installed ${fw.sdkPackage}.`); + } catch (err) { + installSpinner.fail(`Failed to install ${fw.sdkPackage}.`); + const msg = err instanceof Error ? err.message : String(err); + fail(`${install.cmd} ${install.args.join(' ')} failed:\n${msg}`); + } +} diff --git a/packages/cli/src/features/sdk/setup.ts b/packages/cli/src/features/sdk/setup.ts new file mode 100644 index 0000000..84fac34 --- /dev/null +++ b/packages/cli/src/features/sdk/setup.ts @@ -0,0 +1,49 @@ +import { getIntegration } from '@spotify-confidence/core'; +import { fail } from '@output/print.js'; +import { resolveIde, resolveFlag, resolveProjectDir } from '@features/ide/index.js'; +import { resolveAuthToken } from '@features/mcp/index.js'; + +function buildSdkSetupPrompt(): string { + return `Set up the Confidence SDK in this project. + +Use the "analyze-project" skill from the Confidence plugin to determine the correct SDK +for this project, install it, and create a working configuration file. + +Only set up the SDK — do not create feature flags, event tracking, or session recordings. + +Use the Confidence MCP tools for SDK references and best practices.`; +} + +export async function runSdkSetup(argv: Record): Promise { + const ideId = await resolveIde(resolveFlag('ide', argv)); + const projectDir = resolveProjectDir(argv); + const integration = getIntegration(ideId); + + const plugin = await integration.detectPlugin(projectDir); + if (!plugin) { + fail( + 'Confidence AI plugin not installed. Run "confidence plugin install" to install it or "confidence quickstart" to set up your project first.', + ); + return; + } + + const statuses = await integration.detectMcpStatuses(projectDir); + if (Object.values(statuses).some((s) => s === 'not-installed')) { + fail('MCP servers not installed. Run "confidence mcp install" to set them up.'); + return; + } + + if (Object.values(statuses).some((s) => s === 'auth-expired')) { + fail('MCP server auth expired. Run "confidence mcp auth" to re-authenticate.'); + return; + } + + const token = await resolveAuthToken({ profile: resolveFlag('profile', argv) }); + if (!token) { + fail('Not authenticated. Run "confidence login" to sign in.'); + return; + } + + const prompt = buildSdkSetupPrompt(); + integration.launchChat({ prompt, cwd: projectDir, token }); +} From ae696c7e8b63cc1547f15f4b74a85e7665ec7442 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Tue, 6 Oct 2026 11:04:22 +0200 Subject: [PATCH 02/13] feat(core): allow specifying prompt kind for chat sessions --- packages/core/src/integrations/chat.ts | 2 +- packages/core/src/integrations/claude/chat.ts | 9 +++++++-- packages/core/src/integrations/codex/chat.ts | 3 ++- packages/core/src/integrations/cursor/chat.ts | 3 ++- packages/core/src/integrations/types.ts | 3 ++- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/core/src/integrations/chat.ts b/packages/core/src/integrations/chat.ts index c5002c1..6702a06 100644 --- a/packages/core/src/integrations/chat.ts +++ b/packages/core/src/integrations/chat.ts @@ -46,7 +46,7 @@ function buildChatPrompt(session: WizardSession): string { export function launchChatSession(session: WizardSession, ide: IdeId): void { const integration = getIntegration(ide); integration.launchChat({ - prompt: buildChatPrompt(session), + systemPrompt: buildChatPrompt(session), cwd: session.projectDir, token: session.authState.token, }); diff --git a/packages/core/src/integrations/claude/chat.ts b/packages/core/src/integrations/claude/chat.ts index 4ee8783..05e0047 100644 --- a/packages/core/src/integrations/claude/chat.ts +++ b/packages/core/src/integrations/claude/chat.ts @@ -1,8 +1,13 @@ import { spawn } from '../../exec/exec.js'; import type { ChatOpts } from '../types.js'; -export function launchChat({ prompt, cwd }: ChatOpts): void { - spawn('claude', ['--append-system-prompt', prompt], { +export function launchChat({ userPrompt, systemPrompt, cwd }: ChatOpts): void { + const args: string[] = []; + + if (systemPrompt) args.push('--append-system-prompt', systemPrompt); + if (userPrompt) args.push('--prompt', userPrompt); + + spawn('claude', args, { cwd, stdio: 'inherit', detached: false, diff --git a/packages/core/src/integrations/codex/chat.ts b/packages/core/src/integrations/codex/chat.ts index 1d6c05d..65d5ee6 100644 --- a/packages/core/src/integrations/codex/chat.ts +++ b/packages/core/src/integrations/codex/chat.ts @@ -1,8 +1,9 @@ import { spawn } from '../../exec/exec.js'; import type { ChatOpts } from '../types.js'; -export function launchChat({ prompt, cwd, token }: ChatOpts): void { +export function launchChat({ userPrompt, systemPrompt, cwd, token }: ChatOpts): void { const env = token ? { ...globalThis.process.env, CONFIDENCE_ACCESS_TOKEN: token } : undefined; + const prompt = [systemPrompt, userPrompt].filter(Boolean).join('\n\n'); spawn('codex', ['-C', cwd, prompt], { cwd, diff --git a/packages/core/src/integrations/cursor/chat.ts b/packages/core/src/integrations/cursor/chat.ts index adf2686..f541ea5 100644 --- a/packages/core/src/integrations/cursor/chat.ts +++ b/packages/core/src/integrations/cursor/chat.ts @@ -1,8 +1,9 @@ import { spawn } from '../../exec/exec.js'; import type { ChatOpts } from '../types.js'; -export function launchChat({ prompt, cwd, token }: ChatOpts): void { +export function launchChat({ userPrompt, systemPrompt, cwd, token }: ChatOpts): void { const env = token ? { ...globalThis.process.env, CONFIDENCE_ACCESS_TOKEN: token } : undefined; + const prompt = [systemPrompt, userPrompt].filter(Boolean).join('\n\n'); spawn('cursor', ['agent', prompt, '--approve-mcps'], { cwd, diff --git a/packages/core/src/integrations/types.ts b/packages/core/src/integrations/types.ts index fe27357..6378a9b 100644 --- a/packages/core/src/integrations/types.ts +++ b/packages/core/src/integrations/types.ts @@ -40,7 +40,8 @@ export type OnboardingOpts = { }; export type ChatOpts = { - prompt: string; + userPrompt?: string; + systemPrompt?: string; cwd: string; token?: string; }; From 8850a1c687e80aaec86e15dfc145ddd8ef505a9a Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Tue, 6 Oct 2026 11:04:53 +0200 Subject: [PATCH 03/13] chore(cli): start chat with user message --- packages/cli/__tests__/commands/migrate.test.ts | 6 +++--- packages/cli/src/features/migrate/launch.ts | 4 ++-- packages/cli/src/features/sdk/setup.ts | 13 +++---------- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/cli/__tests__/commands/migrate.test.ts b/packages/cli/__tests__/commands/migrate.test.ts index 931e896..91c75d3 100644 --- a/packages/cli/__tests__/commands/migrate.test.ts +++ b/packages/cli/__tests__/commands/migrate.test.ts @@ -101,9 +101,9 @@ describe('migrate ', () => { await run(['migrate', 'statsig', '--ide', 'claude']); - const { prompt } = vi.mocked(mockIntegration.launchChat).mock.calls[0][0]; - expect(prompt).toContain('migrate-statsig'); - expect(prompt).toContain('(statsig)'); + const { userPrompt } = vi.mocked(mockIntegration.launchChat).mock.calls[0][0]; + expect(userPrompt).toContain('migrate-statsig'); + expect(userPrompt).toContain('(statsig)'); }); it('fails when plugin is not installed', async () => { diff --git a/packages/cli/src/features/migrate/launch.ts b/packages/cli/src/features/migrate/launch.ts index 9ec7ef3..fd7208b 100644 --- a/packages/cli/src/features/migrate/launch.ts +++ b/packages/cli/src/features/migrate/launch.ts @@ -35,6 +35,6 @@ export async function launchMigration( const token = await resolveAuthToken({ profile: resolveFlag('profile', argv) }); if (!token) return; - const prompt = buildMigrationPrompt(provider); - integration.launchChat({ prompt, cwd: projectDir, token }); + const userPrompt = buildMigrationPrompt(provider); + integration.launchChat({ userPrompt, cwd: projectDir, token }); } diff --git a/packages/cli/src/features/sdk/setup.ts b/packages/cli/src/features/sdk/setup.ts index 84fac34..6348792 100644 --- a/packages/cli/src/features/sdk/setup.ts +++ b/packages/cli/src/features/sdk/setup.ts @@ -3,16 +3,10 @@ import { fail } from '@output/print.js'; import { resolveIde, resolveFlag, resolveProjectDir } from '@features/ide/index.js'; import { resolveAuthToken } from '@features/mcp/index.js'; -function buildSdkSetupPrompt(): string { - return `Set up the Confidence SDK in this project. - -Use the "analyze-project" skill from the Confidence plugin to determine the correct SDK -for this project, install it, and create a working configuration file. - +const SDK_SETUP_PROMPT = `Set up the Confidence SDK in this project. +Use the "analyze-project" skill from the Confidence plugin to determine the correct SDK, install it, and create a working configuration file. Only set up the SDK — do not create feature flags, event tracking, or session recordings. - Use the Confidence MCP tools for SDK references and best practices.`; -} export async function runSdkSetup(argv: Record): Promise { const ideId = await resolveIde(resolveFlag('ide', argv)); @@ -44,6 +38,5 @@ export async function runSdkSetup(argv: Record): Promise return; } - const prompt = buildSdkSetupPrompt(); - integration.launchChat({ prompt, cwd: projectDir, token }); + integration.launchChat({ userPrompt: SDK_SETUP_PROMPT, cwd: projectDir, token }); } From 7484bb16b4ae386b7ca2cae149541922dd8623bd Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Tue, 6 Oct 2026 11:06:23 +0200 Subject: [PATCH 04/13] fix: correctly pass user prompt for Claude --- packages/core/src/integrations/claude/chat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/integrations/claude/chat.ts b/packages/core/src/integrations/claude/chat.ts index 05e0047..33b5b50 100644 --- a/packages/core/src/integrations/claude/chat.ts +++ b/packages/core/src/integrations/claude/chat.ts @@ -5,7 +5,7 @@ export function launchChat({ userPrompt, systemPrompt, cwd }: ChatOpts): void { const args: string[] = []; if (systemPrompt) args.push('--append-system-prompt', systemPrompt); - if (userPrompt) args.push('--prompt', userPrompt); + if (userPrompt) args.push(userPrompt); spawn('claude', args, { cwd, From bae342dc6af4b8e9947a8f114e4cfa6c680acdd3 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Tue, 6 Oct 2026 11:09:07 +0200 Subject: [PATCH 05/13] test: update tests --- packages/cli/__tests__/commands/migrate.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/__tests__/commands/migrate.test.ts b/packages/cli/__tests__/commands/migrate.test.ts index 91c75d3..d87d80d 100644 --- a/packages/cli/__tests__/commands/migrate.test.ts +++ b/packages/cli/__tests__/commands/migrate.test.ts @@ -89,7 +89,7 @@ describe('migrate ', () => { expect(mockIntegration.launchChat).toHaveBeenCalledWith( expect.objectContaining({ - prompt: expect.stringContaining('Statsig'), + userPrompt: expect.stringContaining('Statsig'), cwd: process.cwd(), }), ); From 9fdbf1fcba09b0eb5d093b6ee4530cd355d238f1 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Tue, 6 Oct 2026 11:34:57 +0200 Subject: [PATCH 06/13] 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) --- .../__tests__/features/sdk/install.test.ts | 135 ++++++++++++++++++ packages/cli/src/features/sdk/install.ts | 25 +++- 2 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 packages/cli/__tests__/features/sdk/install.test.ts diff --git a/packages/cli/__tests__/features/sdk/install.test.ts b/packages/cli/__tests__/features/sdk/install.test.ts new file mode 100644 index 0000000..b68d698 --- /dev/null +++ b/packages/cli/__tests__/features/sdk/install.test.ts @@ -0,0 +1,135 @@ +import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import type { FrameworkConfig } from '@spotify-confidence/core'; +import { runSdkInstall } from '@features/sdk/install.js'; + +const mockExecFile = vi.fn<() => Promise>(); + +vi.mock('ora', () => ({ + default: () => ({ start: () => ({ succeed: vi.fn(), fail: vi.fn() }) }), +})); + +vi.mock('@spotify-confidence/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + detectFramework: async (_dir: string) => REACT_FRAMEWORK, + execFile: (...args: unknown[]) => mockExecFile(...(args as [])), + }; +}); + +vi.mock('@output/print.js', () => ({ + message: vi.fn(), + fail: vi.fn(), +})); + +const REACT_FRAMEWORK: FrameworkConfig = { + id: 'react', + name: 'React', + docsUrl: 'https://confidence.spotify.com/docs/sdk/react', + sdkPackage: '@spotify-confidence/sdk', + detect: async () => true, +}; + +const tmpDir = join(process.env.TMPDIR ?? '/tmp', 'sdk-install-test'); + +beforeEach(() => { + mkdirSync(tmpDir, { recursive: true }); + mockExecFile.mockResolvedValue(undefined); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + mockExecFile.mockReset(); +}); + +describe('runSdkInstall workspace root handling', () => { + it('passes -w when pnpm-workspace.yaml is present', async () => { + writeFileSync(join(tmpDir, 'pnpm-lock.yaml'), ''); + writeFileSync(join(tmpDir, 'pnpm-workspace.yaml'), 'packages:\n - packages/*'); + + await runSdkInstall({ dir: tmpDir }); + + expect(mockExecFile).toHaveBeenCalledWith( + 'pnpm', + ['add', '-w', '@spotify-confidence/sdk'], + expect.objectContaining({ cwd: tmpDir }), + ); + }); + + it('omits -w for pnpm without workspace config', async () => { + writeFileSync(join(tmpDir, 'pnpm-lock.yaml'), ''); + + await runSdkInstall({ dir: tmpDir }); + + expect(mockExecFile).toHaveBeenCalledWith( + 'pnpm', + ['add', '@spotify-confidence/sdk'], + expect.objectContaining({ cwd: tmpDir }), + ); + }); + + it('passes -W for yarn classic with workspaces', async () => { + writeFileSync(join(tmpDir, 'yarn.lock'), ''); + writeFileSync(join(tmpDir, 'package.json'), JSON.stringify({ workspaces: ['packages/*'] })); + + await runSdkInstall({ dir: tmpDir }); + + expect(mockExecFile).toHaveBeenCalledWith( + 'yarn', + ['add', '-W', '@spotify-confidence/sdk'], + expect.objectContaining({ cwd: tmpDir }), + ); + }); + + it('omits -W for yarn berry with workspaces', async () => { + writeFileSync(join(tmpDir, 'yarn.lock'), ''); + writeFileSync(join(tmpDir, 'package.json'), JSON.stringify({ workspaces: ['packages/*'] })); + writeFileSync(join(tmpDir, '.yarnrc.yml'), 'nodeLinker: node-modules'); + + await runSdkInstall({ dir: tmpDir }); + + expect(mockExecFile).toHaveBeenCalledWith( + 'yarn', + ['add', '@spotify-confidence/sdk'], + expect.objectContaining({ cwd: tmpDir }), + ); + }); + + it('omits -W for yarn without workspaces', async () => { + writeFileSync(join(tmpDir, 'yarn.lock'), ''); + + await runSdkInstall({ dir: tmpDir }); + + expect(mockExecFile).toHaveBeenCalledWith( + 'yarn', + ['add', '@spotify-confidence/sdk'], + expect.objectContaining({ cwd: tmpDir }), + ); + }); + + it('does not add flags for npm at a workspace root', async () => { + writeFileSync(join(tmpDir, 'package.json'), JSON.stringify({ workspaces: ['packages/*'] })); + + await runSdkInstall({ dir: tmpDir }); + + expect(mockExecFile).toHaveBeenCalledWith( + 'npm', + ['add', '@spotify-confidence/sdk'], + expect.objectContaining({ cwd: tmpDir }), + ); + }); + + it('does not add flags for bun at a workspace root', async () => { + writeFileSync(join(tmpDir, 'bun.lockb'), ''); + writeFileSync(join(tmpDir, 'package.json'), JSON.stringify({ workspaces: ['packages/*'] })); + + await runSdkInstall({ dir: tmpDir }); + + expect(mockExecFile).toHaveBeenCalledWith( + 'bun', + ['add', '@spotify-confidence/sdk'], + expect.objectContaining({ cwd: tmpDir }), + ); + }); +}); diff --git a/packages/cli/src/features/sdk/install.ts b/packages/cli/src/features/sdk/install.ts index 1f48250..0552e2c 100644 --- a/packages/cli/src/features/sdk/install.ts +++ b/packages/cli/src/features/sdk/install.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import ora from 'ora'; import { @@ -23,13 +23,34 @@ function detectProjectPM(dir: string): ProjectPM { return 'npm'; } +function hasPackageJsonWorkspaces(dir: string): boolean { + const pkgPath = join(dir, 'package.json'); + if (!existsSync(pkgPath)) return false; + + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + return Array.isArray(pkg.workspaces) || typeof pkg.workspaces === 'object'; + } catch { + return false; + } +} + +function workspaceRootArgs(pm: ProjectPM, dir: string): string[] { + if (pm === 'pnpm' && existsSync(join(dir, 'pnpm-workspace.yaml'))) return ['-w']; + if (pm === 'yarn' && hasPackageJsonWorkspaces(dir) && !existsSync(join(dir, '.yarnrc.yml'))) { + return ['-W']; + } + + return []; +} + function buildInstallCommand(fw: FrameworkConfig, dir: string): InstallCommand { switch (fw.id) { case 'react': case 'nextjs': case 'node': { const pm = detectProjectPM(dir); - return { type: 'auto', cmd: pm, args: ['add', fw.sdkPackage] }; + return { type: 'auto', cmd: pm, args: ['add', ...workspaceRootArgs(pm, dir), fw.sdkPackage] }; } case 'python': return { type: 'auto', cmd: 'pip', args: ['install', fw.sdkPackage] }; From a5427f32cda97784f49d546f02b1fa5aef8d704b Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Tue, 6 Oct 2026 11:39:59 +0200 Subject: [PATCH 07/13] 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) --- .../__tests__/features/sdk/install.test.ts | 66 +++++++++++++- packages/cli/src/features/sdk/install-jvm.ts | 28 ++++++ .../cli/src/features/sdk/install-native.ts | 17 ++++ packages/cli/src/features/sdk/install-node.ts | 38 +++++++++ .../cli/src/features/sdk/install-python.ts | 33 +++++++ .../cli/src/features/sdk/install-types.ts | 4 + packages/cli/src/features/sdk/install.ts | 85 +++---------------- 7 files changed, 196 insertions(+), 75 deletions(-) create mode 100644 packages/cli/src/features/sdk/install-jvm.ts create mode 100644 packages/cli/src/features/sdk/install-native.ts create mode 100644 packages/cli/src/features/sdk/install-node.ts create mode 100644 packages/cli/src/features/sdk/install-python.ts create mode 100644 packages/cli/src/features/sdk/install-types.ts diff --git a/packages/cli/__tests__/features/sdk/install.test.ts b/packages/cli/__tests__/features/sdk/install.test.ts index b68d698..a3b69e1 100644 --- a/packages/cli/__tests__/features/sdk/install.test.ts +++ b/packages/cli/__tests__/features/sdk/install.test.ts @@ -4,6 +4,8 @@ import type { FrameworkConfig } from '@spotify-confidence/core'; import { runSdkInstall } from '@features/sdk/install.js'; const mockExecFile = vi.fn<() => Promise>(); +const mockDetectFramework = vi.fn<() => Promise>(); +const mockMessage = vi.fn(); vi.mock('ora', () => ({ default: () => ({ start: () => ({ succeed: vi.fn(), fail: vi.fn() }) }), @@ -13,13 +15,13 @@ vi.mock('@spotify-confidence/core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - detectFramework: async (_dir: string) => REACT_FRAMEWORK, + detectFramework: (...args: unknown[]) => mockDetectFramework(...(args as [])), execFile: (...args: unknown[]) => mockExecFile(...(args as [])), }; }); vi.mock('@output/print.js', () => ({ - message: vi.fn(), + message: (...args: unknown[]) => mockMessage(...args), fail: vi.fn(), })); @@ -31,16 +33,27 @@ const REACT_FRAMEWORK: FrameworkConfig = { detect: async () => true, }; +const PYTHON_FRAMEWORK: FrameworkConfig = { + id: 'python', + name: 'Python', + docsUrl: 'https://confidence.spotify.com/docs/sdk/python', + sdkPackage: 'spotify-confidence-sdk', + detect: async () => true, +}; + const tmpDir = join(process.env.TMPDIR ?? '/tmp', 'sdk-install-test'); beforeEach(() => { mkdirSync(tmpDir, { recursive: true }); mockExecFile.mockResolvedValue(undefined); + mockDetectFramework.mockResolvedValue(REACT_FRAMEWORK); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); mockExecFile.mockReset(); + mockDetectFramework.mockReset(); + mockMessage.mockReset(); }); describe('runSdkInstall workspace root handling', () => { @@ -133,3 +146,52 @@ describe('runSdkInstall workspace root handling', () => { ); }); }); + +describe('runSdkInstall Python package manager detection', () => { + beforeEach(() => { + mockDetectFramework.mockResolvedValue(PYTHON_FRAMEWORK); + }); + + it('uses poetry add when poetry.lock is present', async () => { + writeFileSync(join(tmpDir, 'poetry.lock'), ''); + + await runSdkInstall({ dir: tmpDir }); + + expect(mockExecFile).toHaveBeenCalledWith( + 'poetry', + ['add', 'spotify-confidence-sdk'], + expect.objectContaining({ cwd: tmpDir }), + ); + }); + + it('uses uv add when uv.lock is present', async () => { + writeFileSync(join(tmpDir, 'uv.lock'), ''); + + await runSdkInstall({ dir: tmpDir }); + + expect(mockExecFile).toHaveBeenCalledWith( + 'uv', + ['add', 'spotify-confidence-sdk'], + expect.objectContaining({ cwd: tmpDir }), + ); + }); + + it('uses pipenv install when Pipfile is present', async () => { + writeFileSync(join(tmpDir, 'Pipfile'), ''); + + await runSdkInstall({ dir: tmpDir }); + + expect(mockExecFile).toHaveBeenCalledWith( + 'pipenv', + ['install', 'spotify-confidence-sdk'], + expect.objectContaining({ cwd: tmpDir }), + ); + }); + + it('falls back to manual install when no Python PM is detected', async () => { + await runSdkInstall({ dir: tmpDir }); + + expect(mockExecFile).not.toHaveBeenCalled(); + expect(mockMessage).toHaveBeenCalledWith(expect.stringContaining('pip install')); + }); +}); diff --git a/packages/cli/src/features/sdk/install-jvm.ts b/packages/cli/src/features/sdk/install-jvm.ts new file mode 100644 index 0000000..82f61ce --- /dev/null +++ b/packages/cli/src/features/sdk/install-jvm.ts @@ -0,0 +1,28 @@ +import type { InstallCommand } from './install-types.js'; + +export function buildKotlinInstall(pkg: string): InstallCommand { + return { + type: 'manual', + snippet: + `Add the following to your app/build.gradle.kts:\n\n` + + ` dependencies {\n` + + ` implementation("${pkg}:")\n` + + ` }`, + }; +} + +export function buildJavaInstall(pkg: string): InstallCommand { + return { + type: 'manual', + snippet: + `Add the following to your build.gradle.kts or pom.xml:\n\n` + + ` Gradle:\n` + + ` implementation("${pkg}:")\n\n` + + ` Maven:\n` + + ` \n` + + ` com.spotify.confidence\n` + + ` openfeature-provider\n` + + ` VERSION\n` + + ` `, + }; +} diff --git a/packages/cli/src/features/sdk/install-native.ts b/packages/cli/src/features/sdk/install-native.ts new file mode 100644 index 0000000..3833fca --- /dev/null +++ b/packages/cli/src/features/sdk/install-native.ts @@ -0,0 +1,17 @@ +import type { InstallCommand } from './install-types.js'; + +export function buildGoInstall(pkg: string): InstallCommand { + return { type: 'auto', cmd: 'go', args: ['get', pkg] }; +} + +export function buildSwiftInstall(pkg: string): InstallCommand { + return { + type: 'manual', + snippet: + `Add the following to your Package.swift dependencies:\n\n` + + ` .package(\n` + + ` url: "https://github.com/spotify/${pkg}.git",\n` + + ` from: ""\n` + + ` )`, + }; +} diff --git a/packages/cli/src/features/sdk/install-node.ts b/packages/cli/src/features/sdk/install-node.ts new file mode 100644 index 0000000..a9611dd --- /dev/null +++ b/packages/cli/src/features/sdk/install-node.ts @@ -0,0 +1,38 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { InstallCommand } from './install-types.js'; + +type NodePM = 'npm' | 'pnpm' | 'yarn' | 'bun'; + +function detectNodePM(dir: string): NodePM { + if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm'; + if (existsSync(join(dir, 'yarn.lock'))) return 'yarn'; + if (existsSync(join(dir, 'bun.lockb')) || existsSync(join(dir, 'bun.lock'))) return 'bun'; + return 'npm'; +} + +function hasPackageJsonWorkspaces(dir: string): boolean { + const pkgPath = join(dir, 'package.json'); + if (!existsSync(pkgPath)) return false; + + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + return Array.isArray(pkg.workspaces) || typeof pkg.workspaces === 'object'; + } catch { + return false; + } +} + +function workspaceRootArgs(pm: NodePM, dir: string): string[] { + if (pm === 'pnpm' && existsSync(join(dir, 'pnpm-workspace.yaml'))) return ['-w']; + if (pm === 'yarn' && hasPackageJsonWorkspaces(dir) && !existsSync(join(dir, '.yarnrc.yml'))) { + return ['-W']; + } + + return []; +} + +export function buildNodeInstall(pkg: string, dir: string): InstallCommand { + const pm = detectNodePM(dir); + return { type: 'auto', cmd: pm, args: ['add', ...workspaceRootArgs(pm, dir), pkg] }; +} diff --git a/packages/cli/src/features/sdk/install-python.ts b/packages/cli/src/features/sdk/install-python.ts new file mode 100644 index 0000000..3f84265 --- /dev/null +++ b/packages/cli/src/features/sdk/install-python.ts @@ -0,0 +1,33 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { InstallCommand } from './install-types.js'; + +type PythonPM = 'poetry' | 'uv' | 'pipenv' | 'pip'; + +function detectPythonPM(dir: string): PythonPM { + if (existsSync(join(dir, 'poetry.lock'))) return 'poetry'; + if (existsSync(join(dir, 'uv.lock'))) return 'uv'; + if (existsSync(join(dir, 'Pipfile')) || existsSync(join(dir, 'Pipfile.lock'))) return 'pipenv'; + return 'pip'; +} + +export function buildPythonInstall(pkg: string, dir: string): InstallCommand { + const pm = detectPythonPM(dir); + switch (pm) { + case 'poetry': + return { type: 'auto', cmd: 'poetry', args: ['add', pkg] }; + case 'uv': + return { type: 'auto', cmd: 'uv', args: ['add', pkg] }; + case 'pipenv': + return { type: 'auto', cmd: 'pipenv', args: ['install', pkg] }; + case 'pip': + return { + type: 'manual', + snippet: `Add ${pkg} to your project dependencies and install it:\n\n pip install ${pkg}`, + }; + default: { + const _exhaustive: never = pm; + throw new Error(`Unknown Python PM: ${_exhaustive}`); + } + } +} diff --git a/packages/cli/src/features/sdk/install-types.ts b/packages/cli/src/features/sdk/install-types.ts new file mode 100644 index 0000000..4ccb8a1 --- /dev/null +++ b/packages/cli/src/features/sdk/install-types.ts @@ -0,0 +1,4 @@ +type AutoInstall = { type: 'auto'; cmd: string; args: string[] }; +type ManualInstall = { type: 'manual'; snippet: string }; + +export type InstallCommand = AutoInstall | ManualInstall; diff --git a/packages/cli/src/features/sdk/install.ts b/packages/cli/src/features/sdk/install.ts index 0552e2c..ef67284 100644 --- a/packages/cli/src/features/sdk/install.ts +++ b/packages/cli/src/features/sdk/install.ts @@ -1,5 +1,3 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; import ora from 'ora'; import { detectFramework, @@ -8,87 +6,28 @@ import { type FrameworkConfig, } from '@spotify-confidence/core'; import { message, fail } from '@output/print.js'; - -type ProjectPM = 'npm' | 'pnpm' | 'yarn' | 'bun'; - -type AutoInstall = { type: 'auto'; cmd: string; args: string[] }; -type ManualInstall = { type: 'manual'; snippet: string }; - -type InstallCommand = AutoInstall | ManualInstall; - -function detectProjectPM(dir: string): ProjectPM { - if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm'; - if (existsSync(join(dir, 'yarn.lock'))) return 'yarn'; - if (existsSync(join(dir, 'bun.lockb')) || existsSync(join(dir, 'bun.lock'))) return 'bun'; - return 'npm'; -} - -function hasPackageJsonWorkspaces(dir: string): boolean { - const pkgPath = join(dir, 'package.json'); - if (!existsSync(pkgPath)) return false; - - try { - const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); - return Array.isArray(pkg.workspaces) || typeof pkg.workspaces === 'object'; - } catch { - return false; - } -} - -function workspaceRootArgs(pm: ProjectPM, dir: string): string[] { - if (pm === 'pnpm' && existsSync(join(dir, 'pnpm-workspace.yaml'))) return ['-w']; - if (pm === 'yarn' && hasPackageJsonWorkspaces(dir) && !existsSync(join(dir, '.yarnrc.yml'))) { - return ['-W']; - } - - return []; -} +import type { InstallCommand } from './install-types.js'; +import { buildNodeInstall } from './install-node.js'; +import { buildPythonInstall } from './install-python.js'; +import { buildJavaInstall, buildKotlinInstall } from './install-jvm.js'; +import { buildGoInstall, buildSwiftInstall } from './install-native.js'; function buildInstallCommand(fw: FrameworkConfig, dir: string): InstallCommand { switch (fw.id) { case 'react': case 'nextjs': - case 'node': { - const pm = detectProjectPM(dir); - return { type: 'auto', cmd: pm, args: ['add', ...workspaceRootArgs(pm, dir), fw.sdkPackage] }; - } + case 'node': + return buildNodeInstall(fw.sdkPackage, dir); case 'python': - return { type: 'auto', cmd: 'pip', args: ['install', fw.sdkPackage] }; + return buildPythonInstall(fw.sdkPackage, dir); case 'go': - return { type: 'auto', cmd: 'go', args: ['get', fw.sdkPackage] }; + return buildGoInstall(fw.sdkPackage); case 'kotlin': - return { - type: 'manual', - snippet: - `Add the following to your app/build.gradle.kts:\n\n` + - ` dependencies {\n` + - ` implementation("${fw.sdkPackage}:")\n` + - ` }`, - }; + return buildKotlinInstall(fw.sdkPackage); case 'java': - return { - type: 'manual', - snippet: - `Add the following to your build.gradle.kts or pom.xml:\n\n` + - ` Gradle:\n` + - ` implementation("${fw.sdkPackage}:")\n\n` + - ` Maven:\n` + - ` \n` + - ` com.spotify.confidence\n` + - ` openfeature-provider\n` + - ` VERSION\n` + - ` `, - }; + return buildJavaInstall(fw.sdkPackage); case 'swift': - return { - type: 'manual', - snippet: - `Add the following to your Package.swift dependencies:\n\n` + - ` .package(\n` + - ` url: "https://github.com/spotify/${fw.sdkPackage}.git",\n` + - ` from: ""\n` + - ` )`, - }; + return buildSwiftInstall(fw.sdkPackage); default: { const _exhaustive: never = fw.id as never; throw new Error(`Unknown framework: ${_exhaustive}`); From 953d36ca1f3e8e98c32c85bb607bb5a6e93651ef Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Tue, 6 Oct 2026 11:42:36 +0200 Subject: [PATCH 08/13] test: utilize project dir scaffolding --- .../__tests__/features/sdk/install.test.ts | 109 ++++++++++-------- 1 file changed, 62 insertions(+), 47 deletions(-) diff --git a/packages/cli/__tests__/features/sdk/install.test.ts b/packages/cli/__tests__/features/sdk/install.test.ts index a3b69e1..ad0b79c 100644 --- a/packages/cli/__tests__/features/sdk/install.test.ts +++ b/packages/cli/__tests__/features/sdk/install.test.ts @@ -1,6 +1,7 @@ -import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { FrameworkConfig } from '@spotify-confidence/core'; +import { createProjectDir } from '@spotify-confidence/testing/scaffold'; import { runSdkInstall } from '@features/sdk/install.js'; const mockExecFile = vi.fn<() => Promise>(); @@ -41,108 +42,117 @@ const PYTHON_FRAMEWORK: FrameworkConfig = { detect: async () => true, }; -const tmpDir = join(process.env.TMPDIR ?? '/tmp', 'sdk-install-test'); - beforeEach(() => { - mkdirSync(tmpDir, { recursive: true }); mockExecFile.mockResolvedValue(undefined); mockDetectFramework.mockResolvedValue(REACT_FRAMEWORK); }); -afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }); - mockExecFile.mockReset(); - mockDetectFramework.mockReset(); - mockMessage.mockReset(); -}); - describe('runSdkInstall workspace root handling', () => { it('passes -w when pnpm-workspace.yaml is present', async () => { - writeFileSync(join(tmpDir, 'pnpm-lock.yaml'), ''); - writeFileSync(join(tmpDir, 'pnpm-workspace.yaml'), 'packages:\n - packages/*'); + using project = createProjectDir('empty'); + writeFileSync(join(project.path, 'pnpm-lock.yaml'), ''); + writeFileSync(join(project.path, 'pnpm-workspace.yaml'), 'packages:\n - packages/*'); - await runSdkInstall({ dir: tmpDir }); + await runSdkInstall({ dir: project.path }); expect(mockExecFile).toHaveBeenCalledWith( 'pnpm', ['add', '-w', '@spotify-confidence/sdk'], - expect.objectContaining({ cwd: tmpDir }), + expect.objectContaining({ cwd: project.path }), ); }); it('omits -w for pnpm without workspace config', async () => { - writeFileSync(join(tmpDir, 'pnpm-lock.yaml'), ''); + using project = createProjectDir('empty'); + writeFileSync(join(project.path, 'pnpm-lock.yaml'), ''); - await runSdkInstall({ dir: tmpDir }); + await runSdkInstall({ dir: project.path }); expect(mockExecFile).toHaveBeenCalledWith( 'pnpm', ['add', '@spotify-confidence/sdk'], - expect.objectContaining({ cwd: tmpDir }), + expect.objectContaining({ cwd: project.path }), ); }); it('passes -W for yarn classic with workspaces', async () => { - writeFileSync(join(tmpDir, 'yarn.lock'), ''); - writeFileSync(join(tmpDir, 'package.json'), JSON.stringify({ workspaces: ['packages/*'] })); + using project = createProjectDir('empty'); + writeFileSync(join(project.path, 'yarn.lock'), ''); + writeFileSync( + join(project.path, 'package.json'), + JSON.stringify({ workspaces: ['packages/*'] }), + ); - await runSdkInstall({ dir: tmpDir }); + await runSdkInstall({ dir: project.path }); expect(mockExecFile).toHaveBeenCalledWith( 'yarn', ['add', '-W', '@spotify-confidence/sdk'], - expect.objectContaining({ cwd: tmpDir }), + expect.objectContaining({ cwd: project.path }), ); }); it('omits -W for yarn berry with workspaces', async () => { - writeFileSync(join(tmpDir, 'yarn.lock'), ''); - writeFileSync(join(tmpDir, 'package.json'), JSON.stringify({ workspaces: ['packages/*'] })); - writeFileSync(join(tmpDir, '.yarnrc.yml'), 'nodeLinker: node-modules'); + using project = createProjectDir('empty'); + writeFileSync(join(project.path, 'yarn.lock'), ''); + writeFileSync( + join(project.path, 'package.json'), + JSON.stringify({ workspaces: ['packages/*'] }), + ); + writeFileSync(join(project.path, '.yarnrc.yml'), 'nodeLinker: node-modules'); - await runSdkInstall({ dir: tmpDir }); + await runSdkInstall({ dir: project.path }); expect(mockExecFile).toHaveBeenCalledWith( 'yarn', ['add', '@spotify-confidence/sdk'], - expect.objectContaining({ cwd: tmpDir }), + expect.objectContaining({ cwd: project.path }), ); }); it('omits -W for yarn without workspaces', async () => { - writeFileSync(join(tmpDir, 'yarn.lock'), ''); + using project = createProjectDir('empty'); + writeFileSync(join(project.path, 'yarn.lock'), ''); - await runSdkInstall({ dir: tmpDir }); + await runSdkInstall({ dir: project.path }); expect(mockExecFile).toHaveBeenCalledWith( 'yarn', ['add', '@spotify-confidence/sdk'], - expect.objectContaining({ cwd: tmpDir }), + expect.objectContaining({ cwd: project.path }), ); }); it('does not add flags for npm at a workspace root', async () => { - writeFileSync(join(tmpDir, 'package.json'), JSON.stringify({ workspaces: ['packages/*'] })); + using project = createProjectDir('empty'); + writeFileSync( + join(project.path, 'package.json'), + JSON.stringify({ workspaces: ['packages/*'] }), + ); - await runSdkInstall({ dir: tmpDir }); + await runSdkInstall({ dir: project.path }); expect(mockExecFile).toHaveBeenCalledWith( 'npm', ['add', '@spotify-confidence/sdk'], - expect.objectContaining({ cwd: tmpDir }), + expect.objectContaining({ cwd: project.path }), ); }); it('does not add flags for bun at a workspace root', async () => { - writeFileSync(join(tmpDir, 'bun.lockb'), ''); - writeFileSync(join(tmpDir, 'package.json'), JSON.stringify({ workspaces: ['packages/*'] })); + using project = createProjectDir('empty'); + writeFileSync(join(project.path, 'bun.lockb'), ''); + writeFileSync( + join(project.path, 'package.json'), + JSON.stringify({ workspaces: ['packages/*'] }), + ); - await runSdkInstall({ dir: tmpDir }); + await runSdkInstall({ dir: project.path }); expect(mockExecFile).toHaveBeenCalledWith( 'bun', ['add', '@spotify-confidence/sdk'], - expect.objectContaining({ cwd: tmpDir }), + expect.objectContaining({ cwd: project.path }), ); }); }); @@ -153,43 +163,48 @@ describe('runSdkInstall Python package manager detection', () => { }); it('uses poetry add when poetry.lock is present', async () => { - writeFileSync(join(tmpDir, 'poetry.lock'), ''); + using project = createProjectDir('empty'); + writeFileSync(join(project.path, 'poetry.lock'), ''); - await runSdkInstall({ dir: tmpDir }); + await runSdkInstall({ dir: project.path }); expect(mockExecFile).toHaveBeenCalledWith( 'poetry', ['add', 'spotify-confidence-sdk'], - expect.objectContaining({ cwd: tmpDir }), + expect.objectContaining({ cwd: project.path }), ); }); it('uses uv add when uv.lock is present', async () => { - writeFileSync(join(tmpDir, 'uv.lock'), ''); + using project = createProjectDir('empty'); + writeFileSync(join(project.path, 'uv.lock'), ''); - await runSdkInstall({ dir: tmpDir }); + await runSdkInstall({ dir: project.path }); expect(mockExecFile).toHaveBeenCalledWith( 'uv', ['add', 'spotify-confidence-sdk'], - expect.objectContaining({ cwd: tmpDir }), + expect.objectContaining({ cwd: project.path }), ); }); it('uses pipenv install when Pipfile is present', async () => { - writeFileSync(join(tmpDir, 'Pipfile'), ''); + using project = createProjectDir('empty'); + writeFileSync(join(project.path, 'Pipfile'), ''); - await runSdkInstall({ dir: tmpDir }); + await runSdkInstall({ dir: project.path }); expect(mockExecFile).toHaveBeenCalledWith( 'pipenv', ['install', 'spotify-confidence-sdk'], - expect.objectContaining({ cwd: tmpDir }), + expect.objectContaining({ cwd: project.path }), ); }); it('falls back to manual install when no Python PM is detected', async () => { - await runSdkInstall({ dir: tmpDir }); + using project = createProjectDir('empty'); + + await runSdkInstall({ dir: project.path }); expect(mockExecFile).not.toHaveBeenCalled(); expect(mockMessage).toHaveBeenCalledWith(expect.stringContaining('pip install')); From 9351427e2a068ad5bacee4517580871601ebac02 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Tue, 6 Oct 2026 11:45:52 +0200 Subject: [PATCH 09/13] fix(core): type FrameworkConfig.id as a FrameworkId union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/cli/src/features/sdk/install.ts | 2 +- packages/core/src/frameworks/index.ts | 2 +- packages/core/src/frameworks/types.ts | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/features/sdk/install.ts b/packages/cli/src/features/sdk/install.ts index ef67284..090ff9e 100644 --- a/packages/cli/src/features/sdk/install.ts +++ b/packages/cli/src/features/sdk/install.ts @@ -29,7 +29,7 @@ function buildInstallCommand(fw: FrameworkConfig, dir: string): InstallCommand { case 'swift': return buildSwiftInstall(fw.sdkPackage); default: { - const _exhaustive: never = fw.id as never; + const _exhaustive: never = fw.id; throw new Error(`Unknown framework: ${_exhaustive}`); } } diff --git a/packages/core/src/frameworks/index.ts b/packages/core/src/frameworks/index.ts index a09514f..57fa699 100644 --- a/packages/core/src/frameworks/index.ts +++ b/packages/core/src/frameworks/index.ts @@ -8,7 +8,7 @@ import { javaFramework } from './java/index.js'; import { goFramework } from './go/index.js'; import { pythonFramework } from './python/index.js'; -export type { FrameworkConfig } from './types.js'; +export type { FrameworkConfig, FrameworkId } from './types.js'; const FRAMEWORKS: FrameworkConfig[] = [ nextjsFramework, diff --git a/packages/core/src/frameworks/types.ts b/packages/core/src/frameworks/types.ts index 51a06e3..ab677f4 100644 --- a/packages/core/src/frameworks/types.ts +++ b/packages/core/src/frameworks/types.ts @@ -1,5 +1,8 @@ +export type FrameworkId = + 'react' | 'nextjs' | 'node' | 'python' | 'go' | 'kotlin' | 'java' | 'swift'; + export type FrameworkConfig = { - id: string; + id: FrameworkId; name: string; docsUrl: string; sdkPackage: string; From 30a3ac55fec3a7d923a40b43595db46739e7ec9b Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Tue, 6 Oct 2026 11:46:37 +0200 Subject: [PATCH 10/13] 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) --- packages/cli/src/features/sdk/install.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/features/sdk/install.ts b/packages/cli/src/features/sdk/install.ts index 090ff9e..1ad7846 100644 --- a/packages/cli/src/features/sdk/install.ts +++ b/packages/cli/src/features/sdk/install.ts @@ -6,6 +6,7 @@ import { type FrameworkConfig, } from '@spotify-confidence/core'; import { message, fail } from '@output/print.js'; +import { resolveProjectDir } from '@features/ide/index.js'; import type { InstallCommand } from './install-types.js'; import { buildNodeInstall } from './install-node.js'; import { buildPythonInstall } from './install-python.js'; @@ -36,7 +37,7 @@ function buildInstallCommand(fw: FrameworkConfig, dir: string): InstallCommand { } export async function runSdkInstall(argv: Record): Promise { - const dir = (argv.dir as string | undefined) ?? process.cwd(); + const dir = resolveProjectDir(argv); const spinner = ora('Detecting framework...').start(); const fw = await detectFramework(dir); From 763c6bc33c3b98b069d77b8d961718294ad56568 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Tue, 6 Oct 2026 11:47:20 +0200 Subject: [PATCH 11/13] 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) --- packages/cli/src/features/sdk/install-jvm.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/features/sdk/install-jvm.ts b/packages/cli/src/features/sdk/install-jvm.ts index 82f61ce..c6a997a 100644 --- a/packages/cli/src/features/sdk/install-jvm.ts +++ b/packages/cli/src/features/sdk/install-jvm.ts @@ -12,6 +12,7 @@ export function buildKotlinInstall(pkg: string): InstallCommand { } export function buildJavaInstall(pkg: string): InstallCommand { + const [groupId, artifactId] = pkg.split(':'); return { type: 'manual', snippet: @@ -20,9 +21,9 @@ export function buildJavaInstall(pkg: string): InstallCommand { ` implementation("${pkg}:")\n\n` + ` Maven:\n` + ` \n` + - ` com.spotify.confidence\n` + - ` openfeature-provider\n` + - ` VERSION\n` + + ` ${groupId}\n` + + ` ${artifactId}\n` + + ` \n` + ` `, }; } From 9e63775f58e88e82574a4f0e8846adc8e6286e23 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Tue, 6 Oct 2026 11:57:22 +0200 Subject: [PATCH 12/13] fix(cli): point sdk setup prompt at confidence-docs MCP tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/cli/src/features/sdk/setup.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/features/sdk/setup.ts b/packages/cli/src/features/sdk/setup.ts index 6348792..d9ebfa6 100644 --- a/packages/cli/src/features/sdk/setup.ts +++ b/packages/cli/src/features/sdk/setup.ts @@ -4,9 +4,9 @@ import { resolveIde, resolveFlag, resolveProjectDir } from '@features/ide/index. import { resolveAuthToken } from '@features/mcp/index.js'; const SDK_SETUP_PROMPT = `Set up the Confidence SDK in this project. -Use the "analyze-project" skill from the Confidence plugin to determine the correct SDK, install it, and create a working configuration file. -Only set up the SDK — do not create feature flags, event tracking, or session recordings. -Use the Confidence MCP tools for SDK references and best practices.`; +Detect the project's framework, then use the getCodeSnippetAndSdkIntegrationTips tool from the confidence-docs MCP server to get the integration guide for that SDK. +Install the SDK package and create a working configuration file following the guide. +Only set up the SDK — do not create feature flags, event tracking, or session recordings.`; export async function runSdkSetup(argv: Record): Promise { const ideId = await resolveIde(resolveFlag('ide', argv)); From b6958e957cfa872d3654ece8f874dadb44a9c261 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Tue, 6 Oct 2026 11:59:38 +0200 Subject: [PATCH 13/13] fix(cli): don't assume app/ module in Kotlin install snippet Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/features/sdk/install-jvm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/features/sdk/install-jvm.ts b/packages/cli/src/features/sdk/install-jvm.ts index c6a997a..2b7bca8 100644 --- a/packages/cli/src/features/sdk/install-jvm.ts +++ b/packages/cli/src/features/sdk/install-jvm.ts @@ -4,7 +4,7 @@ export function buildKotlinInstall(pkg: string): InstallCommand { return { type: 'manual', snippet: - `Add the following to your app/build.gradle.kts:\n\n` + + `Add the following to your module's build.gradle.kts:\n\n` + ` dependencies {\n` + ` implementation("${pkg}:")\n` + ` }`,