diff --git a/.gitignore b/.gitignore index c9f2f33a..f8f7b971 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.log node_modules/ .codex/ +todos/ diff --git a/README.md b/README.md index 11bfe937..3d733df9 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: @@ -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. diff --git a/docs/plans/2026-02-14-feat-add-gemini-cli-target-provider-plan.md b/docs/plans/2026-02-14-feat-add-gemini-cli-target-provider-plan.md new file mode 100644 index 00000000..19a0a8ce --- /dev/null +++ b/docs/plans/2026-02-14-feat-add-gemini-cli-target-provider-plan.md @@ -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 + url?: string + headers?: Record + }> +} +``` + +### 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//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["convert"], + write: writeGeminiBundle as TargetHandler["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//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 diff --git a/docs/specs/gemini.md b/docs/specs/gemini.md new file mode 100644 index 00000000..36e8d24d --- /dev/null +++ b/docs/specs/gemini.md @@ -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. diff --git a/src/commands/convert.ts b/src/commands/convert.ts index 08e885ea..9f62511e 100644 --- a/src/commands/convert.ts +++ b/src/commands/convert.ts @@ -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 } diff --git a/src/commands/install.ts b/src/commands/install.ts index c9a86e5b..35506e8c 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -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 } diff --git a/src/converters/claude-to-gemini.ts b/src/converters/claude-to-gemini.ts new file mode 100644 index 00000000..7dc4389d --- /dev/null +++ b/src/converters/claude-to-gemini.ts @@ -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() + const usedCommandNames = new Set() + + 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): GeminiSkill { + const name = uniqueName(normalizeName(agent.name), usedNames) + const description = sanitizeDescription( + agent.description ?? `Use this skill for ${agent.name} tasks`, + ) + + const frontmatter: Record = { 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): 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, +): Record | undefined { + if (!servers || Object.keys(servers).length === 0) return undefined + + const result: Record = {} + 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 { + 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 +} diff --git a/src/targets/gemini.ts b/src/targets/gemini.ts new file mode 100644 index 00000000..0bc8c666 --- /dev/null +++ b/src/targets/gemini.ts @@ -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 { + 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 = {} + if (await pathExists(settingsPath)) { + try { + existingSettings = await readJson>(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 + : {} + 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"), + } +} diff --git a/src/targets/index.ts b/src/targets/index.ts index 3e60631e..b76dfc1d 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -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 = { name: string @@ -53,4 +56,10 @@ export const targets: Record = { convert: convertClaudeToPi as TargetHandler["convert"], write: writePiBundle as TargetHandler["write"], }, + gemini: { + name: "gemini", + implemented: true, + convert: convertClaudeToGemini as TargetHandler["convert"], + write: writeGeminiBundle as TargetHandler["write"], + }, } diff --git a/src/types/gemini.ts b/src/types/gemini.ts new file mode 100644 index 00000000..7e37e699 --- /dev/null +++ b/src/types/gemini.ts @@ -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 + url?: string + headers?: Record +} + +export type GeminiBundle = { + generatedSkills: GeminiSkill[] // From agents + skillDirs: GeminiSkillDir[] // From skills (pass-through) + commands: GeminiCommand[] + mcpServers?: Record +} diff --git a/tests/gemini-converter.test.ts b/tests/gemini-converter.test.ts new file mode 100644 index 00000000..bd9675af --- /dev/null +++ b/tests/gemini-converter.test.ts @@ -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('\\"\\"\\"') + }) +}) diff --git a/tests/gemini-writer.test.ts b/tests/gemini-writer.test.ts new file mode 100644 index 00000000..a6a9df3a --- /dev/null +++ b/tests/gemini-writer.test.ts @@ -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 { + 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") + }) +})