From 201ad6d0fba6d18dc8285439ddf0dc748cb4a20e Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sat, 14 Feb 2026 20:33:21 -0800 Subject: [PATCH] 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") + }) +})