diff --git a/README.md b/README.md index 715ef16..21c5b4a 100644 --- a/README.md +++ b/README.md @@ -137,27 +137,69 @@ The sandbox mode allows you to safely test code changes, run scripts, or execute These tools are designed to be used by the AI assistant. -- **`ask-gemini`**: Asks Google Gemini for its perspective. Can be used for general questions or complex analysis of files. +- **`ask-gemini`**: Execute Gemini CLI with full feature support including advanced flags, caching, and change mode. - **`prompt`** (required): The analysis request. Use the `@` syntax to include file or directory references (e.g., `@src/main.js explain this code`) or ask general questions (e.g., `Please use a web search to find the latest news stories`). - - **`model`** (optional): The Gemini model to use. Defaults to `gemini-2.5-pro`. + - **`model`** (optional): The Gemini model to use. Defaults to `gemini-2.5-pro`. Use `gemini-2.5-flash` for faster responses. - **`sandbox`** (optional): Set to `true` to run in sandbox mode for safe code execution. -- **`sandbox-test`**: Safely executes code or commands in Gemini's sandbox environment. Always runs in sandbox mode. - - **`prompt`** (required): Code testing request (e.g., `Create and run a Python script that...` or `@script.py Run this safely`). + - **`changeMode`** (optional): Enable structured change mode for edit suggestions that Claude can apply directly. + - **`yolo`** (optional): Auto-accept all actions (YOLO mode). Use with caution. + - **`approvalMode`** (optional): Fine-grained approval control: `default`, `auto_edit`, or `yolo`. + - **`outputFormat`** (optional): Control output format: `text`, `json`, or `stream-json`. + - **`includeDirectories`** (optional): Additional directories to include in workspace. + - **`debug`** (optional): Enable verbose logging for troubleshooting. + - **`promptInteractive`** (optional): Execute prompt and continue in interactive mode. + - **`extensions`** (optional): Filter specific file extensions. + - **`resume`** (optional): Resume previous session (use `latest` or session number). + +- **`brainstorm`**: Generate creative ideas with structured methodologies and domain context. + - **`prompt`** (required): Brainstorming challenge or question to explore. - **`model`** (optional): The Gemini model to use. -- **`Ping`**: A simple test tool that echoes back a message. -- **`Help`**: Shows the Gemini CLI help text. + - **`methodology`** (optional): Framework to use: `divergent`, `convergent`, `scamper`, `design-thinking`, `lateral`, or `auto` (default). + - **`domain`** (optional): Domain context (e.g., 'software', 'business', 'creative', 'research'). + - **`constraints`** (optional): Known limitations or requirements. + - **`ideaCount`** (optional): Number of ideas to generate (default: 12). + - **`includeAnalysis`** (optional): Include feasibility and impact analysis (default: true). + +- **`fetch-chunk`**: Retrieve cached chunks from large changeMode responses. + - **`cacheKey`** (required): Cache key from initial changeMode response. + - **`chunkIndex`** (required): Chunk number to retrieve (1-based index). + +- **`ping`**: Echo test message to verify server connection. + - **`prompt`** (optional): Message to echo back. + +- **`Help`**: Display Gemini CLI help information. ### Slash Commands (for the User) You can use these commands directly in Claude Code's interface (compatibility with other clients has not been tested). -- **/analyze**: Analyzes files or directories using Gemini, or asks general questions. - - **`prompt`** (required): The analysis prompt. Use `@` syntax to include files (e.g., `/analyze prompt:@src/ summarize this directory`) or ask general questions (e.g., `/analyze prompt:Please use a web search to find the latest news stories`). -- **/sandbox**: Safely tests code or scripts in Gemini's sandbox environment. - - **`prompt`** (required): Code testing request (e.g., `/sandbox prompt:Create and run a Python script that processes CSV data` or `/sandbox prompt:@script.py Test this script safely`). -- **/help**: Displays the Gemini CLI help information. -- **/ping**: Tests the connection to the server. - - **`message`** (optional): A message to echo back. +- **/ask-gemini**: Execute Gemini CLI with advanced features and caching. + - **Example**: `/ask-gemini prompt:@src/ summarize this directory` + - **With sandbox**: `/ask-gemini prompt:@script.py test this safely sandbox:true` + - **With change mode**: `/ask-gemini prompt:Refactor this code changeMode:true` + - **Supports all flags**: yolo, approvalMode, outputFormat, debug, etc. + +- **/brainstorm**: Generate structured ideas with creative methodologies. + - **Example**: `/brainstorm prompt:How can we improve user onboarding? methodology:design-thinking domain:software` + - **Quick use**: `/brainstorm prompt:Ideas for a mobile app feature` + +- **/fetch-chunk**: Retrieve next chunk of a large changeMode response. + - **Example**: `/fetch-chunk cacheKey:abc123 chunkIndex:2` + +- **/Help**: Display Gemini CLI help information. + - **Example**: `/Help` + +- **/ping**: Test the MCP server connection. + - **Example**: `/ping prompt:Hello server!` + +## Performance Features + +This MCP server includes several performance optimizations: + +- **LRU Response Cache**: Near-instant responses for repeated queries with 30-minute TTL and 10MB max size +- **Efficient Command Execution**: O(n) array buffer performance for large outputs +- **Smart Chunking**: Large changeMode responses are automatically chunked for better handling +- **Progress Notifications**: Real-time progress updates during long-running operations ## Contributing diff --git a/package-lock.json b/package-lock.json index 17a2766..a382408 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "chalk": "^5.0.0", "d3-shape": "^3.2.0", "inquirer": "^9.0.0", + "lru-cache": "^11.2.4", "prismjs": "^1.30.0", "zod": "^3.25.76", "zod-to-json-schema": "^3.24.6" @@ -26,7 +27,6 @@ "@types/node": "^20.0.0", "archiver": "^7.0.1", "mermaid": "^11.9.0", - "nodemon": "^3.1.10", "tsx": "^4.0.0", "typescript": "^5.0.0", "vitepress": "^1.6.3", @@ -251,6 +251,7 @@ "integrity": "sha512-cZ0Iq3OzFUPpgszzDr1G1aJV5UMIZ4VygJ2Az252q4Rdf5cQMhYEIKArWY/oUjMhQmosM8ygOovNq7gvA9CdCg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.29.0", "@algolia/requester-browser-xhr": "5.29.0", @@ -2267,6 +2268,7 @@ "integrity": "sha512-E2l6AlTWGznM2e7vEE6T6hzObvEyXukxMOlBmVlMyixZyK1umuO/CiVc6sDBbzVH0oEviCE5IfVY1oZBmccYPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-abtesting": "5.29.0", "@algolia/client-analytics": "5.29.0", @@ -2325,20 +2327,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/archiver": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", @@ -2510,19 +2498,6 @@ ], "license": "MIT" }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/birpc": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.4.0.tgz", @@ -2554,19 +2529,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -2667,6 +2629,7 @@ "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -2689,31 +2652,6 @@ "chevrotain": "^11.0.0" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -2854,13 +2792,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/confbox": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", @@ -3007,6 +2938,7 @@ "integrity": "sha512-dbeqFTLYEwlFg7UGtcZhCCG/2WayX72zK3Sq323CEX29CY81tYfVhw1MIdduCtpstB0cTOhJswWlM/OEB3Xp+Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -3440,6 +3372,7 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -3776,25 +3709,13 @@ "dev": true, "license": "MIT" }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/focus-trap": { "version": "7.6.5", "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.5.tgz", "integrity": "sha512-7Ke1jyybbbPZyZXFxEftUtxFGLMpE2n6A+z//m4CRDlj0hW+o3iYSmh8nFlYMurOiJVDmJRilUQtJr08KfIxlg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tabbable": "^6.2.0" } @@ -3878,19 +3799,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/globals": { "version": "15.15.0", "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", @@ -4031,13 +3939,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true, - "license": "ISC" - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4077,29 +3978,6 @@ "node": ">=12" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4109,19 +3987,6 @@ "node": ">=8" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-interactive": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", @@ -4131,16 +3996,6 @@ "node": ">=8" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4213,8 +4068,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-schema": { "version": "0.4.0", @@ -4418,7 +4272,6 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -4427,11 +4280,13 @@ } }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/magic-string": { "version": "0.30.17", @@ -4491,6 +4346,7 @@ "integrity": "sha512-YdPXn9slEwO0omQfQIsW6vS84weVQftIyyTGAZCwM//MGhPzL1+l6vO6bkf0wnP4tHigH1alZ5Ooy3HXI2gOag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@braintree/sanitize-url": "^7.0.4", "@iconify/utils": "^2.1.33", @@ -4723,82 +4579,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/nodemon": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", - "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^4", - "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", - "pstree.remy": "^1.1.8", - "semver": "^7.5.3", - "simple-update-notifier": "^2.0.0", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/nodemon/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/nodemon/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/nodemon/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/nodemon/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/non-layered-tidy-tree-layout": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz", @@ -4940,6 +4720,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4961,19 +4748,6 @@ "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/pkg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz", @@ -5081,13 +4855,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/pstree.remy": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true, - "license": "MIT" - }, "node_modules/quansync": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz", @@ -5170,19 +4937,6 @@ "node": ">=10" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/regex": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz", @@ -5365,19 +5119,6 @@ "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", "license": "BSD-3-Clause" }, - "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -5430,19 +5171,6 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5682,19 +5410,6 @@ "node": ">=0.6.0" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -5704,16 +5419,6 @@ "node": ">=0.6" } }, - "node_modules/touch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", - "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", - "dev": true, - "license": "ISC", - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -5779,6 +5484,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5794,13 +5500,6 @@ "dev": true, "license": "MIT" }, - "node_modules/undefsafe": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true, - "license": "MIT" - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -5955,6 +5654,7 @@ "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -6445,6 +6145,7 @@ "integrity": "sha512-fCkfdOk8yRZT8GD9BFqusW3+GggWYZ/rYncOfmgcDtP3ualNHCAg+Robxp2/6xfH1WwPHtGpPwv7mbA3qomtBw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@docsearch/css": "3.8.2", "@docsearch/js": "3.8.2", @@ -6556,6 +6257,7 @@ "integrity": "sha512-LbHV3xPN9BeljML+Xctq4lbz2lVHCR6DtbpTf5XIO6gugpXUN49j2QQPcMj086r9+AkJ0FfUT8xjulKKBkkr9g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.17", "@vue/compiler-sfc": "3.5.17", @@ -6704,6 +6406,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 2a547c7..7304823 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "build": "tsc", "start": "node dist/index.js", "dev": "tsc && node dist/index.js", - "test": "echo \"No tests yet\" && exit 0", + "test": "vitest run", + "test:watch": "vitest watch", + "test:coverage": "vitest run --coverage", "lint": "tsc --noEmit", "contribute": "tsx src/contribute.ts", "prepublishOnly": "echo '⚠️ Remember to test locally first!' && npm run build", @@ -51,6 +53,7 @@ "chalk": "^5.0.0", "d3-shape": "^3.2.0", "inquirer": "^9.0.0", + "lru-cache": "^11.2.4", "prismjs": "^1.30.0", "zod": "^3.25.76", "zod-to-json-schema": "^3.24.6" @@ -58,12 +61,14 @@ "devDependencies": { "@types/inquirer": "^9.0.0", "@types/node": "^20.0.0", + "@vitest/coverage-v8": "^2.1.8", "archiver": "^7.0.1", "mermaid": "^11.9.0", "tsx": "^4.0.0", "typescript": "^5.0.0", "vitepress": "^1.6.3", - "vitepress-plugin-mermaid": "^2.0.17" + "vitepress-plugin-mermaid": "^2.0.17", + "vitest": "^2.1.8" }, "publishConfig": { "access": "public" diff --git a/src/__tests__/ask-gemini.test.ts b/src/__tests__/ask-gemini.test.ts new file mode 100644 index 0000000..b268ebe --- /dev/null +++ b/src/__tests__/ask-gemini.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { z } from 'zod'; + +describe('ask-gemini Tool', () => { + describe('Argument Schema Validation', () => { + const askGeminiArgsSchema = z.object({ + prompt: z.string().min(1), + model: z.string().optional(), + sandbox: z.boolean().default(false), + changeMode: z.boolean().default(false), + chunkIndex: z.union([z.number(), z.string()]).optional(), + chunkCacheKey: z.string().optional(), + yolo: z.boolean().default(false), + approvalMode: z.enum(['default', 'auto_edit', 'yolo']).optional(), + outputFormat: z.enum(['text', 'json', 'stream-json']).optional(), + includeDirectories: z.union([z.string(), z.array(z.string())]).optional(), + debug: z.boolean().default(false), + promptInteractive: z.string().optional(), + extensions: z.union([z.string(), z.array(z.string())]).optional(), + resume: z.string().optional(), + }); + + it('should accept valid minimal arguments', () => { + const result = askGeminiArgsSchema.parse({ prompt: 'test prompt' }); + expect(result.prompt).toBe('test prompt'); + expect(result.sandbox).toBe(false); + expect(result.changeMode).toBe(false); + }); + + it('should accept all optional flags', () => { + const result = askGeminiArgsSchema.parse({ + prompt: 'test prompt', + model: 'gemini-2.5-flash', + sandbox: true, + changeMode: true, + yolo: true, + approvalMode: 'auto_edit', + outputFormat: 'json', + debug: true, + }); + + expect(result.model).toBe('gemini-2.5-flash'); + expect(result.sandbox).toBe(true); + expect(result.changeMode).toBe(true); + expect(result.yolo).toBe(true); + expect(result.approvalMode).toBe('auto_edit'); + expect(result.outputFormat).toBe('json'); + expect(result.debug).toBe(true); + }); + + it('should reject missing prompt', () => { + expect(() => askGeminiArgsSchema.parse({})).toThrow(); + }); + + it('should reject empty prompt', () => { + expect(() => askGeminiArgsSchema.parse({ prompt: '' })).toThrow(); + }); + + it('should reject invalid approval mode', () => { + expect(() => + askGeminiArgsSchema.parse({ + prompt: 'test', + approvalMode: 'invalid', + }) + ).toThrow(); + }); + + it('should reject invalid output format', () => { + expect(() => + askGeminiArgsSchema.parse({ + prompt: 'test', + outputFormat: 'invalid', + }) + ).toThrow(); + }); + + it('should accept includeDirectories as string', () => { + const result = askGeminiArgsSchema.parse({ + prompt: 'test', + includeDirectories: 'src,tests', + }); + expect(result.includeDirectories).toBe('src,tests'); + }); + + it('should accept includeDirectories as array', () => { + const result = askGeminiArgsSchema.parse({ + prompt: 'test', + includeDirectories: ['src', 'tests'], + }); + expect(result.includeDirectories).toEqual(['src', 'tests']); + }); + + it('should accept extensions as string', () => { + const result = askGeminiArgsSchema.parse({ + prompt: 'test', + extensions: 'ts,js', + }); + expect(result.extensions).toBe('ts,js'); + }); + + it('should accept extensions as array', () => { + const result = askGeminiArgsSchema.parse({ + prompt: 'test', + extensions: ['ts', 'js'], + }); + expect(result.extensions).toEqual(['ts', 'js']); + }); + + it('should accept resume parameter', () => { + const result = askGeminiArgsSchema.parse({ + prompt: 'test', + resume: 'latest', + }); + expect(result.resume).toBe('latest'); + }); + + it('should accept chunkIndex as number', () => { + const result = askGeminiArgsSchema.parse({ + prompt: 'test', + chunkIndex: 1, + }); + expect(result.chunkIndex).toBe(1); + }); + + it('should accept chunkIndex as string', () => { + const result = askGeminiArgsSchema.parse({ + prompt: 'test', + chunkIndex: '1', + }); + expect(result.chunkIndex).toBe('1'); + }); + }); + + describe('Flag Combinations', () => { + const askGeminiArgsSchema = z.object({ + prompt: z.string().min(1), + sandbox: z.boolean().default(false), + yolo: z.boolean().default(false), + approvalMode: z.enum(['default', 'auto_edit', 'yolo']).optional(), + }); + + it('should allow sandbox with yolo', () => { + const result = askGeminiArgsSchema.parse({ + prompt: 'test', + sandbox: true, + yolo: true, + }); + expect(result.sandbox).toBe(true); + expect(result.yolo).toBe(true); + }); + + it('should allow approvalMode to override yolo', () => { + const result = askGeminiArgsSchema.parse({ + prompt: 'test', + yolo: true, + approvalMode: 'auto_edit', + }); + expect(result.yolo).toBe(true); + expect(result.approvalMode).toBe('auto_edit'); + // Note: In actual implementation, approvalMode should take precedence + }); + }); + + describe('Error Messages', () => { + const askGeminiArgsSchema = z.object({ + prompt: z.string().min(1), + }); + + it('should provide clear error for missing prompt', () => { + try { + askGeminiArgsSchema.parse({}); + } catch (error: any) { + expect(error.issues[0].path).toContain('prompt'); + expect(error.issues[0].message).toMatch(/required/i); + } + }); + + it('should provide clear error for empty prompt', () => { + try { + askGeminiArgsSchema.parse({ prompt: '' }); + } catch (error: any) { + expect(error.issues[0].path).toContain('prompt'); + expect(error.issues[0].message).toMatch(/at least 1/i); + } + }); + }); +}); diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts new file mode 100644 index 0000000..989b46f --- /dev/null +++ b/src/__tests__/server.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + +// Mock dependencies +vi.mock('@modelcontextprotocol/sdk/server/index.js', () => ({ + Server: vi.fn().mockImplementation(() => ({ + setRequestHandler: vi.fn(), + connect: vi.fn(), + notification: vi.fn(), + })), +})); + +vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ + StdioServerTransport: vi.fn(), +})); + +describe('Gemini MCP Server', () => { + let consoleErrorSpy: any; + let consoleDebugSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + consoleDebugSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + consoleDebugSpy.mockRestore(); + }); + + describe('Server Initialization', () => { + it('should create server with correct name and version', () => { + // Import will initialize the server + const mockServer = vi.mocked(Server).mock.results[0]; + expect(Server).toHaveBeenCalled(); + + const serverConfig = vi.mocked(Server).mock.calls[0][0]; + expect(serverConfig).toHaveProperty('name', 'gemini-cli-mcp'); + expect(serverConfig).toHaveProperty('version'); + }); + + it('should register capabilities', () => { + const serverConfig = vi.mocked(Server).mock.calls[0][1]; + expect(serverConfig).toHaveProperty('capabilities'); + expect(serverConfig.capabilities).toHaveProperty('tools'); + expect(serverConfig.capabilities).toHaveProperty('prompts'); + }); + }); + + describe('Request Handlers', () => { + it('should register ListToolsRequestSchema handler', () => { + const mockServerInstance = vi.mocked(Server).mock.results[0].value; + expect(mockServerInstance.setRequestHandler).toHaveBeenCalled(); + + const calls = mockServerInstance.setRequestHandler.mock.calls; + const listToolsHandler = calls.find((call: any) => + call[0]?.name === 'tools/list' || call[0]?.method === 'tools/list' + ); + + // At minimum, verify handler was registered + expect(calls.length).toBeGreaterThan(0); + }); + + it('should register CallToolRequestSchema handler', () => { + const mockServerInstance = vi.mocked(Server).mock.results[0].value; + const calls = mockServerInstance.setRequestHandler.mock.calls; + + // Verify multiple handlers were registered + expect(calls.length).toBeGreaterThanOrEqual(2); + }); + + it('should register ListPromptsRequestSchema handler', () => { + const mockServerInstance = vi.mocked(Server).mock.results[0].value; + const calls = mockServerInstance.setRequestHandler.mock.calls; + + // Verify prompts handler was registered + expect(calls.length).toBeGreaterThanOrEqual(3); + }); + }); + + describe('Progress Notifications', () => { + it('should support progress notifications', () => { + const mockServerInstance = vi.mocked(Server).mock.results[0].value; + expect(mockServerInstance).toHaveProperty('notification'); + expect(typeof mockServerInstance.notification).toBe('function'); + }); + }); + + describe('Transport Connection', () => { + it('should connect to StdioServerTransport', () => { + expect(StdioServerTransport).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/__tests__/tools.test.ts b/src/__tests__/tools.test.ts new file mode 100644 index 0000000..53eb1de --- /dev/null +++ b/src/__tests__/tools.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { toolRegistry, getToolDefinitions, toolExists, getPromptDefinitions } from '../tools/registry.js'; + +describe('Tool Registry', () => { + describe('getToolDefinitions', () => { + it('should return an array of tool definitions', () => { + const tools = getToolDefinitions(); + expect(Array.isArray(tools)).toBe(true); + expect(tools.length).toBeGreaterThan(0); + }); + + it('should include ask-gemini tool', () => { + const tools = getToolDefinitions(); + const askGeminiTool = tools.find(t => t.name === 'ask-gemini'); + + expect(askGeminiTool).toBeDefined(); + expect(askGeminiTool?.description).toBeTruthy(); + expect(askGeminiTool?.inputSchema).toBeDefined(); + }); + + it('should include brainstorm tool', () => { + const tools = getToolDefinitions(); + const brainstormTool = tools.find(t => t.name === 'brainstorm'); + + expect(brainstormTool).toBeDefined(); + expect(brainstormTool?.description).toContain('brainstorm'); + }); + + it('should include ping tool', () => { + const tools = getToolDefinitions(); + const pingTool = tools.find(t => t.name === 'ping'); + + expect(pingTool).toBeDefined(); + }); + + it('should include Help tool', () => { + const tools = getToolDefinitions(); + const helpTool = tools.find(t => t.name === 'Help'); + + expect(helpTool).toBeDefined(); + }); + + it('should include fetch-chunk tool', () => { + const tools = getToolDefinitions(); + const fetchChunkTool = tools.find(t => t.name === 'fetch-chunk'); + + expect(fetchChunkTool).toBeDefined(); + }); + + it('should have valid input schemas', () => { + const tools = getToolDefinitions(); + + tools.forEach(tool => { + expect(tool.inputSchema).toBeDefined(); + expect(tool.inputSchema.type).toBe('object'); + expect(tool.inputSchema).toHaveProperty('properties'); + expect(tool.inputSchema).toHaveProperty('required'); + }); + }); + }); + + describe('toolExists', () => { + it('should return true for existing tools', () => { + expect(toolExists('ask-gemini')).toBe(true); + expect(toolExists('brainstorm')).toBe(true); + expect(toolExists('ping')).toBe(true); + expect(toolExists('Help')).toBe(true); + expect(toolExists('fetch-chunk')).toBe(true); + }); + + it('should return false for non-existent tools', () => { + expect(toolExists('nonexistent-tool')).toBe(false); + expect(toolExists('fake-tool')).toBe(false); + }); + }); + + describe('getPromptDefinitions', () => { + it('should return an array of prompt definitions', () => { + const prompts = getPromptDefinitions(); + expect(Array.isArray(prompts)).toBe(true); + expect(prompts.length).toBeGreaterThan(0); + }); + + it('should only include tools with prompt property', () => { + const prompts = getPromptDefinitions(); + + prompts.forEach(prompt => { + expect(prompt).toHaveProperty('name'); + expect(prompt).toHaveProperty('description'); + expect(prompt).toHaveProperty('arguments'); + }); + }); + + it('should include ask-gemini prompt', () => { + const prompts = getPromptDefinitions(); + const askGeminiPrompt = prompts.find(p => p.name === 'ask-gemini'); + + expect(askGeminiPrompt).toBeDefined(); + expect(askGeminiPrompt?.description).toBeTruthy(); + }); + + it('should include brainstorm prompt', () => { + const prompts = getPromptDefinitions(); + const brainstormPrompt = prompts.find(p => p.name === 'brainstorm'); + + expect(brainstormPrompt).toBeDefined(); + }); + }); + + describe('Tool Schema Validation', () => { + it('ask-gemini should have required prompt parameter', () => { + const tools = getToolDefinitions(); + const askGeminiTool = tools.find(t => t.name === 'ask-gemini'); + + expect(askGeminiTool?.inputSchema.properties).toHaveProperty('prompt'); + expect(askGeminiTool?.inputSchema.required).toContain('prompt'); + }); + + it('ask-gemini should have optional parameters', () => { + const tools = getToolDefinitions(); + const askGeminiTool = tools.find(t => t.name === 'ask-gemini'); + + const properties = askGeminiTool?.inputSchema.properties; + expect(properties).toHaveProperty('model'); + expect(properties).toHaveProperty('sandbox'); + expect(properties).toHaveProperty('changeMode'); + expect(properties).toHaveProperty('yolo'); + expect(properties).toHaveProperty('approvalMode'); + }); + + it('brainstorm should have methodology parameter', () => { + const tools = getToolDefinitions(); + const brainstormTool = tools.find(t => t.name === 'brainstorm'); + + expect(brainstormTool?.inputSchema.properties).toHaveProperty('methodology'); + }); + + it('fetch-chunk should have required cacheKey and chunkIndex', () => { + const tools = getToolDefinitions(); + const fetchChunkTool = tools.find(t => t.name === 'fetch-chunk'); + + expect(fetchChunkTool?.inputSchema.properties).toHaveProperty('cacheKey'); + expect(fetchChunkTool?.inputSchema.properties).toHaveProperty('chunkIndex'); + expect(fetchChunkTool?.inputSchema.required).toContain('cacheKey'); + expect(fetchChunkTool?.inputSchema.required).toContain('chunkIndex'); + }); + }); + + describe('Tool Categories', () => { + it('should categorize tools correctly', () => { + const askGemini = toolRegistry.find(t => t.name === 'ask-gemini'); + const brainstorm = toolRegistry.find(t => t.name === 'brainstorm'); + const ping = toolRegistry.find(t => t.name === 'ping'); + const fetchChunk = toolRegistry.find(t => t.name === 'fetch-chunk'); + + expect(askGemini?.category).toBe('gemini'); + expect(brainstorm?.category).toBe('gemini'); + expect(ping?.category).toBe('simple'); + expect(fetchChunk?.category).toBe('utility'); + }); + }); +}); diff --git a/src/__tests__/utils/test-helpers.ts b/src/__tests__/utils/test-helpers.ts new file mode 100644 index 0000000..3f3bd29 --- /dev/null +++ b/src/__tests__/utils/test-helpers.ts @@ -0,0 +1,51 @@ +import { vi } from 'vitest'; +import { EventEmitter } from 'node:events'; + +/** + * Creates a mock child process for testing command execution + */ +export function createMockProcess() { + const mockProcess = new EventEmitter() as any; + mockProcess.stdout = new EventEmitter(); + mockProcess.stderr = new EventEmitter(); + mockProcess.stdout.on = vi.fn((event, handler) => { + if (event === 'data') mockProcess.stdout['data'] = handler; + }); + mockProcess.stderr.on = vi.fn((event, handler) => { + if (event === 'data') mockProcess.stderr['data'] = handler; + }); + return mockProcess; +} + +/** + * Simulates successful process execution + */ +export function simulateProcessSuccess(mockProcess: any, output: string, delay = 10) { + setTimeout(() => { + if (mockProcess.stdout['data']) { + mockProcess.stdout['data'](output); + } + mockProcess.emit('close', 0); + }, delay); +} + +/** + * Simulates process failure + */ +export function simulateProcessFailure(mockProcess: any, errorOutput: string, exitCode = 1, delay = 10) { + setTimeout(() => { + if (mockProcess.stderr['data']) { + mockProcess.stderr['data'](errorOutput); + } + mockProcess.emit('close', exitCode); + }, delay); +} + +/** + * Simulates process error + */ +export function simulateProcessError(mockProcess: any, error: Error, delay = 10) { + setTimeout(() => { + mockProcess.emit('error', error); + }, delay); +} diff --git a/src/constants.ts b/src/constants.ts index 184cac2..3830fd0 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -70,12 +70,28 @@ export const CLI = { SANDBOX: "-s", PROMPT: "-p", HELP: "-help", + // Phase 1: Critical flags + YOLO: "-y", + APPROVAL_MODE: "--approval-mode", + OUTPUT_FORMAT: "-o", + INCLUDE_DIRECTORIES: "--include-directories", + DEBUG: "-d", + // Phase 2: Enhanced features + PROMPT_INTERACTIVE: "-i", + EXTENSIONS: "-e", + RESUME: "-r", }, // Default values DEFAULTS: { MODEL: "default", // Fallback model used when no specific model is provided BOOLEAN_TRUE: "true", BOOLEAN_FALSE: "false", + APPROVAL_MODE_DEFAULT: "default", + APPROVAL_MODE_AUTO_EDIT: "auto_edit", + APPROVAL_MODE_YOLO: "yolo", + OUTPUT_FORMAT_TEXT: "text", + OUTPUT_FORMAT_JSON: "json", + OUTPUT_FORMAT_STREAM_JSON: "stream-json", }, } as const; @@ -89,14 +105,26 @@ export interface ToolArguments { chunkIndex?: number | string; // Which chunk to return (1-based) chunkCacheKey?: string; // Optional cache key for continuation message?: string; // For Ping tool -- Un-used. - - // --> new tool + + // Phase 1: Critical flags + yolo?: boolean | string; // Auto-accept all actions (YOLO mode) + approvalMode?: string; // Approval mode: default, auto_edit, yolo + outputFormat?: string; // Output format: text, json, stream-json + includeDirectories?: string | string[]; // Additional directories to include + debug?: boolean | string; // Enable debug mode + + // Phase 2: Enhanced features + promptInteractive?: string; // Execute prompt and continue interactively + extensions?: string | string[]; // Extensions to use + resume?: string; // Resume previous session + + // Brainstorm tool methodology?: string; // Brainstorming framework to use domain?: string; // Domain context for specialized brainstorming constraints?: string; // Known limitations or requirements existingContext?: string; // Background information to build upon ideaCount?: number; // Target number of ideas to generate includeAnalysis?: boolean; // Include feasibility and impact analysis - - [key: string]: string | boolean | number | undefined; // Allow additional properties + + [key: string]: string | boolean | number | string[] | undefined; // Allow additional properties } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 46c6118..88879b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,7 +36,6 @@ const server = new Server( tools: {}, prompts: {}, notifications: {}, - logging: {}, }, }, ); diff --git a/src/tools/ask-gemini.tool.ts b/src/tools/ask-gemini.tool.ts index bc76b3a..b9117ed 100644 --- a/src/tools/ask-gemini.tool.ts +++ b/src/tools/ask-gemini.tool.ts @@ -13,18 +13,34 @@ const askGeminiArgsSchema = z.object({ changeMode: z.boolean().default(false).describe("Enable structured change mode - formats prompts to prevent tool errors and returns structured edit suggestions that Claude can apply directly"), chunkIndex: z.union([z.number(), z.string()]).optional().describe("Which chunk to return (1-based)"), chunkCacheKey: z.string().optional().describe("Optional cache key for continuation"), + + // Phase 1: Critical flags + yolo: z.boolean().default(false).describe("Auto-accept all actions (YOLO mode). Automatically approves all confirmations without prompting. Use with caution in automated workflows."), + approvalMode: z.enum(["default", "auto_edit", "yolo"]).optional().describe("Set approval mode: 'default' (prompt for approval), 'auto_edit' (auto-approve edit tools only), 'yolo' (auto-approve all tools). Overrides yolo flag if both are set."), + outputFormat: z.enum(["text", "json", "stream-json"]).optional().describe("Output format: 'text' (default human-readable), 'json' (structured JSON), 'stream-json' (streaming JSON for real-time processing)"), + includeDirectories: z.union([z.string(), z.array(z.string())]).optional().describe("Additional directories to include in workspace (comma-separated string or array). Example: 'src,tests' or ['src', 'tests']"), + debug: z.boolean().default(false).describe("Enable debug mode for verbose logging and troubleshooting"), + + // Phase 2: Enhanced features (for future implementation) + promptInteractive: z.string().optional().describe("Execute the provided prompt and continue in interactive mode"), + extensions: z.union([z.string(), z.array(z.string())]).optional().describe("Specific extensions to use (comma-separated or array). If not provided, all extensions are used."), + resume: z.string().optional().describe("Resume a previous session. Use 'latest' for most recent or session index number"), }); export const askGeminiTool: UnifiedTool = { name: "ask-gemini", - description: "model selection [-m], sandbox [-s], and changeMode:boolean for providing edits", + description: "Ask Google Gemini with full CLI support: model selection [-m], sandbox [-s], YOLO mode [-y], approval modes, output formats, and more", zodSchema: askGeminiArgsSchema, prompt: { - description: "Execute 'gemini -p ' to get Gemini AI's response. Supports enhanced change mode for structured edit suggestions.", + description: "Execute Gemini CLI with advanced options. Supports file analysis (@syntax), YOLO mode, different approval levels, output formats (text/json), debug mode, and structured change mode for edit suggestions.", }, category: 'gemini', execute: async (args, onProgress) => { - const { prompt, model, sandbox, changeMode, chunkIndex, chunkCacheKey } = args; if (!prompt?.trim()) { throw new Error(ERROR_MESSAGES.NO_PROMPT_PROVIDED); } + const { + prompt, model, sandbox, changeMode, chunkIndex, chunkCacheKey, + yolo, approvalMode, outputFormat, includeDirectories, debug, + promptInteractive, extensions, resume + } = args; if (!prompt?.trim()) { throw new Error(ERROR_MESSAGES.NO_PROMPT_PROVIDED); } if (changeMode && chunkIndex && chunkCacheKey) { return processChangeModeOutput( @@ -37,9 +53,19 @@ export const askGeminiTool: UnifiedTool = { const result = await executeGeminiCLI( prompt as string, - model as string | undefined, - !!sandbox, - !!changeMode, + { + model: model as string | undefined, + sandbox: !!sandbox, + changeMode: !!changeMode, + yolo: !!yolo, + approvalMode: approvalMode as string | undefined, + outputFormat: outputFormat as string | undefined, + includeDirectories: includeDirectories, + debug: !!debug, + promptInteractive: promptInteractive as string | undefined, + extensions: extensions, + resume: resume as string | undefined, + }, onProgress ); diff --git a/src/tools/brainstorm.tool.ts b/src/tools/brainstorm.tool.ts index 0970ade..58717a1 100644 --- a/src/tools/brainstorm.tool.ts +++ b/src/tools/brainstorm.tool.ts @@ -166,6 +166,14 @@ export const brainstormTool: UnifiedTool = { onProgress?.(`Generating ${ideaCount} ideas via ${methodology} methodology...`); // Execute with Gemini - return await executeGeminiCLI(enhancedPrompt, model as string | undefined, false, false, onProgress); + return await executeGeminiCLI( + enhancedPrompt, + { + model: model as string | undefined, + sandbox: false, + changeMode: false, + }, + onProgress + ); } }; \ No newline at end of file diff --git a/src/utils/commandExecutor.ts b/src/utils/commandExecutor.ts index b9b6f1b..8231735 100644 --- a/src/utils/commandExecutor.ts +++ b/src/utils/commandExecutor.ts @@ -16,31 +16,32 @@ export async function executeCommand( stdio: ["ignore", "pipe", "pipe"], }); - let stdout = ""; - let stderr = ""; + // Use array buffers for O(n) performance instead of O(n²) string concatenation + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; let isResolved = false; - let lastReportedLength = 0; - + childProcess.stdout.on("data", (data) => { - stdout += data.toString(); - - // Report new content if callback provided - if (onProgress && stdout.length > lastReportedLength) { - const newContent = stdout.substring(lastReportedLength); - lastReportedLength = stdout.length; - onProgress(newContent); + const chunk = data.toString(); + stdoutChunks.push(chunk); + + // Report immediately if callback provided (no substring calculation needed) + if (onProgress) { + onProgress(chunk); } }); // CLI level errors childProcess.stderr.on("data", (data) => { - stderr += data.toString(); + const chunk = data.toString(); + stderrChunks.push(chunk); // find RESOURCE_EXHAUSTED when gemini-2.5-pro quota is exceeded - if (stderr.includes("RESOURCE_EXHAUSTED")) { - const modelMatch = stderr.match(/Quota exceeded for quota metric '([^']+)'/); - const statusMatch = stderr.match(/status["\s]*[:=]\s*(\d+)/); - const reasonMatch = stderr.match(/"reason":\s*"([^"]+)"/); + if (chunk.includes("RESOURCE_EXHAUSTED")) { + const stderrSoFar = stderrChunks.join(''); + const modelMatch = stderrSoFar.match(/Quota exceeded for quota metric '([^']+)'/); + const statusMatch = stderrSoFar.match(/status["\s]*[:=]\s*(\d+)/); + const reasonMatch = stderrSoFar.match(/"reason":\s*"([^"]+)"/); const model = modelMatch ? modelMatch[1] : "Unknown Model"; const status = statusMatch ? statusMatch[1] : "429"; const reason = reasonMatch ? reasonMatch[1] : "rateLimitExceeded"; @@ -68,6 +69,10 @@ export async function executeCommand( childProcess.on("close", (code) => { if (!isResolved) { isResolved = true; + // Join array buffers efficiently + const stdout = stdoutChunks.join(''); + const stderr = stderrChunks.join(''); + if (code === 0) { Logger.commandComplete(startTime, code, stdout.length); resolve(stdout.trim()); diff --git a/src/utils/geminiExecutor.ts b/src/utils/geminiExecutor.ts index f7e79d3..7cb0085 100644 --- a/src/utils/geminiExecutor.ts +++ b/src/utils/geminiExecutor.ts @@ -1,9 +1,9 @@ import { executeCommand } from './commandExecutor.js'; import { Logger } from './logger.js'; -import { - ERROR_MESSAGES, - STATUS_MESSAGES, - MODELS, +import { + ERROR_MESSAGES, + STATUS_MESSAGES, + MODELS, CLI } from '../constants.js'; @@ -11,19 +11,120 @@ import { parseChangeModeOutput, validateChangeModeEdits } from './changeModePars import { formatChangeModeResponse, summarizeChangeModeEdits } from './changeModeTranslator.js'; import { chunkChangeModeEdits } from './changeModeChunker.js'; import { cacheChunks, getChunks } from './chunkCache.js'; +import { generateCacheKey, getCachedResponse, cacheResponse } from './responseCache.js'; + +export interface GeminiCLIOptions { + model?: string; + sandbox?: boolean; + changeMode?: boolean; + yolo?: boolean; + approvalMode?: string; + outputFormat?: string; + includeDirectories?: string | string[]; + debug?: boolean; + promptInteractive?: string; + extensions?: string | string[]; + resume?: string; +} + +/** + * Helper function to build Gemini CLI arguments from options + * Eliminates code duplication between main execution and fallback + */ +function buildGeminiArgs(opts: GeminiCLIOptions, prompt: string, forceModel?: string): string[] { + const args: string[] = []; + const model = forceModel || opts.model; + + // Model selection + if (model) { + args.push(CLI.FLAGS.MODEL, model); + } + + // Boolean flags + if (opts.sandbox) { + args.push(CLI.FLAGS.SANDBOX); + } + if (opts.yolo) { + args.push(CLI.FLAGS.YOLO); + } + if (opts.debug) { + args.push(CLI.FLAGS.DEBUG); + } + + // Approval mode (overrides yolo if both are set) + if (opts.approvalMode) { + args.push(CLI.FLAGS.APPROVAL_MODE, opts.approvalMode); + } + + // Output format + if (opts.outputFormat) { + args.push(CLI.FLAGS.OUTPUT_FORMAT, opts.outputFormat); + } + + // Include directories (array or comma-separated string) + if (opts.includeDirectories) { + const dirs = Array.isArray(opts.includeDirectories) + ? opts.includeDirectories.join(',') + : opts.includeDirectories; + args.push(CLI.FLAGS.INCLUDE_DIRECTORIES, dirs); + } + + // Extensions (array or comma-separated string) + if (opts.extensions) { + const exts = Array.isArray(opts.extensions) + ? opts.extensions.join(',') + : opts.extensions; + args.push(CLI.FLAGS.EXTENSIONS, exts); + } + + // Resume session + if (opts.resume) { + args.push(CLI.FLAGS.RESUME, opts.resume); + } + + // Prompt interactive + if (opts.promptInteractive) { + args.push(CLI.FLAGS.PROMPT_INTERACTIVE, opts.promptInteractive); + } + + // Ensure @ symbols work cross-platform by wrapping in quotes if needed + const finalPrompt = prompt.includes('@') && !prompt.startsWith('"') + ? `"${prompt}"` + : prompt; + + args.push(CLI.FLAGS.PROMPT, finalPrompt); + + return args; +} export async function executeGeminiCLI( prompt: string, - model?: string, - sandbox?: boolean, - changeMode?: boolean, + options: GeminiCLIOptions | string, onProgress?: (newOutput: string) => void ): Promise { + // Handle backward compatibility - if options is a string, it's the old 'model' parameter + let opts: GeminiCLIOptions; + if (typeof options === 'string') { + opts = { model: options }; + } else { + opts = options || {}; + } + + // Check cache first for non-changeMode requests (changeMode needs fresh responses) + if (!opts.changeMode) { + const cacheKey = generateCacheKey(prompt, opts); + const cached = getCachedResponse(cacheKey); + if (cached) { + Logger.debug('Returning cached response'); + return cached; + } + } + let prompt_processed = prompt; - - if (changeMode) { + + if (opts.changeMode) { prompt_processed = prompt.replace(/file:(\S+)/g, '@$1'); - + const changeModeInstructions = ` [CHANGEMODE INSTRUCTIONS] You are generating code modifications that will be processed by an automated system. The output format is critical because it enables programmatic application of changes without human intervention. @@ -86,41 +187,40 @@ ${prompt_processed} `; prompt_processed = changeModeInstructions; } - - const args = []; - if (model) { args.push(CLI.FLAGS.MODEL, model); } - if (sandbox) { args.push(CLI.FLAGS.SANDBOX); } - - // Ensure @ symbols work cross-platform by wrapping in quotes if needed - const finalPrompt = prompt_processed.includes('@') && !prompt_processed.startsWith('"') - ? `"${prompt_processed}"` - : prompt_processed; - - args.push(CLI.FLAGS.PROMPT, finalPrompt); - + + // Use helper function to build args (eliminates code duplication) + const args = buildGeminiArgs(opts, prompt_processed); + try { - return await executeCommand(CLI.COMMANDS.GEMINI, args, onProgress); + const result = await executeCommand(CLI.COMMANDS.GEMINI, args, onProgress); + + // Cache successful non-changeMode responses + if (!opts.changeMode) { + const cacheKey = generateCacheKey(prompt, opts); + cacheResponse(cacheKey, result); + } + + return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes(ERROR_MESSAGES.QUOTA_EXCEEDED) && model !== MODELS.FLASH) { + if (errorMessage.includes(ERROR_MESSAGES.QUOTA_EXCEEDED) && opts.model !== MODELS.FLASH) { Logger.warn(`${ERROR_MESSAGES.QUOTA_EXCEEDED}. Falling back to ${MODELS.FLASH}.`); await sendStatusMessage(STATUS_MESSAGES.FLASH_RETRY); - const fallbackArgs = []; - fallbackArgs.push(CLI.FLAGS.MODEL, MODELS.FLASH); - if (sandbox) { - fallbackArgs.push(CLI.FLAGS.SANDBOX); - } - - // Same @ symbol handling for fallback - const fallbackPrompt = prompt_processed.includes('@') && !prompt_processed.startsWith('"') - ? `"${prompt_processed}"` - : prompt_processed; - - fallbackArgs.push(CLI.FLAGS.PROMPT, fallbackPrompt); + + // Use helper function with Flash model override (eliminates code duplication) + const fallbackArgs = buildGeminiArgs(opts, prompt_processed, MODELS.FLASH); + try { const result = await executeCommand(CLI.COMMANDS.GEMINI, fallbackArgs, onProgress); Logger.warn(`Successfully executed with ${MODELS.FLASH} fallback.`); await sendStatusMessage(STATUS_MESSAGES.FLASH_SUCCESS); + + // Cache successful fallback response (non-changeMode only) + if (!opts.changeMode) { + const cacheKey = generateCacheKey(prompt, opts); + cacheResponse(cacheKey, result); + } + return result; } catch (fallbackError) { const fallbackErrorMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError); diff --git a/src/utils/responseCache.ts b/src/utils/responseCache.ts new file mode 100644 index 0000000..e36282c --- /dev/null +++ b/src/utils/responseCache.ts @@ -0,0 +1,86 @@ +import { LRUCache } from 'lru-cache'; +import { createHash } from 'crypto'; +import { Logger } from './logger.js'; +import { GeminiCLIOptions } from './geminiExecutor.js'; + +/** + * LRU Cache for Gemini API responses + * Caches responses to identical prompts with identical options + * + * Benefits: + * - Near-instant responses for repeated queries + * - Reduces API quota consumption + * - 30-minute TTL ensures fresh data + * - 10MB max size prevents memory bloat + */ +const responseCache = new LRUCache({ + max: 100, // Cache up to 100 recent responses + ttl: 1000 * 60 * 30, // 30 minutes + maxSize: 10 * 1024 * 1024, // 10MB max cache size + sizeCalculation: (value) => value.length, + dispose: (value, key) => { + Logger.debug(`Cache evicted: ${key.substring(0, 16)}...`); + } +}); + +/** + * Generate a cache key from prompt and options + */ +export function generateCacheKey(prompt: string, options: GeminiCLIOptions): string { + // Create deterministic hash of prompt + options + const cacheInput = JSON.stringify({ + prompt, + model: options.model, + sandbox: options.sandbox, + changeMode: options.changeMode, + yolo: options.yolo, + approvalMode: options.approvalMode, + outputFormat: options.outputFormat, + includeDirectories: options.includeDirectories, + debug: options.debug, + promptInteractive: options.promptInteractive, + extensions: options.extensions, + resume: options.resume + }); + + return createHash('sha256').update(cacheInput).digest('hex'); +} + +/** + * Get cached response if available + */ +export function getCachedResponse(cacheKey: string): string | undefined { + const cached = responseCache.get(cacheKey); + if (cached) { + Logger.debug(`Cache hit: ${cacheKey.substring(0, 16)}...`); + } + return cached; +} + +/** + * Cache a response + */ +export function cacheResponse(cacheKey: string, response: string): void { + responseCache.set(cacheKey, response); + Logger.debug(`Cached response: ${cacheKey.substring(0, 16)}... (${response.length} bytes)`); +} + +/** + * Clear the entire cache + */ +export function clearCache(): void { + responseCache.clear(); + Logger.debug('Response cache cleared'); +} + +/** + * Get cache statistics + */ +export function getCacheStats() { + return { + size: responseCache.size, + maxSize: responseCache.max, + calculatedSize: responseCache.calculatedSize, + maxCalculatedSize: responseCache.maxSize + }; +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..274518a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/**', + 'dist/**', + '**/*.d.ts', + '**/*.test.ts', + '**/*.spec.ts', + ], + }, + mockReset: true, + clearMocks: true, + restoreMocks: true, + }, +});