Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
*.log
node_modules/
.codex/
todos/
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ A Claude Code plugin marketplace featuring the **Compound Engineering Plugin**
/plugin install compound-engineering
```

## OpenCode, Codex, Droid, Cursor & Pi (experimental) Install
## OpenCode, Codex, Droid, Cursor, Pi & Gemini (experimental) Install

This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Cursor, and Pi.
This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Cursor, Pi, and Gemini CLI.

```bash
# convert the compound-engineering plugin into OpenCode format
Expand All @@ -31,6 +31,9 @@ bunx @every-env/compound-plugin install compound-engineering --to cursor

# 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:
Expand All @@ -44,6 +47,7 @@ Codex output is written to `~/.codex/prompts` and `~/.codex/skills`, with each C
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.
Cursor output is written to `.cursor/` with rules (`.mdc`), commands, skills, and `mcp.json`. Agents become "Agent Requested" rules (`alwaysApply: false`) so Cursor's AI activates them on demand. Works with both the Cursor IDE and Cursor CLI (`cursor-agent`) — they share the same `.cursor/` config directory.
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.

Expand Down
370 changes: 370 additions & 0 deletions docs/plans/2026-02-14-feat-add-gemini-cli-target-provider-plan.md

Large diffs are not rendered by default.

122 changes: 122 additions & 0 deletions docs/specs/gemini.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion src/commands/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
}
6 changes: 5 additions & 1 deletion src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
}

Expand Down
193 changes: 193 additions & 0 deletions src/converters/claude-to-gemini.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading