mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 16:20:57 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0904c3767e | |||
| f18e45d34d | |||
| a3af4a8cae | |||
| 821355c429 |
@@ -124,6 +124,8 @@ npm install -g @google/gemini-cli@nightly
|
||||
|
||||
### Advanced Capabilities
|
||||
|
||||
- **Automated Iterative Loops**: Use [Ralph Wiggum mode](./docs/ralph-wiggum.md)
|
||||
to repeatedly execute prompts until a goal is met (e.g., fixing tests).
|
||||
- Ground your queries with built-in
|
||||
[Google Search](https://ai.google.dev/gemini-api/docs/grounding) for real-time
|
||||
information
|
||||
@@ -256,6 +258,33 @@ use `--output-format stream-json` to get newline-delimited JSON events:
|
||||
gemini -p "Run tests and deploy" --output-format stream-json
|
||||
```
|
||||
|
||||
### Ralph Wiggum Mode (Iterative Automation)
|
||||
|
||||
Ralph Wiggum mode is an advanced automation feature that allows Gemini CLI to
|
||||
repeatedly execute a prompt in a loop until a specific goal is achieved. This is
|
||||
ideal for tasks like fixing failing tests or complex refactoring.
|
||||
|
||||
To use Ralph Wiggum mode, provide a prompt and a **completion promise** (a
|
||||
string to look for in the output). The CLI will:
|
||||
|
||||
1. Enter **YOLO mode** to auto-approve all tool calls.
|
||||
2. Run the prompt and check the response for your completion string.
|
||||
3. If not found, it repeats the process up to a specified **max iterations**.
|
||||
4. **Persistent Context**: It uses a **memory file** (`memories.md` by default)
|
||||
to pass notes between iterations. **Note:** Use a unique `--memory-file` for
|
||||
different tasks in the same directory to ensure context isolation.
|
||||
|
||||
```bash
|
||||
gemini -p "Fix the failing tests in this repo" \
|
||||
--ralph-wiggum \
|
||||
--completion-promise "ALL TESTS PASSED" \
|
||||
--max-iterations 5 \
|
||||
--memory-file "task-fix-tests.md"
|
||||
```
|
||||
|
||||
At the end of the run, a summary table displays the result of each iteration and
|
||||
extracted test statistics.
|
||||
|
||||
### Quick Examples
|
||||
|
||||
#### Start a new project
|
||||
|
||||
@@ -35,6 +35,9 @@ and parameters.
|
||||
| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. |
|
||||
| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. |
|
||||
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
|
||||
| `--ralph-wiggum` | - | boolean | `false` | Enable Ralph Wiggum iterative loop mode |
|
||||
| `--completion-promise` | - | string | - | String to look for to signal completion in Ralph Wiggum mode |
|
||||
| `--max-iterations` | - | number | `10` | Maximum loop iterations for Ralph Wiggum mode |
|
||||
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
|
||||
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` |
|
||||
| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. |
|
||||
@@ -99,3 +102,18 @@ See [Extensions Documentation](../extensions/index.md) for more details.
|
||||
| `gemini mcp list` | List all configured MCP servers | `gemini mcp list` |
|
||||
|
||||
See [MCP Server Integration](../tools/mcp-server.md) for more details.
|
||||
|
||||
## Skills management
|
||||
|
||||
| Command | Description | Example |
|
||||
| -------------------------------- | ------------------------------------- | ------------------------------------------------- |
|
||||
| `gemini skills list` | List all discovered agent skills | `gemini skills list` |
|
||||
| `gemini skills install <source>` | Install skill from Git, path, or file | `gemini skills install https://github.com/u/repo` |
|
||||
| `gemini skills link <path>` | Link local agent skills via symlink | `gemini skills link /path/to/my-skills` |
|
||||
| `gemini skills uninstall <name>` | Uninstall an agent skill | `gemini skills uninstall my-skill` |
|
||||
| `gemini skills enable <name>` | Enable an agent skill | `gemini skills enable my-skill` |
|
||||
| `gemini skills disable <name>` | Disable an agent skill | `gemini skills disable my-skill` |
|
||||
| `gemini skills enable --all` | Enable all skills | `gemini skills enable --all` |
|
||||
| `gemini skills disable --all` | Disable all skills | `gemini skills disable --all` |
|
||||
|
||||
See [Agent Skills Documentation](./skills.md) for more details.
|
||||
|
||||
@@ -52,6 +52,7 @@ locations override lower ones: **Workspace > User > Extension**.
|
||||
Use the `/skills` slash command to view and manage available expertise:
|
||||
|
||||
- `/skills list` (default): Shows all discovered skills and their status.
|
||||
- `/skills link <path>`: Links agent skills from a local directory via symlink.
|
||||
- `/skills disable <name>`: Prevents a specific skill from being used.
|
||||
- `/skills enable <name>`: Re-enables a disabled skill.
|
||||
- `/skills reload`: Refreshes the list of discovered skills from all tiers.
|
||||
@@ -67,6 +68,13 @@ The `gemini skills` command provides management utilities:
|
||||
# List all discovered skills
|
||||
gemini skills list
|
||||
|
||||
# Link agent skills from a local directory via symlink
|
||||
# Discovers skills (SKILL.md or */SKILL.md) and creates symlinks in ~/.gemini/skills (user)
|
||||
gemini skills link /path/to/my-skills-repo
|
||||
|
||||
# Link to the workspace scope (.gemini/skills)
|
||||
gemini skills link /path/to/my-skills-repo --scope workspace
|
||||
|
||||
# Install a skill from a Git repository, local directory, or zipped skill file (.skill)
|
||||
# Uses the user scope by default (~/.gemini/skills)
|
||||
gemini skills install https://github.com/user/repo.git
|
||||
|
||||
@@ -264,7 +264,7 @@ primary ways of releasing extensions are via a Git repository or through GitHub
|
||||
Releases. Using a public Git repository is the simplest method.
|
||||
|
||||
For detailed instructions on both methods, please refer to the
|
||||
[Extension Releasing Guide](./broken-link/releasing.md).
|
||||
[Extension Releasing Guide](./releasing.md).
|
||||
|
||||
## Conclusion
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Ralph Wiggum mode
|
||||
|
||||
Ralph Wiggum mode is an iterative automation technique that lets Gemini CLI
|
||||
repeatedly execute a prompt until a specific goal is met. This mode is designed
|
||||
for tasks that benefit from persistent refinement, such as fixing failing tests
|
||||
or performing complex refactoring.
|
||||
|
||||
> **Note:** This is a preview feature currently under active development.
|
||||
|
||||
## Overview
|
||||
|
||||
Inspired by the "Ralph Wiggum" technique, this mode treats failures as data and
|
||||
uses a feedback loop to reach a successful state. When you enable Ralph Wiggum
|
||||
mode, Gemini CLI enters YOLO (auto-approval) mode and continues to process the
|
||||
provided prompt until it detects your specified completion string in the model's
|
||||
output or reaches the maximum number of iterations.
|
||||
|
||||
## Usage
|
||||
|
||||
To use Ralph Wiggum mode, you must provide a prompt using the `-p` or `--prompt`
|
||||
flag. You then configure the loop behavior using the following flags:
|
||||
|
||||
| Flag | Description |
|
||||
| :--------------------- | :--------------------------------------------------------- |
|
||||
| `--ralph-wiggum` | Enables the Ralph Wiggum iterative loop mode. |
|
||||
| `--completion-promise` | The string to look for in the output to signal completion. |
|
||||
| `--max-iterations` | The maximum number of times to run the loop (default: 10). |
|
||||
| `--memory-file` | Task-specific memory file (default: `memories.md`). |
|
||||
|
||||
### Example
|
||||
|
||||
The following command attempts to fix tests by running the loop up to 5 times
|
||||
until the string "TESTS PASSED" appears in the output, using a specific memory
|
||||
file for this task:
|
||||
|
||||
```bash
|
||||
gemini -p "Fix the tests in packages/core" \
|
||||
--ralph-wiggum \
|
||||
--completion-promise "TESTS PASSED" \
|
||||
--max-iterations 5 \
|
||||
--memory-file "fix-core-tests.md"
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
When you run Gemini CLI with the `--ralph-wiggum` flag, the following process
|
||||
occurs:
|
||||
|
||||
1. **Enforces YOLO mode:** The tool automatically sets the approval mode to
|
||||
`yolo`. This ensures that tool calls (like writing files or running shell
|
||||
commands) are approved automatically to allow the automation to proceed
|
||||
without human intervention.
|
||||
2. **Iterative execution:** The CLI executes the provided prompt in a loop.
|
||||
3. **Completion check:** After each iteration, the CLI scans the full text of
|
||||
the assistant's response for the string provided in `--completion-promise`.
|
||||
4. **Loop termination:**
|
||||
- If the completion string is found, the loop exits successfully.
|
||||
- If the completion string is not found, the CLI starts a new iteration
|
||||
using the same initial prompt.
|
||||
- If the number of iterations reaches the `--max-iterations` limit, the loop
|
||||
stops.
|
||||
|
||||
## Persistent context (Memories)
|
||||
|
||||
To help the agent learn from previous attempts, Ralph Wiggum mode uses a
|
||||
`memories.md` file in your current working directory.
|
||||
|
||||
- **Automatic creation:** If the file doesn't exist, the CLI creates it with a
|
||||
default header.
|
||||
- **Context injection:** At the start of each iteration, the content of
|
||||
`memories.md` is read and prepended to your prompt.
|
||||
- **Usage:** You (or the agent, via tool use) can write notes, error logs, or
|
||||
successful patterns into this file. This allows the agent to "remember" what
|
||||
failed in iteration 1 and avoid repeating the same mistake in iteration 2.
|
||||
|
||||
## Summary statistics
|
||||
|
||||
At the end of the execution, Ralph Wiggum mode provides a summary table in the
|
||||
terminal. This table details the performance of each iteration, including:
|
||||
|
||||
- **Iteration number:** The sequence of the run.
|
||||
- **Status:** Whether the iteration met the completion promise ("Success") or
|
||||
failed to do so ("Failed").
|
||||
- **Tests Passed/Failed:** If the output contains recognizable test runner
|
||||
patterns (such as those from Vitest, Jest, or Mocha), the CLI extracts and
|
||||
displays the number of passing and failing tests.
|
||||
|
||||
### Example summary table
|
||||
|
||||
```text
|
||||
--- Ralph Wiggum Mode Summary ---
|
||||
| Iteration | Status | Tests Passed | Tests Failed |
|
||||
|-----------|---------|--------------|--------------|
|
||||
| 1 | Failed | 2 | 10 |
|
||||
| 2 | Failed | 8 | 4 |
|
||||
| 3 | Success | 12 | 0 |
|
||||
---------------------------------
|
||||
```
|
||||
|
||||
## Best practices
|
||||
|
||||
To get the most out of Ralph Wiggum mode, we recommend the following:
|
||||
|
||||
- **Clear completion criteria:** Ensure your prompt instructs the model to emit
|
||||
a specific, unique string (like "ALL TESTS PASSED") only when the task is
|
||||
truly complete.
|
||||
- **Incremental goals:** Use prompts that encourage the model to make small,
|
||||
verifiable changes in each iteration.
|
||||
- **Safety nets:** Always set a reasonable `--max-iterations` limit to prevent
|
||||
unintended long-running processes.
|
||||
|
||||
## Development and rebuilding
|
||||
|
||||
If you're modifying Ralph Wiggum mode or enabling it in a development
|
||||
environment, you must recompile the TypeScript source code.
|
||||
|
||||
### Full rebuild
|
||||
|
||||
To build all packages in the monorepo, run the following command from the root
|
||||
directory:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Fast CLI rebuild
|
||||
|
||||
If you've already performed a full build and are only making changes to the CLI
|
||||
package, you can run a targeted build:
|
||||
|
||||
```bash
|
||||
npm run build -w @google/gemini-cli
|
||||
```
|
||||
|
||||
### Running in development
|
||||
|
||||
After rebuilding, test your changes using the `npm run start` script:
|
||||
|
||||
```bash
|
||||
npm run start -- -p "Your task" --ralph-wiggum --completion-promise "SUCCESS"
|
||||
```
|
||||
@@ -45,6 +45,7 @@
|
||||
{ "label": "Custom commands", "slug": "docs/cli/custom-commands" },
|
||||
{ "label": "Enterprise features", "slug": "docs/cli/enterprise" },
|
||||
{ "label": "Headless mode & scripting", "slug": "docs/cli/headless" },
|
||||
{ "label": "Ralph Wiggum mode", "slug": "docs/ralph-wiggum" },
|
||||
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
|
||||
{ "label": "System prompt override", "slug": "docs/cli/system-prompt" },
|
||||
{ "label": "Telemetry", "slug": "docs/cli/telemetry" }
|
||||
|
||||
Generated
+24
-1
@@ -2251,6 +2251,7 @@
|
||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.2",
|
||||
@@ -2431,6 +2432,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -2464,6 +2466,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
|
||||
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -2832,6 +2835,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
|
||||
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -2865,6 +2869,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
|
||||
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/resources": "2.0.1"
|
||||
@@ -2917,6 +2922,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
|
||||
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/resources": "2.0.1",
|
||||
@@ -4122,6 +4128,7 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4399,6 +4406,7 @@
|
||||
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.35.0",
|
||||
"@typescript-eslint/types": "8.35.0",
|
||||
@@ -5391,6 +5399,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -8400,6 +8409,7 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8940,6 +8950,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -10541,6 +10552,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
|
||||
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -14299,6 +14311,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -14309,6 +14322,7 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -16545,6 +16559,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16768,7 +16783,8 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -16776,6 +16792,7 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -16948,6 +16965,7 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -17155,6 +17173,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -17268,6 +17287,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17280,6 +17300,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -17984,6 +18005,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"
|
||||
}
|
||||
@@ -18278,6 +18300,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import { listCommand } from './skills/list.js';
|
||||
import { enableCommand } from './skills/enable.js';
|
||||
import { disableCommand } from './skills/disable.js';
|
||||
import { installCommand } from './skills/install.js';
|
||||
import { linkCommand } from './skills/link.js';
|
||||
import { uninstallCommand } from './skills/uninstall.js';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
import { defer } from '../deferred.js';
|
||||
@@ -27,6 +28,7 @@ export const skillsCommand: CommandModule = {
|
||||
.command(defer(enableCommand, 'skills'))
|
||||
.command(defer(disableCommand, 'skills'))
|
||||
.command(defer(installCommand, 'skills'))
|
||||
.command(defer(linkCommand, 'skills'))
|
||||
.command(defer(uninstallCommand, 'skills'))
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { handleLink, linkCommand } from './link.js';
|
||||
|
||||
const mockLinkSkill = vi.hoisted(() => vi.fn());
|
||||
const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());
|
||||
const mockSkillsConsentString = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../utils/skillUtils.js', () => ({
|
||||
linkSkill: mockLinkSkill,
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger: { log: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
||||
skillsConsentString: mockSkillsConsentString,
|
||||
}));
|
||||
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
describe('skills link command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
});
|
||||
|
||||
describe('linkCommand', () => {
|
||||
it('should have correct command and describe', () => {
|
||||
expect(linkCommand.command).toBe('link <path>');
|
||||
expect(linkCommand.describe).toContain('Links an agent skill');
|
||||
});
|
||||
});
|
||||
|
||||
it('should call linkSkill with correct arguments', async () => {
|
||||
const sourcePath = '/source/path';
|
||||
mockLinkSkill.mockResolvedValue([
|
||||
{ name: 'test-skill', location: '/dest/path' },
|
||||
]);
|
||||
|
||||
await handleLink({ path: sourcePath, scope: 'user' });
|
||||
|
||||
expect(mockLinkSkill).toHaveBeenCalledWith(
|
||||
sourcePath,
|
||||
'user',
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Successfully linked skills'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle linkSkill failure', async () => {
|
||||
mockLinkSkill.mockRejectedValue(new Error('Link failed'));
|
||||
|
||||
await handleLink({ path: '/some/path' });
|
||||
|
||||
expect(debugLogger.error).toHaveBeenCalledWith('Link failed');
|
||||
expect(process.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import {
|
||||
requestConsentNonInteractive,
|
||||
skillsConsentString,
|
||||
} from '../../config/extensions/consent.js';
|
||||
import { linkSkill } from '../../utils/skillUtils.js';
|
||||
|
||||
interface LinkArgs {
|
||||
path: string;
|
||||
scope?: 'user' | 'workspace';
|
||||
consent?: boolean;
|
||||
}
|
||||
|
||||
export async function handleLink(args: LinkArgs) {
|
||||
try {
|
||||
const { scope = 'user', consent } = args;
|
||||
|
||||
await linkSkill(
|
||||
args.path,
|
||||
scope,
|
||||
(msg) => debugLogger.log(msg),
|
||||
async (skills, targetDir) => {
|
||||
const consentString = await skillsConsentString(
|
||||
skills,
|
||||
args.path,
|
||||
targetDir,
|
||||
true,
|
||||
);
|
||||
if (consent) {
|
||||
debugLogger.log('You have consented to the following:');
|
||||
debugLogger.log(consentString);
|
||||
return true;
|
||||
}
|
||||
return requestConsentNonInteractive(consentString);
|
||||
},
|
||||
);
|
||||
|
||||
debugLogger.log(chalk.green('\nSuccessfully linked skills.'));
|
||||
} catch (error) {
|
||||
debugLogger.error(getErrorMessage(error));
|
||||
await exitCli(1);
|
||||
}
|
||||
}
|
||||
|
||||
export const linkCommand: CommandModule = {
|
||||
command: 'link <path>',
|
||||
describe:
|
||||
'Links an agent skill from a local path. Updates to the source will be reflected immediately.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('path', {
|
||||
describe: 'The local path of the skill to link.',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('scope', {
|
||||
describe:
|
||||
'The scope to link the skill into. Defaults to "user" (global).',
|
||||
choices: ['user', 'workspace'],
|
||||
default: 'user',
|
||||
})
|
||||
.option('consent', {
|
||||
describe:
|
||||
'Acknowledge the security risks of linking a skill and skip the confirmation prompt.',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
})
|
||||
.check((argv) => {
|
||||
if (!argv.path) {
|
||||
throw new Error('The path argument must be provided.');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleLink({
|
||||
path: argv['path'] as string,
|
||||
scope: argv['scope'] as 'user' | 'workspace',
|
||||
consent: argv['consent'] as boolean | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -70,6 +70,11 @@ export interface CliArgs {
|
||||
prompt: string | undefined;
|
||||
promptInteractive: string | undefined;
|
||||
|
||||
ralphWiggum: boolean | undefined;
|
||||
completionPromise: string | undefined;
|
||||
maxIterations: number | undefined;
|
||||
memoryFile: string | undefined;
|
||||
|
||||
yolo: boolean | undefined;
|
||||
approvalMode: string | undefined;
|
||||
allowedMcpServerNames: string[] | undefined;
|
||||
@@ -141,6 +146,31 @@ export async function parseArguments(
|
||||
description: 'Run in sandbox?',
|
||||
})
|
||||
|
||||
.option('ralph-wiggum', {
|
||||
alias: 'ralphWiggum',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Enable Ralph Wiggum mode (iterative loop with YOLO mode).',
|
||||
})
|
||||
.option('completion-promise', {
|
||||
alias: 'completionPromise',
|
||||
type: 'string',
|
||||
description:
|
||||
'The string to look for in the output to signal completion in Ralph Wiggum mode.',
|
||||
})
|
||||
.option('max-iterations', {
|
||||
alias: 'maxIterations',
|
||||
type: 'number',
|
||||
description: 'Maximum number of iterations for Ralph Wiggum mode.',
|
||||
})
|
||||
.option('memory-file', {
|
||||
alias: 'memoryFile',
|
||||
type: 'string',
|
||||
description:
|
||||
'Task-specific memory file for Ralph Wiggum mode (defaults to memories.md).',
|
||||
default: 'memories.md',
|
||||
})
|
||||
|
||||
.option('yolo', {
|
||||
alias: 'y',
|
||||
type: 'boolean',
|
||||
|
||||
@@ -28,14 +28,19 @@ export async function skillsConsentString(
|
||||
skills: SkillDefinition[],
|
||||
source: string,
|
||||
targetDir?: string,
|
||||
isLink = false,
|
||||
): Promise<string> {
|
||||
const action = isLink ? 'Linking' : 'Installing';
|
||||
const output: string[] = [];
|
||||
output.push(`Installing agent skill(s) from "${source}".`);
|
||||
output.push('\nThe following agent skill(s) will be installed:\n');
|
||||
output.push(`${action} agent skill(s) from "${source}".`);
|
||||
output.push(
|
||||
`\nThe following agent skill(s) will be ${action.toLowerCase()}:\n`,
|
||||
);
|
||||
output.push(...(await renderSkillsList(skills)));
|
||||
|
||||
if (targetDir) {
|
||||
output.push(`Install Destination: ${targetDir}`);
|
||||
const destLabel = isLink ? 'Link' : 'Install';
|
||||
output.push(`${destLabel} Destination: ${targetDir}`);
|
||||
}
|
||||
output.push('\n' + SKILLS_WARNING_MESSAGE);
|
||||
|
||||
|
||||
@@ -476,6 +476,10 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
prompt: undefined,
|
||||
promptInteractive: undefined,
|
||||
query: undefined,
|
||||
ralphWiggum: undefined,
|
||||
completionPromise: undefined,
|
||||
maxIterations: undefined,
|
||||
memoryFile: undefined,
|
||||
yolo: undefined,
|
||||
approvalMode: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
|
||||
@@ -25,6 +25,7 @@ import { getStartupWarnings } from './utils/startupWarnings.js';
|
||||
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
import { runRalphWiggum } from './ralphWiggum.js';
|
||||
import {
|
||||
cleanupCheckpoints,
|
||||
registerCleanup,
|
||||
@@ -740,13 +741,25 @@ export async function main() {
|
||||
|
||||
initializeOutputListenersAndFlush();
|
||||
|
||||
await runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
});
|
||||
if (argv.ralphWiggum) {
|
||||
await runRalphWiggum({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
completionPromise: argv.completionPromise,
|
||||
maxIterations: argv.maxIterations,
|
||||
});
|
||||
} else {
|
||||
await runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
});
|
||||
}
|
||||
// Call cleanup before process.exit, which causes cleanup to not run
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
} from './utils/errors.js';
|
||||
import { TextOutput } from './ui/utils/textOutput.js';
|
||||
|
||||
interface RunNonInteractiveParams {
|
||||
export interface RunNonInteractiveParams {
|
||||
config: Config;
|
||||
settings: LoadedSettings;
|
||||
input: string;
|
||||
@@ -55,13 +55,15 @@ interface RunNonInteractiveParams {
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
}
|
||||
|
||||
// Moved to ralphWiggum.ts
|
||||
|
||||
export async function runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
}: RunNonInteractiveParams): Promise<void> {
|
||||
}: RunNonInteractiveParams): Promise<string> {
|
||||
return promptIdContext.run(prompt_id, async () => {
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
@@ -181,6 +183,9 @@ export async function runNonInteractive({
|
||||
}
|
||||
};
|
||||
|
||||
// Store accumulated response text to return
|
||||
let fullResponseText = '';
|
||||
|
||||
let errorToHandle: unknown | undefined;
|
||||
try {
|
||||
consolePatcher.patch();
|
||||
@@ -316,6 +321,13 @@ export async function runNonInteractive({
|
||||
const isRaw =
|
||||
config.getRawOutput() || config.getAcceptRawOutputRisk();
|
||||
const output = isRaw ? event.value : stripAnsi(event.value);
|
||||
|
||||
// Accumulate full response
|
||||
if (event.value) {
|
||||
fullResponseText += event.value;
|
||||
responseText += output;
|
||||
}
|
||||
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.MESSAGE,
|
||||
@@ -325,7 +337,7 @@ export async function runNonInteractive({
|
||||
delta: true,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
responseText += output;
|
||||
// responseText is already updated
|
||||
} else {
|
||||
if (event.value) {
|
||||
textOutput.write(output);
|
||||
@@ -381,7 +393,7 @@ export async function runNonInteractive({
|
||||
),
|
||||
});
|
||||
}
|
||||
return;
|
||||
return fullResponseText;
|
||||
} else if (event.type === GeminiEventType.AgentExecutionBlocked) {
|
||||
const blockMessage = `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
@@ -488,7 +500,7 @@ export async function runNonInteractive({
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
||||
}
|
||||
return;
|
||||
return fullResponseText;
|
||||
}
|
||||
|
||||
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
||||
@@ -512,7 +524,7 @@ export async function runNonInteractive({
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
||||
}
|
||||
return;
|
||||
return fullResponseText;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -528,5 +540,6 @@ export async function runNonInteractive({
|
||||
if (errorToHandle) {
|
||||
handleError(errorToHandle, config);
|
||||
}
|
||||
return fullResponseText;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { runRalphWiggum } from './ralphWiggum.js';
|
||||
import * as nonInteractiveCli from './nonInteractiveCli.js';
|
||||
import fs from 'node:fs';
|
||||
import type { Config, ResumedSessionData } from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('node:fs');
|
||||
vi.mock('./nonInteractiveCli.js');
|
||||
|
||||
describe('runRalphWiggum', () => {
|
||||
const mockConfig = {} as unknown as Config;
|
||||
const mockSettings = {} as unknown as LoadedSettings;
|
||||
const mockInput = 'Fix bugs';
|
||||
const mockPromptId = 'prompt-123';
|
||||
const mockResumedSessionData = {
|
||||
conversation: { messages: [], sessionId: 'session-123' },
|
||||
filePath: 'session.json',
|
||||
} as unknown as ResumedSessionData;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Default mock implementation for fs
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('');
|
||||
});
|
||||
|
||||
it('should preserve resumedSessionData in second iteration', async () => {
|
||||
// Setup runNonInteractive to return "Failed" first, then "Success"
|
||||
const runNonInteractiveMock = vi.mocked(
|
||||
nonInteractiveCli.runNonInteractive,
|
||||
);
|
||||
runNonInteractiveMock
|
||||
.mockResolvedValueOnce('Test failed')
|
||||
.mockResolvedValueOnce('Test Success');
|
||||
|
||||
await runRalphWiggum({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: mockInput,
|
||||
prompt_id: mockPromptId,
|
||||
resumedSessionData: mockResumedSessionData,
|
||||
completionPromise: 'Success',
|
||||
maxIterations: 2,
|
||||
});
|
||||
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// First call should have resumedSessionData
|
||||
expect(runNonInteractiveMock.mock.calls[0][0].resumedSessionData).toBe(
|
||||
mockResumedSessionData,
|
||||
);
|
||||
|
||||
// Second call should have resumedSessionData (FIXED)
|
||||
expect(runNonInteractiveMock.mock.calls[1][0].resumedSessionData).toBe(
|
||||
mockResumedSessionData,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
runNonInteractive,
|
||||
type RunNonInteractiveParams,
|
||||
} from './nonInteractiveCli.js';
|
||||
|
||||
interface IterationResult {
|
||||
iteration: number;
|
||||
status: 'Success' | 'Failed';
|
||||
testsPassed?: number;
|
||||
testsFailed?: number;
|
||||
testsTotal?: number;
|
||||
}
|
||||
|
||||
function extractTestStats(output: string): {
|
||||
passed?: number;
|
||||
failed?: number;
|
||||
total?: number;
|
||||
} {
|
||||
// Common patterns for test runners (Vitest, Jest, Mocha, etc.)
|
||||
const patterns = [
|
||||
// Vitest/Jest: "Tests: 3 passed, 1 failed, 4 total"
|
||||
/Tests:\s*(?:(\d+)\s+passed)?(?:,\s*)?(?:(\d+)\s+failed)?(?:,\s*)?(?:(\d+)\s+total)?/i,
|
||||
// Mocha: "3 passing (10ms)"
|
||||
/(\d+)\s+passing/i,
|
||||
// Mocha: "1 failing"
|
||||
/(\d+)\s+failing/i,
|
||||
// Generic: "Passed: 3, Failed: 1"
|
||||
/Passed:\s*(\d+)/i,
|
||||
/Failed:\s*(\d+)/i,
|
||||
];
|
||||
|
||||
let passed: number | undefined;
|
||||
let failed: number | undefined;
|
||||
let total: number | undefined;
|
||||
|
||||
// Try Vitest/Jest pattern first as it is most comprehensive
|
||||
const vitestMatch = output.match(patterns[0]);
|
||||
if (vitestMatch && (vitestMatch[1] || vitestMatch[2] || vitestMatch[3])) {
|
||||
passed = vitestMatch[1] ? parseInt(vitestMatch[1], 10) : 0;
|
||||
failed = vitestMatch[2] ? parseInt(vitestMatch[2], 10) : 0;
|
||||
total = vitestMatch[3] ? parseInt(vitestMatch[3], 10) : 0;
|
||||
return { passed, failed, total };
|
||||
}
|
||||
|
||||
// Fallback to individual patterns
|
||||
const passingMatch = output.match(patterns[1]);
|
||||
if (passingMatch) {
|
||||
passed = parseInt(passingMatch[1], 10);
|
||||
} else {
|
||||
const passedMatch = output.match(patterns[3]);
|
||||
if (passedMatch) passed = parseInt(passedMatch[1], 10);
|
||||
}
|
||||
|
||||
const failingMatch = output.match(patterns[2]);
|
||||
if (failingMatch) {
|
||||
failed = parseInt(failingMatch[1], 10);
|
||||
} else {
|
||||
const failedMatch = output.match(patterns[4]);
|
||||
if (failedMatch) failed = parseInt(failedMatch[1], 10);
|
||||
}
|
||||
|
||||
return { passed, failed, total };
|
||||
}
|
||||
|
||||
function printSummary(results: IterationResult[]) {
|
||||
process.stderr.write('\n--- Ralph Wiggum Mode Summary ---\n');
|
||||
process.stderr.write(
|
||||
'| Iteration | Status | Tests Passed | Tests Failed |\n',
|
||||
);
|
||||
process.stderr.write(
|
||||
'|-----------|---------|--------------|--------------|\n',
|
||||
);
|
||||
for (const result of results) {
|
||||
const passed = result.testsPassed !== undefined ? result.testsPassed : '-';
|
||||
const failed = result.testsFailed !== undefined ? result.testsFailed : '-';
|
||||
process.stderr.write(
|
||||
`| ${result.iteration.toString().padEnd(9)} | ${result.status.padEnd(7)} | ${passed.toString().padEnd(12)} | ${failed.toString().padEnd(12)} |\n`,
|
||||
);
|
||||
}
|
||||
process.stderr.write('---------------------------------\n\n');
|
||||
}
|
||||
|
||||
export async function runRalphWiggum({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
completionPromise,
|
||||
maxIterations,
|
||||
memoryFile,
|
||||
}: RunNonInteractiveParams & {
|
||||
completionPromise?: string;
|
||||
maxIterations?: number;
|
||||
memoryFile?: string;
|
||||
}): Promise<void> {
|
||||
const effectiveMaxIterations = maxIterations ?? 10;
|
||||
let iterations = 0;
|
||||
const currentResumedSessionData = resumedSessionData;
|
||||
const results: IterationResult[] = [];
|
||||
const effectiveMemoryFile = memoryFile || 'memories.md';
|
||||
const memoriesPath = path.join(process.cwd(), effectiveMemoryFile);
|
||||
|
||||
if (!fs.existsSync(memoriesPath)) {
|
||||
fs.writeFileSync(
|
||||
memoriesPath,
|
||||
`# Ralph Wiggum Memories\n\nTask: ${input}\n\nUse this file (${effectiveMemoryFile}) to store notes on what worked and what didn't work across iterations. The agent will read this at the start of each run.\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
process.stderr.write(
|
||||
`[Ralph Wiggum] Starting loop. Max iterations: ${effectiveMaxIterations}\n`,
|
||||
);
|
||||
|
||||
while (iterations < effectiveMaxIterations) {
|
||||
iterations++;
|
||||
process.stderr.write(
|
||||
`[Ralph Wiggum] Iteration ${iterations}/${effectiveMaxIterations}\n`,
|
||||
);
|
||||
|
||||
let currentInput = input;
|
||||
try {
|
||||
if (fs.existsSync(memoriesPath)) {
|
||||
const memories = fs.readFileSync(memoriesPath, 'utf-8');
|
||||
if (memories.trim()) {
|
||||
currentInput = `Context from previous iterations (${effectiveMemoryFile}):\n${memories}\n\nTask:\n${input}`;
|
||||
process.stderr.write(
|
||||
`[Ralph Wiggum] Loaded context from ${effectiveMemoryFile}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`[Ralph Wiggum] Failed to read ${effectiveMemoryFile}: ${error}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const output = await runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
input: currentInput,
|
||||
prompt_id,
|
||||
resumedSessionData: currentResumedSessionData,
|
||||
});
|
||||
|
||||
const stats = extractTestStats(output);
|
||||
const success =
|
||||
completionPromise && output.includes(completionPromise) ? true : false;
|
||||
|
||||
results.push({
|
||||
iteration: iterations,
|
||||
status: success ? 'Success' : 'Failed',
|
||||
testsPassed: stats.passed,
|
||||
testsFailed: stats.failed,
|
||||
testsTotal: stats.total,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
process.stderr.write(
|
||||
`[Ralph Wiggum] Completion promise "${completionPromise}" met. Exiting.\n`,
|
||||
);
|
||||
printSummary(results);
|
||||
return;
|
||||
}
|
||||
|
||||
// currentResumedSessionData = undefined; // Fixed: Keep resumedSessionData for subsequent iterations
|
||||
}
|
||||
process.stderr.write(
|
||||
`[Ralph Wiggum] Max iterations reached without meeting completion promise.\n`,
|
||||
);
|
||||
printSummary(results);
|
||||
}
|
||||
@@ -17,6 +17,27 @@ import {
|
||||
type MergedSettings,
|
||||
} from '../../config/settings.js';
|
||||
|
||||
vi.mock('../../utils/skillUtils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../utils/skillUtils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
linkSkill: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extensions/consent.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../config/extensions/consent.js')>();
|
||||
return {
|
||||
...actual,
|
||||
requestConsentInteractive: vi.fn().mockResolvedValue(true),
|
||||
skillsConsentString: vi.fn().mockResolvedValue('Mock Consent'),
|
||||
};
|
||||
});
|
||||
|
||||
import { linkSkill } from '../../utils/skillUtils.js';
|
||||
|
||||
vi.mock('../../config/settings.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../config/settings.js')>();
|
||||
@@ -185,6 +206,80 @@ describe('skillsCommand', () => {
|
||||
expect(lastCall.skills).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe('link', () => {
|
||||
it('should link a skill successfully', async () => {
|
||||
const linkCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'link',
|
||||
)!;
|
||||
vi.mocked(linkSkill).mockResolvedValue([
|
||||
{ name: 'test-skill', location: '/path' },
|
||||
]);
|
||||
|
||||
await linkCmd.action!(context, '/some/path');
|
||||
|
||||
expect(linkSkill).toHaveBeenCalledWith(
|
||||
'/some/path',
|
||||
'user',
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Successfully linked skills from "/some/path" (user).',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should link a skill with workspace scope', async () => {
|
||||
const linkCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'link',
|
||||
)!;
|
||||
vi.mocked(linkSkill).mockResolvedValue([
|
||||
{ name: 'test-skill', location: '/path' },
|
||||
]);
|
||||
|
||||
await linkCmd.action!(context, '/some/path --scope workspace');
|
||||
|
||||
expect(linkSkill).toHaveBeenCalledWith(
|
||||
'/some/path',
|
||||
'workspace',
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error if link fails', async () => {
|
||||
const linkCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'link',
|
||||
)!;
|
||||
vi.mocked(linkSkill).mockRejectedValue(new Error('Link failed'));
|
||||
|
||||
await linkCmd.action!(context, '/some/path');
|
||||
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to link skills: Link failed',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error if path is missing', async () => {
|
||||
const linkCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'link',
|
||||
)!;
|
||||
await linkCmd.action!(context, '');
|
||||
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /skills link <path> [--scope user|workspace]',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disable/enable', () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
|
||||
@@ -16,10 +16,18 @@ import {
|
||||
MessageType,
|
||||
} from '../types.js';
|
||||
import { disableSkill, enableSkill } from '../../utils/skillSettings.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
import { getAdminErrorMessage } from '@google/gemini-cli-core';
|
||||
import { renderSkillActionFeedback } from '../../utils/skillUtils.js';
|
||||
import {
|
||||
linkSkill,
|
||||
renderSkillActionFeedback,
|
||||
} from '../../utils/skillUtils.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import {
|
||||
requestConsentInteractive,
|
||||
skillsConsentString,
|
||||
} from '../../config/extensions/consent.js';
|
||||
|
||||
async function listAction(
|
||||
context: CommandContext,
|
||||
@@ -68,6 +76,69 @@ async function listAction(
|
||||
context.ui.addItem(skillsListItem);
|
||||
}
|
||||
|
||||
async function linkAction(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | SlashCommandActionReturn> {
|
||||
const parts = args.trim().split(/\s+/);
|
||||
const sourcePath = parts[0];
|
||||
|
||||
if (!sourcePath) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /skills link <path> [--scope user|workspace]',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let scopeArg = 'user';
|
||||
if (parts.length >= 3 && parts[1] === '--scope') {
|
||||
scopeArg = parts[2];
|
||||
} else if (parts.length >= 2 && parts[1].startsWith('--scope=')) {
|
||||
scopeArg = parts[1].split('=')[1];
|
||||
}
|
||||
|
||||
const scope = scopeArg === 'workspace' ? 'workspace' : 'user';
|
||||
|
||||
try {
|
||||
await linkSkill(
|
||||
sourcePath,
|
||||
scope,
|
||||
(msg) =>
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: msg,
|
||||
}),
|
||||
async (skills, targetDir) => {
|
||||
const consentString = await skillsConsentString(
|
||||
skills,
|
||||
sourcePath,
|
||||
targetDir,
|
||||
true,
|
||||
);
|
||||
return requestConsentInteractive(
|
||||
consentString,
|
||||
context.ui.setConfirmationRequest.bind(context.ui),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Successfully linked skills from "${sourcePath}" (${scope}).`,
|
||||
});
|
||||
|
||||
if (context.services.config) {
|
||||
await context.services.config.reloadSkills();
|
||||
}
|
||||
} catch (error) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to link skills: ${getErrorMessage(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function disableAction(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
@@ -301,6 +372,13 @@ export const skillsCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: listAction,
|
||||
},
|
||||
{
|
||||
name: 'link',
|
||||
description:
|
||||
'Link an agent skill from a local path. Usage: /skills link <path> [--scope user|workspace]',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: linkAction,
|
||||
},
|
||||
{
|
||||
name: 'disable',
|
||||
description: 'Disable a skill by name. Usage: /skills disable <name>',
|
||||
|
||||
@@ -83,6 +83,12 @@ export interface CommandContext {
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
|
||||
/**
|
||||
* Sets a confirmation request to be displayed to the user.
|
||||
*
|
||||
* @param value The confirmation request details.
|
||||
*/
|
||||
setConfirmationRequest: (value: ConfirmationRequest) => void;
|
||||
removeComponent: () => void;
|
||||
toggleBackgroundShell: () => void;
|
||||
};
|
||||
|
||||
@@ -237,6 +237,7 @@ export const useSlashCommandProcessor = (
|
||||
dispatchExtensionStateUpdate: actions.dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest:
|
||||
actions.addConfirmUpdateExtensionRequest,
|
||||
setConfirmationRequest,
|
||||
removeComponent: () => setCustomDialog(null),
|
||||
toggleBackgroundShell: actions.toggleBackgroundShell,
|
||||
},
|
||||
@@ -258,6 +259,7 @@ export const useSlashCommandProcessor = (
|
||||
actions,
|
||||
pendingItem,
|
||||
setPendingItem,
|
||||
setConfirmationRequest,
|
||||
toggleVimEnabled,
|
||||
sessionShellAllowlist,
|
||||
reloadCommands,
|
||||
|
||||
@@ -28,6 +28,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
||||
extensionsUpdateState: new Map(),
|
||||
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
|
||||
addConfirmUpdateExtensionRequest: (_request) => {},
|
||||
setConfirmationRequest: (_request) => {},
|
||||
removeComponent: () => {},
|
||||
toggleBackgroundShell: () => {},
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { installSkill } from './skillUtils.js';
|
||||
import { installSkill, linkSkill } from './skillUtils.js';
|
||||
|
||||
describe('skillUtils', () => {
|
||||
let tempDir: string;
|
||||
@@ -24,6 +24,94 @@ describe('skillUtils', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('linkSkill', () => {
|
||||
it('should successfully link from a local directory', async () => {
|
||||
// Create a mock skill directory
|
||||
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
|
||||
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
|
||||
await fs.mkdir(skillSubDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillSubDir, 'SKILL.md'),
|
||||
'---\nname: test-skill\ndescription: test\n---\nbody',
|
||||
);
|
||||
|
||||
const skills = await linkSkill(mockSkillSourceDir, 'workspace', () => {});
|
||||
expect(skills.length).toBe(1);
|
||||
expect(skills[0].name).toBe('test-skill');
|
||||
|
||||
const linkedPath = path.join(tempDir, '.gemini/skills', 'test-skill');
|
||||
const stats = await fs.lstat(linkedPath);
|
||||
expect(stats.isSymbolicLink()).toBe(true);
|
||||
|
||||
const linkTarget = await fs.readlink(linkedPath);
|
||||
expect(path.resolve(linkTarget)).toBe(path.resolve(skillSubDir));
|
||||
});
|
||||
|
||||
it('should overwrite existing skill at destination', async () => {
|
||||
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
|
||||
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
|
||||
await fs.mkdir(skillSubDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillSubDir, 'SKILL.md'),
|
||||
'---\nname: test-skill\ndescription: test\n---\nbody',
|
||||
);
|
||||
|
||||
const targetDir = path.join(tempDir, '.gemini/skills');
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
const existingPath = path.join(targetDir, 'test-skill');
|
||||
await fs.mkdir(existingPath);
|
||||
|
||||
const skills = await linkSkill(mockSkillSourceDir, 'workspace', () => {});
|
||||
expect(skills.length).toBe(1);
|
||||
|
||||
const stats = await fs.lstat(existingPath);
|
||||
expect(stats.isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('should abort linking if consent is rejected', async () => {
|
||||
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
|
||||
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
|
||||
await fs.mkdir(skillSubDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillSubDir, 'SKILL.md'),
|
||||
'---\nname: test-skill\ndescription: test\n---\nbody',
|
||||
);
|
||||
|
||||
const requestConsent = vi.fn().mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
linkSkill(mockSkillSourceDir, 'workspace', () => {}, requestConsent),
|
||||
).rejects.toThrow('Skill linking cancelled by user.');
|
||||
|
||||
expect(requestConsent).toHaveBeenCalled();
|
||||
|
||||
// Verify it was NOT linked
|
||||
const linkedPath = path.join(tempDir, '.gemini/skills', 'test-skill');
|
||||
const exists = await fs.lstat(linkedPath).catch(() => null);
|
||||
expect(exists).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw error if multiple skills with same name are discovered', async () => {
|
||||
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
|
||||
const skillDir1 = path.join(mockSkillSourceDir, 'skill1');
|
||||
const skillDir2 = path.join(mockSkillSourceDir, 'skill2');
|
||||
await fs.mkdir(skillDir1, { recursive: true });
|
||||
await fs.mkdir(skillDir2, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillDir1, 'SKILL.md'),
|
||||
'---\nname: duplicate-skill\ndescription: desc1\n---\nbody1',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(skillDir2, 'SKILL.md'),
|
||||
'---\nname: duplicate-skill\ndescription: desc2\n---\nbody2',
|
||||
);
|
||||
|
||||
await expect(
|
||||
linkSkill(mockSkillSourceDir, 'workspace', () => {}),
|
||||
).rejects.toThrow('Duplicate skill name "duplicate-skill" found');
|
||||
});
|
||||
});
|
||||
|
||||
it('should successfully install from a .skill file', async () => {
|
||||
const skillPath = path.join(projectRoot, 'weather-skill.skill');
|
||||
|
||||
|
||||
@@ -186,6 +186,75 @@ export async function installSkill(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Central logic for linking a skill from a local path via symlink.
|
||||
*/
|
||||
export async function linkSkill(
|
||||
source: string,
|
||||
scope: 'user' | 'workspace',
|
||||
onLog: (msg: string) => void,
|
||||
requestConsent: (
|
||||
skills: SkillDefinition[],
|
||||
targetDir: string,
|
||||
) => Promise<boolean> = () => Promise.resolve(true),
|
||||
): Promise<Array<{ name: string; location: string }>> {
|
||||
const sourcePath = path.resolve(source);
|
||||
|
||||
onLog(`Searching for skills in ${sourcePath}...`);
|
||||
const skills = await loadSkillsFromDir(sourcePath);
|
||||
|
||||
if (skills.length === 0) {
|
||||
throw new Error(
|
||||
`No valid skills found in "${sourcePath}". Ensure a SKILL.md file exists with valid frontmatter.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check for internal name collisions
|
||||
const seenNames = new Map<string, string>();
|
||||
for (const skill of skills) {
|
||||
if (seenNames.has(skill.name)) {
|
||||
throw new Error(
|
||||
`Duplicate skill name "${skill.name}" found at multiple locations:\n - ${seenNames.get(skill.name)}\n - ${skill.location}`,
|
||||
);
|
||||
}
|
||||
seenNames.set(skill.name, skill.location);
|
||||
}
|
||||
|
||||
const workspaceDir = process.cwd();
|
||||
const storage = new Storage(workspaceDir);
|
||||
const targetDir =
|
||||
scope === 'workspace'
|
||||
? storage.getProjectSkillsDir()
|
||||
: Storage.getUserSkillsDir();
|
||||
|
||||
if (!(await requestConsent(skills, targetDir))) {
|
||||
throw new Error('Skill linking cancelled by user.');
|
||||
}
|
||||
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
|
||||
const linkedSkills: Array<{ name: string; location: string }> = [];
|
||||
|
||||
for (const skill of skills) {
|
||||
const skillName = skill.name;
|
||||
const skillSourceDir = path.dirname(skill.location);
|
||||
const destPath = path.join(targetDir, skillName);
|
||||
|
||||
const exists = await fs.lstat(destPath).catch(() => null);
|
||||
if (exists) {
|
||||
onLog(
|
||||
`Skill "${skillName}" already exists at destination. Overwriting...`,
|
||||
);
|
||||
await fs.rm(destPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
await fs.symlink(skillSourceDir, destPath, 'dir');
|
||||
linkedSkills.push({ name: skillName, location: destPath });
|
||||
}
|
||||
|
||||
return linkedSkills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central logic for uninstalling a skill by name.
|
||||
*/
|
||||
|
||||
@@ -254,4 +254,21 @@ description:no-space-desc
|
||||
expect(skills[0].name).toBe('no-space-name');
|
||||
expect(skills[0].description).toBe('no-space-desc');
|
||||
});
|
||||
|
||||
it('should sanitize skill names containing invalid filename characters', async () => {
|
||||
const skillFile = path.join(testRootDir, 'SKILL.md');
|
||||
await fs.writeFile(
|
||||
skillFile,
|
||||
`---
|
||||
name: gke:prs-troubleshooter
|
||||
description: Test sanitization
|
||||
---
|
||||
`,
|
||||
);
|
||||
|
||||
const skills = await loadSkillsFromDir(testRootDir);
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].name).toBe('gke-prs-troubleshooter');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,10 +121,12 @@ export async function loadSkillsFromDir(
|
||||
return [];
|
||||
}
|
||||
|
||||
const skillFiles = await glob(['SKILL.md', '*/SKILL.md'], {
|
||||
const pattern = ['SKILL.md', '*/SKILL.md'];
|
||||
const skillFiles = await glob(pattern, {
|
||||
cwd: absoluteSearchPath,
|
||||
absolute: true,
|
||||
nodir: true,
|
||||
ignore: ['**/node_modules/**', '**/.git/**'],
|
||||
});
|
||||
|
||||
for (const skillFile of skillFiles) {
|
||||
@@ -171,8 +173,11 @@ export async function loadSkillFromFile(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sanitize name for use as a filename/directory name (e.g. replace ':' with '-')
|
||||
const sanitizedName = frontmatter.name.replace(/[:\\/<>*?"|]/g, '-');
|
||||
|
||||
return {
|
||||
name: frontmatter.name,
|
||||
name: sanitizedName,
|
||||
description: frontmatter.description,
|
||||
location: filePath,
|
||||
body: match[2]?.trim() ?? '',
|
||||
|
||||
@@ -749,9 +749,9 @@ describe('mcp-client', () => {
|
||||
vi.mocked(ClientLib.Client).mockReturnValue(
|
||||
mockedClient as unknown as ClientLib.Client,
|
||||
);
|
||||
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
|
||||
{} as SdkClientStdioLib.StdioClientTransport,
|
||||
);
|
||||
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue({
|
||||
close: vi.fn(),
|
||||
} as unknown as SdkClientStdioLib.StdioClientTransport);
|
||||
const mockedToolRegistry = {
|
||||
registerTool: vi.fn(),
|
||||
unregisterTool: vi.fn(),
|
||||
@@ -1888,7 +1888,7 @@ describe('connectToMcpServer with OAuth', () => {
|
||||
EMPTY_CONFIG,
|
||||
);
|
||||
|
||||
expect(client).toBe(mockedClient);
|
||||
expect(client.client).toBe(mockedClient);
|
||||
expect(mockedClient.connect).toHaveBeenCalledTimes(2);
|
||||
expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -1934,7 +1934,7 @@ describe('connectToMcpServer with OAuth', () => {
|
||||
EMPTY_CONFIG,
|
||||
);
|
||||
|
||||
expect(client).toBe(mockedClient);
|
||||
expect(client.client).toBe(mockedClient);
|
||||
expect(mockedClient.connect).toHaveBeenCalledTimes(2);
|
||||
expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce();
|
||||
expect(OAuthUtils.discoverOAuthConfig).toHaveBeenCalledWith(serverUrl);
|
||||
@@ -2029,7 +2029,7 @@ describe('connectToMcpServer - HTTP→SSE fallback', () => {
|
||||
EMPTY_CONFIG,
|
||||
);
|
||||
|
||||
expect(client).toBe(mockedClient);
|
||||
expect(client.client).toBe(mockedClient);
|
||||
// First HTTP attempt fails, second SSE attempt succeeds
|
||||
expect(mockedClient.connect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -2070,7 +2070,7 @@ describe('connectToMcpServer - HTTP→SSE fallback', () => {
|
||||
EMPTY_CONFIG,
|
||||
);
|
||||
|
||||
expect(client).toBe(mockedClient);
|
||||
expect(client.client).toBe(mockedClient);
|
||||
expect(mockedClient.connect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -2155,7 +2155,7 @@ describe('connectToMcpServer - OAuth with transport fallback', () => {
|
||||
EMPTY_CONFIG,
|
||||
);
|
||||
|
||||
expect(client).toBe(mockedClient);
|
||||
expect(client.client).toBe(mockedClient);
|
||||
expect(mockedClient.connect).toHaveBeenCalledTimes(3);
|
||||
expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -144,7 +144,7 @@ export class McpClient {
|
||||
}
|
||||
this.updateStatus(MCPServerStatus.CONNECTING);
|
||||
try {
|
||||
this.client = await connectToMcpServer(
|
||||
const { client, transport } = await connectToMcpServer(
|
||||
this.clientVersion,
|
||||
this.serverName,
|
||||
this.serverConfig,
|
||||
@@ -152,11 +152,13 @@ export class McpClient {
|
||||
this.workspaceContext,
|
||||
this.cliConfig.sanitizationConfig,
|
||||
);
|
||||
this.client = client;
|
||||
this.transport = transport;
|
||||
|
||||
this.registerNotificationHandlers();
|
||||
|
||||
const originalOnError = this.client.onerror;
|
||||
this.client.onerror = (error) => {
|
||||
this.client.onerror = async (error) => {
|
||||
if (this.status !== MCPServerStatus.CONNECTED) {
|
||||
return;
|
||||
}
|
||||
@@ -167,6 +169,14 @@ export class McpClient {
|
||||
error,
|
||||
);
|
||||
this.updateStatus(MCPServerStatus.DISCONNECTED);
|
||||
// Close transport to prevent memory leaks
|
||||
if (this.transport) {
|
||||
try {
|
||||
await this.transport.close();
|
||||
} catch {
|
||||
// Ignore errors when closing transport on error
|
||||
}
|
||||
}
|
||||
};
|
||||
this.updateStatus(MCPServerStatus.CONNECTED);
|
||||
} catch (error) {
|
||||
@@ -909,8 +919,9 @@ export async function connectAndDiscover(
|
||||
updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTING);
|
||||
|
||||
let mcpClient: Client | undefined;
|
||||
let transport: Transport | undefined;
|
||||
try {
|
||||
mcpClient = await connectToMcpServer(
|
||||
const result = await connectToMcpServer(
|
||||
clientVersion,
|
||||
mcpServerName,
|
||||
mcpServerConfig,
|
||||
@@ -918,10 +929,20 @@ export async function connectAndDiscover(
|
||||
workspaceContext,
|
||||
cliConfig.sanitizationConfig,
|
||||
);
|
||||
mcpClient = result.client;
|
||||
transport = result.transport;
|
||||
|
||||
mcpClient.onerror = (error) => {
|
||||
mcpClient.onerror = async (error) => {
|
||||
coreEvents.emitFeedback('error', `MCP ERROR (${mcpServerName}):`, error);
|
||||
updateMCPServerStatus(mcpServerName, MCPServerStatus.DISCONNECTED);
|
||||
// Close transport to prevent memory leaks
|
||||
if (transport) {
|
||||
try {
|
||||
await transport.close();
|
||||
} catch {
|
||||
// Ignore errors when closing transport on error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Attempt to discover both prompts and tools
|
||||
@@ -1302,16 +1323,18 @@ function createSSETransportWithAuth(
|
||||
* @param client The MCP client to connect
|
||||
* @param config The MCP server configuration
|
||||
* @param accessToken Optional OAuth access token for authentication
|
||||
* @returns The transport used for connection
|
||||
*/
|
||||
async function connectWithSSETransport(
|
||||
client: Client,
|
||||
config: MCPServerConfig,
|
||||
accessToken?: string | null,
|
||||
): Promise<void> {
|
||||
): Promise<Transport> {
|
||||
const transport = createSSETransportWithAuth(config, accessToken);
|
||||
await client.connect(transport, {
|
||||
timeout: config.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC,
|
||||
});
|
||||
return transport;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1341,6 +1364,7 @@ async function showAuthRequiredMessage(serverName: string): Promise<never> {
|
||||
* @param config The MCP server configuration
|
||||
* @param accessToken The OAuth access token to use
|
||||
* @param httpReturned404 Whether the HTTP transport returned 404 (indicating SSE-only server)
|
||||
* @returns The transport used for connection
|
||||
*/
|
||||
async function retryWithOAuth(
|
||||
client: Client,
|
||||
@@ -1348,17 +1372,21 @@ async function retryWithOAuth(
|
||||
config: MCPServerConfig,
|
||||
accessToken: string,
|
||||
httpReturned404: boolean,
|
||||
): Promise<void> {
|
||||
): Promise<Transport> {
|
||||
if (httpReturned404) {
|
||||
// HTTP returned 404, only try SSE
|
||||
debugLogger.log(
|
||||
`Retrying SSE connection to '${serverName}' with OAuth token...`,
|
||||
);
|
||||
await connectWithSSETransport(client, config, accessToken);
|
||||
const transport = await connectWithSSETransport(
|
||||
client,
|
||||
config,
|
||||
accessToken,
|
||||
);
|
||||
debugLogger.log(
|
||||
`Successfully connected to '${serverName}' using SSE with OAuth.`,
|
||||
);
|
||||
return;
|
||||
return transport;
|
||||
}
|
||||
|
||||
// HTTP returned 401, try HTTP with OAuth first
|
||||
@@ -1382,6 +1410,7 @@ async function retryWithOAuth(
|
||||
debugLogger.log(
|
||||
`Successfully connected to '${serverName}' using HTTP with OAuth.`,
|
||||
);
|
||||
return httpTransport;
|
||||
} catch (httpError) {
|
||||
await httpTransport.close();
|
||||
|
||||
@@ -1393,10 +1422,15 @@ async function retryWithOAuth(
|
||||
!config.httpUrl
|
||||
) {
|
||||
debugLogger.log(`HTTP with OAuth returned 404, trying SSE with OAuth...`);
|
||||
await connectWithSSETransport(client, config, accessToken);
|
||||
const sseTransport = await connectWithSSETransport(
|
||||
client,
|
||||
config,
|
||||
accessToken,
|
||||
);
|
||||
debugLogger.log(
|
||||
`Successfully connected to '${serverName}' using SSE with OAuth.`,
|
||||
);
|
||||
return sseTransport;
|
||||
} else {
|
||||
throw httpError;
|
||||
}
|
||||
@@ -1410,7 +1444,7 @@ async function retryWithOAuth(
|
||||
*
|
||||
* @param mcpServerName The name of the MCP server, used for logging and identification.
|
||||
* @param mcpServerConfig The configuration specifying how to connect to the server.
|
||||
* @returns A promise that resolves to a connected MCP `Client` instance.
|
||||
* @returns A promise that resolves to a connected MCP `Client` instance and its transport.
|
||||
* @throws An error if the connection fails or the configuration is invalid.
|
||||
*/
|
||||
export async function connectToMcpServer(
|
||||
@@ -1420,7 +1454,7 @@ export async function connectToMcpServer(
|
||||
debugMode: boolean,
|
||||
workspaceContext: WorkspaceContext,
|
||||
sanitizationConfig: EnvironmentSanitizationConfig,
|
||||
): Promise<Client> {
|
||||
): Promise<{ client: Client; transport: Transport }> {
|
||||
const mcpClient = new Client(
|
||||
{
|
||||
name: 'gemini-cli-mcp-client',
|
||||
@@ -1492,7 +1526,7 @@ export async function connectToMcpServer(
|
||||
await mcpClient.connect(transport, {
|
||||
timeout: mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC,
|
||||
});
|
||||
return mcpClient;
|
||||
return { client: mcpClient, transport };
|
||||
} catch (error) {
|
||||
await transport.close();
|
||||
firstAttemptError = error as Error;
|
||||
@@ -1523,7 +1557,7 @@ export async function connectToMcpServer(
|
||||
try {
|
||||
// Try SSE with stored OAuth token if available
|
||||
// This ensures that SSE fallback works for authenticated servers
|
||||
await connectWithSSETransport(
|
||||
const sseTransport = await connectWithSSETransport(
|
||||
mcpClient,
|
||||
mcpServerConfig,
|
||||
await getStoredOAuthToken(mcpServerName),
|
||||
@@ -1532,7 +1566,7 @@ export async function connectToMcpServer(
|
||||
debugLogger.log(
|
||||
`MCP server '${mcpServerName}': Successfully connected using SSE transport.`,
|
||||
);
|
||||
return mcpClient;
|
||||
return { client: mcpClient, transport: sseTransport };
|
||||
} catch (sseFallbackError) {
|
||||
sseError = sseFallbackError as Error;
|
||||
|
||||
@@ -1639,14 +1673,14 @@ export async function connectToMcpServer(
|
||||
);
|
||||
}
|
||||
|
||||
await retryWithOAuth(
|
||||
const oauthTransport = await retryWithOAuth(
|
||||
mcpClient,
|
||||
mcpServerName,
|
||||
mcpServerConfig,
|
||||
accessToken,
|
||||
httpReturned404,
|
||||
);
|
||||
return mcpClient;
|
||||
return { client: mcpClient, transport: oauthTransport };
|
||||
} else {
|
||||
throw new Error(
|
||||
`Failed to handle automatic OAuth for server '${mcpServerName}'`,
|
||||
@@ -1727,7 +1761,7 @@ export async function connectToMcpServer(
|
||||
timeout: mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC,
|
||||
});
|
||||
// Connection successful with OAuth
|
||||
return mcpClient;
|
||||
return { client: mcpClient, transport: oauthTransport };
|
||||
} else {
|
||||
throw new Error(
|
||||
`OAuth configuration failed for '${mcpServerName}'. Please authenticate manually with /mcp auth ${mcpServerName}`,
|
||||
|
||||
Reference in New Issue
Block a user