From e4d730d5b4b24be908ede32f1baa1feb5b711b7f Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sat, 14 Feb 2026 21:08:44 -0800 Subject: [PATCH 1/4] feat: add detect-tools utility and Gemini sync with tests --- src/sync/gemini.ts | 76 ++++++++++++++++++++++++++ src/utils/detect-tools.ts | 46 ++++++++++++++++ tests/detect-tools.test.ts | 96 +++++++++++++++++++++++++++++++++ tests/sync-gemini.test.ts | 106 +++++++++++++++++++++++++++++++++++++ 4 files changed, 324 insertions(+) create mode 100644 src/sync/gemini.ts create mode 100644 src/utils/detect-tools.ts create mode 100644 tests/detect-tools.test.ts create mode 100644 tests/sync-gemini.test.ts diff --git a/src/sync/gemini.ts b/src/sync/gemini.ts new file mode 100644 index 00000000..d8c05443 --- /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 00000000..b6701dae --- /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 00000000..75900e2d --- /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 00000000..3ff4a997 --- /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) + }) +}) From bc655f714efb245cb11daf1a7bf250b5bead8ecf Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sat, 14 Feb 2026 21:10:52 -0800 Subject: [PATCH 2/4] feat: wire --to all into install/convert and --target all/gemini into sync --- src/commands/convert.ts | 56 ++++++++++++++++++++++++----- src/commands/install.ts | 56 ++++++++++++++++++++++++----- src/commands/sync.ts | 79 +++++++++++++++++++++++++++++------------ 3 files changed, 150 insertions(+), 41 deletions(-) diff --git a/src/commands/convert.ts b/src/commands/convert.ts index 9f62511e..7ac3d889 100644 --- a/src/commands/convert.ts +++ b/src/commands/convert.ts @@ -6,6 +6,7 @@ import { targets } from "../targets" import type { PermissionMode } from "../converters/claude-to-opencode" import { ensureCodexAgentsFile } from "../utils/codex-agents" import { expandHome, resolveTargetHome } from "../utils/resolve-home" +import { detectInstalledTools } from "../utils/detect-tools" const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"] @@ -23,7 +24,7 @@ export default defineCommand({ to: { type: "string", default: "opencode", - description: "Target format (opencode | codex | droid | cursor | pi | gemini)", + description: "Target format (opencode | codex | droid | cursor | pi | gemini | all)", }, output: { type: "string", @@ -62,14 +63,6 @@ export default defineCommand({ }, async run({ args }) { const targetName = String(args.to) - const target = targets[targetName] - if (!target) { - throw new Error(`Unknown target: ${targetName}`) - } - - if (!target.implemented) { - throw new Error(`Target ${targetName} is registered but not implemented yet.`) - } const permissions = String(args.permissions) if (!permissionModes.includes(permissions as PermissionMode)) { @@ -87,6 +80,51 @@ export default defineCommand({ permissions: permissions as PermissionMode, } + if (targetName === "all") { + const detected = await detectInstalledTools() + const activeTargets = detected.filter((t) => t.detected) + + if (activeTargets.length === 0) { + console.log("No AI coding tools detected. Install at least one tool first.") + return + } + + console.log(`Detected ${activeTargets.length} tool(s):`) + for (const tool of detected) { + console.log(` ${tool.detected ? "✓" : "✗"} ${tool.name} — ${tool.reason}`) + } + + for (const tool of activeTargets) { + const handler = targets[tool.name] + if (!handler || !handler.implemented) { + console.warn(`Skipping ${tool.name}: not implemented.`) + continue + } + const bundle = handler.convert(plugin, options) + if (!bundle) { + console.warn(`Skipping ${tool.name}: no output returned.`) + continue + } + const root = resolveTargetOutputRoot(tool.name, outputRoot, codexHome, piHome) + await handler.write(root, bundle) + console.log(`Converted ${plugin.manifest.name} to ${tool.name} at ${root}`) + } + + if (activeTargets.some((t) => t.name === "codex")) { + await ensureCodexAgentsFile(codexHome) + } + return + } + + const target = targets[targetName] + if (!target) { + throw new Error(`Unknown target: ${targetName}`) + } + + if (!target.implemented) { + throw new Error(`Target ${targetName} is registered but not implemented yet.`) + } + const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome) const bundle = target.convert(plugin, options) if (!bundle) { diff --git a/src/commands/install.ts b/src/commands/install.ts index 35506e8c..fb91e4a1 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -8,6 +8,7 @@ import { pathExists } from "../utils/files" import type { PermissionMode } from "../converters/claude-to-opencode" import { ensureCodexAgentsFile } from "../utils/codex-agents" import { expandHome, resolveTargetHome } from "../utils/resolve-home" +import { detectInstalledTools } from "../utils/detect-tools" const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"] @@ -25,7 +26,7 @@ export default defineCommand({ to: { type: "string", default: "opencode", - description: "Target format (opencode | codex | droid | cursor | pi | gemini)", + description: "Target format (opencode | codex | droid | cursor | pi | gemini | all)", }, output: { type: "string", @@ -64,13 +65,6 @@ export default defineCommand({ }, async run({ args }) { const targetName = String(args.to) - const target = targets[targetName] - if (!target) { - throw new Error(`Unknown target: ${targetName}`) - } - if (!target.implemented) { - throw new Error(`Target ${targetName} is registered but not implemented yet.`) - } const permissions = String(args.permissions) if (!permissionModes.includes(permissions as PermissionMode)) { @@ -84,6 +78,7 @@ export default defineCommand({ const outputRoot = resolveOutputRoot(args.output) const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex")) const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent")) + const hasExplicitOutput = Boolean(args.output && String(args.output).trim()) const options = { agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent", @@ -91,11 +86,54 @@ export default defineCommand({ permissions: permissions as PermissionMode, } + if (targetName === "all") { + const detected = await detectInstalledTools() + const activeTargets = detected.filter((t) => t.detected) + + if (activeTargets.length === 0) { + console.log("No AI coding tools detected. Install at least one tool first.") + return + } + + console.log(`Detected ${activeTargets.length} tool(s):`) + for (const tool of detected) { + console.log(` ${tool.detected ? "✓" : "✗"} ${tool.name} — ${tool.reason}`) + } + + for (const tool of activeTargets) { + const handler = targets[tool.name] + if (!handler || !handler.implemented) { + console.warn(`Skipping ${tool.name}: not implemented.`) + continue + } + const bundle = handler.convert(plugin, options) + if (!bundle) { + console.warn(`Skipping ${tool.name}: no output returned.`) + continue + } + const root = resolveTargetOutputRoot(tool.name, outputRoot, codexHome, piHome, hasExplicitOutput) + await handler.write(root, bundle) + console.log(`Installed ${plugin.manifest.name} to ${tool.name} at ${root}`) + } + + if (activeTargets.some((t) => t.name === "codex")) { + await ensureCodexAgentsFile(codexHome) + } + return + } + + const target = targets[targetName] + if (!target) { + throw new Error(`Unknown target: ${targetName}`) + } + if (!target.implemented) { + throw new Error(`Target ${targetName} is registered but not implemented yet.`) + } + const bundle = target.convert(plugin, options) if (!bundle) { throw new Error(`Target ${targetName} did not return a bundle.`) } - const hasExplicitOutput = Boolean(args.output && String(args.output).trim()) const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome, hasExplicitOutput) await target.write(primaryOutputRoot, bundle) console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`) diff --git a/src/commands/sync.ts b/src/commands/sync.ts index e5b576e1..c860ca33 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -7,9 +7,11 @@ import { syncToCodex } from "../sync/codex" import { syncToPi } from "../sync/pi" import { syncToDroid } from "../sync/droid" import { syncToCursor } from "../sync/cursor" +import { syncToGemini } from "../sync/gemini" import { expandHome } from "../utils/resolve-home" +import { detectInstalledTools } from "../utils/detect-tools" -const validTargets = ["opencode", "codex", "pi", "droid", "cursor"] as const +const validTargets = ["opencode", "codex", "pi", "droid", "cursor", "gemini", "all"] as const type SyncTarget = (typeof validTargets)[number] function isValidTarget(value: string): value is SyncTarget { @@ -30,7 +32,7 @@ function hasPotentialSecrets(mcpServers: Record): boolean { return false } -function resolveOutputRoot(target: SyncTarget): string { +function resolveOutputRoot(target: string): string { switch (target) { case "opencode": return path.join(os.homedir(), ".config", "opencode") @@ -42,19 +44,46 @@ function resolveOutputRoot(target: SyncTarget): string { return path.join(os.homedir(), ".factory") case "cursor": return path.join(process.cwd(), ".cursor") + case "gemini": + return path.join(process.cwd(), ".gemini") + default: + throw new Error(`No output root for target: ${target}`) + } +} + +async function syncTarget(target: string, config: Awaited>, outputRoot: string): Promise { + switch (target) { + case "opencode": + await syncToOpenCode(config, outputRoot) + break + case "codex": + await syncToCodex(config, outputRoot) + break + case "pi": + await syncToPi(config, outputRoot) + break + case "droid": + await syncToDroid(config, outputRoot) + break + case "cursor": + await syncToCursor(config, outputRoot) + break + case "gemini": + await syncToGemini(config, outputRoot) + break } } export default defineCommand({ meta: { name: "sync", - description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, Pi, Droid, or Cursor", + description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, Pi, Droid, Cursor, or Gemini", }, args: { target: { type: "string", required: true, - description: "Target: opencode | codex | pi | droid | cursor", + description: "Target: opencode | codex | pi | droid | cursor | gemini | all", }, claudeHome: { type: "string", @@ -78,30 +107,34 @@ export default defineCommand({ ) } + if (args.target === "all") { + const detected = await detectInstalledTools() + const activeTargets = detected.filter((t) => t.detected).map((t) => t.name) + + if (activeTargets.length === 0) { + console.log("No AI coding tools detected.") + return + } + + console.log(`Syncing to ${activeTargets.length} detected tool(s)...`) + for (const tool of detected) { + console.log(` ${tool.detected ? "✓" : "✗"} ${tool.name} — ${tool.reason}`) + } + + for (const name of activeTargets) { + const outputRoot = resolveOutputRoot(name) + await syncTarget(name, config, outputRoot) + console.log(`✓ Synced to ${name}: ${outputRoot}`) + } + return + } + console.log( `Syncing ${config.skills.length} skills, ${Object.keys(config.mcpServers).length} MCP servers...`, ) const outputRoot = resolveOutputRoot(args.target) - - switch (args.target) { - case "opencode": - await syncToOpenCode(config, outputRoot) - break - case "codex": - await syncToCodex(config, outputRoot) - break - case "pi": - await syncToPi(config, outputRoot) - break - case "droid": - await syncToDroid(config, outputRoot) - break - case "cursor": - await syncToCursor(config, outputRoot) - break - } - + await syncTarget(args.target, config, outputRoot) console.log(`✓ Synced to ${args.target}: ${outputRoot}`) }, }) From 877e265ec1c66b66c256c121f7825a634b1ae931 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sat, 14 Feb 2026 21:11:46 -0800 Subject: [PATCH 3/4] docs: add auto-detect and Gemini sync to README, bump to 0.8.0 --- CHANGELOG.md | 9 +++++++++ README.md | 9 +++++++++ package.json | 2 +- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27f5f059..5e45db5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to the `@every-env/compound-plugin` CLI tool will be documen The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.0] - 2026-02-14 + +### Added + +- **Auto-detect install targets** — `install --to all` and `convert --to all` auto-detect installed AI coding tools and install to all of them +- **Gemini sync** — `sync --target gemini` symlinks personal skills to `.gemini/skills/` and merges MCP servers into `.gemini/settings.json` +- **Sync all targets** — `sync --target all` syncs personal config to all detected tools +- **Tool detection utility** — Checks config directories for OpenCode, Codex, Droid, Cursor, Pi, and Gemini + ## [0.6.0] - 2026-02-12 ### Added diff --git a/README.md b/README.md index 3d733df9..5e44e1bc 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ 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 + +# auto-detect installed tools and install to all +bunx @every-env/compound-plugin install compound-engineering --to all ``` Local dev: @@ -70,6 +73,12 @@ bunx @every-env/compound-plugin sync --target droid # Sync to Cursor (skills + MCP servers) bunx @every-env/compound-plugin sync --target cursor + +# Sync to Gemini (skills + MCP servers) +bunx @every-env/compound-plugin sync --target gemini + +# Sync to all detected tools +bunx @every-env/compound-plugin sync --target all ``` This syncs: diff --git a/package.json b/package.json index 832a5b2d..1115dc00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@every-env/compound-plugin", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "private": false, "bin": { From f859619a40fc0ebb30175f7e3adf6523aa12a771 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sat, 14 Feb 2026 21:12:35 -0800 Subject: [PATCH 4/4] docs: mark plan as completed --- ...uto-detect-install-and-gemini-sync-plan.md | 360 ++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 docs/plans/2026-02-14-feat-auto-detect-install-and-gemini-sync-plan.md diff --git a/docs/plans/2026-02-14-feat-auto-detect-install-and-gemini-sync-plan.md b/docs/plans/2026-02-14-feat-auto-detect-install-and-gemini-sync-plan.md new file mode 100644 index 00000000..a4867bc5 --- /dev/null +++ b/docs/plans/2026-02-14-feat-auto-detect-install-and-gemini-sync-plan.md @@ -0,0 +1,360 @@ +--- +title: Auto-detect install targets and add Gemini sync +type: feat +status: completed +date: 2026-02-14 +completed_date: 2026-02-14 +completed_by: "Claude Opus 4.6" +actual_effort: "Completed in one session" +--- + +# Auto-detect Install Targets and Add Gemini Sync + +## Overview + +Two related improvements to the converter CLI: + +1. **`install --to all`** — Auto-detect which AI coding tools are installed and convert to all of them in one command +2. **`sync --target gemini`** — Add Gemini CLI as a sync target (currently missing), then add `sync --target all` to sync personal config to every detected tool + +## Problem Statement + +Users currently must run 6 separate commands to install to all targets: + +```bash +bunx @every-env/compound-plugin install compound-engineering --to opencode +bunx @every-env/compound-plugin install compound-engineering --to codex +bunx @every-env/compound-plugin install compound-engineering --to droid +bunx @every-env/compound-plugin install compound-engineering --to cursor +bunx @every-env/compound-plugin install compound-engineering --to pi +bunx @every-env/compound-plugin install compound-engineering --to gemini +``` + +Similarly, sync requires separate commands per target. And Gemini sync doesn't exist yet. + +## Acceptance Criteria + +### Auto-detect install + +- [x]`install --to all` detects installed tools and installs to each +- [x]Detection checks config directories and/or binaries for each tool +- [x]Prints which tools were detected and which were skipped +- [x]Tools with no detection signal are skipped (not errored) +- [x]`convert --to all` also works (same detection logic) +- [x]Existing `--to ` behavior unchanged +- [x]Tests for detection logic and `all` target handling + +### Gemini sync + +- [x]`sync --target gemini` symlinks skills and writes MCP servers to `.gemini/settings.json` +- [x]MCP servers merged into existing `settings.json` (same pattern as writer) +- [x]`gemini` added to `validTargets` in `sync.ts` +- [x]Tests for Gemini sync + +### Sync all + +- [x]`sync --target all` syncs to all detected tools +- [x]Reuses same detection logic as install +- [x]Prints summary of what was synced where + +## Implementation + +### Phase 1: Tool Detection Utility + +**Create `src/utils/detect-tools.ts`** + +```typescript +import os from "os" +import path from "path" +import { pathExists } from "./files" + +export type DetectedTool = { + name: string + detected: boolean + reason: string // e.g. "found ~/.codex/" or "not found" +} + +export async function detectInstalledTools(): Promise { + const home = os.homedir() + const cwd = process.cwd() + + 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(): Promise { + const tools = await detectInstalledTools() + return tools.filter((t) => t.detected).map((t) => t.name) +} +``` + +**Detection heuristics:** + +| Tool | Check paths | Notes | +|------|------------|-------| +| OpenCode | `~/.config/opencode/`, `.opencode/` | XDG config or project-local | +| Codex | `~/.codex/` | Global only | +| Droid | `~/.factory/` | Global only | +| Cursor | `.cursor/`, `~/.cursor/` | Project-local or global | +| Pi | `~/.pi/` | Global only | +| Gemini | `.gemini/`, `~/.gemini/` | Project-local or global | + +### Phase 2: Gemini Sync + +**Create `src/sync/gemini.ts`** + +Follow the Cursor sync pattern (`src/sync/cursor.ts`) since both use JSON config with `mcpServers` key: + +```typescript +import path from "path" +import { symlinkSkills } from "../utils/symlink" +import { backupFile, pathExists, readJson, writeJson } from "../utils/files" +import type { ClaudeMcpServer } from "../types/claude" + +export async function syncToGemini( + skills: { name: string; sourceDir: string }[], + mcpServers: Record, + outputRoot: string, +): Promise { + const geminiDir = path.join(outputRoot, ".gemini") + + // Symlink skills + if (skills.length > 0) { + const skillsDir = path.join(geminiDir, "skills") + await symlinkSkills(skills, skillsDir) + } + + // Merge MCP servers into settings.json + if (Object.keys(mcpServers).length > 0) { + const settingsPath = path.join(geminiDir, "settings.json") + let existing: Record = {} + if (await pathExists(settingsPath)) { + await backupFile(settingsPath) + try { + existing = await readJson>(settingsPath) + } catch { + console.warn("Warning: existing settings.json could not be parsed and will be replaced.") + } + } + + const existingMcp = (existing.mcpServers && typeof existing.mcpServers === "object") + ? existing.mcpServers as Record + : {} + + const merged = { ...existing, mcpServers: { ...existingMcp, ...convertMcpServers(mcpServers) } } + await writeJson(settingsPath, merged) + } +} + +function convertMcpServers(servers: Record) { + const result: Record> = {} + for (const [name, server] of Object.entries(servers)) { + const entry: Record = {} + if (server.command) { + entry.command = server.command + if (server.args?.length) 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 +} +``` + +**Update `src/commands/sync.ts`:** + +- Add `"gemini"` to `validTargets` array +- Import `syncToGemini` from `../sync/gemini` +- Add case in switch for `"gemini"` calling `syncToGemini(skills, mcpServers, outputRoot)` + +### Phase 3: Wire `--to all` into Install and Convert + +**Modify `src/commands/install.ts`:** + +```typescript +import { detectInstalledTools } from "../utils/detect-tools" + +// In args definition, update --to description: +to: { + type: "string", + default: "opencode", + description: "Target format (opencode | codex | droid | cursor | pi | gemini | all)", +}, + +// In run(), before the existing target lookup: +if (targetName === "all") { + const detected = await detectInstalledTools() + const activeTargets = detected.filter((t) => t.detected) + + if (activeTargets.length === 0) { + console.log("No AI coding tools detected. Install at least one tool first.") + return + } + + console.log(`Detected ${activeTargets.length} tools:`) + for (const tool of detected) { + console.log(` ${tool.detected ? "✓" : "✗"} ${tool.name} — ${tool.reason}`) + } + + // Install to each detected target + for (const tool of activeTargets) { + const handler = targets[tool.name] + const bundle = handler.convert(plugin, options) + if (!bundle) continue + const root = resolveTargetOutputRoot(tool.name, outputRoot, codexHome, piHome, hasExplicitOutput) + await handler.write(root, bundle) + console.log(`Installed ${plugin.manifest.name} to ${tool.name} at ${root}`) + } + + // Codex post-processing + if (activeTargets.some((t) => t.name === "codex")) { + await ensureCodexAgentsFile(codexHome) + } + return +} +``` + +**Same change in `src/commands/convert.ts`** with its version of `resolveTargetOutputRoot`. + +### Phase 4: Wire `--target all` into Sync + +**Modify `src/commands/sync.ts`:** + +```typescript +import { detectInstalledTools } from "../utils/detect-tools" + +// Update validTargets: +const validTargets = ["opencode", "codex", "pi", "droid", "cursor", "gemini", "all"] as const + +// In run(), handle "all": +if (targetName === "all") { + const detected = await detectInstalledTools() + const activeTargets = detected.filter((t) => t.detected).map((t) => t.name) + + if (activeTargets.length === 0) { + console.log("No AI coding tools detected.") + return + } + + console.log(`Syncing to ${activeTargets.length} detected tools...`) + for (const name of activeTargets) { + // call existing sync logic for each target + } + return +} +``` + +### Phase 5: Tests + +**Create `tests/detect-tools.test.ts`** + +- Test detection with mocked directories (create temp dirs, check detection) +- Test `getDetectedTargetNames` returns only detected tools +- Test empty detection returns empty array + +**Create `tests/gemini-sync.test.ts`** + +Follow `tests/sync-cursor.test.ts` pattern: + +- Test skills are symlinked to `.gemini/skills/` +- Test MCP servers merged into `settings.json` +- Test existing `settings.json` is backed up +- Test empty skills/servers produce no output + +**Update `tests/cli.test.ts`** + +- Test `--to all` flag is accepted +- Test `sync --target all` is accepted +- Test `sync --target gemini` is accepted + +### Phase 6: Documentation + +**Update `README.md`:** + +Add to install section: +```bash +# auto-detect installed tools and install to all +bunx @every-env/compound-plugin install compound-engineering --to all +``` + +Add to sync section: +```bash +# Sync to Gemini +bunx @every-env/compound-plugin sync --target gemini + +# Sync to all detected tools +bunx @every-env/compound-plugin sync --target all +``` + +## What We're NOT Doing + +- Not adding binary detection (`which cursor`, `which gemini`) — directory checks are sufficient and don't require shell execution +- Not adding interactive prompts ("Install to Cursor? y/n") — auto-detect is fire-and-forget +- Not adding `--exclude` flag for skipping specific targets — can use `--to X --also Y` for manual selection +- Not adding Gemini to the `sync` symlink watcher (no watcher exists for any target) + +## Complexity Assessment + +**Low-medium change.** All patterns are established: +- Detection utility is new but simple (pathExists checks) +- Gemini sync follows cursor sync pattern exactly +- `--to all` is plumbing — iterate detected tools through existing handlers +- No new dependencies needed + +## References + +- Cursor sync (reference pattern): `src/sync/cursor.ts` +- Gemini writer (merge pattern): `src/targets/gemini.ts` +- Install command: `src/commands/install.ts` +- Sync command: `src/commands/sync.ts` +- File utilities: `src/utils/files.ts` +- Symlink utilities: `src/utils/symlink.ts` + +## Completion Summary + +### What Was Delivered +- Tool detection utility (`src/utils/detect-tools.ts`) with `detectInstalledTools()` and `getDetectedTargetNames()` +- Gemini sync (`src/sync/gemini.ts`) following cursor sync pattern — symlinks skills, merges MCP servers into `settings.json` +- `install --to all` and `convert --to all` auto-detect and install to all detected tools +- `sync --target gemini` added to sync command +- `sync --target all` syncs to all detected tools with summary output +- 8 new tests across 2 test files (detect-tools + sync-gemini) + +### Implementation Statistics +- 4 new files, 3 modified files +- 139 tests passing (8 new + 131 existing) +- No new dependencies + +### Git Commits +- `e4d730d` feat: add detect-tools utility and Gemini sync with tests +- `bc655f7` feat: wire --to all into install/convert and --target all/gemini into sync +- `877e265` docs: add auto-detect and Gemini sync to README, bump to 0.8.0 + +### Completion Details +- **Completed By:** Claude Opus 4.6 +- **Date:** 2026-02-14 +- **Session:** Single session, TDD approach