From fb6683e91eecaa8a66f9b0c9d44f70a0754f2420 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 8 Oct 2026 09:04:39 +0200 Subject: [PATCH 01/22] fix(cli): include required value field in flag variant payload The createFlag MCP call was sending variants without the required `value` field, causing the API to reject with "does not exist in schema". Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/network/flags.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/network/flags.ts b/packages/cli/src/network/flags.ts index 55c4a397..27702c60 100644 --- a/packages/cli/src/network/flags.ts +++ b/packages/cli/src/network/flags.ts @@ -76,7 +76,9 @@ export async function createFlag( const args: JsonObject = { flagName: flagKey }; if (opts?.client) args.clientName = opts.client; if (opts?.description) args.description = opts.description; - if (opts?.variants) args.variants = JSON.stringify(opts.variants.map((v) => ({ name: v }))); + if (opts?.variants) { + args.variants = JSON.stringify(opts.variants.map((v) => ({ name: v, value: {} }))); + } return mcpCallTool(serverOpts(token), 'createFlag', args); } From a679e4aed54ef3bdcdbefeabbfc2a7e562360a09 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 8 Oct 2026 09:23:38 +0200 Subject: [PATCH 02/22] fix(cli): comply with schema for flag enablement --- packages/cli/__tests__/commands/flags.test.ts | 47 +++++++++---------- packages/cli/src/commands/flags.ts | 7 ++- packages/cli/src/features/flags/toggle.ts | 15 ++---- packages/cli/src/network/flags.ts | 14 +++--- 4 files changed, 40 insertions(+), 43 deletions(-) diff --git a/packages/cli/__tests__/commands/flags.test.ts b/packages/cli/__tests__/commands/flags.test.ts index 575ae44d..2985714b 100644 --- a/packages/cli/__tests__/commands/flags.test.ts +++ b/packages/cli/__tests__/commands/flags.test.ts @@ -282,55 +282,54 @@ describe('flags update', () => { }); describe('flags toggle', () => { - it('enables a flag with --on', async () => { + it('enables a flag with --on via MCP', async () => { using _auth = prepareAuthTokens('valid'); using output = captureOutput(); - server.use( - http.patch(`${FLAGS_EU_BASE}/v1/flags/dark-mode`, () => { - return HttpResponse.json({ name: 'flags/dark-mode', flagId: 'dark-mode', enabled: true }); - }), - ); + mockMcpCallTool.mockResolvedValueOnce(textResult('Enabled flag dark-mode for client web-app')); - await run(['flags', 'toggle', 'dark-mode', '--on']); + await run(['flags', 'toggle', 'dark-mode', '--on', '--client', 'web-app']); - expect(output.stdout).toContain('Flag "dark-mode" enabled.'); + expect(output.stdout).toContain('Enabled flag dark-mode'); + expect(mockMcpCallTool).toHaveBeenCalledWith( + expect.anything(), + 'addFlagToClient', + expect.objectContaining({ flagName: 'dark-mode', clientName: 'web-app' }), + ); }); - it('disables a flag with --off', async () => { + it('disables a flag with --off via MCP', async () => { using _auth = prepareAuthTokens('valid'); using output = captureOutput(); - server.use( - http.patch(`${FLAGS_EU_BASE}/v1/flags/dark-mode`, () => { - return HttpResponse.json({ name: 'flags/dark-mode', flagId: 'dark-mode', enabled: false }); - }), - ); + mockMcpCallTool.mockResolvedValueOnce(textResult('Disabled flag dark-mode for client web-app')); - await run(['flags', 'toggle', 'dark-mode', '--off']); + await run(['flags', 'toggle', 'dark-mode', '--off', '--client', 'web-app']); - expect(output.stdout).toContain('Flag "dark-mode" disabled.'); + expect(output.stdout).toContain('Disabled flag dark-mode'); + expect(mockMcpCallTool).toHaveBeenCalledWith( + expect.anything(), + 'removeFlagFromClient', + expect.objectContaining({ flagName: 'dark-mode', clientName: 'web-app' }), + ); }); it('prints request body in dry-run mode', async () => { using _auth = prepareAuthTokens('valid'); using output = captureOutput(); - await run(['flags', 'toggle', 'dark-mode', '--on', '--dry-run']); + await run(['flags', 'toggle', 'dark-mode', '--on', '--client', 'web-app', '--dry-run']); const parsed = JSON.parse(output.stdout); expect(parsed.flagKey).toBe('dark-mode'); expect(parsed.enabled).toBe(true); + expect(parsed.client).toBe('web-app'); }); - it('reports API errors', async () => { + it('reports MCP errors', async () => { using _auth = prepareAuthTokens('valid'); using output = captureOutput(); - server.use( - http.patch(`${FLAGS_EU_BASE}/v1/flags/dark-mode`, () => { - return HttpResponse.json({ code: 404, message: 'Flag not found' }, { status: 404 }); - }), - ); + mockMcpCallTool.mockRejectedValueOnce(new Error('Flag not found')); - await run(['flags', 'toggle', 'dark-mode', '--on']); + await run(['flags', 'toggle', 'dark-mode', '--on', '--client', 'web-app']); expect(output.stderr).toContain('Flag not found'); }); diff --git a/packages/cli/src/commands/flags.ts b/packages/cli/src/commands/flags.ts index 373f2261..bc705646 100644 --- a/packages/cli/src/commands/flags.ts +++ b/packages/cli/src/commands/flags.ts @@ -78,12 +78,17 @@ export const flagsCommand = { ) .command( 'toggle ', - 'Enable or disable a flag', + 'Enable or disable a flag for a client', (y: Argv) => y .positional('flag-key', { type: 'string', demandOption: true }) .option('on', { type: 'boolean', describe: 'Enable the flag' }) .option('off', { type: 'boolean', describe: 'Disable the flag' }) + .option('client', { + type: 'string', + describe: 'Client name for the flag', + demandOption: true, + }) .conflicts('on', 'off') .check((args) => { if (!args.on && !args.off) { diff --git a/packages/cli/src/features/flags/toggle.ts b/packages/cli/src/features/flags/toggle.ts index a92b9a65..0f711ff9 100644 --- a/packages/cli/src/features/flags/toggle.ts +++ b/packages/cli/src/features/flags/toggle.ts @@ -1,22 +1,17 @@ import { toggleFlag } from '@network/index.js'; -import { fail, message } from '@output/index.js'; +import { message, printMcpResult } from '@output/index.js'; import { withAuth } from '@utils/index.js'; export const toggleFlagCmd = withAuth(async function toggleFlagCmd(argv, token) { const flagKey = argv['flag-key'] as string; const enabled = argv.on === true; + const client = argv.client as string; if (argv['dry-run']) { - message(JSON.stringify({ flagKey, enabled }, null, 2)); + message(JSON.stringify({ flagKey, enabled, client }, null, 2)); return; } - const result = await toggleFlag(token, flagKey, enabled); - - if (!result.ok) { - fail(result.error.message); - return; - } - - message(`Flag "${flagKey}" ${enabled ? 'enabled' : 'disabled'}.`); + const result = await toggleFlag(token, flagKey, { enabled, client }); + printMcpResult(result, argv); }); diff --git a/packages/cli/src/network/flags.ts b/packages/cli/src/network/flags.ts index 27702c60..8ef83ccf 100644 --- a/packages/cli/src/network/flags.ts +++ b/packages/cli/src/network/flags.ts @@ -100,14 +100,12 @@ export async function updateFlag( export async function toggleFlag( token: string, flagKey: string, - enabled: boolean, -): Promise> { - return apiRequest({ - ...restOpts(token), - path: `/v1/flags/${encodeURIComponent(flagKey)}`, - method: 'PATCH', - body: { enabled }, - params: { update_mask: 'enabled' }, + opts: { enabled: boolean; client: string }, +): Promise { + const tool = opts.enabled ? 'addFlagToClient' : 'removeFlagFromClient'; + return mcpCallTool(serverOpts(token), tool, { + flagName: flagKey, + clientName: opts.client, }); } From ebd3fbca256d2c20297dc91c9ed284b2a50e3aea Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 8 Oct 2026 09:24:24 +0200 Subject: [PATCH 03/22] fix(core): compute MCP preference location at runtime Prevents tests from crating real `mcp_preference` file --- packages/core/src/integrations/mcp/preference.ts | 13 ++++++++----- .../ui/screens/ConnectToolsScreen.auth.test.tsx | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/core/src/integrations/mcp/preference.ts b/packages/core/src/integrations/mcp/preference.ts index a6a267a1..ec2fc569 100644 --- a/packages/core/src/integrations/mcp/preference.ts +++ b/packages/core/src/integrations/mcp/preference.ts @@ -4,13 +4,16 @@ import { getConfigDir } from '../../auth/credentials/paths.js'; type McpPreference = 'connected' | 'skipped'; -const PREFERENCE_FILE = join(getConfigDir(), 'mcp_preference'); +function preferencePath(): string { + return join(getConfigDir(), 'mcp_preference'); +} export function loadMcpPreference(): McpPreference | null { - if (!existsSync(PREFERENCE_FILE)) return null; + const file = preferencePath(); + if (!existsSync(file)) return null; try { - const value = readFileSync(PREFERENCE_FILE, 'utf-8').trim(); + const value = readFileSync(file, 'utf-8').trim(); return ['connected', 'skipped'].includes(value) ? (value as McpPreference) : null; } catch { return null; @@ -19,12 +22,12 @@ export function loadMcpPreference(): McpPreference | null { export function persistMcpPreference(preference: McpPreference): void { mkdirSync(getConfigDir(), { recursive: true }); - writeFileSync(PREFERENCE_FILE, preference, 'utf-8'); + writeFileSync(preferencePath(), preference, 'utf-8'); } export function clearMcpPreference(): void { try { - unlinkSync(PREFERENCE_FILE); + unlinkSync(preferencePath()); } catch { // already gone } diff --git a/packages/quickstart/__tests__/ui/screens/ConnectToolsScreen.auth.test.tsx b/packages/quickstart/__tests__/ui/screens/ConnectToolsScreen.auth.test.tsx index 1bdd3c52..22590f08 100644 --- a/packages/quickstart/__tests__/ui/screens/ConnectToolsScreen.auth.test.tsx +++ b/packages/quickstart/__tests__/ui/screens/ConnectToolsScreen.auth.test.tsx @@ -37,8 +37,8 @@ describe('ConnectToolsScreen', () => { http.post('https://mcp.confidence.dev/mcp/docs', () => HttpResponse.error()), ); - using _pref = createMcpPreference('connected'); using _auth = prepareAuthTokens('none'); + using _pref = createMcpPreference('connected'); using project = createProjectDir(); // Act From 592119f03121a10b29a532a1f848ec7db438631e Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 8 Oct 2026 09:33:12 +0200 Subject: [PATCH 04/22] fix(cli): make --client required for flags resolve command The MCP server's resolveFlag tool requires clientName, but the CLI treated it as optional, causing a server-side NPE when omitted. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/__tests__/commands/flags.test.ts | 13 ++++++++++++- packages/cli/src/commands/flags.ts | 6 +++++- packages/cli/src/features/flags/resolve.ts | 2 +- packages/cli/src/network/flags.ts | 4 ++-- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/cli/__tests__/commands/flags.test.ts b/packages/cli/__tests__/commands/flags.test.ts index 2985714b..c3197951 100644 --- a/packages/cli/__tests__/commands/flags.test.ts +++ b/packages/cli/__tests__/commands/flags.test.ts @@ -351,6 +351,8 @@ describe('flags resolve', () => { 'targeting_key', '--entity-value', 'user-123', + '--client', + 'web-app', ]); expect(output.stdout).toContain('dark-mode'); @@ -358,7 +360,11 @@ describe('flags resolve', () => { expect(mockMcpCallTool).toHaveBeenCalledWith( expect.anything(), 'resolveFlag', - expect.objectContaining({ entity: 'targeting_key', entityValue: 'user-123' }), + expect.objectContaining({ + clientName: 'web-app', + entity: 'targeting_key', + entityValue: 'user-123', + }), ); }); @@ -375,6 +381,8 @@ describe('flags resolve', () => { 'targeting_key', '--entity-value', 'user-123', + '--client', + 'web-app', '--context', 'user=alice', '--context', @@ -386,6 +394,7 @@ describe('flags resolve', () => { 'resolveFlag', expect.objectContaining({ flagName: 'dark-mode', + clientName: 'web-app', context: JSON.stringify({ user: 'alice', plan: 'premium' }), }), ); @@ -403,6 +412,8 @@ describe('flags resolve', () => { 'targeting_key', '--entity-value', 'u1', + '--client', + 'web-app', '--context', 'no-equals', ]); diff --git a/packages/cli/src/commands/flags.ts b/packages/cli/src/commands/flags.ts index bc705646..f6c6c85e 100644 --- a/packages/cli/src/commands/flags.ts +++ b/packages/cli/src/commands/flags.ts @@ -119,7 +119,11 @@ export const flagsCommand = { array: true, describe: 'Additional context key=value pair', }) - .option('client', { type: 'string', describe: 'Client name for resolution' }), + .option('client', { + type: 'string', + describe: 'Client name for resolution', + demandOption: true, + }), safely(resolveFlagCmd), ) .command( diff --git a/packages/cli/src/features/flags/resolve.ts b/packages/cli/src/features/flags/resolve.ts index 9ea5826a..3708a247 100644 --- a/packages/cli/src/features/flags/resolve.ts +++ b/packages/cli/src/features/flags/resolve.ts @@ -22,7 +22,7 @@ export const resolveFlagCmd = withAuth(async function resolveFlagCmd(argv, token const flagKey = argv['flag-key'] as string; const entity = argv.entity as string; const entityValue = argv['entity-value'] as string; - const client = argv.client as string | undefined; + const client = argv.client as string; const contextSpecs = (argv.context as string[] | undefined) ?? []; let context: Record | undefined; diff --git a/packages/cli/src/network/flags.ts b/packages/cli/src/network/flags.ts index 8ef83ccf..127072bc 100644 --- a/packages/cli/src/network/flags.ts +++ b/packages/cli/src/network/flags.ts @@ -112,14 +112,14 @@ export async function toggleFlag( export async function resolveFlag( token: string, flagKey: string, - opts: { entity: string; entityValue: string; client?: string; context?: Record }, + opts: { entity: string; entityValue: string; client: string; context?: Record }, ): Promise { const args: JsonObject = { flagName: flagKey, + clientName: opts.client, entity: opts.entity, entityValue: opts.entityValue, }; - if (opts.client) args.clientName = opts.client; if (opts.context) args.context = JSON.stringify(opts.context); return mcpCallTool(serverOpts(token), 'resolveFlag', args); From 59f48deb650fb9acd8b54d99b8d09e02e1717617 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 8 Oct 2026 09:49:19 +0200 Subject: [PATCH 05/22] fix(cli): allow events create with --from-file without --field The defaults callback in resolveInput threw before file data could be merged, so --from-file alone always failed. Move validation after resolveInput so the error only fires when the final schema is empty. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/features/events/create.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/features/events/create.ts b/packages/cli/src/features/events/create.ts index e079a3b8..4a1c95f6 100644 --- a/packages/cli/src/features/events/create.ts +++ b/packages/cli/src/features/events/create.ts @@ -10,12 +10,13 @@ export const createEvent = withAuth(async function createEvent(argv, token) { const schema = resolveInput(argv, () => { const fieldSpecs = (argv.field as string[] | undefined) ?? []; - if (fieldSpecs.length === 0) { - throw new Error('Provide at least one --field or --from-file.'); - } return Object.fromEntries(fieldSpecs.map(parseFieldArg)); }); + if (Object.keys(schema).length === 0) { + throw new Error('Provide at least one --field or --from-file.'); + } + if (argv['dry-run']) { message(JSON.stringify({ eventDefinitionId, schema }, null, 2)); return; From 9e5f6402ae742262fcaecdd023c5a7e9648e4397 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 8 Oct 2026 09:55:03 +0200 Subject: [PATCH 06/22] chore: update defaults for usage --- packages/cli/src/commands/events.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/events.ts b/packages/cli/src/commands/events.ts index 328ec589..e1c656d3 100644 --- a/packages/cli/src/commands/events.ts +++ b/packages/cli/src/commands/events.ts @@ -76,7 +76,7 @@ export const eventsCommand = { (y: Argv) => y .positional('name', { type: 'string', demandOption: true }) - .option('days', { type: 'number', describe: 'Number of days (1-7)', default: 7 }), + .option('days', { type: 'number', describe: 'Number of days (1-7)', default: 1 }), safely(eventUsage), ) .demandCommand(1) From 746edc8fcac351516acbf341ea76eae0b4fcc3a6 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 8 Oct 2026 11:28:55 +0200 Subject: [PATCH 07/22] fix: look up git root when detecting MCP status --- packages/cli/.prettierignore | 1 + packages/core/src/integrations/claude/mcp/detect.ts | 8 +++++++- packages/core/src/system/fs.ts | 13 +++++++++++++ packages/core/src/system/index.ts | 2 +- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/cli/.prettierignore b/packages/cli/.prettierignore index 1eae0cf6..dd83f1ce 100644 --- a/packages/cli/.prettierignore +++ b/packages/cli/.prettierignore @@ -1,2 +1,3 @@ dist/ node_modules/ +.claude/ diff --git a/packages/core/src/integrations/claude/mcp/detect.ts b/packages/core/src/integrations/claude/mcp/detect.ts index 089d61ae..7f0a5717 100644 --- a/packages/core/src/integrations/claude/mcp/detect.ts +++ b/packages/core/src/integrations/claude/mcp/detect.ts @@ -2,6 +2,7 @@ import { realpathSync, readFileSync } from 'node:fs'; import type { McpStatusMap } from '../../mcp/servers.js'; import { type McpServerName, detectMcpStatuses as detectShared } from '../../mcp/servers.js'; import { onlyKnownServerNames } from '../../mcp/config.js'; +import { resolveGitRoot } from '../../../system/fs.js'; import { globalConfigPath } from '../paths.js'; type McpServerEntry = { @@ -20,8 +21,13 @@ function readProjectMcpServers(projectDir: string): Record Date: Thu, 8 Oct 2026 11:56:00 +0200 Subject: [PATCH 08/22] fix: resolve .cmd shims on Windows and consolidate isWindows resolveBin now returns shell: true when no .js shim is found on Windows, letting cmd.exe resolve npm-installed .cmd wrappers. Moved isWindows to shared-kernel so all packages share one constant. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/__tests__/exec/resolve-bin.test.ts | 7 ++-- packages/core/src/exec/exec.ts | 7 ++-- packages/core/src/exec/resolve-bin.ts | 21 ++++++++-- .../src/integrations/claude/mcp/detect.ts | 40 +++++++++++-------- packages/core/src/system/fs.ts | 13 +++--- .../__tests__/e2e/system-check-failure.e2e.ts | 2 +- packages/shared-kernel/package.json | 1 + packages/shared-kernel/src/index.ts | 1 + packages/shared-kernel/src/platform.ts | 1 + packages/shared-kernel/tsconfig.json | 3 +- packages/testing/src/e2e/mocks/binaries/fs.ts | 2 +- packages/testing/src/e2e/terminal/session.ts | 3 +- packages/testing/src/env/index.ts | 2 +- packages/testing/src/env/platform.ts | 2 +- pnpm-lock.yaml | 3 ++ 15 files changed, 70 insertions(+), 38 deletions(-) create mode 100644 packages/shared-kernel/src/platform.ts diff --git a/packages/core/__tests__/exec/resolve-bin.test.ts b/packages/core/__tests__/exec/resolve-bin.test.ts index 2a57aab8..a680e636 100644 --- a/packages/core/__tests__/exec/resolve-bin.test.ts +++ b/packages/core/__tests__/exec/resolve-bin.test.ts @@ -9,7 +9,7 @@ describe('resolveBin', () => { exists: () => true, }); - expect(sut).toEqual({ command: 'claude', args: ['--version'] }); + expect(sut).toEqual({ command: 'claude', args: ['--version'], shell: false }); }); it('runs a PATH .js shim with node on Windows', () => { @@ -26,16 +26,17 @@ describe('resolveBin', () => { expect(sut).toEqual({ command: '/nodejs/node.exe', args: [jsPath, '--version'], + shell: false, }); }); - it('returns the command unchanged on Windows when no .js shim exists', () => { + it('uses shell mode on Windows when no .js shim exists', () => { const sut = resolveBin('claude', ['--version'], { platform: 'win32', pathEnv: join('/Windows', 'System32'), exists: () => false, }); - expect(sut).toEqual({ command: 'claude', args: ['--version'] }); + expect(sut).toEqual({ command: 'claude', args: ['--version'], shell: true }); }); }); diff --git a/packages/core/src/exec/exec.ts b/packages/core/src/exec/exec.ts index f9c868d4..b49ef11a 100644 --- a/packages/core/src/exec/exec.ts +++ b/packages/core/src/exec/exec.ts @@ -11,7 +11,7 @@ import { pathFromEnv, resolveBin } from './resolve-bin.js'; const promisifiedExecFile = promisify(cpExecFile); /** - * `spawn` that runs Windows `.js` PATH shims with Node instead of `.cmd` files. + * `spawn` that resolves Windows `.js` and `.cmd` PATH shims. * * @see {@link resolveBin} */ @@ -21,11 +21,11 @@ export function spawn( options?: SpawnOptions, ): ChildProcess { const resolved = resolveBin(command, args, { pathEnv: pathFromEnv(options?.env) }); - return cpSpawn(resolved.command, resolved.args, options ?? {}); + return cpSpawn(resolved.command, resolved.args, { ...options, shell: resolved.shell }); } /** - * `execFile` that runs Windows `.js` PATH shims with Node instead of `.cmd` files. + * `execFile` that resolves Windows `.js` and `.cmd` PATH shims. * * @see {@link resolveBin} */ @@ -38,6 +38,7 @@ export async function execFile( const { stdout, stderr } = await promisifiedExecFile(resolved.command, resolved.args, { ...options, encoding: 'utf8', + shell: resolved.shell, }); return { stdout, stderr }; } diff --git a/packages/core/src/exec/resolve-bin.ts b/packages/core/src/exec/resolve-bin.ts index a1047595..6d8d4f2e 100644 --- a/packages/core/src/exec/resolve-bin.ts +++ b/packages/core/src/exec/resolve-bin.ts @@ -12,20 +12,30 @@ type ResolveBinOptions = { exists?: (path: string) => boolean; }; +export type ResolvedBin = { + command: string; + args: string[]; + shell: boolean; +}; + /** * Resolves a command so `execFile`/`spawn` can run it without a shell. * * On Windows, `CreateProcess` cannot execute `.cmd` shims. If `{command}.js` * exists on PATH (the e2e mock layout), run it with Node instead so flags * like `--version` are passed to the script rather than to Node itself. + * When no `.js` shim is found, `shell: true` is returned so `cmd.exe` + * can resolve `.cmd` wrappers installed by npm. */ export function resolveBin( command: string, args: readonly string[], options: ResolveBinOptions = {}, -): { command: string; args: string[] } { +): ResolvedBin { const platform = options.platform ?? process.platform; - if (platform !== 'win32') return { command, args: [...args] }; + if (platform !== 'win32') { + return { command, args: [...args], shell: false }; + } const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? ''; const exists = options.exists ?? existsSync; @@ -34,10 +44,13 @@ export function resolveBin( for (const dir of pathEnv.split(delimiter)) { if (!dir) continue; const jsPath = join(dir, `${command}.js`); - if (exists(jsPath)) return { command: execPath, args: [jsPath, ...args] }; + + if (exists(jsPath)) { + return { command: execPath, args: [jsPath, ...args], shell: false }; + } } - return { command, args: [...args] }; + return { command, args: [...args], shell: true }; } export function pathFromEnv(env?: NodeJS.ProcessEnv): string | undefined { diff --git a/packages/core/src/integrations/claude/mcp/detect.ts b/packages/core/src/integrations/claude/mcp/detect.ts index 7f0a5717..c09cb3a5 100644 --- a/packages/core/src/integrations/claude/mcp/detect.ts +++ b/packages/core/src/integrations/claude/mcp/detect.ts @@ -1,32 +1,38 @@ import { realpathSync, readFileSync } from 'node:fs'; -import type { McpStatusMap } from '../../mcp/servers.js'; -import { type McpServerName, detectMcpStatuses as detectShared } from '../../mcp/servers.js'; -import { onlyKnownServerNames } from '../../mcp/config.js'; +import { normalize } from 'node:path'; import { resolveGitRoot } from '../../../system/fs.js'; +import { onlyKnownServerNames } from '../../mcp/config.js'; +import { + type McpServerName, + type McpStatusMap, + detectMcpStatuses as detectShared, +} from '../../mcp/servers.js'; import { globalConfigPath } from '../paths.js'; -type McpServerEntry = { - headers?: Record; -}; - -type ClaudeProjectConfig = { - mcpServers?: Record; -}; +type McpServerEntry = { headers?: Record }; +type ClaudeProjectConfig = { mcpServers?: Record }; +type ClaudeGlobalConfig = { projects?: Record }; -type ClaudeGlobalConfig = { - projects?: Record; -}; +function normalizedProjects(config: ClaudeGlobalConfig): Record { + return Object.entries(config.projects ?? {}) + .map(([rawKey, project]) => [normalize(rawKey), project] as const) + .reduce>( + (acc, [normalizedKey, project]) => ({ ...acc, [normalizedKey]: project }), + {}, + ); +} function readProjectMcpServers(projectDir: string): Record { try { const resolved = realpathSync(projectDir); + const gitRoot = resolveGitRoot(resolved); const config = JSON.parse(readFileSync(globalConfigPath(), 'utf-8')) as ClaudeGlobalConfig; - const projectGitRoot = resolveGitRoot(resolved); + const projects = normalizedProjects(config); return ( - config.projects?.[resolved]?.mcpServers ?? - config.projects?.[projectDir]?.mcpServers ?? - (projectGitRoot ? config.projects?.[projectGitRoot]?.mcpServers : {}) ?? + projects[resolved]?.mcpServers ?? + projects[normalize(projectDir)]?.mcpServers ?? + (gitRoot ? projects[gitRoot]?.mcpServers : undefined) ?? {} ); } catch { diff --git a/packages/core/src/system/fs.ts b/packages/core/src/system/fs.ts index 5325b557..a4437496 100644 --- a/packages/core/src/system/fs.ts +++ b/packages/core/src/system/fs.ts @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process'; import { readdirSync } from 'node:fs'; +import { normalize } from 'node:path'; export function isDirectoryEmpty(dir: string): boolean { try { @@ -13,11 +14,13 @@ export function isDirectoryEmpty(dir: string): boolean { export function resolveGitRoot(dir: string): string | null { try { - return execFileSync('git', ['rev-parse', '--show-toplevel'], { - cwd: dir, - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); + return normalize( + execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd: dir, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(), + ); } catch { return null; } diff --git a/packages/quickstart/__tests__/e2e/system-check-failure.e2e.ts b/packages/quickstart/__tests__/e2e/system-check-failure.e2e.ts index 33b99626..e6858389 100644 --- a/packages/quickstart/__tests__/e2e/system-check-failure.e2e.ts +++ b/packages/quickstart/__tests__/e2e/system-check-failure.e2e.ts @@ -1,5 +1,5 @@ import { dirname } from 'node:path'; -import { isWindows } from '@spotify-confidence/testing/env'; +import { isWindows } from '@spotify-confidence/shared-kernel'; import { createSession } from '@spotify-confidence/testing/e2e'; describe('when system check fails', () => { diff --git a/packages/shared-kernel/package.json b/packages/shared-kernel/package.json index 3743e102..dbdb6cf8 100644 --- a/packages/shared-kernel/package.json +++ b/packages/shared-kernel/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { + "@types/node": "^26.6.3", "typescript": "^6.0.3" } } diff --git a/packages/shared-kernel/src/index.ts b/packages/shared-kernel/src/index.ts index fb9d52c5..9f8775e2 100644 --- a/packages/shared-kernel/src/index.ts +++ b/packages/shared-kernel/src/index.ts @@ -10,3 +10,4 @@ export type { } from './types.js'; export { noop } from './noop.js'; export { isDefined } from './guards.js'; +export { isWindows } from './platform.js'; diff --git a/packages/shared-kernel/src/platform.ts b/packages/shared-kernel/src/platform.ts new file mode 100644 index 00000000..9c9ec8ee --- /dev/null +++ b/packages/shared-kernel/src/platform.ts @@ -0,0 +1 @@ +export const isWindows = process.platform === 'win32'; diff --git a/packages/shared-kernel/tsconfig.json b/packages/shared-kernel/tsconfig.json index 5a24989c..3fbff51d 100644 --- a/packages/shared-kernel/tsconfig.json +++ b/packages/shared-kernel/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "types": ["node"] }, "include": ["src"] } diff --git a/packages/testing/src/e2e/mocks/binaries/fs.ts b/packages/testing/src/e2e/mocks/binaries/fs.ts index 253ad092..e3dc5bed 100644 --- a/packages/testing/src/e2e/mocks/binaries/fs.ts +++ b/packages/testing/src/e2e/mocks/binaries/fs.ts @@ -1,6 +1,6 @@ import { writeFileSync, chmodSync } from 'node:fs'; import { join } from 'node:path'; -import { isWindows } from '../../../env/index.js'; +import { isWindows } from '@spotify-confidence/shared-kernel'; export const writeMockBinary = isWindows ? writeWindowsBinary : writeUnixBinary; diff --git a/packages/testing/src/e2e/terminal/session.ts b/packages/testing/src/e2e/terminal/session.ts index 9b31e8ba..630121ba 100644 --- a/packages/testing/src/e2e/terminal/session.ts +++ b/packages/testing/src/e2e/terminal/session.ts @@ -5,7 +5,8 @@ import { join } from 'node:path'; import { stripAnsi } from './strip-ansi.js'; import { renderScreen, normalizeSnapshot } from './screen-buffer.js'; import { E2E_BASE_ENV } from '../env.js'; -import { overlayEnv, isWindows } from '../../env/index.js'; +import { isWindows } from '@spotify-confidence/shared-kernel'; +import { overlayEnv } from '../../env/index.js'; import { resolveKey, type Modifiers } from '../../terminal/index.js'; const DEFAULT_COLS = 100; diff --git a/packages/testing/src/env/index.ts b/packages/testing/src/env/index.ts index 27093335..29f365c3 100644 --- a/packages/testing/src/env/index.ts +++ b/packages/testing/src/env/index.ts @@ -1,2 +1,2 @@ export { overlayEnv } from './overlay-env.js'; -export { isWindows, perPlatform } from './platform.js'; +export { perPlatform } from './platform.js'; diff --git a/packages/testing/src/env/platform.ts b/packages/testing/src/env/platform.ts index 7dbb9679..af1fb76a 100644 --- a/packages/testing/src/env/platform.ts +++ b/packages/testing/src/env/platform.ts @@ -1,4 +1,4 @@ -export const isWindows = process.platform === 'win32'; +import { isWindows } from '@spotify-confidence/shared-kernel'; export function perPlatform(options: { windows: T; unix: T }): T { return isWindows ? options.windows : options.unix; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0239f96b..aaa79e4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,9 @@ importers: packages/shared-kernel: devDependencies: + '@types/node': + specifier: ^26.6.3 + version: 26.6.3 typescript: specifier: ^6.0.3 version: 6.0.3 From f32984ee46f644bae07344f3e5e7300feeebd4fa Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 8 Oct 2026 12:13:09 +0200 Subject: [PATCH 09/22] fix: resolve full plugin ID before calling claude plugin update `claude plugin update` requires the full plugin ID (e.g. `confidence@claude-plugins-official`), unlike install/uninstall which accept the short name. Resolve the ID from `claude plugin list` first so the update succeeds via CLI instead of silently falling back to download. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/integrations/claude/plugins.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/core/src/integrations/claude/plugins.ts b/packages/core/src/integrations/claude/plugins.ts index aea1591a..1d27e21a 100644 --- a/packages/core/src/integrations/claude/plugins.ts +++ b/packages/core/src/integrations/claude/plugins.ts @@ -45,7 +45,8 @@ export async function updatePlugin( projectDir: string, scope: PluginScope = 'project', ): Promise { - await execFile('claude', ['plugin', 'update', PLUGIN_NAME, '--scope', SCOPE_MAP[scope]], { + const pluginId = await resolvePluginId(projectDir); + await execFile('claude', ['plugin', 'update', pluginId, '--scope', SCOPE_MAP[scope]], { cwd: projectDir, }); } @@ -59,6 +60,18 @@ export async function uninstallPlugin( }); } +async function resolvePluginId(projectDir: string): Promise { + const { stdout } = await execFile('claude', ['plugin', 'list', '--json'], { cwd: projectDir }); + + const plugins = JSON.parse(stdout) as PluginEntry[]; + const match = plugins.find((p) => p.id.startsWith(`${PLUGIN_NAME}@`)); + if (!match) { + throw new Error(`Plugin "${PLUGIN_NAME}" is not installed`); + } + + return match.id; +} + function isAvailable(plugin: PluginEntry, projectDir: string): boolean { if (!plugin.id.startsWith(`${PLUGIN_NAME}@`)) return false; if (!plugin.enabled) return false; From a42eaf4022d4714fc4e94d9e105d0283cedc3355 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 8 Oct 2026 18:55:46 +0200 Subject: [PATCH 10/22] refactor: download skills to `~/.config/confidence` and reference by absolute path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin skill file resolution broke when the CLI plugin registered as `confidence@claude-plugins-official` — command prompts inside the plugin couldn't resolve sibling skill files. Instead of depending on the plugin's internal resolution, skills are now always downloaded to `~/.config/confidence/skills/` and referenced via absolute path in prompts. Key changes: - installPlugin/updatePlugin always download skills to the shared dir, regardless of whether the CLI plugin install succeeds - referenceInstruction() produces an absolute file path; followInstruction() does the same for commands that should follow (not just reference) the skill - detectPlugin() returns boolean (is the CLI plugin installed?) - Removed PluginInstallationMethod, InstalledPlugin, skillsDir from IdeIntegration, and pluginInstallMethod from WizardSession - Moved getConfigDir() from auth to config module --- .../cli/__tests__/commands/migrate.test.ts | 18 ++- .../commands/warehouse-setup.test.ts | 8 +- .../cli/__tests__/helpers/mock-integration.ts | 3 +- .../cli/src/features/ide/launch-skill-chat.ts | 7 +- packages/cli/src/features/migrate/launch.ts | 6 +- packages/cli/src/features/plugin/install.ts | 4 +- packages/cli/src/features/plugin/update.ts | 4 +- packages/cli/src/features/warehouse/setup.ts | 7 +- .../__tests__/auth/credentials/store.test.ts | 2 +- .../__tests__/integrations/plugins.test.ts | 76 ++++----- packages/core/src/auth/credentials/index.ts | 2 +- packages/core/src/auth/credentials/paths.ts | 7 +- packages/core/src/auth/index.ts | 2 +- packages/core/src/config/config.ts | 2 +- packages/core/src/config/index.ts | 1 + packages/core/src/config/paths.ts | 7 + packages/core/src/integrations/chat.ts | 17 +- .../core/src/integrations/claude/index.ts | 2 - .../core/src/integrations/claude/paths.ts | 4 - .../core/src/integrations/claude/plugins.ts | 16 +- packages/core/src/integrations/codex/index.ts | 2 - packages/core/src/integrations/codex/paths.ts | 4 - .../core/src/integrations/codex/plugins.ts | 14 +- .../core/src/integrations/cursor/index.ts | 2 - .../core/src/integrations/cursor/paths.ts | 4 - .../core/src/integrations/cursor/plugins.ts | 7 +- packages/core/src/integrations/index.ts | 5 +- .../core/src/integrations/mcp/preference.ts | 2 +- .../core/src/integrations/skills/index.ts | 2 + .../core/src/integrations/skills/local.ts | 9 +- .../core/src/integrations/skills/plugin.ts | 50 +++--- .../src/integrations/skills/references.ts | 26 +++ packages/core/src/integrations/types.ts | 15 +- packages/core/src/session/session.ts | 7 - packages/core/tsconfig.json | 3 +- packages/core/vitest.config.ts | 1 + .../__tests__/e2e/chat-launch.e2e.ts | 8 +- .../__tests__/e2e/download-fallback.e2e.ts | 20 --- .../e2e/onboarding-invocation.e2e.ts | 6 +- .../features/onboarding/build-prompt.test.ts | 149 ++---------------- .../features/onboarding/tool-vars.test.ts | 69 +++----- .../ui/screens/InstallPluginsScreen.test.tsx | 7 +- .../src/features/onboarding/build-prompt.ts | 27 +--- .../onboarding/sections/event-tracking.ts | 11 +- .../onboarding/sections/feature-flags.ts | 5 +- .../features/onboarding/sections/recording.ts | 9 +- .../src/features/onboarding/tool-vars.ts | 31 +--- .../install-plugins/telemetry-events.ts | 9 +- .../install-plugins/useInitialDetection.ts | 10 +- .../install-plugins/usePluginInstall.ts | 4 +- .../onboard-project/useOnboardingProcess.ts | 1 - packages/quickstart/src/ui/store.ts | 12 +- packages/shared-kernel/src/index.ts | 1 - packages/shared-kernel/src/types.ts | 2 - scripts/clean-dev-env.sh | 2 +- 55 files changed, 230 insertions(+), 501 deletions(-) create mode 100644 packages/core/src/config/paths.ts create mode 100644 packages/core/src/integrations/skills/references.ts delete mode 100644 packages/quickstart/__tests__/e2e/download-fallback.e2e.ts diff --git a/packages/cli/__tests__/commands/migrate.test.ts b/packages/cli/__tests__/commands/migrate.test.ts index d87d80da..23f0b4a0 100644 --- a/packages/cli/__tests__/commands/migrate.test.ts +++ b/packages/cli/__tests__/commands/migrate.test.ts @@ -5,16 +5,21 @@ import { createMockIntegration } from '../helpers/mock-integration.js'; import { createRunner } from '../helpers/run-command.js'; const mockIntegration = createMockIntegration({ - detectPlugin: vi.fn().mockResolvedValue('cli'), + detectPlugin: vi.fn().mockResolvedValue(true), detectMcpStatuses: vi.fn().mockResolvedValue({ 'confidence-flags': 'connected', 'confidence-docs': 'connected', }), }); -const mockDetectProviders = vi - .fn() - .mockReturnValue([{ id: 'statsig', name: 'Statsig', skillName: 'migrate-statsig' }]); +const mockHasSkills = vi.fn().mockReturnValue(true); +const mockDetectProviders = vi.fn().mockReturnValue([ + { + id: 'statsig', + name: 'Statsig', + skillName: 'migrate-statsig', + }, +]); const mockAuthenticate = vi.fn().mockResolvedValue({ accessToken: buildTestJwt({ email: 'test@example.com' }), @@ -27,6 +32,7 @@ vi.mock('@spotify-confidence/core', async (importOriginal) => { ...actual, getIntegration: () => mockIntegration, getIntegrations: () => [mockIntegration], + hasSkills: () => mockHasSkills(), detectProviders: (...args: unknown[]) => mockDetectProviders(...args), authenticate: (...args: unknown[]) => mockAuthenticate(...args), }; @@ -109,11 +115,11 @@ describe('migrate ', () => { it('fails when plugin is not installed', async () => { using _auth = prepareAuthTokens('valid'); using output = captureOutput(); - vi.mocked(mockIntegration.detectPlugin).mockResolvedValueOnce(null); + mockHasSkills.mockReturnValueOnce(false); await run(['migrate', 'statsig', '--ide', 'claude']); - expect(output.stderr).toContain('Confidence AI plugin not installed'); + expect(output.stderr).toContain('Confidence skills not installed'); expect(output.stderr).toContain('confidence quickstart'); expect(mockIntegration.launchChat).not.toHaveBeenCalled(); }); diff --git a/packages/cli/__tests__/commands/warehouse-setup.test.ts b/packages/cli/__tests__/commands/warehouse-setup.test.ts index a74ae39b..41cc093b 100644 --- a/packages/cli/__tests__/commands/warehouse-setup.test.ts +++ b/packages/cli/__tests__/commands/warehouse-setup.test.ts @@ -5,13 +5,14 @@ import { createMockIntegration } from '../helpers/mock-integration.js'; import { createRunner } from '../helpers/run-command.js'; const mockIntegration = createMockIntegration({ - detectPlugin: vi.fn().mockResolvedValue('cli'), + detectPlugin: vi.fn().mockResolvedValue(true), detectMcpStatuses: vi.fn().mockResolvedValue({ 'confidence-flags': 'connected', 'confidence-docs': 'connected', }), }); +const mockHasSkills = vi.fn().mockReturnValue(true); const mockAuthenticate = vi.fn().mockResolvedValue({ accessToken: buildTestJwt({ email: 'test@example.com' }), region: 'EU', @@ -23,6 +24,7 @@ vi.mock('@spotify-confidence/core', async (importOriginal) => { ...actual, getIntegration: () => mockIntegration, getIntegrations: () => [mockIntegration], + hasSkills: () => mockHasSkills(), authenticate: (...args: unknown[]) => mockAuthenticate(...args), }; }); @@ -71,11 +73,11 @@ describe('warehouse setup', () => { it('fails when plugin is not installed', async () => { using _auth = prepareAuthTokens('valid'); using output = captureOutput(); - vi.mocked(mockIntegration.detectPlugin).mockResolvedValueOnce(null); + mockHasSkills.mockReturnValueOnce(false); await run(['warehouse', 'setup', '--warehouse-type', 'bigquery', '--ide', 'claude']); - expect(output.stderr).toContain('Confidence AI plugin not installed'); + expect(output.stderr).toContain('Confidence skills not installed'); expect(mockIntegration.launchChat).not.toHaveBeenCalled(); }); diff --git a/packages/cli/__tests__/helpers/mock-integration.ts b/packages/cli/__tests__/helpers/mock-integration.ts index 2c5a0858..cdb8af11 100644 --- a/packages/cli/__tests__/helpers/mock-integration.ts +++ b/packages/cli/__tests__/helpers/mock-integration.ts @@ -7,8 +7,7 @@ export function createMockIntegration(overrides?: Partial): IdeI launchChat: vi.fn(), runOnboarding: vi.fn().mockReturnValue(null), prepare: vi.fn().mockResolvedValue(undefined), - skillsDir: vi.fn().mockReturnValue('/project/.claude/skills'), - detectPlugin: vi.fn().mockResolvedValue(null), + detectPlugin: vi.fn().mockResolvedValue(false), installPlugin: vi.fn().mockResolvedValue(undefined), updatePlugin: vi.fn().mockResolvedValue(undefined), uninstallPlugin: vi.fn().mockResolvedValue(undefined), diff --git a/packages/cli/src/features/ide/launch-skill-chat.ts b/packages/cli/src/features/ide/launch-skill-chat.ts index a2ced602..1b08158c 100644 --- a/packages/cli/src/features/ide/launch-skill-chat.ts +++ b/packages/cli/src/features/ide/launch-skill-chat.ts @@ -1,4 +1,4 @@ -import { getIntegration } from '@spotify-confidence/core'; +import { getIntegration, hasSkills } 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'; @@ -11,10 +11,9 @@ export async function launchSkillChat( const projectDir = resolveProjectDir(argv); const integration = getIntegration(ideId); - const plugin = await integration.detectPlugin(projectDir); - if (!plugin) { + if (!hasSkills()) { fail( - 'Confidence AI plugin not installed. Run "confidence plugin install" to install it or "confidence quickstart" to set up your project first.', + 'Confidence skills not installed. Run "confidence plugin install" to install them or "confidence quickstart" to set up your project first.', ); return; } diff --git a/packages/cli/src/features/migrate/launch.ts b/packages/cli/src/features/migrate/launch.ts index 65f16aa7..472ddda2 100644 --- a/packages/cli/src/features/migrate/launch.ts +++ b/packages/cli/src/features/migrate/launch.ts @@ -1,10 +1,10 @@ -import type { ProviderConfig } from '@spotify-confidence/core'; +import { type ProviderConfig, followInstruction } from '@spotify-confidence/core'; import { launchSkillChat } from '@features/ide/index.js'; function buildMigrationPrompt(provider: ProviderConfig) { + const skillRef = followInstruction(provider.skillName); return `I want to migrate this project from ${provider.name} (${provider.id}) to Confidence. -Use the "${provider.skillName}" skill from the Confidence plugin to run the migration. -Scan the codebase for ${provider.name} SDK usage, replace it with the Confidence SDK, and clean up removed dependencies. +${skillRef} — scan the codebase for ${provider.name} SDK usage, replace it with the Confidence SDK, and clean up removed dependencies. Use the Confidence MCP tools for SDK references and flag management.`; } diff --git a/packages/cli/src/features/plugin/install.ts b/packages/cli/src/features/plugin/install.ts index 309ce0d8..826bb18b 100644 --- a/packages/cli/src/features/plugin/install.ts +++ b/packages/cli/src/features/plugin/install.ts @@ -11,8 +11,8 @@ export async function installPluginForIde( const spinner = ora(`Installing Confidence plugin for ${integration.name}…`).start(); try { - const method = await installPlugin(ideId, projectDir, scope); - spinner.succeed(`Confidence plugin installed for ${integration.name} (via ${method})`); + await installPlugin(ideId, projectDir, scope); + spinner.succeed(`Confidence plugin installed for ${integration.name}`); } catch (err) { spinner.fail(); throw err; diff --git a/packages/cli/src/features/plugin/update.ts b/packages/cli/src/features/plugin/update.ts index 2c685069..b0f2f33a 100644 --- a/packages/cli/src/features/plugin/update.ts +++ b/packages/cli/src/features/plugin/update.ts @@ -11,8 +11,8 @@ export async function updatePluginForIde( const spinner = ora(`Updating Confidence plugin for ${integration.name}…`).start(); try { - const method = await updatePlugin(ideId, projectDir, scope); - spinner.succeed(`Confidence plugin updated for ${integration.name} (via ${method})`); + await updatePlugin(ideId, projectDir, scope); + spinner.succeed(`Confidence plugin updated for ${integration.name}`); } catch (err) { spinner.fail(); throw err; diff --git a/packages/cli/src/features/warehouse/setup.ts b/packages/cli/src/features/warehouse/setup.ts index bdcefdef..57088db8 100644 --- a/packages/cli/src/features/warehouse/setup.ts +++ b/packages/cli/src/features/warehouse/setup.ts @@ -1,4 +1,4 @@ -import { PLUGIN_NAME } from '@spotify-confidence/core'; +import { followInstruction } from '@spotify-confidence/core'; import { launchSkillChat } from '@features/ide/index.js'; import type { WarehouseType } from './types.js'; import { validateWarehouseType } from './utils.js'; @@ -11,10 +11,9 @@ const WAREHOUSE_SKILLS: Record = { }; function buildSetupPrompt(warehouseType: WarehouseType): string { - const skill = WAREHOUSE_SKILLS[warehouseType]; + const skillRef = followInstruction(WAREHOUSE_SKILLS[warehouseType]); return `Set up a ${warehouseType} data warehouse for Confidence experimentation analytics. -Use the "${skill}" skill from the ${PLUGIN_NAME} plugin to guide the setup process. -Follow the skill instructions step by step — collect configuration, validate, create the warehouse, set up connectors, and verify the pipeline.`; +${skillRef} step by step: collect configuration, validate, create the warehouse, set up connectors, and verify the pipeline.`; } export async function runWarehouseSetup(argv: Record): Promise { diff --git a/packages/core/__tests__/auth/credentials/store.test.ts b/packages/core/__tests__/auth/credentials/store.test.ts index a44e083d..2090ce08 100644 --- a/packages/core/__tests__/auth/credentials/store.test.ts +++ b/packages/core/__tests__/auth/credentials/store.test.ts @@ -1,11 +1,11 @@ import { existsSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { prepareAuthTokens } from '@spotify-confidence/testing/auth'; +import { getConfigDir } from '@config/index.js'; import { readCredentials, writeCredentials, clearTokens, - getConfigDir, credentialsPath, } from '@auth/credentials/index.js'; diff --git a/packages/core/__tests__/integrations/plugins.test.ts b/packages/core/__tests__/integrations/plugins.test.ts index c5c9a311..a281db3f 100644 --- a/packages/core/__tests__/integrations/plugins.test.ts +++ b/packages/core/__tests__/integrations/plugins.test.ts @@ -1,10 +1,15 @@ import { installPlugin, uninstallPlugin, updatePlugin } from '@integrations/skills/plugin.js'; import type { IdeIntegration } from '@integrations/types.js'; +vi.mock('../../src/telemetry/telemetry.js', () => ({ + track: vi.fn(), +})); + vi.mock('../../src/integrations/skills/local.js', () => ({ downloadSkills: vi.fn().mockResolvedValue(undefined), removeSkills: vi.fn().mockResolvedValue(undefined), - hasDownloadedSkills: vi.fn().mockReturnValue(false), + hasSkills: vi.fn().mockReturnValue(false), + getSkillsDir: vi.fn().mockReturnValue('/mock-home/.config/confidence/skills'), })); const mockIntegration: IdeIntegration = { @@ -13,8 +18,7 @@ const mockIntegration: IdeIntegration = { launchChat: vi.fn(), runOnboarding: vi.fn().mockReturnValue(null), prepare: vi.fn().mockResolvedValue(undefined), - skillsDir: vi.fn().mockReturnValue('/project/.claude/skills'), - detectPlugin: vi.fn().mockResolvedValue(null), + detectPlugin: vi.fn().mockResolvedValue(false), installPlugin: vi.fn().mockResolvedValue(undefined), updatePlugin: vi.fn().mockResolvedValue(undefined), uninstallPlugin: vi.fn().mockResolvedValue(undefined), @@ -33,81 +37,67 @@ beforeEach(() => { }); describe('installPlugin', () => { - it('returns cli when IDE install succeeds', async () => { - const sut = await installPlugin('claude', '/project'); + it('attempts CLI install and downloads skills', async () => { + await installPlugin('claude', '/project'); - expect(sut).toBe('cli'); expect(mockIntegration.installPlugin).toHaveBeenCalledWith('/project', undefined); }); - it('falls back to download on any error', async () => { + it('always downloads skills to the shared directory', async () => { const { downloadSkills } = await import('../../src/integrations/skills/local.js'); - vi.mocked(mockIntegration.installPlugin).mockRejectedValueOnce(new Error('network error')); - const sut = await installPlugin('claude', '/project'); + await installPlugin('claude', '/project'); - expect(sut).toBe('download'); - expect(downloadSkills).toHaveBeenCalledWith('/project/.claude/skills'); + expect(downloadSkills).toHaveBeenCalledWith('/mock-home/.config/confidence/skills'); }); -}); -describe('uninstallPlugin', () => { - it('calls CLI uninstall and removes skills when installed via cli', async () => { - const { removeSkills } = await import('../../src/integrations/skills/local.js'); - vi.mocked(mockIntegration.detectPlugin).mockResolvedValueOnce('cli'); + it('still downloads skills when CLI install fails', async () => { + const { downloadSkills } = await import('../../src/integrations/skills/local.js'); + vi.mocked(mockIntegration.installPlugin).mockRejectedValueOnce(new Error('network error')); - await uninstallPlugin('claude', '/project'); + await installPlugin('claude', '/project'); - expect(mockIntegration.uninstallPlugin).toHaveBeenCalledWith('/project', undefined); - expect(removeSkills).toHaveBeenCalledWith('/project/.claude/skills'); + expect(downloadSkills).toHaveBeenCalledWith('/mock-home/.config/confidence/skills'); }); +}); - it('only removes downloaded skills when installed via download', async () => { +describe('uninstallPlugin', () => { + it('calls CLI uninstall and removes skills when CLI plugin is detected', async () => { const { removeSkills } = await import('../../src/integrations/skills/local.js'); - vi.mocked(mockIntegration.detectPlugin).mockResolvedValueOnce('download'); + vi.mocked(mockIntegration.detectPlugin).mockResolvedValueOnce(true); await uninstallPlugin('claude', '/project'); - expect(mockIntegration.uninstallPlugin).not.toHaveBeenCalled(); - expect(removeSkills).toHaveBeenCalledWith('/project/.claude/skills'); + expect(mockIntegration.uninstallPlugin).toHaveBeenCalledWith('/project', undefined); + expect(removeSkills).toHaveBeenCalledWith('/mock-home/.config/confidence/skills'); }); - it('only removes downloaded skills when no plugin detected', async () => { + it('only removes skills when CLI plugin is not detected', async () => { const { removeSkills } = await import('../../src/integrations/skills/local.js'); - vi.mocked(mockIntegration.detectPlugin).mockResolvedValueOnce(null); await uninstallPlugin('claude', '/project'); expect(mockIntegration.uninstallPlugin).not.toHaveBeenCalled(); - expect(removeSkills).toHaveBeenCalledWith('/project/.claude/skills'); - }); - - it('does not remove local skills when scope is not project', async () => { - const { removeSkills } = await import('../../src/integrations/skills/local.js'); - vi.mocked(mockIntegration.detectPlugin).mockResolvedValueOnce('cli'); - - await uninstallPlugin('claude', '/project', 'global'); - - expect(mockIntegration.uninstallPlugin).toHaveBeenCalledWith('/project', 'global'); - expect(removeSkills).not.toHaveBeenCalled(); + expect(removeSkills).toHaveBeenCalledWith('/mock-home/.config/confidence/skills'); }); }); describe('updatePlugin', () => { - it('returns cli when IDE update succeeds', async () => { - const sut = await updatePlugin('claude', '/project'); + it('attempts CLI update and force-downloads skills', async () => { + const { downloadSkills } = await import('../../src/integrations/skills/local.js'); + + await updatePlugin('claude', '/project'); - expect(sut).toBe('cli'); expect(mockIntegration.updatePlugin).toHaveBeenCalledWith('/project', undefined); + expect(downloadSkills).toHaveBeenCalledWith('/mock-home/.config/confidence/skills', true); }); - it('falls back to download on any error', async () => { + it('still downloads skills when CLI update fails', async () => { const { downloadSkills } = await import('../../src/integrations/skills/local.js'); vi.mocked(mockIntegration.updatePlugin).mockRejectedValueOnce(new Error('network error')); - const sut = await updatePlugin('claude', '/project'); + await updatePlugin('claude', '/project'); - expect(sut).toBe('download'); - expect(downloadSkills).toHaveBeenCalledWith('/project/.claude/skills', true); + expect(downloadSkills).toHaveBeenCalledWith('/mock-home/.config/confidence/skills', true); }); }); diff --git a/packages/core/src/auth/credentials/index.ts b/packages/core/src/auth/credentials/index.ts index 0e12ffd4..2c30f636 100644 --- a/packages/core/src/auth/credentials/index.ts +++ b/packages/core/src/auth/credentials/index.ts @@ -1,4 +1,4 @@ -export { getConfigDir, credentialsPath } from './paths.js'; +export { credentialsPath } from './paths.js'; export { ensureDir, readCredentials, diff --git a/packages/core/src/auth/credentials/paths.ts b/packages/core/src/auth/credentials/paths.ts index 6eb73146..b8edab91 100644 --- a/packages/core/src/auth/credentials/paths.ts +++ b/packages/core/src/auth/credentials/paths.ts @@ -1,10 +1,5 @@ -import { homedir } from 'node:os'; import { join } from 'node:path'; -import { env } from '../../system/env.js'; - -export function getConfigDir(): string { - return env('CONFIDENCE_CONFIG_DIR') ?? join(homedir(), '.config', 'confidence'); -} +import { getConfigDir } from '../../config/paths.js'; const VALID_PROFILE = /^[a-z0-9_-]+$/; diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts index 781007d6..9c351769 100644 --- a/packages/core/src/auth/index.ts +++ b/packages/core/src/auth/index.ts @@ -4,6 +4,6 @@ export { AUTH_CALLBACK_PORT, type AuthResult, } from './authenticate/index.js'; -export { ensureDir, loadPersistedToken, clearTokens, getConfigDir } from './credentials/index.js'; +export { ensureDir, loadPersistedToken, clearTokens } from './credentials/index.js'; export { decodeJwtPayload, extractRegion, validateToken } from './jwt.js'; export { successPage, errorPage, exchangeErrorPage } from './callback-pages.js'; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 1eb0cf84..f26c1f36 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1,7 +1,7 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { ensureDir } from '../auth/credentials/store.js'; -import { getConfigDir } from '../auth/credentials/paths.js'; +import { getConfigDir } from './paths.js'; type ConfigKey = 'project' | 'environment' | 'output' | 'profile' | 'ide'; diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 8717de79..182e44b1 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -1,3 +1,4 @@ +export { getConfigDir } from './paths.js'; export { readConfig, getConfigValue, diff --git a/packages/core/src/config/paths.ts b/packages/core/src/config/paths.ts new file mode 100644 index 00000000..318b50ea --- /dev/null +++ b/packages/core/src/config/paths.ts @@ -0,0 +1,7 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { env } from '../system/env.js'; + +export function getConfigDir(): string { + return env('CONFIDENCE_CONFIG_DIR') ?? join(homedir(), '.config', 'confidence'); +} diff --git a/packages/core/src/integrations/chat.ts b/packages/core/src/integrations/chat.ts index 6702a069..dc5e33d6 100644 --- a/packages/core/src/integrations/chat.ts +++ b/packages/core/src/integrations/chat.ts @@ -1,8 +1,9 @@ import type { IdeId } from '@spotify-confidence/shared-kernel'; import type { WizardSession } from '../session/session.js'; import { getIntegration } from './registry.js'; +import { skillInvocation } from './skills/references.js'; -function buildChatPrompt(session: WizardSession): string { +function buildChatPrompt(session: WizardSession, ide: IdeId): string { const lines = session.codeChanges.length <= 0 ? [`I'd like to integrate Confidence into this project.`] @@ -29,13 +30,13 @@ function buildChatPrompt(session: WizardSession): string { ); if (session.pluginTargets.length) { + const warehouseCmd = skillInvocation('setup-warehouse', ide); + const migrateHint = session.detectedProviders.length + ? ` or \`${skillInvocation(`migrate-${session.detectedProviders[0].id}`, ide)}\` to migrate another provider's flags to Confidence` + : ''; lines.push( - 'I have installed Confidence AI plugin that contains useful skills and commands for working with Confidence,', - 'for example, `/setup-warehouse` for setting up a data warehouse', - - session.detectedProviders.length - ? "or `/migrate-` to migrate another provider's flags to Confidence." - : '.', + `I have installed Confidence AI plugin with skills and commands for working with Confidence,`, + `for example \`${warehouseCmd}\` for setting up a data warehouse${migrateHint}.`, ); } } @@ -46,7 +47,7 @@ function buildChatPrompt(session: WizardSession): string { export function launchChatSession(session: WizardSession, ide: IdeId): void { const integration = getIntegration(ide); integration.launchChat({ - systemPrompt: buildChatPrompt(session), + systemPrompt: buildChatPrompt(session, ide), cwd: session.projectDir, token: session.authState.token, }); diff --git a/packages/core/src/integrations/claude/index.ts b/packages/core/src/integrations/claude/index.ts index 01749dc1..74c93afb 100644 --- a/packages/core/src/integrations/claude/index.ts +++ b/packages/core/src/integrations/claude/index.ts @@ -1,7 +1,6 @@ import type { IdeIntegration } from '../types.js'; import { launchChat } from './chat.js'; import { detectPlugin, installPlugin, updatePlugin, uninstallPlugin } from './plugins.js'; -import { skillsDir } from './paths.js'; import { detectMcpStatuses, connectMcpServer, disconnectMcpServer } from './mcp/index.js'; import { runOnboarding } from './onboarding.js'; import { prepare } from './prepare.js'; @@ -13,7 +12,6 @@ export const claudeIntegration: IdeIntegration = { launchChat, runOnboarding, prepare, - skillsDir, detectPlugin, installPlugin, updatePlugin, diff --git a/packages/core/src/integrations/claude/paths.ts b/packages/core/src/integrations/claude/paths.ts index 2b6c8241..f8711db9 100644 --- a/packages/core/src/integrations/claude/paths.ts +++ b/packages/core/src/integrations/claude/paths.ts @@ -12,7 +12,3 @@ export function projectConfigPath(projectDir: string): string { export function mcpConfigPath(projectDir: string): string { return join(projectDir, '.claude', 'settings.local.json'); } - -export function skillsDir(projectDir: string): string { - return join(projectDir, '.claude', 'skills'); -} diff --git a/packages/core/src/integrations/claude/plugins.ts b/packages/core/src/integrations/claude/plugins.ts index 1d27e21a..3b4f9506 100644 --- a/packages/core/src/integrations/claude/plugins.ts +++ b/packages/core/src/integrations/claude/plugins.ts @@ -1,9 +1,7 @@ import { resolve } from 'node:path'; +import type { PluginScope } from '@spotify-confidence/shared-kernel'; import { execFile } from '../../exec/exec.js'; import { PLUGIN_NAME } from '../../constants.js'; -import type { PluginInstallationMethod, PluginScope } from '@spotify-confidence/shared-kernel'; -import { hasDownloadedSkills } from '../skills/local.js'; -import { skillsDir } from './paths.js'; const SCOPE_MAP: Record = { project: 'project', @@ -18,18 +16,14 @@ type PluginEntry = { projectPath?: string; }; -export async function detectPlugin(projectDir: string): Promise { +export async function detectPlugin(projectDir: string): Promise { try { - const cwd = projectDir; - const { stdout } = await execFile('claude', ['plugin', 'list', '--json'], { cwd }); + const { stdout } = await execFile('claude', ['plugin', 'list', '--json'], { cwd: projectDir }); const plugins = JSON.parse(stdout) as PluginEntry[]; - - if (plugins.some((p) => isAvailable(p, projectDir))) return 'cli'; + return plugins.some((p) => isAvailable(p, projectDir)); } catch { - // CLI unavailable; fallback to locally downloaded files. + return false; } - - return hasDownloadedSkills(skillsDir(projectDir)) ? 'download' : null; } export async function installPlugin( diff --git a/packages/core/src/integrations/codex/index.ts b/packages/core/src/integrations/codex/index.ts index 95d42c6a..8a499754 100644 --- a/packages/core/src/integrations/codex/index.ts +++ b/packages/core/src/integrations/codex/index.ts @@ -1,7 +1,6 @@ import type { IdeIntegration } from '../types.js'; import { launchChat } from './chat.js'; import { detectPlugin, installPlugin, updatePlugin, uninstallPlugin } from './plugins.js'; -import { skillsDir } from './paths.js'; import { detectMcpStatuses, connectMcpServer, disconnectMcpServer } from './mcp/index.js'; import { runOnboarding } from './onboarding.js'; import { prepare } from './prepare.js'; @@ -13,7 +12,6 @@ export const codexIntegration: IdeIntegration = { launchChat, runOnboarding, prepare, - skillsDir, detectPlugin, installPlugin, updatePlugin, diff --git a/packages/core/src/integrations/codex/paths.ts b/packages/core/src/integrations/codex/paths.ts index 5e78bfd5..6c8b7119 100644 --- a/packages/core/src/integrations/codex/paths.ts +++ b/packages/core/src/integrations/codex/paths.ts @@ -8,7 +8,3 @@ export function globalConfigPath(): string { export function projectConfigPath(projectDir: string): string { return join(projectDir, '.codex', 'config.toml'); } - -export function skillsDir(projectDir: string): string { - return join(projectDir, '.agents', 'skills'); -} diff --git a/packages/core/src/integrations/codex/plugins.ts b/packages/core/src/integrations/codex/plugins.ts index 338e6b40..7d441503 100644 --- a/packages/core/src/integrations/codex/plugins.ts +++ b/packages/core/src/integrations/codex/plugins.ts @@ -1,20 +1,14 @@ import { execFile } from '../../exec/exec.js'; import { PLUGIN_MARKETPLACE_REPO, PLUGIN_MARKETPLACE_NAME, PLUGIN_NAME } from '../../constants.js'; -import type { PluginInstallationMethod } from '@spotify-confidence/shared-kernel'; -import { hasDownloadedSkills } from '../skills/local.js'; -import { skillsDir } from './paths.js'; -export async function detectPlugin(projectDir: string): Promise { +export async function detectPlugin(projectDir: string): Promise { try { - const cwd = projectDir; - const { stdout } = await execFile('codex', ['plugin', 'list', '--json'], { cwd }); + const { stdout } = await execFile('codex', ['plugin', 'list', '--json'], { cwd: projectDir }); const { installed } = JSON.parse(stdout) as { installed: Array<{ pluginId: string }> }; - if (installed.some((p) => p.pluginId.startsWith(`${PLUGIN_NAME}@`))) return 'cli'; + return installed.some((p) => p.pluginId.startsWith(`${PLUGIN_NAME}@`)); } catch { - // CLI unavailable; fallback to locally downloaded files. + return false; } - - return hasDownloadedSkills(skillsDir(projectDir)) ? 'download' : null; } export async function installPlugin(projectDir: string): Promise { diff --git a/packages/core/src/integrations/cursor/index.ts b/packages/core/src/integrations/cursor/index.ts index d3a30ea4..1b8b1543 100644 --- a/packages/core/src/integrations/cursor/index.ts +++ b/packages/core/src/integrations/cursor/index.ts @@ -3,7 +3,6 @@ import { launchChat } from './chat.js'; import { detectMcpStatuses, connectMcpServer, disconnectMcpServer } from './mcp/index.js'; import { runOnboarding } from './onboarding.js'; import { detectPlugin, installPlugin, updatePlugin, uninstallPlugin } from './plugins.js'; -import { skillsDir } from './paths.js'; import { prepare } from './prepare.js'; export const cursorIntegration: IdeIntegration = { @@ -13,7 +12,6 @@ export const cursorIntegration: IdeIntegration = { launchChat, runOnboarding, prepare, - skillsDir, detectPlugin, installPlugin, updatePlugin, diff --git a/packages/core/src/integrations/cursor/paths.ts b/packages/core/src/integrations/cursor/paths.ts index d812619d..6bd731c1 100644 --- a/packages/core/src/integrations/cursor/paths.ts +++ b/packages/core/src/integrations/cursor/paths.ts @@ -8,7 +8,3 @@ export function globalConfigPath(): string { export function cliConfigPath(projectDir: string): string { return join(projectDir, '.cursor', 'cli.json'); } - -export function skillsDir(projectDir: string): string { - return join(projectDir, '.cursor', 'skills'); -} diff --git a/packages/core/src/integrations/cursor/plugins.ts b/packages/core/src/integrations/cursor/plugins.ts index e22a3c58..2c645bcf 100644 --- a/packages/core/src/integrations/cursor/plugins.ts +++ b/packages/core/src/integrations/cursor/plugins.ts @@ -1,11 +1,8 @@ import { execFile } from '../../exec/exec.js'; import { PLUGIN_REPO_URL } from '../../constants.js'; -import type { PluginInstallationMethod } from '@spotify-confidence/shared-kernel'; -import { hasDownloadedSkills } from '../skills/local.js'; -import { skillsDir } from './paths.js'; -export async function detectPlugin(projectDir: string): Promise { - return hasDownloadedSkills(skillsDir(projectDir)) ? 'download' : null; +export async function detectPlugin(_projectDir: string): Promise { + return false; } export async function installPlugin(projectDir: string): Promise { diff --git a/packages/core/src/integrations/index.ts b/packages/core/src/integrations/index.ts index 97688acf..18cdd324 100644 --- a/packages/core/src/integrations/index.ts +++ b/packages/core/src/integrations/index.ts @@ -4,7 +4,6 @@ export type { McpDisconnectOpts, OnboardingOpts, OnboardingCallbacks, - InstalledPlugin, } from './types.js'; export { getIntegrations, getIntegration } from './registry.js'; @@ -30,4 +29,8 @@ export { installPlugin, uninstallPlugin, updatePlugin, + hasSkills, + skillInvocation, + referenceInstruction, + followInstruction, } from './skills/index.js'; diff --git a/packages/core/src/integrations/mcp/preference.ts b/packages/core/src/integrations/mcp/preference.ts index ec2fc569..22d453a7 100644 --- a/packages/core/src/integrations/mcp/preference.ts +++ b/packages/core/src/integrations/mcp/preference.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; -import { getConfigDir } from '../../auth/credentials/paths.js'; +import { getConfigDir } from '../../config/paths.js'; type McpPreference = 'connected' | 'skipped'; diff --git a/packages/core/src/integrations/skills/index.ts b/packages/core/src/integrations/skills/index.ts index 1faf73f3..6c511b2e 100644 --- a/packages/core/src/integrations/skills/index.ts +++ b/packages/core/src/integrations/skills/index.ts @@ -1,3 +1,4 @@ +export { hasSkills } from './local.js'; export { detectInstalledPlugins, prepareIde, @@ -5,3 +6,4 @@ export { uninstallPlugin, updatePlugin, } from './plugin.js'; +export { skillInvocation, referenceInstruction, followInstruction } from './references.js'; diff --git a/packages/core/src/integrations/skills/local.ts b/packages/core/src/integrations/skills/local.ts index 29da2989..4ba649aa 100644 --- a/packages/core/src/integrations/skills/local.ts +++ b/packages/core/src/integrations/skills/local.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { SKILLS_BASE_URL } from '../../constants.js'; +import { getConfigDir } from '../../config/paths.js'; const SKILL_NAMES = [ 'analyze-project', @@ -18,8 +19,12 @@ const SKILL_NAMES = [ 'migrate-statsig', ] as const; -export function hasDownloadedSkills(skillsDir: string): boolean { - return SKILL_NAMES.some((name) => existsSync(join(skillsDir, name, 'SKILL.md'))); +export function getSkillsDir(): string { + return join(getConfigDir(), 'skills'); +} + +export function hasSkills(): boolean { + return SKILL_NAMES.some((name) => existsSync(join(getSkillsDir(), name, 'SKILL.md'))); } export async function removeSkills(skillsDir: string): Promise { diff --git a/packages/core/src/integrations/skills/plugin.ts b/packages/core/src/integrations/skills/plugin.ts index c01384a0..a3d173d3 100644 --- a/packages/core/src/integrations/skills/plugin.ts +++ b/packages/core/src/integrations/skills/plugin.ts @@ -1,18 +1,13 @@ -import type { - IdeId, - PluginInstallationMethod, - PluginScope, -} from '@spotify-confidence/shared-kernel'; -import type { InstalledPlugin } from '../types.js'; +import type { IdeId, PluginScope } from '@spotify-confidence/shared-kernel'; import { getIntegration, getIntegrations } from '../registry.js'; -import { downloadSkills, removeSkills } from './local.js'; +import { track } from '../../telemetry/telemetry.js'; +import { downloadSkills, getSkillsDir, removeSkills } from './local.js'; -export async function detectInstalledPlugins(projectDir: string): Promise { - return ( - await Promise.all( - getIntegrations().map(async (i) => ({ ide: i.id, via: await i.detectPlugin(projectDir) })), - ) - ).filter((plugin): plugin is InstalledPlugin => !!plugin.via); +export async function detectInstalledPlugins(projectDir: string): Promise { + const results = await Promise.all( + getIntegrations().map(async (i) => ((await i.detectPlugin(projectDir)) ? i.id : null)), + ); + return results.filter((id): id is IdeId => id !== null); } export function prepareIde(ide: IdeId): Promise { @@ -23,16 +18,16 @@ export async function installPlugin( ide: IdeId, projectDir: string, scope?: PluginScope, -): Promise { +): Promise { const integration = getIntegration(ide); try { await integration.installPlugin(projectDir, scope); - return 'cli'; } catch { - await downloadSkills(integration.skillsDir(projectDir)); - return 'download'; + track({ step: 'plugin.install', action: `cli-failed:${ide}`, sentiment: 'frustrated' }); } + + await downloadSkills(getSkillsDir()); } export async function uninstallPlugin( @@ -41,29 +36,30 @@ export async function uninstallPlugin( scope?: PluginScope, ): Promise { const integration = getIntegration(ide); - const method = await integration.detectPlugin(projectDir); - if (method === 'cli') { - await integration.uninstallPlugin(projectDir, scope); + if (await integration.detectPlugin(projectDir)) { + try { + await integration.uninstallPlugin(projectDir, scope); + } catch { + track({ step: 'plugin.uninstall', action: `cli-failed:${ide}`, sentiment: 'frustrated' }); + } } - if (method === 'download' || !scope || scope === 'project') { - await removeSkills(integration.skillsDir(projectDir)); - } + await removeSkills(getSkillsDir()); } export async function updatePlugin( ide: IdeId, projectDir: string, scope?: PluginScope, -): Promise { +): Promise { const integration = getIntegration(ide); try { await integration.updatePlugin(projectDir, scope); - return 'cli'; } catch { - await downloadSkills(integration.skillsDir(projectDir), true); - return 'download'; + track({ step: 'plugin.update', action: `cli-failed:${ide}`, sentiment: 'frustrated' }); } + + await downloadSkills(getSkillsDir(), true); } diff --git a/packages/core/src/integrations/skills/references.ts b/packages/core/src/integrations/skills/references.ts new file mode 100644 index 00000000..8b9f1d73 --- /dev/null +++ b/packages/core/src/integrations/skills/references.ts @@ -0,0 +1,26 @@ +import { join } from 'node:path'; +import type { IdeId } from '@spotify-confidence/shared-kernel'; +import { PLUGIN_NAME } from '../../constants.js'; +import { getSkillsDir } from './local.js'; + +const SKILL_INVOCATIONS: Record string> = { + claude: (skill) => `/${PLUGIN_NAME}:${skill}`, + codex: (skill) => `$${skill}`, + cursor: (skill) => `/${skill}`, +}; + +export function skillInvocation(skillName: string, ide: IdeId): string { + return SKILL_INVOCATIONS[ide](skillName); +} + +export function skillPath(skillName: string): string { + return join(getSkillsDir(), skillName, 'SKILL.md'); +} + +export function referenceInstruction(skillName: string): string { + return `Read \`${skillPath(skillName)}\` as a **methodology reference**`; +} + +export function followInstruction(skillName: string): string { + return `Read \`${skillPath(skillName)}\` and follow the skill instructions`; +} diff --git a/packages/core/src/integrations/types.ts b/packages/core/src/integrations/types.ts index ac4301ad..103f8101 100644 --- a/packages/core/src/integrations/types.ts +++ b/packages/core/src/integrations/types.ts @@ -1,16 +1,7 @@ import type { ChildProcess } from 'node:child_process'; -import type { - IdeId, - PluginInstallationMethod, - PluginScope, -} from '@spotify-confidence/shared-kernel'; +import type { IdeId, PluginScope } from '@spotify-confidence/shared-kernel'; import type { McpServerName, McpStatusMap } from './mcp/servers.js'; -export type InstalledPlugin = { - ide: IdeId; - via: PluginInstallationMethod; -}; - export type McpConnectOpts = { serverName: McpServerName; serverUrl: string; @@ -52,8 +43,7 @@ export type IdeIntegration = { prepare: () => Promise; - skillsDir: (projectDir: string) => string; - detectPlugin: (projectDir: string) => Promise; + detectPlugin: (projectDir: string) => Promise; installPlugin: (projectDir: string, scope?: PluginScope) => Promise; updatePlugin: (projectDir: string, scope?: PluginScope) => Promise; uninstallPlugin: (projectDir: string, scope?: PluginScope) => Promise; @@ -63,6 +53,5 @@ export type IdeIntegration = { disconnectMcpServer: (opts: McpDisconnectOpts) => Promise; runOnboarding: (opts: OnboardingOpts, callbacks: OnboardingCallbacks) => ChildProcess | null; - launchChat: (opts: ChatOpts) => void; }; diff --git a/packages/core/src/session/session.ts b/packages/core/src/session/session.ts index 1ede32f1..544bcbb0 100644 --- a/packages/core/src/session/session.ts +++ b/packages/core/src/session/session.ts @@ -3,7 +3,6 @@ import type { AuthState, IdeId, OnboardingGoal, - PluginInstallationMethod, DetectedProvider, } from '@spotify-confidence/shared-kernel'; @@ -75,11 +74,6 @@ export type WizardSession = { * @default [] */ pluginTargets: IdeId[]; - /** - * How plugins were installed — via CLI marketplace or local download. - * @default null - */ - pluginInstallMethod: PluginInstallationMethod | null; /** * MCP server names that were successfully connected. * @default [] @@ -175,7 +169,6 @@ export function createSession(opts?: { authState: { status: 'idle' }, ide: opts?.ide ?? null, pluginTargets: [], - pluginInstallMethod: null, connectedMcps: [], isEmptyProject: false, detectedProviders: [], diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 723fa039..40ce2d89 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -13,7 +13,8 @@ "@frameworks/*": ["./src/frameworks/*"], "@integrations/*": ["./src/integrations/*"], "@providers/*": ["./src/providers/*"], - "@api/*": ["./src/api/*"] + "@api/*": ["./src/api/*"], + "@config/*": ["./src/config/*"] } }, "include": ["src", "__tests__", "types", "vitest.config.ts"] diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 601afc14..34305360 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ '@integrations': fileURLToPath(new URL('./src/integrations', import.meta.url)), '@providers': fileURLToPath(new URL('./src/providers', import.meta.url)), '@api': fileURLToPath(new URL('./src/api', import.meta.url)), + '@config': fileURLToPath(new URL('./src/config', import.meta.url)), }, }, test: { diff --git a/packages/quickstart/__tests__/e2e/chat-launch.e2e.ts b/packages/quickstart/__tests__/e2e/chat-launch.e2e.ts index 8fc3f14f..40ba73de 100644 --- a/packages/quickstart/__tests__/e2e/chat-launch.e2e.ts +++ b/packages/quickstart/__tests__/e2e/chat-launch.e2e.ts @@ -27,8 +27,8 @@ describe('when the user starts chat after onboarding', () => { expect(prompt).toContain('CONFIDENCE_QUICKSTART.md'); expect(prompt).toContain('Help me with next steps'); expect(prompt).toContain('Confidence AI plugin'); - expect(prompt).toContain('/setup-warehouse'); - expect(prompt).not.toContain('/migrate-'); + expect(prompt).toContain('/confidence:setup-warehouse'); + expect(prompt).not.toContain('migrate-'); }); it('includes migration hint when competitors are detected', async () => { @@ -46,8 +46,8 @@ describe('when the user starts chat after onboarding', () => { const prompt = readFileSync(join(session.cwd, CHAT_PROMPT_FILE), 'utf-8'); expect(prompt).toContain('Confidence AI plugin'); - expect(prompt).toContain('/setup-warehouse'); - expect(prompt).toContain('/migrate-'); + expect(prompt).toContain('/confidence:setup-warehouse'); + expect(prompt).toContain('/confidence:migrate-'); }); it('sends an integration prompt when onboarding was skipped', async () => { diff --git a/packages/quickstart/__tests__/e2e/download-fallback.e2e.ts b/packages/quickstart/__tests__/e2e/download-fallback.e2e.ts deleted file mode 100644 index 769a67f0..00000000 --- a/packages/quickstart/__tests__/e2e/download-fallback.e2e.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { - createSession, - navigateToPlugins, - selectIdeAndOnboard, - readInvocation, -} from '@spotify-confidence/testing/e2e'; - -describe('when CLI plugin install fails', () => { - it('falls back to download and references skills by file path in the prompt', async () => { - using session = createSession({ env: { E2E_PLUGIN_INSTALL_FAIL: '1' } }); - - await navigateToPlugins(session); - await selectIdeAndOnboard(session, 0); - - const invocation = readInvocation(session.cwd); - - expect(invocation.prompt).toContain('.claude/skills/analyze-project/SKILL.md'); - expect(invocation.prompt).not.toContain('`/analyze-project`'); - }); -}); diff --git a/packages/quickstart/__tests__/e2e/onboarding-invocation.e2e.ts b/packages/quickstart/__tests__/e2e/onboarding-invocation.e2e.ts index 5168fa2d..41a39ed1 100644 --- a/packages/quickstart/__tests__/e2e/onboarding-invocation.e2e.ts +++ b/packages/quickstart/__tests__/e2e/onboarding-invocation.e2e.ts @@ -13,7 +13,7 @@ const IDE_CASES = [ expectedArgs: ['--print', '--output-format', 'stream-json', '--verbose'], expectedPromptSnippets: [ 'Confidence SDK', - '/confidence:analyze-project', + '.config/confidence/skills/analyze-project/SKILL.md', 'existing codebase', 'mcp__confidence-flags__', ], @@ -25,7 +25,7 @@ const IDE_CASES = [ expectedArgs: ['--print', '--output-format', 'stream-json', '--approve-mcps', '--auto-review'], expectedPromptSnippets: [ 'Confidence SDK', - '.cursor/skills/analyze-project/SKILL.md', + '.config/confidence/skills/analyze-project/SKILL.md', 'existing codebase', 'mcp__confidence-flags__', ], @@ -46,7 +46,7 @@ const IDE_CASES = [ ], expectedPromptSnippets: [ 'Confidence SDK', - '$analyze-project', + '.config/confidence/skills/analyze-project/SKILL.md', 'existing codebase', 'confidence-flags:', ], diff --git a/packages/quickstart/__tests__/features/onboarding/build-prompt.test.ts b/packages/quickstart/__tests__/features/onboarding/build-prompt.test.ts index d0f85e17..17f77841 100644 --- a/packages/quickstart/__tests__/features/onboarding/build-prompt.test.ts +++ b/packages/quickstart/__tests__/features/onboarding/build-prompt.test.ts @@ -8,42 +8,12 @@ describe('buildOnboardingPrompt', () => { goals: ['feature-flags' as const], }; - describe('when plugins are installed via CLI', () => { - it('references analyze-project skill with plugin namespace for claude', () => { - const sut = buildOnboardingPrompt({ - ...baseOpts, - pluginInstallMethod: 'cli', - }); - - expect(sut).toContain( - 'Invoke the `/confidence:analyze-project` skill as a **methodology reference**', - ); - expect(sut).not.toContain('Read `.claude/skills/analyze-project/SKILL.md`'); - }); - }); - - describe('when plugins are installed via download', () => { - it('references analyze-project skill as a file path', () => { - const sut = buildOnboardingPrompt({ - ...baseOpts, - pluginInstallMethod: 'download', - }); - - expect(sut).toContain( - 'Read `.claude/skills/analyze-project/SKILL.md` as a **methodology reference**', - ); - expect(sut).not.toContain('Invoke the `/analyze-project`'); - }); - }); - - describe('when pluginInstallMethod is null', () => { - it('defaults to file path references', () => { - const sut = buildOnboardingPrompt({ - ...baseOpts, - pluginInstallMethod: null, - }); + describe('skill references', () => { + it('references analyze-project skill as an absolute file path', () => { + const sut = buildOnboardingPrompt(baseOpts); - expect(sut).toContain('Read `.claude/skills/analyze-project/SKILL.md`'); + expect(sut).toContain('.config/confidence/skills/analyze-project/SKILL.md'); + expect(sut).toContain('as a **methodology reference**'); }); it('asks the summary to list Confidence resources next to file changes', () => { @@ -69,66 +39,11 @@ describe('buildOnboardingPrompt', () => { goals: ['feature-flags' as const, 'event-tracking' as const], }; - it('uses namespaced slash command when installed via CLI', () => { - const sut = buildOnboardingPrompt({ - ...eventOpts, - pluginInstallMethod: 'cli', - }); - - expect(sut).toContain( - 'Invoke the `/confidence:instrument-events` skill as a **methodology reference**', - ); - }); + it('references instrument-events skill as an absolute file path', () => { + const sut = buildOnboardingPrompt(eventOpts); - it('uses file path when installed via download', () => { - const sut = buildOnboardingPrompt({ - ...eventOpts, - pluginInstallMethod: 'download', - }); - - expect(sut).toContain('Read `.claude/skills/instrument-events/SKILL.md`'); - }); - }); - - describe('with different IDEs', () => { - it('uses cursor skills dir for cursor with download method', () => { - const sut = buildOnboardingPrompt({ - ...baseOpts, - ide: 'cursor', - pluginInstallMethod: 'download', - }); - - expect(sut).toContain('Read `.cursor/skills/analyze-project/SKILL.md`'); - }); - - it('uses codex skills dir for codex with download method', () => { - const sut = buildOnboardingPrompt({ - ...baseOpts, - ide: 'codex', - pluginInstallMethod: 'download', - }); - - expect(sut).toContain('Read `.agents/skills/analyze-project/SKILL.md`'); - }); - - it('uses $ prefix for codex skill invocations via CLI', () => { - const sut = buildOnboardingPrompt({ - ...baseOpts, - ide: 'codex', - pluginInstallMethod: 'cli', - }); - - expect(sut).toContain('Invoke the `$analyze-project` skill as a **methodology reference**'); - }); - - it('uses bare slash for cursor skill invocations via CLI', () => { - const sut = buildOnboardingPrompt({ - ...baseOpts, - ide: 'cursor', - pluginInstallMethod: 'cli', - }); - - expect(sut).toContain('Invoke the `/analyze-project` skill as a **methodology reference**'); + expect(sut).toContain('.config/confidence/skills/instrument-events/SKILL.md'); + expect(sut).toContain('as a **methodology reference**'); }); }); @@ -138,49 +53,11 @@ describe('buildOnboardingPrompt', () => { goals: ['session-recordings' as const], }; - it('delegates to setup-session-recording skill via CLI when installed via CLI', () => { - const sut = buildOnboardingPrompt({ - ...recordingOpts, - pluginInstallMethod: 'cli', - }); - - expect(sut).toContain( - 'Invoke the `/confidence:setup-session-recording` skill as a **methodology reference**', - ); - expect(sut).not.toContain('Read `.claude/skills/setup-session-recording/SKILL.md`'); - }); - - it('delegates to setup-session-recording skill as file path when installed via download', () => { - const sut = buildOnboardingPrompt({ - ...recordingOpts, - pluginInstallMethod: 'download', - }); - - expect(sut).toContain( - 'Read `.claude/skills/setup-session-recording/SKILL.md` as a **methodology reference**', - ); - }); - - it('uses cursor skills dir for cursor with download method', () => { - const sut = buildOnboardingPrompt({ - ...recordingOpts, - ide: 'cursor', - pluginInstallMethod: 'download', - }); - - expect(sut).toContain('Read `.cursor/skills/setup-session-recording/SKILL.md`'); - }); - - it('uses codex skill invocation for codex with CLI method', () => { - const sut = buildOnboardingPrompt({ - ...recordingOpts, - ide: 'codex', - pluginInstallMethod: 'cli', - }); + it('references setup-session-recording skill as an absolute file path', () => { + const sut = buildOnboardingPrompt(recordingOpts); - expect(sut).toContain( - 'Invoke the `$setup-session-recording` skill as a **methodology reference**', - ); + expect(sut).toContain('.config/confidence/skills/setup-session-recording/SKILL.md'); + expect(sut).toContain('as a **methodology reference**'); }); it('overrides skill formatting with STATUS-line-only output', () => { diff --git a/packages/quickstart/__tests__/features/onboarding/tool-vars.test.ts b/packages/quickstart/__tests__/features/onboarding/tool-vars.test.ts index 733b292b..6906b021 100644 --- a/packages/quickstart/__tests__/features/onboarding/tool-vars.test.ts +++ b/packages/quickstart/__tests__/features/onboarding/tool-vars.test.ts @@ -1,4 +1,8 @@ -import { skillInvocation, referenceInstruction } from '@features/onboarding/tool-vars.js'; +import { + skillInvocation, + referenceInstruction, + followInstruction, +} from '@features/onboarding/tool-vars.js'; import type { IdeId } from '@spotify-confidence/shared-kernel'; describe('skillInvocation', () => { @@ -13,55 +17,22 @@ describe('skillInvocation', () => { }); describe('referenceInstruction', () => { - describe('when method is cli', () => { - it.each<{ ide: IdeId; expected: string }>([ - { - ide: 'claude', - expected: 'Invoke the `/confidence:analyze-project` skill as a **methodology reference**', - }, - { - ide: 'codex', - expected: 'Invoke the `$analyze-project` skill as a **methodology reference**', - }, - { - ide: 'cursor', - expected: 'Invoke the `/analyze-project` skill as a **methodology reference**', - }, - ])('produces IDE-specific invocation for $ide', ({ ide, expected }) => { - const sut = referenceInstruction('analyze-project', ide, 'cli'); - expect(sut).toBe(expected); - }); + it('produces a read instruction with methodology reference', () => { + const sut = referenceInstruction('analyze-project'); + expect(sut).toContain('analyze-project/SKILL.md'); + expect(sut).toContain('.config/confidence/skills/'); + expect(sut).toMatch(/^Read `/); + expect(sut).toContain('as a **methodology reference**'); }); +}); - describe('when method is download', () => { - it.each<{ ide: IdeId; expected: string }>([ - { - ide: 'claude', - expected: 'Read `.claude/skills/analyze-project/SKILL.md` as a **methodology reference**', - }, - { - ide: 'codex', - expected: 'Read `.agents/skills/analyze-project/SKILL.md` as a **methodology reference**', - }, - { - ide: 'cursor', - expected: 'Read `.cursor/skills/analyze-project/SKILL.md` as a **methodology reference**', - }, - ])('produces file path for $ide', ({ ide, expected }) => { - const sut = referenceInstruction('analyze-project', ide, 'download'); - expect(sut).toBe(expected); - }); - }); - - describe('when method is null or undefined', () => { - it('falls back to file path', () => { - const sut = referenceInstruction('analyze-project', 'claude', null); - expect(sut).toContain('Read `.claude/skills/analyze-project/SKILL.md`'); - }); - - it('falls back to file path when omitted', () => { - const sut = referenceInstruction('analyze-project', 'claude'); - expect(sut).toContain('Read `.claude/skills/analyze-project/SKILL.md`'); - }); +describe('followInstruction', () => { + it('produces a read instruction that tells the agent to follow the skill', () => { + const sut = followInstruction('migrate-statsig'); + expect(sut).toContain('migrate-statsig/SKILL.md'); + expect(sut).toContain('.config/confidence/skills/'); + expect(sut).toMatch(/^Read `/); + expect(sut).toContain('follow the skill instructions'); + expect(sut).not.toContain('methodology reference'); }); }); diff --git a/packages/quickstart/__tests__/ui/screens/InstallPluginsScreen.test.tsx b/packages/quickstart/__tests__/ui/screens/InstallPluginsScreen.test.tsx index 89f5f2ff..486e4744 100644 --- a/packages/quickstart/__tests__/ui/screens/InstallPluginsScreen.test.tsx +++ b/packages/quickstart/__tests__/ui/screens/InstallPluginsScreen.test.tsx @@ -84,7 +84,7 @@ describe('InstallPluginsScreen', () => { it('shows continue option when plugins already installed', async () => { using _config = createConfigDir(); const { detectInstalledPlugins } = await import('@spotify-confidence/core'); - vi.mocked(detectInstalledPlugins).mockResolvedValueOnce([{ ide: 'claude', via: 'cli' }]); + vi.mocked(detectInstalledPlugins).mockResolvedValueOnce(['claude']); using sut = renderApp({ screen: ScreenId.InstallPlugins }); @@ -103,10 +103,7 @@ describe('InstallPluginsScreen', () => { it('sorts detected IDEs above non-detected ones', async () => { using _config = createConfigDir(); const { detectInstalledPlugins } = await import('@spotify-confidence/core'); - vi.mocked(detectInstalledPlugins).mockResolvedValueOnce([ - { ide: 'claude', via: 'cli' }, - { ide: 'codex', via: 'cli' }, - ]); + vi.mocked(detectInstalledPlugins).mockResolvedValueOnce(['claude', 'codex']); using sut = renderScreen(, { screen: ScreenId.InstallPlugins }); diff --git a/packages/quickstart/src/features/onboarding/build-prompt.ts b/packages/quickstart/src/features/onboarding/build-prompt.ts index d673cb48..8e87545a 100644 --- a/packages/quickstart/src/features/onboarding/build-prompt.ts +++ b/packages/quickstart/src/features/onboarding/build-prompt.ts @@ -1,8 +1,4 @@ -import type { - IdeId, - OnboardingGoal, - PluginInstallationMethod, -} from '@spotify-confidence/shared-kernel'; +import type { IdeId, OnboardingGoal } from '@spotify-confidence/shared-kernel'; import { addIf } from '@spotify-confidence/core'; import { buildToolVars } from './tool-vars.js'; import { preflight } from './sections/preflight.js'; @@ -20,7 +16,6 @@ type PromptOptions = { isEmptyProject?: boolean; goals?: OnboardingGoal[]; hasProviders?: boolean; - pluginInstallMethod?: PluginInstallationMethod | null; }; export function buildOnboardingPrompt({ @@ -30,7 +25,6 @@ export function buildOnboardingPrompt({ isEmptyProject = false, goals = ['feature-flags'], hasProviders = false, - pluginInstallMethod = null, }: PromptOptions): string { const steps = new StepCounter(isEmptyProject ? 2 : 1); const tools = buildToolVars(ide); @@ -42,26 +36,19 @@ export function buildOnboardingPrompt({ const sections = [ preamble(framework, projectDir, isEmptyProject, goals), preflight(tools), - addIf(isEmptyProject, () => scaffold(framework, steps.next())), - - addIf(withFlags, () => - integrateFeatureFlags(framework, steps.next(), isEmptyProject, ide, pluginInstallMethod), - ), - addIf(withRecordings, () => - integrateRecording(framework, steps.next(), isEmptyProject, ide, pluginInstallMethod), - ), - - addIf(withEventTracking, () => - instrumentEvents(framework, steps.next(), isEmptyProject, ide, pluginInstallMethod), - ), + addIf(isEmptyProject, () => scaffold(framework, steps.next())), + addIf(withFlags, () => integrateFeatureFlags(framework, steps.next(), isEmptyProject)), + addIf(withRecordings, () => integrateRecording(framework, steps.next(), isEmptyProject)), + addIf(withEventTracking, () => instrumentEvents(framework, steps.next(), isEmptyProject)), generateReport({ step: steps.next(), - isEmptyProject, goals, + isEmptyProject, hasProviders, }), + summary(steps.next()), rules(), ]; diff --git a/packages/quickstart/src/features/onboarding/sections/event-tracking.ts b/packages/quickstart/src/features/onboarding/sections/event-tracking.ts index e81a91ad..370b66d1 100644 --- a/packages/quickstart/src/features/onboarding/sections/event-tracking.ts +++ b/packages/quickstart/src/features/onboarding/sections/event-tracking.ts @@ -1,18 +1,11 @@ -import type { IdeId, PluginInstallationMethod } from '@spotify-confidence/shared-kernel'; import { loadStep } from '../steps/load.js'; import { referenceInstruction } from '../tool-vars.js'; -export function instrumentEvents( - framework: string, - step: number, - isEmptyProject: boolean, - ide: IdeId, - pluginInstallMethod?: PluginInstallationMethod | null, -): string { +export function instrumentEvents(framework: string, step: number, isEmptyProject: boolean): string { return loadStep('instrument-events.md', { STEP: step, FRAMEWORK: framework, - SKILL_READ_INSTRUCTION: referenceInstruction('instrument-events', ide, pluginInstallMethod), + SKILL_READ_INSTRUCTION: referenceInstruction('instrument-events'), DOMAIN_CONTEXT: isEmptyProject ? "The project was just scaffolded — treat the sample app's features as the domain." : "Study the project's codebase to understand its domain, UI flows, and business logic.", diff --git a/packages/quickstart/src/features/onboarding/sections/feature-flags.ts b/packages/quickstart/src/features/onboarding/sections/feature-flags.ts index c3bdac77..fd5f98b7 100644 --- a/packages/quickstart/src/features/onboarding/sections/feature-flags.ts +++ b/packages/quickstart/src/features/onboarding/sections/feature-flags.ts @@ -1,4 +1,3 @@ -import type { IdeId, PluginInstallationMethod } from '@spotify-confidence/shared-kernel'; import { loadStep } from '../steps/load.js'; import { referenceInstruction } from '../tool-vars.js'; @@ -6,15 +5,13 @@ export function integrateFeatureFlags( framework: string, step: number, isEmptyProject: boolean, - ide: IdeId, - pluginInstallMethod?: PluginInstallationMethod | null, ): string { const needsReactGotchas = /react|nextjs|next/i.test(framework); return loadStep('integrate-feature-flags.md', { STEP: step, FRAMEWORK: framework, - SKILL_READ_INSTRUCTION: referenceInstruction('analyze-project', ide, pluginInstallMethod), + SKILL_READ_INSTRUCTION: referenceInstruction('analyze-project'), DOMAIN_CONTEXT: isEmptyProject ? "The project was just scaffolded — treat the sample app's features as the domain." diff --git a/packages/quickstart/src/features/onboarding/sections/recording.ts b/packages/quickstart/src/features/onboarding/sections/recording.ts index 701a898b..5d121464 100644 --- a/packages/quickstart/src/features/onboarding/sections/recording.ts +++ b/packages/quickstart/src/features/onboarding/sections/recording.ts @@ -1,4 +1,3 @@ -import type { IdeId, PluginInstallationMethod } from '@spotify-confidence/shared-kernel'; import { loadStep } from '../steps/load.js'; import { referenceInstruction } from '../tool-vars.js'; @@ -6,17 +5,11 @@ export function integrateRecording( framework: string, step: number, isEmptyProject: boolean, - ide: IdeId, - pluginInstallMethod?: PluginInstallationMethod | null, ): string { return loadStep('integrate-recording.md', { STEP: step, FRAMEWORK: framework, - SKILL_READ_INSTRUCTION: referenceInstruction( - 'setup-session-recording', - ide, - pluginInstallMethod, - ), + SKILL_READ_INSTRUCTION: referenceInstruction('setup-session-recording'), DOMAIN_CONTEXT: isEmptyProject ? "The project was just scaffolded — configure recording on the sample app's main view." : "Identify the app's entry point or root layout where the session recorder should be initialized.", diff --git a/packages/quickstart/src/features/onboarding/tool-vars.ts b/packages/quickstart/src/features/onboarding/tool-vars.ts index 0af29954..84118533 100644 --- a/packages/quickstart/src/features/onboarding/tool-vars.ts +++ b/packages/quickstart/src/features/onboarding/tool-vars.ts @@ -1,5 +1,6 @@ -import type { IdeId, PluginInstallationMethod } from '@spotify-confidence/shared-kernel'; -import { PLUGIN_NAME } from '@spotify-confidence/core'; +import type { IdeId } from '@spotify-confidence/shared-kernel'; + +export { skillInvocation, referenceInstruction, followInstruction } from '@spotify-confidence/core'; type ToolFormatter = (server: string, tool: string) => string; @@ -9,32 +10,6 @@ const TOOL_FORMATTERS: Record = { cursor: (server, tool) => `mcp__${server}__${tool}`, }; -const SKILL_INVOCATIONS: Record string> = { - claude: (skill) => `/${PLUGIN_NAME}:${skill}`, - codex: (skill) => `$${skill}`, - cursor: (skill) => `/${skill}`, -}; - -const SKILLS_DIRS: Record = { - claude: '.claude/skills', - cursor: '.cursor/skills', - codex: '.agents/skills', -}; - -export function skillInvocation(skillName: string, ide: IdeId): string { - return SKILL_INVOCATIONS[ide](skillName); -} - -export function referenceInstruction( - skillName: string, - ide: IdeId, - method?: PluginInstallationMethod | null, -): string { - return method === 'cli' - ? `Invoke the \`${skillInvocation(skillName, ide)}\` skill as a **methodology reference**` - : `Read \`${SKILLS_DIRS[ide]}/${skillName}/SKILL.md\` as a **methodology reference**`; -} - export function buildToolVars(ide: IdeId): Record { const fmt = TOOL_FORMATTERS[ide]; const flags = (tool: string) => fmt('confidence-flags', tool); diff --git a/packages/quickstart/src/ui/screens/install-plugins/telemetry-events.ts b/packages/quickstart/src/ui/screens/install-plugins/telemetry-events.ts index 68f672f0..8e0a6035 100644 --- a/packages/quickstart/src/ui/screens/install-plugins/telemetry-events.ts +++ b/packages/quickstart/src/ui/screens/install-plugins/telemetry-events.ts @@ -1,16 +1,11 @@ -import type { PluginInstallationMethod } from '@spotify-confidence/shared-kernel'; import type { TelemetryEvent } from '@spotify-confidence/core'; export function pluginsAlreadyDetected(): TelemetryEvent { return { step: 'install-plugins.detect', action: 'already-installed', sentiment: 'positive' }; } -export function pluginInstallCompleted(method?: PluginInstallationMethod | null): TelemetryEvent { - return { - step: 'install-plugins.install', - action: method ? `completed:${method}` : 'completed', - sentiment: 'positive', - }; +export function pluginInstallCompleted(): TelemetryEvent { + return { step: 'install-plugins.install', action: 'completed', sentiment: 'positive' }; } export function pluginInstallFailed(): TelemetryEvent { diff --git a/packages/quickstart/src/ui/screens/install-plugins/useInitialDetection.ts b/packages/quickstart/src/ui/screens/install-plugins/useInitialDetection.ts index 59cba36e..a60b45b5 100644 --- a/packages/quickstart/src/ui/screens/install-plugins/useInitialDetection.ts +++ b/packages/quickstart/src/ui/screens/install-plugins/useInitialDetection.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import { type InstalledPlugin, detectInstalledPlugins } from '@spotify-confidence/core'; +import { detectInstalledPlugins } from '@spotify-confidence/core'; import type { IdeId } from '@spotify-confidence/shared-kernel'; import { useSession, store } from '../../store.js'; import type { PluginPhase } from './usePluginInstall.js'; @@ -16,14 +16,12 @@ export function useInitialDetection(): InitialDetection { const [savedIde] = useState(() => session.ide); const applyResults = useCallback( - function applyResults(found: InstalledPlugin[]) { - const ides = found.map((d) => d.ide); + function applyResults(ides: IdeId[]) { setDetected(ides); - setPhase(savedIde ? 'restoring' : found.length > 0 ? 'already-installed' : 'choose-ide'); + setPhase(savedIde ? 'restoring' : ides.length > 0 ? 'already-installed' : 'choose-ide'); - if (found.length > 0) { + if (ides.length > 0) { store.setPluginTargets(ides); - store.setPluginInstallMethod(found[0].via); } }, [savedIde], diff --git a/packages/quickstart/src/ui/screens/install-plugins/usePluginInstall.ts b/packages/quickstart/src/ui/screens/install-plugins/usePluginInstall.ts index 68e6af04..dd3a0d77 100644 --- a/packages/quickstart/src/ui/screens/install-plugins/usePluginInstall.ts +++ b/packages/quickstart/src/ui/screens/install-plugins/usePluginInstall.ts @@ -41,7 +41,6 @@ export function usePluginInstall(): PluginInstallState { if ($session.get().dryRun) { setTimeout(() => { store.setPluginTargets([ide]); - store.setPluginInstallMethod('download'); setInstallPhase('done'); }, 1000); return; @@ -51,9 +50,8 @@ export function usePluginInstall(): PluginInstallState { prepareIde(ide) .then(() => action(ide, $session.get().projectDir)) - .then((method) => { + .then(() => { store.setPluginTargets([ide]); - store.setPluginInstallMethod(method); setInstallPhase('done'); }) .catch((err) => { diff --git a/packages/quickstart/src/ui/screens/onboard-project/useOnboardingProcess.ts b/packages/quickstart/src/ui/screens/onboard-project/useOnboardingProcess.ts index b1b9d13d..86b3dd79 100644 --- a/packages/quickstart/src/ui/screens/onboard-project/useOnboardingProcess.ts +++ b/packages/quickstart/src/ui/screens/onboard-project/useOnboardingProcess.ts @@ -108,7 +108,6 @@ export function useOnboardingProcess(): OnboardingProcess { ide, isEmptyProject: isEmpty, goals, - pluginInstallMethod: s.pluginInstallMethod, hasProviders: s.detectedProviders.length > 0, }); diff --git a/packages/quickstart/src/ui/store.ts b/packages/quickstart/src/ui/store.ts index 6597eae9..08eb6695 100644 --- a/packages/quickstart/src/ui/store.ts +++ b/packages/quickstart/src/ui/store.ts @@ -1,11 +1,6 @@ import { atom } from 'nanostores'; import { useStore } from '@nanostores/react'; -import type { - IdeId, - OnboardingGoal, - PluginInstallationMethod, - DetectedProvider, -} from '@spotify-confidence/shared-kernel'; +import type { IdeId, OnboardingGoal, DetectedProvider } from '@spotify-confidence/shared-kernel'; import { type WizardSession, type CheckResult, @@ -91,11 +86,6 @@ export const store = { pluginTargets: plugins, }), - setPluginInstallMethod: (method: PluginInstallationMethod): void => - updateSession({ - pluginInstallMethod: method, - }), - setConnectedMcps: (mcps: string[]): void => updateSession({ connectedMcps: mcps, diff --git a/packages/shared-kernel/src/index.ts b/packages/shared-kernel/src/index.ts index 9f8775e2..9ee8ccca 100644 --- a/packages/shared-kernel/src/index.ts +++ b/packages/shared-kernel/src/index.ts @@ -3,7 +3,6 @@ export type { IdeId, JsonObject, OnboardingGoal, - PluginInstallationMethod, PluginScope, ProviderId, DetectedProvider, diff --git a/packages/shared-kernel/src/types.ts b/packages/shared-kernel/src/types.ts index f6e24bb4..d564412f 100644 --- a/packages/shared-kernel/src/types.ts +++ b/packages/shared-kernel/src/types.ts @@ -4,8 +4,6 @@ export type IdeId = 'claude' | 'cursor' | 'codex'; export type OnboardingGoal = 'feature-flags' | 'session-recordings' | 'event-tracking'; -export type PluginInstallationMethod = 'cli' | 'download'; - export type PluginScope = 'project' | 'local' | 'global'; export type ProviderId = 'eppo' | 'optimizely' | 'posthog' | 'statsig'; diff --git a/scripts/clean-dev-env.sh b/scripts/clean-dev-env.sh index f2132254..0ba28964 100755 --- a/scripts/clean-dev-env.sh +++ b/scripts/clean-dev-env.sh @@ -87,7 +87,7 @@ if $clean_mcp; then # --- Confidence AI plugin skills --- - skills_dirs=("$PROJECT_DIR/.claude/skills" "$PROJECT_DIR/.cursor/skills" "$PROJECT_DIR/.agents/skills") + skills_dirs=("$config_dir/skills" "$PROJECT_DIR/.claude/skills" "$PROJECT_DIR/.cursor/skills" "$PROJECT_DIR/.agents/skills") confidence_skills=( analyze-project instrument-events From b8fb243466c0019229f4a94b38bf058e476ddc2f Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 9 Oct 2026 08:14:31 +0200 Subject: [PATCH 11/22] perf: parallelize CLI call and skills download --- .../__tests__/integrations/plugins.test.ts | 6 ++--- .../core/src/integrations/claude/plugins.ts | 5 ++-- .../core/src/integrations/codex/plugins.ts | 9 +++++-- .../core/src/integrations/skills/plugin.ts | 26 +++++++------------ .../core/src/integrations/skills/telemetry.ts | 10 +++++++ 5 files changed, 33 insertions(+), 23 deletions(-) create mode 100644 packages/core/src/integrations/skills/telemetry.ts diff --git a/packages/core/__tests__/integrations/plugins.test.ts b/packages/core/__tests__/integrations/plugins.test.ts index a281db3f..53a3f975 100644 --- a/packages/core/__tests__/integrations/plugins.test.ts +++ b/packages/core/__tests__/integrations/plugins.test.ts @@ -83,13 +83,13 @@ describe('uninstallPlugin', () => { }); describe('updatePlugin', () => { - it('attempts CLI update and force-downloads skills', async () => { + it('attempts CLI update and downloads skills', async () => { const { downloadSkills } = await import('../../src/integrations/skills/local.js'); await updatePlugin('claude', '/project'); expect(mockIntegration.updatePlugin).toHaveBeenCalledWith('/project', undefined); - expect(downloadSkills).toHaveBeenCalledWith('/mock-home/.config/confidence/skills', true); + expect(downloadSkills).toHaveBeenCalledWith('/mock-home/.config/confidence/skills'); }); it('still downloads skills when CLI update fails', async () => { @@ -98,6 +98,6 @@ describe('updatePlugin', () => { await updatePlugin('claude', '/project'); - expect(downloadSkills).toHaveBeenCalledWith('/mock-home/.config/confidence/skills', true); + expect(downloadSkills).toHaveBeenCalledWith('/mock-home/.config/confidence/skills'); }); }); diff --git a/packages/core/src/integrations/claude/plugins.ts b/packages/core/src/integrations/claude/plugins.ts index 3b4f9506..e7371d60 100644 --- a/packages/core/src/integrations/claude/plugins.ts +++ b/packages/core/src/integrations/claude/plugins.ts @@ -18,8 +18,9 @@ type PluginEntry = { export async function detectPlugin(projectDir: string): Promise { try { - const { stdout } = await execFile('claude', ['plugin', 'list', '--json'], { cwd: projectDir }); - const plugins = JSON.parse(stdout) as PluginEntry[]; + const config = { cwd: projectDir, timeout: 5_000 }; + const result = await execFile('claude', ['plugin', 'list', '--json'], config); + const plugins = JSON.parse(result.stdout) as PluginEntry[]; return plugins.some((p) => isAvailable(p, projectDir)); } catch { return false; diff --git a/packages/core/src/integrations/codex/plugins.ts b/packages/core/src/integrations/codex/plugins.ts index 7d441503..a35f384e 100644 --- a/packages/core/src/integrations/codex/plugins.ts +++ b/packages/core/src/integrations/codex/plugins.ts @@ -1,10 +1,15 @@ import { execFile } from '../../exec/exec.js'; import { PLUGIN_MARKETPLACE_REPO, PLUGIN_MARKETPLACE_NAME, PLUGIN_NAME } from '../../constants.js'; +type CodexListPluginsResult = { + installed: Array<{ pluginId: string }>; +}; + export async function detectPlugin(projectDir: string): Promise { try { - const { stdout } = await execFile('codex', ['plugin', 'list', '--json'], { cwd: projectDir }); - const { installed } = JSON.parse(stdout) as { installed: Array<{ pluginId: string }> }; + const config = { cwd: projectDir, timeout: 5_000 }; + const { stdout } = await execFile('codex', ['plugin', 'list', '--json'], config); + const { installed } = JSON.parse(stdout) as CodexListPluginsResult; return installed.some((p) => p.pluginId.startsWith(`${PLUGIN_NAME}@`)); } catch { return false; diff --git a/packages/core/src/integrations/skills/plugin.ts b/packages/core/src/integrations/skills/plugin.ts index a3d173d3..27262cc5 100644 --- a/packages/core/src/integrations/skills/plugin.ts +++ b/packages/core/src/integrations/skills/plugin.ts @@ -1,7 +1,7 @@ import type { IdeId, PluginScope } from '@spotify-confidence/shared-kernel'; import { getIntegration, getIntegrations } from '../registry.js'; -import { track } from '../../telemetry/telemetry.js'; import { downloadSkills, getSkillsDir, removeSkills } from './local.js'; +import { cliFailed } from './telemetry.js'; export async function detectInstalledPlugins(projectDir: string): Promise { const results = await Promise.all( @@ -21,13 +21,10 @@ export async function installPlugin( ): Promise { const integration = getIntegration(ide); - try { - await integration.installPlugin(projectDir, scope); - } catch { - track({ step: 'plugin.install', action: `cli-failed:${ide}`, sentiment: 'frustrated' }); - } - - await downloadSkills(getSkillsDir()); + await Promise.all([ + integration.installPlugin(projectDir, scope).catch(() => cliFailed('install', ide)), + downloadSkills(getSkillsDir()), + ]); } export async function uninstallPlugin( @@ -41,7 +38,7 @@ export async function uninstallPlugin( try { await integration.uninstallPlugin(projectDir, scope); } catch { - track({ step: 'plugin.uninstall', action: `cli-failed:${ide}`, sentiment: 'frustrated' }); + cliFailed('uninstall', ide); } } @@ -55,11 +52,8 @@ export async function updatePlugin( ): Promise { const integration = getIntegration(ide); - try { - await integration.updatePlugin(projectDir, scope); - } catch { - track({ step: 'plugin.update', action: `cli-failed:${ide}`, sentiment: 'frustrated' }); - } - - await downloadSkills(getSkillsDir(), true); + await Promise.all([ + integration.updatePlugin(projectDir, scope).catch(() => cliFailed('update', ide)), + downloadSkills(getSkillsDir()), + ]); } diff --git a/packages/core/src/integrations/skills/telemetry.ts b/packages/core/src/integrations/skills/telemetry.ts new file mode 100644 index 00000000..fdf31a64 --- /dev/null +++ b/packages/core/src/integrations/skills/telemetry.ts @@ -0,0 +1,10 @@ +import type { IdeId } from '@spotify-confidence/shared-kernel'; +import { track } from '../../telemetry/telemetry.js'; + +export function cliFailed(step: string, ide: IdeId): void { + track({ + step: `plugin.${step}`, + action: `cli-failed:${ide}`, + sentiment: 'frustrated', + }); +} From 5431394591cf86a836442ed9728d659088fa8b51 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 9 Oct 2026 08:24:49 +0200 Subject: [PATCH 12/22] chore: mention plugin skills when launching chat session --- .../core/__tests__/integrations/chat.test.ts | 130 ++++++++++++++++++ packages/core/src/integrations/chat.ts | 11 +- .../core/src/integrations/skills/const.ts | 14 ++ .../core/src/integrations/skills/index.ts | 1 + .../core/src/integrations/skills/local.ts | 16 +-- 5 files changed, 150 insertions(+), 22 deletions(-) create mode 100644 packages/core/__tests__/integrations/chat.test.ts create mode 100644 packages/core/src/integrations/skills/const.ts diff --git a/packages/core/__tests__/integrations/chat.test.ts b/packages/core/__tests__/integrations/chat.test.ts new file mode 100644 index 00000000..e349e0a9 --- /dev/null +++ b/packages/core/__tests__/integrations/chat.test.ts @@ -0,0 +1,130 @@ +import { createProjectDir } from '@spotify-confidence/testing/scaffold'; +import { launchChatSession } from '@integrations/chat.js'; +import { SKILL_NAMES } from '@integrations/skills/const.js'; +import type { IdeIntegration } from '@integrations/types.js'; +import { createSession } from '../../src/session/session.js'; + +const mockIntegration: IdeIntegration = { + id: 'claude', + name: 'Claude Code', + launchChat: vi.fn(), + runOnboarding: vi.fn().mockReturnValue(null), + prepare: vi.fn().mockResolvedValue(undefined), + detectPlugin: vi.fn().mockResolvedValue(false), + installPlugin: vi.fn().mockResolvedValue(undefined), + updatePlugin: vi.fn().mockResolvedValue(undefined), + uninstallPlugin: vi.fn().mockResolvedValue(undefined), + detectMcpStatuses: vi.fn().mockResolvedValue({}), + connectMcpServer: vi.fn().mockResolvedValue(undefined), + disconnectMcpServer: vi.fn().mockResolvedValue(undefined), +}; + +vi.mock('../../src/integrations/registry.js', () => ({ + getIntegration: () => mockIntegration, +})); + +function capturedPrompt(): string { + return vi.mocked(mockIntegration.launchChat).mock.calls[0][0].systemPrompt ?? ''; +} + +describe('launchChatSession', () => { + it('asks to integrate when no code changes were made', () => { + using project = createProjectDir('empty'); + const session = createSession({ dir: project.path }); + + launchChatSession(session, 'claude'); + + expect(capturedPrompt()).toContain("I'd like to integrate Confidence"); + }); + + it('summarises code changes when present', () => { + using project = createProjectDir('empty'); + const session = createSession({ dir: project.path }); + session.codeChanges = ['Added @spotify-confidence/sdk', 'Created confidence.config.ts']; + + launchChatSession(session, 'claude'); + + const sut = capturedPrompt(); + expect(sut).toContain('Added @spotify-confidence/sdk'); + expect(sut).toContain('Created confidence.config.ts'); + }); + + it('includes report file path when available', () => { + using project = createProjectDir('empty'); + const session = createSession({ dir: project.path }); + session.codeChanges = ['change']; + session.reportFile = 'CONFIDENCE_QUICKSTART.md'; + + launchChatSession(session, 'claude'); + + expect(capturedPrompt()).toContain('CONFIDENCE_QUICKSTART.md'); + }); + + it('warns about missing MCP tools when none connected', () => { + using project = createProjectDir('empty'); + const session = createSession({ dir: project.path }); + + launchChatSession(session, 'claude'); + + expect(capturedPrompt()).toContain("don't have Confidence MCP tools connected"); + }); + + it('does not warn about MCP tools when connected', () => { + using project = createProjectDir('empty'); + const session = createSession({ dir: project.path }); + session.connectedMcps = ['confidence-flags']; + + launchChatSession(session, 'claude'); + + expect(capturedPrompt()).not.toContain("don't have Confidence MCP tools connected"); + }); + + it('lists all plugin skills when plugin is installed', () => { + using project = createProjectDir('empty'); + const session = createSession({ dir: project.path }); + session.codeChanges = ['change']; + session.pluginTargets = ['claude']; + + launchChatSession(session, 'claude'); + + const sut = capturedPrompt(); + for (const name of SKILL_NAMES) { + expect(sut).toContain(`/confidence:${name}`); + } + }); + + it('formats skill invocations for the target IDE', () => { + using project = createProjectDir('empty'); + const session = createSession({ dir: project.path }); + session.codeChanges = ['change']; + session.pluginTargets = ['cursor']; + + launchChatSession(session, 'cursor'); + + const sut = capturedPrompt(); + expect(sut).toContain('/setup-warehouse'); + expect(sut).not.toContain('confidence:'); + }); + + it('does not mention skills when no plugin is installed', () => { + using project = createProjectDir('empty'); + const session = createSession({ dir: project.path }); + session.codeChanges = ['change']; + + launchChatSession(session, 'claude'); + + expect(capturedPrompt()).not.toContain('Confidence AI plugin'); + }); + + it('passes project dir and auth token to the integration', () => { + using project = createProjectDir('empty'); + const session = createSession({ dir: project.path }); + session.authState = { status: 'authenticated', token: 'tok_123' }; + + launchChatSession(session, 'claude'); + + expect(mockIntegration.launchChat).toHaveBeenCalledWith( + expect.objectContaining({ cwd: project.path, token: 'tok_123' }), + ); + }); +}); diff --git a/packages/core/src/integrations/chat.ts b/packages/core/src/integrations/chat.ts index dc5e33d6..b7889f5e 100644 --- a/packages/core/src/integrations/chat.ts +++ b/packages/core/src/integrations/chat.ts @@ -1,7 +1,7 @@ import type { IdeId } from '@spotify-confidence/shared-kernel'; import type { WizardSession } from '../session/session.js'; import { getIntegration } from './registry.js'; -import { skillInvocation } from './skills/references.js'; +import { SKILL_NAMES, skillInvocation } from './skills/index.js'; function buildChatPrompt(session: WizardSession, ide: IdeId): string { const lines = @@ -30,13 +30,10 @@ function buildChatPrompt(session: WizardSession, ide: IdeId): string { ); if (session.pluginTargets.length) { - const warehouseCmd = skillInvocation('setup-warehouse', ide); - const migrateHint = session.detectedProviders.length - ? ` or \`${skillInvocation(`migrate-${session.detectedProviders[0].id}`, ide)}\` to migrate another provider's flags to Confidence` - : ''; + const skills = SKILL_NAMES.map((name) => `\`${skillInvocation(name, ide)}\``).join(', '); lines.push( - `I have installed Confidence AI plugin with skills and commands for working with Confidence,`, - `for example \`${warehouseCmd}\` for setting up a data warehouse${migrateHint}.`, + `The Confidence AI plugin is installed with the following skills: ${skills}.`, + 'Use these skills for Confidence-related tasks like setting up a data warehouse, migrating from other providers, or instrumenting events.', ); } } diff --git a/packages/core/src/integrations/skills/const.ts b/packages/core/src/integrations/skills/const.ts new file mode 100644 index 00000000..673d6ade --- /dev/null +++ b/packages/core/src/integrations/skills/const.ts @@ -0,0 +1,14 @@ +export const SKILL_NAMES = [ + 'analyze-project', + 'instrument-events', + 'onboard-confidence', + 'setup-warehouse', + 'setup-warehouse-bigquery', + 'setup-warehouse-databricks', + 'setup-warehouse-redshift', + 'setup-warehouse-snowflake', + 'migrate-eppo', + 'migrate-optimizely', + 'migrate-posthog', + 'migrate-statsig', +] as const; diff --git a/packages/core/src/integrations/skills/index.ts b/packages/core/src/integrations/skills/index.ts index 6c511b2e..3c322394 100644 --- a/packages/core/src/integrations/skills/index.ts +++ b/packages/core/src/integrations/skills/index.ts @@ -1,3 +1,4 @@ +export { SKILL_NAMES } from './const.js'; export { hasSkills } from './local.js'; export { detectInstalledPlugins, diff --git a/packages/core/src/integrations/skills/local.ts b/packages/core/src/integrations/skills/local.ts index 4ba649aa..12cb046d 100644 --- a/packages/core/src/integrations/skills/local.ts +++ b/packages/core/src/integrations/skills/local.ts @@ -3,21 +3,7 @@ import { mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { SKILLS_BASE_URL } from '../../constants.js'; import { getConfigDir } from '../../config/paths.js'; - -const SKILL_NAMES = [ - 'analyze-project', - 'instrument-events', - 'onboard-confidence', - 'setup-warehouse', - 'setup-warehouse-bigquery', - 'setup-warehouse-databricks', - 'setup-warehouse-redshift', - 'setup-warehouse-snowflake', - 'migrate-eppo', - 'migrate-optimizely', - 'migrate-posthog', - 'migrate-statsig', -] as const; +import { SKILL_NAMES } from './const.js'; export function getSkillsDir(): string { return join(getConfigDir(), 'skills'); From c0b979c9bb1ea7b1ffaf9dbd193a50eccee86d13 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 9 Oct 2026 08:25:34 +0200 Subject: [PATCH 13/22] test: update e2e --- .../__tests__/e2e/chat-launch.e2e.ts | 21 +------------------ 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/packages/quickstart/__tests__/e2e/chat-launch.e2e.ts b/packages/quickstart/__tests__/e2e/chat-launch.e2e.ts index 40ba73de..76c3eb15 100644 --- a/packages/quickstart/__tests__/e2e/chat-launch.e2e.ts +++ b/packages/quickstart/__tests__/e2e/chat-launch.e2e.ts @@ -28,26 +28,7 @@ describe('when the user starts chat after onboarding', () => { expect(prompt).toContain('Help me with next steps'); expect(prompt).toContain('Confidence AI plugin'); expect(prompt).toContain('/confidence:setup-warehouse'); - expect(prompt).not.toContain('migrate-'); - }); - - it('includes migration hint when competitors are detected', async () => { - using session = createSession({ project: 'react-statsig' }); - - await navigateToOnboarding(session); - await session.press('Enter'); - await session.waitForText('onboarding complete', { timeout: 30_000 }); - - await session.waitForText('Continue work with Claude Code'); - await session.press('Enter'); - - const exitCode = await session.waitForExit(); - expect(exitCode).toBe(0); - - const prompt = readFileSync(join(session.cwd, CHAT_PROMPT_FILE), 'utf-8'); - expect(prompt).toContain('Confidence AI plugin'); - expect(prompt).toContain('/confidence:setup-warehouse'); - expect(prompt).toContain('/confidence:migrate-'); + expect(prompt).toContain('/confidence:migrate-eppo'); }); it('sends an integration prompt when onboarding was skipped', async () => { From dc9304b1fae9d7e962559c3fcc12fb7020886110 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 9 Oct 2026 08:29:19 +0200 Subject: [PATCH 14/22] refactor: reduce duplication for mock integration --- .../core/__tests__/integrations/chat.test.ts | 17 ++--------------- .../integrations/mock-integration.ts | 19 +++++++++++++++++++ .../__tests__/integrations/plugins.test.ts | 17 ++--------------- 3 files changed, 23 insertions(+), 30 deletions(-) create mode 100644 packages/core/__tests__/integrations/mock-integration.ts diff --git a/packages/core/__tests__/integrations/chat.test.ts b/packages/core/__tests__/integrations/chat.test.ts index e349e0a9..b98b6d2c 100644 --- a/packages/core/__tests__/integrations/chat.test.ts +++ b/packages/core/__tests__/integrations/chat.test.ts @@ -1,23 +1,10 @@ import { createProjectDir } from '@spotify-confidence/testing/scaffold'; import { launchChatSession } from '@integrations/chat.js'; import { SKILL_NAMES } from '@integrations/skills/const.js'; -import type { IdeIntegration } from '@integrations/types.js'; import { createSession } from '../../src/session/session.js'; +import { createMockIntegration } from './mock-integration.js'; -const mockIntegration: IdeIntegration = { - id: 'claude', - name: 'Claude Code', - launchChat: vi.fn(), - runOnboarding: vi.fn().mockReturnValue(null), - prepare: vi.fn().mockResolvedValue(undefined), - detectPlugin: vi.fn().mockResolvedValue(false), - installPlugin: vi.fn().mockResolvedValue(undefined), - updatePlugin: vi.fn().mockResolvedValue(undefined), - uninstallPlugin: vi.fn().mockResolvedValue(undefined), - detectMcpStatuses: vi.fn().mockResolvedValue({}), - connectMcpServer: vi.fn().mockResolvedValue(undefined), - disconnectMcpServer: vi.fn().mockResolvedValue(undefined), -}; +const mockIntegration = createMockIntegration(); vi.mock('../../src/integrations/registry.js', () => ({ getIntegration: () => mockIntegration, diff --git a/packages/core/__tests__/integrations/mock-integration.ts b/packages/core/__tests__/integrations/mock-integration.ts new file mode 100644 index 00000000..18f77769 --- /dev/null +++ b/packages/core/__tests__/integrations/mock-integration.ts @@ -0,0 +1,19 @@ +import type { IdeIntegration } from '@integrations/types.js'; + +export function createMockIntegration(overrides?: Partial): IdeIntegration { + return { + id: 'claude', + name: 'Claude Code', + launchChat: vi.fn(), + runOnboarding: vi.fn().mockReturnValue(null), + prepare: vi.fn().mockResolvedValue(undefined), + detectPlugin: vi.fn().mockResolvedValue(false), + installPlugin: vi.fn().mockResolvedValue(undefined), + updatePlugin: vi.fn().mockResolvedValue(undefined), + uninstallPlugin: vi.fn().mockResolvedValue(undefined), + detectMcpStatuses: vi.fn().mockResolvedValue({}), + connectMcpServer: vi.fn().mockResolvedValue(undefined), + disconnectMcpServer: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} diff --git a/packages/core/__tests__/integrations/plugins.test.ts b/packages/core/__tests__/integrations/plugins.test.ts index 53a3f975..37db0ddd 100644 --- a/packages/core/__tests__/integrations/plugins.test.ts +++ b/packages/core/__tests__/integrations/plugins.test.ts @@ -1,5 +1,5 @@ import { installPlugin, uninstallPlugin, updatePlugin } from '@integrations/skills/plugin.js'; -import type { IdeIntegration } from '@integrations/types.js'; +import { createMockIntegration } from './mock-integration.js'; vi.mock('../../src/telemetry/telemetry.js', () => ({ track: vi.fn(), @@ -12,20 +12,7 @@ vi.mock('../../src/integrations/skills/local.js', () => ({ getSkillsDir: vi.fn().mockReturnValue('/mock-home/.config/confidence/skills'), })); -const mockIntegration: IdeIntegration = { - id: 'claude', - name: 'Claude Code', - launchChat: vi.fn(), - runOnboarding: vi.fn().mockReturnValue(null), - prepare: vi.fn().mockResolvedValue(undefined), - detectPlugin: vi.fn().mockResolvedValue(false), - installPlugin: vi.fn().mockResolvedValue(undefined), - updatePlugin: vi.fn().mockResolvedValue(undefined), - uninstallPlugin: vi.fn().mockResolvedValue(undefined), - detectMcpStatuses: vi.fn().mockResolvedValue({}), - connectMcpServer: vi.fn().mockResolvedValue(undefined), - disconnectMcpServer: vi.fn().mockResolvedValue(undefined), -}; +const mockIntegration = createMockIntegration(); vi.mock('../../src/integrations/registry.js', () => ({ getIntegration: () => mockIntegration, From 7a1739695daadbcac6242be6166bd77725a2af33 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 9 Oct 2026 08:54:35 +0200 Subject: [PATCH 15/22] chore: add missing skills --- .../core/__tests__/integrations/chat.test.ts | 24 ++++++++++++++----- .../core/src/integrations/skills/const.ts | 3 +++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/core/__tests__/integrations/chat.test.ts b/packages/core/__tests__/integrations/chat.test.ts index b98b6d2c..cf170d97 100644 --- a/packages/core/__tests__/integrations/chat.test.ts +++ b/packages/core/__tests__/integrations/chat.test.ts @@ -1,6 +1,5 @@ import { createProjectDir } from '@spotify-confidence/testing/scaffold'; import { launchChatSession } from '@integrations/chat.js'; -import { SKILL_NAMES } from '@integrations/skills/const.js'; import { createSession } from '../../src/session/session.js'; import { createMockIntegration } from './mock-integration.js'; @@ -66,7 +65,23 @@ describe('launchChatSession', () => { expect(capturedPrompt()).not.toContain("don't have Confidence MCP tools connected"); }); - it('lists all plugin skills when plugin is installed', () => { + it.each([ + 'analyze-project', + 'explore-metric', + 'instrument-events', + 'onboard-confidence', + 'onboard-confidence-dry-run', + 'setup-session-recording', + 'setup-warehouse', + 'setup-warehouse-bigquery', + 'setup-warehouse-databricks', + 'setup-warehouse-redshift', + 'setup-warehouse-snowflake', + 'migrate-eppo', + 'migrate-optimizely', + 'migrate-posthog', + 'migrate-statsig', + ])('includes /confidence:%s skill when plugin is installed', (skill) => { using project = createProjectDir('empty'); const session = createSession({ dir: project.path }); session.codeChanges = ['change']; @@ -74,10 +89,7 @@ describe('launchChatSession', () => { launchChatSession(session, 'claude'); - const sut = capturedPrompt(); - for (const name of SKILL_NAMES) { - expect(sut).toContain(`/confidence:${name}`); - } + expect(capturedPrompt()).toContain(`/confidence:${skill}`); }); it('formats skill invocations for the target IDE', () => { diff --git a/packages/core/src/integrations/skills/const.ts b/packages/core/src/integrations/skills/const.ts index 673d6ade..b6f985b5 100644 --- a/packages/core/src/integrations/skills/const.ts +++ b/packages/core/src/integrations/skills/const.ts @@ -1,7 +1,10 @@ export const SKILL_NAMES = [ 'analyze-project', + 'explore-metric', 'instrument-events', 'onboard-confidence', + 'onboard-confidence-dry-run', + 'setup-session-recording', 'setup-warehouse', 'setup-warehouse-bigquery', 'setup-warehouse-databricks', From e304e56c97ef6acd09a1c79c526688088209509b Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 9 Oct 2026 09:11:48 +0200 Subject: [PATCH 16/22] test: stub config dir --- .../ui/screens/OnboardingFlow.test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/quickstart/__tests__/ui/screens/OnboardingFlow.test.tsx b/packages/quickstart/__tests__/ui/screens/OnboardingFlow.test.tsx index 8c5a0965..b30c8380 100644 --- a/packages/quickstart/__tests__/ui/screens/OnboardingFlow.test.tsx +++ b/packages/quickstart/__tests__/ui/screens/OnboardingFlow.test.tsx @@ -9,6 +9,7 @@ import { ESCAPE, waitFor, } from '../testing-framework/index.js'; +import { createConfigDir } from '@spotify-confidence/testing/scaffold'; import { OnboardProjectScreen } from '@ui/screens/onboard-project/index.js'; import { ScreenId } from '@spotify-confidence/core'; @@ -24,6 +25,7 @@ describe('Onboarding flow', () => { describe('confirmation prompt', () => { it('shows confirmation prompt on mount', async () => { + using _config = createConfigDir(); using project = createProjectDir(); using sut = renderApp({ @@ -43,6 +45,7 @@ describe('Onboarding flow', () => { }); it('advances to Done on skip', async () => { + using _config = createConfigDir(); using project = createProjectDir(); using sut = renderApp({ @@ -64,6 +67,7 @@ describe('Onboarding flow', () => { describe('when onboarding is confirmed', () => { it('shows progress screen after confirming', async () => { + using _config = createConfigDir(); using project = createProjectDir(); mockNextSpawn({ hang: true }); @@ -85,6 +89,7 @@ describe('Onboarding flow', () => { }); it('shows status updates from spawned process', async () => { + using _config = createConfigDir(); using project = createProjectDir(); mockNextSpawn({ lines: ['STATUS: Creating feature flag example...', 'other output without STATUS prefix'], @@ -109,6 +114,7 @@ describe('Onboarding flow', () => { }); it('advances to Done after successful onboarding', async () => { + using _config = createConfigDir(); using project = createProjectDir(); mockNextSpawn({ lines: [ @@ -135,6 +141,7 @@ describe('Onboarding flow', () => { }); it('shows error when process exits with non-zero code', async () => { + using _config = createConfigDir(); using project = createProjectDir(); mockNextSpawn({ exitCode: 1, @@ -159,6 +166,7 @@ describe('Onboarding flow', () => { }); it('shows error when process fails to start', async () => { + using _config = createConfigDir(); using project = createProjectDir(); mockNextSpawn({ error: new Error('spawn claude ENOENT'), @@ -181,6 +189,7 @@ describe('Onboarding flow', () => { }); it('advances to Done on cancel from progress screen', async () => { + using _config = createConfigDir(); using project = createProjectDir(); mockNextSpawn({ lines: ['STATUS: Working...'], hang: true }); @@ -207,6 +216,7 @@ describe('Onboarding flow', () => { }); it('shows plain time estimate for a single goal', async () => { + using _config = createConfigDir(); using project = createProjectDir(); mockNextSpawn({ hang: true }); @@ -230,6 +240,7 @@ describe('Onboarding flow', () => { }); it('shows per-feature time estimate for multiple goals', async () => { + using _config = createConfigDir(); using project = createProjectDir(); mockNextSpawn({ hang: true }); @@ -273,6 +284,7 @@ describe('Onboarding flow', () => { describe('IDE-specific sandbox warning', () => { it('shows standard warning when IDE is Claude Code', async () => { + using _config = createConfigDir(); using project = createProjectDir(); using sut = renderScreen(, { @@ -290,6 +302,7 @@ describe('Onboarding flow', () => { }); it('shows elevated warning when IDE is Cursor', async () => { + using _config = createConfigDir(); using project = createProjectDir(); using sut = renderScreen(, { @@ -305,6 +318,7 @@ describe('Onboarding flow', () => { }); it('shows elevated warning when IDE is Codex', async () => { + using _config = createConfigDir(); using project = createProjectDir(); using sut = renderScreen(, { @@ -322,6 +336,7 @@ describe('Onboarding flow', () => { describe('selected goal display', () => { it('shows feature flag steps when goal is feature-flags', async () => { + using _config = createConfigDir(); using project = createProjectDir(); using sut = renderScreen(, { @@ -339,6 +354,7 @@ describe('Onboarding flow', () => { }); it('shows session recording steps when goal is session-recordings', async () => { + using _config = createConfigDir(); using project = createProjectDir(); using sut = renderScreen(, { @@ -356,6 +372,7 @@ describe('Onboarding flow', () => { }); it('shows combined steps when multiple goals are selected', async () => { + using _config = createConfigDir(); using project = createProjectDir(); using sut = renderScreen(, { From 7da0332eb14389646357a5402e8efefd8f910a3b Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 9 Oct 2026 09:48:40 +0200 Subject: [PATCH 17/22] fix: replace shell: true with cross-spawn for safe Windows .cmd resolution The shell: true fallback passed untrusted args (multi-line prompts, project paths) through cmd.exe, breaking on spaces/newlines and exposing cmd metacharacter injection. cross-spawn handles .cmd resolution and argument escaping without a shell. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/__tests__/exec/resolve-bin.test.ts | 7 +++---- packages/core/package.json | 4 +++- packages/core/src/exec/exec.ts | 19 ++++++++----------- packages/core/src/exec/resolve-bin.ts | 10 ++++------ pnpm-lock.yaml | 13 +++++++++++++ 5 files changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/core/__tests__/exec/resolve-bin.test.ts b/packages/core/__tests__/exec/resolve-bin.test.ts index a680e636..ecebfaa4 100644 --- a/packages/core/__tests__/exec/resolve-bin.test.ts +++ b/packages/core/__tests__/exec/resolve-bin.test.ts @@ -9,7 +9,7 @@ describe('resolveBin', () => { exists: () => true, }); - expect(sut).toEqual({ command: 'claude', args: ['--version'], shell: false }); + expect(sut).toEqual({ command: 'claude', args: ['--version'] }); }); it('runs a PATH .js shim with node on Windows', () => { @@ -26,17 +26,16 @@ describe('resolveBin', () => { expect(sut).toEqual({ command: '/nodejs/node.exe', args: [jsPath, '--version'], - shell: false, }); }); - it('uses shell mode on Windows when no .js shim exists', () => { + it('returns command unchanged on Windows when no .js shim exists', () => { const sut = resolveBin('claude', ['--version'], { platform: 'win32', pathEnv: join('/Windows', 'System32'), exists: () => false, }); - expect(sut).toEqual({ command: 'claude', args: ['--version'], shell: true }); + expect(sut).toEqual({ command: 'claude', args: ['--version'] }); }); }); diff --git a/packages/core/package.json b/packages/core/package.json index d27ff5a1..ef11e352 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,7 +8,8 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.31.0", - "@spotify-confidence/shared-kernel": "workspace:*" + "@spotify-confidence/shared-kernel": "workspace:*", + "cross-spawn": "^7.0.6" }, "scripts": { "test": "vitest run", @@ -18,6 +19,7 @@ "devDependencies": { "@spotify-confidence/eslint-config": "workspace:*", "@spotify-confidence/testing": "workspace:*", + "@types/cross-spawn": "^6.0.6", "@types/node": "^26.6.3", "eslint": "^10.11.0", "msw": "^2.15.0", diff --git a/packages/core/src/exec/exec.ts b/packages/core/src/exec/exec.ts index b49ef11a..6756123c 100644 --- a/packages/core/src/exec/exec.ts +++ b/packages/core/src/exec/exec.ts @@ -1,17 +1,15 @@ -import { - execFile as cpExecFile, - spawn as cpSpawn, - type ChildProcess, - type ExecFileOptions, - type SpawnOptions, -} from 'node:child_process'; +import { execFile as cpExecFile, type ExecFileOptions } from 'node:child_process'; +import type { ChildProcess, SpawnOptions } from 'node:child_process'; import { promisify } from 'node:util'; + +import crossSpawn from 'cross-spawn'; + import { pathFromEnv, resolveBin } from './resolve-bin.js'; const promisifiedExecFile = promisify(cpExecFile); /** - * `spawn` that resolves Windows `.js` and `.cmd` PATH shims. + * `spawn` that resolves Windows `.js` PATH shims via `cross-spawn`. * * @see {@link resolveBin} */ @@ -21,11 +19,11 @@ export function spawn( options?: SpawnOptions, ): ChildProcess { const resolved = resolveBin(command, args, { pathEnv: pathFromEnv(options?.env) }); - return cpSpawn(resolved.command, resolved.args, { ...options, shell: resolved.shell }); + return crossSpawn(resolved.command, resolved.args, options ?? {}); } /** - * `execFile` that resolves Windows `.js` and `.cmd` PATH shims. + * `execFile` that resolves Windows `.js` PATH shims via `cross-spawn`. * * @see {@link resolveBin} */ @@ -38,7 +36,6 @@ export async function execFile( const { stdout, stderr } = await promisifiedExecFile(resolved.command, resolved.args, { ...options, encoding: 'utf8', - shell: resolved.shell, }); return { stdout, stderr }; } diff --git a/packages/core/src/exec/resolve-bin.ts b/packages/core/src/exec/resolve-bin.ts index 6d8d4f2e..689c00a9 100644 --- a/packages/core/src/exec/resolve-bin.ts +++ b/packages/core/src/exec/resolve-bin.ts @@ -15,7 +15,6 @@ type ResolveBinOptions = { export type ResolvedBin = { command: string; args: string[]; - shell: boolean; }; /** @@ -24,8 +23,7 @@ export type ResolvedBin = { * On Windows, `CreateProcess` cannot execute `.cmd` shims. If `{command}.js` * exists on PATH (the e2e mock layout), run it with Node instead so flags * like `--version` are passed to the script rather than to Node itself. - * When no `.js` shim is found, `shell: true` is returned so `cmd.exe` - * can resolve `.cmd` wrappers installed by npm. + * For `.cmd` wrappers installed by npm, `cross-spawn` handles resolution. */ export function resolveBin( command: string, @@ -34,7 +32,7 @@ export function resolveBin( ): ResolvedBin { const platform = options.platform ?? process.platform; if (platform !== 'win32') { - return { command, args: [...args], shell: false }; + return { command, args: [...args] }; } const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? ''; @@ -46,11 +44,11 @@ export function resolveBin( const jsPath = join(dir, `${command}.js`); if (exists(jsPath)) { - return { command: execPath, args: [jsPath, ...args], shell: false }; + return { command: execPath, args: [jsPath, ...args] }; } } - return { command, args: [...args], shell: true }; + return { command, args: [...args] }; } export function pathFromEnv(env?: NodeJS.ProcessEnv): string | undefined { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aaa79e4a..2252ed3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: '@spotify-confidence/shared-kernel': specifier: workspace:* version: link:../shared-kernel + cross-spawn: + specifier: ^7.0.6 + version: 7.0.6 devDependencies: '@spotify-confidence/eslint-config': specifier: workspace:* @@ -118,6 +121,9 @@ importers: '@spotify-confidence/testing': specifier: workspace:* version: link:../testing + '@types/cross-spawn': + specifier: ^6.0.6 + version: 6.0.6 '@types/node': specifier: ^26.6.3 version: 26.6.3 @@ -1022,6 +1028,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cross-spawn@6.0.6': + resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -3454,6 +3463,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/cross-spawn@6.0.6': + dependencies: + '@types/node': 26.6.3 + '@types/deep-eql@4.0.2': {} '@types/esrecurse@4.3.1': {} From 41bfcfdbc6ec9b76715e742219bb81d9e428ecce Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 9 Oct 2026 10:02:21 +0200 Subject: [PATCH 18/22] test: fix spawn mocks --- .../__tests__/ui/screens/OnboardingFlow.test.tsx | 10 +++++----- .../__tests__/ui/testing-framework/mocks/spawn.ts | 4 ++-- packages/quickstart/package.json | 2 ++ pnpm-lock.yaml | 6 ++++++ 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/quickstart/__tests__/ui/screens/OnboardingFlow.test.tsx b/packages/quickstart/__tests__/ui/screens/OnboardingFlow.test.tsx index b30c8380..2c76247a 100644 --- a/packages/quickstart/__tests__/ui/screens/OnboardingFlow.test.tsx +++ b/packages/quickstart/__tests__/ui/screens/OnboardingFlow.test.tsx @@ -1,4 +1,4 @@ -import { spawn } from 'node:child_process'; +import crossSpawn from 'cross-spawn'; import { renderApp, renderScreen, @@ -13,14 +13,14 @@ import { createConfigDir } from '@spotify-confidence/testing/scaffold'; import { OnboardProjectScreen } from '@ui/screens/onboard-project/index.js'; import { ScreenId } from '@spotify-confidence/core'; -vi.mock('node:child_process', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, spawn: vi.fn() }; +vi.mock('cross-spawn', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, default: vi.fn() }; }); describe('Onboarding flow', () => { afterEach(() => { - vi.mocked(spawn).mockReset(); + vi.mocked(crossSpawn).mockReset(); }); describe('confirmation prompt', () => { diff --git a/packages/quickstart/__tests__/ui/testing-framework/mocks/spawn.ts b/packages/quickstart/__tests__/ui/testing-framework/mocks/spawn.ts index dc1b67fd..295edd28 100644 --- a/packages/quickstart/__tests__/ui/testing-framework/mocks/spawn.ts +++ b/packages/quickstart/__tests__/ui/testing-framework/mocks/spawn.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; -import { spawn } from 'node:child_process'; +import crossSpawn from 'cross-spawn'; function toStreamJsonLine(text: string): string { const event = { type: 'assistant', message: { content: [{ type: 'text', text }] } }; @@ -53,5 +53,5 @@ export function createFakeChild( export function mockNextSpawn(opts?: Parameters[0]) { // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.mocked(spawn).mockImplementationOnce((() => createFakeChild(opts)) as any); + vi.mocked(crossSpawn).mockImplementationOnce((() => createFakeChild(opts)) as any); } diff --git a/packages/quickstart/package.json b/packages/quickstart/package.json index cdbcbf2b..26893eac 100644 --- a/packages/quickstart/package.json +++ b/packages/quickstart/package.json @@ -40,9 +40,11 @@ "@spotify-confidence/shared-kernel": "workspace:*", "@spotify-confidence/testing": "workspace:*", "@testing-library/react": "^16.3.3", + "@types/cross-spawn": "^6.0.6", "@types/node": "^26.6.3", "@types/react": "^19.3.0", "@types/yargs": "^17.0.35", + "cross-spawn": "^7.0.6", "eslint": "^10.11.0", "ink-testing-library": "^4.0.0", "msw": "^2.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2252ed3d..f682d158 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,6 +197,9 @@ importers: '@testing-library/react': specifier: ^16.3.3 version: 16.3.3(@testing-library/dom@10.4.1)(@types/react@19.3.0)(react-dom@19.2.7(react@19.3.0))(react@19.3.0) + '@types/cross-spawn': + specifier: ^6.0.6 + version: 6.0.6 '@types/node': specifier: ^26.6.3 version: 26.6.3 @@ -206,6 +209,9 @@ importers: '@types/yargs': specifier: ^17.0.35 version: 17.0.35 + cross-spawn: + specifier: ^7.0.6 + version: 7.0.6 eslint: specifier: ^10.11.0 version: 10.11.0(jiti@2.7.0) From c6cf25d6dce1b9dd310a4b01658237e76878978b Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 9 Oct 2026 10:05:12 +0200 Subject: [PATCH 19/22] fix: skill download error handling, uninstall scoping, and force on update - downloadSkills uses Promise.allSettled and returns boolean so a single network error no longer rejects the whole install - install/update throw only when both CLI and download fail - updatePlugin passes force: true so existing skills get refreshed - uninstallPlugin only removes shared skills when no other IDE has the plugin installed Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/integrations/plugins.test.ts | 44 +++++++++++++++---- .../core/src/integrations/skills/local.ts | 14 +++--- .../core/src/integrations/skills/plugin.ts | 27 +++++++++--- .../ui/screens/InstallPluginsScreen.test.tsx | 4 +- 4 files changed, 67 insertions(+), 22 deletions(-) diff --git a/packages/core/__tests__/integrations/plugins.test.ts b/packages/core/__tests__/integrations/plugins.test.ts index 37db0ddd..dd450d19 100644 --- a/packages/core/__tests__/integrations/plugins.test.ts +++ b/packages/core/__tests__/integrations/plugins.test.ts @@ -6,7 +6,7 @@ vi.mock('../../src/telemetry/telemetry.js', () => ({ })); vi.mock('../../src/integrations/skills/local.js', () => ({ - downloadSkills: vi.fn().mockResolvedValue(undefined), + downloadSkills: vi.fn().mockResolvedValue(true), removeSkills: vi.fn().mockResolvedValue(undefined), hasSkills: vi.fn().mockReturnValue(false), getSkillsDir: vi.fn().mockReturnValue('/mock-home/.config/confidence/skills'), @@ -38,7 +38,7 @@ describe('installPlugin', () => { expect(downloadSkills).toHaveBeenCalledWith('/mock-home/.config/confidence/skills'); }); - it('still downloads skills when CLI install fails', async () => { + it('succeeds via download when CLI install fails', async () => { const { downloadSkills } = await import('../../src/integrations/skills/local.js'); vi.mocked(mockIntegration.installPlugin).mockRejectedValueOnce(new Error('network error')); @@ -46,12 +46,21 @@ describe('installPlugin', () => { expect(downloadSkills).toHaveBeenCalledWith('/mock-home/.config/confidence/skills'); }); + + it('throws when both CLI install and download fail', async () => { + const { downloadSkills } = await import('../../src/integrations/skills/local.js'); + vi.mocked(mockIntegration.installPlugin).mockRejectedValueOnce(new Error('cli failed')); + vi.mocked(downloadSkills).mockResolvedValueOnce(false); + + await expect(installPlugin('claude', '/project')).rejects.toThrow('cli failed'); + }); }); describe('uninstallPlugin', () => { - it('calls CLI uninstall and removes skills when CLI plugin is detected', async () => { + it('calls CLI uninstall and removes skills when no other IDE has plugin', async () => { const { removeSkills } = await import('../../src/integrations/skills/local.js'); vi.mocked(mockIntegration.detectPlugin).mockResolvedValueOnce(true); + vi.mocked(mockIntegration.detectPlugin).mockResolvedValueOnce(false); await uninstallPlugin('claude', '/project'); @@ -59,7 +68,18 @@ describe('uninstallPlugin', () => { expect(removeSkills).toHaveBeenCalledWith('/mock-home/.config/confidence/skills'); }); - it('only removes skills when CLI plugin is not detected', async () => { + it('preserves skills when another IDE still has the plugin', async () => { + const { removeSkills } = await import('../../src/integrations/skills/local.js'); + vi.mocked(mockIntegration.detectPlugin).mockResolvedValueOnce(true); + vi.mocked(mockIntegration.detectPlugin).mockResolvedValueOnce(true); + + await uninstallPlugin('claude', '/project'); + + expect(mockIntegration.uninstallPlugin).toHaveBeenCalled(); + expect(removeSkills).not.toHaveBeenCalled(); + }); + + it('removes skills when CLI plugin is not detected and no other IDE has it', async () => { const { removeSkills } = await import('../../src/integrations/skills/local.js'); await uninstallPlugin('claude', '/project'); @@ -70,21 +90,29 @@ describe('uninstallPlugin', () => { }); describe('updatePlugin', () => { - it('attempts CLI update and downloads skills', async () => { + it('attempts CLI update and force-downloads skills', async () => { const { downloadSkills } = await import('../../src/integrations/skills/local.js'); await updatePlugin('claude', '/project'); expect(mockIntegration.updatePlugin).toHaveBeenCalledWith('/project', undefined); - expect(downloadSkills).toHaveBeenCalledWith('/mock-home/.config/confidence/skills'); + expect(downloadSkills).toHaveBeenCalledWith('/mock-home/.config/confidence/skills', true); }); - it('still downloads skills when CLI update fails', async () => { + it('succeeds via download when CLI update fails', async () => { const { downloadSkills } = await import('../../src/integrations/skills/local.js'); vi.mocked(mockIntegration.updatePlugin).mockRejectedValueOnce(new Error('network error')); await updatePlugin('claude', '/project'); - expect(downloadSkills).toHaveBeenCalledWith('/mock-home/.config/confidence/skills'); + expect(downloadSkills).toHaveBeenCalledWith('/mock-home/.config/confidence/skills', true); + }); + + it('throws when both CLI update and download fail', async () => { + const { downloadSkills } = await import('../../src/integrations/skills/local.js'); + vi.mocked(mockIntegration.updatePlugin).mockRejectedValueOnce(new Error('cli failed')); + vi.mocked(downloadSkills).mockResolvedValueOnce(false); + + await expect(updatePlugin('claude', '/project')).rejects.toThrow('cli failed'); }); }); diff --git a/packages/core/src/integrations/skills/local.ts b/packages/core/src/integrations/skills/local.ts index 12cb046d..7fa01598 100644 --- a/packages/core/src/integrations/skills/local.ts +++ b/packages/core/src/integrations/skills/local.ts @@ -19,20 +19,22 @@ export async function removeSkills(skillsDir: string): Promise { ); } -export async function downloadSkills(skillsDir: string, force = false): Promise { - await Promise.all( +export async function downloadSkills(skillsDir: string, force = false): Promise { + const results = await Promise.allSettled( SKILL_NAMES.map(async (name) => { const destDir = join(skillsDir, name); const destFile = join(destDir, 'SKILL.md'); - if (existsSync(destFile) && !force) return; + if (existsSync(destFile) && !force) return true; - const url = `${SKILLS_BASE_URL}/${name}/SKILL.md`; - const res = await fetch(url); - if (!res.ok) return; + const res = await fetch(`${SKILLS_BASE_URL}/${name}/SKILL.md`); + if (!res.ok) return false; const content = await res.text(); await mkdir(destDir, { recursive: true }); await writeFile(destFile, content, 'utf-8'); + return true; }), ); + + return results.some((r) => r.status === 'fulfilled' && r.value); } diff --git a/packages/core/src/integrations/skills/plugin.ts b/packages/core/src/integrations/skills/plugin.ts index 27262cc5..175635a0 100644 --- a/packages/core/src/integrations/skills/plugin.ts +++ b/packages/core/src/integrations/skills/plugin.ts @@ -21,10 +21,16 @@ export async function installPlugin( ): Promise { const integration = getIntegration(ide); - await Promise.all([ - integration.installPlugin(projectDir, scope).catch(() => cliFailed('install', ide)), + const [cliResult, localResult] = await Promise.allSettled([ + integration.installPlugin(projectDir, scope), downloadSkills(getSkillsDir()), ]); + + const cliOk = cliResult.status === 'fulfilled'; + const localOk = localResult.status === 'fulfilled' && localResult.value; + + if (!cliOk) cliFailed('install', ide); + if (!cliOk && !localOk) throw cliResult.reason; } export async function uninstallPlugin( @@ -42,7 +48,10 @@ export async function uninstallPlugin( } } - await removeSkills(getSkillsDir()); + const others = await detectInstalledPlugins(projectDir); + if (others.length === 0) { + await removeSkills(getSkillsDir()); + } } export async function updatePlugin( @@ -52,8 +61,14 @@ export async function updatePlugin( ): Promise { const integration = getIntegration(ide); - await Promise.all([ - integration.updatePlugin(projectDir, scope).catch(() => cliFailed('update', ide)), - downloadSkills(getSkillsDir()), + const [cliResult, localResult] = await Promise.allSettled([ + integration.updatePlugin(projectDir, scope), + downloadSkills(getSkillsDir(), true), ]); + + const cliOk = cliResult.status === 'fulfilled'; + const localOk = localResult.status === 'fulfilled' && localResult.value; + + if (!cliOk) cliFailed('update', ide); + if (!cliOk && !localOk) throw cliResult.reason; } diff --git a/packages/quickstart/__tests__/ui/screens/InstallPluginsScreen.test.tsx b/packages/quickstart/__tests__/ui/screens/InstallPluginsScreen.test.tsx index 486e4744..ecede296 100644 --- a/packages/quickstart/__tests__/ui/screens/InstallPluginsScreen.test.tsx +++ b/packages/quickstart/__tests__/ui/screens/InstallPluginsScreen.test.tsx @@ -15,8 +15,8 @@ vi.mock('@spotify-confidence/core', async (importOriginal) => ({ ...(await importOriginal()), detectInstalledPlugins: vi.fn().mockResolvedValue([]), prepareIde: vi.fn().mockResolvedValue(undefined), - installPlugin: vi.fn().mockResolvedValue('download'), - updatePlugin: vi.fn().mockResolvedValue('download'), + installPlugin: vi.fn().mockResolvedValue(undefined), + updatePlugin: vi.fn().mockResolvedValue(undefined), })); describe('InstallPluginsScreen', () => { From db591779f007b27acf358fbf7fb95af6386ada28 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 9 Oct 2026 10:06:38 +0200 Subject: [PATCH 20/22] chore: update clean script --- scripts/clean-dev-env.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/clean-dev-env.sh b/scripts/clean-dev-env.sh index 0ba28964..f6a38fad 100755 --- a/scripts/clean-dev-env.sh +++ b/scripts/clean-dev-env.sh @@ -90,8 +90,11 @@ if $clean_mcp; then skills_dirs=("$config_dir/skills" "$PROJECT_DIR/.claude/skills" "$PROJECT_DIR/.cursor/skills" "$PROJECT_DIR/.agents/skills") confidence_skills=( analyze-project + explore-metric instrument-events onboard-confidence + onboard-confidence-dry-run + setup-session-recording setup-warehouse setup-warehouse-bigquery setup-warehouse-databricks From 553eeab3fb8bce3acca154ee0b98b0e20584968b Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 9 Oct 2026 10:09:02 +0200 Subject: [PATCH 21/22] refactor: minor fixes in MCP detect and plugin resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use Object.fromEntries instead of O(n²) reduce with spread - Defer resolveGitRoot until direct key lookups miss - resolvePluginId reuses isAvailable to check enabled and projectPath Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/integrations/claude/mcp/detect.ts | 22 ++++++++----------- .../core/src/integrations/claude/plugins.ts | 2 +- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/core/src/integrations/claude/mcp/detect.ts b/packages/core/src/integrations/claude/mcp/detect.ts index c09cb3a5..61d54097 100644 --- a/packages/core/src/integrations/claude/mcp/detect.ts +++ b/packages/core/src/integrations/claude/mcp/detect.ts @@ -14,27 +14,23 @@ type ClaudeProjectConfig = { mcpServers?: Record }; type ClaudeGlobalConfig = { projects?: Record }; function normalizedProjects(config: ClaudeGlobalConfig): Record { - return Object.entries(config.projects ?? {}) - .map(([rawKey, project]) => [normalize(rawKey), project] as const) - .reduce>( - (acc, [normalizedKey, project]) => ({ ...acc, [normalizedKey]: project }), - {}, - ); + return Object.fromEntries( + Object.entries(config.projects ?? {}).map(([rawKey, project]) => [normalize(rawKey), project]), + ); } function readProjectMcpServers(projectDir: string): Record { try { const resolved = realpathSync(projectDir); - const gitRoot = resolveGitRoot(resolved); const config = JSON.parse(readFileSync(globalConfigPath(), 'utf-8')) as ClaudeGlobalConfig; const projects = normalizedProjects(config); - return ( - projects[resolved]?.mcpServers ?? - projects[normalize(projectDir)]?.mcpServers ?? - (gitRoot ? projects[gitRoot]?.mcpServers : undefined) ?? - {} - ); + const byResolved = + projects[resolved]?.mcpServers ?? projects[normalize(projectDir)]?.mcpServers; + if (byResolved) return byResolved; + + const gitRoot = resolveGitRoot(resolved); + return (gitRoot ? projects[gitRoot]?.mcpServers : undefined) ?? {}; } catch { return {}; } diff --git a/packages/core/src/integrations/claude/plugins.ts b/packages/core/src/integrations/claude/plugins.ts index e7371d60..de3e111f 100644 --- a/packages/core/src/integrations/claude/plugins.ts +++ b/packages/core/src/integrations/claude/plugins.ts @@ -59,7 +59,7 @@ async function resolvePluginId(projectDir: string): Promise { const { stdout } = await execFile('claude', ['plugin', 'list', '--json'], { cwd: projectDir }); const plugins = JSON.parse(stdout) as PluginEntry[]; - const match = plugins.find((p) => p.id.startsWith(`${PLUGIN_NAME}@`)); + const match = plugins.find((p) => isAvailable(p, projectDir)); if (!match) { throw new Error(`Plugin "${PLUGIN_NAME}" is not installed`); } From 1ae504bdaf9846452dfc2eb553138af99e004c81 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 9 Oct 2026 10:18:39 +0200 Subject: [PATCH 22/22] docs: update guidance about shared kernel --- .claude/skills/architecture/SKILL.md | 26 +++++++++++++------------- AGENTS.md | 4 ++-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.claude/skills/architecture/SKILL.md b/.claude/skills/architecture/SKILL.md index 727082b4..1efc6f72 100644 --- a/.claude/skills/architecture/SKILL.md +++ b/.claude/skills/architecture/SKILL.md @@ -10,14 +10,14 @@ Structural rules, domain boundaries, and constraints for the Confidence CLI mono ## Monorepo Structure -| Package | Published | Purpose | -| ------------------------- | --------- | -------------------------------------------------------------------------------------------------------- | -| `packages/shared-kernel/` | No | Cross-domain types and helpers (`noop`, `isDefined`). No runtime deps | -| `packages/eslint-config/` | No | Shared ESLint config (base + react presets) | -| `packages/core/` | No | Shared infrastructure (api, auth, config, session, telemetry, exec, system, sdk, mcp, frameworks, integrations, providers) | -| `packages/testing/` | No | Test infrastructure (sub-paths: `/auth`, `/scaffold`, `/env`, `/terminal`, `/msw`, `/e2e`) | -| `packages/quickstart/` | Yes | TUI wizard — `@spotify-confidence/quickstart` | -| `packages/cli/` | Yes | CLI — `@spotify-confidence/cli` | +| Package | Published | Purpose | +| ------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `packages/shared-kernel/` | No | Cross-domain types and lightweight helpers (`noop`, `isDefined`, `isWindows`). Breaks circular deps between core and testing | +| `packages/eslint-config/` | No | Shared ESLint config (base + react presets) | +| `packages/core/` | No | Shared infrastructure (api, auth, config, session, telemetry, exec, system, sdk, mcp, frameworks, integrations, providers) | +| `packages/testing/` | No | Test infrastructure (sub-paths: `/auth`, `/scaffold`, `/env`, `/terminal`, `/msw`, `/e2e`) | +| `packages/quickstart/` | Yes | TUI wizard — `@spotify-confidence/quickstart` | +| `packages/cli/` | Yes | CLI — `@spotify-confidence/cli` | ### Dependency Graph @@ -39,15 +39,15 @@ import { buildTestJwt } from '@spotify-confidence/testing/auth'; **Intra-package** — use path aliases for cross-domain, relative for within-domain: -| Package | Aliases | -| ---------- | ----------------------------------------------------------------- | -| quickstart | `@commands/*`, `@features/*`, `@ui/*` | +| Package | Aliases | +| ---------- | ---------------------------------------------------------------------------------------- | +| quickstart | `@commands/*`, `@features/*`, `@ui/*` | | cli | `@commands/*`, `@features/*`, `@input/*`, `@output/*`, `@network/*`, `@utils/*`, `@meta` | -| core | Relative imports in `src/`; tsconfig aliases in `__tests__/` only | +| core | Relative imports in `src/`; tsconfig aliases in `__tests__/` only | ## Domain Boundaries -- **shared-kernel** — Types used by 3+ packages. Depends on nothing. +- **shared-kernel** — Types and lightweight helpers shared by 3+ packages. Breaks circular dependencies between core and testing. - **core** — Shared infrastructure. Must not import from quickstart, cli, or testing. - **testing** — Test scaffolds. Depends on shared-kernel only. - **quickstart** — TUI wizard. See `ink-tui` skill for UI details. diff --git a/AGENTS.md b/AGENTS.md index 8ff13405..b3e1bcf2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ pnpm workspace with six packages under `packages/`: | Package | Published | Purpose | | ------------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `packages/shared-kernel/` | No (private) | Cross-domain types (`AuthState`, `IdeId`, `OnboardingGoal`, etc.) and helpers (`noop`, `isDefined`). No runtime dependencies. | +| `packages/shared-kernel/` | No (private) | Cross-domain types (`AuthState`, `IdeId`, `OnboardingGoal`, etc.) and lightweight helpers (`noop`, `isDefined`, `isWindows`). Breaks circular dependencies between core and testing. | | `packages/eslint-config/` | No (private) | Shared ESLint configuration. Exports base preset and `/react` preset with React Hooks rules. | | `packages/core/` | No (private) | Shared infrastructure — api, auth, config, session, telemetry, exec, system, sdk, mcp, utils, constants, frameworks, integrations, providers. Depends on `shared-kernel`. | | `packages/testing/` | No (private) | Test infrastructure — auth scaffolds, project scaffolds, env helpers, terminal helpers, MSW handlers. Sub-path exports: `/auth`, `/scaffold`, `/env`, `/terminal`, `/msw`, `/e2e`. Depends on `shared-kernel`. | @@ -18,7 +18,7 @@ pnpm workspace with six packages under `packages/`: ### Dependency graph ``` -shared-kernel (types-only leaf) +shared-kernel (shared leaf) ▲ ├── core (infrastructure) ├── testing (test scaffolds)