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 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ export default defineCommand({
|
|||||||
to: {
|
to: {
|
||||||
type: "string",
|
type: "string",
|
||||||
default: "opencode",
|
default: "opencode",
|
||||||
description: "Target format (opencode | codex | droid | cursor | pi)",
|
description: "Target format (opencode | codex | droid | cursor | pi | gemini)",
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -145,5 +145,6 @@ function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHo
|
|||||||
if (targetName === "pi") return piHome
|
if (targetName === "pi") return piHome
|
||||||
if (targetName === "droid") return path.join(os.homedir(), ".factory")
|
if (targetName === "droid") return path.join(os.homedir(), ".factory")
|
||||||
if (targetName === "cursor") return path.join(outputRoot, ".cursor")
|
if (targetName === "cursor") return path.join(outputRoot, ".cursor")
|
||||||
|
if (targetName === "gemini") return path.join(outputRoot, ".gemini")
|
||||||
return outputRoot
|
return outputRoot
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default defineCommand({
|
|||||||
to: {
|
to: {
|
||||||
type: "string",
|
type: "string",
|
||||||
default: "opencode",
|
default: "opencode",
|
||||||
description: "Target format (opencode | codex | droid | cursor | pi)",
|
description: "Target format (opencode | codex | droid | cursor | pi | gemini)",
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -183,6 +183,10 @@ function resolveTargetOutputRoot(
|
|||||||
const base = hasExplicitOutput ? outputRoot : process.cwd()
|
const base = hasExplicitOutput ? outputRoot : process.cwd()
|
||||||
return path.join(base, ".cursor")
|
return path.join(base, ".cursor")
|
||||||
}
|
}
|
||||||
|
if (targetName === "gemini") {
|
||||||
|
const base = hasExplicitOutput ? outputRoot : process.cwd()
|
||||||
|
return path.join(base, ".gemini")
|
||||||
|
}
|
||||||
return outputRoot
|
return outputRoot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
193
src/converters/claude-to-gemini.ts
Normal file
193
src/converters/claude-to-gemini.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { formatFrontmatter } from "../utils/frontmatter"
|
||||||
|
import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
|
||||||
|
import type { GeminiBundle, GeminiCommand, GeminiSkill } from "../types/gemini"
|
||||||
|
import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
|
||||||
|
|
||||||
|
export type ClaudeToGeminiOptions = ClaudeToOpenCodeOptions
|
||||||
|
|
||||||
|
const GEMINI_DESCRIPTION_MAX_LENGTH = 1024
|
||||||
|
|
||||||
|
export function convertClaudeToGemini(
|
||||||
|
plugin: ClaudePlugin,
|
||||||
|
_options: ClaudeToGeminiOptions,
|
||||||
|
): GeminiBundle {
|
||||||
|
const usedSkillNames = new Set<string>()
|
||||||
|
const usedCommandNames = new Set<string>()
|
||||||
|
|
||||||
|
const skillDirs = plugin.skills.map((skill) => ({
|
||||||
|
name: skill.name,
|
||||||
|
sourceDir: skill.sourceDir,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Reserve skill names from pass-through skills
|
||||||
|
for (const skill of skillDirs) {
|
||||||
|
usedSkillNames.add(normalizeName(skill.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedSkills = plugin.agents.map((agent) => convertAgentToSkill(agent, usedSkillNames))
|
||||||
|
|
||||||
|
const commands = plugin.commands.map((command) => convertCommand(command, usedCommandNames))
|
||||||
|
|
||||||
|
const mcpServers = convertMcpServers(plugin.mcpServers)
|
||||||
|
|
||||||
|
if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) {
|
||||||
|
console.warn("Warning: Gemini CLI hooks use a different format (BeforeTool/AfterTool with matchers). Hooks were skipped during conversion.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return { generatedSkills, skillDirs, commands, mcpServers }
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertAgentToSkill(agent: ClaudeAgent, usedNames: Set<string>): GeminiSkill {
|
||||||
|
const name = uniqueName(normalizeName(agent.name), usedNames)
|
||||||
|
const description = sanitizeDescription(
|
||||||
|
agent.description ?? `Use this skill for ${agent.name} tasks`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const frontmatter: Record<string, unknown> = { name, description }
|
||||||
|
|
||||||
|
let body = transformContentForGemini(agent.body.trim())
|
||||||
|
if (agent.capabilities && agent.capabilities.length > 0) {
|
||||||
|
const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n")
|
||||||
|
body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
|
||||||
|
}
|
||||||
|
if (body.length === 0) {
|
||||||
|
body = `Instructions converted from the ${agent.name} agent.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = formatFrontmatter(frontmatter, body)
|
||||||
|
return { name, content }
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertCommand(command: ClaudeCommand, usedNames: Set<string>): GeminiCommand {
|
||||||
|
// Preserve namespace structure: workflows:plan -> workflows/plan
|
||||||
|
const commandPath = resolveCommandPath(command.name)
|
||||||
|
const pathKey = commandPath.join("/")
|
||||||
|
uniqueName(pathKey, usedNames) // Track for dedup
|
||||||
|
|
||||||
|
const description = command.description ?? `Converted from Claude command ${command.name}`
|
||||||
|
const transformedBody = transformContentForGemini(command.body.trim())
|
||||||
|
|
||||||
|
let prompt = transformedBody
|
||||||
|
if (command.argumentHint) {
|
||||||
|
prompt += `\n\nUser request: {{args}}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = toToml(description, prompt)
|
||||||
|
return { name: pathKey, content }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform Claude Code content to Gemini-compatible content.
|
||||||
|
*
|
||||||
|
* 1. Task agent calls: Task agent-name(args) -> Use the agent-name skill to: args
|
||||||
|
* 2. Path rewriting: .claude/ -> .gemini/, ~/.claude/ -> ~/.gemini/
|
||||||
|
* 3. Agent references: @agent-name -> the agent-name skill
|
||||||
|
*/
|
||||||
|
export function transformContentForGemini(body: string): string {
|
||||||
|
let result = body
|
||||||
|
|
||||||
|
// 1. Transform Task agent calls
|
||||||
|
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
|
||||||
|
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
|
||||||
|
const skillName = normalizeName(agentName)
|
||||||
|
return `${prefix}Use the ${skillName} skill to: ${args.trim()}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Rewrite .claude/ paths to .gemini/
|
||||||
|
result = result
|
||||||
|
.replace(/~\/\.claude\//g, "~/.gemini/")
|
||||||
|
.replace(/\.claude\//g, ".gemini/")
|
||||||
|
|
||||||
|
// 3. Transform @agent-name references
|
||||||
|
const agentRefPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi
|
||||||
|
result = result.replace(agentRefPattern, (_match, agentName: string) => {
|
||||||
|
return `the ${normalizeName(agentName)} skill`
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertMcpServers(
|
||||||
|
servers?: Record<string, ClaudeMcpServer>,
|
||||||
|
): GeminiBundle["mcpServers"] | undefined {
|
||||||
|
if (!servers || Object.keys(servers).length === 0) return undefined
|
||||||
|
|
||||||
|
const result: NonNullable<GeminiBundle["mcpServers"]> = {}
|
||||||
|
for (const [name, server] of Object.entries(servers)) {
|
||||||
|
const entry: NonNullable<GeminiBundle["mcpServers"]>[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>): 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
|
||||||
|
}
|
||||||
65
src/targets/gemini.ts
Normal file
65
src/targets/gemini.ts
Normal file
@@ -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<void> {
|
||||||
|
const paths = resolveGeminiPaths(outputRoot)
|
||||||
|
await ensureDir(paths.geminiDir)
|
||||||
|
|
||||||
|
if (bundle.generatedSkills.length > 0) {
|
||||||
|
for (const skill of bundle.generatedSkills) {
|
||||||
|
await writeText(path.join(paths.skillsDir, skill.name, "SKILL.md"), skill.content + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bundle.skillDirs.length > 0) {
|
||||||
|
for (const skill of bundle.skillDirs) {
|
||||||
|
await copyDir(skill.sourceDir, path.join(paths.skillsDir, skill.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bundle.commands.length > 0) {
|
||||||
|
for (const command of bundle.commands) {
|
||||||
|
await writeText(path.join(paths.commandsDir, `${command.name}.toml`), command.content + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bundle.mcpServers && Object.keys(bundle.mcpServers).length > 0) {
|
||||||
|
const settingsPath = path.join(paths.geminiDir, "settings.json")
|
||||||
|
const backupPath = await backupFile(settingsPath)
|
||||||
|
if (backupPath) {
|
||||||
|
console.log(`Backed up existing settings.json to ${backupPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge mcpServers into existing settings if present
|
||||||
|
let existingSettings: Record<string, unknown> = {}
|
||||||
|
if (await pathExists(settingsPath)) {
|
||||||
|
try {
|
||||||
|
existingSettings = await readJson<Record<string, unknown>>(settingsPath)
|
||||||
|
} catch {
|
||||||
|
// 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,19 @@ import type { CodexBundle } from "../types/codex"
|
|||||||
import type { DroidBundle } from "../types/droid"
|
import type { DroidBundle } from "../types/droid"
|
||||||
import type { CursorBundle } from "../types/cursor"
|
import type { CursorBundle } from "../types/cursor"
|
||||||
import type { PiBundle } from "../types/pi"
|
import type { PiBundle } from "../types/pi"
|
||||||
|
import type { GeminiBundle } from "../types/gemini"
|
||||||
import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode"
|
import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode"
|
||||||
import { convertClaudeToCodex } from "../converters/claude-to-codex"
|
import { convertClaudeToCodex } from "../converters/claude-to-codex"
|
||||||
import { convertClaudeToDroid } from "../converters/claude-to-droid"
|
import { convertClaudeToDroid } from "../converters/claude-to-droid"
|
||||||
import { convertClaudeToCursor } from "../converters/claude-to-cursor"
|
import { convertClaudeToCursor } from "../converters/claude-to-cursor"
|
||||||
import { convertClaudeToPi } from "../converters/claude-to-pi"
|
import { convertClaudeToPi } from "../converters/claude-to-pi"
|
||||||
|
import { convertClaudeToGemini } from "../converters/claude-to-gemini"
|
||||||
import { writeOpenCodeBundle } from "./opencode"
|
import { writeOpenCodeBundle } from "./opencode"
|
||||||
import { writeCodexBundle } from "./codex"
|
import { writeCodexBundle } from "./codex"
|
||||||
import { writeDroidBundle } from "./droid"
|
import { writeDroidBundle } from "./droid"
|
||||||
import { writeCursorBundle } from "./cursor"
|
import { writeCursorBundle } from "./cursor"
|
||||||
import { writePiBundle } from "./pi"
|
import { writePiBundle } from "./pi"
|
||||||
|
import { writeGeminiBundle } from "./gemini"
|
||||||
|
|
||||||
export type TargetHandler<TBundle = unknown> = {
|
export type TargetHandler<TBundle = unknown> = {
|
||||||
name: string
|
name: string
|
||||||
@@ -53,4 +56,10 @@ export const targets: Record<string, TargetHandler> = {
|
|||||||
convert: convertClaudeToPi as TargetHandler<PiBundle>["convert"],
|
convert: convertClaudeToPi as TargetHandler<PiBundle>["convert"],
|
||||||
write: writePiBundle as TargetHandler<PiBundle>["write"],
|
write: writePiBundle as TargetHandler<PiBundle>["write"],
|
||||||
},
|
},
|
||||||
|
gemini: {
|
||||||
|
name: "gemini",
|
||||||
|
implemented: true,
|
||||||
|
convert: convertClaudeToGemini as TargetHandler<GeminiBundle>["convert"],
|
||||||
|
write: writeGeminiBundle as TargetHandler<GeminiBundle>["write"],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/types/gemini.ts
Normal file
27
src/types/gemini.ts
Normal file
@@ -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<string, {
|
||||||
|
command?: string
|
||||||
|
args?: string[]
|
||||||
|
env?: Record<string, string>
|
||||||
|
url?: string
|
||||||
|
headers?: Record<string, string>
|
||||||
|
}>
|
||||||
|
}
|
||||||
342
tests/gemini-converter.test.ts
Normal file
342
tests/gemini-converter.test.ts
Normal file
@@ -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\\""')
|
||||||
|
})
|
||||||
|
})
|
||||||
179
tests/gemini-writer.test.ts
Normal file
179
tests/gemini-writer.test.ts
Normal file
@@ -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<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access(filePath)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("writeGeminiBundle", () => {
|
||||||
|
test("writes skills, commands, and settings.json", async () => {
|
||||||
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-test-"))
|
||||||
|
const bundle: GeminiBundle = {
|
||||||
|
generatedSkills: [
|
||||||
|
{
|
||||||
|
name: "security-reviewer",
|
||||||
|
content: "---\nname: security-reviewer\ndescription: Security\n---\n\nReview code.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skillDirs: [
|
||||||
|
{
|
||||||
|
name: "skill-one",
|
||||||
|
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: "plan",
|
||||||
|
content: 'description = "Plan"\nprompt = """\nPlan the work.\n"""',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mcpServers: {
|
||||||
|
playwright: { command: "npx", args: ["-y", "@anthropic/mcp-playwright"] },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeGeminiBundle(tempRoot, bundle)
|
||||||
|
|
||||||
|
expect(await exists(path.join(tempRoot, ".gemini", "skills", "security-reviewer", "SKILL.md"))).toBe(true)
|
||||||
|
expect(await exists(path.join(tempRoot, ".gemini", "skills", "skill-one", "SKILL.md"))).toBe(true)
|
||||||
|
expect(await exists(path.join(tempRoot, ".gemini", "commands", "plan.toml"))).toBe(true)
|
||||||
|
expect(await exists(path.join(tempRoot, ".gemini", "settings.json"))).toBe(true)
|
||||||
|
|
||||||
|
const skillContent = await fs.readFile(
|
||||||
|
path.join(tempRoot, ".gemini", "skills", "security-reviewer", "SKILL.md"),
|
||||||
|
"utf8",
|
||||||
|
)
|
||||||
|
expect(skillContent).toContain("Review code.")
|
||||||
|
|
||||||
|
const commandContent = await fs.readFile(
|
||||||
|
path.join(tempRoot, ".gemini", "commands", "plan.toml"),
|
||||||
|
"utf8",
|
||||||
|
)
|
||||||
|
expect(commandContent).toContain("Plan the work.")
|
||||||
|
|
||||||
|
const settingsContent = JSON.parse(
|
||||||
|
await fs.readFile(path.join(tempRoot, ".gemini", "settings.json"), "utf8"),
|
||||||
|
)
|
||||||
|
expect(settingsContent.mcpServers.playwright.command).toBe("npx")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("namespaced commands create subdirectories", async () => {
|
||||||
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-ns-"))
|
||||||
|
const bundle: GeminiBundle = {
|
||||||
|
generatedSkills: [],
|
||||||
|
skillDirs: [],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: "workflows/plan",
|
||||||
|
content: 'description = "Plan"\nprompt = """\nPlan.\n"""',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeGeminiBundle(tempRoot, bundle)
|
||||||
|
|
||||||
|
expect(await exists(path.join(tempRoot, ".gemini", "commands", "workflows", "plan.toml"))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not double-nest when output root is .gemini", async () => {
|
||||||
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-home-"))
|
||||||
|
const geminiRoot = path.join(tempRoot, ".gemini")
|
||||||
|
const bundle: GeminiBundle = {
|
||||||
|
generatedSkills: [
|
||||||
|
{ name: "reviewer", content: "Reviewer skill content" },
|
||||||
|
],
|
||||||
|
skillDirs: [],
|
||||||
|
commands: [
|
||||||
|
{ name: "plan", content: "Plan content" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeGeminiBundle(geminiRoot, bundle)
|
||||||
|
|
||||||
|
expect(await exists(path.join(geminiRoot, "skills", "reviewer", "SKILL.md"))).toBe(true)
|
||||||
|
expect(await exists(path.join(geminiRoot, "commands", "plan.toml"))).toBe(true)
|
||||||
|
// Should NOT double-nest under .gemini/.gemini
|
||||||
|
expect(await exists(path.join(geminiRoot, ".gemini"))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles empty bundles gracefully", async () => {
|
||||||
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-empty-"))
|
||||||
|
const bundle: GeminiBundle = {
|
||||||
|
generatedSkills: [],
|
||||||
|
skillDirs: [],
|
||||||
|
commands: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeGeminiBundle(tempRoot, bundle)
|
||||||
|
expect(await exists(tempRoot)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("backs up existing settings.json before overwrite", async () => {
|
||||||
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-backup-"))
|
||||||
|
const geminiRoot = path.join(tempRoot, ".gemini")
|
||||||
|
await fs.mkdir(geminiRoot, { recursive: true })
|
||||||
|
|
||||||
|
// Write existing settings.json
|
||||||
|
const settingsPath = path.join(geminiRoot, "settings.json")
|
||||||
|
await fs.writeFile(settingsPath, JSON.stringify({ mcpServers: { old: { command: "old-cmd" } } }))
|
||||||
|
|
||||||
|
const bundle: GeminiBundle = {
|
||||||
|
generatedSkills: [],
|
||||||
|
skillDirs: [],
|
||||||
|
commands: [],
|
||||||
|
mcpServers: {
|
||||||
|
newServer: { command: "new-cmd" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeGeminiBundle(geminiRoot, bundle)
|
||||||
|
|
||||||
|
// New settings.json should have the new content
|
||||||
|
const newContent = JSON.parse(await fs.readFile(settingsPath, "utf8"))
|
||||||
|
expect(newContent.mcpServers.newServer.command).toBe("new-cmd")
|
||||||
|
|
||||||
|
// A backup file should exist
|
||||||
|
const files = await fs.readdir(geminiRoot)
|
||||||
|
const backupFiles = files.filter((f) => f.startsWith("settings.json.bak."))
|
||||||
|
expect(backupFiles.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("merges mcpServers into existing settings.json without clobbering other keys", async () => {
|
||||||
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-merge-"))
|
||||||
|
const geminiRoot = path.join(tempRoot, ".gemini")
|
||||||
|
await fs.mkdir(geminiRoot, { recursive: true })
|
||||||
|
|
||||||
|
// Write existing settings.json with other keys
|
||||||
|
const settingsPath = path.join(geminiRoot, "settings.json")
|
||||||
|
await fs.writeFile(settingsPath, JSON.stringify({
|
||||||
|
model: "gemini-2.5-pro",
|
||||||
|
mcpServers: { old: { command: "old-cmd" } },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const bundle: GeminiBundle = {
|
||||||
|
generatedSkills: [],
|
||||||
|
skillDirs: [],
|
||||||
|
commands: [],
|
||||||
|
mcpServers: {
|
||||||
|
newServer: { command: "new-cmd" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeGeminiBundle(geminiRoot, bundle)
|
||||||
|
|
||||||
|
const content = JSON.parse(await fs.readFile(settingsPath, "utf8"))
|
||||||
|
// Should preserve existing model key
|
||||||
|
expect(content.model).toBe("gemini-2.5-pro")
|
||||||
|
// mcpServers should be replaced (not merged) with new content
|
||||||
|
expect(content.mcpServers.newServer.command).toBe("new-cmd")
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user