From 201ad6d0fba6d18dc8285439ddf0dc748cb4a20e Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sat, 14 Feb 2026 20:33:21 -0800 Subject: [PATCH 1/7] feat(gemini): add Gemini CLI as sixth target provider Add `--to gemini` support for both `convert` and `install` commands, converting Claude Code plugins into Gemini CLI-compatible format. - Agents convert to `.gemini/skills/*/SKILL.md` with description frontmatter - Commands convert to `.gemini/commands/*.toml` with TOML prompt format - Namespaced commands create directory structure (workflows:plan -> workflows/plan.toml) - Skills pass through unchanged (identical SKILL.md standard) - MCP servers written to `.gemini/settings.json` with merge support - Content transforms: .claude/ paths, Task calls, @agent references - Hooks emit warning (different format in Gemini) Co-Authored-By: Claude Opus 4.6 --- src/commands/convert.ts | 3 +- src/commands/install.ts | 6 +- src/converters/claude-to-gemini.ts | 193 ++++++++++++++++ src/targets/gemini.ts | 65 ++++++ src/targets/index.ts | 9 + src/types/gemini.ts | 27 +++ tests/gemini-converter.test.ts | 342 +++++++++++++++++++++++++++++ tests/gemini-writer.test.ts | 179 +++++++++++++++ 8 files changed, 822 insertions(+), 2 deletions(-) create mode 100644 src/converters/claude-to-gemini.ts create mode 100644 src/targets/gemini.ts create mode 100644 src/types/gemini.ts create mode 100644 tests/gemini-converter.test.ts create mode 100644 tests/gemini-writer.test.ts diff --git a/src/commands/convert.ts b/src/commands/convert.ts index 08e885e..9f62511 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 c9a86e5..35506e8 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 0000000..3f136a0 --- /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, 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, +): GeminiBundle["mcpServers"] | undefined { + if (!servers || Object.keys(servers).length === 0) return undefined + + const result: NonNullable = {} + for (const [name, server] of Object.entries(servers)) { + const entry: NonNullable[string] = {} + 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 0000000..0ed9ae9 --- /dev/null +++ b/src/targets/gemini.ts @@ -0,0 +1,65 @@ +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 { + // If existing file is invalid JSON, start fresh + } + } + + const merged = { ...existingSettings, mcpServers: 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 3e60631..b76dfc1 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 0000000..25172d3 --- /dev/null +++ b/src/types/gemini.ts @@ -0,0 +1,27 @@ +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 + }> +} diff --git a/tests/gemini-converter.test.ts b/tests/gemini-converter.test.ts new file mode 100644 index 0000000..9531faf --- /dev/null +++ b/tests/gemini-converter.test.ts @@ -0,0 +1,342 @@ +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("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\\""') + }) +}) diff --git a/tests/gemini-writer.test.ts b/tests/gemini-writer.test.ts new file mode 100644 index 0000000..8b02ab3 --- /dev/null +++ b/tests/gemini-writer.test.ts @@ -0,0 +1,179 @@ +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") + // mcpServers should be replaced (not merged) with new content + expect(content.mcpServers.newServer.command).toBe("new-cmd") + }) +}) From 8351851a13f477dc780bae02c325185b361c1218 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sat, 14 Feb 2026 20:34:31 -0800 Subject: [PATCH 2/7] docs: add Gemini CLI spec and update README with gemini target Co-Authored-By: Claude Opus 4.6 --- README.md | 8 ++- docs/specs/gemini.md | 122 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 docs/specs/gemini.md diff --git a/README.md b/README.md index 11bfe93..3d733df 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/specs/gemini.md b/docs/specs/gemini.md new file mode 100644 index 0000000..36e8d24 --- /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. From e113d20126189159463295807a13d3347d594bde Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sat, 14 Feb 2026 20:35:09 -0800 Subject: [PATCH 3/7] docs: mark gemini target plan as completed Co-Authored-By: Claude Opus 4.6 --- ...eat-add-gemini-cli-target-provider-plan.md | 370 ++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 docs/plans/2026-02-14-feat-add-gemini-cli-target-provider-plan.md 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 0000000..19a0a8c --- /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 From d487915f0fcb3d49edea99f170151294245063fd Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sat, 14 Feb 2026 20:46:31 -0800 Subject: [PATCH 4/7] fix: address code review findings for gemini target - Extract named GeminiMcpServer type (eliminates NonNullable indexing) - Deep-merge mcpServers in settings.json (preserves existing entries) - Warn when existing settings.json cannot be parsed - Add test for uniqueName dedup (agent/skill name collision) - Add test for TOML triple-quote escaping Co-Authored-By: Claude Opus 4.6 --- src/converters/claude-to-gemini.ts | 8 ++++---- src/targets/gemini.ts | 7 +++++-- src/types/gemini.ts | 16 ++++++++------- tests/gemini-converter.test.ts | 31 ++++++++++++++++++++++++++++++ tests/gemini-writer.test.ts | 4 +++- 5 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/converters/claude-to-gemini.ts b/src/converters/claude-to-gemini.ts index 3f136a0..7dc4389 100644 --- a/src/converters/claude-to-gemini.ts +++ b/src/converters/claude-to-gemini.ts @@ -1,6 +1,6 @@ import { formatFrontmatter } from "../utils/frontmatter" import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude" -import type { GeminiBundle, GeminiCommand, GeminiSkill } from "../types/gemini" +import type { GeminiBundle, GeminiCommand, GeminiMcpServer, GeminiSkill } from "../types/gemini" import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode" export type ClaudeToGeminiOptions = ClaudeToOpenCodeOptions @@ -109,12 +109,12 @@ export function transformContentForGemini(body: string): string { function convertMcpServers( servers?: Record, -): GeminiBundle["mcpServers"] | undefined { +): Record | undefined { if (!servers || Object.keys(servers).length === 0) return undefined - const result: NonNullable = {} + const result: Record = {} for (const [name, server] of Object.entries(servers)) { - const entry: NonNullable[string] = {} + const entry: GeminiMcpServer = {} if (server.command) { entry.command = server.command if (server.args && server.args.length > 0) entry.args = server.args diff --git a/src/targets/gemini.ts b/src/targets/gemini.ts index 0ed9ae9..0bc8c66 100644 --- a/src/targets/gemini.ts +++ b/src/targets/gemini.ts @@ -37,11 +37,14 @@ export async function writeGeminiBundle(outputRoot: string, bundle: GeminiBundle try { existingSettings = await readJson>(settingsPath) } catch { - // If existing file is invalid JSON, start fresh + console.warn("Warning: existing settings.json could not be parsed and will be replaced.") } } - const merged = { ...existingSettings, mcpServers: bundle.mcpServers } + const existingMcp = (existingSettings.mcpServers && typeof existingSettings.mcpServers === "object") + ? existingSettings.mcpServers as Record + : {} + const merged = { ...existingSettings, mcpServers: { ...existingMcp, ...bundle.mcpServers } } await writeJson(settingsPath, merged) } } diff --git a/src/types/gemini.ts b/src/types/gemini.ts index 25172d3..7e37e69 100644 --- a/src/types/gemini.ts +++ b/src/types/gemini.ts @@ -13,15 +13,17 @@ export type GeminiCommand = { 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 - url?: string - headers?: Record - }> + mcpServers?: Record } diff --git a/tests/gemini-converter.test.ts b/tests/gemini-converter.test.ts index 9531faf..bd9675a 100644 --- a/tests/gemini-converter.test.ts +++ b/tests/gemini-converter.test.ts @@ -267,6 +267,25 @@ describe("convertClaudeToGemini", () => { 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 @@ -339,4 +358,16 @@ describe("toToml", () => { 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 index 8b02ab3..a6a9df3 100644 --- a/tests/gemini-writer.test.ts +++ b/tests/gemini-writer.test.ts @@ -173,7 +173,9 @@ describe("writeGeminiBundle", () => { const content = JSON.parse(await fs.readFile(settingsPath, "utf8")) // Should preserve existing model key expect(content.model).toBe("gemini-2.5-pro") - // mcpServers should be replaced (not merged) with new content + // 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") }) }) From 552ebceb0b9ddb894a3e06334a1afa1684ddd689 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sat, 14 Feb 2026 20:46:53 -0800 Subject: [PATCH 5/7] chore: mark review todos as complete Co-Authored-By: Claude Opus 4.6 --- ...001-complete-p2-extract-gemini-mcp-type.md | 25 +++++++++++++++++++ .../002-complete-p2-test-uniquename-dedup.md | 24 ++++++++++++++++++ .../003-complete-p2-test-toml-triple-quote.md | 24 ++++++++++++++++++ .../004-complete-p2-deep-merge-mcp-servers.md | 25 +++++++++++++++++++ ...-complete-p2-warn-invalid-settings-json.md | 23 +++++++++++++++++ 5 files changed, 121 insertions(+) create mode 100644 todos/001-complete-p2-extract-gemini-mcp-type.md create mode 100644 todos/002-complete-p2-test-uniquename-dedup.md create mode 100644 todos/003-complete-p2-test-toml-triple-quote.md create mode 100644 todos/004-complete-p2-deep-merge-mcp-servers.md create mode 100644 todos/005-complete-p2-warn-invalid-settings-json.md diff --git a/todos/001-complete-p2-extract-gemini-mcp-type.md b/todos/001-complete-p2-extract-gemini-mcp-type.md new file mode 100644 index 0000000..de84fc7 --- /dev/null +++ b/todos/001-complete-p2-extract-gemini-mcp-type.md @@ -0,0 +1,25 @@ +--- +status: pending +priority: p2 +issue_id: "001" +tags: [code-review, typescript, types] +dependencies: [] +--- + +# Extract GeminiMcpServer as named type + +## Problem Statement +The `GeminiBundle` inlines the MCP server type definition, and the converter uses `NonNullable[string]` which is hard to read. Other targets (Cursor) define a named type. + +## Findings +- `src/types/gemini.ts` lines 20-26: inline type in GeminiBundle +- `src/converters/claude-to-gemini.ts` line 117: `NonNullable[string]` + +## Proposed Solution +Extract a named `GeminiMcpServer` type in `src/types/gemini.ts` and use it in both the bundle type and converter. + +## Acceptance Criteria +- [ ] `GeminiMcpServer` type exists in `src/types/gemini.ts` +- [ ] `GeminiBundle.mcpServers` uses `Record` +- [ ] Converter uses `GeminiMcpServer` instead of indexed access type +- [ ] Tests still pass diff --git a/todos/002-complete-p2-test-uniquename-dedup.md b/todos/002-complete-p2-test-uniquename-dedup.md new file mode 100644 index 0000000..9446179 --- /dev/null +++ b/todos/002-complete-p2-test-uniquename-dedup.md @@ -0,0 +1,24 @@ +--- +status: pending +priority: p2 +issue_id: "002" +tags: [code-review, testing] +dependencies: [] +--- + +# Add test for uniqueName dedup when agent collides with skill + +## Problem Statement +The `uniqueName` function handles name collisions by appending `-2`, but there is no test covering the scenario where an agent name collides with a pass-through skill name. + +## Findings +- `src/converters/claude-to-gemini.ts` lines 181-193: uniqueName function +- `tests/gemini-converter.test.ts`: no dedup test + +## Proposed Solution +Add a test where a plugin has both a skill named "security-reviewer" and an agent named "Security Reviewer". The generated skill should get name "security-reviewer-2". + +## Acceptance Criteria +- [ ] Test added for agent/skill name collision +- [ ] Test verifies the deduped name is `security-reviewer-2` +- [ ] All tests pass diff --git a/todos/003-complete-p2-test-toml-triple-quote.md b/todos/003-complete-p2-test-toml-triple-quote.md new file mode 100644 index 0000000..4ebb2f4 --- /dev/null +++ b/todos/003-complete-p2-test-toml-triple-quote.md @@ -0,0 +1,24 @@ +--- +status: pending +priority: p2 +issue_id: "003" +tags: [code-review, testing, security] +dependencies: [] +--- + +# Add test for TOML triple-quote escaping in prompt + +## Problem Statement +The `toToml` function escapes `"""` in prompts, but there is no test verifying this works correctly. This is a potential TOML injection vector. + +## Findings +- `src/converters/claude-to-gemini.ts` line 150: `prompt.replace(/"""/g, '\\"\\"\\"')` +- `tests/gemini-converter.test.ts`: no triple-quote test in `toToml` describe block + +## Proposed Solution +Add a test in the `toToml` describe block that passes a prompt containing `"""` and verifies the output escapes it correctly. + +## Acceptance Criteria +- [ ] Test added for prompt containing `"""` +- [ ] Escaped output does not prematurely close the TOML multi-line string +- [ ] All tests pass diff --git a/todos/004-complete-p2-deep-merge-mcp-servers.md b/todos/004-complete-p2-deep-merge-mcp-servers.md new file mode 100644 index 0000000..4be7602 --- /dev/null +++ b/todos/004-complete-p2-deep-merge-mcp-servers.md @@ -0,0 +1,25 @@ +--- +status: pending +priority: p2 +issue_id: "004" +tags: [code-review, security, data-loss] +dependencies: [] +--- + +# Deep-merge mcpServers in settings.json instead of replacing + +## Problem Statement +The Gemini writer replaces the entire `mcpServers` key in `settings.json`, silently destroying any existing user MCP servers. The test name says "merges" but it actually replaces. + +## Findings +- `src/targets/gemini.ts` line 44: `{ ...existingSettings, mcpServers: bundle.mcpServers }` +- `tests/gemini-writer.test.ts` line 150: test name says "merges" but asserts replacement + +## Proposed Solution +Deep-merge `mcpServers` entries: `{ ...existingMcp, ...bundle.mcpServers }`. Update the test to verify existing servers are preserved alongside new ones. + +## Acceptance Criteria +- [ ] Existing mcpServers entries are preserved when new ones are added +- [ ] New entries with same name override existing (not merged at field level) +- [ ] Test verifies both old and new servers exist after merge +- [ ] All tests pass diff --git a/todos/005-complete-p2-warn-invalid-settings-json.md b/todos/005-complete-p2-warn-invalid-settings-json.md new file mode 100644 index 0000000..2b2d3cd --- /dev/null +++ b/todos/005-complete-p2-warn-invalid-settings-json.md @@ -0,0 +1,23 @@ +--- +status: pending +priority: p2 +issue_id: "005" +tags: [code-review, error-handling] +dependencies: ["004"] +--- + +# Warn when existing settings.json is invalid JSON + +## Problem Statement +When an existing `settings.json` cannot be parsed, the error is silently swallowed and the file is overwritten. Users get no warning that their settings were discarded. + +## Findings +- `src/targets/gemini.ts` lines 37-41: empty catch block + +## Proposed Solution +Add a `console.warn` in the catch block to inform the user that their existing settings.json could not be parsed and will be replaced. + +## Acceptance Criteria +- [ ] `console.warn` emitted when settings.json parse fails +- [ ] File is still replaced (behavior unchanged) +- [ ] All tests pass From 3e1d62d4c465c4a4c8810881a350d1dd00b5991a Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sat, 14 Feb 2026 20:48:46 -0800 Subject: [PATCH 6/7] chore: remove todos from git tracking Co-Authored-By: Claude Opus 4.6 --- ...001-complete-p2-extract-gemini-mcp-type.md | 25 ------------------- .../002-complete-p2-test-uniquename-dedup.md | 24 ------------------ .../003-complete-p2-test-toml-triple-quote.md | 24 ------------------ .../004-complete-p2-deep-merge-mcp-servers.md | 25 ------------------- ...-complete-p2-warn-invalid-settings-json.md | 23 ----------------- 5 files changed, 121 deletions(-) delete mode 100644 todos/001-complete-p2-extract-gemini-mcp-type.md delete mode 100644 todos/002-complete-p2-test-uniquename-dedup.md delete mode 100644 todos/003-complete-p2-test-toml-triple-quote.md delete mode 100644 todos/004-complete-p2-deep-merge-mcp-servers.md delete mode 100644 todos/005-complete-p2-warn-invalid-settings-json.md diff --git a/todos/001-complete-p2-extract-gemini-mcp-type.md b/todos/001-complete-p2-extract-gemini-mcp-type.md deleted file mode 100644 index de84fc7..0000000 --- a/todos/001-complete-p2-extract-gemini-mcp-type.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -status: pending -priority: p2 -issue_id: "001" -tags: [code-review, typescript, types] -dependencies: [] ---- - -# Extract GeminiMcpServer as named type - -## Problem Statement -The `GeminiBundle` inlines the MCP server type definition, and the converter uses `NonNullable[string]` which is hard to read. Other targets (Cursor) define a named type. - -## Findings -- `src/types/gemini.ts` lines 20-26: inline type in GeminiBundle -- `src/converters/claude-to-gemini.ts` line 117: `NonNullable[string]` - -## Proposed Solution -Extract a named `GeminiMcpServer` type in `src/types/gemini.ts` and use it in both the bundle type and converter. - -## Acceptance Criteria -- [ ] `GeminiMcpServer` type exists in `src/types/gemini.ts` -- [ ] `GeminiBundle.mcpServers` uses `Record` -- [ ] Converter uses `GeminiMcpServer` instead of indexed access type -- [ ] Tests still pass diff --git a/todos/002-complete-p2-test-uniquename-dedup.md b/todos/002-complete-p2-test-uniquename-dedup.md deleted file mode 100644 index 9446179..0000000 --- a/todos/002-complete-p2-test-uniquename-dedup.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -status: pending -priority: p2 -issue_id: "002" -tags: [code-review, testing] -dependencies: [] ---- - -# Add test for uniqueName dedup when agent collides with skill - -## Problem Statement -The `uniqueName` function handles name collisions by appending `-2`, but there is no test covering the scenario where an agent name collides with a pass-through skill name. - -## Findings -- `src/converters/claude-to-gemini.ts` lines 181-193: uniqueName function -- `tests/gemini-converter.test.ts`: no dedup test - -## Proposed Solution -Add a test where a plugin has both a skill named "security-reviewer" and an agent named "Security Reviewer". The generated skill should get name "security-reviewer-2". - -## Acceptance Criteria -- [ ] Test added for agent/skill name collision -- [ ] Test verifies the deduped name is `security-reviewer-2` -- [ ] All tests pass diff --git a/todos/003-complete-p2-test-toml-triple-quote.md b/todos/003-complete-p2-test-toml-triple-quote.md deleted file mode 100644 index 4ebb2f4..0000000 --- a/todos/003-complete-p2-test-toml-triple-quote.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -status: pending -priority: p2 -issue_id: "003" -tags: [code-review, testing, security] -dependencies: [] ---- - -# Add test for TOML triple-quote escaping in prompt - -## Problem Statement -The `toToml` function escapes `"""` in prompts, but there is no test verifying this works correctly. This is a potential TOML injection vector. - -## Findings -- `src/converters/claude-to-gemini.ts` line 150: `prompt.replace(/"""/g, '\\"\\"\\"')` -- `tests/gemini-converter.test.ts`: no triple-quote test in `toToml` describe block - -## Proposed Solution -Add a test in the `toToml` describe block that passes a prompt containing `"""` and verifies the output escapes it correctly. - -## Acceptance Criteria -- [ ] Test added for prompt containing `"""` -- [ ] Escaped output does not prematurely close the TOML multi-line string -- [ ] All tests pass diff --git a/todos/004-complete-p2-deep-merge-mcp-servers.md b/todos/004-complete-p2-deep-merge-mcp-servers.md deleted file mode 100644 index 4be7602..0000000 --- a/todos/004-complete-p2-deep-merge-mcp-servers.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -status: pending -priority: p2 -issue_id: "004" -tags: [code-review, security, data-loss] -dependencies: [] ---- - -# Deep-merge mcpServers in settings.json instead of replacing - -## Problem Statement -The Gemini writer replaces the entire `mcpServers` key in `settings.json`, silently destroying any existing user MCP servers. The test name says "merges" but it actually replaces. - -## Findings -- `src/targets/gemini.ts` line 44: `{ ...existingSettings, mcpServers: bundle.mcpServers }` -- `tests/gemini-writer.test.ts` line 150: test name says "merges" but asserts replacement - -## Proposed Solution -Deep-merge `mcpServers` entries: `{ ...existingMcp, ...bundle.mcpServers }`. Update the test to verify existing servers are preserved alongside new ones. - -## Acceptance Criteria -- [ ] Existing mcpServers entries are preserved when new ones are added -- [ ] New entries with same name override existing (not merged at field level) -- [ ] Test verifies both old and new servers exist after merge -- [ ] All tests pass diff --git a/todos/005-complete-p2-warn-invalid-settings-json.md b/todos/005-complete-p2-warn-invalid-settings-json.md deleted file mode 100644 index 2b2d3cd..0000000 --- a/todos/005-complete-p2-warn-invalid-settings-json.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -status: pending -priority: p2 -issue_id: "005" -tags: [code-review, error-handling] -dependencies: ["004"] ---- - -# Warn when existing settings.json is invalid JSON - -## Problem Statement -When an existing `settings.json` cannot be parsed, the error is silently swallowed and the file is overwritten. Users get no warning that their settings were discarded. - -## Findings -- `src/targets/gemini.ts` lines 37-41: empty catch block - -## Proposed Solution -Add a `console.warn` in the catch block to inform the user that their existing settings.json could not be parsed and will be replaced. - -## Acceptance Criteria -- [ ] `console.warn` emitted when settings.json parse fails -- [ ] File is still replaced (behavior unchanged) -- [ ] All tests pass From fb6a2a3d11470a431dc87e288d7de3537884db77 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sat, 14 Feb 2026 20:49:05 -0800 Subject: [PATCH 7/7] chore: add todos/ to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c9f2f33..f8f7b97 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.log node_modules/ .codex/ +todos/