feat(sync): add Claude home sync parity across providers

This commit is contained in:
Kieran Klaassen
2026-03-02 21:02:21 -08:00
parent 1a0ddb9de1
commit 168c946033
38 changed files with 2323 additions and 307 deletions

View File

@@ -5,6 +5,24 @@ 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.13.0] - 2026-03-03
### Added
- **Sync parity across supported providers** — `sync` now uses a shared target registry and supports MCP sync for Codex, Droid, Gemini, Copilot, Pi, Windsurf, Kiro, and Qwen, with OpenClaw kept validation-gated for skills-only sync.
- **Personal command sync** — Personal Claude commands from `~/.claude/commands/` now sync into provider-native command surfaces, including Codex prompts and generated skills, Gemini TOML commands, OpenCode command markdown, Windsurf workflows, and converted skills where that is the closest available equivalent.
### Changed
- **Global user config targets** — Copilot sync now writes to `~/.copilot/` and Gemini sync writes to `~/.gemini/`, matching current documented user-level config locations.
- **Gemini skill deduplication** — Gemini sync now avoids mirroring skills that Gemini already resolves from `~/.agents/skills`, preventing duplicate skill conflict warnings after sync.
### Fixed
- **Safe skill sync replacement** — When a real directory already exists at a symlink target (for example `~/.config/opencode/skills/proof`), sync now logs a warning and skips instead of throwing an error.
---
## [0.12.0] - 2026-03-01 ## [0.12.0] - 2026-03-01
### Added ### Added

View File

@@ -88,7 +88,7 @@ All provider targets are experimental and may change as the formats evolve.
## Sync Personal Config ## Sync Personal Config
Sync your personal Claude Code config (`~/.claude/`) to other AI coding tools. Omit `--target` to sync to all detected tools automatically: Sync your personal Claude Code config (`~/.claude/`) to other AI coding tools. Omit `--target` to sync to all detected supported tools automatically:
```bash ```bash
# Sync to all detected tools (default) # Sync to all detected tools (default)
@@ -103,7 +103,7 @@ bunx @every-env/compound-plugin sync --target codex
# Sync to Pi # Sync to Pi
bunx @every-env/compound-plugin sync --target pi bunx @every-env/compound-plugin sync --target pi
# Sync to Droid (skills only) # Sync to Droid
bunx @every-env/compound-plugin sync --target droid bunx @every-env/compound-plugin sync --target droid
# Sync to GitHub Copilot (skills + MCP servers) # Sync to GitHub Copilot (skills + MCP servers)
@@ -112,16 +112,49 @@ bunx @every-env/compound-plugin sync --target copilot
# Sync to Gemini (skills + MCP servers) # Sync to Gemini (skills + MCP servers)
bunx @every-env/compound-plugin sync --target gemini bunx @every-env/compound-plugin sync --target gemini
# Sync to Windsurf
bunx @every-env/compound-plugin sync --target windsurf
# Sync to Kiro
bunx @every-env/compound-plugin sync --target kiro
# Sync to Qwen
bunx @every-env/compound-plugin sync --target qwen
# Sync to OpenClaw (skills only; MCP is validation-gated)
bunx @every-env/compound-plugin sync --target openclaw
# Sync to all detected tools # Sync to all detected tools
bunx @every-env/compound-plugin sync --target all bunx @every-env/compound-plugin sync --target all
``` ```
This syncs: This syncs:
- Personal skills from `~/.claude/skills/` (as symlinks) - Personal skills from `~/.claude/skills/` (as symlinks)
- Personal slash commands from `~/.claude/commands/` (as provider-native prompts, workflows, or converted skills where supported)
- MCP servers from `~/.claude/settings.json` - MCP servers from `~/.claude/settings.json`
Skills are symlinked (not copied) so changes in Claude Code are reflected immediately. Skills are symlinked (not copied) so changes in Claude Code are reflected immediately.
Supported sync targets:
- `opencode`
- `codex`
- `pi`
- `droid`
- `copilot`
- `gemini`
- `windsurf`
- `kiro`
- `qwen`
- `openclaw`
Notes:
- Codex sync preserves non-managed `config.toml` content and now includes remote MCP servers.
- Command sync reuses each provider's existing Claude command conversion, so some targets receive prompts or workflows while others receive converted skills.
- Copilot sync writes personal skills to `~/.copilot/skills/` and MCP config to `~/.copilot/mcp-config.json`.
- Gemini sync writes MCP config to `~/.gemini/` and avoids mirroring skills that Gemini already discovers from `~/.agents/skills`, which prevents duplicate-skill warnings.
- Droid, Windsurf, Kiro, and Qwen sync merge MCP servers into the provider's documented user config.
- OpenClaw currently syncs skills only. Personal command sync is skipped because this repo does not yet have a documented user-level OpenClaw command surface, and MCP sync is skipped because the current official OpenClaw docs do not clearly document an MCP server config contract.
## Workflow ## Workflow
``` ```

View File

@@ -0,0 +1,639 @@
---
title: "feat: Sync Claude MCP servers to all supported providers"
type: feat
date: 2026-03-03
status: completed
deepened: 2026-03-03
---
# feat: Sync Claude MCP servers to all supported providers
## Overview
Expand the `sync` command so a user's local Claude Code MCP configuration can be propagated to every provider this CLI can reasonably support, instead of only the current partial set.
Today, `sync` already symlinks Claude skills and syncs MCP servers for a subset of targets. The gap is that install/convert support has grown much faster than sync support, so the product promise in `README.md` has drifted away from what `src/commands/sync.ts` can actually do.
This feature should close that parity gap without changing the core sync contract:
- Claude remains the source of truth for personal skills and MCP servers.
- Skills stay symlinked, not copied.
- Existing user config in the destination tool is preserved where possible.
- Target-specific MCP formats stay target-specific.
## Problem Statement
The current implementation has three concrete problems:
1. `sync` only knows about `opencode`, `codex`, `pi`, `droid`, `copilot`, and `gemini`, while install/convert now supports `kiro`, `windsurf`, `openclaw`, and `qwen` too.
2. `sync --target all` relies on stale detection metadata that still includes `cursor`, but misses newer supported tools.
3. Existing MCP sync support is incomplete even for some already-supported targets:
- `codex` only emits stdio servers and silently drops remote MCP servers.
- `droid` is still skills-only even though Factory now documents `mcp.json`.
User impact:
- A user can install the plugin to more providers than they can sync their personal Claude setup to.
- `sync --target all` does not mean "all supported tools" anymore.
- Users with remote MCP servers in Claude get partial results depending on target.
## Research Summary
### No Relevant Brainstorm
I checked recent brainstorms in `docs/brainstorms/` and found no relevant document for this feature within the last 14 days.
### Internal Findings
- `src/commands/sync.ts:15-125` hardcodes the sync target list, output roots, and per-target dispatch. It omits `windsurf`, `kiro`, `openclaw`, and `qwen`.
- `src/utils/detect-tools.ts:15-22` still detects `cursor`, but not `windsurf`, `kiro`, `openclaw`, or `qwen`.
- `src/parsers/claude-home.ts:11-19` already gives sync exactly the right inputs: personal skills plus `settings.json` `mcpServers`.
- `src/sync/codex.ts:25-91` only serializes stdio MCP servers, even though Codex supports remote MCP config.
- `src/sync/droid.ts:6-21` symlinks skills but ignores MCP entirely.
- Target writers already encode several missing MCP formats and merge behaviors:
- `src/targets/windsurf.ts:65-92`
- `src/targets/kiro.ts:68-91`
- `src/targets/openclaw.ts:34-42`
- `src/targets/qwen.ts:9-15`
- `README.md:89-123` promises "Sync Personal Config" but only documents the old subset of targets.
### Institutional Learnings
`docs/solutions/adding-converter-target-providers.md:20-32` and `docs/solutions/adding-converter-target-providers.md:208-214` reinforce the right pattern for this feature:
- keep target mappings explicit,
- treat MCP conversion as target-specific,
- warn on unsupported features instead of forcing fake parity,
- and add tests for each mapping.
Note: `docs/solutions/patterns/critical-patterns.md` does not exist in this repository, so there was no critical-patterns file to apply.
### External Findings
Official docs confirm that the missing targets are not all equivalent, so this cannot be solved with a generic JSON pass-through.
| Target | Official MCP / skills location | Key notes |
| --- | --- | --- |
| Factory Droid | `~/.factory/mcp.json`, `.factory/mcp.json`, `~/.factory/skills/` | Supports `stdio` and `http`; user config overrides project config. |
| Windsurf | `~/.codeium/windsurf/mcp_config.json`, `~/.codeium/windsurf/skills/` | Supports `stdio`, Streamable HTTP, and SSE; remote config uses `serverUrl` or `url`. |
| Kiro | `~/.kiro/settings/mcp.json`, `.kiro/settings/mcp.json`, `~/.kiro/skills/` | Supports user and workspace config; remote MCP support was added after this repo's local Kiro spec was written. |
| Qwen Code | `~/.qwen/settings.json`, `.qwen/settings.json`, `~/.qwen/skills/`, `.qwen/skills/` | Supports `stdio`, `http`, and `sse`; official docs say prefer `http`, with `sse` treated as legacy/deprecated. |
| OpenClaw | `~/.openclaw/skills`, `<workspace>/skills`, `~/.openclaw/openclaw.json` | Skills are well-documented; a generic MCP server config surface is not clearly documented in official docs, so MCP sync needs validation before implementation is promised. |
Additional important findings:
- Kiro's current official behavior supersedes the local repo spec that says "workspace only" and "stdio only".
- Qwen's current docs explicitly distinguish `httpUrl` from legacy SSE `url`; blindly copying Claude's `url` is too lossy.
- Factory and Windsurf both support remote MCP, so `droid` should no longer be treated as skills-only.
## Proposed Solution
### Product Decision
Treat this as **sync parity for MCP-capable providers**, not as a one-off patch.
That means this feature should:
- add missing sync targets where the provider has a documented skills/MCP surface,
- upgrade partial implementations where existing sync support drops valid Claude MCP data,
- and replace stale detection metadata so `sync --target all` is truthful again.
### Scope
#### In Scope
- Add MCP sync coverage for:
- `droid`
- `windsurf`
- `kiro`
- `qwen`
- Expand `codex` sync to support remote MCP servers.
- Add provider detection for newly supported sync targets.
- Keep skills syncing for all synced targets.
- Update CLI help text, README sync docs, and tests.
#### Conditional / Validation Gate
- `openclaw` skills sync is straightforward and should be included if the target is added to `sync`.
- `openclaw` MCP sync should only be implemented if its config surface is validated against current upstream docs or current upstream source. If that validation fails, the feature should explicitly skip OpenClaw MCP sync with a warning rather than inventing a format.
#### Out of Scope
- Standardizing all existing sync targets onto user-level paths only.
- Reworking install/convert output roots.
- Hook sync.
- A full rewrite of target writers.
### Design Decisions
#### 0. Keep existing sync roots stable unless this feature is explicitly adding a new target
Do not use this feature to migrate existing `copilot` and `gemini` sync behavior.
Backward-compatibility rule:
- existing targets keep their current sync roots unless a correctness bug forces a change,
- newly added sync targets use the provider's documented personal/global config surface,
- and any future root migration belongs in a separate plan.
Planned sync roots after this feature:
| Target | Sync root | Notes |
| --- | --- | --- |
| `opencode` | `~/.config/opencode` | unchanged |
| `codex` | `~/.codex` | unchanged |
| `pi` | `~/.pi/agent` | unchanged |
| `droid` | `~/.factory` | unchanged root, new MCP file |
| `copilot` | `.github` | unchanged for backwards compatibility |
| `gemini` | `.gemini` | unchanged for backwards compatibility |
| `windsurf` | `~/.codeium/windsurf` | new |
| `kiro` | `~/.kiro` | new |
| `qwen` | `~/.qwen` | new |
| `openclaw` | `~/.openclaw` | new, MCP still validation-gated |
#### 1. Add a dedicated sync target registry
Do not keep growing `sync.ts` as a hand-maintained switch statement.
Create a dedicated sync registry, for example:
### `src/sync/registry.ts`
```ts
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
export type SyncTargetDefinition = {
name: string
detectPaths: (home: string, cwd: string) => string[]
resolveOutputRoot: (home: string, cwd: string) => string
sync: (config: ClaudeHomeConfig, outputRoot: string) => Promise<void>
}
```
This registry becomes the single source of truth for:
- valid `sync` targets,
- `sync --target all` detection,
- output root resolution,
- and dispatch.
This avoids the current drift between:
- `src/commands/sync.ts`
- `src/utils/detect-tools.ts`
- `README.md`
#### 2. Preserve sync semantics, not writer semantics
Do not directly reuse install target writers for sync.
Reason:
- writers mostly copy skill directories,
- sync intentionally symlinks skills,
- writers often emit full plugin/install bundles,
- sync only needs personal skills plus MCP config.
However, provider-specific MCP conversion helpers should be extracted or reused where practical so sync and writer logic do not diverge again.
#### 3. Keep merge behavior additive, with Claude winning on same-name collisions
For JSON-based targets:
- preserve unrelated user keys,
- preserve unrelated user MCP servers,
- but if the same server name exists in Claude and the target config, Claude's value should overwrite that server entry during sync.
Codex remains the special case:
- continue using the managed marker block,
- remove the previous managed block,
- rewrite the managed block from Claude,
- leave the rest of `config.toml` untouched.
#### 4. Secure config writes where secrets may exist
Any config file that may contain MCP headers or env vars should be written with restrictive permissions where the platform already supports that pattern.
At minimum:
- `config.toml`
- `mcp.json`
- `mcp_config.json`
- `settings.json`
should follow the repo's existing "secure write" conventions where possible.
#### 5. Do not silently coerce ambiguous remote transports
Qwen and possibly future targets distinguish Streamable HTTP from legacy SSE.
Use this mapping rule:
- if Claude explicitly provides `type: "sse"` or an equivalent known signal, map to the target's SSE field,
- otherwise prefer the target's HTTP form for remote URLs,
- and log a warning when a target requires more specificity than Claude provides.
## Provider Mapping Plan
### Existing Targets to Upgrade
#### Codex
Current issue:
- only stdio servers are synced.
Implementation:
- extend `syncToCodex()` so remote MCP servers are serialized into the Codex TOML format, not dropped.
- keep the existing marker-based idempotent section handling.
Notes:
- This is a correctness fix, not a new target.
#### Droid / Factory
Current issue:
- skills-only sync despite current official MCP support.
Implementation:
- add `src/sync/droid.ts` MCP config writing to `~/.factory/mcp.json`.
- merge with existing `mcpServers`.
- support both `stdio` and `http`.
### New Sync Targets
#### Windsurf
Add `src/sync/windsurf.ts`:
- symlink Claude skills into `~/.codeium/windsurf/skills/`
- merge MCP servers into `~/.codeium/windsurf/mcp_config.json`
- support `stdio`, Streamable HTTP, and SSE
- prefer `serverUrl` for remote HTTP config
- preserve unrelated existing servers
- write with secure permissions
Reference implementation:
- `src/targets/windsurf.ts:65-92`
#### Kiro
Add `src/sync/kiro.ts`:
- symlink Claude skills into `~/.kiro/skills/`
- merge MCP servers into `~/.kiro/settings/mcp.json`
- support both local and remote MCP servers
- preserve user config already present in `mcp.json`
Important:
- This feature must treat the repository's local Kiro spec as stale where it conflicts with official 2025-2026 Kiro docs/blog posts.
Reference implementation:
- `src/targets/kiro.ts:68-91`
#### Qwen
Add `src/sync/qwen.ts`:
- symlink Claude skills into `~/.qwen/skills/`
- merge MCP servers into `~/.qwen/settings.json`
- map stdio directly
- map remote URLs to `httpUrl` by default
- only emit legacy SSE `url` when Claude transport clearly indicates SSE
Important:
- capture the deprecation note in docs/comments: SSE is legacy, so HTTP is the default remote mapping.
#### OpenClaw
Add `src/sync/openclaw.ts` only if validated during implementation:
- symlink skills into `~/.openclaw/skills`
- optionally merge MCP config into `~/.openclaw/openclaw.json` if the official/current upstream contract is confirmed
Fallback behavior if MCP config cannot be validated:
- sync skills only,
- emit a warning that OpenClaw MCP sync is skipped because the official config surface is not documented clearly enough.
## Implementation Phases
### Phase 1: Registry and shared helpers
Files:
- `src/commands/sync.ts`
- `src/utils/detect-tools.ts`
- `src/sync/registry.ts` (new)
- `src/sync/skills.ts` or `src/utils/symlink.ts` extension
- optional `src/sync/mcp-merge.ts`
Tasks:
- move sync target metadata into a single registry
- make `validTargets` derive from the registry
- make `sync --target all` use the registry
- update detection to include supported sync targets instead of stale `cursor`
- extract a shared helper for validated skill symlinking
### Phase 2: Upgrade existing partial targets
Files:
- `src/sync/codex.ts`
- `src/sync/droid.ts`
- `tests/sync-droid.test.ts`
- new or expanded `tests/sync-codex.test.ts`
Tasks:
- add remote MCP support to Codex sync
- add MCP config writing to Droid sync
- preserve current skill symlink behavior
### Phase 3: Add missing sync targets
Files:
- `src/sync/windsurf.ts`
- `src/sync/kiro.ts`
- `src/sync/qwen.ts`
- optionally `src/sync/openclaw.ts`
- `tests/sync-windsurf.test.ts`
- `tests/sync-kiro.test.ts`
- `tests/sync-qwen.test.ts`
- optionally `tests/sync-openclaw.test.ts`
Tasks:
- implement skill symlink + MCP merge for each target
- align output paths with the target's documented personal config surface
- secure writes and corrupted-config fallbacks
### Phase 4: CLI, docs, and detection parity
Files:
- `src/commands/sync.ts`
- `src/utils/detect-tools.ts`
- `tests/detect-tools.test.ts`
- `tests/cli.test.ts`
- `README.md`
- optionally `docs/specs/kiro.md`
Tasks:
- update `sync` help text and summary output
- ensure `sync --target all` only reports real sync-capable tools
- document newly supported sync targets
- fix stale Kiro assumptions if repository docs are updated in the same change
## SpecFlow Analysis
### Primary user flows
#### Flow 1: Explicit sync to one target
1. User runs `bunx @every-env/compound-plugin sync --target <provider>`
2. CLI loads `~/.claude/skills` and `~/.claude/settings.json`
3. CLI resolves that provider's sync root
4. Skills are symlinked
5. MCP config is merged
6. CLI prints the destination path and completion summary
#### Flow 2: Sync to all detected tools
1. User runs `bunx @every-env/compound-plugin sync`
2. CLI detects installed/supported tools
3. CLI prints which tools were found and which were skipped
4. CLI syncs each detected target in sequence
5. CLI prints per-target success lines
#### Flow 3: Existing config already present
1. User already has destination config file(s)
2. Sync reads and parses the existing file
3. Existing unrelated keys are preserved
4. Claude MCP entries are merged in
5. Corrupt config produces a warning and replacement behavior
### Edge cases to account for
- Claude has zero MCP servers: skills still sync, no config file is written.
- Claude has remote MCP servers: targets that support remote config receive them; unsupported transports warn, not crash.
- Existing target config is invalid JSON/TOML: warn and replace the managed portion.
- Skill name contains path traversal characters: skip with warning, same as current behavior.
- Real directory already exists where a symlink would go: skip safely, do not delete user data.
- `sync --target all` detects a tool with skills support but unclear MCP support: sync only the documented subset and warn explicitly.
### Critical product decisions already assumed
- `sync` remains additive and non-destructive.
- Sync roots may differ from install roots when the provider has a documented personal config location.
- OpenClaw MCP support is validation-gated rather than assumed.
## Acceptance Criteria
### Functional Requirements
- [x] `sync --target` accepts `windsurf`, `kiro`, and `qwen`, in addition to the existing targets.
- [x] `sync --target droid` writes MCP servers to Factory's documented `mcp.json` format instead of remaining skills-only.
- [x] `sync --target codex` syncs both stdio and remote MCP servers.
- [x] `sync --target all` detects only sync-capable supported tools and includes the new targets.
- [x] Claude personal skills continue to be symlinked, not copied.
- [x] Existing destination config keys unrelated to MCP are preserved during merge.
- [x] Existing same-named MCP entries are refreshed from Claude for sync-managed targets.
- [x] Unsafe skill names are skipped without deleting user content.
- [x] If OpenClaw MCP sync is not validated, the CLI warns and skips MCP sync for OpenClaw instead of writing an invented format.
### Non-Functional Requirements
- [x] MCP config files that may contain secrets are written with restrictive permissions where supported.
- [x] Corrupt destination config files warn and recover cleanly.
- [x] New sync code does not duplicate target detection metadata in multiple places.
- [x] Remote transport mapping is explicit and tested, especially for Qwen and Codex.
### Quality Gates
- [x] Add target-level sync tests for every new or upgraded provider.
- [x] Update `tests/detect-tools.test.ts` for new detection rules and remove stale cursor expectations.
- [x] Add or expand CLI coverage for `sync --target all`.
- [x] `bun test` passes.
## Testing Plan
### Unit / integration tests
Add or expand:
- `tests/sync-codex.test.ts`
- remote URL server is emitted
- existing non-managed TOML content is preserved
- `tests/sync-droid.test.ts`
- writes `mcp.json`
- merges with existing file
- `tests/sync-windsurf.test.ts`
- writes `mcp_config.json`
- merges existing servers
- preserves HTTP/SSE fields
- `tests/sync-kiro.test.ts`
- writes `settings/mcp.json`
- supports user-scope root
- preserves remote servers
- `tests/sync-qwen.test.ts`
- writes `settings.json`
- maps remote servers to `httpUrl`
- emits legacy SSE only when explicitly indicated
- `tests/sync-openclaw.test.ts` if implemented
- skills path
- MCP behavior or explicit skip warning
### CLI tests
Expand `tests/cli.test.ts` or add focused sync CLI coverage for:
- `sync --target windsurf`
- `sync --target kiro`
- `sync --target qwen`
- `sync --target all` with detected new tool homes
- `sync --target all` no longer surfacing unsupported `cursor`
## Risks and Mitigations
### Risk: local specs are stale relative to current provider docs
Impact:
- implementing from local docs alone would produce incorrect paths and transport support.
Mitigation:
- treat official 2025-2026 docs/blog posts as source of truth where they supersede local specs
- update any obviously stale repo docs touched by this feature
### Risk: transport ambiguity for remote MCP servers
Impact:
- a Claude `url` may map incorrectly for targets that distinguish HTTP vs SSE.
Mitigation:
- prefer HTTP where the target recommends it
- only emit legacy SSE when Claude transport is explicit
- warn when mapping is lossy
### Risk: OpenClaw MCP surface is not sufficiently documented
Impact:
- writing a guessed MCP config could create a broken or misleading feature.
Mitigation:
- validation gate during implementation
- if validation fails, ship OpenClaw skills sync only and document MCP as a follow-up
### Risk: `sync --target all` remains easy to drift out of sync again
Impact:
- future providers get added to install/convert but missed by sync.
Mitigation:
- derive sync valid targets and detection from a shared registry
- add tests that assert detection and sync target lists match expected supported names
## Alternative Approaches Considered
### 1. Just add more cases to `sync.ts`
Rejected:
- this is exactly how the current drift happened.
### 2. Reuse target writers directly
Rejected:
- writers copy directories and emit install bundles;
- sync must symlink skills and only manage personal config subsets.
### 3. Standardize every sync target on user-level output now
Rejected for this feature:
- it would change existing `gemini` and `copilot` behavior and broaden scope into a migration project.
## Documentation Plan
- Update `README.md` sync section to list all supported sync targets and call out any exceptions.
- Update sync examples for `windsurf`, `kiro`, and `qwen`.
- If OpenClaw MCP is skipped, document that explicitly.
- If repository specs are corrected during implementation, update `docs/specs/kiro.md` to match official current behavior.
## Success Metrics
- `sync --target all` covers the same provider surface users reasonably expect from the current CLI, excluding only targets that lack a validated MCP config contract.
- A Claude config with one stdio server and one remote server syncs correctly to every documented MCP-capable provider.
- No user data is deleted during sync.
- Documentation and CLI help no longer over-promise relative to actual behavior.
## AI Pairing Notes
- Treat official provider docs as authoritative over older local notes, especially for Kiro and Qwen transport handling.
- Have a human review any AI-generated MCP mapping code before merge because these config files may contain secrets and lossy transport assumptions are easy to miss.
- When using an implementation agent, keep the work split by target so each provider's config contract can be tested independently.
## References & Research
### Internal References
- `src/commands/sync.ts:15-125`
- `src/utils/detect-tools.ts:11-46`
- `src/parsers/claude-home.ts:11-64`
- `src/sync/codex.ts:7-92`
- `src/sync/droid.ts:6-21`
- `src/targets/windsurf.ts:13-93`
- `src/targets/kiro.ts:5-93`
- `src/targets/openclaw.ts:6-95`
- `src/targets/qwen.ts:5-64`
- `docs/solutions/adding-converter-target-providers.md:20-32`
- `docs/solutions/adding-converter-target-providers.md:208-214`
- `README.md:89-123`
### External References
- Factory MCP docs: https://docs.factory.ai/factory-cli/configuration/mcp
- Factory skills docs: https://docs.factory.ai/cli/configuration/skills
- Windsurf MCP docs: https://docs.windsurf.com/windsurf/cascade/mcp
- Kiro MCP overview: https://kiro.dev/blog/unlock-your-development-productivity-with-kiro-and-mcp/
- Kiro remote MCP support: https://kiro.dev/blog/introducing-remote-mcp/
- Kiro skills announcement: https://kiro.dev/blog/custom-subagents-skills-and-enterprise-controls/
- Qwen settings docs: https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/
- Qwen MCP docs: https://qwenlm.github.io/qwen-code-docs/en/users/features/mcp/
- Qwen skills docs: https://qwenlm.github.io/qwen-code-docs/zh/users/features/skills/
- OpenClaw setup/config docs: https://docs.openclaw.ai/start/setup
- OpenClaw skills docs: https://docs.openclaw.ai/skills
## Implementation Notes for the Follow-Up `/workflows-work` Step
Suggested implementation order:
1. registry + detection cleanup
2. codex remote MCP + droid MCP
3. windsurf + kiro + qwen sync modules
4. openclaw validation and implementation or explicit warning path
5. docs + tests

View File

@@ -1,6 +1,6 @@
{ {
"name": "@every-env/compound-plugin", "name": "@every-env/compound-plugin",
"version": "0.12.0", "version": "0.13.0",
"type": "module", "type": "module",
"private": false, "private": false,
"bin": { "bin": {

View File

@@ -1,76 +1,34 @@
import { defineCommand } from "citty" import { defineCommand } from "citty"
import os from "os"
import path from "path" import path from "path"
import { loadClaudeHome } from "../parsers/claude-home" import { loadClaudeHome } from "../parsers/claude-home"
import { syncToOpenCode } from "../sync/opencode" import {
import { syncToCodex } from "../sync/codex" getDefaultSyncRegistryContext,
import { syncToPi } from "../sync/pi" getSyncTarget,
import { syncToDroid } from "../sync/droid" isSyncTargetName,
import { syncToCopilot } from "../sync/copilot" syncTargetNames,
import { syncToGemini } from "../sync/gemini" type SyncTargetName,
} from "../sync/registry"
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" import { detectInstalledTools } from "../utils/detect-tools"
const validTargets = ["opencode", "codex", "pi", "droid", "copilot", "gemini", "all"] as const const validTargets = [...syncTargetNames, "all"] as const
type SyncTarget = (typeof validTargets)[number] type SyncTarget = SyncTargetName | "all"
function isValidTarget(value: string): value is SyncTarget { function isValidTarget(value: string): value is SyncTarget {
return (validTargets as readonly string[]).includes(value) return value === "all" || isSyncTargetName(value)
}
function resolveOutputRoot(target: string): string {
switch (target) {
case "opencode":
return path.join(os.homedir(), ".config", "opencode")
case "codex":
return path.join(os.homedir(), ".codex")
case "pi":
return path.join(os.homedir(), ".pi", "agent")
case "droid":
return path.join(os.homedir(), ".factory")
case "copilot":
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, Copilot, or Gemini", description: "Sync Claude Code config (~/.claude/) to supported provider configs and skills",
}, },
args: { args: {
target: { target: {
type: "string", type: "string",
default: "all", default: "all",
description: "Target: opencode | codex | pi | droid | copilot | gemini | all (default: all)", description: `Target: ${syncTargetNames.join(" | ")} | all (default: all)`,
}, },
claudeHome: { claudeHome: {
type: "string", type: "string",
@@ -83,7 +41,8 @@ export default defineCommand({
throw new Error(`Unknown target: ${args.target}. Use one of: ${validTargets.join(", ")}`) throw new Error(`Unknown target: ${args.target}. Use one of: ${validTargets.join(", ")}`)
} }
const claudeHome = expandHome(args.claudeHome ?? path.join(os.homedir(), ".claude")) const { home, cwd } = getDefaultSyncRegistryContext()
const claudeHome = expandHome(args.claudeHome ?? path.join(home, ".claude"))
const config = await loadClaudeHome(claudeHome) const config = await loadClaudeHome(claudeHome)
// Warn about potential secrets in MCP env vars // Warn about potential secrets in MCP env vars
@@ -109,19 +68,21 @@ export default defineCommand({
} }
for (const name of activeTargets) { for (const name of activeTargets) {
const outputRoot = resolveOutputRoot(name) const target = getSyncTarget(name as SyncTargetName)
await syncTarget(name, config, outputRoot) const outputRoot = target.resolveOutputRoot(home, cwd)
await target.sync(config, outputRoot)
console.log(`✓ Synced to ${name}: ${outputRoot}`) console.log(`✓ Synced to ${name}: ${outputRoot}`)
} }
return return
} }
console.log( console.log(
`Syncing ${config.skills.length} skills, ${Object.keys(config.mcpServers).length} MCP servers...`, `Syncing ${config.skills.length} skills, ${config.commands?.length ?? 0} commands, ${Object.keys(config.mcpServers).length} MCP servers...`,
) )
const outputRoot = resolveOutputRoot(args.target) const target = getSyncTarget(args.target as SyncTargetName)
await syncTarget(args.target, config, outputRoot) const outputRoot = target.resolveOutputRoot(home, cwd)
await target.sync(config, outputRoot)
console.log(`✓ Synced to ${args.target}: ${outputRoot}`) console.log(`✓ Synced to ${args.target}: ${outputRoot}`)
}, },
}) })

View File

@@ -1,22 +1,26 @@
import path from "path" import path from "path"
import os from "os" import os from "os"
import fs from "fs/promises" import fs from "fs/promises"
import type { ClaudeSkill, ClaudeMcpServer } from "../types/claude" import { parseFrontmatter } from "../utils/frontmatter"
import { walkFiles } from "../utils/files"
import type { ClaudeCommand, ClaudeSkill, ClaudeMcpServer } from "../types/claude"
export interface ClaudeHomeConfig { export interface ClaudeHomeConfig {
skills: ClaudeSkill[] skills: ClaudeSkill[]
commands?: ClaudeCommand[]
mcpServers: Record<string, ClaudeMcpServer> mcpServers: Record<string, ClaudeMcpServer>
} }
export async function loadClaudeHome(claudeHome?: string): Promise<ClaudeHomeConfig> { export async function loadClaudeHome(claudeHome?: string): Promise<ClaudeHomeConfig> {
const home = claudeHome ?? path.join(os.homedir(), ".claude") const home = claudeHome ?? path.join(os.homedir(), ".claude")
const [skills, mcpServers] = await Promise.all([ const [skills, commands, mcpServers] = await Promise.all([
loadPersonalSkills(path.join(home, "skills")), loadPersonalSkills(path.join(home, "skills")),
loadPersonalCommands(path.join(home, "commands")),
loadSettingsMcp(path.join(home, "settings.json")), loadSettingsMcp(path.join(home, "settings.json")),
]) ])
return { skills, mcpServers } return { skills, commands, mcpServers }
} }
async function loadPersonalSkills(skillsDir: string): Promise<ClaudeSkill[]> { async function loadPersonalSkills(skillsDir: string): Promise<ClaudeSkill[]> {
@@ -63,3 +67,51 @@ async function loadSettingsMcp(
return {} // File doesn't exist or invalid JSON return {} // File doesn't exist or invalid JSON
} }
} }
async function loadPersonalCommands(commandsDir: string): Promise<ClaudeCommand[]> {
try {
const files = (await walkFiles(commandsDir))
.filter((file) => file.endsWith(".md"))
.sort()
const commands: ClaudeCommand[] = []
for (const file of files) {
const raw = await fs.readFile(file, "utf8")
const { data, body } = parseFrontmatter(raw)
commands.push({
name: typeof data.name === "string" ? data.name : deriveCommandName(commandsDir, file),
description: data.description as string | undefined,
argumentHint: data["argument-hint"] as string | undefined,
model: data.model as string | undefined,
allowedTools: parseAllowedTools(data["allowed-tools"]),
disableModelInvocation: data["disable-model-invocation"] === true ? true : undefined,
body: body.trim(),
sourcePath: file,
})
}
return commands
} catch {
return []
}
}
function deriveCommandName(commandsDir: string, filePath: string): string {
const relative = path.relative(commandsDir, filePath)
const withoutExt = relative.replace(/\.md$/i, "")
return withoutExt.split(path.sep).join(":")
}
function parseAllowedTools(value: unknown): string[] | undefined {
if (!value) return undefined
if (Array.isArray(value)) {
return value.map((item) => String(item))
}
if (typeof value === "string") {
return value
.split(/,/)
.map((item) => item.trim())
.filter(Boolean)
}
return undefined
}

View File

@@ -1,31 +1,29 @@
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home" import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude" import { renderCodexConfig } from "../targets/codex"
import { forceSymlink, isValidSkillName } from "../utils/symlink" import { writeTextSecure } from "../utils/files"
import { syncCodexCommands } from "./commands"
import { syncSkills } from "./skills"
const CURRENT_START_MARKER = "# BEGIN compound-plugin Claude Code MCP"
const CURRENT_END_MARKER = "# END compound-plugin Claude Code MCP"
const LEGACY_MARKER = "# MCP servers synced from Claude Code"
export async function syncToCodex( export async function syncToCodex(
config: ClaudeHomeConfig, config: ClaudeHomeConfig,
outputRoot: string, outputRoot: string,
): Promise<void> { ): Promise<void> {
// Ensure output directories exist await syncSkills(config.skills, path.join(outputRoot, "skills"))
const skillsDir = path.join(outputRoot, "skills") await syncCodexCommands(config, outputRoot)
await fs.mkdir(skillsDir, { recursive: true })
// Symlink skills (with validation)
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)
}
// Write MCP servers to config.toml (TOML format) // Write MCP servers to config.toml (TOML format)
if (Object.keys(config.mcpServers).length > 0) { if (Object.keys(config.mcpServers).length > 0) {
const configPath = path.join(outputRoot, "config.toml") const configPath = path.join(outputRoot, "config.toml")
const mcpToml = convertMcpForCodex(config.mcpServers) const mcpToml = renderCodexConfig(config.mcpServers)
if (!mcpToml) {
return
}
// Read existing config and merge idempotently // Read existing config and merge idempotently
let existingContent = "" let existingContent = ""
@@ -37,56 +35,34 @@ export async function syncToCodex(
} }
} }
// Remove any existing Claude Code MCP section to make idempotent const managedBlock = [
const marker = "# MCP servers synced from Claude Code" CURRENT_START_MARKER,
const markerIndex = existingContent.indexOf(marker) mcpToml.trim(),
if (markerIndex !== -1) { CURRENT_END_MARKER,
existingContent = existingContent.slice(0, markerIndex).trimEnd() "",
} ].join("\n")
const newContent = existingContent const withoutCurrentBlock = existingContent.replace(
? existingContent + "\n\n" + marker + "\n" + mcpToml new RegExp(
: "# Codex config - synced from Claude Code\n\n" + mcpToml `${escapeForRegex(CURRENT_START_MARKER)}[\\s\\S]*?${escapeForRegex(CURRENT_END_MARKER)}\\n?`,
"g",
),
"",
).trimEnd()
await fs.writeFile(configPath, newContent, { mode: 0o600 }) const legacyMarkerIndex = withoutCurrentBlock.indexOf(LEGACY_MARKER)
const cleaned = legacyMarkerIndex === -1
? withoutCurrentBlock
: withoutCurrentBlock.slice(0, legacyMarkerIndex).trimEnd()
const newContent = cleaned
? `${cleaned}\n\n${managedBlock}`
: `${managedBlock}`
await writeTextSecure(configPath, newContent)
} }
} }
/** Escape a string for TOML double-quoted strings */ function escapeForRegex(value: string): string {
function escapeTomlString(str: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
return str
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t")
}
function convertMcpForCodex(servers: Record<string, ClaudeMcpServer>): string {
const sections: string[] = []
for (const [name, server] of Object.entries(servers)) {
if (!server.command) continue
const lines: string[] = []
lines.push(`[mcp_servers.${name}]`)
lines.push(`command = "${escapeTomlString(server.command)}"`)
if (server.args && server.args.length > 0) {
const argsStr = server.args.map((arg) => `"${escapeTomlString(arg)}"`).join(", ")
lines.push(`args = [${argsStr}]`)
}
if (server.env && Object.keys(server.env).length > 0) {
lines.push("")
lines.push(`[mcp_servers.${name}.env]`)
for (const [key, value] of Object.entries(server.env)) {
lines.push(`${key} = "${escapeTomlString(value)}"`)
}
}
sections.push(lines.join("\n"))
}
return sections.join("\n\n") + "\n"
} }

198
src/sync/commands.ts Normal file
View File

@@ -0,0 +1,198 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudePlugin } from "../types/claude"
import { backupFile, writeText } from "../utils/files"
import { convertClaudeToCodex } from "../converters/claude-to-codex"
import { convertClaudeToCopilot } from "../converters/claude-to-copilot"
import { convertClaudeToDroid } from "../converters/claude-to-droid"
import { convertClaudeToGemini } from "../converters/claude-to-gemini"
import { convertClaudeToKiro } from "../converters/claude-to-kiro"
import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode"
import { convertClaudeToPi } from "../converters/claude-to-pi"
import { convertClaudeToQwen, type ClaudeToQwenOptions } from "../converters/claude-to-qwen"
import { convertClaudeToWindsurf } from "../converters/claude-to-windsurf"
import { writeWindsurfBundle } from "../targets/windsurf"
type WindsurfSyncScope = "global" | "workspace"
const HOME_SYNC_PLUGIN_ROOT = path.join(process.cwd(), ".compound-sync-home")
const DEFAULT_SYNC_OPTIONS: ClaudeToOpenCodeOptions = {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
}
const DEFAULT_QWEN_SYNC_OPTIONS: ClaudeToQwenOptions = {
agentMode: "subagent",
inferTemperature: false,
}
function hasCommands(config: ClaudeHomeConfig): boolean {
return (config.commands?.length ?? 0) > 0
}
function buildClaudeHomePlugin(config: ClaudeHomeConfig): ClaudePlugin {
return {
root: HOME_SYNC_PLUGIN_ROOT,
manifest: {
name: "claude-home",
version: "1.0.0",
description: "Personal Claude Code home config",
},
agents: [],
commands: config.commands ?? [],
skills: config.skills,
mcpServers: undefined,
}
}
export async function syncOpenCodeCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToOpenCode(plugin, DEFAULT_SYNC_OPTIONS)
for (const commandFile of bundle.commandFiles) {
const commandPath = path.join(outputRoot, "commands", `${commandFile.name}.md`)
const backupPath = await backupFile(commandPath)
if (backupPath) {
console.log(`Backed up existing command file to ${backupPath}`)
}
await writeText(commandPath, commandFile.content + "\n")
}
}
export async function syncCodexCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToCodex(plugin, DEFAULT_SYNC_OPTIONS)
for (const prompt of bundle.prompts) {
await writeText(path.join(outputRoot, "prompts", `${prompt.name}.md`), prompt.content + "\n")
}
for (const skill of bundle.generatedSkills) {
await writeText(path.join(outputRoot, "skills", skill.name, "SKILL.md"), skill.content + "\n")
}
}
export async function syncPiCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToPi(plugin, DEFAULT_SYNC_OPTIONS)
for (const prompt of bundle.prompts) {
await writeText(path.join(outputRoot, "prompts", `${prompt.name}.md`), prompt.content + "\n")
}
for (const extension of bundle.extensions) {
await writeText(path.join(outputRoot, "extensions", extension.name), extension.content + "\n")
}
}
export async function syncDroidCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToDroid(plugin, DEFAULT_SYNC_OPTIONS)
for (const command of bundle.commands) {
await writeText(path.join(outputRoot, "commands", `${command.name}.md`), command.content + "\n")
}
}
export async function syncCopilotCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToCopilot(plugin, DEFAULT_SYNC_OPTIONS)
for (const skill of bundle.generatedSkills) {
await writeText(path.join(outputRoot, "skills", skill.name, "SKILL.md"), skill.content + "\n")
}
}
export async function syncGeminiCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToGemini(plugin, DEFAULT_SYNC_OPTIONS)
for (const command of bundle.commands) {
await writeText(path.join(outputRoot, "commands", `${command.name}.toml`), command.content + "\n")
}
}
export async function syncKiroCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToKiro(plugin, DEFAULT_SYNC_OPTIONS)
for (const skill of bundle.generatedSkills) {
await writeText(path.join(outputRoot, "skills", skill.name, "SKILL.md"), skill.content + "\n")
}
}
export async function syncWindsurfCommands(
config: ClaudeHomeConfig,
outputRoot: string,
scope: WindsurfSyncScope = "global",
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToWindsurf(plugin, DEFAULT_SYNC_OPTIONS)
await writeWindsurfBundle(outputRoot, {
agentSkills: [],
commandWorkflows: bundle.commandWorkflows,
skillDirs: [],
mcpConfig: null,
}, scope)
}
export async function syncQwenCommands(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
if (!hasCommands(config)) return
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToQwen(plugin, DEFAULT_QWEN_SYNC_OPTIONS)
for (const commandFile of bundle.commandFiles) {
const parts = commandFile.name.split(":")
if (parts.length > 1) {
const nestedDir = path.join(outputRoot, "commands", ...parts.slice(0, -1))
await writeText(path.join(nestedDir, `${parts[parts.length - 1]}.md`), commandFile.content + "\n")
continue
}
await writeText(path.join(outputRoot, "commands", `${commandFile.name}.md`), commandFile.content + "\n")
}
}
export function warnUnsupportedOpenClawCommands(config: ClaudeHomeConfig): void {
if (!hasCommands(config)) return
console.warn(
"Warning: OpenClaw personal command sync is skipped because this sync target currently has no documented user-level command surface.",
)
}

View File

@@ -1,11 +1,13 @@
import fs from "fs/promises"
import path from "path" import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home" import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude" import type { ClaudeMcpServer } from "../types/claude"
import { forceSymlink, isValidSkillName } from "../utils/symlink" import { syncCopilotCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { hasExplicitSseTransport } from "./mcp-transports"
import { syncSkills } from "./skills"
type CopilotMcpServer = { type CopilotMcpServer = {
type: string type: "local" | "http" | "sse"
command?: string command?: string
args?: string[] args?: string[]
url?: string url?: string
@@ -22,41 +24,17 @@ export async function syncToCopilot(
config: ClaudeHomeConfig, config: ClaudeHomeConfig,
outputRoot: string, outputRoot: string,
): Promise<void> { ): Promise<void> {
const skillsDir = path.join(outputRoot, "skills") await syncSkills(config.skills, path.join(outputRoot, "skills"))
await fs.mkdir(skillsDir, { recursive: true }) await syncCopilotCommands(config, outputRoot)
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) { if (Object.keys(config.mcpServers).length > 0) {
const mcpPath = path.join(outputRoot, "copilot-mcp-config.json") const mcpPath = path.join(outputRoot, "mcp-config.json")
const existing = await readJsonSafe(mcpPath)
const converted = convertMcpForCopilot(config.mcpServers) const converted = convertMcpForCopilot(config.mcpServers)
const merged: CopilotMcpConfig = { await mergeJsonConfigAtKey({
mcpServers: { configPath: mcpPath,
...(existing.mcpServers ?? {}), key: "mcpServers",
...converted, incoming: converted,
}, })
}
await fs.writeFile(mcpPath, JSON.stringify(merged, null, 2), { mode: 0o600 })
}
}
async function readJsonSafe(filePath: string): Promise<Partial<CopilotMcpConfig>> {
try {
const content = await fs.readFile(filePath, "utf-8")
return JSON.parse(content) as Partial<CopilotMcpConfig>
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return {}
}
throw err
} }
} }
@@ -66,7 +44,7 @@ function convertMcpForCopilot(
const result: Record<string, CopilotMcpServer> = {} const result: Record<string, CopilotMcpServer> = {}
for (const [name, server] of Object.entries(servers)) { for (const [name, server] of Object.entries(servers)) {
const entry: CopilotMcpServer = { const entry: CopilotMcpServer = {
type: server.command ? "local" : "sse", type: server.command ? "local" : hasExplicitSseTransport(server) ? "sse" : "http",
tools: ["*"], tools: ["*"],
} }

View File

@@ -1,21 +1,62 @@
import fs from "fs/promises"
import path from "path" import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home" import type { ClaudeHomeConfig } from "../parsers/claude-home"
import { forceSymlink, isValidSkillName } from "../utils/symlink" import type { ClaudeMcpServer } from "../types/claude"
import { syncDroidCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { syncSkills } from "./skills"
type DroidMcpServer = {
type: "stdio" | "http"
command?: string
args?: string[]
env?: Record<string, string>
url?: string
headers?: Record<string, string>
disabled: boolean
}
export async function syncToDroid( export async function syncToDroid(
config: ClaudeHomeConfig, config: ClaudeHomeConfig,
outputRoot: string, outputRoot: string,
): Promise<void> { ): Promise<void> {
const skillsDir = path.join(outputRoot, "skills") await syncSkills(config.skills, path.join(outputRoot, "skills"))
await fs.mkdir(skillsDir, { recursive: true }) await syncDroidCommands(config, outputRoot)
for (const skill of config.skills) { if (Object.keys(config.mcpServers).length > 0) {
if (!isValidSkillName(skill.name)) { await mergeJsonConfigAtKey({
console.warn(`Skipping skill with invalid name: ${skill.name}`) configPath: path.join(outputRoot, "mcp.json"),
continue key: "mcpServers",
} incoming: convertMcpForDroid(config.mcpServers),
const target = path.join(skillsDir, skill.name) })
await forceSymlink(skill.sourceDir, target)
} }
} }
function convertMcpForDroid(
servers: Record<string, ClaudeMcpServer>,
): Record<string, DroidMcpServer> {
const result: Record<string, DroidMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
result[name] = {
type: "stdio",
command: server.command,
args: server.args,
env: server.env,
disabled: false,
}
continue
}
if (server.url) {
result[name] = {
type: "http",
url: server.url,
headers: server.headers,
disabled: false,
}
}
}
return result
}

View File

@@ -2,7 +2,9 @@ import fs from "fs/promises"
import path from "path" import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home" import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude" import type { ClaudeMcpServer } from "../types/claude"
import { forceSymlink, isValidSkillName } from "../utils/symlink" import { syncGeminiCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { syncSkills } from "./skills"
type GeminiMcpServer = { type GeminiMcpServer = {
command?: string command?: string
@@ -16,43 +18,100 @@ export async function syncToGemini(
config: ClaudeHomeConfig, config: ClaudeHomeConfig,
outputRoot: string, outputRoot: string,
): Promise<void> { ): Promise<void> {
const skillsDir = path.join(outputRoot, "skills") await syncGeminiSkills(config.skills, outputRoot)
await fs.mkdir(skillsDir, { recursive: true }) await syncGeminiCommands(config, outputRoot)
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) { if (Object.keys(config.mcpServers).length > 0) {
const settingsPath = path.join(outputRoot, "settings.json") const settingsPath = path.join(outputRoot, "settings.json")
const existing = await readJsonSafe(settingsPath)
const converted = convertMcpForGemini(config.mcpServers) const converted = convertMcpForGemini(config.mcpServers)
const existingMcp = await mergeJsonConfigAtKey({
existing.mcpServers && typeof existing.mcpServers === "object" configPath: settingsPath,
? (existing.mcpServers as Record<string, unknown>) key: "mcpServers",
: {} incoming: converted,
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>> { async function syncGeminiSkills(
try { skills: ClaudeHomeConfig["skills"],
const content = await fs.readFile(filePath, "utf-8") outputRoot: string,
return JSON.parse(content) as Record<string, unknown> ): Promise<void> {
} catch (err) { const skillsDir = path.join(outputRoot, "skills")
if ((err as NodeJS.ErrnoException).code === "ENOENT") { const sharedSkillsDir = getGeminiSharedSkillsDir(outputRoot)
return {}
if (!sharedSkillsDir) {
await syncSkills(skills, skillsDir)
return
}
const canonicalSharedSkillsDir = await canonicalizePath(sharedSkillsDir)
const mirroredSkills: ClaudeHomeConfig["skills"] = []
const directSkills: ClaudeHomeConfig["skills"] = []
for (const skill of skills) {
if (await isWithinDir(skill.sourceDir, canonicalSharedSkillsDir)) {
mirroredSkills.push(skill)
} else {
directSkills.push(skill)
}
}
await removeGeminiMirrorConflicts(mirroredSkills, skillsDir, canonicalSharedSkillsDir)
await syncSkills(directSkills, skillsDir)
}
function getGeminiSharedSkillsDir(outputRoot: string): string | null {
if (path.basename(outputRoot) !== ".gemini") return null
return path.join(path.dirname(outputRoot), ".agents", "skills")
}
async function canonicalizePath(targetPath: string): Promise<string> {
try {
return await fs.realpath(targetPath)
} catch {
return path.resolve(targetPath)
}
}
async function isWithinDir(candidate: string, canonicalParentDir: string): Promise<boolean> {
const resolvedCandidate = await canonicalizePath(candidate)
return resolvedCandidate === canonicalParentDir
|| resolvedCandidate.startsWith(`${canonicalParentDir}${path.sep}`)
}
async function removeGeminiMirrorConflicts(
skills: ClaudeHomeConfig["skills"],
skillsDir: string,
sharedSkillsDir: string,
): Promise<void> {
for (const skill of skills) {
const duplicatePath = path.join(skillsDir, skill.name)
let stat
try {
stat = await fs.lstat(duplicatePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
continue
}
throw error
}
if (!stat.isSymbolicLink()) {
continue
}
let resolvedTarget: string
try {
resolvedTarget = await canonicalizePath(duplicatePath)
} catch {
continue
}
if (resolvedTarget === await canonicalizePath(skill.sourceDir)
|| await isWithinDir(resolvedTarget, sharedSkillsDir)) {
await fs.unlink(duplicatePath)
} }
throw err
} }
} }

47
src/sync/json-config.ts Normal file
View File

@@ -0,0 +1,47 @@
import path from "path"
import { pathExists, readJson, writeJsonSecure } from "../utils/files"
type JsonObject = Record<string, unknown>
function isJsonObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
export async function mergeJsonConfigAtKey(options: {
configPath: string
key: string
incoming: Record<string, unknown>
}): Promise<void> {
const { configPath, key, incoming } = options
const existing = await readJsonObjectSafe(configPath)
const existingEntries = isJsonObject(existing[key]) ? existing[key] : {}
const merged = {
...existing,
[key]: {
...existingEntries,
...incoming,
},
}
await writeJsonSecure(configPath, merged)
}
async function readJsonObjectSafe(configPath: string): Promise<JsonObject> {
if (!(await pathExists(configPath))) {
return {}
}
try {
const parsed = await readJson<unknown>(configPath)
if (isJsonObject(parsed)) {
return parsed
}
} catch {
// Fall through to warning and replacement.
}
console.warn(
`Warning: existing ${path.basename(configPath)} could not be parsed and will be replaced.`,
)
return {}
}

49
src/sync/kiro.ts Normal file
View File

@@ -0,0 +1,49 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude"
import type { KiroMcpServer } from "../types/kiro"
import { syncKiroCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { syncSkills } from "./skills"
export async function syncToKiro(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncKiroCommands(config, outputRoot)
if (Object.keys(config.mcpServers).length > 0) {
await mergeJsonConfigAtKey({
configPath: path.join(outputRoot, "settings", "mcp.json"),
key: "mcpServers",
incoming: convertMcpForKiro(config.mcpServers),
})
}
}
function convertMcpForKiro(
servers: Record<string, ClaudeMcpServer>,
): Record<string, KiroMcpServer> {
const result: Record<string, KiroMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
result[name] = {
command: server.command,
args: server.args,
env: server.env,
}
continue
}
if (server.url) {
result[name] = {
url: server.url,
headers: server.headers,
}
}
}
return result
}

View File

@@ -0,0 +1,19 @@
import type { ClaudeMcpServer } from "../types/claude"
function getTransportType(server: ClaudeMcpServer): string {
return server.type?.toLowerCase().trim() ?? ""
}
export function hasExplicitSseTransport(server: ClaudeMcpServer): boolean {
const type = getTransportType(server)
return type.includes("sse")
}
export function hasExplicitHttpTransport(server: ClaudeMcpServer): boolean {
const type = getTransportType(server)
return type.includes("http") || type.includes("streamable")
}
export function hasExplicitRemoteTransport(server: ClaudeMcpServer): boolean {
return hasExplicitSseTransport(server) || hasExplicitHttpTransport(server)
}

18
src/sync/openclaw.ts Normal file
View File

@@ -0,0 +1,18 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import { warnUnsupportedOpenClawCommands } from "./commands"
import { syncSkills } from "./skills"
export async function syncToOpenClaw(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
await syncSkills(config.skills, path.join(outputRoot, "skills"))
warnUnsupportedOpenClawCommands(config)
if (Object.keys(config.mcpServers).length > 0) {
console.warn(
"Warning: OpenClaw MCP sync is skipped because the current official OpenClaw docs do not clearly document an MCP server config contract.",
)
}
}

View File

@@ -1,47 +1,27 @@
import fs from "fs/promises"
import path from "path" import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home" import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude" import type { ClaudeMcpServer } from "../types/claude"
import type { OpenCodeMcpServer } from "../types/opencode" import type { OpenCodeMcpServer } from "../types/opencode"
import { forceSymlink, isValidSkillName } from "../utils/symlink" import { syncOpenCodeCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { syncSkills } from "./skills"
export async function syncToOpenCode( export async function syncToOpenCode(
config: ClaudeHomeConfig, config: ClaudeHomeConfig,
outputRoot: string, outputRoot: string,
): Promise<void> { ): Promise<void> {
// Ensure output directories exist await syncSkills(config.skills, path.join(outputRoot, "skills"))
const skillsDir = path.join(outputRoot, "skills") await syncOpenCodeCommands(config, outputRoot)
await fs.mkdir(skillsDir, { recursive: true })
// Symlink skills (with validation)
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)
}
// Merge MCP servers into opencode.json // Merge MCP servers into opencode.json
if (Object.keys(config.mcpServers).length > 0) { if (Object.keys(config.mcpServers).length > 0) {
const configPath = path.join(outputRoot, "opencode.json") const configPath = path.join(outputRoot, "opencode.json")
const existing = await readJsonSafe(configPath)
const mcpConfig = convertMcpForOpenCode(config.mcpServers) const mcpConfig = convertMcpForOpenCode(config.mcpServers)
existing.mcp = { ...(existing.mcp ?? {}), ...mcpConfig } await mergeJsonConfigAtKey({
await fs.writeFile(configPath, JSON.stringify(existing, null, 2), { mode: 0o600 }) configPath,
} key: "mcp",
} incoming: mcpConfig,
})
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
} }
} }

View File

@@ -1,8 +1,10 @@
import fs from "fs/promises"
import path from "path" import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home" import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude" import type { ClaudeMcpServer } from "../types/claude"
import { forceSymlink, isValidSkillName } from "../utils/symlink" import { ensureDir } from "../utils/files"
import { syncPiCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { syncSkills } from "./skills"
type McporterServer = { type McporterServer = {
baseUrl?: string baseUrl?: string
@@ -20,45 +22,19 @@ export async function syncToPi(
config: ClaudeHomeConfig, config: ClaudeHomeConfig,
outputRoot: string, outputRoot: string,
): Promise<void> { ): Promise<void> {
const skillsDir = path.join(outputRoot, "skills")
const mcporterPath = path.join(outputRoot, "compound-engineering", "mcporter.json") const mcporterPath = path.join(outputRoot, "compound-engineering", "mcporter.json")
await fs.mkdir(skillsDir, { recursive: true }) await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncPiCommands(config, outputRoot)
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) { if (Object.keys(config.mcpServers).length > 0) {
await fs.mkdir(path.dirname(mcporterPath), { recursive: true }) await ensureDir(path.dirname(mcporterPath))
const existing = await readJsonSafe(mcporterPath)
const converted = convertMcpToMcporter(config.mcpServers) const converted = convertMcpToMcporter(config.mcpServers)
const merged: McporterConfig = { await mergeJsonConfigAtKey({
mcpServers: { configPath: mcporterPath,
...(existing.mcpServers ?? {}), key: "mcpServers",
...converted.mcpServers, incoming: converted.mcpServers,
}, })
}
await fs.writeFile(mcporterPath, JSON.stringify(merged, null, 2), { mode: 0o600 })
}
}
async function readJsonSafe(filePath: string): Promise<Partial<McporterConfig>> {
try {
const content = await fs.readFile(filePath, "utf-8")
return JSON.parse(content) as Partial<McporterConfig>
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return {}
}
throw err
} }
} }

66
src/sync/qwen.ts Normal file
View File

@@ -0,0 +1,66 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude"
import type { QwenMcpServer } from "../types/qwen"
import { syncQwenCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { hasExplicitRemoteTransport, hasExplicitSseTransport } from "./mcp-transports"
import { syncSkills } from "./skills"
export async function syncToQwen(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncQwenCommands(config, outputRoot)
if (Object.keys(config.mcpServers).length > 0) {
await mergeJsonConfigAtKey({
configPath: path.join(outputRoot, "settings.json"),
key: "mcpServers",
incoming: convertMcpForQwen(config.mcpServers),
})
}
}
function convertMcpForQwen(
servers: Record<string, ClaudeMcpServer>,
): Record<string, QwenMcpServer> {
const result: Record<string, QwenMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
result[name] = {
command: server.command,
args: server.args,
env: server.env,
}
continue
}
if (!server.url) {
continue
}
if (hasExplicitSseTransport(server)) {
result[name] = {
url: server.url,
headers: server.headers,
}
continue
}
if (!hasExplicitRemoteTransport(server)) {
console.warn(
`Warning: Qwen MCP server "${name}" has an ambiguous remote transport; defaulting to Streamable HTTP.`,
)
}
result[name] = {
httpUrl: server.url,
headers: server.headers,
}
}
return result
}

141
src/sync/registry.ts Normal file
View File

@@ -0,0 +1,141 @@
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import { syncToCodex } from "./codex"
import { syncToCopilot } from "./copilot"
import { syncToDroid } from "./droid"
import { syncToGemini } from "./gemini"
import { syncToKiro } from "./kiro"
import { syncToOpenClaw } from "./openclaw"
import { syncToOpenCode } from "./opencode"
import { syncToPi } from "./pi"
import { syncToQwen } from "./qwen"
import { syncToWindsurf } from "./windsurf"
function getCopilotHomeRoot(home: string): string {
return path.join(home, ".copilot")
}
function getGeminiHomeRoot(home: string): string {
return path.join(home, ".gemini")
}
export type SyncTargetName =
| "opencode"
| "codex"
| "pi"
| "droid"
| "copilot"
| "gemini"
| "windsurf"
| "kiro"
| "qwen"
| "openclaw"
export type SyncTargetDefinition = {
name: SyncTargetName
detectPaths: (home: string, cwd: string) => string[]
resolveOutputRoot: (home: string, cwd: string) => string
sync: (config: ClaudeHomeConfig, outputRoot: string) => Promise<void>
}
export const syncTargets: SyncTargetDefinition[] = [
{
name: "opencode",
detectPaths: (home, cwd) => [
path.join(home, ".config", "opencode"),
path.join(cwd, ".opencode"),
],
resolveOutputRoot: (home) => path.join(home, ".config", "opencode"),
sync: syncToOpenCode,
},
{
name: "codex",
detectPaths: (home) => [path.join(home, ".codex")],
resolveOutputRoot: (home) => path.join(home, ".codex"),
sync: syncToCodex,
},
{
name: "pi",
detectPaths: (home) => [path.join(home, ".pi")],
resolveOutputRoot: (home) => path.join(home, ".pi", "agent"),
sync: syncToPi,
},
{
name: "droid",
detectPaths: (home) => [path.join(home, ".factory")],
resolveOutputRoot: (home) => path.join(home, ".factory"),
sync: syncToDroid,
},
{
name: "copilot",
detectPaths: (home, cwd) => [
getCopilotHomeRoot(home),
path.join(cwd, ".github", "skills"),
path.join(cwd, ".github", "agents"),
path.join(cwd, ".github", "copilot-instructions.md"),
],
resolveOutputRoot: (home) => getCopilotHomeRoot(home),
sync: syncToCopilot,
},
{
name: "gemini",
detectPaths: (home, cwd) => [
path.join(cwd, ".gemini"),
getGeminiHomeRoot(home),
],
resolveOutputRoot: (home) => getGeminiHomeRoot(home),
sync: syncToGemini,
},
{
name: "windsurf",
detectPaths: (home, cwd) => [
path.join(home, ".codeium", "windsurf"),
path.join(cwd, ".windsurf"),
],
resolveOutputRoot: (home) => path.join(home, ".codeium", "windsurf"),
sync: syncToWindsurf,
},
{
name: "kiro",
detectPaths: (home, cwd) => [
path.join(home, ".kiro"),
path.join(cwd, ".kiro"),
],
resolveOutputRoot: (home) => path.join(home, ".kiro"),
sync: syncToKiro,
},
{
name: "qwen",
detectPaths: (home, cwd) => [
path.join(home, ".qwen"),
path.join(cwd, ".qwen"),
],
resolveOutputRoot: (home) => path.join(home, ".qwen"),
sync: syncToQwen,
},
{
name: "openclaw",
detectPaths: (home) => [path.join(home, ".openclaw")],
resolveOutputRoot: (home) => path.join(home, ".openclaw"),
sync: syncToOpenClaw,
},
]
export const syncTargetNames = syncTargets.map((target) => target.name)
export function isSyncTargetName(value: string): value is SyncTargetName {
return syncTargetNames.includes(value as SyncTargetName)
}
export function getSyncTarget(name: SyncTargetName): SyncTargetDefinition {
const target = syncTargets.find((entry) => entry.name === name)
if (!target) {
throw new Error(`Unknown sync target: ${name}`)
}
return target
}
export function getDefaultSyncRegistryContext(): { home: string; cwd: string } {
return { home: os.homedir(), cwd: process.cwd() }
}

21
src/sync/skills.ts Normal file
View File

@@ -0,0 +1,21 @@
import path from "path"
import type { ClaudeSkill } from "../types/claude"
import { ensureDir } from "../utils/files"
import { forceSymlink, isValidSkillName } from "../utils/symlink"
export async function syncSkills(
skills: ClaudeSkill[],
skillsDir: string,
): Promise<void> {
await ensureDir(skillsDir)
for (const skill of 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)
}
}

59
src/sync/windsurf.ts Normal file
View File

@@ -0,0 +1,59 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude"
import type { WindsurfMcpServerEntry } from "../types/windsurf"
import { syncWindsurfCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { hasExplicitSseTransport } from "./mcp-transports"
import { syncSkills } from "./skills"
export async function syncToWindsurf(
config: ClaudeHomeConfig,
outputRoot: string,
): Promise<void> {
await syncSkills(config.skills, path.join(outputRoot, "skills"))
await syncWindsurfCommands(config, outputRoot, "global")
if (Object.keys(config.mcpServers).length > 0) {
await mergeJsonConfigAtKey({
configPath: path.join(outputRoot, "mcp_config.json"),
key: "mcpServers",
incoming: convertMcpForWindsurf(config.mcpServers),
})
}
}
function convertMcpForWindsurf(
servers: Record<string, ClaudeMcpServer>,
): Record<string, WindsurfMcpServerEntry> {
const result: Record<string, WindsurfMcpServerEntry> = {}
for (const [name, server] of Object.entries(servers)) {
if (server.command) {
result[name] = {
command: server.command,
args: server.args,
env: server.env,
}
continue
}
if (!server.url) {
continue
}
const entry: WindsurfMcpServerEntry = {
headers: server.headers,
}
if (hasExplicitSseTransport(server)) {
entry.url = server.url
} else {
entry.serverUrl = server.url
}
result[name] = entry
}
return result
}

View File

@@ -30,9 +30,11 @@ export type KiroSteeringFile = {
} }
export type KiroMcpServer = { export type KiroMcpServer = {
command: string command?: string
args?: string[] args?: string[]
env?: Record<string, string> env?: Record<string, string>
url?: string
headers?: Record<string, string>
} }
export type KiroBundle = { export type KiroBundle = {

View File

@@ -14,6 +14,9 @@ export type QwenMcpServer = {
args?: string[] args?: string[]
env?: Record<string, string> env?: Record<string, string>
cwd?: string cwd?: string
httpUrl?: string
url?: string
headers?: Record<string, string>
} }
export type QwenSetting = { export type QwenSetting = {

View File

@@ -19,6 +19,7 @@ export type WindsurfMcpServerEntry = {
args?: string[] args?: string[]
env?: Record<string, string> env?: Record<string, string>
serverUrl?: string serverUrl?: string
url?: string
headers?: Record<string, string> headers?: Record<string, string>
} }

View File

@@ -1,6 +1,6 @@
import os from "os" import os from "os"
import path from "path"
import { pathExists } from "./files" import { pathExists } from "./files"
import { syncTargets } from "../sync/registry"
export type DetectedTool = { export type DetectedTool = {
name: string name: string
@@ -12,27 +12,18 @@ export async function detectInstalledTools(
home: string = os.homedir(), home: string = os.homedir(),
cwd: string = process.cwd(), cwd: string = process.cwd(),
): Promise<DetectedTool[]> { ): 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[] = [] const results: DetectedTool[] = []
for (const check of checks) { for (const target of syncTargets) {
let detected = false let detected = false
let reason = "not found" let reason = "not found"
for (const p of check.paths) { for (const p of target.detectPaths(home, cwd)) {
if (await pathExists(p)) { if (await pathExists(p)) {
detected = true detected = true
reason = `found ${p}` reason = `found ${p}`
break break
} }
} }
results.push({ name: check.name, detected, reason }) results.push({ name: target.name, detected, reason })
} }
return results return results
} }

View File

@@ -41,6 +41,12 @@ export async function writeText(filePath: string, content: string): Promise<void
await fs.writeFile(filePath, content, "utf8") await fs.writeFile(filePath, content, "utf8")
} }
export async function writeTextSecure(filePath: string, content: string): Promise<void> {
await ensureDir(path.dirname(filePath))
await fs.writeFile(filePath, content, { encoding: "utf8", mode: 0o600 })
await fs.chmod(filePath, 0o600)
}
export async function writeJson(filePath: string, data: unknown): Promise<void> { export async function writeJson(filePath: string, data: unknown): Promise<void> {
const content = JSON.stringify(data, null, 2) const content = JSON.stringify(data, null, 2)
await writeText(filePath, content + "\n") await writeText(filePath, content + "\n")
@@ -51,6 +57,7 @@ export async function writeJsonSecure(filePath: string, data: unknown): Promise<
const content = JSON.stringify(data, null, 2) const content = JSON.stringify(data, null, 2)
await ensureDir(path.dirname(filePath)) await ensureDir(path.dirname(filePath))
await fs.writeFile(filePath, content + "\n", { encoding: "utf8", mode: 0o600 }) await fs.writeFile(filePath, content + "\n", { encoding: "utf8", mode: 0o600 })
await fs.chmod(filePath, 0o600)
} }
export async function walkFiles(root: string): Promise<string[]> { export async function walkFiles(root: string): Promise<string[]> {

View File

@@ -2,7 +2,7 @@ import fs from "fs/promises"
/** /**
* Create a symlink, safely replacing any existing symlink at target. * Create a symlink, safely replacing any existing symlink at target.
* Only removes existing symlinks - refuses to delete real directories. * Only removes existing symlinks - skips real directories with a warning.
*/ */
export async function forceSymlink(source: string, target: string): Promise<void> { export async function forceSymlink(source: string, target: string): Promise<void> {
try { try {
@@ -11,11 +11,9 @@ export async function forceSymlink(source: string, target: string): Promise<void
// Safe to remove existing symlink // Safe to remove existing symlink
await fs.unlink(target) await fs.unlink(target)
} else if (stat.isDirectory()) { } else if (stat.isDirectory()) {
// Refuse to delete real directories // Skip real directories rather than deleting them
throw new Error( console.warn(`Skipping ${target}: a real directory exists there (remove it manually to replace with a symlink).`)
`Cannot create symlink at ${target}: a real directory exists there. ` + return
`Remove it manually if you want to replace it with a symlink.`
)
} else { } else {
// Regular file - remove it // Regular file - remove it
await fs.unlink(target) await fs.unlink(target)

46
tests/claude-home.test.ts Normal file
View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import { loadClaudeHome } from "../src/parsers/claude-home"
describe("loadClaudeHome", () => {
test("loads personal skills, commands, and MCP servers", async () => {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "claude-home-"))
const skillDir = path.join(tempHome, "skills", "reviewer")
const commandsDir = path.join(tempHome, "commands")
await fs.mkdir(skillDir, { recursive: true })
await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: reviewer\n---\nReview things.\n")
await fs.mkdir(path.join(commandsDir, "workflows"), { recursive: true })
await fs.writeFile(
path.join(commandsDir, "workflows", "plan.md"),
"---\ndescription: Planning command\nargument-hint: \"[feature]\"\n---\nPlan the work.\n",
)
await fs.writeFile(
path.join(commandsDir, "custom.md"),
"---\nname: custom-command\ndescription: Custom command\nallowed-tools: Bash, Read\n---\nDo custom work.\n",
)
await fs.writeFile(
path.join(tempHome, "settings.json"),
JSON.stringify({
mcpServers: {
context7: { url: "https://mcp.context7.com/mcp" },
},
}),
)
const config = await loadClaudeHome(tempHome)
expect(config.skills.map((skill) => skill.name)).toEqual(["reviewer"])
expect(config.commands?.map((command) => command.name)).toEqual([
"custom-command",
"workflows:plan",
])
expect(config.commands?.find((command) => command.name === "workflows:plan")?.argumentHint).toBe("[feature]")
expect(config.commands?.find((command) => command.name === "custom-command")?.allowedTools).toEqual(["Bash", "Read"])
expect(config.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
})
})

View File

@@ -504,4 +504,106 @@ describe("CLI", () => {
expect(json).toHaveProperty("permission") expect(json).toHaveProperty("permission")
expect(json.permission).not.toBeNull() expect(json.permission).not.toBeNull()
}) })
test("sync --target all detects new sync targets and ignores stale cursor directories", async () => {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "cli-sync-home-"))
const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "cli-sync-cwd-"))
const repoRoot = path.join(import.meta.dir, "..")
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
const claudeSkillsDir = path.join(tempHome, ".claude", "skills", "skill-one")
const claudeCommandsDir = path.join(tempHome, ".claude", "commands", "workflows")
await fs.mkdir(path.dirname(claudeSkillsDir), { recursive: true })
await fs.cp(fixtureSkillDir, claudeSkillsDir, { recursive: true })
await fs.mkdir(claudeCommandsDir, { recursive: true })
await fs.writeFile(
path.join(claudeCommandsDir, "plan.md"),
[
"---",
"name: workflows:plan",
"description: Plan work",
"argument-hint: \"[goal]\"",
"---",
"",
"Plan the work.",
].join("\n"),
)
await fs.writeFile(
path.join(tempHome, ".claude", "settings.json"),
JSON.stringify({
mcpServers: {
local: { command: "echo", args: ["hello"] },
remote: { url: "https://example.com/mcp" },
legacy: { type: "sse", url: "https://example.com/sse" },
},
}, null, 2),
)
await fs.mkdir(path.join(tempHome, ".config", "opencode"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".codex"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".pi"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".factory"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".copilot"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".gemini"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".codeium", "windsurf"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".kiro"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".qwen"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".openclaw"), { recursive: true })
await fs.mkdir(path.join(tempCwd, ".cursor"), { recursive: true })
const proc = Bun.spawn([
"bun",
"run",
path.join(repoRoot, "src", "index.ts"),
"sync",
"--target",
"all",
], {
cwd: tempCwd,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
HOME: tempHome,
},
})
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
if (exitCode !== 0) {
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
}
expect(stdout).toContain("Synced to codex")
expect(stdout).toContain("Synced to opencode")
expect(stdout).toContain("Synced to pi")
expect(stdout).toContain("Synced to droid")
expect(stdout).toContain("Synced to windsurf")
expect(stdout).toContain("Synced to kiro")
expect(stdout).toContain("Synced to qwen")
expect(stdout).toContain("Synced to openclaw")
expect(stdout).toContain("Synced to copilot")
expect(stdout).toContain("Synced to gemini")
expect(stdout).not.toContain("cursor")
expect(await exists(path.join(tempHome, ".config", "opencode", "commands", "workflows:plan.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".codex", "config.toml"))).toBe(true)
expect(await exists(path.join(tempHome, ".codex", "prompts", "workflows-plan.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".codex", "skills", "workflows-plan", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".pi", "agent", "prompts", "workflows-plan.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".factory", "commands", "plan.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".codeium", "windsurf", "mcp_config.json"))).toBe(true)
expect(await exists(path.join(tempHome, ".codeium", "windsurf", "global_workflows", "workflows-plan.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".kiro", "settings", "mcp.json"))).toBe(true)
expect(await exists(path.join(tempHome, ".kiro", "skills", "workflows-plan", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".qwen", "settings.json"))).toBe(true)
expect(await exists(path.join(tempHome, ".qwen", "commands", "workflows", "plan.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".copilot", "mcp-config.json"))).toBe(true)
expect(await exists(path.join(tempHome, ".copilot", "skills", "workflows-plan", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".gemini", "settings.json"))).toBe(true)
expect(await exists(path.join(tempHome, ".gemini", "commands", "workflows", "plan.toml"))).toBe(true)
expect(await exists(path.join(tempHome, ".openclaw", "skills", "skill-one"))).toBe(true)
})
}) })

View File

@@ -11,8 +11,9 @@ describe("detectInstalledTools", () => {
// Create directories for some tools // Create directories for some tools
await fs.mkdir(path.join(tempHome, ".codex"), { recursive: true }) await fs.mkdir(path.join(tempHome, ".codex"), { recursive: true })
await fs.mkdir(path.join(tempCwd, ".cursor"), { recursive: true }) await fs.mkdir(path.join(tempHome, ".codeium", "windsurf"), { recursive: true })
await fs.mkdir(path.join(tempCwd, ".gemini"), { recursive: true }) await fs.mkdir(path.join(tempHome, ".gemini"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".copilot"), { recursive: true })
const results = await detectInstalledTools(tempHome, tempCwd) const results = await detectInstalledTools(tempHome, tempCwd)
@@ -20,14 +21,18 @@ describe("detectInstalledTools", () => {
expect(codex?.detected).toBe(true) expect(codex?.detected).toBe(true)
expect(codex?.reason).toContain(".codex") expect(codex?.reason).toContain(".codex")
const cursor = results.find((t) => t.name === "cursor") const windsurf = results.find((t) => t.name === "windsurf")
expect(cursor?.detected).toBe(true) expect(windsurf?.detected).toBe(true)
expect(cursor?.reason).toContain(".cursor") expect(windsurf?.reason).toContain(".codeium/windsurf")
const gemini = results.find((t) => t.name === "gemini") const gemini = results.find((t) => t.name === "gemini")
expect(gemini?.detected).toBe(true) expect(gemini?.detected).toBe(true)
expect(gemini?.reason).toContain(".gemini") expect(gemini?.reason).toContain(".gemini")
const copilot = results.find((t) => t.name === "copilot")
expect(copilot?.detected).toBe(true)
expect(copilot?.reason).toContain(".copilot")
// Tools without directories should not be detected // Tools without directories should not be detected
const opencode = results.find((t) => t.name === "opencode") const opencode = results.find((t) => t.name === "opencode")
expect(opencode?.detected).toBe(false) expect(opencode?.detected).toBe(false)
@@ -45,7 +50,7 @@ describe("detectInstalledTools", () => {
const results = await detectInstalledTools(tempHome, tempCwd) const results = await detectInstalledTools(tempHome, tempCwd)
expect(results.length).toBe(6) expect(results.length).toBe(10)
for (const tool of results) { for (const tool of results) {
expect(tool.detected).toBe(false) expect(tool.detected).toBe(false)
expect(tool.reason).toBe("not found") expect(tool.reason).toBe("not found")
@@ -59,12 +64,30 @@ describe("detectInstalledTools", () => {
await fs.mkdir(path.join(tempHome, ".config", "opencode"), { recursive: true }) 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, ".factory"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".pi"), { recursive: true }) await fs.mkdir(path.join(tempHome, ".pi"), { recursive: true })
await fs.mkdir(path.join(tempHome, ".openclaw"), { recursive: true })
const results = await detectInstalledTools(tempHome, tempCwd) const results = await detectInstalledTools(tempHome, tempCwd)
expect(results.find((t) => t.name === "opencode")?.detected).toBe(true) 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 === "droid")?.detected).toBe(true)
expect(results.find((t) => t.name === "pi")?.detected).toBe(true) expect(results.find((t) => t.name === "pi")?.detected).toBe(true)
expect(results.find((t) => t.name === "openclaw")?.detected).toBe(true)
})
test("detects copilot from project-specific skills without generic .github false positives", async () => {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "detect-copilot-home-"))
const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-copilot-cwd-"))
await fs.mkdir(path.join(tempCwd, ".github"), { recursive: true })
let results = await detectInstalledTools(tempHome, tempCwd)
expect(results.find((t) => t.name === "copilot")?.detected).toBe(false)
await fs.mkdir(path.join(tempCwd, ".github", "skills"), { recursive: true })
results = await detectInstalledTools(tempHome, tempCwd)
expect(results.find((t) => t.name === "copilot")?.detected).toBe(true)
expect(results.find((t) => t.name === "copilot")?.reason).toContain(".github/skills")
}) })
}) })
@@ -74,7 +97,7 @@ describe("getDetectedTargetNames", () => {
const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-names-cwd-")) 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(tempHome, ".codex"), { recursive: true })
await fs.mkdir(path.join(tempCwd, ".gemini"), { recursive: true }) await fs.mkdir(path.join(tempHome, ".gemini"), { recursive: true })
const names = await getDetectedTargetNames(tempHome, tempCwd) const names = await getDetectedTargetNames(tempHome, tempCwd)

64
tests/sync-codex.test.ts Normal file
View File

@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
import { syncToCodex } from "../src/sync/codex"
describe("syncToCodex", () => {
test("writes stdio and remote MCP servers into a managed block without clobbering user config", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-codex-"))
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
const configPath = path.join(tempRoot, "config.toml")
await fs.writeFile(
configPath,
[
"[custom]",
"enabled = true",
"",
"# BEGIN compound-plugin Claude Code MCP",
"[mcp_servers.old]",
"command = \"old\"",
"# END compound-plugin Claude Code MCP",
"",
"[post]",
"value = 2",
"",
].join("\n"),
)
const config: ClaudeHomeConfig = {
skills: [
{
name: "skill-one",
sourceDir: fixtureSkillDir,
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
},
],
mcpServers: {
local: { command: "echo", args: ["hello"], env: { KEY: "VALUE" } },
remote: { url: "https://example.com/mcp", headers: { Authorization: "Bearer token" } },
},
}
await syncToCodex(config, tempRoot)
const skillPath = path.join(tempRoot, "skills", "skill-one")
expect((await fs.lstat(skillPath)).isSymbolicLink()).toBe(true)
const content = await fs.readFile(configPath, "utf8")
expect(content).toContain("[custom]")
expect(content).toContain("[post]")
expect(content).not.toContain("[mcp_servers.old]")
expect(content).toContain("[mcp_servers.local]")
expect(content).toContain("command = \"echo\"")
expect(content).toContain("[mcp_servers.remote]")
expect(content).toContain("url = \"https://example.com/mcp\"")
expect(content).toContain("http_headers")
expect(content.match(/# BEGIN compound-plugin Claude Code MCP/g)?.length).toBe(1)
const perms = (await fs.stat(configPath)).mode & 0o777
expect(perms).toBe(0o600)
})
})

View File

@@ -28,6 +28,34 @@ describe("syncToCopilot", () => {
expect(linkedStat.isSymbolicLink()).toBe(true) expect(linkedStat.isSymbolicLink()).toBe(true)
}) })
test("converts personal commands into Copilot skills", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-cmd-"))
const config: ClaudeHomeConfig = {
skills: [],
commands: [
{
name: "workflows:plan",
description: "Planning command",
argumentHint: "[goal]",
body: "Plan the work carefully.",
sourcePath: "/tmp/workflows/plan.md",
},
],
mcpServers: {},
}
await syncToCopilot(config, tempRoot)
const skillContent = await fs.readFile(
path.join(tempRoot, "skills", "workflows-plan", "SKILL.md"),
"utf8",
)
expect(skillContent).toContain("name: workflows-plan")
expect(skillContent).toContain("Planning command")
expect(skillContent).toContain("## Arguments")
})
test("skips skills with invalid names", async () => { test("skips skills with invalid names", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-invalid-")) const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-invalid-"))
@@ -51,7 +79,7 @@ describe("syncToCopilot", () => {
test("merges MCP config with existing file", async () => { test("merges MCP config with existing file", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-merge-")) const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-merge-"))
const mcpPath = path.join(tempRoot, "copilot-mcp-config.json") const mcpPath = path.join(tempRoot, "mcp-config.json")
await fs.writeFile( await fs.writeFile(
mcpPath, mcpPath,
@@ -77,6 +105,7 @@ describe("syncToCopilot", () => {
expect(merged.mcpServers.existing?.command).toBe("node") expect(merged.mcpServers.existing?.command).toBe("node")
expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp") expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
expect(merged.mcpServers.context7?.type).toBe("http")
}) })
test("transforms MCP env var names to COPILOT_MCP_ prefix", async () => { test("transforms MCP env var names to COPILOT_MCP_ prefix", async () => {
@@ -95,7 +124,7 @@ describe("syncToCopilot", () => {
await syncToCopilot(config, tempRoot) await syncToCopilot(config, tempRoot)
const mcpPath = path.join(tempRoot, "copilot-mcp-config.json") const mcpPath = path.join(tempRoot, "mcp-config.json")
const mcpConfig = JSON.parse(await fs.readFile(mcpPath, "utf8")) as { const mcpConfig = JSON.parse(await fs.readFile(mcpPath, "utf8")) as {
mcpServers: Record<string, { env?: Record<string, string> }> mcpServers: Record<string, { env?: Record<string, string> }>
} }
@@ -118,7 +147,7 @@ describe("syncToCopilot", () => {
await syncToCopilot(config, tempRoot) await syncToCopilot(config, tempRoot)
const mcpPath = path.join(tempRoot, "copilot-mcp-config.json") const mcpPath = path.join(tempRoot, "mcp-config.json")
const stat = await fs.stat(mcpPath) const stat = await fs.stat(mcpPath)
// Check owner read+write permission (0o600 = 33216 in decimal, masked to file perms) // Check owner read+write permission (0o600 = 33216 in decimal, masked to file perms)
const perms = stat.mode & 0o777 const perms = stat.mode & 0o777
@@ -142,7 +171,34 @@ describe("syncToCopilot", () => {
await syncToCopilot(config, tempRoot) await syncToCopilot(config, tempRoot)
const mcpExists = await fs.access(path.join(tempRoot, "copilot-mcp-config.json")).then(() => true).catch(() => false) const mcpExists = await fs.access(path.join(tempRoot, "mcp-config.json")).then(() => true).catch(() => false)
expect(mcpExists).toBe(false) expect(mcpExists).toBe(false)
}) })
test("preserves explicit SSE transport for legacy remote servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-sse-"))
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
legacy: {
type: "sse",
url: "https://example.com/sse",
},
},
}
await syncToCopilot(config, tempRoot)
const mcpPath = path.join(tempRoot, "mcp-config.json")
const mcpConfig = JSON.parse(await fs.readFile(mcpPath, "utf8")) as {
mcpServers: Record<string, { type?: string; url?: string }>
}
expect(mcpConfig.mcpServers.legacy).toEqual({
type: "sse",
tools: ["*"],
url: "https://example.com/sse",
})
})
}) })

View File

@@ -6,7 +6,7 @@ import { syncToDroid } from "../src/sync/droid"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home" import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
describe("syncToDroid", () => { describe("syncToDroid", () => {
test("symlinks skills to factory skills dir", async () => { test("symlinks skills to factory skills dir and writes mcp.json", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-droid-")) const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-droid-"))
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one") const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
@@ -29,9 +29,49 @@ describe("syncToDroid", () => {
const linkedStat = await fs.lstat(linkedSkillPath) const linkedStat = await fs.lstat(linkedSkillPath)
expect(linkedStat.isSymbolicLink()).toBe(true) expect(linkedStat.isSymbolicLink()).toBe(true)
// Droid does not write MCP config const mcpConfig = JSON.parse(
const mcpExists = await fs.access(path.join(tempRoot, "mcp.json")).then(() => true).catch(() => false) await fs.readFile(path.join(tempRoot, "mcp.json"), "utf8"),
expect(mcpExists).toBe(false) ) as {
mcpServers: Record<string, { type: string; url?: string; disabled: boolean }>
}
expect(mcpConfig.mcpServers.context7?.type).toBe("http")
expect(mcpConfig.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
expect(mcpConfig.mcpServers.context7?.disabled).toBe(false)
})
test("merges existing mcp.json and overwrites same-named servers from Claude", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-droid-merge-"))
await fs.writeFile(
path.join(tempRoot, "mcp.json"),
JSON.stringify({
theme: "dark",
mcpServers: {
shared: { type: "http", url: "https://old.example.com", disabled: true },
existing: { type: "stdio", command: "node", disabled: false },
},
}, null, 2),
)
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
shared: { url: "https://new.example.com" },
},
}
await syncToDroid(config, tempRoot)
const mcpConfig = JSON.parse(
await fs.readFile(path.join(tempRoot, "mcp.json"), "utf8"),
) as {
theme: string
mcpServers: Record<string, { type: string; url?: string; command?: string; disabled: boolean }>
}
expect(mcpConfig.theme).toBe("dark")
expect(mcpConfig.mcpServers.existing?.command).toBe("node")
expect(mcpConfig.mcpServers.shared?.url).toBe("https://new.example.com")
expect(mcpConfig.mcpServers.shared?.disabled).toBe(false)
}) })
test("skips skills with invalid names", async () => { test("skips skills with invalid names", async () => {

View File

@@ -77,6 +77,33 @@ describe("syncToGemini", () => {
expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp") expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
}) })
test("writes personal commands as Gemini TOML prompts", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-cmd-"))
const config: ClaudeHomeConfig = {
skills: [],
commands: [
{
name: "workflows:plan",
description: "Planning command",
argumentHint: "[goal]",
body: "Plan the work carefully.",
sourcePath: "/tmp/workflows/plan.md",
},
],
mcpServers: {},
}
await syncToGemini(config, tempRoot)
const content = await fs.readFile(
path.join(tempRoot, "commands", "workflows", "plan.toml"),
"utf8",
)
expect(content).toContain("Planning command")
expect(content).toContain("User request: {{args}}")
})
test("does not write settings.json when no MCP servers", async () => { test("does not write settings.json when no MCP servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-nomcp-")) 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 fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
@@ -103,4 +130,31 @@ describe("syncToGemini", () => {
const settingsExists = await fs.access(path.join(tempRoot, "settings.json")).then(() => true).catch(() => false) const settingsExists = await fs.access(path.join(tempRoot, "settings.json")).then(() => true).catch(() => false)
expect(settingsExists).toBe(false) expect(settingsExists).toBe(false)
}) })
test("skips mirrored ~/.agents skills when syncing to ~/.gemini and removes stale duplicate symlinks", async () => {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "sync-gemini-home-"))
const geminiRoot = path.join(tempHome, ".gemini")
const agentsSkillDir = path.join(tempHome, ".agents", "skills", "skill-one")
await fs.mkdir(path.join(agentsSkillDir), { recursive: true })
await fs.writeFile(path.join(agentsSkillDir, "SKILL.md"), "# Skill One\n", "utf8")
await fs.mkdir(path.join(geminiRoot, "skills"), { recursive: true })
await fs.symlink(agentsSkillDir, path.join(geminiRoot, "skills", "skill-one"))
const config: ClaudeHomeConfig = {
skills: [
{
name: "skill-one",
sourceDir: agentsSkillDir,
skillPath: path.join(agentsSkillDir, "SKILL.md"),
},
],
mcpServers: {},
}
await syncToGemini(config, geminiRoot)
const duplicateExists = await fs.access(path.join(geminiRoot, "skills", "skill-one")).then(() => true).catch(() => false)
expect(duplicateExists).toBe(false)
})
}) })

83
tests/sync-kiro.test.ts Normal file
View File

@@ -0,0 +1,83 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
import { syncToKiro } from "../src/sync/kiro"
describe("syncToKiro", () => {
test("writes user-scope settings/mcp.json with local and remote servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-kiro-"))
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: {
local: { command: "echo", args: ["hello"], env: { TOKEN: "secret" } },
remote: { url: "https://example.com/mcp", headers: { Authorization: "Bearer token" } },
},
}
await syncToKiro(config, tempRoot)
expect((await fs.lstat(path.join(tempRoot, "skills", "skill-one"))).isSymbolicLink()).toBe(true)
const content = JSON.parse(
await fs.readFile(path.join(tempRoot, "settings", "mcp.json"), "utf8"),
) as {
mcpServers: Record<string, {
command?: string
args?: string[]
env?: Record<string, string>
url?: string
headers?: Record<string, string>
}>
}
expect(content.mcpServers.local?.command).toBe("echo")
expect(content.mcpServers.local?.args).toEqual(["hello"])
expect(content.mcpServers.local?.env).toEqual({ TOKEN: "secret" })
expect(content.mcpServers.remote?.url).toBe("https://example.com/mcp")
expect(content.mcpServers.remote?.headers).toEqual({ Authorization: "Bearer token" })
})
test("merges existing settings/mcp.json", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-kiro-merge-"))
await fs.mkdir(path.join(tempRoot, "settings"), { recursive: true })
await fs.writeFile(
path.join(tempRoot, "settings", "mcp.json"),
JSON.stringify({
note: "preserve",
mcpServers: {
existing: { command: "node" },
},
}, null, 2),
)
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
remote: { url: "https://example.com/mcp" },
},
}
await syncToKiro(config, tempRoot)
const content = JSON.parse(
await fs.readFile(path.join(tempRoot, "settings", "mcp.json"), "utf8"),
) as {
note: string
mcpServers: Record<string, { command?: string; url?: string }>
}
expect(content.note).toBe("preserve")
expect(content.mcpServers.existing?.command).toBe("node")
expect(content.mcpServers.remote?.url).toBe("https://example.com/mcp")
})
})

View File

@@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
import { syncToOpenClaw } from "../src/sync/openclaw"
describe("syncToOpenClaw", () => {
test("symlinks skills and warns instead of writing unvalidated MCP config", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-openclaw-"))
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
const warnings: string[] = []
const originalWarn = console.warn
console.warn = (message?: unknown) => {
warnings.push(String(message))
}
try {
const config: ClaudeHomeConfig = {
skills: [
{
name: "skill-one",
sourceDir: fixtureSkillDir,
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
},
],
commands: [
{
name: "workflows:plan",
description: "Planning command",
body: "Plan the work.",
sourcePath: "/tmp/workflows/plan.md",
},
],
mcpServers: {
remote: { url: "https://example.com/mcp" },
},
}
await syncToOpenClaw(config, tempRoot)
} finally {
console.warn = originalWarn
}
expect((await fs.lstat(path.join(tempRoot, "skills", "skill-one"))).isSymbolicLink()).toBe(true)
const openclawConfigExists = await fs.access(path.join(tempRoot, "openclaw.json")).then(() => true).catch(() => false)
expect(openclawConfigExists).toBe(false)
expect(warnings.some((warning) => warning.includes("OpenClaw personal command sync is skipped"))).toBe(true)
expect(warnings.some((warning) => warning.includes("OpenClaw MCP sync is skipped"))).toBe(true)
})
})

75
tests/sync-qwen.test.ts Normal file
View File

@@ -0,0 +1,75 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
import { syncToQwen } from "../src/sync/qwen"
describe("syncToQwen", () => {
test("defaults ambiguous remote URLs to httpUrl and warns", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-qwen-"))
const warnings: string[] = []
const originalWarn = console.warn
console.warn = (message?: unknown) => {
warnings.push(String(message))
}
try {
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
remote: { url: "https://example.com/mcp", headers: { Authorization: "Bearer token" } },
},
}
await syncToQwen(config, tempRoot)
} finally {
console.warn = originalWarn
}
const content = JSON.parse(
await fs.readFile(path.join(tempRoot, "settings.json"), "utf8"),
) as {
mcpServers: Record<string, { httpUrl?: string; url?: string; headers?: Record<string, string> }>
}
expect(content.mcpServers.remote?.httpUrl).toBe("https://example.com/mcp")
expect(content.mcpServers.remote?.url).toBeUndefined()
expect(content.mcpServers.remote?.headers).toEqual({ Authorization: "Bearer token" })
expect(warnings.some((warning) => warning.includes("ambiguous remote transport"))).toBe(true)
})
test("uses legacy url only for explicit SSE servers and preserves existing settings", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-qwen-sse-"))
await fs.writeFile(
path.join(tempRoot, "settings.json"),
JSON.stringify({
theme: "dark",
mcpServers: {
existing: { command: "node" },
},
}, null, 2),
)
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
legacy: { type: "sse", url: "https://example.com/sse" },
},
}
await syncToQwen(config, tempRoot)
const content = JSON.parse(
await fs.readFile(path.join(tempRoot, "settings.json"), "utf8"),
) as {
theme: string
mcpServers: Record<string, { command?: string; httpUrl?: string; url?: string }>
}
expect(content.theme).toBe("dark")
expect(content.mcpServers.existing?.command).toBe("node")
expect(content.mcpServers.legacy?.url).toBe("https://example.com/sse")
expect(content.mcpServers.legacy?.httpUrl).toBeUndefined()
})
})

View File

@@ -0,0 +1,89 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
import { syncToWindsurf } from "../src/sync/windsurf"
describe("syncToWindsurf", () => {
test("writes stdio, http, and sse MCP servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-windsurf-"))
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: {
local: { command: "npx", args: ["serve"], env: { FOO: "bar" } },
remoteHttp: { url: "https://example.com/mcp", headers: { Authorization: "Bearer a" } },
remoteSse: { type: "sse", url: "https://example.com/sse" },
},
}
await syncToWindsurf(config, tempRoot)
expect((await fs.lstat(path.join(tempRoot, "skills", "skill-one"))).isSymbolicLink()).toBe(true)
const content = JSON.parse(
await fs.readFile(path.join(tempRoot, "mcp_config.json"), "utf8"),
) as {
mcpServers: Record<string, {
command?: string
args?: string[]
env?: Record<string, string>
serverUrl?: string
url?: string
}>
}
expect(content.mcpServers.local).toEqual({
command: "npx",
args: ["serve"],
env: { FOO: "bar" },
})
expect(content.mcpServers.remoteHttp?.serverUrl).toBe("https://example.com/mcp")
expect(content.mcpServers.remoteSse?.url).toBe("https://example.com/sse")
const perms = (await fs.stat(path.join(tempRoot, "mcp_config.json"))).mode & 0o777
expect(perms).toBe(0o600)
})
test("merges existing config and overwrites same-named servers", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-windsurf-merge-"))
await fs.writeFile(
path.join(tempRoot, "mcp_config.json"),
JSON.stringify({
theme: "dark",
mcpServers: {
existing: { command: "node" },
shared: { serverUrl: "https://old.example.com" },
},
}, null, 2),
)
const config: ClaudeHomeConfig = {
skills: [],
mcpServers: {
shared: { url: "https://new.example.com" },
},
}
await syncToWindsurf(config, tempRoot)
const content = JSON.parse(
await fs.readFile(path.join(tempRoot, "mcp_config.json"), "utf8"),
) as {
theme: string
mcpServers: Record<string, { command?: string; serverUrl?: string }>
}
expect(content.theme).toBe("dark")
expect(content.mcpServers.existing?.command).toBe("node")
expect(content.mcpServers.shared?.serverUrl).toBe("https://new.example.com")
})
})