Merge branch 'main' into chore/remove-cursor-target-support

This commit is contained in:
Eric Zakariasson
2026-02-17 10:07:38 -08:00
committed by GitHub
18 changed files with 1390 additions and 8 deletions

View File

@@ -12,7 +12,7 @@
{
"name": "compound-engineering",
"description": "AI-powered development tools that get smarter with every use. Make each unit of engineering work easier than the last. Includes 29 specialized agents, 22 commands, and 19 skills.",
"version": "2.33.0",
"version": "2.34.0",
"author": {
"name": "Kieran Klaassen",
"url": "https://github.com/kieranklaassen",

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
*.log
node_modules/
.codex/
todos/

View File

@@ -18,9 +18,9 @@ A Claude Code plugin marketplace featuring the **Compound Engineering Plugin**
/add-plugin compound-engineering
```
## OpenCode, Codex, Droid & Pi (experimental) Install
## OpenCode, Codex, Droid & Pi & Gemini (experimental) Install
This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid and Pi.
This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Pi, and Gemini CLI.
```bash
# convert the compound-engineering plugin into OpenCode format
@@ -34,6 +34,9 @@ bunx @every-env/compound-plugin install compound-engineering --to droid
# convert to Pi format
bunx @every-env/compound-plugin install compound-engineering --to pi
# convert to Gemini CLI format
bunx @every-env/compound-plugin install compound-engineering --to gemini
```
Local dev:
@@ -46,6 +49,7 @@ OpenCode output is written to `~/.config/opencode` by default, with `opencode.js
Codex output is written to `~/.codex/prompts` and `~/.codex/skills`, with each Claude command converted into both a prompt and a skill (the prompt instructs Codex to load the corresponding skill). Generated Codex skill descriptions are truncated to 1024 characters (Codex limit).
Droid output is written to `~/.factory/` with commands, droids (agents), and skills. Claude tool names are mapped to Factory equivalents (`Bash``Execute`, `Write``Create`, etc.) and namespace prefixes are stripped from commands.
Pi output is written to `~/.pi/agent/` by default with prompts, skills, extensions, and `compound-engineering/mcporter.json` for MCPorter interoperability.
Gemini output is written to `.gemini/` with skills (from agents), commands (`.toml`), and `settings.json` (MCP servers). Namespaced commands create directory structure (`workflows:plan``commands/workflows/plan.toml`). Skills use the identical SKILL.md standard and pass through unchanged.
All provider targets are experimental and may change as the formats evolve.

View File

@@ -0,0 +1,370 @@
---
title: Add Gemini CLI as a Target Provider
type: feat
status: completed
completed_date: 2026-02-14
completed_by: "Claude Opus 4.6"
actual_effort: "Completed in one session"
date: 2026-02-14
---
# Add Gemini CLI as a Target Provider
## Overview
Add `gemini` as a sixth target provider in the converter CLI, alongside `opencode`, `codex`, `droid`, `cursor`, and `pi`. This enables `--to gemini` for both `convert` and `install` commands, converting Claude Code plugins into Gemini CLI-compatible format.
Gemini CLI ([google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli)) is Google's open-source AI agent for the terminal. It supports GEMINI.md context files, custom commands (TOML format), agent skills (SKILL.md standard), MCP servers, and extensions -- making it a strong conversion target with good coverage of Claude Code plugin concepts.
## Component Mapping
| Claude Code | Gemini Equivalent | Notes |
|---|---|---|
| `agents/*.md` | `.gemini/skills/*/SKILL.md` | Agents become skills -- Gemini activates them on demand via `activate_skill` tool based on description matching |
| `commands/*.md` | `.gemini/commands/*.toml` | TOML format with `prompt` and `description` fields; namespaced via directory structure |
| `skills/*/SKILL.md` | `.gemini/skills/*/SKILL.md` | **Identical standard** -- copy directly |
| MCP servers | `settings.json` `mcpServers` | Same MCP protocol; different config location (`settings.json` vs `.mcp.json`) |
| `hooks/` | `settings.json` hooks | Gemini has hooks (`BeforeTool`, `AfterTool`, `SessionStart`, etc.) but different format; emit `console.warn` and skip for now |
| `.claude/` paths | `.gemini/` paths | Content rewriting needed |
### Key Design Decisions
**1. Agents become skills (not GEMINI.md context)**
With 29 agents, dumping them into GEMINI.md would flood every session's context. Instead, agents convert to skills -- Gemini autonomously activates them based on the skill description when relevant. This matches how Claude Code agents are invoked on demand via the Task tool.
**2. Commands use TOML format with directory-based namespacing**
Gemini CLI commands are `.toml` files where the path determines the command name: `.gemini/commands/git/commit.toml` becomes `/git:commit`. This maps cleanly from Claude Code's colon-namespaced commands (`workflows:plan` -> `.gemini/commands/workflows/plan.toml`).
**3. Commands use `{{args}}` placeholder**
Gemini's TOML commands support `{{args}}` for argument injection, mapping from Claude Code's `argument-hint` field. Commands with `argument-hint` get `{{args}}` appended to the prompt.
**4. MCP servers go into project-level settings.json**
Gemini CLI reads MCP config from `.gemini/settings.json` under the `mcpServers` key. The format is compatible -- same `command`, `args`, `env` fields, plus Gemini-specific `cwd`, `timeout`, `trust`, `includeTools`, `excludeTools`.
**5. Skills pass through unchanged**
Gemini adopted the same SKILL.md standard (YAML frontmatter with `name` and `description`, markdown body). Skills copy directly.
### TOML Command Format
```toml
description = "Brief description of the command"
prompt = """
The prompt content that will be sent to Gemini.
User request: {{args}}
"""
```
- `description` (string): One-line description shown in `/help`
- `prompt` (string): The prompt sent to the model; supports `{{args}}`, `!{shell}`, `@{file}` placeholders
### Skill (SKILL.md) Format
```yaml
---
name: skill-name
description: When and how Gemini should use this skill
---
# Skill Title
Detailed instructions...
```
Identical to Claude Code's format. The `description` field is critical -- Gemini uses it to decide when to activate the skill.
### MCP Server Format (settings.json)
```json
{
"mcpServers": {
"server-name": {
"command": "npx",
"args": ["-y", "package-name"],
"env": { "KEY": "value" }
}
}
}
```
## Acceptance Criteria
- [x] `bun run src/index.ts convert --to gemini ./plugins/compound-engineering` produces valid Gemini config
- [x] Agents convert to `.gemini/skills/*/SKILL.md` with populated `description` in frontmatter
- [x] Commands convert to `.gemini/commands/*.toml` with `prompt` and `description` fields
- [x] Namespaced commands create directory structure (`workflows:plan` -> `commands/workflows/plan.toml`)
- [x] Commands with `argument-hint` include `{{args}}` placeholder in prompt
- [x] Commands with `disable-model-invocation: true` are still included (TOML commands are prompts, not code)
- [x] Skills copied to `.gemini/skills/` (identical format)
- [x] MCP servers written to `.gemini/settings.json` under `mcpServers` key
- [x] Existing `.gemini/settings.json` is backed up before overwrite, and MCP config is merged (not clobbered)
- [x] Content transformation rewrites `.claude/` and `~/.claude/` paths to `.gemini/` and `~/.gemini/`
- [x] `/workflows:plan` transformed to `/workflows:plan` (Gemini preserves colon namespacing via directories)
- [x] `Task agent-name(args)` transformed to `Use the agent-name skill to: args`
- [x] Plugins with hooks emit `console.warn` about format differences
- [x] Writer does not double-nest `.gemini/.gemini/`
- [x] `model` and `allowedTools` fields silently dropped (no Gemini equivalent in skills/commands)
- [x] Converter and writer tests pass
- [x] Existing tests still pass (`bun test`)
## Implementation
### Phase 1: Types
**Create `src/types/gemini.ts`**
```typescript
export type GeminiSkill = {
name: string
content: string // Full SKILL.md with YAML frontmatter
}
export type GeminiSkillDir = {
name: string
sourceDir: string
}
export type GeminiCommand = {
name: string // e.g. "plan" or "workflows/plan"
content: string // Full TOML content
}
export type GeminiBundle = {
generatedSkills: GeminiSkill[] // From agents
skillDirs: GeminiSkillDir[] // From skills (pass-through)
commands: GeminiCommand[]
mcpServers?: Record<string, {
command?: string
args?: string[]
env?: Record<string, string>
url?: string
headers?: Record<string, string>
}>
}
```
### Phase 2: Converter
**Create `src/converters/claude-to-gemini.ts`**
Core functions:
1. **`convertClaudeToGemini(plugin, options)`** -- main entry point
- Convert each agent to a skill via `convertAgentToSkill()`
- Convert each command via `convertCommand()`
- Pass skills through as directory references
- Convert MCP servers to settings-compatible object
- Emit `console.warn` if `plugin.hooks` has entries
2. **`convertAgentToSkill(agent)`** -- agent -> SKILL.md
- Frontmatter: `name` (from agent name), `description` (from agent description, max ~300 chars)
- Body: agent body with content transformations applied
- Prepend capabilities section if present
- Silently drop `model` field (no Gemini equivalent)
- If description is empty, generate from agent name: `"Use this skill for ${agent.name} tasks"`
3. **`convertCommand(command, usedNames)`** -- command -> TOML file
- Preserve namespace structure: `workflows:plan` -> path `workflows/plan`
- `description` field from command description
- `prompt` field from command body with content transformations
- If command has `argument-hint`, append `\n\nUser request: {{args}}` to prompt
- Body: apply `transformContentForGemini()` transformations
- Silently drop `allowedTools` (no Gemini equivalent)
4. **`transformContentForGemini(body)`** -- content rewriting
- `.claude/` -> `.gemini/` and `~/.claude/` -> `~/.gemini/`
- `Task agent-name(args)` -> `Use the agent-name skill to: args`
- `@agent-name` references -> `the agent-name skill`
- Skip file paths (containing `/`) and common non-command patterns
5. **`convertMcpServers(servers)`** -- MCP config
- Map each `ClaudeMcpServer` entry to Gemini-compatible JSON
- Pass through: `command`, `args`, `env`, `url`, `headers`
- Drop `type` field (Gemini infers transport)
6. **`toToml(description, prompt)`** -- TOML serializer
- Escape TOML strings properly
- Use multi-line strings (`"""`) for prompt field
- Simple string for description
### Phase 3: Writer
**Create `src/targets/gemini.ts`**
Output structure:
```
.gemini/
├── commands/
│ ├── plan.toml
│ └── workflows/
│ └── plan.toml
├── skills/
│ ├── agent-name-1/
│ │ └── SKILL.md
│ ├── agent-name-2/
│ │ └── SKILL.md
│ └── original-skill/
│ └── SKILL.md
└── settings.json (only mcpServers key)
```
Core function: `writeGeminiBundle(outputRoot, bundle)`
- `resolveGeminiPaths(outputRoot)` -- detect if path already ends in `.gemini` to avoid double-nesting (follow droid writer pattern)
- Write generated skills to `skills/<name>/SKILL.md`
- Copy original skill directories to `skills/` via `copyDir()`
- Write commands to `commands/` as `.toml` files, creating subdirectories for namespaced commands
- Write `settings.json` with `{ "mcpServers": {...} }` via `writeJson()` with `backupFile()` for existing files
- If settings.json exists, read it first and merge `mcpServers` key (don't clobber other settings)
### Phase 4: Wire into CLI
**Modify `src/targets/index.ts`**
```typescript
import { convertClaudeToGemini } from "../converters/claude-to-gemini"
import { writeGeminiBundle } from "./gemini"
import type { GeminiBundle } from "../types/gemini"
// Add to targets:
gemini: {
name: "gemini",
implemented: true,
convert: convertClaudeToGemini as TargetHandler<GeminiBundle>["convert"],
write: writeGeminiBundle as TargetHandler<GeminiBundle>["write"],
},
```
**Modify `src/commands/convert.ts`**
- Update `--to` description: `"Target format (opencode | codex | droid | cursor | pi | gemini)"`
- Add to `resolveTargetOutputRoot`: `if (targetName === "gemini") return path.join(outputRoot, ".gemini")`
**Modify `src/commands/install.ts`**
- Same two changes as convert.ts
### Phase 5: Tests
**Create `tests/gemini-converter.test.ts`**
Test cases (use inline `ClaudePlugin` fixtures, following existing converter test patterns):
- Agent converts to skill with SKILL.md frontmatter (`name` and `description` populated)
- Agent with empty description gets default description text
- Agent with capabilities prepended to body
- Agent `model` field silently dropped
- Agent with empty body gets default body text
- Command converts to TOML with `prompt` and `description` fields
- Namespaced command creates correct path (`workflows:plan` -> `workflows/plan`)
- Command with `disable-model-invocation` is still included
- Command `allowedTools` silently dropped
- Command with `argument-hint` gets `{{args}}` placeholder in prompt
- Skills pass through as directory references
- MCP servers convert to settings.json-compatible config
- Content transformation: `.claude/` paths -> `.gemini/`
- Content transformation: `~/.claude/` paths -> `~/.gemini/`
- Content transformation: `Task agent(args)` -> natural language skill reference
- Hooks present -> `console.warn` emitted
- Plugin with zero agents produces empty generatedSkills array
- Plugin with only skills works correctly
- TOML output is valid (description and prompt properly escaped)
**Create `tests/gemini-writer.test.ts`**
Test cases (use temp directories, following existing writer test patterns):
- Full bundle writes skills, commands, settings.json
- Generated skills written as `skills/<name>/SKILL.md`
- Original skills copied to `skills/` directory
- Commands written as `.toml` files in `commands/` directory
- Namespaced commands create subdirectories (`commands/workflows/plan.toml`)
- MCP config written as valid JSON `settings.json` with `mcpServers` key
- Existing `settings.json` is backed up before overwrite
- Output root already ending in `.gemini` does NOT double-nest
- Empty bundle produces no output
### Phase 6: Documentation
**Create `docs/specs/gemini.md`**
Document the Gemini CLI spec as reference, following existing `docs/specs/codex.md` pattern:
- GEMINI.md context file format
- Custom commands format (TOML with `prompt`, `description`)
- Skills format (identical SKILL.md standard)
- MCP server configuration (`settings.json`)
- Extensions system (for reference, not converted)
- Hooks system (for reference, format differences noted)
- Config file locations (user-level `~/.gemini/` vs project-level `.gemini/`)
- Directory layout conventions
**Update `README.md`**
Add `gemini` to the supported targets in the CLI usage section.
## What We're NOT Doing
- Not converting hooks (Gemini has hooks but different format -- `BeforeTool`/`AfterTool` with matchers -- warn and skip)
- Not generating full `settings.json` (only `mcpServers` key -- user-specific settings like `model`, `tools.sandbox` are out of scope)
- Not creating extensions (extension format is for distributing packages, not for converted plugins)
- Not using `@{file}` or `!{shell}` placeholders in converted commands (would require analyzing command intent)
- Not transforming content inside copied SKILL.md files (known limitation -- skills may reference `.claude/` paths internally)
- Not clearing old output before writing (matches existing target behavior)
- Not merging into existing settings.json intelligently beyond `mcpServers` key (too risky to modify user config)
## Complexity Assessment
This is a **medium change**. The converter architecture is well-established with five existing targets, so this is mostly pattern-following. The key novelties are:
1. The TOML command format (unique among all targets -- need simple TOML serializer)
2. Agents map to skills rather than a direct 1:1 concept (but this is the same pattern as codex)
3. Namespaced commands use directory structure (new approach vs flattening in cursor/codex)
4. MCP config goes into a broader `settings.json` file (need to merge, not clobber)
Skills being identical across platforms simplifies things significantly. The TOML serialization is simple (only two fields: `description` string and `prompt` multi-line string).
## References
- [Gemini CLI Repository](https://github.com/google-gemini/gemini-cli)
- [Gemini CLI Configuration](https://geminicli.com/docs/get-started/configuration/)
- [Custom Commands (TOML)](https://geminicli.com/docs/cli/custom-commands/)
- [Agent Skills](https://geminicli.com/docs/cli/skills/)
- [Creating Skills](https://geminicli.com/docs/cli/creating-skills/)
- [Extensions](https://geminicli.com/docs/extensions/writing-extensions/)
- [MCP Servers](https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html)
- Existing cursor plan: `docs/plans/2026-02-12-feat-add-cursor-cli-target-provider-plan.md`
- Existing codex converter: `src/converters/claude-to-codex.ts` (has `uniqueName()` and skill generation patterns)
- Existing droid writer: `src/targets/droid.ts` (has double-nesting guard pattern)
- Target registry: `src/targets/index.ts`
## Completion Summary
### What Was Delivered
- [x] Phase 1: Types (`src/types/gemini.ts`)
- [x] Phase 2: Converter (`src/converters/claude-to-gemini.ts`)
- [x] Phase 3: Writer (`src/targets/gemini.ts`)
- [x] Phase 4: CLI wiring (`src/targets/index.ts`, `src/commands/convert.ts`, `src/commands/install.ts`)
- [x] Phase 5: Tests (`tests/gemini-converter.test.ts`, `tests/gemini-writer.test.ts`)
- [x] Phase 6: Documentation (`docs/specs/gemini.md`, `README.md`)
### Implementation Statistics
- 10 files changed
- 27 new tests added (129 total, all passing)
- 148 output files generated from compound-engineering plugin conversion
- 0 dependencies added
### Git Commits
- `201ad6d` feat(gemini): add Gemini CLI as sixth target provider
- `8351851` docs: add Gemini CLI spec and update README with gemini target
### Completion Details
- **Completed By:** Claude Opus 4.6
- **Date:** 2026-02-14
- **Session:** Single session

122
docs/specs/gemini.md Normal file
View File

@@ -0,0 +1,122 @@
# Gemini CLI Spec (GEMINI.md, Commands, Skills, MCP, Settings)
Last verified: 2026-02-14
## Primary sources
```
https://github.com/google-gemini/gemini-cli
https://geminicli.com/docs/get-started/configuration/
https://geminicli.com/docs/cli/custom-commands/
https://geminicli.com/docs/cli/skills/
https://geminicli.com/docs/cli/creating-skills/
https://geminicli.com/docs/extensions/writing-extensions/
https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html
```
## Config locations
- User-level config: `~/.gemini/settings.json`
- Project-level config: `.gemini/settings.json`
- Project-level takes precedence over user-level for most settings.
- GEMINI.md context file lives at project root (similar to CLAUDE.md).
## GEMINI.md context file
- A markdown file at project root loaded into every session's context.
- Used for project-wide instructions, coding standards, and conventions.
- Equivalent to Claude Code's CLAUDE.md.
## Custom commands (TOML format)
- Custom commands are TOML files stored in `.gemini/commands/`.
- Command name is derived from the file path: `.gemini/commands/git/commit.toml` becomes `/git:commit`.
- Directory-based namespacing: subdirectories create namespaced commands.
- Each command file has two fields:
- `description` (string): One-line description shown in `/help`
- `prompt` (string): The prompt sent to the model
- Supports placeholders:
- `{{args}}` — user-provided arguments
- `!{shell}` — output of a shell command
- `@{file}` — contents of a file
- Example:
```toml
description = "Create a git commit with a good message"
prompt = """
Look at the current git diff and create a commit with a descriptive message.
User request: {{args}}
"""
```
## Skills (SKILL.md standard)
- A skill is a folder containing `SKILL.md` plus optional supporting files.
- Skills live in `.gemini/skills/`.
- `SKILL.md` uses YAML frontmatter with `name` and `description` fields.
- Gemini activates skills on demand via `activate_skill` tool based on description matching.
- The `description` field is critical — Gemini uses it to decide when to activate the skill.
- Format is identical to Claude Code's SKILL.md standard.
- Example:
```yaml
---
name: security-reviewer
description: Review code for security vulnerabilities and OWASP compliance
---
# Security Reviewer
Detailed instructions for security review...
```
## MCP server configuration
- MCP servers are configured in `settings.json` under the `mcpServers` key.
- Same MCP protocol as Claude Code; different config location.
- Supports `command`, `args`, `env` for stdio transport.
- Supports `url`, `headers` for HTTP/SSE transport.
- Additional Gemini-specific fields: `cwd`, `timeout`, `trust`, `includeTools`, `excludeTools`.
- Example:
```json
{
"mcpServers": {
"context7": {
"url": "https://mcp.context7.com/mcp"
},
"playwright": {
"command": "npx",
"args": ["-y", "@anthropic/mcp-playwright"]
}
}
}
```
## Hooks
- Gemini supports hooks: `BeforeTool`, `AfterTool`, `SessionStart`, etc.
- Hooks use a different format from Claude Code hooks (matchers-based).
- Not converted by the plugin converter — a warning is emitted.
## Extensions
- Extensions are distributable packages for Gemini CLI.
- They extend functionality with custom tools, hooks, and commands.
- Not used for plugin conversion (different purpose from Claude Code plugins).
## Settings.json structure
```json
{
"model": "gemini-2.5-pro",
"mcpServers": { ... },
"tools": {
"sandbox": true
}
}
```
- Only the `mcpServers` key is written during plugin conversion.
- Other settings (model, tools, sandbox) are user-specific and out of scope.

View File

@@ -1,6 +1,6 @@
{
"name": "@every-env/compound-plugin",
"version": "0.6.0",
"version": "0.7.0",
"type": "module",
"private": false,
"bin": {

View File

@@ -1,6 +1,6 @@
{
"name": "compound-engineering",
"version": "2.33.0",
"version": "2.34.0",
"description": "AI-powered development tools. 29 agents, 22 commands, 19 skills, 1 MCP server for code review, research, design, and workflow automation.",
"author": {
"name": "Kieran Klaassen",

View File

@@ -5,6 +5,23 @@ All notable changes to the compound-engineering plugin will be documented in thi
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.34.0] - 2026-02-14
### Added
- **Gemini CLI target** — New converter target for [Gemini CLI](https://github.com/google-gemini/gemini-cli). Install with `--to gemini` to convert agents to `.gemini/skills/*/SKILL.md`, commands to `.gemini/commands/*.toml` (TOML format with `description` + `prompt`), and MCP servers to `.gemini/settings.json`. Skills pass through unchanged (identical SKILL.md standard). Namespaced commands create directory structure (`workflows:plan``commands/workflows/plan.toml`). 29 new tests. ([#190](https://github.com/EveryInc/compound-engineering-plugin/pull/190))
---
## [2.33.1] - 2026-02-13
### Changed
- **`/workflows:plan` command** - All plan templates now include `status: active` in YAML frontmatter. Plans are created with `status: active` and marked `status: completed` when work finishes.
- **`/workflows:work` command** - Phase 4 now updates plan frontmatter from `status: active` to `status: completed` after shipping. Agents can grep for status to distinguish current vs historical plans.
---
## [2.33.0] - 2026-02-12
### Added

View File

@@ -178,6 +178,7 @@ Select how comprehensive you want the issue to be, simpler is mostly better.
---
title: [Issue Title]
type: [feat|fix|refactor]
status: active
date: YYYY-MM-DD
---
@@ -230,6 +231,7 @@ end
---
title: [Issue Title]
type: [feat|fix|refactor]
status: active
date: YYYY-MM-DD
---
@@ -294,6 +296,7 @@ date: YYYY-MM-DD
---
title: [Issue Title]
type: [feat|fix|refactor]
status: active
date: YYYY-MM-DD
---

View File

@@ -297,7 +297,14 @@ This command takes a work document (plan, specification, or todo file) and execu
)"
```
4. **Notify User**
4. **Update Plan Status**
If the input document has YAML frontmatter with a `status` field, update it to `completed`:
```
status: active → status: completed
```
5. **Notify User**
- Summarize what was completed
- Link to PR
- Note any follow-up work needed

View File

@@ -23,7 +23,7 @@ export default defineCommand({
to: {
type: "string",
default: "opencode",
description: "Target format (opencode | codex | droid | cursor | pi)",
description: "Target format (opencode | codex | droid | cursor | pi | gemini)",
},
output: {
type: "string",
@@ -145,5 +145,6 @@ function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHo
if (targetName === "pi") return piHome
if (targetName === "droid") return path.join(os.homedir(), ".factory")
if (targetName === "cursor") return path.join(outputRoot, ".cursor")
if (targetName === "gemini") return path.join(outputRoot, ".gemini")
return outputRoot
}

View File

@@ -25,7 +25,7 @@ export default defineCommand({
to: {
type: "string",
default: "opencode",
description: "Target format (opencode | codex | droid | cursor | pi)",
description: "Target format (opencode | codex | droid | cursor | pi | gemini)",
},
output: {
type: "string",
@@ -183,6 +183,10 @@ function resolveTargetOutputRoot(
const base = hasExplicitOutput ? outputRoot : process.cwd()
return path.join(base, ".cursor")
}
if (targetName === "gemini") {
const base = hasExplicitOutput ? outputRoot : process.cwd()
return path.join(base, ".gemini")
}
return outputRoot
}

View File

@@ -0,0 +1,193 @@
import { formatFrontmatter } from "../utils/frontmatter"
import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
import type { GeminiBundle, GeminiCommand, GeminiMcpServer, GeminiSkill } from "../types/gemini"
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
export type ClaudeToGeminiOptions = ClaudeToOpenCodeOptions
const GEMINI_DESCRIPTION_MAX_LENGTH = 1024
export function convertClaudeToGemini(
plugin: ClaudePlugin,
_options: ClaudeToGeminiOptions,
): GeminiBundle {
const usedSkillNames = new Set<string>()
const usedCommandNames = new Set<string>()
const skillDirs = plugin.skills.map((skill) => ({
name: skill.name,
sourceDir: skill.sourceDir,
}))
// Reserve skill names from pass-through skills
for (const skill of skillDirs) {
usedSkillNames.add(normalizeName(skill.name))
}
const generatedSkills = plugin.agents.map((agent) => convertAgentToSkill(agent, usedSkillNames))
const commands = plugin.commands.map((command) => convertCommand(command, usedCommandNames))
const mcpServers = convertMcpServers(plugin.mcpServers)
if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) {
console.warn("Warning: Gemini CLI hooks use a different format (BeforeTool/AfterTool with matchers). Hooks were skipped during conversion.")
}
return { generatedSkills, skillDirs, commands, mcpServers }
}
function convertAgentToSkill(agent: ClaudeAgent, usedNames: Set<string>): GeminiSkill {
const name = uniqueName(normalizeName(agent.name), usedNames)
const description = sanitizeDescription(
agent.description ?? `Use this skill for ${agent.name} tasks`,
)
const frontmatter: Record<string, unknown> = { name, description }
let body = transformContentForGemini(agent.body.trim())
if (agent.capabilities && agent.capabilities.length > 0) {
const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n")
body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
}
if (body.length === 0) {
body = `Instructions converted from the ${agent.name} agent.`
}
const content = formatFrontmatter(frontmatter, body)
return { name, content }
}
function convertCommand(command: ClaudeCommand, usedNames: Set<string>): GeminiCommand {
// Preserve namespace structure: workflows:plan -> workflows/plan
const commandPath = resolveCommandPath(command.name)
const pathKey = commandPath.join("/")
uniqueName(pathKey, usedNames) // Track for dedup
const description = command.description ?? `Converted from Claude command ${command.name}`
const transformedBody = transformContentForGemini(command.body.trim())
let prompt = transformedBody
if (command.argumentHint) {
prompt += `\n\nUser request: {{args}}`
}
const content = toToml(description, prompt)
return { name: pathKey, content }
}
/**
* Transform Claude Code content to Gemini-compatible content.
*
* 1. Task agent calls: Task agent-name(args) -> Use the agent-name skill to: args
* 2. Path rewriting: .claude/ -> .gemini/, ~/.claude/ -> ~/.gemini/
* 3. Agent references: @agent-name -> the agent-name skill
*/
export function transformContentForGemini(body: string): string {
let result = body
// 1. Transform Task agent calls
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
const skillName = normalizeName(agentName)
return `${prefix}Use the ${skillName} skill to: ${args.trim()}`
})
// 2. Rewrite .claude/ paths to .gemini/
result = result
.replace(/~\/\.claude\//g, "~/.gemini/")
.replace(/\.claude\//g, ".gemini/")
// 3. Transform @agent-name references
const agentRefPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi
result = result.replace(agentRefPattern, (_match, agentName: string) => {
return `the ${normalizeName(agentName)} skill`
})
return result
}
function convertMcpServers(
servers?: Record<string, ClaudeMcpServer>,
): Record<string, GeminiMcpServer> | undefined {
if (!servers || Object.keys(servers).length === 0) return undefined
const result: Record<string, GeminiMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
const entry: GeminiMcpServer = {}
if (server.command) {
entry.command = server.command
if (server.args && server.args.length > 0) entry.args = server.args
if (server.env && Object.keys(server.env).length > 0) entry.env = server.env
} else if (server.url) {
entry.url = server.url
if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers
}
result[name] = entry
}
return result
}
/**
* Resolve command name to path segments.
* workflows:plan -> ["workflows", "plan"]
* plan -> ["plan"]
*/
function resolveCommandPath(name: string): string[] {
return name.split(":").map((segment) => normalizeName(segment))
}
/**
* Serialize to TOML command format.
* Uses multi-line strings (""") for prompt field.
*/
export function toToml(description: string, prompt: string): string {
const lines: string[] = []
lines.push(`description = ${formatTomlString(description)}`)
// Use multi-line string for prompt
const escapedPrompt = prompt.replace(/\\/g, "\\\\").replace(/"""/g, '\\"\\"\\"')
lines.push(`prompt = """`)
lines.push(escapedPrompt)
lines.push(`"""`)
return lines.join("\n")
}
function formatTomlString(value: string): string {
return JSON.stringify(value)
}
function normalizeName(value: string): string {
const trimmed = value.trim()
if (!trimmed) return "item"
const normalized = trimmed
.toLowerCase()
.replace(/[\\/]+/g, "-")
.replace(/[:\s]+/g, "-")
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
return normalized || "item"
}
function sanitizeDescription(value: string, maxLength = GEMINI_DESCRIPTION_MAX_LENGTH): string {
const normalized = value.replace(/\s+/g, " ").trim()
if (normalized.length <= maxLength) return normalized
const ellipsis = "..."
return normalized.slice(0, Math.max(0, maxLength - ellipsis.length)).trimEnd() + ellipsis
}
function uniqueName(base: string, used: Set<string>): string {
if (!used.has(base)) {
used.add(base)
return base
}
let index = 2
while (used.has(`${base}-${index}`)) {
index += 1
}
const name = `${base}-${index}`
used.add(name)
return name
}

68
src/targets/gemini.ts Normal file
View File

@@ -0,0 +1,68 @@
import path from "path"
import { backupFile, copyDir, ensureDir, pathExists, readJson, writeJson, writeText } from "../utils/files"
import type { GeminiBundle } from "../types/gemini"
export async function writeGeminiBundle(outputRoot: string, bundle: GeminiBundle): Promise<void> {
const paths = resolveGeminiPaths(outputRoot)
await ensureDir(paths.geminiDir)
if (bundle.generatedSkills.length > 0) {
for (const skill of bundle.generatedSkills) {
await writeText(path.join(paths.skillsDir, skill.name, "SKILL.md"), skill.content + "\n")
}
}
if (bundle.skillDirs.length > 0) {
for (const skill of bundle.skillDirs) {
await copyDir(skill.sourceDir, path.join(paths.skillsDir, skill.name))
}
}
if (bundle.commands.length > 0) {
for (const command of bundle.commands) {
await writeText(path.join(paths.commandsDir, `${command.name}.toml`), command.content + "\n")
}
}
if (bundle.mcpServers && Object.keys(bundle.mcpServers).length > 0) {
const settingsPath = path.join(paths.geminiDir, "settings.json")
const backupPath = await backupFile(settingsPath)
if (backupPath) {
console.log(`Backed up existing settings.json to ${backupPath}`)
}
// Merge mcpServers into existing settings if present
let existingSettings: Record<string, unknown> = {}
if (await pathExists(settingsPath)) {
try {
existingSettings = await readJson<Record<string, unknown>>(settingsPath)
} catch {
console.warn("Warning: existing settings.json could not be parsed and will be replaced.")
}
}
const existingMcp = (existingSettings.mcpServers && typeof existingSettings.mcpServers === "object")
? existingSettings.mcpServers as Record<string, unknown>
: {}
const merged = { ...existingSettings, mcpServers: { ...existingMcp, ...bundle.mcpServers } }
await writeJson(settingsPath, merged)
}
}
function resolveGeminiPaths(outputRoot: string) {
const base = path.basename(outputRoot)
// If already pointing at .gemini, write directly into it
if (base === ".gemini") {
return {
geminiDir: outputRoot,
skillsDir: path.join(outputRoot, "skills"),
commandsDir: path.join(outputRoot, "commands"),
}
}
// Otherwise nest under .gemini
return {
geminiDir: path.join(outputRoot, ".gemini"),
skillsDir: path.join(outputRoot, ".gemini", "skills"),
commandsDir: path.join(outputRoot, ".gemini", "commands"),
}
}

View File

@@ -4,16 +4,19 @@ import type { CodexBundle } from "../types/codex"
import type { DroidBundle } from "../types/droid"
import type { CursorBundle } from "../types/cursor"
import type { PiBundle } from "../types/pi"
import type { GeminiBundle } from "../types/gemini"
import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode"
import { convertClaudeToCodex } from "../converters/claude-to-codex"
import { convertClaudeToDroid } from "../converters/claude-to-droid"
import { convertClaudeToCursor } from "../converters/claude-to-cursor"
import { convertClaudeToPi } from "../converters/claude-to-pi"
import { convertClaudeToGemini } from "../converters/claude-to-gemini"
import { writeOpenCodeBundle } from "./opencode"
import { writeCodexBundle } from "./codex"
import { writeDroidBundle } from "./droid"
import { writeCursorBundle } from "./cursor"
import { writePiBundle } from "./pi"
import { writeGeminiBundle } from "./gemini"
export type TargetHandler<TBundle = unknown> = {
name: string
@@ -53,4 +56,10 @@ export const targets: Record<string, TargetHandler> = {
convert: convertClaudeToPi as TargetHandler<PiBundle>["convert"],
write: writePiBundle as TargetHandler<PiBundle>["write"],
},
gemini: {
name: "gemini",
implemented: true,
convert: convertClaudeToGemini as TargetHandler<GeminiBundle>["convert"],
write: writeGeminiBundle as TargetHandler<GeminiBundle>["write"],
},
}

29
src/types/gemini.ts Normal file
View File

@@ -0,0 +1,29 @@
export type GeminiSkill = {
name: string
content: string // Full SKILL.md with YAML frontmatter
}
export type GeminiSkillDir = {
name: string
sourceDir: string
}
export type GeminiCommand = {
name: string // e.g. "plan" or "workflows/plan"
content: string // Full TOML content
}
export type GeminiMcpServer = {
command?: string
args?: string[]
env?: Record<string, string>
url?: string
headers?: Record<string, string>
}
export type GeminiBundle = {
generatedSkills: GeminiSkill[] // From agents
skillDirs: GeminiSkillDir[] // From skills (pass-through)
commands: GeminiCommand[]
mcpServers?: Record<string, GeminiMcpServer>
}

View File

@@ -0,0 +1,373 @@
import { describe, expect, test } from "bun:test"
import { convertClaudeToGemini, toToml, transformContentForGemini } from "../src/converters/claude-to-gemini"
import { parseFrontmatter } from "../src/utils/frontmatter"
import type { ClaudePlugin } from "../src/types/claude"
const fixturePlugin: ClaudePlugin = {
root: "/tmp/plugin",
manifest: { name: "fixture", version: "1.0.0" },
agents: [
{
name: "Security Reviewer",
description: "Security-focused agent",
capabilities: ["Threat modeling", "OWASP"],
model: "claude-sonnet-4-20250514",
body: "Focus on vulnerabilities.",
sourcePath: "/tmp/plugin/agents/security-reviewer.md",
},
],
commands: [
{
name: "workflows:plan",
description: "Planning command",
argumentHint: "[FOCUS]",
model: "inherit",
allowedTools: ["Read"],
body: "Plan the work.",
sourcePath: "/tmp/plugin/commands/workflows/plan.md",
},
],
skills: [
{
name: "existing-skill",
description: "Existing skill",
sourceDir: "/tmp/plugin/skills/existing-skill",
skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md",
},
],
hooks: undefined,
mcpServers: {
local: { command: "echo", args: ["hello"] },
},
}
describe("convertClaudeToGemini", () => {
test("converts agents to skills with SKILL.md frontmatter", () => {
const bundle = convertClaudeToGemini(fixturePlugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
const skill = bundle.generatedSkills.find((s) => s.name === "security-reviewer")
expect(skill).toBeDefined()
const parsed = parseFrontmatter(skill!.content)
expect(parsed.data.name).toBe("security-reviewer")
expect(parsed.data.description).toBe("Security-focused agent")
expect(parsed.body).toContain("Focus on vulnerabilities.")
})
test("agent with capabilities prepended to body", () => {
const bundle = convertClaudeToGemini(fixturePlugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
const skill = bundle.generatedSkills.find((s) => s.name === "security-reviewer")
expect(skill).toBeDefined()
const parsed = parseFrontmatter(skill!.content)
expect(parsed.body).toContain("## Capabilities")
expect(parsed.body).toContain("- Threat modeling")
expect(parsed.body).toContain("- OWASP")
})
test("agent with empty description gets default description", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{
name: "my-agent",
body: "Do things.",
sourcePath: "/tmp/plugin/agents/my-agent.md",
},
],
commands: [],
skills: [],
}
const bundle = convertClaudeToGemini(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
const parsed = parseFrontmatter(bundle.generatedSkills[0].content)
expect(parsed.data.description).toBe("Use this skill for my-agent tasks")
})
test("agent model field silently dropped", () => {
const bundle = convertClaudeToGemini(fixturePlugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
const skill = bundle.generatedSkills.find((s) => s.name === "security-reviewer")
const parsed = parseFrontmatter(skill!.content)
expect(parsed.data.model).toBeUndefined()
})
test("agent with empty body gets default body text", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{
name: "Empty Agent",
description: "An empty agent",
body: "",
sourcePath: "/tmp/plugin/agents/empty.md",
},
],
commands: [],
skills: [],
}
const bundle = convertClaudeToGemini(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
const parsed = parseFrontmatter(bundle.generatedSkills[0].content)
expect(parsed.body).toContain("Instructions converted from the Empty Agent agent.")
})
test("converts commands to TOML with prompt and description", () => {
const bundle = convertClaudeToGemini(fixturePlugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
expect(bundle.commands).toHaveLength(1)
const command = bundle.commands[0]
expect(command.name).toBe("workflows/plan")
expect(command.content).toContain('description = "Planning command"')
expect(command.content).toContain('prompt = """')
expect(command.content).toContain("Plan the work.")
})
test("namespaced command creates correct path", () => {
const bundle = convertClaudeToGemini(fixturePlugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
const command = bundle.commands.find((c) => c.name === "workflows/plan")
expect(command).toBeDefined()
})
test("command with argument-hint gets {{args}} placeholder", () => {
const bundle = convertClaudeToGemini(fixturePlugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
const command = bundle.commands[0]
expect(command.content).toContain("{{args}}")
})
test("command with disable-model-invocation is still included", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
commands: [
{
name: "disabled-command",
description: "Disabled command",
disableModelInvocation: true,
body: "Disabled body.",
sourcePath: "/tmp/plugin/commands/disabled.md",
},
],
agents: [],
skills: [],
}
const bundle = convertClaudeToGemini(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
// Gemini TOML commands are prompts, not code — always include
expect(bundle.commands).toHaveLength(1)
expect(bundle.commands[0].name).toBe("disabled-command")
})
test("command allowedTools silently dropped", () => {
const bundle = convertClaudeToGemini(fixturePlugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
const command = bundle.commands[0]
expect(command.content).not.toContain("allowedTools")
expect(command.content).not.toContain("Read")
})
test("skills pass through as directory references", () => {
const bundle = convertClaudeToGemini(fixturePlugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
expect(bundle.skillDirs).toHaveLength(1)
expect(bundle.skillDirs[0].name).toBe("existing-skill")
expect(bundle.skillDirs[0].sourceDir).toBe("/tmp/plugin/skills/existing-skill")
})
test("MCP servers convert to settings.json-compatible config", () => {
const bundle = convertClaudeToGemini(fixturePlugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
expect(bundle.mcpServers?.local?.command).toBe("echo")
expect(bundle.mcpServers?.local?.args).toEqual(["hello"])
})
test("plugin with zero agents produces empty generatedSkills", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [],
commands: [],
skills: [],
}
const bundle = convertClaudeToGemini(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
expect(bundle.generatedSkills).toHaveLength(0)
})
test("plugin with only skills works correctly", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [],
commands: [],
}
const bundle = convertClaudeToGemini(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
expect(bundle.generatedSkills).toHaveLength(0)
expect(bundle.skillDirs).toHaveLength(1)
expect(bundle.commands).toHaveLength(0)
})
test("agent name colliding with skill name gets deduplicated", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
skills: [{ name: "security-reviewer", description: "Existing skill", sourceDir: "/tmp/skill", skillPath: "/tmp/skill/SKILL.md" }],
agents: [{ name: "Security Reviewer", description: "Agent version", body: "Body.", sourcePath: "/tmp/agents/sr.md" }],
commands: [],
}
const bundle = convertClaudeToGemini(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
// Agent should be deduplicated since skill already has "security-reviewer"
expect(bundle.generatedSkills[0].name).toBe("security-reviewer-2")
expect(bundle.skillDirs[0].name).toBe("security-reviewer")
})
test("hooks present emits console.warn", () => {
const warnings: string[] = []
const originalWarn = console.warn
console.warn = (msg: string) => warnings.push(msg)
const plugin: ClaudePlugin = {
...fixturePlugin,
hooks: { hooks: { PreToolUse: [{ matcher: "*", body: "hook body" }] } },
agents: [],
commands: [],
skills: [],
}
convertClaudeToGemini(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
console.warn = originalWarn
expect(warnings.some((w) => w.includes("Gemini"))).toBe(true)
})
})
describe("transformContentForGemini", () => {
test("transforms .claude/ paths to .gemini/", () => {
const result = transformContentForGemini("Read .claude/settings.json for config.")
expect(result).toContain(".gemini/settings.json")
expect(result).not.toContain(".claude/")
})
test("transforms ~/.claude/ paths to ~/.gemini/", () => {
const result = transformContentForGemini("Check ~/.claude/config for settings.")
expect(result).toContain("~/.gemini/config")
expect(result).not.toContain("~/.claude/")
})
test("transforms Task agent(args) to natural language skill reference", () => {
const input = `Run these:
- Task repo-research-analyst(feature_description)
- Task learnings-researcher(feature_description)
Task best-practices-researcher(topic)`
const result = transformContentForGemini(input)
expect(result).toContain("Use the repo-research-analyst skill to: feature_description")
expect(result).toContain("Use the learnings-researcher skill to: feature_description")
expect(result).toContain("Use the best-practices-researcher skill to: topic")
expect(result).not.toContain("Task repo-research-analyst")
})
test("transforms @agent references to skill references", () => {
const result = transformContentForGemini("Ask @security-sentinel for a review.")
expect(result).toContain("the security-sentinel skill")
expect(result).not.toContain("@security-sentinel")
})
})
describe("toToml", () => {
test("produces valid TOML with description and prompt", () => {
const result = toToml("A description", "The prompt content")
expect(result).toContain('description = "A description"')
expect(result).toContain('prompt = """')
expect(result).toContain("The prompt content")
expect(result).toContain('"""')
})
test("escapes quotes in description", () => {
const result = toToml('Say "hello"', "Prompt")
expect(result).toContain('description = "Say \\"hello\\""')
})
test("escapes triple quotes in prompt", () => {
const result = toToml("A command", 'Content with """ inside it')
// Should not contain an unescaped """ that would close the TOML multi-line string prematurely
// The prompt section should have the escaped version
expect(result).toContain('description = "A command"')
expect(result).toContain('prompt = """')
// The inner """ should be escaped
expect(result).not.toMatch(/""".*""".*"""/s) // Should not have 3 separate triple-quote sequences (open, content, close would make 3)
// Verify it contains the escaped form
expect(result).toContain('\\"\\"\\"')
})
})

181
tests/gemini-writer.test.ts Normal file
View File

@@ -0,0 +1,181 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import path from "path"
import os from "os"
import { writeGeminiBundle } from "../src/targets/gemini"
import type { GeminiBundle } from "../src/types/gemini"
async function exists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
describe("writeGeminiBundle", () => {
test("writes skills, commands, and settings.json", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-test-"))
const bundle: GeminiBundle = {
generatedSkills: [
{
name: "security-reviewer",
content: "---\nname: security-reviewer\ndescription: Security\n---\n\nReview code.",
},
],
skillDirs: [
{
name: "skill-one",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
commands: [
{
name: "plan",
content: 'description = "Plan"\nprompt = """\nPlan the work.\n"""',
},
],
mcpServers: {
playwright: { command: "npx", args: ["-y", "@anthropic/mcp-playwright"] },
},
}
await writeGeminiBundle(tempRoot, bundle)
expect(await exists(path.join(tempRoot, ".gemini", "skills", "security-reviewer", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".gemini", "skills", "skill-one", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".gemini", "commands", "plan.toml"))).toBe(true)
expect(await exists(path.join(tempRoot, ".gemini", "settings.json"))).toBe(true)
const skillContent = await fs.readFile(
path.join(tempRoot, ".gemini", "skills", "security-reviewer", "SKILL.md"),
"utf8",
)
expect(skillContent).toContain("Review code.")
const commandContent = await fs.readFile(
path.join(tempRoot, ".gemini", "commands", "plan.toml"),
"utf8",
)
expect(commandContent).toContain("Plan the work.")
const settingsContent = JSON.parse(
await fs.readFile(path.join(tempRoot, ".gemini", "settings.json"), "utf8"),
)
expect(settingsContent.mcpServers.playwright.command).toBe("npx")
})
test("namespaced commands create subdirectories", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-ns-"))
const bundle: GeminiBundle = {
generatedSkills: [],
skillDirs: [],
commands: [
{
name: "workflows/plan",
content: 'description = "Plan"\nprompt = """\nPlan.\n"""',
},
],
}
await writeGeminiBundle(tempRoot, bundle)
expect(await exists(path.join(tempRoot, ".gemini", "commands", "workflows", "plan.toml"))).toBe(true)
})
test("does not double-nest when output root is .gemini", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-home-"))
const geminiRoot = path.join(tempRoot, ".gemini")
const bundle: GeminiBundle = {
generatedSkills: [
{ name: "reviewer", content: "Reviewer skill content" },
],
skillDirs: [],
commands: [
{ name: "plan", content: "Plan content" },
],
}
await writeGeminiBundle(geminiRoot, bundle)
expect(await exists(path.join(geminiRoot, "skills", "reviewer", "SKILL.md"))).toBe(true)
expect(await exists(path.join(geminiRoot, "commands", "plan.toml"))).toBe(true)
// Should NOT double-nest under .gemini/.gemini
expect(await exists(path.join(geminiRoot, ".gemini"))).toBe(false)
})
test("handles empty bundles gracefully", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-empty-"))
const bundle: GeminiBundle = {
generatedSkills: [],
skillDirs: [],
commands: [],
}
await writeGeminiBundle(tempRoot, bundle)
expect(await exists(tempRoot)).toBe(true)
})
test("backs up existing settings.json before overwrite", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-backup-"))
const geminiRoot = path.join(tempRoot, ".gemini")
await fs.mkdir(geminiRoot, { recursive: true })
// Write existing settings.json
const settingsPath = path.join(geminiRoot, "settings.json")
await fs.writeFile(settingsPath, JSON.stringify({ mcpServers: { old: { command: "old-cmd" } } }))
const bundle: GeminiBundle = {
generatedSkills: [],
skillDirs: [],
commands: [],
mcpServers: {
newServer: { command: "new-cmd" },
},
}
await writeGeminiBundle(geminiRoot, bundle)
// New settings.json should have the new content
const newContent = JSON.parse(await fs.readFile(settingsPath, "utf8"))
expect(newContent.mcpServers.newServer.command).toBe("new-cmd")
// A backup file should exist
const files = await fs.readdir(geminiRoot)
const backupFiles = files.filter((f) => f.startsWith("settings.json.bak."))
expect(backupFiles.length).toBeGreaterThanOrEqual(1)
})
test("merges mcpServers into existing settings.json without clobbering other keys", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-merge-"))
const geminiRoot = path.join(tempRoot, ".gemini")
await fs.mkdir(geminiRoot, { recursive: true })
// Write existing settings.json with other keys
const settingsPath = path.join(geminiRoot, "settings.json")
await fs.writeFile(settingsPath, JSON.stringify({
model: "gemini-2.5-pro",
mcpServers: { old: { command: "old-cmd" } },
}))
const bundle: GeminiBundle = {
generatedSkills: [],
skillDirs: [],
commands: [],
mcpServers: {
newServer: { command: "new-cmd" },
},
}
await writeGeminiBundle(geminiRoot, bundle)
const content = JSON.parse(await fs.readFile(settingsPath, "utf8"))
// Should preserve existing model key
expect(content.model).toBe("gemini-2.5-pro")
// Should preserve existing MCP server
expect(content.mcpServers.old.command).toBe("old-cmd")
// Should add new MCP server
expect(content.mcpServers.newServer.command).toBe("new-cmd")
})
})