Compare commits

..

4 Commits

Author SHA1 Message Date
Raza Khan 0904c3767e Fixing state restore 2026-02-08 01:11:52 -08:00
Raza Khan f18e45d34d Initial Version 2026-02-06 14:34:32 -08:00
Grant McCloskey a3af4a8cae feat(skills): implement linking for agent skills (#18295) 2026-02-04 22:11:01 +00:00
Chris Coutinho 821355c429 fix(mcp): ensure MCP transport is closed to prevent memory leaks (#18054)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-02-04 21:00:41 +00:00
28 changed files with 1138 additions and 47 deletions
+29
View File
@@ -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
+18
View File
@@ -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.
+8
View File
@@ -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
+1 -1
View File
@@ -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
+141
View File
@@ -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"
```
+1
View File
@@ -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" }
+24 -1
View File
@@ -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"
},
+2
View File
@@ -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);
});
});
+93
View File
@@ -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();
},
};
+30
View File
@@ -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);
+4
View File
@@ -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,
+20 -7
View File
@@ -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);
+19 -6
View File
@@ -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;
});
}
+67
View File
@@ -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,
);
});
});
+180
View File
@@ -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(() => {
(
+79 -1
View File
@@ -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>',
+6
View File
@@ -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: () => {},
};
+89 -1
View File
@@ -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');
+69
View File
@@ -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');
});
});
+7 -2
View File
@@ -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() ?? '',
+8 -8
View File
@@ -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();
});
+51 -17
View File
@@ -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}`,