Merge branch 'main' into chore/remove-cursor-target-support
This commit is contained in:
@@ -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
1
.gitignore
vendored
@@ -2,3 +2,4 @@
|
||||
*.log
|
||||
node_modules/
|
||||
.codex/
|
||||
todos/
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
122
docs/specs/gemini.md
Normal 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.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@every-env/compound-plugin",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"private": false,
|
||||
"bin": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
193
src/converters/claude-to-gemini.ts
Normal file
193
src/converters/claude-to-gemini.ts
Normal 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
68
src/targets/gemini.ts
Normal 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"),
|
||||
}
|
||||
}
|
||||
@@ -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
29
src/types/gemini.ts
Normal 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>
|
||||
}
|
||||
373
tests/gemini-converter.test.ts
Normal file
373
tests/gemini-converter.test.ts
Normal 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
181
tests/gemini-writer.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user