feat: auto-detect install targets, Gemini sync, and --target all (v0.12.0)
feat: auto-detect install targets and add Gemini sync
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -5,6 +5,17 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.12.0] - 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Auto-detect install targets** — `install --to all` and `convert --to all` auto-detect installed AI coding tools and install to all of them in one command
|
||||||
|
- **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.11.0] - 2026-03-01
|
## [0.11.0] - 2026-03-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
41
README.md
41
README.md
@@ -55,6 +55,9 @@ bunx @every-env/compound-plugin install compound-engineering --to windsurf --sco
|
|||||||
|
|
||||||
# convert to Qwen Code format
|
# convert to Qwen Code format
|
||||||
bunx @every-env/compound-plugin install compound-engineering --to qwen
|
bunx @every-env/compound-plugin install compound-engineering --to qwen
|
||||||
|
|
||||||
|
# auto-detect installed tools and install to all
|
||||||
|
bunx @every-env/compound-plugin install compound-engineering --to all
|
||||||
```
|
```
|
||||||
|
|
||||||
Local dev:
|
Local dev:
|
||||||
@@ -63,24 +66,34 @@ Local dev:
|
|||||||
bun run src/index.ts install ./plugins/compound-engineering --to opencode
|
bun run src/index.ts install ./plugins/compound-engineering --to opencode
|
||||||
```
|
```
|
||||||
|
|
||||||
OpenCode output is written to `~/.config/opencode` by default. Command are written as individual `.md` files to `~/.config/opencode/commands/<name>.md`. Agent, skills, and plugins are written to the corresponding subdirectory alongside. `opencode.json` (MCP servers) is deep-merged into any existing file -- user keys such as `model`, `theme`, and `provider` are preserved, and user values win on conflicts. Command files are backed up before being overwritten.
|
<details>
|
||||||
Codex output is written to `~/.codex/prompts` and `~/.codex/skills`, with each Claude command converted into both a prompt and a skill (the prompt instructs Codex to load the corresponding skill). Generated Codex skill descriptions are truncated to 1024 characters (Codex limit).
|
<summary>Output format details per target</summary>
|
||||||
Droid output is written to `~/.factory/` with commands, droids (agents), and skills. Claude tool names are mapped to Factory equivalents (`Bash` → `Execute`, `Write` → `Create`, etc.) and namespace prefixes are stripped from commands.
|
|
||||||
Pi output is written to `~/.pi/agent/` by default with prompts, skills, extensions, and `compound-engineering/mcporter.json` for MCPorter interoperability.
|
| Target | Output path | Notes |
|
||||||
Gemini output is written to `.gemini/` with skills (from agents), commands (`.toml`), and `settings.json` (MCP servers). Namespaced commands create directory structure (`workflows:plan` → `commands/workflows/plan.toml`). Skills use the identical SKILL.md standard and pass through unchanged.
|
|--------|------------|-------|
|
||||||
Copilot output is written to `.github/` with agents (`.agent.md`), skills (`SKILL.md`), and `copilot-mcp-config.json`. Agents get Copilot frontmatter (`description`, `tools: ["*"]`, `infer: true`), commands are converted to agent skills, and MCP server env vars are prefixed with `COPILOT_MCP_`.
|
| `opencode` | `~/.config/opencode/` | Commands as `.md` files; `opencode.json` MCP config deep-merged; backups made before overwriting |
|
||||||
Kiro output is written to `.kiro/` with custom agents (`.json` configs + prompt `.md` files), skills (from commands), pass-through skills, steering files (from CLAUDE.md), and `mcp.json`. Agents get `includeMcpJson: true` for MCP server access. Only stdio MCP servers are supported (HTTP servers are skipped with a warning).
|
| `codex` | `~/.codex/prompts` + `~/.codex/skills` | Each command becomes a prompt + skill pair; descriptions truncated to 1024 chars |
|
||||||
OpenClaw output is written to `~/.openclaw/extensions/compound-engineering/` by default with `openclaw-extension.json` (extension config + MCP servers), `OPENCLAW.md` (context), an entry-point TypeScript skill file, agents (`.md`), commands (`.md`), and pass-through skills.
|
| `droid` | `~/.factory/` | Tool names mapped (`Bash`→`Execute`, `Write`→`Create`); namespace prefixes stripped |
|
||||||
Windsurf output defaults to global scope (`~/.codeium/windsurf/`). Claude agents become Windsurf skills (`skills/{name}/SKILL.md`), commands become flat workflows (`global_workflows/{name}.md` for global scope, `workflows/{name}.md` for workspace), and pass-through skills copy unchanged. MCP servers write to `mcp_config.json` (machine-readable, merged with existing config). Use `--scope workspace` for project-level output (`.windsurf/`).
|
| `pi` | `~/.pi/agent/` | Prompts, skills, extensions, and `mcporter.json` for MCPorter interoperability |
|
||||||
Qwen output is written to `~/.qwen/extensions/compound-engineering/` by default with `qwen-extension.json` (MCP servers), `QWEN.md` (context), agents (`.yaml`), commands (`.md`), and skills. Claude tool names are passed through unchanged. MCP server environment variables with placeholder values are extracted as settings in `qwen-extension.json`. Nested commands use colon separator (`workflows:plan` → `commands/workflows/plan.md`).
|
| `gemini` | `.gemini/` | Skills from agents; commands as `.toml`; namespaced commands become directories (`workflows:plan` → `commands/workflows/plan.toml`) |
|
||||||
|
| `copilot` | `.github/` | Agents as `.agent.md` with Copilot frontmatter; MCP env vars prefixed with `COPILOT_MCP_` |
|
||||||
|
| `kiro` | `.kiro/` | Agents as JSON configs + prompt `.md` files; only stdio MCP servers supported |
|
||||||
|
| `openclaw` | `~/.openclaw/extensions/<plugin>/` | Entry-point TypeScript skill file; `openclaw-extension.json` for MCP servers |
|
||||||
|
| `windsurf` | `~/.codeium/windsurf/` (global) or `.windsurf/` (workspace) | Agents become skills; commands become flat workflows; `mcp_config.json` merged |
|
||||||
|
| `qwen` | `~/.qwen/extensions/<plugin>/` | Agents as `.yaml`; env vars with placeholders extracted as settings; colon separator for nested commands |
|
||||||
|
|
||||||
All provider targets are experimental and may change as the formats evolve.
|
All provider targets are experimental and may change as the formats evolve.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Sync Personal Config
|
## Sync Personal Config
|
||||||
|
|
||||||
Sync your personal Claude Code config (`~/.claude/`) to other AI coding tools:
|
Sync your personal Claude Code config (`~/.claude/`) to other AI coding tools. Omit `--target` to sync to all detected tools automatically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Sync to all detected tools (default)
|
||||||
|
bunx @every-env/compound-plugin sync
|
||||||
|
|
||||||
# Sync skills and MCP servers to OpenCode
|
# Sync skills and MCP servers to OpenCode
|
||||||
bunx @every-env/compound-plugin sync --target opencode
|
bunx @every-env/compound-plugin sync --target opencode
|
||||||
|
|
||||||
@@ -95,6 +108,12 @@ bunx @every-env/compound-plugin sync --target droid
|
|||||||
|
|
||||||
# Sync to GitHub Copilot (skills + MCP servers)
|
# Sync to GitHub Copilot (skills + MCP servers)
|
||||||
bunx @every-env/compound-plugin sync --target copilot
|
bunx @every-env/compound-plugin sync --target copilot
|
||||||
|
|
||||||
|
# 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:
|
This syncs:
|
||||||
|
|||||||
@@ -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 <target>` 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<DetectedTool[]> {
|
||||||
|
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<string[]> {
|
||||||
|
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<string, ClaudeMcpServer>,
|
||||||
|
outputRoot: string,
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, unknown> = {}
|
||||||
|
if (await pathExists(settingsPath)) {
|
||||||
|
await backupFile(settingsPath)
|
||||||
|
try {
|
||||||
|
existing = await readJson<Record<string, unknown>>(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<string, unknown>
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const merged = { ...existing, mcpServers: { ...existingMcp, ...convertMcpServers(mcpServers) } }
|
||||||
|
await writeJson(settingsPath, merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertMcpServers(servers: Record<string, ClaudeMcpServer>) {
|
||||||
|
const result: Record<string, Record<string, unknown>> = {}
|
||||||
|
for (const [name, server] of Object.entries(servers)) {
|
||||||
|
const entry: Record<string, unknown> = {}
|
||||||
|
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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@every-env/compound-plugin",
|
"name": "@every-env/compound-plugin",
|
||||||
"version": "0.9.1",
|
"version": "0.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": false,
|
"private": false,
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { PermissionMode } from "../converters/claude-to-opencode"
|
|||||||
import { ensureCodexAgentsFile } from "../utils/codex-agents"
|
import { ensureCodexAgentsFile } from "../utils/codex-agents"
|
||||||
import { expandHome, resolveTargetHome } from "../utils/resolve-home"
|
import { expandHome, resolveTargetHome } from "../utils/resolve-home"
|
||||||
import { resolveTargetOutputRoot } from "../utils/resolve-output"
|
import { resolveTargetOutputRoot } from "../utils/resolve-output"
|
||||||
|
import { detectInstalledTools } from "../utils/detect-tools"
|
||||||
|
|
||||||
const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"]
|
const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"]
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ export default defineCommand({
|
|||||||
to: {
|
to: {
|
||||||
type: "string",
|
type: "string",
|
||||||
default: "opencode",
|
default: "opencode",
|
||||||
description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro | windsurf | openclaw | qwen)",
|
description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro | windsurf | openclaw | qwen | all)",
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -77,22 +78,12 @@ export default defineCommand({
|
|||||||
},
|
},
|
||||||
async run({ args }) {
|
async run({ args }) {
|
||||||
const targetName = String(args.to)
|
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)
|
const permissions = String(args.permissions)
|
||||||
if (!permissionModes.includes(permissions as PermissionMode)) {
|
if (!permissionModes.includes(permissions as PermissionMode)) {
|
||||||
throw new Error(`Unknown permissions mode: ${permissions}`)
|
throw new Error(`Unknown permissions mode: ${permissions}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedScope = validateScope(targetName, target, args.scope ? String(args.scope) : undefined)
|
|
||||||
|
|
||||||
const plugin = await loadClaudePlugin(String(args.source))
|
const plugin = await loadClaudePlugin(String(args.source))
|
||||||
const outputRoot = resolveOutputRoot(args.output)
|
const outputRoot = resolveOutputRoot(args.output)
|
||||||
const hasExplicitOutput = Boolean(args.output && String(args.output).trim())
|
const hasExplicitOutput = Boolean(args.output && String(args.output).trim())
|
||||||
@@ -107,6 +98,62 @@ export default defineCommand({
|
|||||||
permissions: permissions as PermissionMode,
|
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({
|
||||||
|
targetName: tool.name,
|
||||||
|
outputRoot,
|
||||||
|
codexHome,
|
||||||
|
piHome,
|
||||||
|
openclawHome,
|
||||||
|
qwenHome,
|
||||||
|
pluginName: plugin.manifest.name,
|
||||||
|
hasExplicitOutput,
|
||||||
|
})
|
||||||
|
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 resolvedScope = validateScope(targetName, target, args.scope ? String(args.scope) : undefined)
|
||||||
|
|
||||||
const primaryOutputRoot = resolveTargetOutputRoot({
|
const primaryOutputRoot = resolveTargetOutputRoot({
|
||||||
targetName,
|
targetName,
|
||||||
outputRoot,
|
outputRoot,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { PermissionMode } from "../converters/claude-to-opencode"
|
|||||||
import { ensureCodexAgentsFile } from "../utils/codex-agents"
|
import { ensureCodexAgentsFile } from "../utils/codex-agents"
|
||||||
import { expandHome, resolveTargetHome } from "../utils/resolve-home"
|
import { expandHome, resolveTargetHome } from "../utils/resolve-home"
|
||||||
import { resolveTargetOutputRoot } from "../utils/resolve-output"
|
import { resolveTargetOutputRoot } from "../utils/resolve-output"
|
||||||
|
import { detectInstalledTools } from "../utils/detect-tools"
|
||||||
|
|
||||||
const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"]
|
const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"]
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ export default defineCommand({
|
|||||||
to: {
|
to: {
|
||||||
type: "string",
|
type: "string",
|
||||||
default: "opencode",
|
default: "opencode",
|
||||||
description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro | windsurf | openclaw | qwen)",
|
description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro | windsurf | openclaw | qwen | all)",
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -79,21 +80,12 @@ export default defineCommand({
|
|||||||
},
|
},
|
||||||
async run({ args }) {
|
async run({ args }) {
|
||||||
const targetName = String(args.to)
|
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)
|
const permissions = String(args.permissions)
|
||||||
if (!permissionModes.includes(permissions as PermissionMode)) {
|
if (!permissionModes.includes(permissions as PermissionMode)) {
|
||||||
throw new Error(`Unknown permissions mode: ${permissions}`)
|
throw new Error(`Unknown permissions mode: ${permissions}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedScope = validateScope(targetName, target, args.scope ? String(args.scope) : undefined)
|
|
||||||
|
|
||||||
const resolvedPlugin = await resolvePluginPath(String(args.plugin))
|
const resolvedPlugin = await resolvePluginPath(String(args.plugin))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -101,6 +93,7 @@ export default defineCommand({
|
|||||||
const outputRoot = resolveOutputRoot(args.output)
|
const outputRoot = resolveOutputRoot(args.output)
|
||||||
const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex"))
|
const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex"))
|
||||||
const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent"))
|
const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent"))
|
||||||
|
const hasExplicitOutput = Boolean(args.output && String(args.output).trim())
|
||||||
const openclawHome = resolveTargetHome(args.openclawHome, path.join(os.homedir(), ".openclaw", "extensions"))
|
const openclawHome = resolveTargetHome(args.openclawHome, path.join(os.homedir(), ".openclaw", "extensions"))
|
||||||
const qwenHome = resolveTargetHome(args.qwenHome, path.join(os.homedir(), ".qwen", "extensions"))
|
const qwenHome = resolveTargetHome(args.qwenHome, path.join(os.homedir(), ".qwen", "extensions"))
|
||||||
|
|
||||||
@@ -110,11 +103,65 @@ export default defineCommand({
|
|||||||
permissions: permissions as PermissionMode,
|
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({
|
||||||
|
targetName: tool.name,
|
||||||
|
outputRoot,
|
||||||
|
codexHome,
|
||||||
|
piHome,
|
||||||
|
openclawHome,
|
||||||
|
qwenHome,
|
||||||
|
pluginName: plugin.manifest.name,
|
||||||
|
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 resolvedScope = validateScope(targetName, target, args.scope ? String(args.scope) : undefined)
|
||||||
|
|
||||||
const bundle = target.convert(plugin, options)
|
const bundle = target.convert(plugin, options)
|
||||||
if (!bundle) {
|
if (!bundle) {
|
||||||
throw new Error(`Target ${targetName} did not return a bundle.`)
|
throw new Error(`Target ${targetName} did not return a bundle.`)
|
||||||
}
|
}
|
||||||
const hasExplicitOutput = Boolean(args.output && String(args.output).trim())
|
|
||||||
const primaryOutputRoot = resolveTargetOutputRoot({
|
const primaryOutputRoot = resolveTargetOutputRoot({
|
||||||
targetName,
|
targetName,
|
||||||
outputRoot,
|
outputRoot,
|
||||||
|
|||||||
@@ -7,17 +7,19 @@ import { syncToCodex } from "../sync/codex"
|
|||||||
import { syncToPi } from "../sync/pi"
|
import { syncToPi } from "../sync/pi"
|
||||||
import { syncToDroid } from "../sync/droid"
|
import { syncToDroid } from "../sync/droid"
|
||||||
import { syncToCopilot } from "../sync/copilot"
|
import { syncToCopilot } from "../sync/copilot"
|
||||||
|
import { syncToGemini } from "../sync/gemini"
|
||||||
import { expandHome } from "../utils/resolve-home"
|
import { expandHome } from "../utils/resolve-home"
|
||||||
import { hasPotentialSecrets } from "../utils/secrets"
|
import { hasPotentialSecrets } from "../utils/secrets"
|
||||||
|
import { detectInstalledTools } from "../utils/detect-tools"
|
||||||
|
|
||||||
const validTargets = ["opencode", "codex", "pi", "droid", "copilot"] as const
|
const validTargets = ["opencode", "codex", "pi", "droid", "copilot", "gemini", "all"] as const
|
||||||
type SyncTarget = (typeof validTargets)[number]
|
type SyncTarget = (typeof validTargets)[number]
|
||||||
|
|
||||||
function isValidTarget(value: string): value is SyncTarget {
|
function isValidTarget(value: string): value is SyncTarget {
|
||||||
return (validTargets as readonly string[]).includes(value)
|
return (validTargets as readonly string[]).includes(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveOutputRoot(target: SyncTarget): string {
|
function resolveOutputRoot(target: string): string {
|
||||||
switch (target) {
|
switch (target) {
|
||||||
case "opencode":
|
case "opencode":
|
||||||
return path.join(os.homedir(), ".config", "opencode")
|
return path.join(os.homedir(), ".config", "opencode")
|
||||||
@@ -29,19 +31,46 @@ function resolveOutputRoot(target: SyncTarget): string {
|
|||||||
return path.join(os.homedir(), ".factory")
|
return path.join(os.homedir(), ".factory")
|
||||||
case "copilot":
|
case "copilot":
|
||||||
return path.join(process.cwd(), ".github")
|
return path.join(process.cwd(), ".github")
|
||||||
|
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<ReturnType<typeof loadClaudeHome>>, outputRoot: string): Promise<void> {
|
||||||
|
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 "copilot":
|
||||||
|
await syncToCopilot(config, outputRoot)
|
||||||
|
break
|
||||||
|
case "gemini":
|
||||||
|
await syncToGemini(config, outputRoot)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineCommand({
|
export default defineCommand({
|
||||||
meta: {
|
meta: {
|
||||||
name: "sync",
|
name: "sync",
|
||||||
description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, Pi, Droid, or Copilot",
|
description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, Pi, Droid, Copilot, or Gemini",
|
||||||
},
|
},
|
||||||
args: {
|
args: {
|
||||||
target: {
|
target: {
|
||||||
type: "string",
|
type: "string",
|
||||||
required: true,
|
default: "all",
|
||||||
description: "Target: opencode | codex | pi | droid | copilot",
|
description: "Target: opencode | codex | pi | droid | copilot | gemini | all (default: all)",
|
||||||
},
|
},
|
||||||
claudeHome: {
|
claudeHome: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -65,30 +94,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(
|
console.log(
|
||||||
`Syncing ${config.skills.length} skills, ${Object.keys(config.mcpServers).length} MCP servers...`,
|
`Syncing ${config.skills.length} skills, ${Object.keys(config.mcpServers).length} MCP servers...`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const outputRoot = resolveOutputRoot(args.target)
|
const outputRoot = resolveOutputRoot(args.target)
|
||||||
|
await syncTarget(args.target, config, outputRoot)
|
||||||
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 "copilot":
|
|
||||||
await syncToCopilot(config, outputRoot)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✓ Synced to ${args.target}: ${outputRoot}`)
|
console.log(`✓ Synced to ${args.target}: ${outputRoot}`)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
76
src/sync/gemini.ts
Normal file
76
src/sync/gemini.ts
Normal file
@@ -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<string, string>
|
||||||
|
headers?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncToGemini(
|
||||||
|
config: ClaudeHomeConfig,
|
||||||
|
outputRoot: string,
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, unknown>)
|
||||||
|
: {}
|
||||||
|
const merged = {
|
||||||
|
...existing,
|
||||||
|
mcpServers: { ...existingMcp, ...converted },
|
||||||
|
}
|
||||||
|
await fs.writeFile(settingsPath, JSON.stringify(merged, null, 2), { mode: 0o600 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonSafe(filePath: string): Promise<Record<string, unknown>> {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(filePath, "utf-8")
|
||||||
|
return JSON.parse(content) as Record<string, unknown>
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertMcpForGemini(
|
||||||
|
servers: Record<string, ClaudeMcpServer>,
|
||||||
|
): Record<string, GeminiMcpServer> {
|
||||||
|
const result: Record<string, GeminiMcpServer> = {}
|
||||||
|
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
|
||||||
|
}
|
||||||
46
src/utils/detect-tools.ts
Normal file
46
src/utils/detect-tools.ts
Normal file
@@ -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<DetectedTool[]> {
|
||||||
|
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<string[]> {
|
||||||
|
const tools = await detectInstalledTools(home, cwd)
|
||||||
|
return tools.filter((t) => t.detected).map((t) => t.name)
|
||||||
|
}
|
||||||
96
tests/detect-tools.test.ts
Normal file
96
tests/detect-tools.test.ts
Normal file
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
106
tests/sync-gemini.test.ts
Normal file
106
tests/sync-gemini.test.ts
Normal file
@@ -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<string, { url?: string; command?: string; args?: string[]; env?: Record<string, string> }>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, { command?: string; url?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user