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-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(); + }); +}); diff --git a/packages/cli/__tests__/commands/warehouse.test.ts b/packages/cli/__tests__/commands/warehouse.test.ts new file mode 100644 index 0000000..9f86de7 --- /dev/null +++ b/packages/cli/__tests__/commands/warehouse.test.ts @@ -0,0 +1,424 @@ +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'; +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 }), + ); + }); + + 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', () => { + 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..82dc5dd --- /dev/null +++ b/packages/cli/src/commands/warehouse.ts @@ -0,0 +1,184 @@ +import type { Argv } from 'yargs'; +import { noop } from '@spotify-confidence/shared-kernel'; +import { + validateConfig, + createWarehouseCmd, + createFlagAppliedConnectionCmd, + createEventConnectionCmd, + createAssignmentTableCmd, + createCryptoKeyCmd, + runWarehouseSetup, +} 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( + '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', + 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: setup, validate, create, connector, assignment-table, crypto-key. Run "confidence warehouse --help" for details.', + ) + .strict(); + }, + handler: noop, +}; 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/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/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/create-assignment-table.ts b/packages/cli/src/features/warehouse/create-assignment-table.ts new file mode 100644 index 0000000..c897270 --- /dev/null +++ b/packages/cli/src/features/warehouse/create-assignment-table.ts @@ -0,0 +1,42 @@ +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; +}; + +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, KEYS); + requireKeys(params, KEYS); + + 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..989780b --- /dev/null +++ b/packages/cli/src/features/warehouse/create-event-connection.ts @@ -0,0 +1,23 @@ +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'; +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']); + + 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 new file mode 100644 index 0000000..4cd3a6f --- /dev/null +++ b/packages/cli/src/features/warehouse/create-flag-applied-connection.ts @@ -0,0 +1,23 @@ +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'; +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']); + + 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 new file mode 100644 index 0000000..0ddea4a --- /dev/null +++ b/packages/cli/src/features/warehouse/create.ts @@ -0,0 +1,21 @@ +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'; +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']); + + 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/index.ts b/packages/cli/src/features/warehouse/index.ts new file mode 100644 index 0000000..e33035a --- /dev/null +++ b/packages/cli/src/features/warehouse/index.ts @@ -0,0 +1,7 @@ +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'; +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..bdcefde --- /dev/null +++ b/packages/cli/src/features/warehouse/setup.ts @@ -0,0 +1,23 @@ +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'; + +const WAREHOUSE_SKILLS: Record = { + bigquery: 'setup-warehouse-bigquery', + snowflake: 'setup-warehouse-snowflake', + databricks: 'setup-warehouse-databricks', + redshift: 'setup-warehouse-redshift', +}; + +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. +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 = validateWarehouseType(argv['warehouse-type'] as string); + await launchSkillChat(argv, buildSetupPrompt(warehouseType)); +} diff --git a/packages/cli/src/features/warehouse/types.ts b/packages/cli/src/features/warehouse/types.ts new file mode 100644 index 0000000..28d6b53 --- /dev/null +++ b/packages/cli/src/features/warehouse/types.ts @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..c1be516 --- /dev/null +++ b/packages/cli/src/features/warehouse/utils.ts @@ -0,0 +1,13 @@ +import type { WarehouseType } from './types.js'; + +const WAREHOUSE_TYPES = ['bigquery', 'snowflake', 'databricks', 'redshift'] as const; + +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 new file mode 100644 index 0000000..5828810 --- /dev/null +++ b/packages/cli/src/features/warehouse/validate.ts @@ -0,0 +1,79 @@ +import { extractText, parseToolJson } from '@spotify-confidence/core'; +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'; +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; + 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 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; + try { + data = parseToolJson(result); + } catch { + message(extractText(result)); + return; + } + + if (!data.successful) { + fail(); + } + + 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.', + }); +}); 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/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; } 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..15332ee --- /dev/null +++ b/packages/cli/src/utils/mcp-error.ts @@ -0,0 +1,17 @@ +import { readMcpText, type CallToolResult } from '@spotify-confidence/core'; +import { fail } from '@output/print.js'; + +type Options = { + formatError?: (text: string) => void; + defaultMessage?: string; +}; + +export function tryHandleMcpError(result: CallToolResult, opts?: Options): boolean { + if (!result.isError) return false; + + const formatError = opts?.formatError ?? fail; + const defaultMessage = opts?.defaultMessage ?? 'MCP tool call failed'; + + formatError(readMcpText(result) || defaultMessage); + return true; +} 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/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';