diff --git a/CHANGELOG.md b/CHANGELOG.md index 27e4af4..0a48541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **CrewForm CLI (`npx crewform`)** — Standalone command-line tool for running AI agents locally without the web platform: + - **12 Commands** — `run`, `chat`, `init`, `validate`, `tools`, `login`, `logout`, `whoami`, `agents`, `teams`, `pull`, `push` + - **Local Agent Execution** — Run agents from JSON config files with streaming output, Ollama auto-detection, and 16 LLM provider support + - **Pipeline Teams** — Multi-agent pipeline execution with sequential steps, fan-out (parallel branches), merge agents, and configurable retry/skip/stop failure handling + - **MCP Client Integration** — Connect to MCP servers via `stdio`, `sse`, or `streamable-http` transports; agents can discover and call MCP tools during execution (`--mcp` flag) + - **Interactive Chat** — REPL with conversation history, token/cost tracking, and `/clear`, `/history`, `/stats`, `/exit` commands + - **Platform API Mode** — `crewform login` to authenticate with an API key, `crewform agents`/`teams` to browse workspace, `crewform pull` to download configs, `crewform push` to dispatch tasks remotely + - **Config Compatibility** — Accepts both simplified inline JSON and full `crewform-export` v1 format from the web app + - **Built-in Tools** — `web_search`, `http_request`, `code_interpreter`, `read_file`, `grammar_check` + - **Zero-Config Start** — `crewform init` scaffolds agent or team configs with Ollama model auto-detection + ## [1.9.2] - 2026-05-08 ### Added diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..1ab415f --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tgz diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..c2fca5c --- /dev/null +++ b/cli/README.md @@ -0,0 +1,209 @@ +# CrewForm CLI + +Run CrewForm AI agents from the command line — scriptable, CI/CD-friendly, Ollama-first. + +``` +npx crewform run agent.json "Summarise the latest AI news" +``` + +## Quick Start + +```bash +# 1. Create an agent config +npx crewform init + +# 2. Run it (Ollama, or set OPENAI_API_KEY / ANTHROPIC_API_KEY) +npx crewform run agent.json "Hello, world!" + +# 3. Interactive chat +npx crewform chat agent.json +``` + +## Installation + +```bash +# Use directly (no install needed) +npx crewform + +# Or install globally +npm install -g crewform +``` + +## Commands + +### Local Execution + +| Command | Description | +|---------|-------------| +| `crewform run [prompt]` | Run an agent or team from a JSON config | +| `crewform chat ` | Interactive chat session | +| `crewform init` | Create a starter config file | +| `crewform validate ` | Validate a config file | +| `crewform tools` | List available built-in tools | + +### Platform (API-Connected) + +| Command | Description | +|---------|-------------| +| `crewform login` | Authenticate with your API key | +| `crewform logout` | Remove saved credentials | +| `crewform whoami` | Show authenticated user & workspace | +| `crewform agents` | List agents in your workspace | +| `crewform teams` | List teams in your workspace | +| `crewform pull ` | Download agent/team config to local JSON | +| `crewform push [prompt]` | Dispatch a task to a remote agent/team | + +### `crewform run` + +```bash +# Basic usage +crewform run agent.json "Write a blog post about MCP" + +# Read prompt from file +crewform run agent.json --input prompt.txt + +# Save output to file +crewform run agent.json "Generate a report" --output report.md + +# Pipe input/output +echo "Review this code" | crewform run agent.json +crewform run agent.json "Summarise" > summary.txt + +# JSON output with metadata +crewform run agent.json "Hello" --json + +# Quiet mode (no streaming, just result) +crewform run agent.json "Hello" --quiet + +# Run a pipeline team +crewform run team.json "Research and write about GraphQL" + +# Run with MCP servers +crewform run agent.json --mcp mcp-servers.json "Query my database" +``` + +### `crewform chat` + +Interactive REPL with conversation history: + +```bash +crewform chat agent.json +``` + +**Commands in chat:** +- `/clear` — Clear conversation history +- `/history` — Show recent messages +- `/stats` — Show token usage & cost +- `/exit` — End session + +### `crewform init` + +```bash +# Create agent config +crewform init + +# Create pipeline team config +crewform init --team + +# Custom name and model +crewform init --name "Code Reviewer" --model gpt-4o +``` + +## Config Format + +### Inline Agent (simplest) + +```json +{ + "name": "My Agent", + "model": "llama3.3", + "system_prompt": "You are a helpful assistant.", + "temperature": 0.7, + "tools": ["web_search"] +} +``` + +### CrewForm Export (from the web app) + +The CLI also accepts the full `crewform-export` v1 format that you get when exporting an agent from the CrewForm web app. + +## Supported Providers + +The CLI supports **16 LLM providers** out of the box: + +| Provider | Env Variable | Notes | +|----------|-------------|-------| +| **Ollama** | (none needed) | Default, auto-detected | +| **OpenAI** | `OPENAI_API_KEY` | GPT-4o, o3, etc. | +| **Anthropic** | `ANTHROPIC_API_KEY` | Claude 4, etc. | +| **Google** | `GOOGLE_API_KEY` | Gemini 2.x | +| **OpenRouter** | `OPENROUTER_API_KEY` | 200+ models | +| **Groq** | `GROQ_API_KEY` | Fast inference | +| **Mistral** | `MISTRAL_API_KEY` | Codestral, etc. | +| **Cohere** | `COHERE_API_KEY` | Command R+ | +| **Together** | `TOGETHER_API_KEY` | Open-source models | +| **NVIDIA** | `NVIDIA_API_KEY` | NIM models | +| **HuggingFace** | `HF_TOKEN` | Inference API | +| **Venice** | `VENICE_API_KEY` | Privacy-first | +| **MiniMax** | `MINIMAX_API_KEY` | | +| **Moonshot** | `MOONSHOT_API_KEY` | | +| **Perplexity** | `PERPLEXITY_API_KEY` | Sonar models | + +## Available Tools + +| Tool | Description | Requires | +|------|-------------|----------| +| `web_search` | Search the web via Serper | `SERPER_API_KEY` | +| `http_request` | Make HTTP requests | — | +| `code_interpreter` | Run JavaScript in sandbox | — | +| `read_file` | Read file from URL | — | +| `grammar_check` | Check grammar/spelling | — | + +## MCP Server Support + +Connect to MCP (Model Context Protocol) servers to give agents access to external tools: + +```bash +crewform run agent.json --mcp mcp-servers.json "What tables are in my DB?" +``` + +**mcp-servers.json:** +```json +[ + { + "name": "my-db", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://..."] + } +] +``` + +Supported transports: `stdio`, `sse`, `streamable-http`. + +## Platform Integration + +Connect to the CrewForm web platform to manage agents remotely: + +```bash +# Authenticate (get your API key from Settings → API Keys) +crewform login + +# Browse your workspace +crewform agents +crewform teams + +# Download an agent to run locally +crewform pull abc-123-uuid +crewform run my-agent.json "Hello" + +# Dispatch work to a cloud agent/team +crewform push abc-123-uuid "Write a report on Q4 metrics" +crewform push abc-123-uuid --type team --wait "Research AI trends" +``` + +Credentials are saved to `~/.crewform/config.json`. Self-hosted users can set `--api-url`. + +## License + +AGPL-3.0-or-later · [CrewForm](https://crewform.tech) diff --git a/cli/package-lock.json b/cli/package-lock.json new file mode 100644 index 0000000..9aa4fa6 --- /dev/null +++ b/cli/package-lock.json @@ -0,0 +1,2066 @@ +{ + "name": "crewform", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "crewform", + "version": "0.1.0", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", + "@google/generative-ai": "^0.24.1", + "@modelcontextprotocol/sdk": "^1.29.0", + "chalk": "^5.4.1", + "commander": "^13.1.0", + "dotenv": "^17.3.1", + "openai": "^6.25.0", + "ora": "^8.2.0", + "zod": "^4.3.6" + }, + "bin": { + "crewform": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.78.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.78.0.tgz", + "integrity": "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/generative-ai": { + "version": "0.24.1", + "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", + "integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openai": { + "version": "6.37.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.37.0.tgz", + "integrity": "sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz", + "integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..11b6b5b --- /dev/null +++ b/cli/package.json @@ -0,0 +1,55 @@ +{ + "name": "crewform", + "version": "0.1.0", + "description": "Run CrewForm AI agents from the command line — scriptable, CI/CD-friendly, Ollama-first.", + "keywords": [ + "ai", + "agents", + "llm", + "cli", + "ollama", + "openai", + "anthropic", + "mcp", + "crewform" + ], + "license": "AGPL-3.0-or-later", + "author": "CrewForm", + "homepage": "https://crewform.tech", + "repository": { + "type": "git", + "url": "https://github.com/vincentgrobler/crewform.git", + "directory": "cli" + }, + "type": "module", + "bin": { + "crewform": "./dist/cli.js" + }, + "files": [ + "dist" + ], + "scripts": { + "dev": "tsx src/cli.ts", + "build": "tsc", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", + "@google/generative-ai": "^0.24.1", + "@modelcontextprotocol/sdk": "^1.29.0", + "chalk": "^5.4.1", + "commander": "^13.1.0", + "dotenv": "^17.3.1", + "openai": "^6.25.0", + "ora": "^8.2.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18" + } +} diff --git a/cli/src/apiClient.ts b/cli/src/apiClient.ts new file mode 100644 index 0000000..435e421 --- /dev/null +++ b/cli/src/apiClient.ts @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 CrewForm +// +// apiClient.ts — HTTP client for the CrewForm REST API. +// Used by: crewform login, crewform agents, crewform teams, crewform pull, crewform run --remote + +import chalk from 'chalk'; +import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'fs'; +import { resolve, join } from 'path'; +import { homedir } from 'os'; + +// ─── Config File ───────────────────────────────────────────────────────────── + +const CONFIG_DIR = join(homedir(), '.crewform'); +const CONFIG_FILE = join(CONFIG_DIR, 'config.json'); + +/** Default API base URL (CrewForm Cloud) */ +const DEFAULT_API_URL = 'https://api.crewform.tech'; + +export interface CliConfig { + /** REST API key (from CrewForm dashboard → Settings → API Keys) */ + api_key: string; + /** API base URL (override for self-hosted) */ + api_url: string; +} + +/** Load saved CLI config from ~/.crewform/config.json */ +export function loadConfig(): CliConfig | null { + if (!existsSync(CONFIG_FILE)) return null; + try { + const raw = readFileSync(CONFIG_FILE, 'utf-8'); + return JSON.parse(raw) as CliConfig; + } catch { + return null; + } +} + +/** Save CLI config to ~/.crewform/config.json */ +export function saveConfig(config: CliConfig): void { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8'); +} + +/** Delete the saved CLI config */ +export function deleteConfig(): boolean { + if (!existsSync(CONFIG_FILE)) return false; + unlinkSync(CONFIG_FILE); + return true; +} + +/** Get the config file path for display */ +export function getConfigPath(): string { + return CONFIG_FILE; +} + +// ─── API Client ────────────────────────────────────────────────────────────── + +export interface ApiClientOptions { + apiKey?: string; + apiUrl?: string; +} + +export class ApiClient { + private apiKey: string; + private baseUrl: string; + + constructor(opts?: ApiClientOptions) { + // Priority: explicit opts → env var → saved config + const config = loadConfig(); + this.apiKey = opts?.apiKey + ?? process.env.CREWFORM_API_KEY + ?? config?.api_key + ?? ''; + this.baseUrl = (opts?.apiUrl + ?? process.env.CREWFORM_API_URL + ?? config?.api_url + ?? DEFAULT_API_URL + ).replace(/\/+$/, ''); + } + + get isAuthenticated(): boolean { + return this.apiKey.length > 0; + } + + private async request( + endpoint: string, + method: string = 'GET', + body?: unknown, + params?: Record, + ): Promise { + if (!this.isAuthenticated) { + throw new Error( + 'Not authenticated. Run `crewform login` or set CREWFORM_API_KEY.', + ); + } + + const url = new URL(`${this.baseUrl}/functions/v1/${endpoint}`); + if (params) { + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + } + + const headers: Record = { + 'X-API-Key': this.apiKey, + 'X-API-Version': '2', + 'Content-Type': 'application/json', + }; + + const response = await fetch(url.toString(), { + method, + headers, + ...(body ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(30000), + }); + + if (!response.ok) { + let errorMsg = `HTTP ${response.status} ${response.statusText}`; + try { + const errorBody = await response.json() as { error?: string; message?: string }; + if (errorBody.error) errorMsg = errorBody.error; + else if (errorBody.message) errorMsg = errorBody.message; + } catch { /* ignore parse errors */ } + throw new Error(errorMsg); + } + + return await response.json() as T; + } + + // ─── Identity ──────────────────────────────────────────────────────── + + async whoami(): Promise<{ + id: string; + email: string | null; + name: string | null; + workspace_id: string; + workspace_name: string; + plan: string; + }> { + return this.request('api-me'); + } + + // ─── Agents ────────────────────────────────────────────────────────── + + async listAgents(limit: number = 50, cursor?: string): Promise<{ + items: Array>; + next_cursor: string | null; + has_more: boolean; + }> { + const params: Record = { limit: String(limit) }; + if (cursor) params.cursor = cursor; + return this.request('api-agents', 'GET', undefined, params); + } + + async getAgent(id: string): Promise> { + return this.request('api-agents', 'GET', undefined, { id }); + } + + // ─── Teams ─────────────────────────────────────────────────────────── + + async listTeams(limit: number = 50, cursor?: string): Promise<{ + items: Array>; + next_cursor: string | null; + has_more: boolean; + }> { + const params: Record = { limit: String(limit) }; + if (cursor) params.cursor = cursor; + return this.request('api-teams', 'GET', undefined, params); + } + + async getTeam(id: string): Promise> { + return this.request('api-teams', 'GET', undefined, { id }); + } + + // ─── Tasks ─────────────────────────────────────────────────────────── + + async createTask(data: { + title: string; + description: string; + priority?: string; + assigned_agent_id?: string; + assigned_team_id?: string; + }): Promise> { + return this.request('api-tasks', 'POST', data); + } + + async getTask(id: string): Promise> { + return this.request('api-tasks', 'GET', undefined, { id }); + } + + async listTasks(limit: number = 50, status?: string, cursor?: string): Promise<{ + items: Array>; + next_cursor: string | null; + has_more: boolean; + }> { + const params: Record = { limit: String(limit) }; + if (status) params.status = status; + if (cursor) params.cursor = cursor; + return this.request('api-tasks', 'GET', undefined, params); + } + + // ─── Runs ──────────────────────────────────────────────────────────── + + async createRun(teamId: string, inputTask: string): Promise> { + return this.request('api-runs', 'POST', { team_id: teamId, input_task: inputTask }); + } + + async getRun(id: string): Promise> { + return this.request('api-runs', 'GET', undefined, { id }); + } + + async listRuns(teamId?: string, limit: number = 50, cursor?: string): Promise<{ + items: Array>; + next_cursor: string | null; + has_more: boolean; + }> { + const params: Record = { limit: String(limit) }; + if (teamId) params.team_id = teamId; + if (cursor) params.cursor = cursor; + return this.request('api-runs', 'GET', undefined, params); + } + + /** + * Poll a run until it reaches a terminal state (completed/failed). + * Returns the final run data. + */ + async waitForRun( + runId: string, + onPoll?: (status: string, elapsed: number) => void, + timeoutMs: number = 300000, // 5 minutes + ): Promise> { + const start = Date.now(); + const POLL_INTERVAL = 2000; + + while (Date.now() - start < timeoutMs) { + const run = await this.getRun(runId); + const status = run.status as string; + const elapsed = Math.round((Date.now() - start) / 1000); + + if (onPoll) onPoll(status, elapsed); + + if (status === 'completed' || status === 'failed' || status === 'cancelled') { + return run; + } + + await new Promise(r => setTimeout(r, POLL_INTERVAL)); + } + + throw new Error(`Run ${runId} timed out after ${timeoutMs / 1000}s`); + } +} diff --git a/cli/src/chat.ts b/cli/src/chat.ts new file mode 100644 index 0000000..4cdc10a --- /dev/null +++ b/cli/src/chat.ts @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 CrewForm +// +// chat.ts — Interactive chat mode (REPL). + +import * as readline from 'readline'; +import chalk from 'chalk'; +import { executeAgent } from './executor.js'; +import type { AgentConfig } from './config.js'; +import type { TokenUsage } from './types.js'; + +interface ChatMessage { + role: 'user' | 'assistant'; + content: string; +} + +/** + * Start an interactive chat session with an agent. + * Maintains conversation history in memory. + */ +export async function startChatSession(agent: AgentConfig): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + }); + + const history: ChatMessage[] = []; + let totalTokens = 0; + let totalCost = 0; + + console.log(''); + console.log(chalk.bold.cyan(`💬 Chat with ${agent.name}`)); + console.log(chalk.dim(` Model: ${agent.model}`)); + console.log(chalk.dim(` Tools: ${agent.tools?.length ? agent.tools.join(', ') : 'none'}`)); + console.log(chalk.dim(` Type /help for commands, /exit to quit`)); + console.log(chalk.dim('─'.repeat(60))); + console.log(''); + + const promptUser = (): void => { + rl.question(chalk.green.bold('You: '), async (input) => { + const trimmed = input.trim(); + + if (!trimmed) { + promptUser(); + return; + } + + // Handle commands + if (trimmed.startsWith('/')) { + handleCommand(trimmed, history, totalTokens, totalCost, rl); + if (trimmed === '/exit' || trimmed === '/quit') return; + promptUser(); + return; + } + + // Add user message to history + history.push({ role: 'user', content: trimmed }); + + // Build the full prompt with conversation history + const contextPrompt = buildContextPrompt(history); + + // Stream the response + process.stdout.write(chalk.blue.bold(`\n${agent.name}: `)); + let fullResponse = ''; + + try { + const result = await executeAgent(agent, { + prompt: contextPrompt, + onStream: (delta) => { + process.stdout.write(delta); + }, + onToolCall: (name, args) => { + process.stdout.write(chalk.dim(`\n 🔧 ${name}(${JSON.stringify(args).slice(0, 80)})\n`)); + }, + }); + + fullResponse = result.result; + totalTokens += result.usage.totalTokens; + totalCost += result.usage.costEstimateUSD; + + // Add assistant response to history + history.push({ role: 'assistant', content: fullResponse }); + + // Show usage + console.log(''); + console.log(chalk.dim( + ` [${result.usage.totalTokens} tokens · $${result.usage.costEstimateUSD.toFixed(4)}` + + (result.toolCallLogs.length > 0 ? ` · ${result.toolCallLogs.length} tool call(s)` : '') + + `]`, + )); + console.log(''); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.log(''); + console.log(chalk.red(` Error: ${msg}`)); + console.log(''); + } + + promptUser(); + }); + }; + + promptUser(); + + // Wait for the readline to close + await new Promise((resolve) => { + rl.on('close', resolve); + }); +} + +/** + * Build a conversation-style prompt from chat history. + * The last message is always the current user input. + */ +function buildContextPrompt(history: ChatMessage[]): string { + if (history.length <= 1) { + return history[0]?.content ?? ''; + } + + const parts: string[] = []; + // Include the last 20 messages for context + const recentHistory = history.slice(-20); + + for (const msg of recentHistory) { + if (msg.role === 'user') { + parts.push(`User: ${msg.content}`); + } else { + parts.push(`Assistant: ${msg.content}`); + } + } + + return parts.join('\n\n'); +} + +/** + * Handle slash commands. + */ +function handleCommand( + command: string, + history: ChatMessage[], + totalTokens: number, + totalCost: number, + rl: readline.Interface, +): void { + switch (command.toLowerCase()) { + case '/exit': + case '/quit': + console.log(''); + console.log(chalk.dim('─'.repeat(60))); + console.log(chalk.dim( + `Session: ${history.length} messages · ${totalTokens} tokens · $${totalCost.toFixed(4)}`, + )); + console.log(chalk.cyan('👋 Goodbye!')); + console.log(''); + rl.close(); + break; + + case '/clear': + history.length = 0; + console.log(chalk.dim(' Conversation history cleared.')); + console.log(''); + break; + + case '/history': + if (history.length === 0) { + console.log(chalk.dim(' No messages yet.')); + } else { + console.log(chalk.dim(` ${history.length} messages in history:`)); + for (const msg of history.slice(-10)) { + const prefix = msg.role === 'user' ? chalk.green(' You: ') : chalk.blue(` Bot: `); + console.log(prefix + chalk.dim(msg.content.slice(0, 80) + (msg.content.length > 80 ? '...' : ''))); + } + } + console.log(''); + break; + + case '/stats': + console.log(chalk.dim(` Messages: ${history.length}`)); + console.log(chalk.dim(` Tokens: ${totalTokens}`)); + console.log(chalk.dim(` Cost: $${totalCost.toFixed(4)}`)); + console.log(''); + break; + + case '/help': + console.log(''); + console.log(chalk.bold(' Commands:')); + console.log(chalk.dim(' /clear — Clear conversation history')); + console.log(chalk.dim(' /history — Show recent messages')); + console.log(chalk.dim(' /stats — Show token usage & cost')); + console.log(chalk.dim(' /exit — End the chat session')); + console.log(''); + break; + + default: + console.log(chalk.dim(` Unknown command: ${command}. Type /help for commands.`)); + console.log(''); + break; + } +} diff --git a/cli/src/cli.ts b/cli/src/cli.ts new file mode 100644 index 0000000..e1cb3cb --- /dev/null +++ b/cli/src/cli.ts @@ -0,0 +1,781 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 CrewForm +// +// cli.ts — CrewForm CLI entry point. +// Usage: npx crewform run agent.json "prompt" +// npx crewform chat agent.json +// npx crewform init +// npx crewform validate agent.json + +import { Command } from 'commander'; +import chalk from 'chalk'; +import ora from 'ora'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; +import dotenv from 'dotenv'; + +import { parseConfigFile, validateConfigFile, generateAgentConfig, generateTeamConfig } from './config.js'; +import { executeAgent } from './executor.js'; +import { executePipeline } from './pipelineExecutor.js'; +import { startChatSession } from './chat.js'; +import { detectOllama, inferProvider, resolveApiKey } from './providers.js'; +import { getAvailableTools } from './tools.js'; +import { parseMcpServers } from './mcpClient.js'; +import type { McpServerConfig } from './mcpClient.js'; +import { ApiClient, saveConfig, deleteConfig, getConfigPath, loadConfig } from './apiClient.js'; + +// Load .env from current directory +dotenv.config(); + +const VERSION = '0.1.0'; + +const program = new Command(); + +program + .name('crewform') + .description('Run CrewForm AI agents from the command line') + .version(VERSION); + +// ─── run command ───────────────────────────────────────────────────────────── + +program + .command('run') + .description('Run an agent or team from a JSON config file') + .argument('', 'Path to agent or team JSON config file') + .argument('[prompt...]', 'The prompt/task to send to the agent') + .option('-i, --input ', 'Read prompt from a file instead') + .option('-o, --output ', 'Write result to a file') + .option('-q, --quiet', 'Suppress streaming output, only show final result') + .option('--no-stream', 'Disable streaming (show result at the end)') + .option('--ollama-url ', 'Custom Ollama base URL (default: http://localhost:11434)') + .option('--mcp ', 'Load MCP server configs from a JSON file') + .option('--json', 'Output result as JSON with usage metadata') + .action(async (file: string, promptParts: string[], options: { + input?: string; + output?: string; + quiet?: boolean; + stream?: boolean; + ollamaUrl?: string; + mcp?: string; + json?: boolean; + }) => { + try { + // Resolve prompt + let prompt = promptParts.join(' '); + if (options.input) { + const inputPath = resolve(options.input); + if (!existsSync(inputPath)) { + console.error(chalk.red(`Input file not found: ${inputPath}`)); + process.exit(1); + } + prompt = readFileSync(inputPath, 'utf-8'); + } + + if (!prompt && !process.stdin.isTTY) { + prompt = await readStdin(); + } + + if (!prompt) { + console.error(chalk.red('No prompt provided. Pass a prompt argument, --input file, or pipe from stdin.')); + process.exit(1); + } + + // Load MCP server configs + let mcpServers: McpServerConfig[] = []; + if (options.mcp) { + const mcpPath = resolve(options.mcp); + if (!existsSync(mcpPath)) { + console.error(chalk.red(`MCP config file not found: ${mcpPath}`)); + process.exit(1); + } + const mcpRaw = JSON.parse(readFileSync(mcpPath, 'utf-8')); + mcpServers = parseMcpServers(Array.isArray(mcpRaw) ? mcpRaw : mcpRaw.servers ?? [mcpRaw]); + } + + // Parse config + const configPath = resolve(file); + const config = parseConfigFile(configPath); + + if (config.type === 'team' && config.team) { + // ── Team / Pipeline Execution ── + await runTeam(config.team, prompt, options, mcpServers); + } else if (config.type === 'agent' && config.agent) { + // ── Single Agent Execution ── + await runAgent(config.agent, prompt, options, mcpServers); + } else { + console.error(chalk.red('Invalid config: no agent or team found.')); + process.exit(1); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(chalk.red(`\n✗ Error: ${msg}\n`)); + process.exit(1); + } + }); + +// ─── chat command ──────────────────────────────────────────────────────────── + +program + .command('chat') + .description('Start an interactive chat session with an agent') + .argument('', 'Path to agent JSON config file') + .option('--ollama-url ', 'Custom Ollama base URL') + .action(async (file: string) => { + try { + const configPath = resolve(file); + const config = parseConfigFile(configPath); + + if (config.type !== 'agent' || !config.agent) { + console.error(chalk.red('Chat mode requires an agent config file, not a team.')); + process.exit(1); + } + + await startChatSession(config.agent); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(chalk.red(`\n✗ Error: ${msg}\n`)); + process.exit(1); + } + }); + +// ─── init command ──────────────────────────────────────────────────────────── + +program + .command('init') + .description('Create a starter agent or team config file') + .option('-t, --team', 'Generate a pipeline team config instead of an agent') + .option('-n, --name ', 'Agent/team name') + .option('-m, --model ', 'Model to use (default: llama3.3)') + .option('-o, --output ', 'Output file path') + .action(async (options: { team?: boolean; name?: string; model?: string; output?: string }) => { + try { + const isTeam = options.team ?? false; + const defaultFile = isTeam ? 'team.json' : 'agent.json'; + const outputPath = resolve(options.output ?? defaultFile); + + if (existsSync(outputPath)) { + console.error(chalk.red(`File already exists: ${outputPath}`)); + console.error(chalk.dim('Use --output to specify a different path.')); + process.exit(1); + } + + // Detect Ollama for helpful messaging + const spinner = ora({ text: 'Checking for Ollama...', color: 'cyan' }).start(); + const ollama = await detectOllama(); + spinner.stop(); + + let content: string; + if (isTeam) { + content = generateTeamConfig(); + } else { + const model = options.model ?? (ollama.available && ollama.models.length > 0 + ? ollama.models[0] + : 'llama3.3'); + content = generateAgentConfig({ + name: options.name, + model, + }); + } + + writeFileSync(outputPath, content, 'utf-8'); + + console.log(''); + console.log(chalk.green(`✓ Created ${outputPath}`)); + console.log(''); + + if (ollama.available) { + console.log(chalk.dim(` Ollama detected with ${ollama.models.length} model(s):`)); + for (const model of ollama.models.slice(0, 5)) { + console.log(chalk.dim(` • ${model}`)); + } + if (ollama.models.length > 5) { + console.log(chalk.dim(` ... and ${ollama.models.length - 5} more`)); + } + } else { + console.log(chalk.dim(' Ollama not detected. Install it at https://ollama.com')); + console.log(chalk.dim(' Or set an API key: OPENAI_API_KEY=sk-... crewform run agent.json "hello"')); + } + + console.log(''); + console.log(chalk.dim(` Next steps:`)); + console.log(chalk.dim(` 1. Edit ${defaultFile} to customise your agent`)); + console.log(chalk.dim(` 2. crewform run ${defaultFile} "your prompt here"`)); + console.log(chalk.dim(` 3. crewform chat ${defaultFile}`)); + console.log(''); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(chalk.red(`\n✗ Error: ${msg}\n`)); + process.exit(1); + } + }); + +// ─── validate command ──────────────────────────────────────────────────────── + +program + .command('validate') + .description('Validate a CrewForm config file') + .argument('', 'Path to config file') + .action((file: string) => { + const configPath = resolve(file); + const result = validateConfigFile(configPath); + + if (result.valid) { + const config = parseConfigFile(configPath); + console.log(''); + console.log(chalk.green(`✓ Valid ${config.type} config: ${configPath}`)); + if (config.type === 'agent' && config.agent) { + console.log(chalk.dim(` Name: ${config.agent.name}`)); + console.log(chalk.dim(` Model: ${config.agent.model}`)); + console.log(chalk.dim(` Tools: ${config.agent.tools?.length ? config.agent.tools.join(', ') : 'none'}`)); + } else if (config.type === 'team' && config.team) { + console.log(chalk.dim(` Name: ${config.team.name}`)); + console.log(chalk.dim(` Mode: ${config.team.mode}`)); + console.log(chalk.dim(` Agents: ${config.team.agents.length}`)); + } + console.log(''); + } else { + console.error(chalk.red(`✗ Invalid config: ${configPath}`)); + for (const err of result.errors) { + console.error(chalk.red(` ${err}`)); + } + console.log(''); + process.exit(1); + } + }); + +// ─── tools command ─────────────────────────────────────────────────────────── + +program + .command('tools') + .description('List available built-in tools') + .action(() => { + const tools = getAvailableTools(); + console.log(''); + console.log(chalk.bold('Available tools:')); + console.log(''); + for (const tool of tools) { + console.log(chalk.cyan(` • ${tool}`)); + } + console.log(''); + console.log(chalk.dim('Add tools to your agent config: "tools": ["web_search", "code_interpreter"]')); + console.log(chalk.dim('Note: web_search requires SERPER_API_KEY environment variable (serper.dev)')); + console.log(''); + }); + +// ─── login command ─────────────────────────────────────────────────────────── + +program + .command('login') + .description('Authenticate with the CrewForm platform using an API key') + .option('--api-key ', 'API key (or set CREWFORM_API_KEY env var)') + .option('--api-url ', 'Custom API URL (for self-hosted instances)') + .action(async (options: { apiKey?: string; apiUrl?: string }) => { + try { + let apiKey = options.apiKey ?? process.env.CREWFORM_API_KEY; + + if (!apiKey) { + // Interactive prompt + const readline = await import('readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + apiKey = await new Promise((res) => { + rl.question(chalk.cyan('Enter your API key: '), (answer: string) => { + rl.close(); + res(answer.trim()); + }); + }); + } + + if (!apiKey) { + console.error(chalk.red('No API key provided.')); + process.exit(1); + } + + const spinner = ora({ text: 'Verifying API key...', color: 'cyan' }).start(); + const client = new ApiClient({ apiKey, apiUrl: options.apiUrl }); + const me = await client.whoami(); + spinner.stop(); + + // Save config + saveConfig({ + api_key: apiKey, + api_url: options.apiUrl ?? 'https://api.crewform.tech', + }); + + console.log(''); + console.log(chalk.green('✓ Logged in successfully')); + console.log(chalk.dim(` User: ${me.name ?? me.email ?? me.id}`)); + console.log(chalk.dim(` Workspace: ${me.workspace_name}`)); + console.log(chalk.dim(` Plan: ${me.plan}`)); + console.log(chalk.dim(` Config: ${getConfigPath()}`)); + console.log(''); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(chalk.red(`\n✗ Login failed: ${msg}\n`)); + process.exit(1); + } + }); + +// ─── logout command ────────────────────────────────────────────────────────── + +program + .command('logout') + .description('Remove saved API credentials') + .action(() => { + if (deleteConfig()) { + console.log(chalk.green('\n✓ Logged out. Credentials removed.\n')); + } else { + console.log(chalk.dim('\nNo saved credentials found.\n')); + } + }); + +// ─── whoami command ────────────────────────────────────────────────────────── + +program + .command('whoami') + .description('Show the currently authenticated user and workspace') + .action(async () => { + try { + const client = new ApiClient(); + if (!client.isAuthenticated) { + console.error(chalk.red('\nNot logged in. Run `crewform login` first.\n')); + process.exit(1); + } + const spinner = ora({ text: 'Fetching...', color: 'cyan' }).start(); + const me = await client.whoami(); + spinner.stop(); + + console.log(''); + console.log(chalk.bold('Authenticated as:')); + console.log(chalk.dim(` Name: ${me.name ?? '(not set)'}`)); + console.log(chalk.dim(` Email: ${me.email ?? '(not set)'}`)); + console.log(chalk.dim(` Workspace: ${me.workspace_name} (${me.workspace_id})`)); + console.log(chalk.dim(` Plan: ${me.plan}`)); + console.log(''); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(chalk.red(`\n✗ Error: ${msg}\n`)); + process.exit(1); + } + }); + +// ─── agents command ────────────────────────────────────────────────────────── + +program + .command('agents') + .description('List agents from your CrewForm workspace') + .option('--limit ', 'Max agents to show', '20') + .option('--json', 'Output as JSON') + .action(async (options: { limit: string; json?: boolean }) => { + try { + const client = new ApiClient(); + if (!client.isAuthenticated) { + console.error(chalk.red('\nNot logged in. Run `crewform login` first.\n')); + process.exit(1); + } + const spinner = ora({ text: 'Fetching agents...', color: 'cyan' }).start(); + const result = await client.listAgents(parseInt(options.limit, 10)); + spinner.stop(); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(''); + console.log(chalk.bold(`Agents (${result.items.length}${result.has_more ? '+' : ''}):`)); + console.log(''); + for (const agent of result.items) { + const a = agent as { id: string; name: string; model: string; status?: string }; + const statusIcon = a.status === 'busy' ? '🔄' : a.status === 'offline' ? '⏸' : '🟢'; + console.log(` ${statusIcon} ${chalk.cyan(a.name)} ${chalk.dim(`(${a.model})`)}`); + console.log(chalk.dim(` ID: ${a.id}`)); + } + if (result.has_more) { + console.log(chalk.dim(`\n ... and more. Use --limit to see more.`)); + } + console.log(''); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(chalk.red(`\n✗ Error: ${msg}\n`)); + process.exit(1); + } + }); + +// ─── teams command ─────────────────────────────────────────────────────────── + +program + .command('teams') + .description('List teams from your CrewForm workspace') + .option('--limit ', 'Max teams to show', '20') + .option('--json', 'Output as JSON') + .action(async (options: { limit: string; json?: boolean }) => { + try { + const client = new ApiClient(); + if (!client.isAuthenticated) { + console.error(chalk.red('\nNot logged in. Run `crewform login` first.\n')); + process.exit(1); + } + const spinner = ora({ text: 'Fetching teams...', color: 'cyan' }).start(); + const result = await client.listTeams(parseInt(options.limit, 10)); + spinner.stop(); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(''); + console.log(chalk.bold(`Teams (${result.items.length}${result.has_more ? '+' : ''}):`)); + console.log(''); + for (const team of result.items) { + const t = team as { id: string; name: string; mode: string; team_members?: unknown[] }; + const members = Array.isArray(t.team_members) ? t.team_members.length : 0; + console.log(` 👥 ${chalk.cyan(t.name)} ${chalk.dim(`(${t.mode} · ${members} agents)`)}`); + console.log(chalk.dim(` ID: ${t.id}`)); + } + if (result.has_more) { + console.log(chalk.dim(`\n ... and more. Use --limit to see more.`)); + } + console.log(''); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(chalk.red(`\n✗ Error: ${msg}\n`)); + process.exit(1); + } + }); + +// ─── pull command ──────────────────────────────────────────────────────────── + +program + .command('pull') + .description('Download an agent or team config from CrewForm to a local JSON file') + .argument('', 'Agent or team UUID to download') + .option('-t, --type ', 'Resource type: agent or team', 'agent') + .option('-o, --output ', 'Output file path (default: .json)') + .action(async (id: string, options: { type: string; output?: string }) => { + try { + const client = new ApiClient(); + if (!client.isAuthenticated) { + console.error(chalk.red('\nNot logged in. Run `crewform login` first.\n')); + process.exit(1); + } + const spinner = ora({ text: `Pulling ${options.type}...`, color: 'cyan' }).start(); + + if (options.type === 'team') { + const team = await client.getTeam(id); + spinner.stop(); + const name = (team.name as string) || 'team'; + const outFile = resolve(options.output ?? `${slugify(name)}.json`); + // Wrap in crewform-export format + const exportData = { + format: 'crewform-export', + version: 1, + exported_at: new Date().toISOString(), + type: 'team', + data: team, + }; + writeFileSync(outFile, JSON.stringify(exportData, null, 2), 'utf-8'); + console.log(chalk.green(`\n✓ Pulled team "${name}" → ${outFile}\n`)); + } else { + const agent = await client.getAgent(id); + spinner.stop(); + const name = (agent.name as string) || 'agent'; + const outFile = resolve(options.output ?? `${slugify(name)}.json`); + const exportData = { + format: 'crewform-export', + version: 1, + exported_at: new Date().toISOString(), + type: 'agent', + data: agent, + }; + writeFileSync(outFile, JSON.stringify(exportData, null, 2), 'utf-8'); + console.log(chalk.green(`\n✓ Pulled agent "${name}" → ${outFile}\n`)); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(chalk.red(`\n✗ Error: ${msg}\n`)); + process.exit(1); + } + }); + +// ─── push command ──────────────────────────────────────────────────────────── + +program + .command('push') + .description('Create a task on the platform and dispatch it to an agent or team') + .argument('', 'Agent or team UUID to dispatch to') + .argument('[prompt...]', 'The task description') + .option('-t, --type ', 'Resource type: agent or team', 'agent') + .option('--wait', 'Wait for the run/task to complete (team runs only)') + .option('--json', 'Output result as JSON') + .action(async (id: string, promptParts: string[], options: { type: string; wait?: boolean; json?: boolean }) => { + try { + const client = new ApiClient(); + if (!client.isAuthenticated) { + console.error(chalk.red('\nNot logged in. Run `crewform login` first.\n')); + process.exit(1); + } + + const prompt = promptParts.join(' '); + if (!prompt) { + console.error(chalk.red('No prompt provided.')); + process.exit(1); + } + + if (options.type === 'team') { + const spinner = ora({ text: 'Creating team run...', color: 'cyan' }).start(); + const run = await client.createRun(id, prompt); + const runId = run.id as string; + spinner.succeed(chalk.dim(`Run created: ${runId}`)); + + if (options.wait) { + const pollSpinner = ora({ text: 'Waiting for completion...', color: 'cyan' }).start(); + const finalRun = await client.waitForRun(runId, (status, elapsed) => { + pollSpinner.text = `Status: ${status} (${elapsed}s)`; + }); + pollSpinner.stop(); + + if (options.json) { + console.log(JSON.stringify(finalRun, null, 2)); + } else { + const status = finalRun.status as string; + const icon = status === 'completed' ? '✅' : '❌'; + console.log(`\n${icon} Run ${status}: ${runId}`); + if (finalRun.output) { + console.log(chalk.dim('─'.repeat(60))); + console.log(finalRun.output as string); + } + } + } else if (options.json) { + console.log(JSON.stringify(run, null, 2)); + } else { + console.log(chalk.dim(`\nRun dispatched. Check status: crewform runs ${runId}\n`)); + } + } else { + const spinner = ora({ text: 'Creating task...', color: 'cyan' }).start(); + const task = await client.createTask({ + title: prompt.slice(0, 100), + description: prompt, + assigned_agent_id: id, + }); + spinner.stop(); + + if (options.json) { + console.log(JSON.stringify(task, null, 2)); + } else { + console.log(chalk.green(`\n✓ Task dispatched: ${task.id}`)); + console.log(chalk.dim(` Status: ${task.status}`)); + console.log(chalk.dim(` Agent: ${id}\n`)); + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(chalk.red(`\n✗ Error: ${msg}\n`)); + process.exit(1); + } + }); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function slugify(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); +} + +function readStdin(): Promise { + return new Promise((resolve) => { + let data = ''; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk) => { data += chunk; }); + process.stdin.on('end', () => { resolve(data.trim()); }); + setTimeout(() => { resolve(data.trim()); }, 100); + }); +} + +// ─── Single Agent Runner ───────────────────────────────────────────────────── + +import type { AgentConfig, TeamConfig } from './config.js'; + +async function runAgent( + agent: AgentConfig, + prompt: string, + options: { quiet?: boolean; json?: boolean; stream?: boolean; ollamaUrl?: string; output?: string }, + mcpServers: McpServerConfig[], +): Promise { + // Show banner + if (!options.quiet && !options.json) { + console.log(''); + console.log(chalk.bold.cyan(`🤖 ${agent.name}`)); + const provider = agent.provider ?? inferProvider(agent.model) ?? 'ollama'; + console.log(chalk.dim(` ${agent.model} via ${provider}`)); + if (agent.tools?.length) { + console.log(chalk.dim(` Tools: ${agent.tools.join(', ')}`)); + } + if (mcpServers.length > 0) { + console.log(chalk.dim(` MCP: ${mcpServers.map(s => s.name).join(', ')}`)); + } + console.log(chalk.dim('─'.repeat(60))); + console.log(''); + } + + const spinner = !options.quiet && !options.json && options.stream !== false + ? null + : (!options.json ? ora({ text: 'Thinking...', color: 'cyan' }).start() : null); + + const result = await executeAgent(agent, { + prompt, + ollamaBaseUrl: options.ollamaUrl, + mcpServers: mcpServers.length > 0 ? mcpServers : undefined, + onStream: (!options.quiet && !options.json && options.stream !== false) + ? (delta) => { process.stdout.write(delta); } + : undefined, + onToolCall: (!options.quiet && !options.json) + ? (name, args) => { + console.log(chalk.dim(`\n 🔧 ${name}(${JSON.stringify(args).slice(0, 100)})`)); + } + : undefined, + }); + + if (spinner) spinner.stop(); + + // Output + if (options.json) { + const jsonOutput = { + agent: agent.name, + model: agent.model, + result: result.result, + usage: result.usage, + toolCalls: result.toolCallLogs, + }; + console.log(JSON.stringify(jsonOutput, null, 2)); + } else if (options.stream === false || options.quiet) { + console.log(result.result); + } else { + console.log(''); + } + + // Usage summary + if (!options.quiet && !options.json) { + console.log(''); + console.log(chalk.dim('─'.repeat(60))); + console.log(chalk.dim( + ` ${result.usage.totalTokens} tokens · ` + + `$${result.usage.costEstimateUSD.toFixed(4)}` + + (result.toolCallLogs.length > 0 ? ` · ${result.toolCallLogs.length} tool call(s)` : ''), + )); + } + + if (options.output) { + writeFileSync(resolve(options.output), result.result, 'utf-8'); + if (!options.quiet && !options.json) { + console.log(chalk.green(` ✓ Saved to ${options.output}`)); + } + } + + console.log(''); +} + +// ─── Team / Pipeline Runner ───────────────────────────────────────────────── + +async function runTeam( + team: TeamConfig, + prompt: string, + options: { quiet?: boolean; json?: boolean; stream?: boolean; ollamaUrl?: string; output?: string }, + mcpServers: McpServerConfig[], +): Promise { + const totalSteps = team.config.steps?.length ?? 0; + + // Show banner + if (!options.quiet && !options.json) { + console.log(''); + console.log(chalk.bold.cyan(`👥 ${team.name}`)); + console.log(chalk.dim(` Mode: ${team.mode} · ${totalSteps} step(s) · ${team.agents.length} agent(s)`)); + if (mcpServers.length > 0) { + console.log(chalk.dim(` MCP: ${mcpServers.map(s => s.name).join(', ')}`)); + } + console.log(chalk.dim('─'.repeat(60))); + console.log(''); + } + + const stepStartTime = Date.now(); + + const result = await executePipeline(team, prompt, { + ollamaBaseUrl: options.ollamaUrl, + onStepStart: (!options.quiet && !options.json) + ? (idx, stepName, agentName) => { + const stepNum = `${idx + 1}/${totalSteps}`; + console.log(chalk.cyan(` ▶ Step ${stepNum}: ${stepName}`)); + console.log(chalk.dim(` Agent: ${agentName}`)); + } + : undefined, + onStepComplete: (!options.quiet && !options.json) + ? (idx, stepName, stepResult) => { + const icon = stepResult.status === 'completed' ? '✅' + : stepResult.status === 'skipped' ? '⏭️' : '❌'; + const tokenInfo = `${stepResult.usage.totalTokens} tokens · $${stepResult.usage.costEstimateUSD.toFixed(4)}`; + const toolInfo = stepResult.toolCallLogs.length > 0 ? ` · ${stepResult.toolCallLogs.length} tool(s)` : ''; + console.log(chalk.dim(` ${icon} ${tokenInfo}${toolInfo}`)); + if (stepResult.error) { + console.log(chalk.dim(chalk.yellow(` ⚠ ${stepResult.error}`))); + } + console.log(''); + } + : undefined, + onStream: (!options.quiet && !options.json && options.stream !== false) + ? (delta) => { process.stdout.write(delta); } + : undefined, + onToolCall: (!options.quiet && !options.json) + ? (name, args) => { + console.log(chalk.dim(` 🔧 ${name}(${JSON.stringify(args).slice(0, 80)})`)); + } + : undefined, + }); + + const elapsed = ((Date.now() - stepStartTime) / 1000).toFixed(1); + + // Output + if (options.json) { + const jsonOutput = { + team: team.name, + mode: team.mode, + steps: result.steps.map(s => ({ + step: s.stepName, + agent: s.agentName, + status: s.status, + usage: s.usage, + toolCalls: s.toolCallLogs, + error: s.error, + })), + result: result.output, + usage: result.usage, + }; + console.log(JSON.stringify(jsonOutput, null, 2)); + } else if (options.stream === false || options.quiet) { + console.log(result.output); + } else { + console.log(''); + } + + // Usage summary + if (!options.quiet && !options.json) { + const completedSteps = result.steps.filter(s => s.status === 'completed').length; + console.log(chalk.dim('─'.repeat(60))); + console.log(chalk.dim( + ` Pipeline complete: ${completedSteps}/${totalSteps} steps · ` + + `${result.usage.totalTokens} tokens · $${result.usage.costEstimateUSD.toFixed(4)} · ${elapsed}s`, + )); + } + + if (options.output) { + writeFileSync(resolve(options.output), result.output, 'utf-8'); + if (!options.quiet && !options.json) { + console.log(chalk.green(` ✓ Saved to ${options.output}`)); + } + } + + console.log(''); +} + +// ─── Parse & Run ───────────────────────────────────────────────────────────── + +program.parse(); diff --git a/cli/src/config.ts b/cli/src/config.ts new file mode 100644 index 0000000..f61fded --- /dev/null +++ b/cli/src/config.ts @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 CrewForm +// +// config.ts — Parse and validate agent/team JSON config files. + +import { z } from 'zod'; +import { readFileSync, existsSync } from 'fs'; + +// ─── Schemas ───────────────────────────────────────────────────────────────── + +/** Voice profile schema */ +const voiceProfileSchema = z.object({ + tone: z.string().optional(), + custom_instructions: z.string().optional(), + output_format_hints: z.string().optional(), +}).optional().nullable(); + +/** Simplified inline agent config (quick usage) */ +const inlineAgentSchema = z.object({ + name: z.string().default('CLI Agent'), + description: z.string().default(''), + model: z.string().default('llama3.3'), + fallback_model: z.string().nullable().default(null), + provider: z.string().nullable().default(null), + system_prompt: z.string().default('You are a helpful AI assistant.'), + temperature: z.number().min(0).max(2).default(0.7), + max_tokens: z.number().nullable().default(null), + tools: z.array(z.string()).default([]), + voice_profile: voiceProfileSchema.default(null), + config: z.record(z.string(), z.unknown()).default({}), + tags: z.array(z.string()).default([]), +}); + +/** crewform-export v1 agent data schema */ +const agentExportSchema = z.object({ + name: z.string(), + description: z.string().default(''), + model: z.string(), + fallback_model: z.string().nullable().default(null), + provider: z.string().nullable().default(null), + system_prompt: z.string(), + temperature: z.number().default(0.7), + max_tokens: z.number().nullable().default(null), + tools: z.array(z.string()).default([]), + voice_profile: voiceProfileSchema.default(null), + config: z.record(z.string(), z.unknown()).default({}), + tags: z.array(z.string()).default([]), +}); + +/** Pipeline step schema */ +const pipelineStepSchema = z.object({ + agent_id: z.string().optional(), + agent_ref: z.string().optional(), // For inline team format — references agent by name + step_name: z.string(), + instructions: z.string().default(''), + expected_output: z.string().default(''), + on_failure: z.enum(['retry', 'stop', 'skip']).default('stop'), + max_retries: z.number().default(1), + type: z.enum(['sequential', 'fan_out']).default('sequential'), + parallel_agents: z.array(z.string()).optional(), + merge_agent_id: z.string().optional(), + merge_agent_ref: z.string().optional(), + fan_out_failure: z.enum(['fail_fast', 'continue_on_partial']).default('fail_fast'), + merge_instructions: z.string().optional(), +}); + +/** Team export schema */ +const teamExportSchema = z.object({ + name: z.string(), + description: z.string().default(''), + mode: z.enum(['pipeline', 'orchestrator', 'collaboration']).default('pipeline'), + config: z.object({ + steps: z.array(pipelineStepSchema).optional(), + auto_handoff: z.boolean().default(true), + }).passthrough(), + agents: z.array(z.object({ + ref_id: z.string(), + role: z.string().default('worker'), + position: z.number().default(0), + agent: agentExportSchema, + })), +}); + +/** Full crewform-export wrapper */ +const exportWrapperSchema = z.object({ + format: z.literal('crewform-export'), + version: z.number(), + exported_at: z.string().optional(), + type: z.enum(['agent', 'team']), + data: z.unknown(), +}); + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type AgentConfig = z.infer; +export type TeamConfig = z.infer; +export type PipelineStep = z.infer; + +export interface ParsedConfig { + type: 'agent' | 'team'; + agent?: AgentConfig; + team?: TeamConfig; +} + +// ─── Parser ────────────────────────────────────────────────────────────────── + +/** + * Parse a JSON config file. Supports three formats: + * 1. crewform-export v1 (agent or team) + * 2. Simplified inline agent config (just name, model, system_prompt) + * 3. Simplified inline team config (agents array + pipeline steps) + */ +export function parseConfigFile(filePath: string): ParsedConfig { + if (!existsSync(filePath)) { + throw new Error(`Config file not found: ${filePath}`); + } + + const raw = readFileSync(filePath, 'utf-8'); + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + throw new Error(`Invalid JSON in ${filePath}`); + } + + const obj = json as Record; + + // Check if it's a crewform-export wrapper + if (obj.format === 'crewform-export') { + const wrapper = exportWrapperSchema.parse(obj); + if (wrapper.version > 1) { + throw new Error(`Unsupported export version: ${wrapper.version}. Please update the CLI.`); + } + + if (wrapper.type === 'agent') { + const agent = agentExportSchema.parse(wrapper.data); + return { type: 'agent', agent }; + } else { + const team = teamExportSchema.parse(wrapper.data); + return { type: 'team', team }; + } + } + + // Check if it looks like a team config (has 'agents' array and 'mode' or 'config.steps') + if (Array.isArray(obj.agents) && (obj.mode || (obj.config && typeof obj.config === 'object'))) { + const team = teamExportSchema.parse(obj); + return { type: 'team', team }; + } + + // Otherwise treat as inline agent config + const agent = inlineAgentSchema.parse(obj); + return { type: 'agent', agent }; +} + +/** + * Validate a config file and return any errors. + */ +export function validateConfigFile(filePath: string): { valid: boolean; errors: string[] } { + try { + parseConfigFile(filePath); + return { valid: true, errors: [] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { valid: false, errors: [msg] }; + } +} + +// ─── Scaffolding ───────────────────────────────────────────────────────────── + +/** Generate a starter agent config JSON */ +export function generateAgentConfig(options?: { + name?: string; + model?: string; + provider?: string; +}): string { + const config = { + name: options?.name ?? 'My Agent', + model: options?.model ?? 'llama3.3', + provider: options?.provider ?? null, + system_prompt: 'You are a helpful AI assistant. Be concise and accurate.', + temperature: 0.7, + max_tokens: null, + tools: [], + voice_profile: null, + }; + return JSON.stringify(config, null, 2); +} + +/** Generate a starter pipeline team config JSON */ +export function generateTeamConfig(): string { + const config = { + name: 'My Pipeline', + description: 'A two-agent pipeline', + mode: 'pipeline', + config: { + steps: [ + { + agent_ref: 'researcher', + step_name: 'Research', + instructions: 'Research the topic thoroughly.', + expected_output: 'A comprehensive research summary.', + on_failure: 'stop', + }, + { + agent_ref: 'writer', + step_name: 'Write', + instructions: 'Write a polished article based on the research.', + expected_output: 'A well-written article.', + on_failure: 'stop', + }, + ], + auto_handoff: true, + }, + agents: [ + { + ref_id: 'researcher', + role: 'worker', + position: 0, + agent: { + name: 'Researcher', + description: 'Researches topics thoroughly', + model: 'llama3.3', + system_prompt: 'You are an expert researcher. Provide detailed, well-sourced analysis.', + temperature: 0.5, + tools: ['web_search'], + }, + }, + { + ref_id: 'writer', + role: 'worker', + position: 1, + agent: { + name: 'Writer', + description: 'Writes polished content', + model: 'llama3.3', + system_prompt: 'You are a skilled writer. Create clear, engaging content from research material.', + temperature: 0.8, + tools: [], + }, + }, + ], + }; + return JSON.stringify(config, null, 2); +} diff --git a/cli/src/executor.ts b/cli/src/executor.ts new file mode 100644 index 0000000..47f19b4 --- /dev/null +++ b/cli/src/executor.ts @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 CrewForm +// +// executor.ts — Local agent execution engine. +// Simplified version of task-runner/src/executor.ts — no Supabase dependency. + +import OpenAI from 'openai'; +import chalk from 'chalk'; +import { executeAnthropic } from './providers/anthropic.js'; +import { executeOpenAI } from './providers/openai.js'; +import { executeGoogle } from './providers/google.js'; +import { inferProvider, resolveApiKey, normaliseModelName, BASE_URL_MAP, NATIVE_SDK_PROVIDERS } from './providers.js'; +import { executeWithToolLoop, getToolDefinitions } from './tools.js'; +import type { ToolDefinition } from './tools.js'; +import type { AgentConfig } from './config.js'; +import type { TokenUsage, ExecutionResult } from './types.js'; +import type { McpServerConfig } from './mcpClient.js'; + +// ─── Streaming callback type ──────────────────────────────────────────────── + +export type OnStreamChunk = (delta: string, fullText: string) => void; + +// ─── Execute Agent ─────────────────────────────────────────────────────────── + +export interface ExecuteOptions { + /** Override the prompt (used by chat mode to pass full conversation) */ + prompt: string; + /** Stream each text chunk to a callback */ + onStream?: OnStreamChunk; + /** Callback when a tool is called */ + onToolCall?: (name: string, args: Record) => void; + /** Override the provider's API key */ + apiKey?: string; + /** Override the Ollama base URL */ + ollamaBaseUrl?: string; + /** MCP server configurations to connect to */ + mcpServers?: McpServerConfig[]; +} + +/** + * Execute a single agent with the given prompt. + * Handles provider routing, API key resolution, tool-use, and streaming. + */ +export async function executeAgent( + agent: AgentConfig, + options: ExecuteOptions, +): Promise { + // 1. Resolve provider + const provider = (agent.provider ?? inferProvider(agent.model) ?? 'ollama').toLowerCase(); + + // 2. Resolve API key + const apiKey = options.apiKey ?? resolveApiKey(provider); + if (!apiKey) { + const envHint = provider === 'openai' ? 'OPENAI_API_KEY' + : provider === 'anthropic' ? 'ANTHROPIC_API_KEY' + : provider === 'google' ? 'GOOGLE_API_KEY' + : `${provider.toUpperCase()}_API_KEY`; + throw new Error( + `No API key found for provider "${provider}". ` + + `Set the ${envHint} environment variable or add it to .env`, + ); + } + + // 3. Build system prompt with voice profile + let systemPrompt = agent.system_prompt || 'You are a helpful AI assistant.'; + if (agent.voice_profile) { + const vp = agent.voice_profile; + const voiceSections: string[] = []; + if (vp.tone) voiceSections.push(`Tone: ${vp.tone}`); + if (vp.custom_instructions) voiceSections.push(vp.custom_instructions); + if (vp.output_format_hints) voiceSections.push(`Output format: ${vp.output_format_hints}`); + if (voiceSections.length > 0) { + systemPrompt += `\n\n## Voice & Tone\n${voiceSections.join('\n')}`; + } + } + + const userPrompt = options.prompt; + + // 4. Stream handler — bridges provider's accumulated text to our delta-based callback + let lastStreamLength = 0; + const onChunk = async (fullText: string): Promise => { + if (options.onStream && fullText.length > lastStreamLength) { + const delta = fullText.slice(lastStreamLength); + lastStreamLength = fullText.length; + options.onStream(delta, fullText); + } + }; + + // 5. Check for tools + const allToolNames = agent.tools ?? []; + const toolNames = allToolNames.filter(t => + // Support built-in tools and MCP tools in CLI mode + !t.startsWith('custom:') && + t !== 'knowledge_search' && t !== 'a2a_delegate', + ); + const hasMcpTools = allToolNames.some(t => t.startsWith('mcp:')); + + // Discover MCP tools if configured + let mcpToolDefs: ToolDefinition[] = []; + const mcpServers = options.mcpServers; + if ((hasMcpTools || (mcpServers && mcpServers.length > 0)) && mcpServers) { + const { discoverTools: discoverMcpTools } = await import('./mcpClient.js'); + for (const server of mcpServers) { + try { + const discovered = await discoverMcpTools(server); + mcpToolDefs.push(...discovered.definitions); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // Non-fatal — log and continue + console.error(chalk.dim(` ⚠ MCP ${server.name}: ${msg}`)); + } + } + } + + const hasTools = toolNames.filter(t => !t.startsWith('mcp:')).length > 0 || mcpToolDefs.length > 0; + + // Resolve Serper API key for web_search + const serperApiKey = toolNames.includes('web_search') + ? resolveApiKey('serper') ?? undefined + : undefined; + + // 6. Execute + let result: ExecutionResult; + + try { + if (hasTools) { + // Tool-use mode (non-streaming, uses OpenAI SDK for all providers) + result = await executeWithTools( + provider, apiKey, agent.model, systemPrompt, userPrompt, + toolNames, onChunk, agent.max_tokens, serperApiKey, + options.onToolCall, options.ollamaBaseUrl, + mcpToolDefs, mcpServers, + ); + } else { + + // Direct streaming mode + const effectiveModel = normaliseModelName(agent.model, provider); + + let directResult: { result: string; usage: TokenUsage }; + + if (provider === 'anthropic') { + directResult = await executeAnthropic(apiKey, effectiveModel, systemPrompt, userPrompt, onChunk, agent.max_tokens); + } else if (provider === 'google') { + directResult = await executeGoogle(apiKey, effectiveModel, systemPrompt, userPrompt, onChunk, agent.max_tokens); + } else { + // OpenAI-compatible (all other providers) + let baseURL = BASE_URL_MAP[provider]; + if (provider === 'ollama' && options.ollamaBaseUrl) { + const cleanUrl = options.ollamaBaseUrl.replace(/\/+$/, ''); + baseURL = cleanUrl.endsWith('/v1') ? cleanUrl : `${cleanUrl}/v1`; + } + directResult = await executeOpenAI(apiKey, effectiveModel, systemPrompt, userPrompt, onChunk, baseURL, agent.max_tokens); + } + + result = { ...directResult, toolCallLogs: [] }; + } + } finally { + // Disconnect MCP servers + if (mcpServers && mcpServers.length > 0) { + const { disconnectAll } = await import('./mcpClient.js'); + await disconnectAll(); + } + } + + return result; +} + +// ─── Tool-Use Execution ───────────────────────────────────────────────────── + +async function executeWithTools( + provider: string, + apiKey: string, + model: string, + systemPrompt: string, + userPrompt: string, + toolNames: string[], + onProgressUpdate: (text: string) => Promise, + maxTokens?: number | null, + serperApiKey?: string, + onToolCall?: (name: string, args: Record) => void, + ollamaBaseUrl?: string, + mcpToolDefs?: ToolDefinition[], + mcpServers?: McpServerConfig[], +): Promise { + let baseURL = BASE_URL_MAP[provider]; + if (provider === 'ollama' && ollamaBaseUrl) { + const cleanUrl = ollamaBaseUrl.replace(/\/+$/, ''); + baseURL = cleanUrl.endsWith('/v1') ? cleanUrl : `${cleanUrl}/v1`; + } + + const effectiveModel = normaliseModelName(model, provider); + const openai = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) }); + + const toolLoopResult = await executeWithToolLoop( + async (messages, toolDefs) => { + const openaiMessages: OpenAI.ChatCompletionMessageParam[] = messages.map(m => { + if (m.role === 'system') return { role: 'system' as const, content: m.content ?? '' }; + if (m.role === 'user') return { role: 'user' as const, content: m.content ?? '' }; + if (m.role === 'tool') return { role: 'tool' as const, content: m.content ?? '', tool_call_id: m.tool_call_id ?? '' }; + const assistantMsg: OpenAI.ChatCompletionAssistantMessageParam = { + role: 'assistant' as const, + content: m.content ?? '', + }; + if (m.tool_calls && m.tool_calls.length > 0) { + assistantMsg.tool_calls = m.tool_calls.map(tc => ({ + id: tc.id, + type: 'function' as const, + function: { name: tc.function.name, arguments: tc.function.arguments }, + })); + } + return assistantMsg; + }); + + const response = await openai.chat.completions.create({ + model: effectiveModel, + messages: openaiMessages, + tools: toolDefs, + ...(maxTokens != null ? { max_tokens: maxTokens } : {}), + }); + + const choice = response.choices[0]; + const msg = choice?.message; + + if (msg?.content) { + await onProgressUpdate(msg.content); + } + + const toolCalls = msg?.tool_calls?.map(tc => ({ + id: tc.id, + function: { + name: (tc as { function: { name: string; arguments: string } }).function.name, + arguments: (tc as { function: { name: string; arguments: string } }).function.arguments, + }, + })); + + return { + message: { + role: 'assistant' as const, + content: msg?.content ?? null, + tool_calls: toolCalls, + }, + usage: { + promptTokens: response.usage?.prompt_tokens ?? 0, + completionTokens: response.usage?.completion_tokens ?? 0, + }, + }; + }, + systemPrompt, + userPrompt, + toolNames, + serperApiKey, + onToolCall, + mcpToolDefs, + mcpServers, + ); + + return { + result: toolLoopResult.result, + usage: toolLoopResult.usage, + toolCallLogs: toolLoopResult.toolCallLogs, + }; +} diff --git a/cli/src/mcpClient.ts b/cli/src/mcpClient.ts new file mode 100644 index 0000000..5bb41dd --- /dev/null +++ b/cli/src/mcpClient.ts @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 CrewForm +// +// mcpClient.ts — MCP client for CLI use. +// Adapted from task-runner/src/mcpClient.ts — standalone, no Supabase dependency. +// Supports Streamable HTTP, SSE, and stdio transports. + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import type { ToolDefinition } from './tools.js'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface McpServerConfig { + /** Unique identifier for this server instance */ + id: string; + /** Human-friendly name */ + name: string; + /** URL (for http/sse) or command (for stdio) */ + url: string; + /** Transport mechanism */ + transport: 'streamable-http' | 'sse' | 'stdio'; + /** Extra configuration */ + config: { + headers?: Record; + env?: Record; + command?: string; + args?: string[]; + }; +} + +interface McpConnection { + client: Client; + serverId: string; + serverName: string; +} + +// ─── Connection Pool ───────────────────────────────────────────────────────── + +const activeConnections = new Map(); + +/** + * Connect to an MCP server and cache the connection. + */ +export async function connectToServer(server: McpServerConfig): Promise { + const existing = activeConnections.get(server.id); + if (existing) return existing.client; + + const client = new Client( + { name: 'crewform-cli', version: '0.1.0' }, + { capabilities: {} }, + ); + + let transport; + + switch (server.transport) { + case 'streamable-http': { + transport = new StreamableHTTPClientTransport( + new URL(server.url), + { requestInit: { headers: server.config.headers ?? {} } }, + ); + break; + } + case 'sse': { + transport = new SSEClientTransport( + new URL(server.url), + { requestInit: { headers: server.config.headers ?? {} } }, + ); + break; + } + case 'stdio': { + const command = server.config.command ?? server.url; + transport = new StdioClientTransport({ + command, + args: server.config.args ?? [], + env: { + ...process.env, + ...(server.config.env ?? {}), + } as Record, + }); + break; + } + default: + throw new Error(`Unsupported MCP transport: ${server.transport}`); + } + + await client.connect(transport); + + activeConnections.set(server.id, { + client, + serverId: server.id, + serverName: server.name, + }); + + return client; +} + +/** + * Discover tools from an MCP server. Returns OpenAI-compatible tool definitions. + */ +export async function discoverTools(server: McpServerConfig): Promise<{ + definitions: ToolDefinition[]; + rawTools: Array<{ name: string; description?: string; inputSchema?: unknown }>; +}> { + const client = await connectToServer(server); + const result = await client.listTools(); + const tools = result.tools ?? []; + + // Convert MCP tool schema to OpenAI-compatible format + const definitions: ToolDefinition[] = tools.map((tool) => { + const schema = (tool.inputSchema ?? { type: 'object', properties: {}, required: [] }) as { + type: string; + properties?: Record; + required?: string[]; + }; + + return { + type: 'function' as const, + function: { + name: `mcp_${server.id.replace(/-/g, '').slice(0, 8)}_${tool.name}`, + description: `[${server.name}] ${tool.description ?? tool.name}`, + parameters: { + type: 'object', + properties: Object.fromEntries( + Object.entries(schema.properties ?? {}).map(([key, val]) => [ + key, + { type: val.type ?? 'string', description: val.description ?? key }, + ]), + ), + required: schema.required ?? [], + }, + }, + }; + }); + + return { + definitions, + rawTools: tools.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })), + }; +} + +/** + * Call a tool on an MCP server. + */ +export async function callMcpTool( + serverId: string, + toolName: string, + args: Record, +): Promise { + const connection = activeConnections.get(serverId); + if (!connection) { + throw new Error(`MCP server "${serverId}" is not connected`); + } + + const result = await connection.client.callTool({ + name: toolName, + arguments: args, + }); + + const content = result.content; + if (!Array.isArray(content) || content.length === 0) { + return result.isError ? 'Error: Tool returned an error with no content' : '(no output)'; + } + + // Concatenate text content blocks + const textParts = content + .filter((block): block is { type: 'text'; text: string } => block.type === 'text') + .map((block) => block.text); + + if (textParts.length > 0) { + const combined = textParts.join('\n'); + return combined.length > 8000 ? combined.slice(0, 8000) + '\n... (truncated)' : combined; + } + + return `Tool returned ${content.length} content block(s) of type: ${content.map((b) => b.type).join(', ')}`; +} + +/** + * Parse an MCP tool function name back into serverId + toolName. + * Format: mcp__ + */ +export function parseMcpToolName( + functionName: string, + servers: McpServerConfig[], +): { serverId: string; toolName: string } | null { + if (!functionName.startsWith('mcp_')) return null; + + const withoutPrefix = functionName.slice(4); + const serverPrefix = withoutPrefix.slice(0, 8); + const toolName = withoutPrefix.slice(9); + + const server = servers.find((s) => s.id.replace(/-/g, '').startsWith(serverPrefix)); + if (!server) return null; + + return { serverId: server.id, toolName }; +} + +/** + * Disconnect all active MCP clients. Call this after execution completes. + */ +export async function disconnectAll(): Promise { + const disconnects = Array.from(activeConnections.entries()).map(async ([id, conn]) => { + try { + await conn.client.close(); + } catch { + // best-effort + } + activeConnections.delete(id); + }); + + await Promise.allSettled(disconnects); +} + +/** + * Parse MCP server configs from a JSON config file section. + */ +export function parseMcpServers(raw: unknown): McpServerConfig[] { + if (!Array.isArray(raw)) return []; + + return raw.map((entry, i) => { + const e = entry as Record; + return { + id: (e.id as string) ?? `mcp-${i}`, + name: (e.name as string) ?? `MCP Server ${i + 1}`, + url: (e.url as string) ?? '', + transport: (e.transport as McpServerConfig['transport']) ?? 'stdio', + config: (e.config as McpServerConfig['config']) ?? {}, + }; + }); +} diff --git a/cli/src/pipelineExecutor.ts b/cli/src/pipelineExecutor.ts new file mode 100644 index 0000000..7e46f08 --- /dev/null +++ b/cli/src/pipelineExecutor.ts @@ -0,0 +1,457 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 CrewForm +// +// pipelineExecutor.ts — Local pipeline team execution engine. +// Runs multi-agent pipelines from JSON config with step-by-step handoffs. +// No Supabase dependency — all agents are inline in the config. + +import chalk from 'chalk'; +import { executeAgent } from './executor.js'; +import type { AgentConfig, TeamConfig, PipelineStep } from './config.js'; +import type { TokenUsage, ExecutionResult, ToolCallLog } from './types.js'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface PipelineResult { + /** Final output from the last step */ + output: string; + /** Per-step results */ + steps: StepResult[]; + /** Aggregated usage across all steps */ + usage: TokenUsage; +} + +interface StepResult { + stepName: string; + agentName: string; + output: string; + usage: TokenUsage; + toolCallLogs: ToolCallLog[]; + status: 'completed' | 'failed' | 'skipped'; + error?: string; +} + +interface FanOutBranchResult { + agentName: string; + status: 'completed' | 'failed'; + output: string | null; + error?: string; + usage: TokenUsage; + toolCallLogs: ToolCallLog[]; +} + +export interface PipelineOptions { + /** Show streaming output for each step */ + onStepStart?: (stepIndex: number, stepName: string, agentName: string) => void; + /** Called when a step completes */ + onStepComplete?: (stepIndex: number, stepName: string, result: StepResult) => void; + /** Stream text output for each step */ + onStream?: (delta: string) => void; + /** Called when a tool is invoked */ + onToolCall?: (name: string, args: Record) => void; + /** Custom Ollama base URL */ + ollamaBaseUrl?: string; +} + +// ─── Pipeline Executor ─────────────────────────────────────────────────────── + +/** + * Execute a pipeline team locally. + * Steps run sequentially, each agent receiving the previous step's output as context. + * Fan-out steps run parallel agents and merge results. + */ +export async function executePipeline( + team: TeamConfig, + inputPrompt: string, + options: PipelineOptions = {}, +): Promise { + const steps = team.config.steps ?? []; + + if (steps.length === 0) { + throw new Error('Pipeline has no steps configured.'); + } + + // Build agent lookup by ref_id + const agentMap = new Map(); + for (const entry of team.agents) { + agentMap.set(entry.ref_id, entry.agent); + } + + const stepResults: StepResult[] = []; + const accumulatedOutputs: string[] = []; + let previousOutput: string | null = null; + let totalPromptTokens = 0; + let totalCompletionTokens = 0; + let totalCost = 0; + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + + if (step.type === 'fan_out' && step.parallel_agents && step.parallel_agents.length > 0) { + // ── Fan-Out Step ── + const fanOutResult = await executeFanOutStep( + step, i, inputPrompt, previousOutput, accumulatedOutputs, + agentMap, options, + ); + + stepResults.push(fanOutResult); + + if (fanOutResult.status === 'completed') { + accumulatedOutputs.push(fanOutResult.output); + previousOutput = fanOutResult.output; + totalPromptTokens += fanOutResult.usage.promptTokens; + totalCompletionTokens += fanOutResult.usage.completionTokens; + totalCost += fanOutResult.usage.costEstimateUSD; + } + } else { + // ── Sequential Step ── + const agentRef = step.agent_ref ?? step.agent_id; + if (!agentRef) { + throw new Error(`Step "${step.step_name}" has no agent_ref or agent_id.`); + } + + const agent = agentMap.get(agentRef); + if (!agent) { + throw new Error( + `Agent "${agentRef}" not found for step "${step.step_name}". ` + + `Available agents: ${Array.from(agentMap.keys()).join(', ')}`, + ); + } + + const stepResult = await executeStepWithRetry( + step, i, agent, inputPrompt, previousOutput, accumulatedOutputs, options, + ); + + stepResults.push(stepResult); + + if (stepResult.status === 'completed') { + accumulatedOutputs.push(stepResult.output); + previousOutput = stepResult.output; + totalPromptTokens += stepResult.usage.promptTokens; + totalCompletionTokens += stepResult.usage.completionTokens; + totalCost += stepResult.usage.costEstimateUSD; + } + } + } + + const totalTokens = totalPromptTokens + totalCompletionTokens; + + return { + output: previousOutput ?? '', + steps: stepResults, + usage: { + promptTokens: totalPromptTokens, + completionTokens: totalCompletionTokens, + totalTokens, + costEstimateUSD: totalCost, + }, + }; +} + +// ─── Step Execution ────────────────────────────────────────────────────────── + +async function executeStepWithRetry( + step: PipelineStep, + stepIndex: number, + agent: AgentConfig, + inputTask: string, + previousOutput: string | null, + accumulatedOutputs: string[], + options: PipelineOptions, + fanOutResults?: FanOutBranchResult[], +): Promise { + const maxAttempts = step.on_failure === 'retry' ? (step.max_retries ?? 1) + 1 : 1; + + options.onStepStart?.(stepIndex, step.step_name, agent.name); + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + // Build the step prompt with handoff context + const prompt = buildStepPrompt(step, { + input: inputTask, + previousOutput, + stepIndex, + stepName: step.step_name, + accumulatedOutputs, + fanOutResults, + }); + + const result = await executeAgent(agent, { + prompt, + onStream: options.onStream, + onToolCall: options.onToolCall, + ollamaBaseUrl: options.ollamaBaseUrl, + }); + + const stepResult: StepResult = { + stepName: step.step_name, + agentName: agent.name, + output: result.result, + usage: result.usage, + toolCallLogs: result.toolCallLogs, + status: 'completed', + }; + + options.onStepComplete?.(stepIndex, step.step_name, stepResult); + + return stepResult; + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + + if (attempt >= maxAttempts) { + if (step.on_failure === 'skip') { + const skipResult: StepResult = { + stepName: step.step_name, + agentName: agent.name, + output: '', + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0, costEstimateUSD: 0 }, + toolCallLogs: [], + status: 'skipped', + error: errMsg, + }; + options.onStepComplete?.(stepIndex, step.step_name, skipResult); + return skipResult; + } + + throw new Error(`Step "${step.step_name}" failed after ${attempt} attempt(s): ${errMsg}`); + } + } + } + + // Should never reach here + throw new Error(`Step "${step.step_name}" failed unexpectedly.`); +} + +// ─── Fan-Out Execution ─────────────────────────────────────────────────────── + +async function executeFanOutStep( + step: PipelineStep, + stepIndex: number, + inputTask: string, + previousOutput: string | null, + accumulatedOutputs: string[], + agentMap: Map, + options: PipelineOptions, +): Promise { + const parallelRefs = step.parallel_agents ?? []; + const mergeRef = step.merge_agent_ref ?? step.merge_agent_id; + const failureMode = step.fan_out_failure ?? 'fail_fast'; + + // ── Execute all parallel branches ── + const branchPromises = parallelRefs.map(async (ref, branchIdx): Promise => { + const agent = agentMap.get(ref); + if (!agent) { + return { + agentName: ref, + status: 'failed', + output: null, + error: `Agent "${ref}" not found`, + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0, costEstimateUSD: 0 }, + toolCallLogs: [], + }; + } + + try { + const branchStep: PipelineStep = { + agent_ref: ref, + step_name: `${step.step_name} [Branch ${branchIdx + 1}]`, + instructions: step.instructions, + expected_output: step.expected_output, + on_failure: 'stop', + max_retries: 0, + type: 'sequential', + fan_out_failure: 'fail_fast', + }; + + const prompt = buildStepPrompt(branchStep, { + input: inputTask, + previousOutput, + stepIndex, + stepName: branchStep.step_name, + accumulatedOutputs, + }); + + const result = await executeAgent(agent, { + prompt, + onToolCall: options.onToolCall, + ollamaBaseUrl: options.ollamaBaseUrl, + }); + + return { + agentName: agent.name, + status: 'completed', + output: result.result, + usage: result.usage, + toolCallLogs: result.toolCallLogs, + }; + } catch (err) { + return { + agentName: agent.name, + status: 'failed', + output: null, + error: err instanceof Error ? err.message : String(err), + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0, costEstimateUSD: 0 }, + toolCallLogs: [], + }; + } + }); + + let branchResults: FanOutBranchResult[]; + + if (failureMode === 'fail_fast') { + branchResults = await Promise.all(branchPromises); + const failed = branchResults.find(b => b.status === 'failed'); + if (failed) { + throw new Error(`Fan-out step "${step.step_name}" failed (fail_fast): ${failed.error}`); + } + } else { + branchResults = await Promise.all( + branchPromises.map(p => p.catch((err): FanOutBranchResult => ({ + agentName: 'unknown', + status: 'failed', + output: null, + error: err instanceof Error ? err.message : String(err), + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0, costEstimateUSD: 0 }, + toolCallLogs: [], + }))), + ); + } + + const completedBranches = branchResults.filter(b => b.status === 'completed'); + if (completedBranches.length === 0) { + throw new Error(`Fan-out step "${step.step_name}" failed: all branches failed.`); + } + + // Aggregate branch usage + let totalPromptTokens = 0; + let totalCompletionTokens = 0; + let totalCost = 0; + const allToolLogs: ToolCallLog[] = []; + for (const branch of branchResults) { + totalPromptTokens += branch.usage.promptTokens; + totalCompletionTokens += branch.usage.completionTokens; + totalCost += branch.usage.costEstimateUSD; + allToolLogs.push(...branch.toolCallLogs); + } + + // ── Merge Step ── + if (mergeRef) { + const mergeAgent = agentMap.get(mergeRef); + if (!mergeAgent) { + throw new Error(`Merge agent "${mergeRef}" not found for fan-out step "${step.step_name}".`); + } + + const mergeStep: PipelineStep = { + agent_ref: mergeRef, + step_name: `${step.step_name} [Merge]`, + instructions: step.merge_instructions ?? 'Review and aggregate the outputs from all parallel branches into a single cohesive result.', + expected_output: step.expected_output, + on_failure: step.on_failure, + max_retries: step.max_retries, + type: 'sequential', + fan_out_failure: 'fail_fast', + }; + + const mergeResult = await executeStepWithRetry( + mergeStep, stepIndex, mergeAgent, + inputTask, null, accumulatedOutputs, options, + branchResults, + ); + + totalPromptTokens += mergeResult.usage.promptTokens; + totalCompletionTokens += mergeResult.usage.completionTokens; + totalCost += mergeResult.usage.costEstimateUSD; + + return { + stepName: step.step_name, + agentName: `Fan-out (${branchResults.length} branches) → ${mergeAgent.name}`, + output: mergeResult.output, + usage: { + promptTokens: totalPromptTokens, + completionTokens: totalCompletionTokens, + totalTokens: totalPromptTokens + totalCompletionTokens, + costEstimateUSD: totalCost, + }, + toolCallLogs: [...allToolLogs, ...mergeResult.toolCallLogs], + status: 'completed', + }; + } + + // No merge agent — concatenate branch outputs + const concatenated = completedBranches + .map((b, i) => `## Branch ${i + 1} — ${b.agentName}\n\n${b.output}`) + .join('\n\n---\n\n'); + + return { + stepName: step.step_name, + agentName: `Fan-out (${completedBranches.length}/${branchResults.length} branches)`, + output: concatenated, + usage: { + promptTokens: totalPromptTokens, + completionTokens: totalCompletionTokens, + totalTokens: totalPromptTokens + totalCompletionTokens, + costEstimateUSD: totalCost, + }, + toolCallLogs: allToolLogs, + status: 'completed', + }; +} + +// ─── Prompt Builder ────────────────────────────────────────────────────────── + +interface StepContext { + input: string; + previousOutput: string | null; + stepIndex: number; + stepName: string; + accumulatedOutputs: string[]; + fanOutResults?: FanOutBranchResult[]; +} + +function buildStepPrompt(step: PipelineStep, context: StepContext): string { + const parts: string[] = []; + + parts.push(`## Task\n${context.input}`); + + if (context.previousOutput) { + parts.push( + `## Previous Step Output\nThe previous step in this pipeline produced the following output:\n\n${context.previousOutput}`, + ); + } + + // Fan-out merge context + if (context.fanOutResults && context.fanOutResults.length > 0) { + const branchParts = context.fanOutResults.map((branch, idx) => { + if (branch.status === 'completed') { + return `### Branch ${idx + 1} — ${branch.agentName} ✅\n${branch.output}`; + } + return `### Branch ${idx + 1} — ${branch.agentName} ❌ (Failed)\nError: ${branch.error ?? 'Unknown error'}`; + }); + + const completedCount = context.fanOutResults.filter(b => b.status === 'completed').length; + const totalCount = context.fanOutResults.length; + + parts.push( + `## Fan-Out Results (${completedCount}/${totalCount} branches completed)\n` + + `The following agents processed the task in parallel. Review and aggregate their outputs.\n\n` + + branchParts.join('\n\n'), + ); + } + + if (step.instructions) { + parts.push(`## Your Instructions\n${step.instructions}`); + } + + if (step.expected_output) { + parts.push(`## Expected Output Format\n${step.expected_output}`); + } + + if (context.accumulatedOutputs.length > 1) { + parts.push( + `## Pipeline Context\nThis is step ${context.stepIndex + 1} in a multi-step pipeline. ` + + `${context.accumulatedOutputs.length} previous steps have completed.`, + ); + } + + return parts.join('\n\n'); +} diff --git a/cli/src/providers.ts b/cli/src/providers.ts new file mode 100644 index 0000000..fc213b7 --- /dev/null +++ b/cli/src/providers.ts @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 CrewForm +// +// providers.ts — Provider routing: model→provider inference and base URL mapping. +// Extracted from task-runner/src/executor.ts for standalone CLI use. + +/** + * Derive provider from model name when not explicitly specified. + * Maps well-known model prefixes to their provider. + */ +export function inferProvider(model: string): string | null { + const m = model.toLowerCase(); + // Prefix checks FIRST — must take priority over keyword matches + if (m.startsWith('openrouter/')) return 'openrouter'; + if (m.startsWith('groq/')) return 'groq'; + // Then keyword matches + if (m.includes('claude')) return 'anthropic'; + if (m.includes('gpt') || m.includes('o1') || m.includes('o3')) return 'openai'; + if (m.includes('gemini')) return 'google'; + if (m.includes('mistral') || m.includes('codestral')) return 'mistral'; + if (m.includes('command-r')) return 'cohere'; + if (m.includes('togethercomputer') || m.includes('together/')) return 'together'; + if (m.includes('nvidia/') || m.includes('nim/')) return 'nvidia'; + if (m.includes('minimax')) return 'minimax'; + if (m.includes('moonshot')) return 'moonshot'; + if (m.includes('sonar')) return 'perplexity'; + // If nothing matches, try Ollama (most common for local models) + return null; +} + +/** + * Base URL map for OpenAI-compatible providers. + * The OpenAI SDK is used for all providers except Anthropic and Google. + */ +export const BASE_URL_MAP: Record = { + openrouter: 'https://openrouter.ai/api/v1', + groq: 'https://api.groq.com/openai/v1', + mistral: 'https://api.mistral.ai/v1', + cohere: 'https://api.cohere.com/compatibility/v1', + together: 'https://api.together.xyz/v1', + nvidia: 'https://integrate.api.nvidia.com/v1', + huggingface: 'https://api-inference.huggingface.co/v1', + venice: 'https://api.venice.ai/api/v1', + minimax: 'https://api.minimaxi.chat/v1', + moonshot: 'https://api.moonshot.cn/v1', + perplexity: 'https://api.perplexity.ai', + ollama: 'http://localhost:11434/v1', +}; + +/** + * Providers that use their own native SDK (not OpenAI-compatible). + */ +export const NATIVE_SDK_PROVIDERS = new Set(['anthropic', 'google']); + +/** + * Map of environment variable names for each provider's API key. + */ +export const API_KEY_ENV_MAP: Record = { + openai: ['OPENAI_API_KEY'], + anthropic: ['ANTHROPIC_API_KEY'], + google: ['GOOGLE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY'], + groq: ['GROQ_API_KEY'], + mistral: ['MISTRAL_API_KEY'], + cohere: ['COHERE_API_KEY'], + openrouter: ['OPENROUTER_API_KEY'], + together: ['TOGETHER_API_KEY'], + nvidia: ['NVIDIA_API_KEY'], + huggingface: ['HUGGINGFACE_API_KEY', 'HF_TOKEN'], + venice: ['VENICE_API_KEY'], + minimax: ['MINIMAX_API_KEY'], + moonshot: ['MOONSHOT_API_KEY'], + perplexity: ['PERPLEXITY_API_KEY'], + ollama: ['OLLAMA_API_KEY'], // Usually not needed + serper: ['SERPER_API_KEY'], +}; + +/** + * Resolve an API key for a provider from environment variables. + * Ollama returns 'ollama' as a dummy key (no auth needed). + */ +export function resolveApiKey(provider: string): string | null { + const lower = provider.toLowerCase(); + + // Ollama doesn't require an API key + if (lower === 'ollama') return 'ollama'; + + const envVars = API_KEY_ENV_MAP[lower]; + if (!envVars) return null; + + for (const envVar of envVars) { + const value = process.env[envVar]; + if (value) return value; + } + return null; +} + +/** + * Strip provider prefix from model name for routed providers. + */ +export function normaliseModelName(model: string, provider: string): string { + const lower = provider.toLowerCase(); + if (lower === 'openrouter') return model.replace(/^openrouter\//, ''); + if (lower === 'groq') return model.replace(/^groq\//, ''); + return model; +} + +/** + * Detect if Ollama is running locally by probing the API. + */ +export async function detectOllama(baseUrl = 'http://localhost:11434'): Promise<{ + available: boolean; + models: string[]; +}> { + try { + const response = await fetch(`${baseUrl}/api/tags`, { + signal: AbortSignal.timeout(2000), + }); + if (!response.ok) return { available: false, models: [] }; + + const data = await response.json() as { models?: { name: string }[] }; + const models = data.models?.map((m) => m.name) ?? []; + return { available: true, models }; + } catch { + return { available: false, models: [] }; + } +} diff --git a/cli/src/providers/anthropic.ts b/cli/src/providers/anthropic.ts new file mode 100644 index 0000000..8bc54b6 --- /dev/null +++ b/cli/src/providers/anthropic.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 CrewForm +// +// providers/anthropic.ts — Anthropic Claude streaming execution. +// Adapted from task-runner/src/providers/anthropic.ts for standalone CLI use. + +import Anthropic from '@anthropic-ai/sdk'; +import type { TokenUsage } from '../types.js'; + +export async function executeAnthropic( + apiKey: string, + model: string, + systemPrompt: string, + userPrompt: string, + onChunk: (text: string) => Promise | void, + maxTokens?: number | null, +): Promise<{ result: string; usage: TokenUsage }> { + const anthropic = new Anthropic({ apiKey }); + let fullText = ''; + let promptTokens = 0; + let completionTokens = 0; + + const stream = await anthropic.messages.create({ + model, + max_tokens: maxTokens ?? 4096, + system: systemPrompt, + messages: [{ role: 'user', content: userPrompt }], + stream: true, + }); + + for await (const chunk of stream) { + if (chunk.type === 'message_start') { + promptTokens = chunk.message.usage.input_tokens; + } else if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') { + fullText += chunk.delta.text; + await onChunk(fullText); + } else if (chunk.type === 'message_delta') { + completionTokens = chunk.usage.output_tokens; + } + } + + const costEstimateUSD = (promptTokens / 1_000_000) * 3 + (completionTokens / 1_000_000) * 15; + + return { + result: fullText, + usage: { + promptTokens, + completionTokens, + totalTokens: promptTokens + completionTokens, + costEstimateUSD, + }, + }; +} diff --git a/cli/src/providers/google.ts b/cli/src/providers/google.ts new file mode 100644 index 0000000..28a09a4 --- /dev/null +++ b/cli/src/providers/google.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 CrewForm +// +// providers/google.ts — Google Gemini streaming execution. +// Adapted from task-runner/src/providers/google.ts for standalone CLI use. + +import { GoogleGenerativeAI } from '@google/generative-ai'; +import type { TokenUsage } from '../types.js'; + +export async function executeGoogle( + apiKey: string, + model: string, + systemPrompt: string, + userPrompt: string, + onChunk: (text: string) => Promise | void, + maxTokens?: number | null, +): Promise<{ result: string; usage: TokenUsage }> { + const genAI = new GoogleGenerativeAI(apiKey); + const genModel = genAI.getGenerativeModel({ + model, + systemInstruction: systemPrompt, + ...(maxTokens != null ? { generationConfig: { maxOutputTokens: maxTokens } } : {}), + }); + + let fullText = ''; + + const result = await genModel.generateContentStream(userPrompt); + + for await (const chunk of result.stream) { + const chunkText = chunk.text(); + fullText += chunkText; + await onChunk(fullText); + } + + const response = await result.response; + + let promptTokens = 0; + let completionTokens = 0; + + if (response.usageMetadata) { + promptTokens = response.usageMetadata.promptTokenCount; + completionTokens = response.usageMetadata.candidatesTokenCount; + } + + const costEstimateUSD = (promptTokens / 1_000_000) * 0.15 + (completionTokens / 1_000_000) * 0.60; + + return { + result: fullText, + usage: { + promptTokens, + completionTokens, + totalTokens: promptTokens + completionTokens, + costEstimateUSD, + }, + }; +} diff --git a/cli/src/providers/openai.ts b/cli/src/providers/openai.ts new file mode 100644 index 0000000..7b893f9 --- /dev/null +++ b/cli/src/providers/openai.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 CrewForm +// +// providers/openai.ts — OpenAI-compatible streaming execution. +// Adapted from task-runner/src/providers/openai.ts for standalone CLI use. + +import OpenAI from 'openai'; +import type { TokenUsage } from '../types.js'; + +export async function executeOpenAI( + apiKey: string, + model: string, + systemPrompt: string, + userPrompt: string, + onChunk: (text: string) => Promise | void, + baseURL?: string, + maxTokens?: number | null, +): Promise<{ result: string; usage: TokenUsage }> { + const openai = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) }); + let fullText = ''; + + const stream = await openai.chat.completions.create({ + model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + stream: true, + stream_options: { include_usage: true }, + ...(maxTokens != null ? { max_tokens: maxTokens } : {}), + }); + + let promptTokens = 0; + let completionTokens = 0; + + for await (const chunk of stream) { + if (chunk.choices.length > 0) { + const content = chunk.choices[0]?.delta?.content || ''; + fullText += content; + if (content) { + await onChunk(fullText); + } + } + if (chunk.usage) { + promptTokens = chunk.usage.prompt_tokens; + completionTokens = chunk.usage.completion_tokens; + } + } + + const costEstimateUSD = (promptTokens / 1_000_000) * 5 + (completionTokens / 1_000_000) * 15; + + return { + result: fullText, + usage: { + promptTokens, + completionTokens, + totalTokens: promptTokens + completionTokens, + costEstimateUSD, + }, + }; +} diff --git a/cli/src/tools.ts b/cli/src/tools.ts new file mode 100644 index 0000000..cae8d53 --- /dev/null +++ b/cli/src/tools.ts @@ -0,0 +1,454 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 CrewForm +// +// tools.ts — Standalone tool executor for CLI use. +// Supports built-in tools + MCP server tools. +// Excluded: knowledge_search, a2a_delegate (require Supabase). + +import type { TokenUsage, ToolCallLog } from './types.js'; +import type { McpServerConfig } from './mcpClient.js'; + +// ─── Tool Definitions (OpenAI-compatible format) ───────────────────────────── + +export interface ToolDefinition { + type: 'function'; + function: { + name: string; + description: string; + parameters: { + type: 'object'; + properties: Record; + required: string[]; + }; + }; +} + +interface ToolCall { + id: string; + function: { + name: string; + arguments: string; + }; +} + +// ─── Tool Registry ─────────────────────────────────────────────────────────── + +const TOOL_REGISTRY: Record = { + web_search: { + type: 'function', + function: { + name: 'web_search', + description: 'Search the web for current information. Returns relevant text snippets.', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'The search query' }, + }, + required: ['query'], + }, + }, + }, + http_request: { + type: 'function', + function: { + name: 'http_request', + description: 'Make an HTTP request to a URL. Returns the response body (truncated to 4000 chars).', + parameters: { + type: 'object', + properties: { + url: { type: 'string', description: 'The URL to request' }, + method: { type: 'string', description: 'HTTP method (GET, POST, PUT, DELETE). Defaults to GET.' }, + body: { type: 'string', description: 'Request body for POST/PUT requests (JSON string)' }, + }, + required: ['url'], + }, + }, + }, + code_interpreter: { + type: 'function', + function: { + name: 'code_interpreter', + description: 'Execute JavaScript code in a sandboxed environment. Returns the result of the last expression or console output.', + parameters: { + type: 'object', + properties: { + code: { type: 'string', description: 'JavaScript code to execute' }, + }, + required: ['code'], + }, + }, + }, + read_file: { + type: 'function', + function: { + name: 'read_file', + description: 'Read the contents of a file from a URL. Returns the file text (truncated to 8000 chars).', + parameters: { + type: 'object', + properties: { + url: { type: 'string', description: 'URL of the file to read' }, + }, + required: ['url'], + }, + }, + }, + grammar_check: { + type: 'function', + function: { + name: 'grammar_check', + description: 'Check text for grammar, spelling, and style issues. Returns a list of issues with suggestions.', + parameters: { + type: 'object', + properties: { + text: { type: 'string', description: 'The text to check' }, + language: { type: 'string', description: 'Language code (e.g. en-US). Defaults to auto-detect.' }, + }, + required: ['text'], + }, + }, + }, +}; + +/** Get tool definitions for the given tool names, optionally merging MCP tool defs. */ +export function getToolDefinitions( + toolNames: string[], + mcpToolDefs?: ToolDefinition[], +): ToolDefinition[] { + const defs: ToolDefinition[] = []; + for (const name of toolNames) { + if (name.startsWith('mcp:')) continue; // MCP tools handled separately + const def = TOOL_REGISTRY[name]; + if (def) defs.push(def); + } + // Append MCP tool definitions + if (mcpToolDefs && mcpToolDefs.length > 0) { + defs.push(...mcpToolDefs); + } + return defs; +} + +/** Get list of available tool names. */ +export function getAvailableTools(): string[] { + return Object.keys(TOOL_REGISTRY); +} + +// ─── Tool Execution ────────────────────────────────────────────────────────── + +async function executeToolCall( + toolCall: ToolCall, + serperApiKey?: string, + mcpServers?: McpServerConfig[], +): Promise { + const { name, arguments: argsStr } = toolCall.function; + + let args: Record; + try { + args = JSON.parse(argsStr) as Record; + } catch { + return `Error: Invalid JSON arguments: ${argsStr}`; + } + + try { + // Check for MCP tool (prefixed with mcp_) + if (name.startsWith('mcp_') && mcpServers) { + const { parseMcpToolName, callMcpTool } = await import('./mcpClient.js'); + const parsed = parseMcpToolName(name, mcpServers); + if (parsed) { + return await callMcpTool(parsed.serverId, parsed.toolName, args); + } + return `Error: Could not resolve MCP tool "${name}"`; + } + + switch (name) { + case 'web_search': + return await executeWebSearch(args.query as string, serperApiKey); + case 'http_request': + return await executeHttpRequest( + args.url as string, + (args.method as string) || 'GET', + args.body as string | undefined, + ); + case 'code_interpreter': + return executeCodeInterpreter(args.code as string); + case 'read_file': + return await executeReadFile(args.url as string); + case 'grammar_check': + return await executeGrammarCheck( + args.text as string, + (args.language as string) || 'auto', + ); + default: + return `Error: Unknown tool "${name}"`; + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return `Error executing ${name}: ${msg}`; + } +} + +// ─── Built-in Tool Implementations ─────────────────────────────────────────── + +async function executeWebSearch(query: string, serperApiKey?: string): Promise { + if (!serperApiKey) { + return 'Error: Web search requires a SERPER_API_KEY environment variable. Get one at serper.dev'; + } + + const response = await fetch('https://google.serper.dev/search', { + method: 'POST', + headers: { + 'X-API-KEY': serperApiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ q: query, num: 10 }), + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) { + return `Search failed: HTTP ${response.status} ${response.statusText}`; + } + + interface SerperResult { title: string; link: string; snippet: string; } + interface SerperResponse { + organic: SerperResult[]; + answerBox?: { answer?: string; snippet?: string; title?: string }; + knowledgeGraph?: { title?: string; description?: string }; + } + + const data = await response.json() as SerperResponse; + const parts: string[] = []; + + if (data.answerBox?.answer) { + parts.push(`**Quick Answer:** ${data.answerBox.answer}`); + } else if (data.answerBox?.snippet) { + parts.push(`**Quick Answer:** ${data.answerBox.snippet}`); + } + + if (data.knowledgeGraph?.description) { + parts.push(`**${data.knowledgeGraph.title ?? 'Overview'}:** ${data.knowledgeGraph.description}`); + } + + const organic = data.organic ?? []; + if (organic.length === 0 && parts.length === 0) { + return `No search results found for: "${query}"`; + } + + for (let i = 0; i < Math.min(organic.length, 10); i++) { + const r = organic[i]; + parts.push(`${i + 1}. **${r.title}**\n ${r.snippet}\n URL: ${r.link}`); + } + + return `Search results for "${query}":\n\n${parts.join('\n\n')}`; +} + +async function executeHttpRequest(url: string, method: string, body?: string): Promise { + const opts: RequestInit = { + method: method.toUpperCase(), + headers: { + 'User-Agent': 'CrewForm-CLI/1.0', + 'Accept': 'application/json, text/plain, */*', + }, + }; + + if (body && (method.toUpperCase() === 'POST' || method.toUpperCase() === 'PUT')) { + opts.body = body; + opts.headers = { ...opts.headers, 'Content-Type': 'application/json' }; + } + + const response = await fetch(url, opts); + const text = await response.text(); + const statusInfo = `HTTP ${response.status} ${response.statusText}`; + const truncated = text.length > 4000 ? text.slice(0, 4000) + '\n... (truncated)' : text; + return `${statusInfo}\n\n${truncated}`; +} + +function executeCodeInterpreter(code: string): string { + try { + const logs: string[] = []; + const mockConsole = { + log: (...args: unknown[]) => logs.push(args.map(String).join(' ')), + warn: (...args: unknown[]) => logs.push(`[warn] ${args.map(String).join(' ')}`), + error: (...args: unknown[]) => logs.push(`[error] ${args.map(String).join(' ')}`), + }; + + const fn = new Function('console', 'Math', 'JSON', 'Date', 'parseInt', 'parseFloat', 'isNaN', 'isFinite', + `"use strict";\n${code}`); + const result: unknown = fn(mockConsole, Math, JSON, Date, parseInt, parseFloat, isNaN, isFinite); + + const output = logs.length > 0 ? logs.join('\n') : ''; + const returnValue = result !== undefined ? String(result) : ''; + + if (output && returnValue) return `Console output:\n${output}\n\nReturn value: ${returnValue}`; + return output || returnValue || '(no output)'; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return `Code execution error: ${msg}`; + } +} + +async function executeReadFile(url: string): Promise { + const response = await fetch(url, { + headers: { 'User-Agent': 'CrewForm-CLI/1.0' }, + }); + if (!response.ok) { + return `Failed to read file: HTTP ${response.status} ${response.statusText}`; + } + const text = await response.text(); + return text.length > 8000 ? text.slice(0, 8000) + '\n... (truncated)' : text; +} + +async function executeGrammarCheck(text: string, language: string): Promise { + const params = new URLSearchParams({ text, language, enabledOnly: 'false' }); + + const response = await fetch('https://api.languagetool.org/v2/check', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'CrewForm-CLI/1.0' }, + body: params.toString(), + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) { + return `Grammar check failed: HTTP ${response.status} ${response.statusText}`; + } + + interface LTMatch { + message: string; offset: number; length: number; + replacements: { value: string }[]; + rule: { category: { name: string } }; + context: { text: string }; + } + interface LTResponse { + matches: LTMatch[]; + language: { name: string; detectedLanguage?: { name: string } }; + } + + const data = await response.json() as LTResponse; + if (data.matches.length === 0) { + return `✅ No grammar, spelling, or style issues found. Language: ${data.language.detectedLanguage?.name ?? data.language.name}.`; + } + + const lang = data.language.detectedLanguage?.name ?? data.language.name; + let result = `Found ${data.matches.length} issue(s) (Language: ${lang}):\n\n`; + for (let i = 0; i < Math.min(data.matches.length, 20); i++) { + const m = data.matches[i]; + const suggestions = m.replacements.slice(0, 3).map(r => `"${r.value}"`).join(', '); + result += `${i + 1}. [${m.rule.category.name}] ${m.message}\n`; + result += ` Context: "...${m.context.text}..."\n`; + if (suggestions) result += ` Suggestions: ${suggestions}\n`; + result += '\n'; + } + return result; +} + +// ─── Tool-Use Loop ─────────────────────────────────────────────────────────── + +interface ToolUseMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + content?: string | null; + tool_calls?: ToolCall[]; + tool_call_id?: string; +} + +export interface ToolUseResult { + result: string; + usage: TokenUsage; + toolCallsMade: number; + toolCallLogs: ToolCallLog[]; +} + +/** + * Execute an OpenAI-compatible tool-use loop. + * Calls the LLM, processes tool_calls, feeds results back, repeats. + * Max 10 rounds to prevent infinite loops. + */ +export async function executeWithToolLoop( + callLLM: (messages: ToolUseMessage[], tools: ToolDefinition[]) => Promise<{ + message: { role: string; content: string | null; tool_calls?: ToolCall[] }; + usage: { promptTokens: number; completionTokens: number }; + }>, + systemPrompt: string, + userPrompt: string, + toolNames: string[], + serperApiKey?: string, + onToolCall?: (name: string, args: Record) => void, + mcpToolDefs?: ToolDefinition[], + mcpServers?: McpServerConfig[], +): Promise { + const tools = getToolDefinitions(toolNames, mcpToolDefs); + const messages: ToolUseMessage[] = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ]; + + const MAX_ROUNDS = 10; + let totalPromptTokens = 0; + let totalCompletionTokens = 0; + let toolCallsMade = 0; + const toolCallLogs: ToolCallLog[] = []; + + for (let round = 0; round < MAX_ROUNDS; round++) { + const response = await callLLM(messages, tools); + totalPromptTokens += response.usage.promptTokens; + totalCompletionTokens += response.usage.completionTokens; + + const assistantMessage = response.message; + + // If no tool calls, we're done + if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) { + const totalTokens = totalPromptTokens + totalCompletionTokens; + const costEstimateUSD = (totalPromptTokens / 1_000_000) * 5 + (totalCompletionTokens / 1_000_000) * 15; + return { + result: assistantMessage.content ?? '', + usage: { promptTokens: totalPromptTokens, completionTokens: totalCompletionTokens, totalTokens, costEstimateUSD }, + toolCallsMade, + toolCallLogs, + }; + } + + // Add assistant message with tool_calls + messages.push({ + role: 'assistant', + content: assistantMessage.content, + tool_calls: assistantMessage.tool_calls, + }); + + // Execute each tool call + for (const toolCall of assistantMessage.tool_calls) { + toolCallsMade++; + + let parsedArgs: Record = {}; + try { parsedArgs = JSON.parse(toolCall.function.arguments) as Record; } catch { /* ignore */ } + + if (onToolCall) onToolCall(toolCall.function.name, parsedArgs); + + const callStart = Date.now(); + const result = await executeToolCall(toolCall, serperApiKey, mcpServers); + const durationMs = Date.now() - callStart; + + toolCallLogs.push({ + tool: toolCall.function.name, + arguments: parsedArgs, + result: result.length > 500 ? result.slice(0, 500) + '…' : result, + success: !result.startsWith('Error'), + duration_ms: durationMs, + }); + + messages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: result, + }); + } + } + + // Hit max rounds + const totalTokens = totalPromptTokens + totalCompletionTokens; + const costEstimateUSD = (totalPromptTokens / 1_000_000) * 5 + (totalCompletionTokens / 1_000_000) * 15; + const lastAssistant = messages.filter(m => m.role === 'assistant').pop(); + + return { + result: lastAssistant?.content ?? '[Tool-use loop reached maximum rounds without final response]', + usage: { promptTokens: totalPromptTokens, completionTokens: totalCompletionTokens, totalTokens, costEstimateUSD }, + toolCallsMade, + toolCallLogs, + }; +} diff --git a/cli/src/types.ts b/cli/src/types.ts new file mode 100644 index 0000000..4f13ece --- /dev/null +++ b/cli/src/types.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 CrewForm +// +// types.ts — Shared types for the CLI tool. + +export interface TokenUsage { + promptTokens: number; + completionTokens: number; + totalTokens: number; + costEstimateUSD: number; +} + +export interface ToolCallLog { + tool: string; + arguments: Record; + result: string; + success: boolean; + duration_ms: number; +} + +export interface ExecutionResult { + result: string; + usage: TokenUsage; + toolCallLogs: ToolCallLog[]; +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..6cc8e41 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/eslint.config.js b/eslint.config.js index f7468b2..311a054 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' export default tseslint.config( - { ignores: ['dist', 'crewform-docs', 'supabase/functions', 'e2e', 'playwright.config.ts', 'task-runner', 'ee', 'scripts', 'chat-widget'] }, + { ignores: ['dist', 'crewform-docs', 'supabase/functions', 'e2e', 'playwright.config.ts', 'task-runner', 'ee', 'scripts', 'chat-widget', 'cli'] }, { extends: [js.configs.recommended, ...tseslint.configs.strictTypeChecked], files: ['**/*.{ts,tsx}'],