diff --git a/README.md b/README.md index d4441356..eaf74f8c 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,99 @@ In each case, the API functionality and how you interact with it are largely the As a result, the value of `SHEPHERD_GITHUB_ENTERPRISE_URL` is a function of the type of GitHub service and used to support git APIs except cloning which is configurable via `SHEPHERD_GITHUB_ENTERPRISE_BASE_URL`. For `SHEPHERD_GITHUB_ENTERPRISE_BASE_URL`, while `github.com` works across GitHub service types, for backwards compatibility, we default to `api.github.com`. +## AI-Powered Migrations + +Shepherd supports AI-powered migrations that use natural language prompts instead of shell scripts. This allows you to describe what changes you want to make in plain English, and let an AI model generate and apply the necessary code changes. + +### Quick Start + +```bash +# 1. Create a shepherd.yml with your adapter config +cat > my-migration/shepherd.yml << EOF +id: node-24-upgrade +title: Upgrade to Node 24 +adapter: + type: github + org: my-org +provider: claude +EOF + +# 2. Checkout repositories +shepherd checkout ./my-migration + +# 3. Apply AI migration with a natural language prompt +shepherd ai ./my-migration "upgrade to Node 24 and update package.json engines field" + +# 4. Review, commit, push, and create PRs +shepherd commit ./my-migration +shepherd push ./my-migration +shepherd pr ./my-migration +``` + +### AI Command + +```bash +shepherd ai [options] +``` + +**Options:** + +- `--provider `: AI provider (`claude`, `openai`, or `ollama`). Required if not in shepherd.yml. +- `--model `: AI model to use (optional, provider uses its default). +- `--max-tokens `: Maximum tokens for AI response. +- `--base-url `: Custom API base URL (for local models). +- `--repos `: Comma-separated list of repos to operate on. + +### AI Configuration in shepherd.yml + +```yaml +id: my-ai-migration +title: My AI Migration +adapter: + type: github + org: my-org + +# AI-specific configuration +provider: claude # Required: 'claude', 'openai', or 'ollama' +model: claude-sonnet-4-20250514 # Optional: specific model +baseUrl: http://localhost:8080/v1 # Optional: for local models +context: # Optional: file filtering + include: + - '**/*.js' + - '**/*.ts' + - 'package.json' + exclude: + - 'node_modules/**' + - 'dist/**' +max_tokens: 4096 # Optional: max tokens for AI response +``` + +### Environment Variables + +| Variable | Provider | Description | +| ------------------- | -------- | ------------------------------------- | +| `ANTHROPIC_API_KEY` | Claude | API key for Anthropic's Claude | +| `OPENAI_API_KEY` | OpenAI | API key for OpenAI | +| `OLLAMA_HOST` | Ollama | Ollama server URL (default: localhost)| + +### Example Prompts + +```bash +# Upgrade dependencies +shepherd ai ./migration "upgrade lodash to v4 and fix any breaking changes" + +# Add TypeScript types +shepherd ai ./migration "add TypeScript type annotations to all functions" + +# Fix security issues +shepherd ai ./migration "update axios to latest version and fix any deprecated API usage" + +# Code modernization +shepherd ai ./migration "replace var with const/let and convert callback functions to async/await" +``` + +For more details, see the [AI Migrations documentation](docs/ai-migrations.md). + ### Usage Shepherd is run as follows: diff --git a/docs/ai-migrations.md b/docs/ai-migrations.md new file mode 100644 index 00000000..a028c877 --- /dev/null +++ b/docs/ai-migrations.md @@ -0,0 +1,399 @@ +# AI-Powered Migrations + +Shepherd's AI-powered migrations allow you to apply code changes across repositories using natural language prompts. Instead of writing shell scripts, you describe what changes you want in plain English, and an AI model generates and applies the necessary code changes. + +## Overview + +The AI migration workflow follows the same pattern as traditional Shepherd migrations: + +1. **Checkout**: Clone repositories using `shepherd checkout` +2. **AI Apply**: Apply AI-generated changes using `shepherd ai` +3. **Review & Commit**: Review changes and commit using `shepherd commit` +4. **Push & PR**: Push and create PRs using `shepherd push` and `shepherd pr` + +The key difference is that instead of the `apply` hook with shell commands, you provide a natural language prompt that describes the desired changes. + +## Prerequisites + +### API Keys + +You need an API key for your chosen AI provider: + +**Claude (Anthropic):** + +```bash +export ANTHROPIC_API_KEY="your-api-key-here" +``` + +**OpenAI:** + +```bash +export OPENAI_API_KEY="your-api-key-here" +``` + +**Ollama (Local):** + +No API key required. Just ensure Ollama is running: + +```bash +ollama serve +``` + +## Configuration + +### Minimal Configuration + +The simplest AI migration configuration: + +```yaml +# shepherd.yml +id: upgrade-lodash +title: Upgrade lodash to v4 +adapter: + type: github + org: my-org +provider: claude +``` + +### Full Configuration + +```yaml +# shepherd.yml +id: node-24-migration +title: Migrate to Node 24 +adapter: + type: github + search_query: org:my-org topic:nodejs + +# AI Configuration +provider: claude # Required: 'claude', 'openai', or 'ollama' +model: claude-sonnet-4-20250514 # Optional: specific model to use +baseUrl: http://localhost:8080/v1 # Optional: custom API URL for local models +context: # Optional: file filtering + include: # Glob patterns for files to include + - '**/*.js' + - '**/*.ts' + - '**/*.jsx' + - '**/*.tsx' + - 'package.json' + - '.nvmrc' + - 'Dockerfile' + exclude: # Glob patterns for files to exclude + - 'node_modules/**' + - 'dist/**' + - 'build/**' + - 'coverage/**' + - '*.min.js' +max_tokens: 8192 # Optional: max tokens for AI response +``` + +### Configuration Fields + +| Field | Required | Description | +| ----------------- | -------- | --------------------------------------------------------------------------------------------- | +| `provider` | Yes\* | AI provider: `claude`, `openai`, or `ollama`. Can be specified via `--provider` CLI flag. | +| `model` | No | Specific model to use. Defaults to provider's recommended model. | +| `baseUrl` | No | Custom API base URL for local models or OpenAI-compatible servers. | +| `context.include` | No | Glob patterns for files to analyze. Defaults to `["**/*"]`. | +| `context.exclude` | No | Glob patterns for files to exclude. Common paths like `node_modules` are excluded by default. | +| `max_tokens` | No | Maximum tokens for AI response. Defaults to 4096. | + +## CLI Usage + +### Basic Usage + +```bash +# Apply AI migration with inline prompt +shepherd ai ./my-migration "upgrade all dependencies to latest versions" +``` + +### CLI Options + +```bash +shepherd ai [options] + +Options: + --provider AI provider (claude, openai, or ollama) + --model AI model to use + --max-tokens Maximum tokens for AI response + --base-url Custom API base URL for local models + --repos Comma-separated list of repos to operate on +``` + +### Examples + +```bash +# Specify provider via CLI +shepherd ai ./migration "fix security vulnerabilities" --provider openai + +# Use specific model +shepherd ai ./migration "add TypeScript types" --provider claude --model claude-3-opus-20240229 + +# Target specific repos +shepherd ai ./migration "update README" --repos org/repo1,org/repo2 +``` + +## Supported Providers + +### Claude (Anthropic) + +- **Environment Variable**: `ANTHROPIC_API_KEY` +- **Default Model**: `claude-sonnet-4-20250514` +- **Documentation**: https://docs.anthropic.com/ + +### OpenAI + +- **Environment Variable**: `OPENAI_API_KEY` +- **Default Model**: `gpt-4o` +- **Documentation**: https://platform.openai.com/docs/ + +### Ollama (Local) + +- **Environment Variable**: `OLLAMA_HOST` (optional, defaults to `http://localhost:11434`) +- **Default Model**: `llama3.2` +- **Documentation**: https://ollama.ai/ + +Ollama is a popular tool for running LLMs locally. No API key is required. + +```yaml +# shepherd.yml +provider: ollama +model: codellama # or llama3.2, mistral, etc. +``` + +```bash +# Start Ollama server +ollama serve + +# Pull a model +ollama pull codellama + +# Run migration +shepherd ai ./migration "refactor this code" --provider ollama +``` + +## Local and Self-Hosted Models + +Shepherd supports local LLM servers through two approaches: + +### 1. Ollama Provider (Recommended for Ollama) + +Use the dedicated `ollama` provider for the simplest setup: + +```yaml +provider: ollama +model: llama3.2 +``` + +Set `OLLAMA_HOST` to point to a remote Ollama server: + +```bash +export OLLAMA_HOST=http://my-ollama-server:11434 +``` + +### 2. OpenAI-Compatible API (For Other Servers) + +Many local LLM servers provide OpenAI-compatible APIs. Use the `openai` provider with a custom `baseUrl`: + +```yaml +provider: openai +model: my-local-model +baseUrl: http://localhost:8080/v1 +``` + +This works with: +- **LM Studio**: `http://localhost:1234/v1` +- **vLLM**: `http://localhost:8000/v1` +- **LocalAI**: `http://localhost:8080/v1` +- **Text Generation WebUI**: `http://localhost:5000/v1` + +Example with LM Studio: + +```yaml +# shepherd.yml +id: local-migration +title: Local LLM Migration +adapter: + type: github + org: my-org +provider: openai +model: local-model +baseUrl: http://localhost:1234/v1 +``` + +```bash +# No API key needed for local servers +shepherd ai ./migration "fix all TODOs in the codebase" +``` + +## File Handling + +### Default Exclusions + +The following patterns are excluded by default: + +- `node_modules/**` +- `.git/**` +- `dist/**` +- `build/**` +- `coverage/**` +- `*.min.js` +- `*.min.css` +- `package-lock.json` +- `yarn.lock` +- `pnpm-lock.yaml` + +### File Size Limits + +- Files larger than 100KB are skipped to avoid token limits +- Binary files are automatically detected and skipped + +## Example Workflows + +### Dependency Upgrade + +```yaml +# shepherd.yml +id: lodash-v4-upgrade +title: Upgrade lodash to v4 +adapter: + type: github + search_query: org:my-org filename:package.json lodash +provider: claude +context: + include: + - '**/*.js' + - '**/*.ts' + - 'package.json' +``` + +```bash +shepherd checkout ./lodash-upgrade +shepherd ai ./lodash-upgrade "upgrade lodash from v3 to v4 and fix all breaking changes including _.pluck to _.map" +shepherd commit ./lodash-upgrade +shepherd push ./lodash-upgrade +shepherd pr ./lodash-upgrade +``` + +### Code Modernization + +```yaml +# shepherd.yml +id: async-await-migration +title: Convert callbacks to async/await +adapter: + type: github + org: my-org +provider: openai +context: + include: + - 'src/**/*.js' + exclude: + - '**/*.test.js' +``` + +```bash +shepherd checkout ./async-migration +shepherd ai ./async-migration "convert all callback-based functions to use async/await syntax, handling errors with try/catch" +shepherd commit ./async-migration +``` + +### Security Fix + +```yaml +# shepherd.yml +id: security-patch +title: Security vulnerability patches +adapter: + type: github + search_query: org:my-org filename:package.json axios +provider: claude +``` + +```bash +shepherd checkout ./security-patch +shepherd ai ./security-patch "upgrade axios to the latest version and update any deprecated API calls" +``` + +## Best Practices + +### Writing Effective Prompts + +1. **Be Specific**: Instead of "update the code", say "upgrade React from v17 to v18 and update all deprecated lifecycle methods" + +2. **Provide Context**: Mention the technologies involved, e.g., "in this TypeScript React application" + +3. **Describe Expected Behavior**: "Update the API calls to use the new v2 endpoint format: /api/v2/resource" + +4. **Mention Edge Cases**: "Handle both CommonJS require() and ES6 import statements" + +### File Filtering + +Use `context.include` and `context.exclude` to focus the AI on relevant files: + +```yaml +# Focus on TypeScript source files only +context: + include: + - 'src/**/*.ts' + - 'src/**/*.tsx' + exclude: + - '**/*.test.ts' + - '**/*.spec.ts' +``` + +### Review Changes + +Always review AI-generated changes before committing: + +```bash +# After running shepherd ai +cd ~/.shepherd//repos// +git diff +``` + +## Troubleshooting + +### API Key Errors + +``` +Missing API key: ANTHROPIC_API_KEY environment variable not set +``` + +Ensure you've exported the appropriate API key: + +```bash +export ANTHROPIC_API_KEY="your-key" +# or +export OPENAI_API_KEY="your-key" +``` + +### Provider Not Specified + +``` +AI provider is required. Specify via --provider flag or 'provider' in shepherd.yml +``` + +Add `provider: claude` or `provider: openai` to your shepherd.yml, or use `--provider` flag. + +### No Files Matched + +``` +No files matched the include patterns +``` + +Check your `context.include` patterns. The default is `["**/*"]` which matches all files. + +### File Too Large + +Large files (>100KB) are automatically skipped. If important files are being skipped, consider breaking them into smaller files or adjusting your include patterns to focus on specific sections. + +## Comparison with Traditional Migrations + +| Aspect | Traditional Migration | AI Migration | +| ----------------- | ----------------------------- | ----------------------------- | +| Change Definition | Shell scripts in `apply` hook | Natural language prompt | +| Deterministic | Yes | No (AI may vary) | +| Complex Logic | Requires scripting | Described in plain English | +| Review Required | Recommended | Essential | +| Best For | Precise, repeatable changes | Complex refactoring, upgrades | diff --git a/docs/tutorial.md b/docs/tutorial.md index a71c8ef0..270537af 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -268,3 +268,35 @@ shepherd commit ~/shepherd-migration shepherd push ~/shepherd-migration shepherd pr ~/shepherd-migration ``` + +## Alternative: AI-Powered Migrations + +For complex migrations that are difficult to express as shell scripts, Shepherd also supports AI-powered migrations. Instead of writing `apply` hooks, you describe the changes you want in natural language: + +```yaml +# shepherd.yml +id: 2024.01.15-eslint-config-upgrade +title: Upgrade ESLint configuration +adapter: + type: github + search_query: repo:YOURUSERNAME/shepherd-demo path:/ filename:.eslintrc +provider: claude +``` + +Then apply the migration with a natural language prompt: + +```sh +shepherd checkout ~/shepherd-migration +shepherd ai ~/shepherd-migration "rename .eslintrc to .eslintrc.yml and ensure the file format is valid YAML" +shepherd commit ~/shepherd-migration +shepherd push ~/shepherd-migration +shepherd pr ~/shepherd-migration +``` + +AI migrations are particularly useful for: + +- Complex refactoring (e.g., "convert all callback functions to async/await") +- Dependency upgrades with breaking changes (e.g., "upgrade React from v17 to v18") +- Code modernization (e.g., "add TypeScript types to all functions") + +Learn more in the [AI Migrations documentation](./ai-migrations.md). diff --git a/package-lock.json b/package-lock.json index b041be9b..990df3fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@shepherd-tools/shepherd", - "version": "3.1.1", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@shepherd-tools/shepherd", - "version": "3.1.1", + "version": "3.2.0", "license": "Apache-2.0", "dependencies": { + "@anthropic-ai/sdk": "^0.30.0", "@octokit/core": "^6.1.2", "@octokit/plugin-retry": "^7.1.2", "@octokit/plugin-throttling": "^9.3.2", @@ -18,11 +19,13 @@ "child-process-promise": "^2.2.1", "commander": "^12.1.0", "fs-extra": "^11.2.0", + "glob": "^11.0.0", "joi": "^17.13.3", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "log-symbols": "^7.0.0", "netrc": "^0.1.4", + "openai": "^4.70.0", "ora": "^9.0.0", "preferences": "^2.0.2", "simple-git": "^3.27.0", @@ -40,6 +43,7 @@ "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@types/fs-extra": "^11.0.4", + "@types/glob": "^8.1.0", "@types/jest": "^29.5.13", "@types/lodash": "^4.17.10", "@types/node": "^22.0.0", @@ -64,6 +68,36 @@ "node": ">=20" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.30.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.30.1.tgz", + "integrity": "sha512-nuKvp7wOIz6BFei8WrTdhmSsx5mwnArYyJgh4+vYu3V4J0Ltb8Xm3odPm51n1aSI0XxNCrDl7O88cxCtUdAkaw==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -2162,6 +2196,111 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2482,6 +2621,17 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/@jest/reporters/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2499,6 +2649,28 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@jest/reporters/node_modules/istanbul-lib-instrument": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", @@ -2516,6 +2688,19 @@ "node": ">=10" } }, + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@jest/reporters/node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -3970,6 +4155,17 @@ "@types/node": "*" } }, + "node_modules/@types/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "^5.1.2", + "@types/node": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -4055,16 +4251,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.18.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.11.tgz", "integrity": "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -4613,6 +4825,18 @@ "win32" ] }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -4646,6 +4870,18 @@ "node": ">= 14" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -4900,6 +5136,12 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -5229,7 +5471,6 @@ "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==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -5598,6 +5839,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -6047,6 +6300,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -6110,7 +6372,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -6131,6 +6392,12 @@ "readable-stream": "^2.0.2" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.237", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", @@ -6459,7 +6726,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6469,7 +6735,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6479,7 +6744,6 @@ "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==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -6492,7 +6756,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7103,6 +7366,15 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -7409,6 +7681,98 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/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/foreground-child/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/foreground-child/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/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -7460,7 +7824,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7556,7 +7919,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -7591,7 +7953,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -7661,22 +8022,23 @@ } }, "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "license": "BlueOak-1.0.0", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" }, "engines": { - "node": "*" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -7695,28 +8057,19 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { - "node": "*" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/globals": { @@ -7753,7 +8106,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7853,7 +8205,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7866,7 +8217,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -7882,7 +8232,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -7979,6 +8328,15 @@ "node": ">=10.17.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -8754,6 +9112,21 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/java-properties": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", @@ -9000,21 +9373,67 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/jest-config/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "license": "MIT", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "*" } }, "node_modules/jest-diff": { @@ -9527,6 +9946,17 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/jest-runtime/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -9544,6 +9974,41 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/jest-snapshot": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", @@ -10233,7 +10698,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10299,6 +10763,27 @@ "node": ">=16" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -10347,6 +10832,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -10408,6 +10902,26 @@ "integrity": "sha512-ye8AIYWQcP9MvoM1i0Z2jV0qed31Z8EWXYnyGNkiUAd+Fo8J+7uy90xTV8g/oAbhtjkY7iZbNTizQaXdKUuwpQ==", "license": "MIT" }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-emoji": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", @@ -10424,6 +10938,26 @@ "node": ">=18" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -13347,6 +13881,51 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -13538,6 +14117,12 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -13631,7 +14216,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13644,6 +14228,31 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -14846,7 +15455,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -14859,7 +15467,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -15322,6 +15929,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string-width/node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", @@ -15408,6 +16030,28 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -15643,6 +16287,28 @@ "concat-map": "0.0.1" } }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/test-exclude/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -15774,6 +16440,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/traverse": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz", @@ -16112,7 +16784,6 @@ "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/unicode-canonical-property-names-ecmascript": { @@ -16342,6 +17013,31 @@ "makeerror": "1.0.12" } }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", @@ -16478,6 +17174,53 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", diff --git a/package.json b/package.json index 1833dbf3..ad57ef36 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "lib" ], "dependencies": { + "@anthropic-ai/sdk": "^0.30.0", "@octokit/core": "^6.1.2", "@octokit/plugin-retry": "^7.1.2", "@octokit/plugin-throttling": "^9.3.2", @@ -52,11 +53,13 @@ "child-process-promise": "^2.2.1", "commander": "^12.1.0", "fs-extra": "^11.2.0", + "glob": "^11.0.0", "joi": "^17.13.3", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "log-symbols": "^7.0.0", "netrc": "^0.1.4", + "openai": "^4.70.0", "ora": "^9.0.0", "preferences": "^2.0.2", "simple-git": "^3.27.0", @@ -71,6 +74,7 @@ "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@types/fs-extra": "^11.0.4", + "@types/glob": "^8.1.0", "@types/jest": "^29.5.13", "@types/lodash": "^4.17.10", "@types/node": "^22.0.0", diff --git a/src/cli.ts b/src/cli.ts index 7a2ed369..745e441a 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,7 @@ import { loadSpec } from './util/migration-spec.js'; import { loadRepoList } from './util/persisted-data.js'; // Commands +import ai from './commands/ai.js'; import apply from './commands/apply.js'; import checkout from './commands/checkout.js'; import commit from './commands/commit.js'; @@ -25,6 +26,8 @@ import version from './commands/version.js'; import issue from './commands/issue.js'; import listIssues from './commands/list-issues.js'; +import { loadAISpec } from './util/ai-migration-spec.js'; + import ConsoleLogger from './logger/index.js'; const program = new Command(); @@ -134,6 +137,57 @@ applyCommand.option( ); applyCommand.action(handleCommand(apply)); +// AI command - special handler since it takes prompt argument +const aiCommand = program + .command('ai ') + .description('Apply AI-powered migration using natural language prompt'); +addReposOption(aiCommand); +aiCommand + .option('--provider ', 'AI provider (claude, openai, or ollama)') + .option('--model ', 'AI model to use') + .option('--max-tokens ', 'Maximum tokens for AI response', parseInt) + .option('--base-url ', 'Custom API base URL (for local models)'); +aiCommand.action(async (migration: string, prompt: string, options: any) => { + try { + const spec = loadAISpec(migration); + const migrationWorkingDirectory = path.join(prefs.workingDirectory, spec.id); + await fs.ensureDir(migrationWorkingDirectory); + + const migrationContext = { + migration: { + migrationDirectory: path.resolve(migration), + spec, + workingDirectory: migrationWorkingDirectory, + }, + shepherd: { + workingDirectory: prefs.workingDirectory, + }, + logger, + } as any; + + const adapter = adapterForName(spec.adapter.type, migrationContext); + migrationContext.adapter = adapter; + + const selectedRepos = options.repos && options.repos.map(adapter.parseRepo); + migrationContext.migration.selectedRepos = selectedRepos; + migrationContext.migration.repos = await loadRepoList(migrationContext); + + // Extract AI config from spec + const specConfig = { + provider: spec.provider, + model: spec.model, + context: spec.context, + max_tokens: spec.max_tokens, + baseUrl: spec.baseUrl, + }; + + await ai(migrationContext, prompt, options, specConfig); + } catch (e: any) { + logger.error(e); + process.exit(1); + } +}); + addCommand('commit', 'Commit all changes for the specified migration', true, commit); addCommand('reset', 'Reset all changes for the specified migration', true, reset); diff --git a/src/commands/ai.test.ts b/src/commands/ai.test.ts new file mode 100644 index 00000000..b4e54d99 --- /dev/null +++ b/src/commands/ai.test.ts @@ -0,0 +1,169 @@ +import ai from './ai'; +import executeAIApply from '../util/execute-ai-apply'; +import forEachRepo from '../util/for-each-repo'; + +jest.mock('../util/execute-ai-apply'); +jest.mock('../util/for-each-repo'); + +describe('ai command', () => { + const mockLogger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }; + + const mockAdapter = { + stringifyRepo: jest.fn((repo) => repo.name), + }; + + const mockContext = { + adapter: mockAdapter, + logger: mockLogger, + migration: { + repos: [{ name: 'org/repo1' }, { name: 'org/repo2' }], + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (forEachRepo as jest.Mock).mockImplementation(async (ctx, callback) => { + for (const repo of ctx.migration.repos) { + await callback(repo); + } + }); + (executeAIApply as jest.Mock).mockResolvedValue({ succeeded: true, stepResults: [] }); + }); + + describe('provider validation', () => { + it('should throw error if provider is not specified', async () => { + await expect(ai(mockContext as any, 'test prompt', {}, {})).rejects.toThrow( + 'AI provider is required' + ); + }); + + it('should throw error for unsupported provider', async () => { + await expect( + ai(mockContext as any, 'test prompt', { provider: 'unsupported' }, {}) + ).rejects.toThrow('Unsupported AI provider'); + }); + + it('should accept claude as provider', async () => { + await ai(mockContext as any, 'test prompt', { provider: 'claude' }, {}); + expect(executeAIApply).toHaveBeenCalled(); + }); + + it('should accept openai as provider', async () => { + await ai(mockContext as any, 'test prompt', { provider: 'openai' }, {}); + expect(executeAIApply).toHaveBeenCalled(); + }); + }); + + describe('option merging', () => { + it('should prefer CLI options over spec config', async () => { + await ai( + mockContext as any, + 'test prompt', + { provider: 'openai', model: 'gpt-4-turbo' }, + { provider: 'claude', model: 'claude-sonnet-4-20250514' } + ); + + expect(executeAIApply).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + provider: 'openai', + model: 'gpt-4-turbo', + }), + expect.anything() + ); + }); + + it('should use spec config when CLI options not provided', async () => { + await ai( + mockContext as any, + 'test prompt', + {}, + { provider: 'claude', model: 'claude-3-opus' } + ); + + expect(executeAIApply).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + provider: 'claude', + model: 'claude-3-opus', + }), + expect.anything() + ); + }); + }); + + describe('execution', () => { + it('should call executeAIApply for each repo', async () => { + await ai(mockContext as any, 'test prompt', { provider: 'claude' }, {}); + + expect(executeAIApply).toHaveBeenCalledTimes(2); + }); + + it('should pass prompt in AI config', async () => { + await ai(mockContext as any, 'migrate to node 24', { provider: 'claude' }, {}); + + expect(executeAIApply).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + prompt: 'migrate to node 24', + }), + expect.anything() + ); + }); + + it('should pass context config to AI', async () => { + const specConfig = { + provider: 'claude', + context: { include: ['**/*.ts'], exclude: ['node_modules/**'] }, + }; + + await ai(mockContext as any, 'test', {}, specConfig); + + expect(executeAIApply).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + context: { include: ['**/*.ts'], exclude: ['node_modules/**'] }, + }), + expect.anything() + ); + }); + + it('should log summary after completion', async () => { + await ai(mockContext as any, 'test', { provider: 'claude' }, {}); + + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Summary')); + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Succeeded: 2')); + }); + + it('should count failures separately', async () => { + (executeAIApply as jest.Mock) + .mockResolvedValueOnce({ succeeded: true, stepResults: [] }) + .mockResolvedValueOnce({ succeeded: false, stepResults: [] }); + + await ai(mockContext as any, 'test', { provider: 'claude' }, {}); + + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Succeeded: 1')); + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Failed: 1')); + }); + + it('should handle exceptions in executeAIApply', async () => { + (executeAIApply as jest.Mock) + .mockResolvedValueOnce({ succeeded: true, stepResults: [] }) + .mockRejectedValueOnce(new Error('API error')); + + await ai(mockContext as any, 'test', { provider: 'claude' }, {}); + + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Succeeded: 1')); + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Failed: 1')); + }); + }); +}); diff --git a/src/commands/ai.ts b/src/commands/ai.ts new file mode 100644 index 00000000..5b0bb632 --- /dev/null +++ b/src/commands/ai.ts @@ -0,0 +1,128 @@ +/** + * AI Command - Apply AI-powered migrations using natural language prompts + */ + +import chalk from 'chalk'; +import IRepoAdapter, { IRepo } from '../adapters/base.js'; +import { IMigrationContext } from '../migration-context.js'; +import { IAIConfig } from '../services/ai/types.js'; +import { isProviderSupported, getSupportedProviders } from '../services/ai/index.js'; +import executeAIApply from '../util/execute-ai-apply.js'; +import forEachRepo from '../util/for-each-repo.js'; + +export interface IAICommandOptions { + provider?: string; + model?: string; + repos?: string[]; + maxTokens?: number; + baseUrl?: string; +} + +const logRepoInfo = ( + repo: IRepo, + count: number, + total: number, + adapter: IRepoAdapter, + repoLogs: string[] +): void => { + const indexString = chalk.dim(`${count}/${total}`); + repoLogs.push(chalk.bold(`\n[${adapter.stringifyRepo(repo)}] ${indexString}`)); +}; + +/** + * Apply AI-powered migration to all checked out repositories + * + * @param context - The migration context + * @param prompt - The natural language prompt for the AI + * @param options - Command options (provider, model, etc.) + * @param specConfig - Configuration from shepherd.yml (provider, model, context, max_tokens) + */ +export default async function ai( + context: IMigrationContext, + prompt: string, + options: IAICommandOptions, + specConfig: { + provider?: string; + model?: string; + context?: { include?: string[]; exclude?: string[] }; + max_tokens?: number; + baseUrl?: string; + } +): Promise { + const { adapter, logger, migration } = context; + const repos = migration.repos || []; + + // Merge CLI options with spec config (CLI takes precedence) + const provider = options.provider || specConfig.provider; + const model = options.model || specConfig.model; + const maxTokens = options.maxTokens || specConfig.max_tokens; + const baseUrl = options.baseUrl || specConfig.baseUrl; + + // Validate provider is specified + if (!provider) { + const supportedList = getSupportedProviders().join(', '); + throw new Error( + `AI provider is required. Specify via --provider flag or 'provider' in shepherd.yml.\n` + + `Supported providers: ${supportedList}` + ); + } + + // Validate provider is supported + if (!isProviderSupported(provider)) { + const supportedList = getSupportedProviders().join(', '); + throw new Error(`Unsupported AI provider: ${provider}. Supported providers: ${supportedList}`); + } + + // Build AI config + const aiConfig: IAIConfig = { + provider, + model, + prompt, + context: specConfig.context, + max_tokens: maxTokens, + baseUrl, + }; + + logger.info(chalk.bold('AI Migration')); + logger.info(chalk.dim(`Provider: ${provider}`)); + if (model) { + logger.info(chalk.dim(`Model: ${model}`)); + } + if (baseUrl) { + logger.info(chalk.dim(`Base URL: ${baseUrl}`)); + } + logger.info(chalk.dim(`Prompt: ${prompt}`)); + logger.info(''); + + let count = 1; + let successCount = 0; + let failCount = 0; + + await forEachRepo(context, async (repo) => { + const repoLogs: string[] = []; + logRepoInfo(repo, count++, repos.length, adapter, repoLogs); + + try { + const result = await executeAIApply(context, repo, aiConfig, repoLogs); + + if (result.succeeded) { + successCount++; + } else { + failCount++; + } + } catch (error: any) { + repoLogs.push(chalk.red(`Error: ${error.message}`)); + failCount++; + } + + repoLogs.forEach((log) => logger.info(log)); + }); + + // Summary + logger.info(''); + logger.info(chalk.bold('Summary')); + logger.info(chalk.green(` Succeeded: ${successCount}`)); + if (failCount > 0) { + logger.info(chalk.red(` Failed: ${failCount}`)); + } +} diff --git a/src/services/ai/file-operations.test.ts b/src/services/ai/file-operations.test.ts new file mode 100644 index 00000000..ac7fc5b8 --- /dev/null +++ b/src/services/ai/file-operations.test.ts @@ -0,0 +1,141 @@ +import { readRepoFiles, applyEdits } from './file-operations'; +import fs from 'fs-extra'; +import { glob } from 'glob'; + +jest.mock('fs-extra'); +jest.mock('glob'); + +describe('file-operations', () => { + const mockRepoDir = '/mock/repo'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('readRepoFiles', () => { + it('should read files matching include patterns', async () => { + (glob as unknown as jest.Mock).mockResolvedValue(['src/index.ts', 'src/utils.ts']); + (fs.stat as jest.Mock).mockResolvedValue({ size: 1024 }); + (fs.readFile as jest.Mock) + .mockResolvedValueOnce('const a = 1;') + .mockResolvedValueOnce('const b = 2;'); + + const result = await readRepoFiles(mockRepoDir, { + context: { include: ['**/*.ts'] }, + }); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ path: 'src/index.ts', content: 'const a = 1;' }); + expect(result[1]).toEqual({ path: 'src/utils.ts', content: 'const b = 2;' }); + }); + + it('should use default patterns when no context provided', async () => { + (glob as unknown as jest.Mock).mockResolvedValue([]); + + await readRepoFiles(mockRepoDir, {}); + + expect(glob).toHaveBeenCalledWith( + '**/*', + expect.objectContaining({ + cwd: mockRepoDir, + nodir: true, + }) + ); + }); + + it('should skip files larger than 100KB', async () => { + (glob as unknown as jest.Mock).mockResolvedValue(['large-file.ts']); + (fs.stat as jest.Mock).mockResolvedValue({ size: 150 * 1024 }); // 150KB + + const result = await readRepoFiles(mockRepoDir, {}); + + expect(result).toHaveLength(0); + expect(fs.readFile).not.toHaveBeenCalled(); + }); + + it('should skip binary files', async () => { + (glob as unknown as jest.Mock).mockResolvedValue(['binary.bin']); + (fs.stat as jest.Mock).mockResolvedValue({ size: 1024 }); + (fs.readFile as jest.Mock).mockResolvedValue('binary\0content'); + + const result = await readRepoFiles(mockRepoDir, {}); + + expect(result).toHaveLength(0); + }); + + it('should skip unreadable files', async () => { + (glob as unknown as jest.Mock).mockResolvedValue(['unreadable.ts']); + (fs.stat as jest.Mock).mockRejectedValue(new Error('EACCES')); + + const result = await readRepoFiles(mockRepoDir, {}); + + expect(result).toHaveLength(0); + }); + }); + + describe('applyEdits', () => { + it('should create a new file', async () => { + (fs.ensureDir as jest.Mock).mockResolvedValue(undefined); + (fs.writeFile as jest.Mock).mockResolvedValue(undefined); + + await applyEdits(mockRepoDir, [ + { path: 'src/new-file.ts', action: 'create', content: 'const x = 1;' }, + ]); + + expect(fs.ensureDir).toHaveBeenCalled(); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('new-file.ts'), + 'const x = 1;' + ); + }); + + it('should modify an existing file', async () => { + (fs.ensureDir as jest.Mock).mockResolvedValue(undefined); + (fs.writeFile as jest.Mock).mockResolvedValue(undefined); + + await applyEdits(mockRepoDir, [ + { path: 'src/index.ts', action: 'modify', content: 'const updated = true;' }, + ]); + + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('index.ts'), + 'const updated = true;' + ); + }); + + it('should delete an existing file', async () => { + (fs.pathExists as jest.Mock).mockResolvedValue(true); + (fs.remove as jest.Mock).mockResolvedValue(undefined); + + await applyEdits(mockRepoDir, [{ path: 'src/delete-me.ts', action: 'delete' }]); + + expect(fs.remove).toHaveBeenCalled(); + }); + + it('should not delete if file does not exist', async () => { + (fs.pathExists as jest.Mock).mockResolvedValue(false); + + await applyEdits(mockRepoDir, [{ path: 'src/nonexistent.ts', action: 'delete' }]); + + expect(fs.remove).not.toHaveBeenCalled(); + }); + + it('should throw error for path traversal attempt', async () => { + await expect( + applyEdits(mockRepoDir, [{ path: '../../../etc/passwd', action: 'create', content: 'bad' }]) + ).rejects.toThrow('Security error'); + }); + + it('should throw error for missing content on create', async () => { + await expect( + applyEdits(mockRepoDir, [{ path: 'src/file.ts', action: 'create' }]) + ).rejects.toThrow('Missing content'); + }); + + it('should throw error for missing content on modify', async () => { + await expect( + applyEdits(mockRepoDir, [{ path: 'src/file.ts', action: 'modify' }]) + ).rejects.toThrow('Missing content'); + }); + }); +}); diff --git a/src/services/ai/file-operations.ts b/src/services/ai/file-operations.ts new file mode 100644 index 00000000..a5ca50f1 --- /dev/null +++ b/src/services/ai/file-operations.ts @@ -0,0 +1,118 @@ +/** + * File operations for AI migrations + */ + +import fs from 'fs-extra'; +import path from 'path'; +import { glob } from 'glob'; +import { IFileContent, IFileEdit, IAIConfig } from './types.js'; + +const DEFAULT_EXCLUDE_PATTERNS = [ + 'node_modules/**', + '.git/**', + 'dist/**', + 'build/**', + 'coverage/**', + '*.min.js', + '*.min.css', + 'package-lock.json', + 'yarn.lock', + 'pnpm-lock.yaml', +]; + +const DEFAULT_INCLUDE_PATTERNS = ['**/*']; + +/** + * Read files from a repository based on include/exclude patterns + */ +export async function readRepoFiles( + repoDir: string, + config: Pick +): Promise { + const includePatterns = config.context?.include || DEFAULT_INCLUDE_PATTERNS; + const excludePatterns = [...DEFAULT_EXCLUDE_PATTERNS, ...(config.context?.exclude || [])]; + + const files: IFileContent[] = []; + + for (const pattern of includePatterns) { + const matches = await glob(pattern, { + cwd: repoDir, + ignore: excludePatterns, + nodir: true, + absolute: false, + }); + + for (const match of matches) { + const filePath = path.join(repoDir, match); + try { + const stat = await fs.stat(filePath); + // Skip files larger than 100KB to avoid token limits + if (stat.size > 100 * 1024) { + continue; + } + + const content = await fs.readFile(filePath, 'utf-8'); + // Skip binary files + if (!isBinaryContent(content)) { + files.push({ path: match, content }); + } + } catch { + // Skip unreadable files + } + } + } + + return files; +} + +/** + * Apply file edits to the repository + */ +export async function applyEdits(repoDir: string, edits: IFileEdit[]): Promise { + for (const edit of edits) { + const filePath = path.join(repoDir, edit.path); + + // Security check: ensure the path is within the repo directory + const resolvedPath = path.resolve(filePath); + const resolvedRepoDir = path.resolve(repoDir); + if (!resolvedPath.startsWith(resolvedRepoDir)) { + throw new Error(`Security error: Path ${edit.path} is outside the repository`); + } + + switch (edit.action) { + case 'create': + case 'modify': + if (edit.content === undefined) { + throw new Error(`Missing content for ${edit.action} action on ${edit.path}`); + } + await fs.ensureDir(path.dirname(filePath)); + await fs.writeFile(filePath, edit.content); + break; + case 'delete': + if (await fs.pathExists(filePath)) { + await fs.remove(filePath); + } + break; + default: + throw new Error(`Unknown edit action: ${(edit as IFileEdit).action}`); + } + } +} + +/** + * Check if content appears to be binary + */ +function isBinaryContent(content: string): boolean { + // Check for null bytes (common in binary files) + if (content.includes('\0')) { + return true; + } + + // Check for high ratio of non-printable characters + const nonPrintable = content.split('').filter((char) => { + const code = char.charCodeAt(0); + return code < 32 && code !== 9 && code !== 10 && code !== 13; + }).length; + + return nonPrintable / content.length > 0.1; +} diff --git a/src/services/ai/index.test.ts b/src/services/ai/index.test.ts new file mode 100644 index 00000000..761a6d2f --- /dev/null +++ b/src/services/ai/index.test.ts @@ -0,0 +1,94 @@ +import { createAIProvider, isProviderSupported, getSupportedProviders } from './index'; +import { ClaudeProvider } from './providers/claude'; +import { OpenAIProvider } from './providers/openai'; +import { OllamaProvider } from './providers/ollama'; + +// Mock the providers +jest.mock('./providers/claude'); +jest.mock('./providers/openai'); +jest.mock('./providers/ollama'); + +describe('AI Service Factory', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { + ...originalEnv, + ANTHROPIC_API_KEY: 'test-anthropic-key', + OPENAI_API_KEY: 'test-openai-key', + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('createAIProvider', () => { + it('should create ClaudeProvider for "claude"', () => { + createAIProvider('claude'); + expect(ClaudeProvider).toHaveBeenCalled(); + }); + + it('should create OpenAIProvider for "openai"', () => { + createAIProvider('openai'); + expect(OpenAIProvider).toHaveBeenCalled(); + }); + + it('should create OllamaProvider for "ollama"', () => { + createAIProvider('ollama'); + expect(OllamaProvider).toHaveBeenCalled(); + }); + + it('should pass baseUrl option to OpenAIProvider', () => { + createAIProvider('openai', { baseUrl: 'http://localhost:8080/v1' }); + expect(OpenAIProvider).toHaveBeenCalledWith({ baseUrl: 'http://localhost:8080/v1' }); + }); + + it('should throw error for unsupported provider', () => { + expect(() => createAIProvider('unsupported')).toThrow('Unsupported AI provider'); + }); + + it('should include supported providers in error message', () => { + expect(() => createAIProvider('gemini')).toThrow('claude, openai, ollama'); + }); + }); + + describe('isProviderSupported', () => { + it('should return true for claude', () => { + expect(isProviderSupported('claude')).toBe(true); + }); + + it('should return true for openai', () => { + expect(isProviderSupported('openai')).toBe(true); + }); + + it('should return true for ollama', () => { + expect(isProviderSupported('ollama')).toBe(true); + }); + + it('should return false for unsupported provider', () => { + expect(isProviderSupported('gemini')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isProviderSupported('')).toBe(false); + }); + }); + + describe('getSupportedProviders', () => { + it('should return array of supported providers', () => { + const providers = getSupportedProviders(); + expect(providers).toContain('claude'); + expect(providers).toContain('openai'); + expect(providers).toContain('ollama'); + expect(providers).toHaveLength(3); + }); + + it('should return a copy of the array', () => { + const providers1 = getSupportedProviders(); + const providers2 = getSupportedProviders(); + expect(providers1).not.toBe(providers2); + }); + }); +}); diff --git a/src/services/ai/index.ts b/src/services/ai/index.ts new file mode 100644 index 00000000..46c3334e --- /dev/null +++ b/src/services/ai/index.ts @@ -0,0 +1,56 @@ +/** + * AI Service Factory + */ + +import { AIProvider } from './providers/base.js'; +import { ClaudeProvider } from './providers/claude.js'; +import { OpenAIProvider } from './providers/openai.js'; +import { OllamaProvider } from './providers/ollama.js'; + +export type SupportedProvider = 'claude' | 'openai' | 'ollama'; + +const SUPPORTED_PROVIDERS: SupportedProvider[] = ['claude', 'openai', 'ollama']; + +export interface CreateProviderOptions { + /** Custom API base URL (for OpenAI-compatible servers) */ + baseUrl?: string; +} + +/** + * Create an AI provider instance based on the provider name + */ +export function createAIProvider(provider: string, options?: CreateProviderOptions): AIProvider { + if (!SUPPORTED_PROVIDERS.includes(provider as SupportedProvider)) { + throw new Error( + `Unsupported AI provider: ${provider}. Supported providers: ${SUPPORTED_PROVIDERS.join(', ')}` + ); + } + + switch (provider) { + case 'claude': + return new ClaudeProvider(); + case 'openai': + return new OpenAIProvider({ baseUrl: options?.baseUrl }); + case 'ollama': + return new OllamaProvider(); + default: + throw new Error(`Unknown AI provider: ${provider}`); + } +} + +/** + * Check if a provider is supported + */ +export function isProviderSupported(provider: string): provider is SupportedProvider { + return SUPPORTED_PROVIDERS.includes(provider as SupportedProvider); +} + +/** + * Get list of supported providers + */ +export function getSupportedProviders(): SupportedProvider[] { + return [...SUPPORTED_PROVIDERS]; +} + +export { AIProvider } from './providers/base.js'; +export * from './types.js'; diff --git a/src/services/ai/providers/base.ts b/src/services/ai/providers/base.ts new file mode 100644 index 00000000..02c9a41d --- /dev/null +++ b/src/services/ai/providers/base.ts @@ -0,0 +1,88 @@ +/** + * Abstract base class for AI providers + */ + +import { IAIRequest, IAIResponse } from '../types.js'; + +export abstract class AIProvider { + protected apiKey: string; + + constructor(apiKeyEnvVar: string, options?: { optional?: boolean }) { + const key = process.env[apiKeyEnvVar]; + if (!key && !options?.optional) { + throw new Error( + `Missing API key: ${apiKeyEnvVar} environment variable not set. ` + + `Please set ${apiKeyEnvVar} to use this AI provider.` + ); + } + this.apiKey = key || ''; + } + + /** + * Generate file edits based on the prompt and repository files + */ + abstract generateEdits(request: IAIRequest): Promise; + + /** + * Build the system prompt for the AI + */ + protected buildSystemPrompt(): string { + return `You are a code migration assistant. Given a repository's files and a migration task, +you must output the exact file changes needed as a JSON object. + +Output format (JSON only, no markdown): +{ + "edits": [ + { + "path": "relative/path/to/file.js", + "action": "modify", + "content": "full new content of the file" + } + ], + "explanation": "Brief explanation of changes made" +} + +Rules: +- Output ONLY valid JSON, no markdown code blocks or other text +- Include the COMPLETE new file content for modifications +- Use relative paths from the repository root +- For "create" action: provide full file content +- For "modify" action: provide complete new file content +- For "delete" action: content is optional +- If no changes are needed, return: {"edits": [], "explanation": "No changes needed"} +- Do not modify files that don't require changes`; + } + + /** + * Build the user message with files and prompt + */ + protected buildUserMessage(request: IAIRequest): string { + const filesSection = request.files.map((f) => `=== ${f.path} ===\n${f.content}`).join('\n\n'); + + return `Migration Task: ${request.prompt} + +Repository Files: +${filesSection} + +Please analyze the files and provide the necessary edits as JSON.`; + } + + /** + * Parse JSON response from AI, handling potential formatting issues + */ + protected parseJsonResponse(text: string): IAIResponse { + // Try to extract JSON from markdown code blocks if present + const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/); + const jsonText = jsonMatch ? jsonMatch[1] : text; + + try { + const parsed = JSON.parse(jsonText.trim()); + return { + edits: parsed.edits || [], + explanation: parsed.explanation, + }; + } catch { + throw new Error(`Failed to parse AI response as JSON: ${text.substring(0, 200)}...`); + } + } +} diff --git a/src/services/ai/providers/claude.test.ts b/src/services/ai/providers/claude.test.ts new file mode 100644 index 00000000..2264dc0f --- /dev/null +++ b/src/services/ai/providers/claude.test.ts @@ -0,0 +1,117 @@ +import { ClaudeProvider } from './claude'; + +// Mock the Anthropic SDK +jest.mock('@anthropic-ai/sdk', () => { + return jest.fn().mockImplementation(() => ({ + messages: { + create: jest.fn(), + }, + })); +}); + +describe('ClaudeProvider', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv, ANTHROPIC_API_KEY: 'test-api-key' }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('constructor', () => { + it('should throw error if ANTHROPIC_API_KEY is not set', () => { + delete process.env.ANTHROPIC_API_KEY; + expect(() => new ClaudeProvider()).toThrow('Missing API key: ANTHROPIC_API_KEY'); + }); + + it('should initialize with API key from environment', () => { + const provider = new ClaudeProvider(); + expect(provider).toBeDefined(); + }); + }); + + describe('generateEdits', () => { + it('should call Anthropic API with correct parameters', async () => { + const Anthropic = require('@anthropic-ai/sdk'); + const mockCreate = jest.fn().mockResolvedValue({ + content: [ + { + type: 'text', + text: JSON.stringify({ + edits: [{ path: 'test.ts', action: 'modify', content: 'new content' }], + explanation: 'Updated test file', + }), + }, + ], + usage: { output_tokens: 100 }, + }); + Anthropic.mockImplementation(() => ({ + messages: { create: mockCreate }, + })); + + const provider = new ClaudeProvider(); + const result = await provider.generateEdits({ + prompt: 'Fix the bug', + files: [{ path: 'test.ts', content: 'old content' }], + repoDir: '/mock/repo', + }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'claude-sonnet-4-20250514', + max_tokens: 4096, + }) + ); + expect(result.edits).toHaveLength(1); + expect(result.explanation).toBe('Updated test file'); + expect(result.tokensUsed).toBe(100); + }); + + it('should use custom model when specified', async () => { + const Anthropic = require('@anthropic-ai/sdk'); + const mockCreate = jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: '{"edits":[],"explanation":"No changes"}' }], + usage: {}, + }); + Anthropic.mockImplementation(() => ({ + messages: { create: mockCreate }, + })); + + const provider = new ClaudeProvider(); + await provider.generateEdits({ + prompt: 'Test', + files: [], + repoDir: '/mock/repo', + model: 'claude-3-opus-20240229', + }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'claude-3-opus-20240229', + }) + ); + }); + + it('should throw error if no text response', async () => { + const Anthropic = require('@anthropic-ai/sdk'); + const mockCreate = jest.fn().mockResolvedValue({ + content: [], + }); + Anthropic.mockImplementation(() => ({ + messages: { create: mockCreate }, + })); + + const provider = new ClaudeProvider(); + await expect( + provider.generateEdits({ + prompt: 'Test', + files: [], + repoDir: '/mock/repo', + }) + ).rejects.toThrow('No text response from Claude'); + }); + }); +}); diff --git a/src/services/ai/providers/claude.ts b/src/services/ai/providers/claude.ts new file mode 100644 index 00000000..49796750 --- /dev/null +++ b/src/services/ai/providers/claude.ts @@ -0,0 +1,39 @@ +/** + * Claude (Anthropic) AI Provider + */ + +import Anthropic from '@anthropic-ai/sdk'; +import { AIProvider } from './base.js'; +import { IAIRequest, IAIResponse } from '../types.js'; + +export class ClaudeProvider extends AIProvider { + private client: Anthropic; + + constructor() { + super('ANTHROPIC_API_KEY'); + this.client = new Anthropic({ apiKey: this.apiKey }); + } + + async generateEdits(request: IAIRequest): Promise { + const systemPrompt = this.buildSystemPrompt(); + const userMessage = this.buildUserMessage(request); + + const response = await this.client.messages.create({ + model: request.model || 'claude-sonnet-4-20250514', + max_tokens: request.maxTokens || 4096, + system: systemPrompt, + messages: [{ role: 'user', content: userMessage }], + }); + + // Extract text content from response + const textContent = response.content.find((block) => block.type === 'text'); + if (!textContent || textContent.type !== 'text') { + throw new Error('No text response from Claude'); + } + + const result = this.parseJsonResponse(textContent.text); + result.tokensUsed = response.usage?.output_tokens; + + return result; + } +} diff --git a/src/services/ai/providers/ollama.test.ts b/src/services/ai/providers/ollama.test.ts new file mode 100644 index 00000000..fcaa9837 --- /dev/null +++ b/src/services/ai/providers/ollama.test.ts @@ -0,0 +1,140 @@ +import { OllamaProvider } from './ollama'; + +// Mock the OpenAI SDK (Ollama uses OpenAI-compatible API) +jest.mock('openai', () => { + return jest.fn().mockImplementation(() => ({ + chat: { + completions: { + create: jest.fn(), + }, + }, + })); +}); + +describe('OllamaProvider', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + // Ollama doesn't require an API key + delete process.env.OLLAMA_API_KEY; + delete process.env.OLLAMA_HOST; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('constructor', () => { + it('should initialize without API key (Ollama does not require one)', () => { + const provider = new OllamaProvider(); + expect(provider).toBeDefined(); + }); + + it('should use default localhost URL', () => { + const OpenAI = require('openai'); + new OllamaProvider(); + + expect(OpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: 'http://localhost:11434/v1', + }) + ); + }); + + it('should use OLLAMA_HOST env var if set', () => { + process.env.OLLAMA_HOST = 'http://my-ollama-server:11434'; + const OpenAI = require('openai'); + new OllamaProvider(); + + expect(OpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: 'http://my-ollama-server:11434/v1', + }) + ); + }); + }); + + describe('generateEdits', () => { + it('should call Ollama API with correct parameters', async () => { + const OpenAI = require('openai'); + const mockCreate = jest.fn().mockResolvedValue({ + choices: [ + { + message: { + content: JSON.stringify({ + edits: [{ path: 'test.ts', action: 'modify', content: 'new content' }], + explanation: 'Updated test file', + }), + }, + }, + ], + usage: { completion_tokens: 50 }, + }); + OpenAI.mockImplementation(() => ({ + chat: { completions: { create: mockCreate } }, + })); + + const provider = new OllamaProvider(); + const result = await provider.generateEdits({ + prompt: 'Fix the bug', + files: [{ path: 'test.ts', content: 'old content' }], + repoDir: '/mock/repo', + }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'llama3.2', + response_format: { type: 'json_object' }, + }) + ); + expect(result.edits).toHaveLength(1); + expect(result.explanation).toBe('Updated test file'); + }); + + it('should use custom model when specified', async () => { + const OpenAI = require('openai'); + const mockCreate = jest.fn().mockResolvedValue({ + choices: [{ message: { content: '{"edits":[],"explanation":"No changes"}' } }], + usage: {}, + }); + OpenAI.mockImplementation(() => ({ + chat: { completions: { create: mockCreate } }, + })); + + const provider = new OllamaProvider(); + await provider.generateEdits({ + prompt: 'Test', + files: [], + repoDir: '/mock/repo', + model: 'codellama', + }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'codellama', + }) + ); + }); + + it('should throw error if no response content', async () => { + const OpenAI = require('openai'); + const mockCreate = jest.fn().mockResolvedValue({ + choices: [{ message: { content: null } }], + }); + OpenAI.mockImplementation(() => ({ + chat: { completions: { create: mockCreate } }, + })); + + const provider = new OllamaProvider(); + await expect( + provider.generateEdits({ + prompt: 'Test', + files: [], + repoDir: '/mock/repo', + }) + ).rejects.toThrow('No response content from Ollama'); + }); + }); +}); diff --git a/src/services/ai/providers/ollama.ts b/src/services/ai/providers/ollama.ts new file mode 100644 index 00000000..56904a28 --- /dev/null +++ b/src/services/ai/providers/ollama.ts @@ -0,0 +1,68 @@ +/** + * Ollama AI Provider + * + * Ollama is a popular tool for running LLMs locally. + * This provider uses Ollama's OpenAI-compatible API endpoint. + * + * @see https://ollama.ai + */ + +import OpenAI from 'openai'; +import { AIProvider } from './base.js'; +import { IAIRequest, IAIResponse } from '../types.js'; + +const DEFAULT_OLLAMA_HOST = 'http://localhost:11434'; +const DEFAULT_MODEL = 'llama3.2'; + +export class OllamaProvider extends AIProvider { + private client: OpenAI; + private baseUrl: string; + + constructor() { + // Ollama doesn't require an API key, but we check for OLLAMA_HOST + super('OLLAMA_API_KEY', { optional: true }); + + // Use OLLAMA_HOST env var or default to localhost + const host = process.env.OLLAMA_HOST || DEFAULT_OLLAMA_HOST; + this.baseUrl = `${host}/v1`; + + this.client = new OpenAI({ + apiKey: 'ollama', // Ollama doesn't validate this, but OpenAI client requires it + baseURL: this.baseUrl, + }); + } + + async generateEdits(request: IAIRequest): Promise { + const systemPrompt = this.buildSystemPrompt(); + const userMessage = this.buildUserMessage(request); + + // Use request-level baseUrl if provided + let client = this.client; + if (request.baseUrl) { + client = new OpenAI({ + apiKey: 'ollama', + baseURL: request.baseUrl, + }); + } + + const response = await client.chat.completions.create({ + model: request.model || DEFAULT_MODEL, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ], + // Ollama supports JSON mode via format parameter + response_format: { type: 'json_object' }, + }); + + const content = response.choices[0]?.message?.content; + if (!content) { + throw new Error('No response content from Ollama'); + } + + const result = this.parseJsonResponse(content); + result.tokensUsed = response.usage?.completion_tokens; + + return result; + } +} diff --git a/src/services/ai/providers/openai.test.ts b/src/services/ai/providers/openai.test.ts new file mode 100644 index 00000000..8167caf1 --- /dev/null +++ b/src/services/ai/providers/openai.test.ts @@ -0,0 +1,138 @@ +import { OpenAIProvider } from './openai'; + +// Mock the OpenAI SDK +jest.mock('openai', () => { + return jest.fn().mockImplementation(() => ({ + chat: { + completions: { + create: jest.fn(), + }, + }, + })); +}); + +describe('OpenAIProvider', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv, OPENAI_API_KEY: 'test-api-key' }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('constructor', () => { + it('should throw error if OPENAI_API_KEY is not set', () => { + delete process.env.OPENAI_API_KEY; + expect(() => new OpenAIProvider()).toThrow('Missing API key: OPENAI_API_KEY'); + }); + + it('should initialize with API key from environment', () => { + const provider = new OpenAIProvider(); + expect(provider).toBeDefined(); + }); + + it('should accept custom baseUrl', () => { + const OpenAI = require('openai'); + new OpenAIProvider({ baseUrl: 'http://localhost:8080/v1' }); + + expect(OpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: 'http://localhost:8080/v1', + }) + ); + }); + + it('should not require API key when baseUrl is provided', () => { + delete process.env.OPENAI_API_KEY; + const provider = new OpenAIProvider({ baseUrl: 'http://localhost:8080/v1' }); + expect(provider).toBeDefined(); + }); + }); + + describe('generateEdits', () => { + it('should call OpenAI API with correct parameters', async () => { + const OpenAI = require('openai'); + const mockCreate = jest.fn().mockResolvedValue({ + choices: [ + { + message: { + content: JSON.stringify({ + edits: [{ path: 'test.ts', action: 'modify', content: 'new content' }], + explanation: 'Updated test file', + }), + }, + }, + ], + usage: { completion_tokens: 50 }, + }); + OpenAI.mockImplementation(() => ({ + chat: { completions: { create: mockCreate } }, + })); + + const provider = new OpenAIProvider(); + const result = await provider.generateEdits({ + prompt: 'Fix the bug', + files: [{ path: 'test.ts', content: 'old content' }], + repoDir: '/mock/repo', + }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'gpt-4o', + max_tokens: 4096, + response_format: { type: 'json_object' }, + }) + ); + expect(result.edits).toHaveLength(1); + expect(result.explanation).toBe('Updated test file'); + expect(result.tokensUsed).toBe(50); + }); + + it('should use custom model when specified', async () => { + const OpenAI = require('openai'); + const mockCreate = jest.fn().mockResolvedValue({ + choices: [{ message: { content: '{"edits":[],"explanation":"No changes"}' } }], + usage: {}, + }); + OpenAI.mockImplementation(() => ({ + chat: { completions: { create: mockCreate } }, + })); + + const provider = new OpenAIProvider(); + await provider.generateEdits({ + prompt: 'Test', + files: [], + repoDir: '/mock/repo', + model: 'gpt-4-turbo', + }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'gpt-4-turbo', + }) + ); + }); + + it('should throw error if no response content', async () => { + const OpenAI = require('openai'); + const mockCreate = jest.fn().mockResolvedValue({ + choices: [{ message: { content: null } }], + }); + OpenAI.mockImplementation(() => ({ + chat: { completions: { create: mockCreate } }, + })); + + const provider = new OpenAIProvider(); + await expect( + provider.generateEdits({ + prompt: 'Test', + files: [], + repoDir: '/mock/repo', + }) + ).rejects.toThrow('No response content from OpenAI'); + }); + }); +}); diff --git a/src/services/ai/providers/openai.ts b/src/services/ai/providers/openai.ts new file mode 100644 index 00000000..3d6fa52e --- /dev/null +++ b/src/services/ai/providers/openai.ts @@ -0,0 +1,66 @@ +/** + * OpenAI AI Provider + * + * Also supports OpenAI-compatible APIs (Ollama, LM Studio, vLLM, LocalAI) + * via the baseUrl option. + */ + +import OpenAI from 'openai'; +import { AIProvider } from './base.js'; +import { IAIRequest, IAIResponse } from '../types.js'; + +export interface OpenAIProviderOptions { + /** Custom API base URL for local/compatible servers */ + baseUrl?: string; +} + +export class OpenAIProvider extends AIProvider { + private client: OpenAI; + private defaultBaseUrl?: string; + + constructor(options?: OpenAIProviderOptions) { + // API key is optional when using a custom baseUrl (local servers often don't require auth) + super('OPENAI_API_KEY', { optional: !!options?.baseUrl }); + + this.defaultBaseUrl = options?.baseUrl; + + this.client = new OpenAI({ + apiKey: this.apiKey || 'not-required', // Some local servers need a placeholder + baseURL: options?.baseUrl, + }); + } + + async generateEdits(request: IAIRequest): Promise { + const systemPrompt = this.buildSystemPrompt(); + const userMessage = this.buildUserMessage(request); + + // Use request-level baseUrl if provided, otherwise use constructor default + let client = this.client; + if (request.baseUrl && request.baseUrl !== this.defaultBaseUrl) { + client = new OpenAI({ + apiKey: this.apiKey || 'not-required', + baseURL: request.baseUrl, + }); + } + + const response = await client.chat.completions.create({ + model: request.model || 'gpt-4o', + max_tokens: request.maxTokens || 4096, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ], + response_format: { type: 'json_object' }, + }); + + const content = response.choices[0]?.message?.content; + if (!content) { + throw new Error('No response content from OpenAI'); + } + + const result = this.parseJsonResponse(content); + result.tokensUsed = response.usage?.completion_tokens; + + return result; + } +} diff --git a/src/services/ai/types.ts b/src/services/ai/types.ts new file mode 100644 index 00000000..e8a83bc1 --- /dev/null +++ b/src/services/ai/types.ts @@ -0,0 +1,78 @@ +/** + * Types for AI-powered migrations + */ + +/** + * Represents a file edit generated by the AI + */ +export interface IFileEdit { + /** Relative path from repository root */ + path: string; + /** Type of edit operation */ + action: 'create' | 'modify' | 'delete'; + /** New content for create/modify operations */ + content?: string; +} + +/** + * Response from AI provider after generating edits + */ +export interface IAIResponse { + /** List of file edits to apply */ + edits: IFileEdit[]; + /** Optional explanation of changes made */ + explanation?: string; + /** Number of tokens used (if available) */ + tokensUsed?: number; +} + +/** + * Request to AI provider for generating edits + */ +export interface IAIRequest { + /** The migration prompt/instruction */ + prompt: string; + /** Files from the repository to analyze */ + files: IFileContent[]; + /** Path to the repository directory */ + repoDir: string; + /** AI model to use (provider-specific) */ + model?: string; + /** Maximum tokens for response */ + maxTokens?: number; + /** Custom API base URL (for local models or proxies) */ + baseUrl?: string; +} + +/** + * File content read from repository + */ +export interface IFileContent { + /** Relative path from repository root */ + path: string; + /** File content as string */ + content: string; +} + +/** + * AI configuration from shepherd.yml or CLI + */ +export interface IAIConfig { + /** AI provider: 'claude', 'openai', or 'ollama' */ + provider: string; + /** Model identifier (provider-specific) */ + model?: string; + /** The migration prompt */ + prompt: string; + /** File filtering configuration */ + context?: { + /** Glob patterns for files to include */ + include?: string[]; + /** Glob patterns for files to exclude */ + exclude?: string[]; + }; + /** Maximum tokens for AI response */ + max_tokens?: number; + /** Custom API base URL (for local models or proxies) */ + baseUrl?: string; +} diff --git a/src/util/ai-migration-spec.test.ts b/src/util/ai-migration-spec.test.ts new file mode 100644 index 00000000..10f42791 --- /dev/null +++ b/src/util/ai-migration-spec.test.ts @@ -0,0 +1,137 @@ +import { loadAISpec, validateAISpec, hasShepherdYaml } from './ai-migration-spec'; +import fs from 'fs'; +import path from 'path'; +import yaml from 'js-yaml'; + +jest.mock('fs'); +jest.mock('path'); +jest.mock('js-yaml'); + +describe('ai-migration-spec', () => { + const mockDirectory = '/mock/directory'; + const mockFilePath = '/mock/directory/shepherd.yml'; + const mockSpec = { + id: 'test-ai-migration', + title: 'Test AI Migration', + adapter: { + type: 'github', + org: 'test-org', + }, + provider: 'claude', + model: 'claude-sonnet-4-20250514', + context: { + include: ['**/*.ts'], + exclude: ['node_modules/**'], + }, + max_tokens: 4096, + }; + + beforeEach(() => { + (path.join as jest.Mock).mockReturnValue(mockFilePath); + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.readFileSync as jest.Mock).mockReturnValue('mock yaml content'); + (yaml.load as jest.Mock).mockReturnValue(mockSpec); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('loadAISpec', () => { + it('should load and validate the AI spec', () => { + const result = loadAISpec(mockDirectory); + expect(path.join).toHaveBeenCalledWith(mockDirectory, 'shepherd.yml'); + expect(fs.readFileSync).toHaveBeenCalledWith(mockFilePath, 'utf8'); + expect(yaml.load).toHaveBeenCalledWith('mock yaml content'); + expect(result).toEqual(mockSpec); + }); + + it('should throw an error if shepherd.yml does not exist', () => { + (fs.existsSync as jest.Mock).mockReturnValue(false); + expect(() => loadAISpec(mockDirectory)).toThrow('shepherd.yml not found'); + }); + + it('should throw an error if validation fails', () => { + const invalidSpec = { ...mockSpec, id: undefined }; + (yaml.load as jest.Mock).mockReturnValue(invalidSpec); + expect(() => loadAISpec(mockDirectory)).toThrow('Error loading AI migration spec'); + }); + }); + + describe('validateAISpec', () => { + it('should validate a complete AI spec', () => { + const result = validateAISpec(mockSpec); + expect(result.error).toBeUndefined(); + }); + + it('should validate a minimal AI spec (without provider)', () => { + const minimalSpec = { + id: 'test-ai-migration', + title: 'Test AI Migration', + adapter: { + type: 'github', + org: 'test-org', + }, + }; + const result = validateAISpec(minimalSpec); + expect(result.error).toBeUndefined(); + }); + + it('should return error for missing id', () => { + const invalidSpec = { ...mockSpec, id: undefined }; + const result = validateAISpec(invalidSpec); + expect(result.error).toBeDefined(); + }); + + it('should return error for missing title', () => { + const invalidSpec = { ...mockSpec, title: undefined }; + const result = validateAISpec(invalidSpec); + expect(result.error).toBeDefined(); + }); + + it('should return error for missing adapter', () => { + const invalidSpec = { ...mockSpec, adapter: undefined }; + const result = validateAISpec(invalidSpec); + expect(result.error).toBeDefined(); + }); + + it('should return error for invalid provider', () => { + const invalidSpec = { ...mockSpec, provider: 'invalid-provider' }; + const result = validateAISpec(invalidSpec); + expect(result.error).toBeDefined(); + }); + + it('should accept openai as valid provider', () => { + const openaiSpec = { ...mockSpec, provider: 'openai' }; + const result = validateAISpec(openaiSpec); + expect(result.error).toBeUndefined(); + }); + + it('should return error for negative max_tokens', () => { + const invalidSpec = { ...mockSpec, max_tokens: -100 }; + const result = validateAISpec(invalidSpec); + expect(result.error).toBeDefined(); + }); + + it('should allow unknown fields (like hooks)', () => { + const specWithHooks = { + ...mockSpec, + hooks: { apply: ['some-step'] }, + }; + const result = validateAISpec(specWithHooks); + expect(result.error).toBeUndefined(); + }); + }); + + describe('hasShepherdYaml', () => { + it('should return true if shepherd.yml exists', () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + expect(hasShepherdYaml(mockDirectory)).toBe(true); + }); + + it('should return false if shepherd.yml does not exist', () => { + (fs.existsSync as jest.Mock).mockReturnValue(false); + expect(hasShepherdYaml(mockDirectory)).toBe(false); + }); + }); +}); diff --git a/src/util/ai-migration-spec.ts b/src/util/ai-migration-spec.ts new file mode 100644 index 00000000..ed06657a --- /dev/null +++ b/src/util/ai-migration-spec.ts @@ -0,0 +1,93 @@ +/** + * AI Migration Spec - Separate schema for AI-powered migrations + */ + +import Joi from 'joi'; +import fs from 'fs'; +import * as yaml from 'js-yaml'; +import path from 'path'; + +/** + * AI Migration Spec interface + * Used when running `shepherd ai` command + */ +export interface IAIMigrationSpec { + id: string; + title: string; + adapter: { + type: string; + [key: string]: any; + }; + /** AI provider: 'claude', 'openai', or 'ollama' */ + provider?: string; + /** Model identifier (provider-specific) */ + model?: string; + /** File filtering configuration */ + context?: { + /** Glob patterns for files to include */ + include?: string[]; + /** Glob patterns for files to exclude */ + exclude?: string[]; + }; + /** Maximum tokens for AI response */ + max_tokens?: number; + /** Custom API base URL (for local models or OpenAI-compatible servers) */ + baseUrl?: string; +} + +const SUPPORTED_PROVIDERS = ['claude', 'openai', 'ollama']; + +/** + * Load and validate AI migration spec from shepherd.yml + */ +export function loadAISpec(directory: string): IAIMigrationSpec { + const docPath = path.join(directory, 'shepherd.yml'); + + if (!fs.existsSync(docPath)) { + throw new Error(`shepherd.yml not found in ${directory}`); + } + + const spec = yaml.load(fs.readFileSync(docPath, 'utf8')) as any; + const validationResult = validateAISpec(spec); + + if (validationResult.error) { + throw new Error(`Error loading AI migration spec: ${validationResult.error.message}`); + } + + return spec as IAIMigrationSpec; +} + +/** + * Validate AI migration spec against schema + */ +export function validateAISpec(spec: any): Joi.ValidationResult { + const schema = Joi.object({ + id: Joi.string().required(), + title: Joi.string().required(), + adapter: Joi.object({ + type: Joi.string().valid('github').required(), + }) + .unknown(true) + .required(), + provider: Joi.string() + .valid(...SUPPORTED_PROVIDERS) + .optional(), + model: Joi.string().optional(), + context: Joi.object({ + include: Joi.array().items(Joi.string()).optional(), + exclude: Joi.array().items(Joi.string()).optional(), + }).optional(), + max_tokens: Joi.number().positive().optional(), + baseUrl: Joi.string().uri().optional(), + }).unknown(true); // Allow other fields (like hooks) to exist but ignore them + + return schema.validate(spec); +} + +/** + * Check if shepherd.yml exists in the directory + */ +export function hasShepherdYaml(directory: string): boolean { + const docPath = path.join(directory, 'shepherd.yml'); + return fs.existsSync(docPath); +} diff --git a/src/util/execute-ai-apply.ts b/src/util/execute-ai-apply.ts new file mode 100644 index 00000000..a23b7fe0 --- /dev/null +++ b/src/util/execute-ai-apply.ts @@ -0,0 +1,130 @@ +/** + * Execute AI-powered migration on a repository + */ + +import chalk from 'chalk'; +import { IRepo } from '../adapters/base.js'; +import { IMigrationContext } from '../migration-context.js'; +import { createAIProvider, IAIConfig } from '../services/ai/index.js'; +import { readRepoFiles, applyEdits } from '../services/ai/file-operations.js'; +import { IStepsResults } from './execute-steps.js'; + +/** + * Execute AI migration on a repository + * + * @param context - The migration context + * @param repo - The repository to migrate + * @param aiConfig - AI configuration (provider, model, prompt, etc.) + * @param repoLogs - Array to collect logs + * @returns Results of the AI migration + */ +export default async function executeAIApply( + context: IMigrationContext, + repo: IRepo, + aiConfig: IAIConfig, + repoLogs: string[] +): Promise { + const { adapter, logger } = context; + const repoDir = adapter.getRepoDir(repo); + + const results: IStepsResults = { + succeeded: false, + stepResults: [], + }; + + try { + // Step 1: Read repository files + repoLogs.push(chalk.dim('Reading repository files...')); + const files = await readRepoFiles(repoDir, aiConfig); + repoLogs.push(chalk.dim(`Found ${files.length} files to analyze`)); + + if (files.length === 0) { + repoLogs.push(chalk.yellow('No files matched the include patterns')); + results.succeeded = true; + results.stepResults.push({ + step: 'ai-read-files', + succeeded: true, + stdout: 'No files to analyze', + }); + return results; + } + + // Step 2: Create AI provider and generate edits + repoLogs.push(chalk.dim(`Calling ${aiConfig.provider} API...`)); + const provider = createAIProvider(aiConfig.provider, { baseUrl: aiConfig.baseUrl }); + + const response = await provider.generateEdits({ + prompt: aiConfig.prompt, + files, + repoDir, + model: aiConfig.model, + maxTokens: aiConfig.max_tokens, + baseUrl: aiConfig.baseUrl, + }); + + repoLogs.push(chalk.dim(`AI generated ${response.edits.length} file edits`)); + + if (response.explanation) { + repoLogs.push(chalk.dim(`Explanation: ${response.explanation}`)); + } + + results.stepResults.push({ + step: 'ai-generate-edits', + succeeded: true, + stdout: `Generated ${response.edits.length} edits`, + }); + + // Step 3: Apply edits to filesystem + if (response.edits.length > 0) { + repoLogs.push(chalk.dim('Applying file changes...')); + await applyEdits(repoDir, response.edits); + + // Log each edit + for (const edit of response.edits) { + const actionColor = + edit.action === 'delete' + ? chalk.red + : edit.action === 'create' + ? chalk.green + : chalk.blue; + repoLogs.push(` ${actionColor(edit.action.toUpperCase())} ${edit.path}`); + } + + results.stepResults.push({ + step: 'ai-apply-edits', + succeeded: true, + stdout: `Applied ${response.edits.length} edits`, + }); + + repoLogs.push(chalk.green('AI migration completed successfully')); + } else { + repoLogs.push(chalk.yellow('No changes needed')); + results.stepResults.push({ + step: 'ai-apply-edits', + succeeded: true, + stdout: 'No changes needed', + }); + } + + results.succeeded = true; + } catch (error: any) { + repoLogs.push(chalk.red(`AI migration failed: ${error.message}`)); + + results.stepResults.push({ + step: 'ai-apply', + succeeded: false, + stderr: error.message, + }); + + // Log detailed error for debugging + if (error.response?.data) { + repoLogs.push(chalk.dim(`API Response: ${JSON.stringify(error.response.data)}`)); + } + + if (error.stack) { + logger.debug(error.stack); + } + } + + return results; +}