From 3968730a17c2158cb561dabb828c400121a5ab34 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Wed, 7 Oct 2026 14:59:14 +0200 Subject: [PATCH 01/12] feat(cli): introduce `warehouse` commands --- .claude/skills/cli/SKILL.md | 2 +- AGENTS.md | 20 +- packages/cli/README.md | 11 + .../cli/__tests__/commands/warehouse.test.ts | 400 ++++++++++++++++++ packages/cli/bin/cli.ts | 2 + packages/cli/src/commands/index.ts | 1 + packages/cli/src/commands/warehouse.ts | 161 +++++++ .../warehouse/create-assignment-table.ts | 46 ++ .../features/warehouse/create-crypto-key.ts | 18 + .../warehouse/create-event-connection.ts | 21 + .../create-flag-applied-connection.ts | 21 + packages/cli/src/features/warehouse/create.ts | 15 + packages/cli/src/features/warehouse/index.ts | 6 + packages/cli/src/features/warehouse/types.ts | 4 + .../cli/src/features/warehouse/validate.ts | 78 ++++ packages/cli/src/network/index.ts | 9 + packages/cli/src/network/warehouse.ts | 66 +++ packages/cli/src/utils/index.ts | 1 + packages/cli/src/utils/mcp-error.ts | 9 + 19 files changed, 880 insertions(+), 11 deletions(-) create mode 100644 packages/cli/__tests__/commands/warehouse.test.ts create mode 100644 packages/cli/src/commands/warehouse.ts create mode 100644 packages/cli/src/features/warehouse/create-assignment-table.ts create mode 100644 packages/cli/src/features/warehouse/create-crypto-key.ts create mode 100644 packages/cli/src/features/warehouse/create-event-connection.ts create mode 100644 packages/cli/src/features/warehouse/create-flag-applied-connection.ts create mode 100644 packages/cli/src/features/warehouse/create.ts create mode 100644 packages/cli/src/features/warehouse/index.ts create mode 100644 packages/cli/src/features/warehouse/types.ts create mode 100644 packages/cli/src/features/warehouse/validate.ts create mode 100644 packages/cli/src/network/warehouse.ts create mode 100644 packages/cli/src/utils/mcp-error.ts diff --git a/.claude/skills/cli/SKILL.md b/.claude/skills/cli/SKILL.md index 8f18dcb..582eac4 100644 --- a/.claude/skills/cli/SKILL.md +++ b/.claude/skills/cli/SKILL.md @@ -30,7 +30,7 @@ For parent commands with subcommands, use `noop` from `@spotify-confidence/share ### Command Types - **Standalone** — `login`, `logout`, `whoami`, `config`, `update` — directly perform their action -- **API commands** — `flags`, `events`, `recordings`, `docs` — CRUD operations against Confidence APIs via `@network/*` +- **API commands** — `flags`, `events`, `recordings`, `docs`, `warehouse` — CRUD operations against Confidence APIs via `@network/*` - **Integration** — `mcp`, `plugin`, `sdk`, `migrate` — manage IDE tooling and SDK setup - **Setup** — `flags setup`, `events setup`, `recordings setup` — delegate to quickstart TUI with pre-selected features - **TUI launcher** — `quickstart` — launches the full interactive wizard diff --git a/AGENTS.md b/AGENTS.md index b8adf0b..8ff1340 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,14 +6,14 @@ CLI tools for setting up and integrating [Confidence](https://confidence.spotify 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/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`. | +| Package | Published | Purpose | +| ------------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/shared-kernel/` | No (private) | Cross-domain types (`AuthState`, `IdeId`, `OnboardingGoal`, etc.) and helpers (`noop`, `isDefined`). No runtime dependencies. | +| `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`. | -| `packages/quickstart/` | Yes (`@spotify-confidence/quickstart`) | Interactive TUI wizard. Depends on `core` and `shared-kernel`. | -| `packages/cli/` | Yes (`@spotify-confidence/cli`) | CLI for managing Confidence (flags, events, recordings, config). Depends on `quickstart`. | +| `packages/quickstart/` | Yes (`@spotify-confidence/quickstart`) | Interactive TUI wizard. Depends on `core` and `shared-kernel`. | +| `packages/cli/` | Yes (`@spotify-confidence/cli`) | CLI for managing Confidence (flags, events, recordings, config). Depends on `quickstart`. | ### Dependency graph @@ -53,11 +53,11 @@ shared-kernel (types-only leaf) ### packages/cli/ structure - **`bin/cli.ts`** — Entry point (yargs, `confidence` binary) -- **`src/commands/`** — Command definitions (login, logout, whoami, config, flags, events, recordings, docs, mcp, plugin, sdk, migrate, update, quickstart) -- **`src/features/`** — Feature implementations (config, docs, events, flags, ide, mcp, migrate, plugin, quickstart, recordings, sdk, update) +- **`src/commands/`** — Command definitions (login, logout, whoami, config, flags, events, recordings, docs, mcp, plugin, sdk, migrate, update, quickstart, warehouse) +- **`src/features/`** — Feature implementations (config, docs, events, flags, ide, mcp, migrate, plugin, quickstart, recordings, sdk, update, warehouse) - **`src/input/`** — Input parsing (file reading, aliases, resolve) - **`src/output/`** — Output formatters (json, table, format detection) -- **`src/network/`** — API clients (flags, events, recordings, docs, config, registry) +- **`src/network/`** — API clients (flags, events, recordings, docs, config, registry, warehouse) - **`src/utils/`** — Shared utilities (auth, safely, telemetry, validation) ## Key Patterns diff --git a/packages/cli/README.md b/packages/cli/README.md index c00ab00..96bfe25 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -72,6 +72,17 @@ confidence quickstart # Launch the interactive setup wizard | `confidence recordings targeting-key show ` | Show available targeting keys | | `confidence recordings targeting-key add` | Add a targeting key to a client | +### Data Warehouses + +| Command | Description | +| ---------------------------------------------------- | -------------------------------------------------- | +| `confidence warehouse validate` | Validate warehouse configuration | +| `confidence warehouse create` | Create a data warehouse connection | +| `confidence warehouse connector create-flag-applied` | Create flag assignment data connector | +| `confidence warehouse connector create-event` | Create event data connector | +| `confidence warehouse assignment-table create` | Create an assignment table for experiment analysis | +| `confidence warehouse crypto-key create` | Create a crypto key (Snowflake) | + ### MCP Servers | Command | Description | diff --git a/packages/cli/__tests__/commands/warehouse.test.ts b/packages/cli/__tests__/commands/warehouse.test.ts new file mode 100644 index 0000000..7f18f5d --- /dev/null +++ b/packages/cli/__tests__/commands/warehouse.test.ts @@ -0,0 +1,400 @@ +import { prepareAuthTokens } from '@spotify-confidence/testing/auth'; +import type { CallToolResult } from '@spotify-confidence/core'; +import { warehouseCommand } from '@commands/warehouse.js'; +import { captureOutput } from '../helpers/capture.js'; +import { textResult, jsonResult } from '../helpers/mcp-result.js'; +import { createRunner } from '../helpers/run-command.js'; + +const mockMcpCallTool = vi.fn<(...args: unknown[]) => Promise>(); + +vi.mock('@spotify-confidence/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + mcpCallTool: (...args: unknown[]) => mockMcpCallTool(...args), + }; +}); + +const run = createRunner(warehouseCommand); + +describe('warehouse validate', () => { + it('shows validation results as table', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + mockMcpCallTool.mockResolvedValueOnce( + jsonResult({ + successful: true, + validation: [ + { key: 'CONNECTION', description: 'Can connect', success: true }, + { key: 'PERMISSIONS', description: 'Has permissions', success: true }, + ], + }), + ); + + await run([ + 'warehouse', + 'validate', + '--warehouse-type', + 'bigquery', + '--config-json', + '{"gcpProjectId":"my-project"}', + '--output', + 'table', + ]); + + expect(output.stdout).toContain('Validation passed'); + expect(output.stdout).toContain('PASS'); + expect(output.stdout).toContain('CONNECTION'); + }); + + it('reports failures with non-zero exit code', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + mockMcpCallTool.mockResolvedValueOnce( + jsonResult({ + successful: false, + validation: [ + { + key: 'PERMISSIONS', + description: 'Has permissions', + success: false, + error: 'Missing bigquery.jobs.create', + }, + ], + }), + ); + + await run([ + 'warehouse', + 'validate', + '--warehouse-type', + 'bigquery', + '--config-json', + '{"gcpProjectId":"my-project"}', + '--output', + 'table', + ]); + + expect(output.stdout).toContain('Validation failed'); + expect(output.stdout).toContain('FAIL'); + expect(process.exitCode).toBe(1); + }); + + it('outputs full response with --json', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + mockMcpCallTool.mockResolvedValueOnce( + jsonResult({ + successful: true, + validation: [{ key: 'CONNECTION', description: 'Can connect', success: true }], + }), + ); + + await run([ + 'warehouse', + 'validate', + '--warehouse-type', + 'bigquery', + '--config-json', + '{"gcpProjectId":"my-project"}', + '--json', + ]); + + const parsed = JSON.parse(output.stdout); + expect(parsed.data.successful).toBe(true); + }); + + it('passes correct args to MCP tool', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + mockMcpCallTool.mockResolvedValueOnce(jsonResult({ successful: true, validation: [] })); + + await run([ + 'warehouse', + 'validate', + '--warehouse-type', + 'snowflake', + '--config-json', + '{"account":"abc"}', + ]); + + expect(mockMcpCallTool).toHaveBeenCalledWith( + expect.anything(), + 'validateWarehouseConfig', + expect.objectContaining({ warehouseType: 'snowflake', configJson: '{"account":"abc"}' }), + ); + }); + + it('fails when not logged in', async () => { + using _auth = prepareAuthTokens('none'); + using output = captureOutput(); + + await run(['warehouse', 'validate', '--warehouse-type', 'bigquery', '--config-json', '{}']); + + expect(output.stderr).toContain('Not logged in'); + }); +}); + +describe('warehouse create', () => { + it('creates a warehouse and outputs result', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + mockMcpCallTool.mockResolvedValueOnce(textResult('Created warehouse: dataWarehouses/abc123')); + + await run([ + 'warehouse', + 'create', + '--warehouse-type', + 'bigquery', + '--config-json', + '{"gcpProjectId":"my-project","dataset":"confidence"}', + ]); + + expect(output.stdout).toContain('dataWarehouses/abc123'); + }); + + it('passes correct args to MCP tool', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + mockMcpCallTool.mockResolvedValueOnce(textResult('Created')); + + const configJson = '{"gcpProjectId":"proj","dataset":"ds"}'; + await run([ + 'warehouse', + 'create', + '--warehouse-type', + 'databricks', + '--config-json', + configJson, + ]); + + expect(mockMcpCallTool).toHaveBeenCalledWith( + expect.anything(), + 'createWarehouse', + expect.objectContaining({ warehouseType: 'databricks', configJson }), + ); + }); +}); + +describe('warehouse connector create-flag-applied', () => { + it('creates a flag applied connector', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + mockMcpCallTool.mockResolvedValueOnce(textResult('Created flag applied connection')); + + await run([ + 'warehouse', + 'connector', + 'create-flag-applied', + '--warehouse-type', + 'bigquery', + '--config-json', + '{"table":"confidence_flag_applied"}', + ]); + + expect(output.stdout).toContain('Created flag applied connection'); + }); + + it('passes correct args to MCP tool', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + mockMcpCallTool.mockResolvedValueOnce(textResult('Created')); + + await run([ + 'warehouse', + 'connector', + 'create-flag-applied', + '--warehouse-type', + 'redshift', + '--config-json', + '{"cluster":"my-cluster"}', + ]); + + expect(mockMcpCallTool).toHaveBeenCalledWith( + expect.anything(), + 'createFlagAppliedConnection', + expect.objectContaining({ + warehouseType: 'redshift', + configJson: '{"cluster":"my-cluster"}', + }), + ); + }); +}); + +describe('warehouse connector create-event', () => { + it('creates an event connector', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + mockMcpCallTool.mockResolvedValueOnce(textResult('Created event connection')); + + await run([ + 'warehouse', + 'connector', + 'create-event', + '--warehouse-type', + 'snowflake', + '--config-json', + '{"tablePrefix":"events_"}', + ]); + + expect(output.stdout).toContain('Created event connection'); + }); + + it('passes correct args to MCP tool', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + mockMcpCallTool.mockResolvedValueOnce(textResult('Created')); + + await run([ + 'warehouse', + 'connector', + 'create-event', + '--warehouse-type', + 'snowflake', + '--config-json', + '{"tablePrefix":"events_"}', + ]); + + expect(mockMcpCallTool).toHaveBeenCalledWith( + expect.anything(), + 'createEventConnection', + expect.objectContaining({ + warehouseType: 'snowflake', + configJson: '{"tablePrefix":"events_"}', + }), + ); + }); +}); + +describe('warehouse assignment-table create', () => { + it('creates an assignment table', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + mockMcpCallTool.mockResolvedValueOnce(textResult('Created assignment table')); + + await run([ + 'warehouse', + 'assignment-table', + 'create', + '--display-name', + 'Flag Assignments', + '--sql', + 'SELECT * FROM assignments', + '--entity-column', + 'targeting_key', + '--timestamp-column', + 'assignment_time', + '--exposure-key-column', + 'rule', + '--variant-key-column', + 'assignment_id', + ]); + + expect(output.stdout).toContain('Created assignment table'); + }); + + it('passes correct args to MCP tool', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + mockMcpCallTool.mockResolvedValueOnce(textResult('Created')); + + await run([ + 'warehouse', + 'assignment-table', + 'create', + '--display-name', + 'My Table', + '--sql', + 'SELECT col FROM t', + '--entity-column', + 'user_id', + '--timestamp-column', + 'ts', + '--exposure-key-column', + 'exp', + '--variant-key-column', + 'var', + ]); + + expect(mockMcpCallTool).toHaveBeenCalledWith( + expect.anything(), + 'createAssignmentTable', + expect.objectContaining({ + displayName: 'My Table', + sql: 'SELECT col FROM t', + entityColumn: 'user_id', + timestampColumn: 'ts', + exposureKeyColumn: 'exp', + variantKeyColumn: 'var', + }), + ); + }); + + it('fails when not logged in', async () => { + using _auth = prepareAuthTokens('none'); + using output = captureOutput(); + + await run([ + 'warehouse', + 'assignment-table', + 'create', + '--display-name', + 'Test', + '--sql', + 'SELECT 1', + '--entity-column', + 'a', + '--timestamp-column', + 'b', + '--exposure-key-column', + 'c', + '--variant-key-column', + 'd', + ]); + + expect(output.stderr).toContain('Not logged in'); + }); +}); + +describe('warehouse crypto-key create', () => { + it('creates a crypto key', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + mockMcpCallTool.mockResolvedValueOnce( + textResult('Created crypto key: cryptoKeys/snowflake-key'), + ); + + await run(['warehouse', 'crypto-key', 'create', '--crypto-key-id', 'snowflake-key']); + + expect(output.stdout).toContain('cryptoKeys/snowflake-key'); + }); + + it('passes correct args to MCP tool', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + mockMcpCallTool.mockResolvedValueOnce(textResult('Created')); + + await run(['warehouse', 'crypto-key', 'create', '--crypto-key-id', 'my-key']); + + expect(mockMcpCallTool).toHaveBeenCalledWith( + expect.anything(), + 'createCryptoKey', + expect.objectContaining({ cryptoKeyId: 'my-key' }), + ); + }); +}); + +describe('warehouse MCP error handling', () => { + it('reports MCP errors to stderr', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + mockMcpCallTool.mockResolvedValueOnce({ + isError: true, + content: [{ type: 'text', text: 'Warehouse type not supported' }], + }); + + await run(['warehouse', 'create', '--warehouse-type', 'bigquery', '--config-json', '{}']); + + expect(output.stderr).toContain('Warehouse type not supported'); + }); +}); diff --git a/packages/cli/bin/cli.ts b/packages/cli/bin/cli.ts index a451ecc..1e9f915 100644 --- a/packages/cli/bin/cli.ts +++ b/packages/cli/bin/cli.ts @@ -19,6 +19,7 @@ import { updateCommand, pluginCommand, migrateCommand, + warehouseCommand, } from '../src/commands/index.js'; const cli = yargs(hideBin(process.argv)) @@ -86,6 +87,7 @@ const cli = yargs(hideBin(process.argv)) .command(updateCommand) .command(pluginCommand) .command(migrateCommand) + .command(warehouseCommand) .example('$0 login', 'Sign in to Confidence') .example('$0 flags setup', 'Set up feature flags in your project') .example('$0 docs search "feature flags"', 'Search the documentation') diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 781f0b4..84dd9b0 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -12,4 +12,5 @@ export { mcpCommand } from './mcp.js'; export { pluginCommand } from './plugin.js'; export { updateCommand } from './update.js'; export { migrateCommand } from './migrate.js'; +export { warehouseCommand } from './warehouse.js'; export type { GlobalFlags } from './types.js'; diff --git a/packages/cli/src/commands/warehouse.ts b/packages/cli/src/commands/warehouse.ts new file mode 100644 index 0000000..6eb187e --- /dev/null +++ b/packages/cli/src/commands/warehouse.ts @@ -0,0 +1,161 @@ +import type { Argv } from 'yargs'; +import { noop } from '@spotify-confidence/shared-kernel'; +import { + validateConfig, + createWarehouseCmd, + createFlagAppliedConnectionCmd, + createEventConnectionCmd, + createAssignmentTableCmd, + createCryptoKeyCmd, +} from '@features/warehouse/index.js'; +import { safely } from '@utils/index.js'; + +const WAREHOUSE_TYPES = ['bigquery', 'snowflake', 'databricks', 'redshift'] as const; + +function warehouseTypeBuilder(yy: Argv): Argv { + return yy + .option('warehouse-type', { + type: 'string', + choices: WAREHOUSE_TYPES, + describe: 'Data warehouse type', + }) + .option('config-json', { + type: 'string', + describe: 'Warehouse-specific configuration as JSON string', + }) + .option('from-file', { + type: 'string', + describe: 'Read params from JSON file', + }) + .check((argv) => { + if (!argv['from-file'] && (!argv['warehouse-type'] || !argv['config-json'])) { + throw new Error('--warehouse-type and --config-json are required (or use --from-file)'); + } + return true; + }); +} + +export const warehouseCommand = { + command: 'warehouse ', + describe: 'Manage data warehouse connections', + builder(yargs: Argv) { + return yargs + .command( + 'validate', + 'Validate warehouse configuration', + warehouseTypeBuilder, + safely(validateConfig), + ) + .command( + 'create', + 'Create a data warehouse', + warehouseTypeBuilder, + safely(createWarehouseCmd), + ) + .command('connector ', 'Manage data connectors', (y: Argv) => + y + .command( + 'create-flag-applied', + 'Create flag assignment connector', + warehouseTypeBuilder, + safely(createFlagAppliedConnectionCmd), + ) + .command( + 'create-event', + 'Create event data connector', + warehouseTypeBuilder, + safely(createEventConnectionCmd), + ) + .demandCommand(1, 'Available actions: create-flag-applied, create-event') + .strict(), + ) + .command('assignment-table ', 'Manage assignment tables', (y: Argv) => + y + .command( + 'create', + 'Create an assignment table', + (yy: Argv) => + yy + .option('display-name', { + type: 'string', + describe: 'Table display name', + }) + .option('sql', { + type: 'string', + describe: 'SQL query defining the table', + }) + .option('entity-column', { + type: 'string', + describe: 'Column name for entity/targeting key', + }) + .option('timestamp-column', { + type: 'string', + describe: 'Column name for timestamps', + }) + .option('exposure-key-column', { + type: 'string', + describe: 'Column name for exposure key', + }) + .option('variant-key-column', { + type: 'string', + describe: 'Column name for variant key', + }) + .option('from-file', { + type: 'string', + describe: 'Read params from JSON file', + }) + .check((argv) => { + if ( + !argv['from-file'] && + (!argv['display-name'] || + !argv.sql || + !argv['entity-column'] || + !argv['timestamp-column'] || + !argv['exposure-key-column'] || + !argv['variant-key-column']) + ) { + throw new Error( + '--display-name, --sql, --entity-column, --timestamp-column, --exposure-key-column, and --variant-key-column are required (or use --from-file)', + ); + } + return true; + }), + safely(createAssignmentTableCmd), + ) + .demandCommand(1, 'Available actions: create') + .strict(), + ) + .command('crypto-key ', 'Manage crypto keys (Snowflake)', (y: Argv) => + y + .command( + 'create', + 'Create a crypto key', + (yy: Argv) => + yy + .option('crypto-key-id', { + type: 'string', + describe: 'Key identifier (e.g. snowflake-key)', + }) + .option('from-file', { + type: 'string', + describe: 'Read params from JSON file', + }) + .check((argv) => { + if (!argv['from-file'] && !argv['crypto-key-id']) { + throw new Error('--crypto-key-id is required (or use --from-file)'); + } + return true; + }), + safely(createCryptoKeyCmd), + ) + .demandCommand(1, 'Available actions: create') + .strict(), + ) + .demandCommand( + 1, + 'Available actions: validate, create, connector, assignment-table, crypto-key. Run "confidence warehouse --help" for details.', + ) + .strict(); + }, + handler: noop, +}; diff --git a/packages/cli/src/features/warehouse/create-assignment-table.ts b/packages/cli/src/features/warehouse/create-assignment-table.ts new file mode 100644 index 0000000..b73fee5 --- /dev/null +++ b/packages/cli/src/features/warehouse/create-assignment-table.ts @@ -0,0 +1,46 @@ +import { createAssignmentTable } from '@network/index.js'; +import { printMcpResult } from '@output/index.js'; +import { withAuth, tryHandleMcpError } from '@utils/index.js'; +import { resolveInput } from '@input/index.js'; +import { requireKeys } from '@utils/validation.js'; + +type AssignmentTableParams = { + 'display-name': string; + sql: string; + 'entity-column': string; + 'timestamp-column': string; + 'exposure-key-column': string; + 'variant-key-column': string; +}; + +export const createAssignmentTableCmd = withAuth( + async function createAssignmentTableCmd(argv, token) { + const params = resolveInput(argv, [ + 'display-name', + 'sql', + 'entity-column', + 'timestamp-column', + 'exposure-key-column', + 'variant-key-column', + ]); + requireKeys(params, [ + 'display-name', + 'sql', + 'entity-column', + 'timestamp-column', + 'exposure-key-column', + 'variant-key-column', + ]); + + const result = await createAssignmentTable(token, { + displayName: params['display-name'], + sql: params.sql, + entityColumn: params['entity-column'], + timestampColumn: params['timestamp-column'], + exposureKeyColumn: params['exposure-key-column'], + variantKeyColumn: params['variant-key-column'], + }); + if (tryHandleMcpError(result)) return; + printMcpResult(result, argv); + }, +); diff --git a/packages/cli/src/features/warehouse/create-crypto-key.ts b/packages/cli/src/features/warehouse/create-crypto-key.ts new file mode 100644 index 0000000..fde0cce --- /dev/null +++ b/packages/cli/src/features/warehouse/create-crypto-key.ts @@ -0,0 +1,18 @@ +import { createCryptoKey } from '@network/index.js'; +import { printMcpResult } from '@output/index.js'; +import { withAuth, tryHandleMcpError } from '@utils/index.js'; +import { resolveInput } from '@input/index.js'; +import { requireKeys } from '@utils/validation.js'; + +type CryptoKeyParams = { + 'crypto-key-id': string; +}; + +export const createCryptoKeyCmd = withAuth(async function createCryptoKeyCmd(argv, token) { + const params = resolveInput(argv, ['crypto-key-id']); + requireKeys(params, ['crypto-key-id']); + + const result = await createCryptoKey(token, params['crypto-key-id']); + if (tryHandleMcpError(result)) return; + printMcpResult(result, argv); +}); diff --git a/packages/cli/src/features/warehouse/create-event-connection.ts b/packages/cli/src/features/warehouse/create-event-connection.ts new file mode 100644 index 0000000..168fe79 --- /dev/null +++ b/packages/cli/src/features/warehouse/create-event-connection.ts @@ -0,0 +1,21 @@ +import { createEventConnection } from '@network/index.js'; +import { printMcpResult } from '@output/index.js'; +import { withAuth, tryHandleMcpError } from '@utils/index.js'; +import { resolveInput } from '@input/index.js'; +import { requireKeys } from '@utils/validation.js'; +import type { WarehouseTypeParams } from './types.js'; + +export const createEventConnectionCmd = withAuth( + async function createEventConnectionCmd(argv, token) { + const params = resolveInput(argv, ['warehouse-type', 'config-json']); + requireKeys(params, ['warehouse-type', 'config-json']); + + const result = await createEventConnection( + token, + params['warehouse-type'], + params['config-json'], + ); + if (tryHandleMcpError(result)) return; + printMcpResult(result, argv); + }, +); diff --git a/packages/cli/src/features/warehouse/create-flag-applied-connection.ts b/packages/cli/src/features/warehouse/create-flag-applied-connection.ts new file mode 100644 index 0000000..347d588 --- /dev/null +++ b/packages/cli/src/features/warehouse/create-flag-applied-connection.ts @@ -0,0 +1,21 @@ +import { createFlagAppliedConnection } from '@network/index.js'; +import { printMcpResult } from '@output/index.js'; +import { withAuth, tryHandleMcpError } from '@utils/index.js'; +import { resolveInput } from '@input/index.js'; +import { requireKeys } from '@utils/validation.js'; +import type { WarehouseTypeParams } from './types.js'; + +export const createFlagAppliedConnectionCmd = withAuth( + async function createFlagAppliedConnectionCmd(argv, token) { + const params = resolveInput(argv, ['warehouse-type', 'config-json']); + requireKeys(params, ['warehouse-type', 'config-json']); + + const result = await createFlagAppliedConnection( + token, + params['warehouse-type'], + params['config-json'], + ); + if (tryHandleMcpError(result)) return; + printMcpResult(result, argv); + }, +); diff --git a/packages/cli/src/features/warehouse/create.ts b/packages/cli/src/features/warehouse/create.ts new file mode 100644 index 0000000..34eef8c --- /dev/null +++ b/packages/cli/src/features/warehouse/create.ts @@ -0,0 +1,15 @@ +import { createWarehouse } from '@network/index.js'; +import { printMcpResult } from '@output/index.js'; +import { withAuth, tryHandleMcpError } from '@utils/index.js'; +import { resolveInput } from '@input/index.js'; +import { requireKeys } from '@utils/validation.js'; +import type { WarehouseTypeParams } from './types.js'; + +export const createWarehouseCmd = withAuth(async function createWarehouseCmd(argv, token) { + const params = resolveInput(argv, ['warehouse-type', 'config-json']); + requireKeys(params, ['warehouse-type', 'config-json']); + + const result = await createWarehouse(token, params['warehouse-type'], params['config-json']); + if (tryHandleMcpError(result)) return; + printMcpResult(result, argv); +}); diff --git a/packages/cli/src/features/warehouse/index.ts b/packages/cli/src/features/warehouse/index.ts new file mode 100644 index 0000000..2e27c18 --- /dev/null +++ b/packages/cli/src/features/warehouse/index.ts @@ -0,0 +1,6 @@ +export { validateConfig } from './validate.js'; +export { createWarehouseCmd } from './create.js'; +export { createFlagAppliedConnectionCmd } from './create-flag-applied-connection.js'; +export { createEventConnectionCmd } from './create-event-connection.js'; +export { createAssignmentTableCmd } from './create-assignment-table.js'; +export { createCryptoKeyCmd } from './create-crypto-key.js'; diff --git a/packages/cli/src/features/warehouse/types.ts b/packages/cli/src/features/warehouse/types.ts new file mode 100644 index 0000000..4ebed2b --- /dev/null +++ b/packages/cli/src/features/warehouse/types.ts @@ -0,0 +1,4 @@ +export type WarehouseTypeParams = { + 'warehouse-type': string; + 'config-json': string; +}; diff --git a/packages/cli/src/features/warehouse/validate.ts b/packages/cli/src/features/warehouse/validate.ts new file mode 100644 index 0000000..ed59ac6 --- /dev/null +++ b/packages/cli/src/features/warehouse/validate.ts @@ -0,0 +1,78 @@ +import { extractText, parseToolJson } from '@spotify-confidence/core'; +import { validateWarehouseConfig } from '@network/index.js'; +import { resolveFormat, formatJson, message, print, extractFlags } from '@output/index.js'; +import { withAuth, tryHandleMcpError } from '@utils/index.js'; +import { resolveInput } from '@input/index.js'; +import { requireKeys } from '@utils/validation.js'; +import type { WarehouseTypeParams } from './types.js'; + +type ValidationCheck = { + key: string; + description: string; + success: boolean; + error?: string; +}; + +type ValidateResponse = { + successful: boolean; + validation: ValidationCheck[]; + configurationResponse?: unknown; +}; + +type ValidationRow = { + status: string; + key: string; + description: string; + error: string; +}; + +export const validateConfig = withAuth(async function validateConfig(argv, token) { + const params = resolveInput(argv, ['warehouse-type', 'config-json']); + requireKeys(params, ['warehouse-type', 'config-json']); + + const result = await validateWarehouseConfig( + token, + params['warehouse-type'], + params['config-json'], + ); + if (tryHandleMcpError(result)) return; + + let data: ValidateResponse; + try { + data = parseToolJson(result); + } catch { + message(extractText(result)); + return; + } + + const flags = extractFlags(argv); + const format = resolveFormat(flags); + + if (format === 'json') { + message(formatJson(data)); + return; + } + + message(data.successful ? 'Validation passed.' : 'Validation failed.'); + + print({ + data: (data.validation ?? []).map((v) => ({ + status: v.success ? 'PASS' : 'FAIL', + key: v.key, + description: v.description, + error: v.error ?? '', + })), + columns: [ + { key: 'status', header: 'Status', width: 6 }, + { key: 'key', header: 'Check', width: 20 }, + { key: 'description', header: 'Description' }, + { key: 'error', header: 'Error' }, + ], + flags, + empty: 'No validation checks returned.', + }); + + if (!data.successful) { + process.exitCode = 1; + } +}); diff --git a/packages/cli/src/network/index.ts b/packages/cli/src/network/index.ts index e2beca0..609492b 100644 --- a/packages/cli/src/network/index.ts +++ b/packages/cli/src/network/index.ts @@ -35,3 +35,12 @@ export { type FlagRule, type FlagListResponse, } from './flags.js'; + +export { + validateWarehouseConfig, + createWarehouse, + createFlagAppliedConnection, + createEventConnection, + createAssignmentTable, + createCryptoKey, +} from './warehouse.js'; diff --git a/packages/cli/src/network/warehouse.ts b/packages/cli/src/network/warehouse.ts new file mode 100644 index 0000000..721569c --- /dev/null +++ b/packages/cli/src/network/warehouse.ts @@ -0,0 +1,66 @@ +import { mcpCallTool, type CallToolResult } from '@spotify-confidence/core'; +import { serverOpts } from './config.js'; + +export async function validateWarehouseConfig( + token: string, + warehouseType: string, + configJson: string, +): Promise { + return mcpCallTool(serverOpts(token), 'validateWarehouseConfig', { + warehouseType, + configJson, + }); +} + +export async function createWarehouse( + token: string, + warehouseType: string, + configJson: string, +): Promise { + return mcpCallTool(serverOpts(token), 'createWarehouse', { + warehouseType, + configJson, + }); +} + +export async function createFlagAppliedConnection( + token: string, + warehouseType: string, + configJson: string, +): Promise { + return mcpCallTool(serverOpts(token), 'createFlagAppliedConnection', { + warehouseType, + configJson, + }); +} + +export async function createEventConnection( + token: string, + warehouseType: string, + configJson: string, +): Promise { + return mcpCallTool(serverOpts(token), 'createEventConnection', { + warehouseType, + configJson, + }); +} + +type CreateAssignmentTableParams = { + displayName: string; + sql: string; + entityColumn: string; + timestampColumn: string; + exposureKeyColumn: string; + variantKeyColumn: string; +}; + +export async function createAssignmentTable( + token: string, + params: CreateAssignmentTableParams, +): Promise { + return mcpCallTool(serverOpts(token), 'createAssignmentTable', params); +} + +export async function createCryptoKey(token: string, cryptoKeyId: string): Promise { + return mcpCallTool(serverOpts(token), 'createCryptoKey', { cryptoKeyId }); +} diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts index 0d2e793..248d517 100644 --- a/packages/cli/src/utils/index.ts +++ b/packages/cli/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './auth.js'; +export * from './mcp-error.js'; export * from './safely.js'; export * from './telemetry.js'; export * from './validation.js'; diff --git a/packages/cli/src/utils/mcp-error.ts b/packages/cli/src/utils/mcp-error.ts new file mode 100644 index 0000000..8057a21 --- /dev/null +++ b/packages/cli/src/utils/mcp-error.ts @@ -0,0 +1,9 @@ +import { readMcpText, type CallToolResult } from '@spotify-confidence/core'; +import { fail } from '@output/print.js'; + +export function tryHandleMcpError(result: CallToolResult): boolean { + if (!result.isError) return false; + + fail(readMcpText(result) || 'MCP tool call failed'); + return true; +} From 6bb2a449d66c3100990a8a786e1c993fdb91739f Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Wed, 7 Oct 2026 15:11:31 +0200 Subject: [PATCH 02/12] refactor: reduce duplication in MCP error handling --- .../features/recordings/format-mcp-error.ts | 20 ++++++++----------- packages/cli/src/utils/mcp-error.ts | 12 +++++++++-- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/features/recordings/format-mcp-error.ts b/packages/cli/src/features/recordings/format-mcp-error.ts index 3d00316..0062d3a 100644 --- a/packages/cli/src/features/recordings/format-mcp-error.ts +++ b/packages/cli/src/features/recordings/format-mcp-error.ts @@ -1,20 +1,17 @@ -import { readMcpText, type CallToolResult } from '@spotify-confidence/core'; +import type { CallToolResult } from '@spotify-confidence/core'; import { error, fail } from '@output/print.js'; +import { tryHandleMcpError as baseTryHandleMcpError } from '@utils/index.js'; export function tryHandleMcpError(result: CallToolResult): boolean { - if (!result.isError) return false; - - const text = readMcpText(result) || 'MCP tool call failed'; - if (!formatClientListError(text)) { - fail(text); - } - - return true; + return baseTryHandleMcpError(result, { formatError: formatClientListError }); } -function formatClientListError(text: string): boolean { +function formatClientListError(text: string): void { const match = text.match(/^(.*?Available clients are: )(.+)$/s); - if (!match) return false; + if (!match) { + fail(text); + return; + } const [, prefix, clientList] = match; fail(prefix.trim()); @@ -22,5 +19,4 @@ function formatClientListError(text: string): boolean { for (const client of clientList.split(/,\s*/)) { error(` ${client.trim()}`); } - return true; } diff --git a/packages/cli/src/utils/mcp-error.ts b/packages/cli/src/utils/mcp-error.ts index 8057a21..15332ee 100644 --- a/packages/cli/src/utils/mcp-error.ts +++ b/packages/cli/src/utils/mcp-error.ts @@ -1,9 +1,17 @@ import { readMcpText, type CallToolResult } from '@spotify-confidence/core'; import { fail } from '@output/print.js'; -export function tryHandleMcpError(result: CallToolResult): boolean { +type Options = { + formatError?: (text: string) => void; + defaultMessage?: string; +}; + +export function tryHandleMcpError(result: CallToolResult, opts?: Options): boolean { if (!result.isError) return false; - fail(readMcpText(result) || 'MCP tool call failed'); + const formatError = opts?.formatError ?? fail; + const defaultMessage = opts?.defaultMessage ?? 'MCP tool call failed'; + + formatError(readMcpText(result) || defaultMessage); return true; } From 958260fb490872e0c8dd3f851d91ddd10b2bef1b Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Wed, 7 Oct 2026 15:22:10 +0200 Subject: [PATCH 03/12] feat(cli): extend `warehouse` commands with `setup` --- packages/cli/src/commands/warehouse.ts | 25 ++++++++- packages/cli/src/features/warehouse/index.ts | 1 + packages/cli/src/features/warehouse/setup.ts | 57 ++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/features/warehouse/setup.ts diff --git a/packages/cli/src/commands/warehouse.ts b/packages/cli/src/commands/warehouse.ts index 6eb187e..82dc5dd 100644 --- a/packages/cli/src/commands/warehouse.ts +++ b/packages/cli/src/commands/warehouse.ts @@ -7,6 +7,7 @@ import { createEventConnectionCmd, createAssignmentTableCmd, createCryptoKeyCmd, + runWarehouseSetup, } from '@features/warehouse/index.js'; import { safely } from '@utils/index.js'; @@ -40,6 +41,28 @@ export const warehouseCommand = { describe: 'Manage data warehouse connections', builder(yargs: Argv) { return yargs + .command( + 'setup', + 'Launch guided warehouse setup in your AI coding agent', + (y: Argv) => + y + .option('warehouse-type', { + type: 'string', + choices: WAREHOUSE_TYPES, + demandOption: true, + describe: 'Data warehouse type', + }) + .option('ide', { + type: 'string', + choices: ['claude', 'cursor', 'codex'] as const, + describe: 'AI coding agent to use', + }) + .option('profile', { + type: 'string', + describe: 'Auth profile to use', + }), + safely(runWarehouseSetup), + ) .command( 'validate', 'Validate warehouse configuration', @@ -153,7 +176,7 @@ export const warehouseCommand = { ) .demandCommand( 1, - 'Available actions: validate, create, connector, assignment-table, crypto-key. Run "confidence warehouse --help" for details.', + 'Available actions: setup, validate, create, connector, assignment-table, crypto-key. Run "confidence warehouse --help" for details.', ) .strict(); }, diff --git a/packages/cli/src/features/warehouse/index.ts b/packages/cli/src/features/warehouse/index.ts index 2e27c18..e33035a 100644 --- a/packages/cli/src/features/warehouse/index.ts +++ b/packages/cli/src/features/warehouse/index.ts @@ -4,3 +4,4 @@ export { createFlagAppliedConnectionCmd } from './create-flag-applied-connection export { createEventConnectionCmd } from './create-event-connection.js'; export { createAssignmentTableCmd } from './create-assignment-table.js'; export { createCryptoKeyCmd } from './create-crypto-key.js'; +export { runWarehouseSetup } from './setup.js'; diff --git a/packages/cli/src/features/warehouse/setup.ts b/packages/cli/src/features/warehouse/setup.ts new file mode 100644 index 0000000..79c0b5c --- /dev/null +++ b/packages/cli/src/features/warehouse/setup.ts @@ -0,0 +1,57 @@ +import { getIntegration } from '@spotify-confidence/core'; +import { fail } from '@output/print.js'; +import { resolveIde, resolveFlag, resolveProjectDir } from '@features/ide/index.js'; +import { resolveAuthToken } from '@features/mcp/index.js'; + +const WAREHOUSE_SKILLS: Record = { + bigquery: 'setup-warehouse-bigquery', + snowflake: 'setup-warehouse-snowflake', + databricks: 'setup-warehouse-databricks', + redshift: 'setup-warehouse-redshift', +}; + +function buildSetupPrompt(warehouseType: string): string { + const skill = WAREHOUSE_SKILLS[warehouseType]; + return `Set up a ${warehouseType} data warehouse for Confidence experimentation analytics. +Use the /onboard-confidence:${skill} skill 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.`; +} + +export async function runWarehouseSetup(argv: Record): Promise { + const warehouseType = argv['warehouse-type'] as string; + if (!WAREHOUSE_SKILLS[warehouseType]) { + fail(`Unknown warehouse type: ${warehouseType}`); + return; + } + + const ideId = await resolveIde(resolveFlag('ide', argv)); + const projectDir = resolveProjectDir(argv); + const integration = getIntegration(ideId); + + const plugin = await integration.detectPlugin(projectDir); + if (!plugin) { + fail( + 'Confidence AI plugin not installed. Run "confidence plugin install" to install it or "confidence quickstart" to set up your project first.', + ); + return; + } + + const statuses = await integration.detectMcpStatuses(projectDir); + if (Object.values(statuses).some((s) => s === 'not-installed')) { + fail('MCP servers not installed. Run "confidence mcp install" to set them up.'); + return; + } + + if (Object.values(statuses).some((s) => s === 'auth-expired')) { + fail('MCP server auth expired. Run "confidence mcp auth" to re-authenticate.'); + return; + } + + const token = await resolveAuthToken({ profile: resolveFlag('profile', argv) }); + if (!token) { + fail('Not authenticated. Run "confidence login" to sign in.'); + return; + } + + integration.launchChat({ userPrompt: buildSetupPrompt(warehouseType), cwd: projectDir, token }); +} From 20b64b164114ce1056371daf1c02f913e57a30ae Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Wed, 7 Oct 2026 15:30:05 +0200 Subject: [PATCH 04/12] test: cover warehouse setup --- .../commands/warehouse-setup.test.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 packages/cli/__tests__/commands/warehouse-setup.test.ts diff --git a/packages/cli/__tests__/commands/warehouse-setup.test.ts b/packages/cli/__tests__/commands/warehouse-setup.test.ts new file mode 100644 index 0000000..a74ae39 --- /dev/null +++ b/packages/cli/__tests__/commands/warehouse-setup.test.ts @@ -0,0 +1,120 @@ +import { buildTestJwt, prepareAuthTokens } from '@spotify-confidence/testing/auth'; +import { warehouseCommand } from '@commands/warehouse.js'; +import { captureOutput } from '../helpers/capture.js'; +import { createMockIntegration } from '../helpers/mock-integration.js'; +import { createRunner } from '../helpers/run-command.js'; + +const mockIntegration = createMockIntegration({ + detectPlugin: vi.fn().mockResolvedValue('cli'), + detectMcpStatuses: vi.fn().mockResolvedValue({ + 'confidence-flags': 'connected', + 'confidence-docs': 'connected', + }), +}); + +const mockAuthenticate = vi.fn().mockResolvedValue({ + accessToken: buildTestJwt({ email: 'test@example.com' }), + region: 'EU', +}); + +vi.mock('@spotify-confidence/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getIntegration: () => mockIntegration, + getIntegrations: () => [mockIntegration], + authenticate: (...args: unknown[]) => mockAuthenticate(...args), + }; +}); + +const run = createRunner(warehouseCommand); + +describe('warehouse setup', () => { + it('launches chat session with correct skill for bigquery', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + + await run(['warehouse', 'setup', '--warehouse-type', 'bigquery', '--ide', 'claude']); + + expect(mockIntegration.launchChat).toHaveBeenCalledWith( + expect.objectContaining({ + userPrompt: expect.stringContaining('setup-warehouse-bigquery'), + cwd: process.cwd(), + }), + ); + }); + + it('launches chat session with correct skill for snowflake', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + + await run(['warehouse', 'setup', '--warehouse-type', 'snowflake', '--ide', 'claude']); + + expect(mockIntegration.launchChat).toHaveBeenCalledWith( + expect.objectContaining({ + userPrompt: expect.stringContaining('setup-warehouse-snowflake'), + }), + ); + }); + + it('passes auth token to chat session', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + + await run(['warehouse', 'setup', '--warehouse-type', 'bigquery', '--ide', 'claude']); + + expect(mockIntegration.launchChat).toHaveBeenCalledWith( + expect.objectContaining({ token: expect.any(String) }), + ); + }); + + it('fails when plugin is not installed', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + vi.mocked(mockIntegration.detectPlugin).mockResolvedValueOnce(null); + + await run(['warehouse', 'setup', '--warehouse-type', 'bigquery', '--ide', 'claude']); + + expect(output.stderr).toContain('Confidence AI plugin not installed'); + expect(mockIntegration.launchChat).not.toHaveBeenCalled(); + }); + + it('fails when MCP servers are not installed', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + vi.mocked(mockIntegration.detectMcpStatuses).mockResolvedValueOnce({ + 'confidence-flags': 'not-installed', + 'confidence-docs': 'not-installed', + }); + + await run(['warehouse', 'setup', '--warehouse-type', 'bigquery', '--ide', 'claude']); + + expect(output.stderr).toContain('MCP servers not installed'); + expect(mockIntegration.launchChat).not.toHaveBeenCalled(); + }); + + it('fails when MCP auth is expired', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + vi.mocked(mockIntegration.detectMcpStatuses).mockResolvedValueOnce({ + 'confidence-flags': 'auth-expired', + 'confidence-docs': 'connected', + }); + + await run(['warehouse', 'setup', '--warehouse-type', 'bigquery', '--ide', 'claude']); + + expect(output.stderr).toContain('MCP server auth expired'); + expect(mockIntegration.launchChat).not.toHaveBeenCalled(); + }); + + it('fails when authentication is unavailable', async () => { + using _auth = prepareAuthTokens('none'); + using output = captureOutput(); + mockAuthenticate.mockRejectedValueOnce(new Error('Authentication timed out')); + + await run(['warehouse', 'setup', '--warehouse-type', 'bigquery', '--ide', 'claude']); + + expect(output.stderr).toContain('Authentication failed'); + expect(mockIntegration.launchChat).not.toHaveBeenCalled(); + }); +}); From 260bd11bf69a45ca4bbf9df1f4c82a8d3adbb04e Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 8 Oct 2026 07:56:27 +0200 Subject: [PATCH 05/12] fix(cli): use PLUGIN_NAME for warehouse setup skill reference The prompt hardcoded the wrong plugin prefix ("onboard-confidence") causing the agent to fail skill lookup. Use PLUGIN_NAME constant to match the pattern in migrate/prompt.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/features/warehouse/setup.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/features/warehouse/setup.ts b/packages/cli/src/features/warehouse/setup.ts index 79c0b5c..3a83cb1 100644 --- a/packages/cli/src/features/warehouse/setup.ts +++ b/packages/cli/src/features/warehouse/setup.ts @@ -1,4 +1,4 @@ -import { getIntegration } from '@spotify-confidence/core'; +import { getIntegration, PLUGIN_NAME } 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'; @@ -13,7 +13,7 @@ const WAREHOUSE_SKILLS: Record = { function buildSetupPrompt(warehouseType: string): string { const skill = WAREHOUSE_SKILLS[warehouseType]; return `Set up a ${warehouseType} data warehouse for Confidence experimentation analytics. -Use the /onboard-confidence:${skill} skill to guide the setup process. +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.`; } From 27fc284855962de3a664520b9895f3004a791b1a Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 8 Oct 2026 07:59:47 +0200 Subject: [PATCH 06/12] fix(cli): set non-zero exit code for failed validation in JSON mode The JSON early return skipped the process.exitCode = 1 check, so `warehouse validate --format json` exited 0 on failure. Move the exit code before the format branch and make fail() accept an optional message so it can set the exit code without printing. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/features/warehouse/validate.ts | 10 +++++----- packages/cli/src/output/print.ts | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/features/warehouse/validate.ts b/packages/cli/src/features/warehouse/validate.ts index ed59ac6..143caf8 100644 --- a/packages/cli/src/features/warehouse/validate.ts +++ b/packages/cli/src/features/warehouse/validate.ts @@ -1,6 +1,6 @@ import { extractText, parseToolJson } from '@spotify-confidence/core'; import { validateWarehouseConfig } from '@network/index.js'; -import { resolveFormat, formatJson, message, print, extractFlags } from '@output/index.js'; +import { resolveFormat, formatJson, message, print, extractFlags, fail } from '@output/index.js'; import { withAuth, tryHandleMcpError } from '@utils/index.js'; import { resolveInput } from '@input/index.js'; import { requireKeys } from '@utils/validation.js'; @@ -45,6 +45,10 @@ export const validateConfig = withAuth(async function validateConfig(argv, token return; } + if (!data.successful) { + fail(); + } + const flags = extractFlags(argv); const format = resolveFormat(flags); @@ -71,8 +75,4 @@ export const validateConfig = withAuth(async function validateConfig(argv, token flags, empty: 'No validation checks returned.', }); - - if (!data.successful) { - process.exitCode = 1; - } }); diff --git a/packages/cli/src/output/print.ts b/packages/cli/src/output/print.ts index 7690fa4..1b35846 100644 --- a/packages/cli/src/output/print.ts +++ b/packages/cli/src/output/print.ts @@ -46,8 +46,8 @@ export function warn(text: string): void { process.stderr.write(`Warning: ${text}\n`); } -export function fail(text: string): void { - error(text); +export function fail(text?: string): void { + if (text) error(text); process.exitCode = 1; } From a2f2f27d620be282f65a86c14a84886ffbea17e2 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 8 Oct 2026 08:11:42 +0200 Subject: [PATCH 07/12] fix(cli): stringify non-string config-json from --from-file When --from-file provides configJson as a nested object, it was passed through to MCP tools as-is instead of as a JSON string. Add ensureJsonString to shared-kernel and apply it in all four warehouse commands (create, validate, create-flag-applied, create-event). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/__tests__/commands/warehouse.test.ts | 24 +++++++++++++++++++ .../warehouse/create-event-connection.ts | 4 +++- .../create-flag-applied-connection.ts | 4 +++- packages/cli/src/features/warehouse/create.ts | 8 ++++++- .../cli/src/features/warehouse/validate.ts | 3 ++- packages/shared-kernel/src/guards.ts | 4 ++++ packages/shared-kernel/src/index.ts | 2 +- .../testing/src/scaffold/file-scaffold.ts | 8 +++++++ packages/testing/src/scaffold/index.ts | 1 + 9 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 packages/testing/src/scaffold/file-scaffold.ts diff --git a/packages/cli/__tests__/commands/warehouse.test.ts b/packages/cli/__tests__/commands/warehouse.test.ts index 7f18f5d..9f86de7 100644 --- a/packages/cli/__tests__/commands/warehouse.test.ts +++ b/packages/cli/__tests__/commands/warehouse.test.ts @@ -1,4 +1,5 @@ import { prepareAuthTokens } from '@spotify-confidence/testing/auth'; +import { createProjectDir, writeJsonFile } from '@spotify-confidence/testing/scaffold'; import type { CallToolResult } from '@spotify-confidence/core'; import { warehouseCommand } from '@commands/warehouse.js'; import { captureOutput } from '../helpers/capture.js'; @@ -174,6 +175,29 @@ describe('warehouse create', () => { expect.objectContaining({ warehouseType: 'databricks', configJson }), ); }); + + it('stringifies config-json object from --from-file', async () => { + using _auth = prepareAuthTokens('valid'); + using _output = captureOutput(); + using project = createProjectDir('empty'); + mockMcpCallTool.mockResolvedValueOnce(textResult('Created')); + + const file = writeJsonFile(project.path, 'config.json', { + warehouseType: 'bigquery', + configJson: { gcpProjectId: 'my-project', dataset: 'confidence' }, + }); + + await run(['warehouse', 'create', '--from-file', file]); + + expect(mockMcpCallTool).toHaveBeenCalledWith( + expect.anything(), + 'createWarehouse', + expect.objectContaining({ + warehouseType: 'bigquery', + configJson: '{"gcpProjectId":"my-project","dataset":"confidence"}', + }), + ); + }); }); describe('warehouse connector create-flag-applied', () => { diff --git a/packages/cli/src/features/warehouse/create-event-connection.ts b/packages/cli/src/features/warehouse/create-event-connection.ts index 168fe79..16e3329 100644 --- a/packages/cli/src/features/warehouse/create-event-connection.ts +++ b/packages/cli/src/features/warehouse/create-event-connection.ts @@ -1,3 +1,4 @@ +import { ensureJsonString } from '@spotify-confidence/shared-kernel'; import { createEventConnection } from '@network/index.js'; import { printMcpResult } from '@output/index.js'; import { withAuth, tryHandleMcpError } from '@utils/index.js'; @@ -13,8 +14,9 @@ export const createEventConnectionCmd = withAuth( const result = await createEventConnection( token, params['warehouse-type'], - params['config-json'], + ensureJsonString(params['config-json']), ); + if (tryHandleMcpError(result)) return; printMcpResult(result, argv); }, diff --git a/packages/cli/src/features/warehouse/create-flag-applied-connection.ts b/packages/cli/src/features/warehouse/create-flag-applied-connection.ts index 347d588..a82f458 100644 --- a/packages/cli/src/features/warehouse/create-flag-applied-connection.ts +++ b/packages/cli/src/features/warehouse/create-flag-applied-connection.ts @@ -1,3 +1,4 @@ +import { ensureJsonString } from '@spotify-confidence/shared-kernel'; import { createFlagAppliedConnection } from '@network/index.js'; import { printMcpResult } from '@output/index.js'; import { withAuth, tryHandleMcpError } from '@utils/index.js'; @@ -13,8 +14,9 @@ export const createFlagAppliedConnectionCmd = withAuth( const result = await createFlagAppliedConnection( token, params['warehouse-type'], - params['config-json'], + ensureJsonString(params['config-json']), ); + if (tryHandleMcpError(result)) return; printMcpResult(result, argv); }, diff --git a/packages/cli/src/features/warehouse/create.ts b/packages/cli/src/features/warehouse/create.ts index 34eef8c..c1b241d 100644 --- a/packages/cli/src/features/warehouse/create.ts +++ b/packages/cli/src/features/warehouse/create.ts @@ -1,3 +1,4 @@ +import { ensureJsonString } from '@spotify-confidence/shared-kernel'; import { createWarehouse } from '@network/index.js'; import { printMcpResult } from '@output/index.js'; import { withAuth, tryHandleMcpError } from '@utils/index.js'; @@ -9,7 +10,12 @@ export const createWarehouseCmd = withAuth(async function createWarehouseCmd(arg const params = resolveInput(argv, ['warehouse-type', 'config-json']); requireKeys(params, ['warehouse-type', 'config-json']); - const result = await createWarehouse(token, params['warehouse-type'], params['config-json']); + const result = await createWarehouse( + token, + params['warehouse-type'], + ensureJsonString(params['config-json']), + ); + if (tryHandleMcpError(result)) return; printMcpResult(result, argv); }); diff --git a/packages/cli/src/features/warehouse/validate.ts b/packages/cli/src/features/warehouse/validate.ts index 143caf8..5c06b2c 100644 --- a/packages/cli/src/features/warehouse/validate.ts +++ b/packages/cli/src/features/warehouse/validate.ts @@ -1,4 +1,5 @@ import { extractText, parseToolJson } from '@spotify-confidence/core'; +import { ensureJsonString } from '@spotify-confidence/shared-kernel'; import { validateWarehouseConfig } from '@network/index.js'; import { resolveFormat, formatJson, message, print, extractFlags, fail } from '@output/index.js'; import { withAuth, tryHandleMcpError } from '@utils/index.js'; @@ -33,7 +34,7 @@ export const validateConfig = withAuth(async function validateConfig(argv, token const result = await validateWarehouseConfig( token, params['warehouse-type'], - params['config-json'], + ensureJsonString(params['config-json']), ); if (tryHandleMcpError(result)) return; diff --git a/packages/shared-kernel/src/guards.ts b/packages/shared-kernel/src/guards.ts index b2cd4c2..3dd3afc 100644 --- a/packages/shared-kernel/src/guards.ts +++ b/packages/shared-kernel/src/guards.ts @@ -1,3 +1,7 @@ export function isDefined(value: T | undefined): value is T { return value !== undefined; } + +export function ensureJsonString(value: unknown): string { + return typeof value === 'string' ? value : JSON.stringify(value); +} diff --git a/packages/shared-kernel/src/index.ts b/packages/shared-kernel/src/index.ts index fb9d52c..03b71d9 100644 --- a/packages/shared-kernel/src/index.ts +++ b/packages/shared-kernel/src/index.ts @@ -9,4 +9,4 @@ export type { DetectedProvider, } from './types.js'; export { noop } from './noop.js'; -export { isDefined } from './guards.js'; +export { isDefined, ensureJsonString } from './guards.js'; diff --git a/packages/testing/src/scaffold/file-scaffold.ts b/packages/testing/src/scaffold/file-scaffold.ts new file mode 100644 index 0000000..3baff68 --- /dev/null +++ b/packages/testing/src/scaffold/file-scaffold.ts @@ -0,0 +1,8 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export function writeJsonFile(dir: string, filename: string, data: unknown): string { + const filePath = join(dir, filename); + writeFileSync(filePath, JSON.stringify(data)); + return filePath; +} diff --git a/packages/testing/src/scaffold/index.ts b/packages/testing/src/scaffold/index.ts index ee94c6e..227cd3a 100644 --- a/packages/testing/src/scaffold/index.ts +++ b/packages/testing/src/scaffold/index.ts @@ -7,4 +7,5 @@ export { writeCursorCliConfig, writeCodexConfig, } from './ide-scaffold.js'; +export { writeJsonFile } from './file-scaffold.js'; export type { ProjectType } from './types.js'; From ee24924dd38fb034ab554b65b595c5eb7b2deff3 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 8 Oct 2026 08:16:01 +0200 Subject: [PATCH 08/12] fix(cli): validate warehouse type from --from-file input The yargs choices constraint only applies to CLI args, so --from-file could pass invalid warehouse types straight to the server. Add validateWarehouseType after resolveInput in all four warehouse commands. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/features/warehouse/create-event-connection.ts | 2 ++ .../features/warehouse/create-flag-applied-connection.ts | 2 ++ packages/cli/src/features/warehouse/create.ts | 2 ++ packages/cli/src/features/warehouse/utils.ts | 9 +++++++++ packages/cli/src/features/warehouse/validate.ts | 2 ++ 5 files changed, 17 insertions(+) create mode 100644 packages/cli/src/features/warehouse/utils.ts diff --git a/packages/cli/src/features/warehouse/create-event-connection.ts b/packages/cli/src/features/warehouse/create-event-connection.ts index 16e3329..bde5f37 100644 --- a/packages/cli/src/features/warehouse/create-event-connection.ts +++ b/packages/cli/src/features/warehouse/create-event-connection.ts @@ -5,11 +5,13 @@ import { withAuth, tryHandleMcpError } from '@utils/index.js'; import { resolveInput } from '@input/index.js'; import { requireKeys } from '@utils/validation.js'; import type { WarehouseTypeParams } from './types.js'; +import { validateWarehouseType } from './utils.js'; export const createEventConnectionCmd = withAuth( async function createEventConnectionCmd(argv, token) { const params = resolveInput(argv, ['warehouse-type', 'config-json']); requireKeys(params, ['warehouse-type', 'config-json']); + validateWarehouseType(params['warehouse-type']); const result = await createEventConnection( token, diff --git a/packages/cli/src/features/warehouse/create-flag-applied-connection.ts b/packages/cli/src/features/warehouse/create-flag-applied-connection.ts index a82f458..27dbedc 100644 --- a/packages/cli/src/features/warehouse/create-flag-applied-connection.ts +++ b/packages/cli/src/features/warehouse/create-flag-applied-connection.ts @@ -5,11 +5,13 @@ import { withAuth, tryHandleMcpError } from '@utils/index.js'; import { resolveInput } from '@input/index.js'; import { requireKeys } from '@utils/validation.js'; import type { WarehouseTypeParams } from './types.js'; +import { validateWarehouseType } from './utils.js'; export const createFlagAppliedConnectionCmd = withAuth( async function createFlagAppliedConnectionCmd(argv, token) { const params = resolveInput(argv, ['warehouse-type', 'config-json']); requireKeys(params, ['warehouse-type', 'config-json']); + validateWarehouseType(params['warehouse-type']); const result = await createFlagAppliedConnection( token, diff --git a/packages/cli/src/features/warehouse/create.ts b/packages/cli/src/features/warehouse/create.ts index c1b241d..ed1116b 100644 --- a/packages/cli/src/features/warehouse/create.ts +++ b/packages/cli/src/features/warehouse/create.ts @@ -5,10 +5,12 @@ import { withAuth, tryHandleMcpError } from '@utils/index.js'; import { resolveInput } from '@input/index.js'; import { requireKeys } from '@utils/validation.js'; import type { WarehouseTypeParams } from './types.js'; +import { validateWarehouseType } from './utils.js'; export const createWarehouseCmd = withAuth(async function createWarehouseCmd(argv, token) { const params = resolveInput(argv, ['warehouse-type', 'config-json']); requireKeys(params, ['warehouse-type', 'config-json']); + validateWarehouseType(params['warehouse-type']); const result = await createWarehouse( token, diff --git a/packages/cli/src/features/warehouse/utils.ts b/packages/cli/src/features/warehouse/utils.ts new file mode 100644 index 0000000..cd96c08 --- /dev/null +++ b/packages/cli/src/features/warehouse/utils.ts @@ -0,0 +1,9 @@ +const WAREHOUSE_TYPES = ['bigquery', 'snowflake', 'databricks', 'redshift'] as const; + +export function validateWarehouseType(value: string): void { + if (!(WAREHOUSE_TYPES as readonly string[]).includes(value)) { + throw new Error( + `Unknown warehouse type "${value}". Valid types: ${WAREHOUSE_TYPES.join(', ')}`, + ); + } +} diff --git a/packages/cli/src/features/warehouse/validate.ts b/packages/cli/src/features/warehouse/validate.ts index 5c06b2c..bc685e3 100644 --- a/packages/cli/src/features/warehouse/validate.ts +++ b/packages/cli/src/features/warehouse/validate.ts @@ -6,6 +6,7 @@ import { withAuth, tryHandleMcpError } from '@utils/index.js'; import { resolveInput } from '@input/index.js'; import { requireKeys } from '@utils/validation.js'; import type { WarehouseTypeParams } from './types.js'; +import { validateWarehouseType } from './utils.js'; type ValidationCheck = { key: string; @@ -30,6 +31,7 @@ type ValidationRow = { export const validateConfig = withAuth(async function validateConfig(argv, token) { const params = resolveInput(argv, ['warehouse-type', 'config-json']); requireKeys(params, ['warehouse-type', 'config-json']); + validateWarehouseType(params['warehouse-type']); const result = await validateWarehouseConfig( token, From 5f348edb259a10109b954db4cc0ee39b6048d1d4 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 8 Oct 2026 08:32:28 +0200 Subject: [PATCH 09/12] refactor(cli): validate config-json locally and narrow warehouse type Move ensureJsonString and validateJsonString from shared-kernel to core/utils with tests. Make validators return their input for inline chaining. Add WarehouseType union and narrow in validateWarehouseType. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../warehouse/create-event-connection.ts | 12 +++--- .../create-flag-applied-connection.ts | 12 +++--- packages/cli/src/features/warehouse/create.ts | 12 +++--- packages/cli/src/features/warehouse/types.ts | 2 + packages/cli/src/features/warehouse/utils.ts | 6 ++- .../cli/src/features/warehouse/validate.ts | 12 +++--- packages/core/__tests__/utils/json.test.ts | 41 +++++++++++++++++++ packages/core/src/utils/index.ts | 1 + packages/core/src/utils/json.ts | 14 +++++++ packages/shared-kernel/src/guards.ts | 4 -- packages/shared-kernel/src/index.ts | 2 +- 11 files changed, 84 insertions(+), 34 deletions(-) create mode 100644 packages/core/__tests__/utils/json.test.ts create mode 100644 packages/core/src/utils/json.ts diff --git a/packages/cli/src/features/warehouse/create-event-connection.ts b/packages/cli/src/features/warehouse/create-event-connection.ts index bde5f37..989780b 100644 --- a/packages/cli/src/features/warehouse/create-event-connection.ts +++ b/packages/cli/src/features/warehouse/create-event-connection.ts @@ -1,4 +1,4 @@ -import { ensureJsonString } from '@spotify-confidence/shared-kernel'; +import { ensureJsonString, validateJsonString } from '@spotify-confidence/core'; import { createEventConnection } from '@network/index.js'; import { printMcpResult } from '@output/index.js'; import { withAuth, tryHandleMcpError } from '@utils/index.js'; @@ -11,13 +11,11 @@ export const createEventConnectionCmd = withAuth( async function createEventConnectionCmd(argv, token) { const params = resolveInput(argv, ['warehouse-type', 'config-json']); requireKeys(params, ['warehouse-type', 'config-json']); - validateWarehouseType(params['warehouse-type']); - const result = await createEventConnection( - token, - params['warehouse-type'], - ensureJsonString(params['config-json']), - ); + const warehouseType = validateWarehouseType(params['warehouse-type']); + const configJson = validateJsonString(ensureJsonString(params['config-json'])); + + const result = await createEventConnection(token, warehouseType, configJson); if (tryHandleMcpError(result)) return; printMcpResult(result, argv); diff --git a/packages/cli/src/features/warehouse/create-flag-applied-connection.ts b/packages/cli/src/features/warehouse/create-flag-applied-connection.ts index 27dbedc..4cd3a6f 100644 --- a/packages/cli/src/features/warehouse/create-flag-applied-connection.ts +++ b/packages/cli/src/features/warehouse/create-flag-applied-connection.ts @@ -1,4 +1,4 @@ -import { ensureJsonString } from '@spotify-confidence/shared-kernel'; +import { ensureJsonString, validateJsonString } from '@spotify-confidence/core'; import { createFlagAppliedConnection } from '@network/index.js'; import { printMcpResult } from '@output/index.js'; import { withAuth, tryHandleMcpError } from '@utils/index.js'; @@ -11,13 +11,11 @@ export const createFlagAppliedConnectionCmd = withAuth( async function createFlagAppliedConnectionCmd(argv, token) { const params = resolveInput(argv, ['warehouse-type', 'config-json']); requireKeys(params, ['warehouse-type', 'config-json']); - validateWarehouseType(params['warehouse-type']); - const result = await createFlagAppliedConnection( - token, - params['warehouse-type'], - ensureJsonString(params['config-json']), - ); + const warehouseType = validateWarehouseType(params['warehouse-type']); + const configJson = validateJsonString(ensureJsonString(params['config-json'])); + + const result = await createFlagAppliedConnection(token, warehouseType, configJson); if (tryHandleMcpError(result)) return; printMcpResult(result, argv); diff --git a/packages/cli/src/features/warehouse/create.ts b/packages/cli/src/features/warehouse/create.ts index ed1116b..0ddea4a 100644 --- a/packages/cli/src/features/warehouse/create.ts +++ b/packages/cli/src/features/warehouse/create.ts @@ -1,4 +1,4 @@ -import { ensureJsonString } from '@spotify-confidence/shared-kernel'; +import { ensureJsonString, validateJsonString } from '@spotify-confidence/core'; import { createWarehouse } from '@network/index.js'; import { printMcpResult } from '@output/index.js'; import { withAuth, tryHandleMcpError } from '@utils/index.js'; @@ -10,13 +10,11 @@ import { validateWarehouseType } from './utils.js'; export const createWarehouseCmd = withAuth(async function createWarehouseCmd(argv, token) { const params = resolveInput(argv, ['warehouse-type', 'config-json']); requireKeys(params, ['warehouse-type', 'config-json']); - validateWarehouseType(params['warehouse-type']); - const result = await createWarehouse( - token, - params['warehouse-type'], - ensureJsonString(params['config-json']), - ); + const warehouseType = validateWarehouseType(params['warehouse-type']); + const configJson = validateJsonString(ensureJsonString(params['config-json'])); + + const result = await createWarehouse(token, warehouseType, configJson); if (tryHandleMcpError(result)) return; printMcpResult(result, argv); diff --git a/packages/cli/src/features/warehouse/types.ts b/packages/cli/src/features/warehouse/types.ts index 4ebed2b..28d6b53 100644 --- a/packages/cli/src/features/warehouse/types.ts +++ b/packages/cli/src/features/warehouse/types.ts @@ -1,3 +1,5 @@ +export type WarehouseType = 'bigquery' | 'snowflake' | 'databricks' | 'redshift'; + export type WarehouseTypeParams = { 'warehouse-type': string; 'config-json': string; diff --git a/packages/cli/src/features/warehouse/utils.ts b/packages/cli/src/features/warehouse/utils.ts index cd96c08..c1be516 100644 --- a/packages/cli/src/features/warehouse/utils.ts +++ b/packages/cli/src/features/warehouse/utils.ts @@ -1,9 +1,13 @@ +import type { WarehouseType } from './types.js'; + const WAREHOUSE_TYPES = ['bigquery', 'snowflake', 'databricks', 'redshift'] as const; -export function validateWarehouseType(value: string): void { +export function validateWarehouseType(value: string): WarehouseType { if (!(WAREHOUSE_TYPES as readonly string[]).includes(value)) { throw new Error( `Unknown warehouse type "${value}". Valid types: ${WAREHOUSE_TYPES.join(', ')}`, ); } + + return value as WarehouseType; } diff --git a/packages/cli/src/features/warehouse/validate.ts b/packages/cli/src/features/warehouse/validate.ts index bc685e3..5828810 100644 --- a/packages/cli/src/features/warehouse/validate.ts +++ b/packages/cli/src/features/warehouse/validate.ts @@ -1,5 +1,5 @@ import { extractText, parseToolJson } from '@spotify-confidence/core'; -import { ensureJsonString } from '@spotify-confidence/shared-kernel'; +import { ensureJsonString, validateJsonString } from '@spotify-confidence/core'; import { validateWarehouseConfig } from '@network/index.js'; import { resolveFormat, formatJson, message, print, extractFlags, fail } from '@output/index.js'; import { withAuth, tryHandleMcpError } from '@utils/index.js'; @@ -31,13 +31,11 @@ type ValidationRow = { export const validateConfig = withAuth(async function validateConfig(argv, token) { const params = resolveInput(argv, ['warehouse-type', 'config-json']); requireKeys(params, ['warehouse-type', 'config-json']); - validateWarehouseType(params['warehouse-type']); - const result = await validateWarehouseConfig( - token, - params['warehouse-type'], - ensureJsonString(params['config-json']), - ); + const warehouseType = validateWarehouseType(params['warehouse-type']); + const configJson = validateJsonString(ensureJsonString(params['config-json'])); + + const result = await validateWarehouseConfig(token, warehouseType, configJson); if (tryHandleMcpError(result)) return; let data: ValidateResponse; diff --git a/packages/core/__tests__/utils/json.test.ts b/packages/core/__tests__/utils/json.test.ts new file mode 100644 index 0000000..45dcf64 --- /dev/null +++ b/packages/core/__tests__/utils/json.test.ts @@ -0,0 +1,41 @@ +import { ensureJsonString, validateJsonString } from '../../src/utils/json.js'; + +describe('ensureJsonString', () => { + const sut = ensureJsonString; + + it('returns a string value unchanged', () => { + expect(sut('{"key":"value"}')).toBe('{"key":"value"}'); + }); + + it('stringifies an object', () => { + expect(sut({ key: 'value' })).toBe('{"key":"value"}'); + }); + + it('stringifies an array', () => { + expect(sut([1, 2])).toBe('[1,2]'); + }); + + it('stringifies null', () => { + expect(sut(null)).toBe('null'); + }); +}); + +describe('validateJsonString', () => { + const sut = validateJsonString; + + it('returns valid JSON unchanged', () => { + expect(sut('{"key":"value"}')).toBe('{"key":"value"}'); + }); + + it('accepts a JSON array', () => { + expect(sut('[1,2,3]')).toBe('[1,2,3]'); + }); + + it('throws on invalid JSON', () => { + expect(() => sut('not json')).toThrow('Invalid JSON: not json'); + }); + + it('includes label in error when provided', () => { + expect(() => sut('bad', '--config')).toThrow('Invalid JSON for --config: bad'); + }); +}); diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 79e4c98..994e3b8 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1,2 +1,3 @@ +export { ensureJsonString, validateJsonString } from './json.js'; export { addIf, interpolate } from './prompt-utils.js'; export { isNewerVersion } from './semver.js'; diff --git a/packages/core/src/utils/json.ts b/packages/core/src/utils/json.ts new file mode 100644 index 0000000..9075951 --- /dev/null +++ b/packages/core/src/utils/json.ts @@ -0,0 +1,14 @@ +export function ensureJsonString(value: unknown): string { + return typeof value === 'string' ? value : JSON.stringify(value); +} + +export function validateJsonString(value: string, label?: string): string { + try { + JSON.parse(value); + return value; + } catch { + const preamble = 'Invalid JSON'; + const origin = label ? ` for ${label}` : ''; + throw new Error(`${preamble}${origin}: ${value}`); + } +} diff --git a/packages/shared-kernel/src/guards.ts b/packages/shared-kernel/src/guards.ts index 3dd3afc..b2cd4c2 100644 --- a/packages/shared-kernel/src/guards.ts +++ b/packages/shared-kernel/src/guards.ts @@ -1,7 +1,3 @@ export function isDefined(value: T | undefined): value is T { return value !== undefined; } - -export function ensureJsonString(value: unknown): string { - return typeof value === 'string' ? value : JSON.stringify(value); -} diff --git a/packages/shared-kernel/src/index.ts b/packages/shared-kernel/src/index.ts index 03b71d9..fb9d52c 100644 --- a/packages/shared-kernel/src/index.ts +++ b/packages/shared-kernel/src/index.ts @@ -9,4 +9,4 @@ export type { DetectedProvider, } from './types.js'; export { noop } from './noop.js'; -export { isDefined, ensureJsonString } from './guards.js'; +export { isDefined } from './guards.js'; From a8ef9983985481e1346f48908daeb9dc8abe9d0f Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 8 Oct 2026 08:34:29 +0200 Subject: [PATCH 10/12] refactor(cli): type WAREHOUSE_SKILLS with WarehouseType, drop dead check Use Record so the compiler enforces key completeness. Replace the unsafe cast with validateWarehouseType. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/features/warehouse/setup.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/features/warehouse/setup.ts b/packages/cli/src/features/warehouse/setup.ts index 3a83cb1..8c1dd28 100644 --- a/packages/cli/src/features/warehouse/setup.ts +++ b/packages/cli/src/features/warehouse/setup.ts @@ -2,15 +2,17 @@ import { getIntegration, PLUGIN_NAME } 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'; +import type { WarehouseType } from './types.js'; +import { validateWarehouseType } from './utils.js'; -const WAREHOUSE_SKILLS: Record = { +const WAREHOUSE_SKILLS: Record = { bigquery: 'setup-warehouse-bigquery', snowflake: 'setup-warehouse-snowflake', databricks: 'setup-warehouse-databricks', redshift: 'setup-warehouse-redshift', }; -function buildSetupPrompt(warehouseType: string): string { +function buildSetupPrompt(warehouseType: WarehouseType): string { const skill = 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. @@ -18,11 +20,7 @@ Follow the skill instructions step by step — collect configuration, validate, } export async function runWarehouseSetup(argv: Record): Promise { - const warehouseType = argv['warehouse-type'] as string; - if (!WAREHOUSE_SKILLS[warehouseType]) { - fail(`Unknown warehouse type: ${warehouseType}`); - return; - } + const warehouseType = validateWarehouseType(argv['warehouse-type'] as string); const ideId = await resolveIde(resolveFlag('ide', argv)); const projectDir = resolveProjectDir(argv); From e2c09aeaeae4db5781d54a6a5a6f9175ae20d08b Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 8 Oct 2026 08:39:10 +0200 Subject: [PATCH 11/12] refactor(cli): extract launchSkillChat shared helper The plugin/MCP/auth-check-then-launch-chat sequence was duplicated across warehouse/setup, sdk/setup, and migrate/launch. Extract into a shared launchSkillChat helper in features/ide/. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/features/ide/index.ts | 1 + .../cli/src/features/ide/launch-skill-chat.ts | 40 ++++++++++++++++++ packages/cli/src/features/migrate/launch.ts | 42 ++++--------------- packages/cli/src/features/migrate/prompt.ts | 14 ------- packages/cli/src/features/sdk/setup.ts | 36 +--------------- packages/cli/src/features/warehouse/setup.ts | 38 ++--------------- 6 files changed, 55 insertions(+), 116 deletions(-) create mode 100644 packages/cli/src/features/ide/launch-skill-chat.ts delete mode 100644 packages/cli/src/features/migrate/prompt.ts diff --git a/packages/cli/src/features/ide/index.ts b/packages/cli/src/features/ide/index.ts index e98977c..2577da4 100644 --- a/packages/cli/src/features/ide/index.ts +++ b/packages/cli/src/features/ide/index.ts @@ -1,2 +1,3 @@ +export { launchSkillChat } from './launch-skill-chat.js'; export { resolveIde } from './resolve-ide.js'; export { resolveFlag, resolveProjectDir, resolveScope, requireClaudeForScope } from './argv.js'; diff --git a/packages/cli/src/features/ide/launch-skill-chat.ts b/packages/cli/src/features/ide/launch-skill-chat.ts new file mode 100644 index 0000000..a2ced60 --- /dev/null +++ b/packages/cli/src/features/ide/launch-skill-chat.ts @@ -0,0 +1,40 @@ +import { getIntegration } from '@spotify-confidence/core'; +import { fail } from '@output/print.js'; +import { resolveIde, resolveFlag, resolveProjectDir } from '@features/ide/index.js'; +import { resolveAuthToken } from '@features/mcp/index.js'; + +export async function launchSkillChat( + argv: Record, + userPrompt: string, +): Promise { + const ideId = await resolveIde(resolveFlag('ide', argv)); + const projectDir = resolveProjectDir(argv); + const integration = getIntegration(ideId); + + const plugin = await integration.detectPlugin(projectDir); + if (!plugin) { + fail( + 'Confidence AI plugin not installed. Run "confidence plugin install" to install it or "confidence quickstart" to set up your project first.', + ); + return; + } + + const statuses = await integration.detectMcpStatuses(projectDir); + if (Object.values(statuses).some((s) => s === 'not-installed')) { + fail('MCP servers not installed. Run "confidence mcp install" to set them up.'); + return; + } + + if (Object.values(statuses).some((s) => s === 'auth-expired')) { + fail('MCP server auth expired. Run "confidence mcp auth" to re-authenticate.'); + return; + } + + const token = await resolveAuthToken({ profile: resolveFlag('profile', argv) }); + if (!token) { + fail('Not authenticated. Run "confidence login" to sign in.'); + return; + } + + integration.launchChat({ userPrompt, cwd: projectDir, token }); +} diff --git a/packages/cli/src/features/migrate/launch.ts b/packages/cli/src/features/migrate/launch.ts index fd7208b..65f16aa 100644 --- a/packages/cli/src/features/migrate/launch.ts +++ b/packages/cli/src/features/migrate/launch.ts @@ -1,40 +1,16 @@ import type { ProviderConfig } from '@spotify-confidence/core'; -import { getIntegration } from '@spotify-confidence/core'; -import { fail } from '@output/print.js'; -import { resolveIde, resolveFlag, resolveProjectDir } from '@features/ide/index.js'; -import { resolveAuthToken } from '@features/mcp/index.js'; -import { buildMigrationPrompt } from './prompt.js'; +import { launchSkillChat } from '@features/ide/index.js'; + +function buildMigrationPrompt(provider: ProviderConfig) { + 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. +Use the Confidence MCP tools for SDK references and flag management.`; +} export async function launchMigration( argv: Record, provider: ProviderConfig, ): Promise { - const ideId = await resolveIde(resolveFlag('ide', argv)); - const projectDir = resolveProjectDir(argv); - const integration = getIntegration(ideId); - - const plugin = await integration.detectPlugin(projectDir); - if (!plugin) { - fail( - 'Confidence AI plugin not installed. Run "confidence plugin install" to install it or "confidence quickstart" to set up your project first.', - ); - return; - } - - const statuses = await integration.detectMcpStatuses(projectDir); - if (Object.values(statuses).some((s) => s === 'not-installed')) { - fail('MCP servers not installed. Run "confidence mcp install" to set them up.'); - return; - } - - if (Object.values(statuses).some((s) => s === 'auth-expired')) { - fail('MCP server auth expired. Run "confidence mcp auth" to re-authenticate.'); - return; - } - - const token = await resolveAuthToken({ profile: resolveFlag('profile', argv) }); - if (!token) return; - - const userPrompt = buildMigrationPrompt(provider); - integration.launchChat({ userPrompt, cwd: projectDir, token }); + await launchSkillChat(argv, buildMigrationPrompt(provider)); } diff --git a/packages/cli/src/features/migrate/prompt.ts b/packages/cli/src/features/migrate/prompt.ts deleted file mode 100644 index c3ce7dd..0000000 --- a/packages/cli/src/features/migrate/prompt.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ProviderConfig } from '@spotify-confidence/core'; - -export function buildMigrationPrompt(provider: ProviderConfig): string { - 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.`, - '', - `Use the Confidence MCP tools for SDK references and flag management.`, - ].join('\n'); -} diff --git a/packages/cli/src/features/sdk/setup.ts b/packages/cli/src/features/sdk/setup.ts index d9ebfa6..3019c46 100644 --- a/packages/cli/src/features/sdk/setup.ts +++ b/packages/cli/src/features/sdk/setup.ts @@ -1,7 +1,4 @@ -import { getIntegration } from '@spotify-confidence/core'; -import { fail } from '@output/print.js'; -import { resolveIde, resolveFlag, resolveProjectDir } from '@features/ide/index.js'; -import { resolveAuthToken } from '@features/mcp/index.js'; +import { launchSkillChat } from '@features/ide/index.js'; const SDK_SETUP_PROMPT = `Set up the Confidence SDK in this project. Detect the project's framework, then use the getCodeSnippetAndSdkIntegrationTips tool from the confidence-docs MCP server to get the integration guide for that SDK. @@ -9,34 +6,5 @@ Install the SDK package and create a working configuration file following the gu Only set up the SDK — do not create feature flags, event tracking, or session recordings.`; export async function runSdkSetup(argv: Record): Promise { - const ideId = await resolveIde(resolveFlag('ide', argv)); - const projectDir = resolveProjectDir(argv); - const integration = getIntegration(ideId); - - const plugin = await integration.detectPlugin(projectDir); - if (!plugin) { - fail( - 'Confidence AI plugin not installed. Run "confidence plugin install" to install it or "confidence quickstart" to set up your project first.', - ); - return; - } - - const statuses = await integration.detectMcpStatuses(projectDir); - if (Object.values(statuses).some((s) => s === 'not-installed')) { - fail('MCP servers not installed. Run "confidence mcp install" to set them up.'); - return; - } - - if (Object.values(statuses).some((s) => s === 'auth-expired')) { - fail('MCP server auth expired. Run "confidence mcp auth" to re-authenticate.'); - return; - } - - const token = await resolveAuthToken({ profile: resolveFlag('profile', argv) }); - if (!token) { - fail('Not authenticated. Run "confidence login" to sign in.'); - return; - } - - integration.launchChat({ userPrompt: SDK_SETUP_PROMPT, cwd: projectDir, token }); + await launchSkillChat(argv, SDK_SETUP_PROMPT); } diff --git a/packages/cli/src/features/warehouse/setup.ts b/packages/cli/src/features/warehouse/setup.ts index 8c1dd28..bdcefde 100644 --- a/packages/cli/src/features/warehouse/setup.ts +++ b/packages/cli/src/features/warehouse/setup.ts @@ -1,7 +1,5 @@ -import { getIntegration, PLUGIN_NAME } 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'; +import { PLUGIN_NAME } from '@spotify-confidence/core'; +import { launchSkillChat } from '@features/ide/index.js'; import type { WarehouseType } from './types.js'; import { validateWarehouseType } from './utils.js'; @@ -21,35 +19,5 @@ Follow the skill instructions step by step — collect configuration, validate, export async function runWarehouseSetup(argv: Record): Promise { const warehouseType = validateWarehouseType(argv['warehouse-type'] as string); - - const ideId = await resolveIde(resolveFlag('ide', argv)); - const projectDir = resolveProjectDir(argv); - const integration = getIntegration(ideId); - - const plugin = await integration.detectPlugin(projectDir); - if (!plugin) { - fail( - 'Confidence AI plugin not installed. Run "confidence plugin install" to install it or "confidence quickstart" to set up your project first.', - ); - return; - } - - const statuses = await integration.detectMcpStatuses(projectDir); - if (Object.values(statuses).some((s) => s === 'not-installed')) { - fail('MCP servers not installed. Run "confidence mcp install" to set them up.'); - return; - } - - if (Object.values(statuses).some((s) => s === 'auth-expired')) { - fail('MCP server auth expired. Run "confidence mcp auth" to re-authenticate.'); - return; - } - - const token = await resolveAuthToken({ profile: resolveFlag('profile', argv) }); - if (!token) { - fail('Not authenticated. Run "confidence login" to sign in.'); - return; - } - - integration.launchChat({ userPrompt: buildSetupPrompt(warehouseType), cwd: projectDir, token }); + await launchSkillChat(argv, buildSetupPrompt(warehouseType)); } From 1ee9801179323a9a7cd609e87c773287c349b0f2 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 8 Oct 2026 08:43:26 +0200 Subject: [PATCH 12/12] refactor(cli): deduplicate assignment-table key list Extract the six param keys into a single const shared by resolveInput and requireKeys. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../warehouse/create-assignment-table.ts | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/features/warehouse/create-assignment-table.ts b/packages/cli/src/features/warehouse/create-assignment-table.ts index b73fee5..c897270 100644 --- a/packages/cli/src/features/warehouse/create-assignment-table.ts +++ b/packages/cli/src/features/warehouse/create-assignment-table.ts @@ -13,24 +13,19 @@ type AssignmentTableParams = { 'variant-key-column': string; }; +const KEYS: (keyof AssignmentTableParams)[] = [ + 'display-name', + 'sql', + 'entity-column', + 'timestamp-column', + 'exposure-key-column', + 'variant-key-column', +]; + export const createAssignmentTableCmd = withAuth( async function createAssignmentTableCmd(argv, token) { - const params = resolveInput(argv, [ - 'display-name', - 'sql', - 'entity-column', - 'timestamp-column', - 'exposure-key-column', - 'variant-key-column', - ]); - requireKeys(params, [ - 'display-name', - 'sql', - 'entity-column', - 'timestamp-column', - 'exposure-key-column', - 'variant-key-column', - ]); + const params = resolveInput(argv, KEYS); + requireKeys(params, KEYS); const result = await createAssignmentTable(token, { displayName: params['display-name'], @@ -40,6 +35,7 @@ export const createAssignmentTableCmd = withAuth( exposureKeyColumn: params['exposure-key-column'], variantKeyColumn: params['variant-key-column'], }); + if (tryHandleMcpError(result)) return; printMcpResult(result, argv); },