diff --git a/.env.example b/.env.example index c9b8ab7..d20b82a 100644 --- a/.env.example +++ b/.env.example @@ -20,9 +20,11 @@ GEMINI_MODEL=gemini-2.0-flash # Kimi (Moonshot AI) KIMI_API_KEY=your-key-here # Alternative: MOONSHOT_API_KEY=your-key-here -KIMI_MODEL=moonshot-v1-8k +KIMI_MODEL=kimi-k2.5 +# Other options: kimi-k2-0324, kimi-latest, moonshot-v1-128k, moonshot-v1-32k, moonshot-v1-8k # Optional: Custom base URLs (for proxies or alternative endpoints) # ANTHROPIC_BASE_URL=https://api.anthropic.com # OPENAI_BASE_URL=https://api.openai.com/v1 -# KIMI_BASE_URL=https://api.moonshot.cn/v1 +# KIMI_BASE_URL=https://api.moonshot.ai/v1 # Global (default) +# KIMI_BASE_URL=https://api.moonshot.cn/v1 # China diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a9b1bd..b5fec9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10 - name: Setup Node.js uses: actions/setup-node@v4 @@ -54,8 +52,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10 - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 @@ -86,8 +82,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e32c410..e6ff7ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,8 +21,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.gitignore b/.gitignore index aeb1569..d4d9a30 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn.lock # Corbat-Coco project data (when testing locally) .coco/ !.coco/README.md + +# Development plans (internal, not published) +.dev/ diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 0000000..44a999b --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,359 @@ +# MCP (Model Context Protocol) Support + +Corbat-Coco supports the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/), enabling integration with 100+ external tools and services. + +## Overview + +The MCP module provides: + +- **MCP Client**: Connect to MCP servers via stdio or HTTP +- **Registry**: Manage multiple MCP server configurations +- **Tools Wrapper**: Use MCP tools as native COCO tools +- **CLI Commands**: Manage servers via `coco mcp` commands + +## Quick Start + +### 1. Add an MCP Server + +```bash +# Add a stdio-based server +coco mcp add filesystem \ + --command "npx" \ + --args "-y,@modelcontextprotocol/server-filesystem,/home/user" \ + --description "Filesystem access" + +# Add an HTTP-based server with authentication +coco mcp add remote-api \ + --transport http \ + --url "https://api.example.com/mcp" \ + --description "Remote API" +``` + +### 2. List Servers + +```bash +# List enabled servers +coco mcp list + +# List all servers including disabled +coco mcp list --all +``` + +### 3. Use MCP Tools in Code + +```typescript +import { createMCPClient, StdioTransport, registerMCPTools } from 'corbat-coco/mcp'; +import { createFullToolRegistry } from 'corbat-coco/tools'; + +// Create client +const transport = new StdioTransport({ + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/home/user'], +}); + +const client = createMCPClient(transport); + +// Register MCP tools in COCO registry +const registry = createFullToolRegistry(); +const wrappedTools = await registerMCPTools(registry, 'filesystem', client); + +// Now use the tools +const result = await registry.execute('mcp_filesystem_read_file', { + path: '/home/user/document.txt', +}); + +console.log(result.data); // File content +``` + +## Configuration + +### Config File Format + +Create an `mcp.json` file: + +```json +{ + "version": "1.0", + "servers": [ + { + "name": "filesystem", + "description": "Filesystem access", + "transport": "stdio", + "stdio": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"] + } + }, + { + "name": "remote-api", + "description": "Remote API", + "transport": "http", + "http": { + "url": "https://api.example.com/mcp", + "auth": { + "type": "bearer", + "tokenEnv": "API_TOKEN" + } + } + } + ] +} +``` + +Load it programmatically: + +```typescript +import { loadMCPConfigFile, createMCPRegistry } from 'corbat-coco/mcp'; + +const servers = await loadMCPConfigFile('./mcp.json'); +const registry = createMCPRegistry(); + +for (const server of servers) { + await registry.addServer(server); +} +``` + +### COCO Config Integration + +Add MCP servers to your `coco.config.json`: + +```json +{ + "project": { + "name": "my-project" + }, + "mcp": { + "enabled": true, + "servers": [ + { + "name": "filesystem", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"] + } + ] + } +} +``` + +## Transports + +### Stdio Transport + +For local command-based MCP servers: + +```typescript +import { StdioTransport } from 'corbat-coco/mcp'; + +const transport = new StdioTransport({ + command: 'python', + args: ['-m', 'mcp_server'], + env: { API_KEY: 'secret' }, + cwd: '/path/to/workdir', + timeout: 60000, +}); +``` + +### HTTP Transport + +For remote MCP servers with authentication: + +```typescript +import { HTTPTransport } from 'corbat-coco/mcp'; + +// Bearer token +const transport = new HTTPTransport({ + url: 'https://api.example.com/mcp', + auth: { + type: 'bearer', + token: 'your-token', + // or tokenEnv: 'API_TOKEN' + }, + timeout: 60000, + retries: 3, +}); + +// API Key +const transport = new HTTPTransport({ + url: 'https://api.example.com/mcp', + auth: { + type: 'apikey', + token: 'your-api-key', + headerName: 'X-API-Key', + }, +}); + +// OAuth +const transport = new HTTPTransport({ + url: 'https://api.example.com/mcp', + auth: { + type: 'oauth', + token: 'oauth-token', + }, +}); +``` + +## CLI Commands + +| Command | Description | +|---------|-------------| +| `coco mcp add ` | Add a new MCP server | +| `coco mcp remove ` | Remove an MCP server | +| `coco mcp list` | List registered servers | +| `coco mcp enable ` | Enable a server | +| `coco mcp disable ` | Disable a server | + +### Add Command Options + +```bash +coco mcp add \ + --transport \ + --command \ + --args \ + --url \ + --env \ + --description +``` + +## Tool Naming + +MCP tools are prefixed when registered in COCO: + +- Format: `mcp__` +- Example: `mcp_filesystem_read_file` + +## Error Handling + +The MCP module provides specific error types: + +```typescript +import { MCPError, MCPConnectionError, MCPTimeoutError } from 'corbat-coco/mcp'; + +try { + await client.callTool({ name: 'read_file', arguments: { path: '/test' } }); +} catch (error) { + if (error instanceof MCPTimeoutError) { + console.log('Request timed out'); + } else if (error instanceof MCPConnectionError) { + console.log('Connection failed'); + } +} +``` + +## Advanced Usage + +### Custom Tool Wrapper Options + +```typescript +import { wrapMCPTools } from 'corbat-coco/mcp'; + +const { tools, wrapped } = wrapMCPTools( + mcpTools, + 'my-server', + client, + { + namePrefix: 'custom', // Default: 'mcp' + category: 'file', // Default: 'deploy' + requestTimeout: 30000, // Default: 60000 + } +); +``` + +### Manual Registry Management + +```typescript +import { createMCPRegistry } from 'corbat-coco/mcp'; + +const registry = createMCPRegistry(); +await registry.load(); + +// Add server +await registry.addServer({ + name: 'custom-server', + transport: 'stdio', + stdio: { command: 'my-command' }, + enabled: true, +}); + +// Check if exists +if (registry.hasServer('custom-server')) { + const config = registry.getServer('custom-server'); + console.log(config); +} + +// List enabled servers +const enabled = registry.listEnabledServers(); +``` + +## API Reference + +### Types + +- `MCPClient` - Client interface for MCP servers +- `MCPTransport` - Transport interface (stdio/http) +- `MCPServerConfig` - Server configuration +- `MCPTool` - MCP tool definition +- `MCPWrappedTool` - Wrapped tool information + +### Functions + +- `createMCPClient(transport, timeout?)` - Create MCP client +- `createMCPRegistry(path?)` - Create server registry +- `registerMCPTools(registry, serverName, client, options?)` - Register MCP tools +- `loadMCPConfigFile(path)` - Load config from JSON file +- `loadMCPServersFromCOCOConfig(path?)` - Load from COCO config + +## Examples + +### Filesystem Server + +```bash +coco mcp add filesystem \ + --command "npx" \ + --args "-y,@modelcontextprotocol/server-filesystem,/home/user" \ + --description "Local filesystem access" +``` + +### GitHub Server + +```bash +coco mcp add github \ + --command "npx" \ + --args "-y,@modelcontextprotocol/server-github" \ + --env "GITHUB_TOKEN=$GITHUB_TOKEN" +``` + +### PostgreSQL Server + +```bash +coco mcp add postgres \ + --command "npx" \ + --args "-y,@modelcontextprotocol/server-postgres,postgresql://localhost/mydb" +``` + +## Troubleshooting + +### Connection Issues + +1. Verify the server command exists: `which ` +2. Check server logs for errors +3. Ensure required environment variables are set +4. Verify network connectivity for HTTP servers + +### Tool Not Found + +1. Check server is enabled: `coco mcp list --all` +2. Verify tool name with prefix: `mcp__` +3. Check server capabilities: `client.listTools()` + +### Authentication Errors + +1. For stdio: Check environment variables in `--env` +2. For HTTP: Verify token or use `tokenEnv` to load from env +3. Check token permissions with the server provider + +## Resources + +- [MCP Specification](https://modelcontextprotocol.io/) +- [MCP Servers Repository](https://github.com/modelcontextprotocol/servers) +- [COCO Tools Documentation](./TOOLS.md) diff --git a/package.json b/package.json index fa0d11b..d6818ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "corbat-coco", - "version": "0.2.0", + "version": "0.3.0", "description": "Autonomous Coding Agent with Self-Review, Quality Convergence, and Production-Ready Output", "type": "module", "main": "dist/index.js", @@ -55,6 +55,7 @@ "@anthropic-ai/sdk": "^0.39.0", "@clack/prompts": "^0.11.0", "@google/generative-ai": "^0.24.1", + "ansi-escapes": "^7.3.0", "chalk": "^5.4.0", "commander": "^13.0.0", "dotenv": "^17.2.3", @@ -62,6 +63,7 @@ "glob": "^11.0.0", "json5": "^2.2.3", "openai": "^6.17.0", + "ora": "^9.2.0", "simple-git": "^3.27.0", "tslog": "^4.9.3", "zod": "^3.24.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb011cc..ca9c8c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@google/generative-ai': specifier: ^0.24.1 version: 0.24.1 + ansi-escapes: + specifier: ^7.3.0 + version: 7.3.0 chalk: specifier: ^5.4.0 version: 5.6.2 @@ -38,6 +41,9 @@ importers: openai: specifier: ^6.17.0 version: 6.17.0(zod@3.25.76) + ora: + specifier: ^9.2.0 + version: 9.2.0 simple-git: specifier: ^3.27.0 version: 3.30.0 @@ -616,6 +622,10 @@ packages: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -684,6 +694,14 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -752,6 +770,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -830,6 +852,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-east-asian-width@1.4.0: + resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -888,6 +914,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -952,6 +982,10 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -994,6 +1028,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + minimatch@10.1.1: resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} @@ -1042,6 +1080,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + openai@6.17.0: resolution: {integrity: sha512-NHRpPEUPzAvFOAFs9+9pC6+HCw/iWsYsKCMPXH5Kw7BpMxqd8g/A07/1o7Gx2TWtCnzevVRyKMRFqyiHyAlqcA==} hasBin: true @@ -1054,6 +1096,10 @@ packages: zod: optional: true + ora@9.2.0: + resolution: {integrity: sha512-4sGT6oNDbqIuciDfD2aCkoPgHLmOe+g+xpFK2WDO0aQuD/bNHNwfdFosWP+DXmcxRyXeF8vnki6kXnOOHTuRSA==} + engines: {node: '>=20'} + oxfmt@0.26.0: resolution: {integrity: sha512-UDD1wFNwfeorMm2ZY0xy1KRAAvJ5NjKBfbDmiMwGP7baEHTq65cYpC0aPP+BGHc8weXUbSZaK8MdGyvuRUvS4Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1149,6 +1195,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1194,6 +1244,10 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stdin-discarder@0.3.1: + resolution: {integrity: sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==} + engines: {node: '>=18'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1202,6 +1256,10 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@8.1.1: + resolution: {integrity: sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==} + engines: {node: '>=20'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1863,6 +1921,10 @@ snapshots: dependencies: humanize-ms: 1.2.1 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -1921,6 +1983,12 @@ snapshots: dependencies: readdirp: 4.1.2 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@3.4.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -1969,6 +2037,8 @@ snapshots: entities@4.5.0: {} + environment@1.1.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -2077,6 +2147,8 @@ snapshots: function-bind@1.1.2: {} + get-east-asian-width@1.4.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2146,6 +2218,8 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-interactive@2.0.0: {} + is-plain-obj@4.1.0: {} is-stream@4.0.1: {} @@ -2201,6 +2275,11 @@ snapshots: load-tsconfig@0.2.5: {} + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + loupe@3.2.1: {} lru-cache@10.4.3: {} @@ -2242,6 +2321,8 @@ snapshots: dependencies: mime-db: 1.52.0 + mimic-function@5.0.1: {} + minimatch@10.1.1: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -2282,10 +2363,25 @@ snapshots: object-assign@4.1.1: {} + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + openai@6.17.0(zod@3.25.76): optionalDependencies: zod: 3.25.76 + ora@9.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.1 + string-width: 8.1.1 + oxfmt@0.26.0: dependencies: tinypool: 2.0.0 @@ -2370,6 +2466,11 @@ snapshots: resolve-pkg-maps@1.0.0: {} + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -2431,6 +2532,8 @@ snapshots: std-env@3.10.0: {} + stdin-discarder@0.3.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -2443,6 +2546,11 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 + string-width@8.1.1: + dependencies: + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 diff --git a/src/cli/commands/build.test.ts b/src/cli/commands/build.test.ts index 9a5bbbb..473071a 100644 --- a/src/cli/commands/build.test.ts +++ b/src/cli/commands/build.test.ts @@ -87,9 +87,7 @@ describe("build command", () => { registerBuildCommand(mockCommand as any); - expect(mockCommand.description).toHaveBeenCalledWith( - expect.stringContaining("task") - ); + expect(mockCommand.description).toHaveBeenCalledWith(expect.stringContaining("task")); }); it("should have task option with short flag", async () => { @@ -104,10 +102,7 @@ describe("build command", () => { registerBuildCommand(mockCommand as any); - expect(mockCommand.option).toHaveBeenCalledWith( - "-t, --task ", - expect.any(String) - ); + expect(mockCommand.option).toHaveBeenCalledWith("-t, --task ", expect.any(String)); }); it("should have sprint option with short flag", async () => { @@ -124,7 +119,7 @@ describe("build command", () => { expect(mockCommand.option).toHaveBeenCalledWith( "-s, --sprint ", - expect.any(String) + expect.any(String), ); }); @@ -140,10 +135,7 @@ describe("build command", () => { registerBuildCommand(mockCommand as any); - expect(mockCommand.option).toHaveBeenCalledWith( - "--no-review", - expect.any(String) - ); + expect(mockCommand.option).toHaveBeenCalledWith("--no-review", expect.any(String)); }); it("should have max-iterations option with default value", async () => { @@ -161,7 +153,7 @@ describe("build command", () => { expect(mockCommand.option).toHaveBeenCalledWith( "--max-iterations ", expect.any(String), - "10" + "10", ); }); @@ -180,7 +172,7 @@ describe("build command", () => { expect(mockCommand.option).toHaveBeenCalledWith( "--min-quality ", expect.any(String), - "85" + "85", ); }); @@ -289,7 +281,7 @@ describe("build command", () => { await promise; expect(p.log.error).toHaveBeenCalledWith( - "No Corbat-Coco project found. Run 'coco init' first." + "No Corbat-Coco project found. Run 'coco init' first.", ); expect(process.exit).toHaveBeenCalledWith(1); }); @@ -313,9 +305,7 @@ describe("build command", () => { await vi.runAllTimersAsync(); await promise; - expect(p.log.error).toHaveBeenCalledWith( - "No development plan found. Run 'coco plan' first." - ); + expect(p.log.error).toHaveBeenCalledWith("No development plan found. Run 'coco plan' first."); expect(process.exit).toHaveBeenCalledWith(1); }); @@ -325,9 +315,7 @@ describe("build command", () => { await vi.runAllTimersAsync(); await promise; - expect(p.log.step).toHaveBeenCalledWith( - expect.stringContaining("85") - ); + expect(p.log.step).toHaveBeenCalledWith(expect.stringContaining("85")); }); it("should parse custom minQuality", async () => { @@ -336,9 +324,7 @@ describe("build command", () => { await vi.runAllTimersAsync(); await promise; - expect(p.log.step).toHaveBeenCalledWith( - expect.stringContaining("90") - ); + expect(p.log.step).toHaveBeenCalledWith(expect.stringContaining("90")); }); it("should log number of tasks found", async () => { @@ -347,9 +333,7 @@ describe("build command", () => { await vi.runAllTimersAsync(); await promise; - expect(p.log.info).toHaveBeenCalledWith( - expect.stringMatching(/Found \d+ tasks to complete/) - ); + expect(p.log.info).toHaveBeenCalledWith(expect.stringMatching(/Found \d+ tasks to complete/)); }); it("should execute tasks with spinner", async () => { @@ -444,7 +428,7 @@ describe("build command", () => { registerBuildCommand(mockCommand as any); const maxIterationsCall = mockCommand.option.mock.calls.find( - (call: string[]) => call[0] === "--max-iterations " + (call: string[]) => call[0] === "--max-iterations ", ); expect(maxIterationsCall).toBeDefined(); @@ -464,7 +448,7 @@ describe("build command", () => { registerBuildCommand(mockCommand as any); const minQualityCall = mockCommand.option.mock.calls.find( - (call: string[]) => call[0] === "--min-quality " + (call: string[]) => call[0] === "--min-quality ", ); expect(minQualityCall).toBeDefined(); @@ -483,9 +467,7 @@ describe("build command", () => { registerBuildCommand(mockCommand as any); - expect(mockCommand.description).toHaveBeenCalledWith( - expect.stringMatching(/build/i) - ); + expect(mockCommand.description).toHaveBeenCalledWith(expect.stringMatching(/build/i)); }); }); @@ -557,7 +539,7 @@ describe("build command", () => { // Stop should be called with quality score messages const stopCalls = mockSpinnerInstance.stop.mock.calls; const qualityCalls = stopCalls.filter((call: string[]) => - call[0]?.includes("Quality score:") + call[0]?.includes("Quality score:"), ); expect(qualityCalls.length).toBeGreaterThan(0); }); @@ -581,9 +563,7 @@ describe("build command", () => { await promise; // Should log a warning about score below threshold - expect(p.log.warn).toHaveBeenCalledWith( - expect.stringContaining("below threshold") - ); + expect(p.log.warn).toHaveBeenCalledWith(expect.stringContaining("below threshold")); }); it("should process tasks in order", async () => { diff --git a/src/cli/commands/build.ts b/src/cli/commands/build.ts index 023fa65..c9815c9 100644 --- a/src/cli/commands/build.ts +++ b/src/cli/commands/build.ts @@ -120,7 +120,9 @@ async function executeTask(_task: Task, options: ExecuteOptions): Promise } while (iteration <= options.maxIterations); if (score < options.minQuality) { - p.log.warn(`Task completed with score ${score.toFixed(0)} (below threshold ${options.minQuality})`); + p.log.warn( + `Task completed with score ${score.toFixed(0)} (below threshold ${options.minQuality})`, + ); } } @@ -128,8 +130,16 @@ async function loadTasks(_options: BuildOptions): Promise { // TODO: Load from .coco/planning/backlog.json // Placeholder tasks for demonstration return [ - { id: "task-001", title: "Create user entity", description: "Create the User entity with validation" }, - { id: "task-002", title: "Implement registration", description: "Create registration endpoint" }, + { + id: "task-001", + title: "Create user entity", + description: "Create the User entity with validation", + }, + { + id: "task-002", + title: "Implement registration", + description: "Create registration endpoint", + }, { id: "task-003", title: "Add authentication", description: "Implement JWT authentication" }, ]; } diff --git a/src/cli/commands/config.test.ts b/src/cli/commands/config.test.ts index 8976e25..48df5d5 100644 --- a/src/cli/commands/config.test.ts +++ b/src/cli/commands/config.test.ts @@ -14,15 +14,86 @@ vi.mock("@clack/prompts", () => ({ cancel: vi.fn(), isCancel: vi.fn().mockReturnValue(false), text: vi.fn().mockResolvedValue("sk-ant-test-key"), + password: vi.fn().mockResolvedValue("sk-ant-test-key"), select: vi.fn().mockResolvedValue("claude-sonnet-4-20250514"), + confirm: vi.fn().mockResolvedValue(true), + spinner: vi.fn(() => ({ + start: vi.fn(), + stop: vi.fn(), + message: "", + })), log: { info: vi.fn(), success: vi.fn(), warning: vi.fn(), error: vi.fn(), + message: vi.fn(), }, })); +// Mock providers-config +vi.mock("../repl/providers-config.js", () => ({ + getAllProviders: vi.fn(() => [ + { + id: "anthropic", + name: "Anthropic", + emoji: "🤖", + description: "Claude AI models", + envVar: "ANTHROPIC_API_KEY", + models: [ + { + id: "claude-sonnet-4-20250514", + name: "Claude Sonnet 4", + context: 200000, + hint: "Recommended for coding", + }, + { + id: "claude-opus-4-20250514", + name: "Claude Opus 4", + context: 200000, + hint: "Most capable", + }, + { + id: "claude-3-5-sonnet-20241022", + name: "Claude 3.5 Sonnet", + context: 200000, + hint: "Fast and capable", + }, + ], + }, + ]), + getProviderDefinition: vi.fn(() => ({ + id: "anthropic", + name: "Anthropic", + emoji: "🤖", + description: "Claude AI models", + envVar: "ANTHROPIC_API_KEY", + models: [ + { + id: "claude-sonnet-4-20250514", + name: "Claude Sonnet 4", + context: 200000, + hint: "Recommended for coding", + }, + { + id: "claude-opus-4-20250514", + name: "Claude Opus 4", + context: 200000, + hint: "Most capable", + }, + { + id: "claude-3-5-sonnet-20241022", + name: "Claude 3.5 Sonnet", + context: 200000, + hint: "Fast and capable", + }, + ], + })), + formatModelInfo: vi.fn( + (model) => `${model.name || model.id}${model.hint ? ` - ${model.hint}` : ""}`, + ), +})); + const mockFsWriteFile = vi.fn().mockResolvedValue(undefined); const mockFsMkdir = vi.fn().mockResolvedValue(undefined); @@ -170,7 +241,9 @@ describe("registerConfigCommand", () => { registerConfigCommand(mockProgram as any); expect(mockConfigCmd.command).toHaveBeenCalledWith("init"); - expect(mockSubCommand.description).toHaveBeenCalledWith("Initialize configuration interactively"); + expect(mockSubCommand.description).toHaveBeenCalledWith( + "Initialize configuration interactively", + ); }); it("should register action handlers for all subcommands", async () => { @@ -548,10 +621,7 @@ describe("config set action handler", () => { await promise; expect(mockFsMkdir).toHaveBeenCalledWith(".coco", { recursive: true }); - expect(mockFsWriteFile).toHaveBeenCalledWith( - ".coco/config.json", - expect.any(String) - ); + expect(mockFsWriteFile).toHaveBeenCalledWith(".coco/config.json", expect.any(String)); }); }); @@ -719,10 +789,12 @@ describe("config init action handler", () => { await vi.runAllTimersAsync(); await promise; - expect(p.text).toHaveBeenCalledWith(expect.objectContaining({ - message: "Enter your Anthropic API key:", - placeholder: "sk-ant-...", - })); + // The code uses p.password for API keys (more secure) + expect(p.password).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Enter your Anthropic API key:", + }), + ); }); it("should prompt for model selection", async () => { @@ -731,31 +803,31 @@ describe("config init action handler", () => { await vi.runAllTimersAsync(); await promise; - expect(p.select).toHaveBeenCalledWith(expect.objectContaining({ - message: "Select the default model:", - })); + expect(p.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Select the default model:", + }), + ); }); it("should prompt for quality threshold", async () => { - vi.mocked(p.text) - .mockResolvedValueOnce("sk-ant-test-key") - .mockResolvedValueOnce("85"); + vi.mocked(p.text).mockResolvedValueOnce("sk-ant-test-key").mockResolvedValueOnce("85"); expect(initHandler).not.toBeNull(); const promise = initHandler!(); await vi.runAllTimersAsync(); await promise; - expect(p.text).toHaveBeenCalledWith(expect.objectContaining({ - message: "Minimum quality score (0-100):", - initialValue: "85", - })); + expect(p.text).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Minimum quality score (0-100):", + initialValue: "85", + }), + ); }); it("should save configuration on success", async () => { - vi.mocked(p.text) - .mockResolvedValueOnce("sk-ant-test-key") - .mockResolvedValueOnce("85"); + vi.mocked(p.text).mockResolvedValueOnce("sk-ant-test-key").mockResolvedValueOnce("85"); expect(initHandler).not.toBeNull(); const promise = initHandler!(); @@ -765,14 +837,12 @@ describe("config init action handler", () => { expect(mockFsMkdir).toHaveBeenCalledWith(".coco", { recursive: true }); expect(mockFsWriteFile).toHaveBeenCalledWith( ".coco/config.json", - expect.stringContaining("provider") + expect.stringContaining("provider"), ); }); it("should display success outro message", async () => { - vi.mocked(p.text) - .mockResolvedValueOnce("sk-ant-test-key") - .mockResolvedValueOnce("85"); + vi.mocked(p.text).mockResolvedValueOnce("sk-ant-test-key").mockResolvedValueOnce("85"); expect(initHandler).not.toBeNull(); const promise = initHandler!(); @@ -795,9 +865,7 @@ describe("config init action handler", () => { }); it("should exit if user cancels model selection", async () => { - vi.mocked(p.isCancel) - .mockReturnValueOnce(false) - .mockReturnValueOnce(true); + vi.mocked(p.isCancel).mockReturnValueOnce(false).mockReturnValueOnce(true); expect(initHandler).not.toBeNull(); const promise = initHandler!(); @@ -809,9 +877,7 @@ describe("config init action handler", () => { }); it("should exit if user cancels quality threshold input", async () => { - vi.mocked(p.text) - .mockResolvedValueOnce("sk-ant-test-key") - .mockResolvedValueOnce("85"); + vi.mocked(p.text).mockResolvedValueOnce("sk-ant-test-key").mockResolvedValueOnce("85"); vi.mocked(p.isCancel) .mockReturnValueOnce(false) .mockReturnValueOnce(false) @@ -832,26 +898,24 @@ describe("config init action handler", () => { await vi.runAllTimersAsync(); await promise; - // Get the text call and extract the validate function - const textCalls = vi.mocked(p.text).mock.calls; - const apiKeyCall = textCalls.find(call => - call[0].message === "Enter your Anthropic API key:" + // The code uses p.password for API keys - get the validate function + const passwordCalls = vi.mocked(p.password).mock.calls; + const apiKeyCall = passwordCalls.find( + (call) => call[0].message === "Enter your Anthropic API key:", ); expect(apiKeyCall).toBeDefined(); const validateFn = apiKeyCall![0].validate; expect(validateFn).toBeDefined(); - // Test validation - expect(validateFn!("")).toBe("API key is required"); - expect(validateFn!("invalid-key")).toBe("Invalid API key format"); - expect(validateFn!("sk-ant-valid-key")).toBeUndefined(); + // Test validation - the actual code uses min length validation + expect(validateFn!("")).toBe("API key is required (min 10 chars)"); + expect(validateFn!("short")).toBe("API key is required (min 10 chars)"); + expect(validateFn!("sk-ant-valid-key-long-enough")).toBeUndefined(); }); it("should validate quality score range", async () => { - vi.mocked(p.text) - .mockResolvedValueOnce("sk-ant-test-key") - .mockResolvedValueOnce("85"); + vi.mocked(p.text).mockResolvedValueOnce("sk-ant-test-key").mockResolvedValueOnce("85"); expect(initHandler).not.toBeNull(); const promise = initHandler!(); @@ -860,8 +924,8 @@ describe("config init action handler", () => { // Get the text call for quality score and extract the validate function const textCalls = vi.mocked(p.text).mock.calls; - const qualityCall = textCalls.find(call => - call[0].message === "Minimum quality score (0-100):" + const qualityCall = textCalls.find( + (call) => call[0].message === "Minimum quality score (0-100):", ); expect(qualityCall).toBeDefined(); @@ -877,19 +941,27 @@ describe("config init action handler", () => { expect(validateFn!("100")).toBeUndefined(); }); - it("should include model options with hints", async () => { + it("should include model options with labels", async () => { expect(initHandler).not.toBeNull(); const promise = initHandler!(); await vi.runAllTimersAsync(); await promise; - expect(p.select).toHaveBeenCalledWith(expect.objectContaining({ - options: expect.arrayContaining([ - expect.objectContaining({ value: "claude-sonnet-4-20250514", hint: "Recommended for coding" }), - expect.objectContaining({ value: "claude-opus-4-20250514", hint: "Most capable" }), - expect.objectContaining({ value: "claude-3-5-sonnet-20241022", hint: "Fast and capable" }), - ]), - })); + // The second p.select call is for model selection + expect(p.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Select the default model:", + options: expect.arrayContaining([ + expect.objectContaining({ + value: "claude-sonnet-4-20250514", + }), + expect.objectContaining({ value: "claude-opus-4-20250514" }), + expect.objectContaining({ + value: "claude-3-5-sonnet-20241022", + }), + ]), + }), + ); }); }); @@ -993,7 +1065,9 @@ describe("helper functions", () => { await vi.runAllTimersAsync(); await promise; - expect(p.log.error).toHaveBeenCalledWith("Configuration key 'provider.type.nonexistent' not found."); + expect(p.log.error).toHaveBeenCalledWith( + "Configuration key 'provider.type.nonexistent' not found.", + ); expect(process.exit).toHaveBeenCalledWith(1); }); diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index 6292ec5..d8af4b3 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -1,11 +1,15 @@ import { Command } from "commander"; import * as p from "@clack/prompts"; import chalk from "chalk"; +import { + getAllProviders, + getProviderDefinition, + formatModelInfo, +} from "../repl/providers-config.js"; +import type { ProviderType } from "../../providers/index.js"; export function registerConfigCommand(program: Command): void { - const configCmd = program - .command("config") - .description("Manage Corbat-Coco configuration"); + const configCmd = program.command("config").description("Manage Corbat-Coco configuration"); configCmd .command("get ") @@ -81,13 +85,29 @@ async function runConfigList(options: { json?: boolean }): Promise { async function runConfigInit(): Promise { p.intro(chalk.cyan("Corbat-Coco Configuration Setup")); + // Select provider + const allProviders = getAllProviders(); + const providerChoice = await p.select({ + message: "Select your AI provider:", + options: allProviders.map((provider) => ({ + value: provider.id, + label: `${provider.emoji} ${provider.name}`, + hint: provider.description, + })), + }); + + if (p.isCancel(providerChoice)) { + p.cancel("Configuration cancelled."); + process.exit(0); + } + + const selectedProvider = getProviderDefinition(providerChoice as ProviderType); + // API Key - const apiKey = await p.text({ - message: "Enter your Anthropic API key:", - placeholder: "sk-ant-...", + const apiKey = await p.password({ + message: `Enter your ${selectedProvider.name} API key:`, validate: (value) => { - if (!value) return "API key is required"; - if (!value.startsWith("sk-ant-")) return "Invalid API key format"; + if (!value || value.length < 10) return "API key is required (min 10 chars)"; return undefined; }, }); @@ -97,14 +117,15 @@ async function runConfigInit(): Promise { process.exit(0); } - // Model + // Model selection + const modelOptions = selectedProvider.models.map((m) => ({ + value: m.id, + label: formatModelInfo(m), + })); + const model = await p.select({ message: "Select the default model:", - options: [ - { value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4", hint: "Recommended for coding" }, - { value: "claude-opus-4-20250514", label: "Claude Opus 4", hint: "Most capable" }, - { value: "claude-3-5-sonnet-20241022", label: "Claude 3.5 Sonnet", hint: "Fast and capable" }, - ], + options: modelOptions, }); if (p.isCancel(model)) { @@ -132,7 +153,7 @@ async function runConfigInit(): Promise { // Save configuration const config = { provider: { - type: "anthropic", + type: providerChoice as string, apiKey: apiKey as string, model: model as string, }, @@ -228,9 +249,10 @@ function printConfig(obj: unknown, prefix: string): void { if (typeof value === "object" && value !== null && !Array.isArray(value)) { printConfig(value, fullKey); } else { - const displayValue = typeof value === "string" && value.startsWith("sk-") - ? chalk.dim("[hidden]") - : chalk.cyan(JSON.stringify(value)); + const displayValue = + typeof value === "string" && value.startsWith("sk-") + ? chalk.dim("[hidden]") + : chalk.cyan(JSON.stringify(value)); console.log(` ${chalk.dim(fullKey + ":")} ${displayValue}`); } } diff --git a/src/cli/commands/init.test.ts b/src/cli/commands/init.test.ts index 56a8ce2..10fd952 100644 --- a/src/cli/commands/init.test.ts +++ b/src/cli/commands/init.test.ts @@ -86,9 +86,7 @@ describe("registerInitCommand", () => { registerInitCommand(mockProgram as any); - expect(mockProgram.description).toHaveBeenCalledWith( - "Initialize a new Corbat-Coco project" - ); + expect(mockProgram.description).toHaveBeenCalledWith("Initialize a new Corbat-Coco project"); }); it("should accept path argument", async () => { @@ -104,11 +102,7 @@ describe("registerInitCommand", () => { registerInitCommand(mockProgram as any); - expect(mockProgram.argument).toHaveBeenCalledWith( - "[path]", - "Project directory path", - "." - ); + expect(mockProgram.argument).toHaveBeenCalledWith("[path]", "Project directory path", "."); }); it("should have template option", async () => { @@ -126,7 +120,7 @@ describe("registerInitCommand", () => { expect(mockProgram.option).toHaveBeenCalledWith( "-t, --template