diff --git a/src/sync/gemini.ts b/src/sync/gemini.ts new file mode 100644 index 0000000..d8c0544 --- /dev/null +++ b/src/sync/gemini.ts @@ -0,0 +1,76 @@ +import fs from "fs/promises" +import path from "path" +import type { ClaudeHomeConfig } from "../parsers/claude-home" +import type { ClaudeMcpServer } from "../types/claude" +import { forceSymlink, isValidSkillName } from "../utils/symlink" + +type GeminiMcpServer = { + command?: string + args?: string[] + url?: string + env?: Record + headers?: Record +} + +export async function syncToGemini( + config: ClaudeHomeConfig, + outputRoot: string, +): Promise { + const skillsDir = path.join(outputRoot, "skills") + await fs.mkdir(skillsDir, { recursive: true }) + + for (const skill of config.skills) { + if (!isValidSkillName(skill.name)) { + console.warn(`Skipping skill with invalid name: ${skill.name}`) + continue + } + const target = path.join(skillsDir, skill.name) + await forceSymlink(skill.sourceDir, target) + } + + if (Object.keys(config.mcpServers).length > 0) { + const settingsPath = path.join(outputRoot, "settings.json") + const existing = await readJsonSafe(settingsPath) + const converted = convertMcpForGemini(config.mcpServers) + const existingMcp = + existing.mcpServers && typeof existing.mcpServers === "object" + ? (existing.mcpServers as Record) + : {} + const merged = { + ...existing, + mcpServers: { ...existingMcp, ...converted }, + } + await fs.writeFile(settingsPath, JSON.stringify(merged, null, 2), { mode: 0o600 }) + } +} + +async function readJsonSafe(filePath: string): Promise> { + try { + const content = await fs.readFile(filePath, "utf-8") + return JSON.parse(content) as Record + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return {} + } + throw err + } +} + +function convertMcpForGemini( + servers: Record, +): Record { + const result: Record = {} + for (const [name, server] of Object.entries(servers)) { + const entry: GeminiMcpServer = {} + if (server.command) { + entry.command = server.command + if (server.args && server.args.length > 0) entry.args = server.args + if (server.env && Object.keys(server.env).length > 0) entry.env = server.env + } else if (server.url) { + entry.url = server.url + if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers + } + result[name] = entry + } + return result +} diff --git a/src/utils/detect-tools.ts b/src/utils/detect-tools.ts new file mode 100644 index 0000000..b6701da --- /dev/null +++ b/src/utils/detect-tools.ts @@ -0,0 +1,46 @@ +import os from "os" +import path from "path" +import { pathExists } from "./files" + +export type DetectedTool = { + name: string + detected: boolean + reason: string +} + +export async function detectInstalledTools( + home: string = os.homedir(), + cwd: string = process.cwd(), +): Promise { + const checks: Array<{ name: string; paths: string[] }> = [ + { name: "opencode", paths: [path.join(home, ".config", "opencode"), path.join(cwd, ".opencode")] }, + { name: "codex", paths: [path.join(home, ".codex")] }, + { name: "droid", paths: [path.join(home, ".factory")] }, + { name: "cursor", paths: [path.join(cwd, ".cursor"), path.join(home, ".cursor")] }, + { name: "pi", paths: [path.join(home, ".pi")] }, + { name: "gemini", paths: [path.join(cwd, ".gemini"), path.join(home, ".gemini")] }, + ] + + const results: DetectedTool[] = [] + for (const check of checks) { + let detected = false + let reason = "not found" + for (const p of check.paths) { + if (await pathExists(p)) { + detected = true + reason = `found ${p}` + break + } + } + results.push({ name: check.name, detected, reason }) + } + return results +} + +export async function getDetectedTargetNames( + home: string = os.homedir(), + cwd: string = process.cwd(), +): Promise { + const tools = await detectInstalledTools(home, cwd) + return tools.filter((t) => t.detected).map((t) => t.name) +} diff --git a/tests/detect-tools.test.ts b/tests/detect-tools.test.ts new file mode 100644 index 0000000..75900e2 --- /dev/null +++ b/tests/detect-tools.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" +import { detectInstalledTools, getDetectedTargetNames } from "../src/utils/detect-tools" + +describe("detectInstalledTools", () => { + test("detects tools when config directories exist", async () => { + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "detect-tools-")) + const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-tools-cwd-")) + + // Create directories for some tools + await fs.mkdir(path.join(tempHome, ".codex"), { recursive: true }) + await fs.mkdir(path.join(tempCwd, ".cursor"), { recursive: true }) + await fs.mkdir(path.join(tempCwd, ".gemini"), { recursive: true }) + + const results = await detectInstalledTools(tempHome, tempCwd) + + const codex = results.find((t) => t.name === "codex") + expect(codex?.detected).toBe(true) + expect(codex?.reason).toContain(".codex") + + const cursor = results.find((t) => t.name === "cursor") + expect(cursor?.detected).toBe(true) + expect(cursor?.reason).toContain(".cursor") + + const gemini = results.find((t) => t.name === "gemini") + expect(gemini?.detected).toBe(true) + expect(gemini?.reason).toContain(".gemini") + + // Tools without directories should not be detected + const opencode = results.find((t) => t.name === "opencode") + expect(opencode?.detected).toBe(false) + + const droid = results.find((t) => t.name === "droid") + expect(droid?.detected).toBe(false) + + const pi = results.find((t) => t.name === "pi") + expect(pi?.detected).toBe(false) + }) + + test("returns all tools with detected=false when no directories exist", async () => { + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "detect-empty-")) + const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-empty-cwd-")) + + const results = await detectInstalledTools(tempHome, tempCwd) + + expect(results.length).toBe(6) + for (const tool of results) { + expect(tool.detected).toBe(false) + expect(tool.reason).toBe("not found") + } + }) + + test("detects home-based tools", async () => { + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "detect-home-")) + const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-home-cwd-")) + + await fs.mkdir(path.join(tempHome, ".config", "opencode"), { recursive: true }) + await fs.mkdir(path.join(tempHome, ".factory"), { recursive: true }) + await fs.mkdir(path.join(tempHome, ".pi"), { recursive: true }) + + const results = await detectInstalledTools(tempHome, tempCwd) + + expect(results.find((t) => t.name === "opencode")?.detected).toBe(true) + expect(results.find((t) => t.name === "droid")?.detected).toBe(true) + expect(results.find((t) => t.name === "pi")?.detected).toBe(true) + }) +}) + +describe("getDetectedTargetNames", () => { + test("returns only names of detected tools", async () => { + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "detect-names-")) + const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-names-cwd-")) + + await fs.mkdir(path.join(tempHome, ".codex"), { recursive: true }) + await fs.mkdir(path.join(tempCwd, ".gemini"), { recursive: true }) + + const names = await getDetectedTargetNames(tempHome, tempCwd) + + expect(names).toContain("codex") + expect(names).toContain("gemini") + expect(names).not.toContain("opencode") + expect(names).not.toContain("droid") + expect(names).not.toContain("pi") + expect(names).not.toContain("cursor") + }) + + test("returns empty array when nothing detected", async () => { + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "detect-none-")) + const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-none-cwd-")) + + const names = await getDetectedTargetNames(tempHome, tempCwd) + expect(names).toEqual([]) + }) +}) diff --git a/tests/sync-gemini.test.ts b/tests/sync-gemini.test.ts new file mode 100644 index 0000000..3ff4a99 --- /dev/null +++ b/tests/sync-gemini.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" +import { syncToGemini } from "../src/sync/gemini" +import type { ClaudeHomeConfig } from "../src/parsers/claude-home" + +describe("syncToGemini", () => { + test("symlinks skills and writes settings.json", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-")) + const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one") + + const config: ClaudeHomeConfig = { + skills: [ + { + name: "skill-one", + sourceDir: fixtureSkillDir, + skillPath: path.join(fixtureSkillDir, "SKILL.md"), + }, + ], + mcpServers: { + context7: { url: "https://mcp.context7.com/mcp" }, + local: { command: "echo", args: ["hello"], env: { FOO: "bar" } }, + }, + } + + await syncToGemini(config, tempRoot) + + // Check skill symlink + const linkedSkillPath = path.join(tempRoot, "skills", "skill-one") + const linkedStat = await fs.lstat(linkedSkillPath) + expect(linkedStat.isSymbolicLink()).toBe(true) + + // Check settings.json + const settingsPath = path.join(tempRoot, "settings.json") + const settings = JSON.parse(await fs.readFile(settingsPath, "utf8")) as { + mcpServers: Record }> + } + + expect(settings.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp") + expect(settings.mcpServers.local?.command).toBe("echo") + expect(settings.mcpServers.local?.args).toEqual(["hello"]) + expect(settings.mcpServers.local?.env).toEqual({ FOO: "bar" }) + }) + + test("merges existing settings.json", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-merge-")) + const settingsPath = path.join(tempRoot, "settings.json") + + await fs.writeFile( + settingsPath, + JSON.stringify({ + theme: "dark", + mcpServers: { existing: { command: "node", args: ["server.js"] } }, + }, null, 2), + ) + + const config: ClaudeHomeConfig = { + skills: [], + mcpServers: { + context7: { url: "https://mcp.context7.com/mcp" }, + }, + } + + await syncToGemini(config, tempRoot) + + const merged = JSON.parse(await fs.readFile(settingsPath, "utf8")) as { + theme: string + mcpServers: Record + } + + // Preserves existing settings + expect(merged.theme).toBe("dark") + // Preserves existing MCP servers + expect(merged.mcpServers.existing?.command).toBe("node") + // Adds new MCP servers + expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp") + }) + + test("does not write settings.json when no MCP servers", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-nomcp-")) + const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one") + + const config: ClaudeHomeConfig = { + skills: [ + { + name: "skill-one", + sourceDir: fixtureSkillDir, + skillPath: path.join(fixtureSkillDir, "SKILL.md"), + }, + ], + mcpServers: {}, + } + + await syncToGemini(config, tempRoot) + + // Skills should still be symlinked + const linkedSkillPath = path.join(tempRoot, "skills", "skill-one") + const linkedStat = await fs.lstat(linkedSkillPath) + expect(linkedStat.isSymbolicLink()).toBe(true) + + // But settings.json should not exist + const settingsExists = await fs.access(path.join(tempRoot, "settings.json")).then(() => true).catch(() => false) + expect(settingsExists).toBe(false) + }) +})