@every-env/compound-plugin 0.12.0 → 2.34.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.github/workflows/publish.yml +20 -10
- package/.releaserc.json +36 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +39 -0
- package/CLAUDE.md +14 -0
- package/README.md +35 -2
- package/bun.lock +977 -0
- package/docs/plans/2026-03-01-fix-setup-skill-non-claude-llm-fallback-plan.md +140 -0
- package/docs/plans/2026-03-03-feat-sync-claude-mcp-all-supported-providers-plan.md +639 -0
- package/docs/solutions/adding-converter-target-providers.md +2 -1
- package/docs/solutions/plugin-versioning-requirements.md +4 -0
- package/package.json +10 -4
- package/plugins/compound-engineering/.claude-plugin/plugin.json +1 -1
- package/plugins/compound-engineering/CHANGELOG.md +10 -0
- package/plugins/compound-engineering/CLAUDE.md +5 -0
- package/plugins/compound-engineering/skills/create-agent-skills/workflows/add-workflow.md +6 -0
- package/plugins/compound-engineering/skills/create-agent-skills/workflows/create-new-skill.md +6 -0
- package/plugins/compound-engineering/skills/setup/SKILL.md +6 -0
- package/src/commands/sync.ts +21 -60
- package/src/index.ts +2 -1
- package/src/parsers/claude-home.ts +55 -3
- package/src/sync/codex.ts +38 -62
- package/src/sync/commands.ts +198 -0
- package/src/sync/copilot.ts +14 -36
- package/src/sync/droid.ts +50 -9
- package/src/sync/gemini.ts +87 -28
- package/src/sync/json-config.ts +47 -0
- package/src/sync/kiro.ts +49 -0
- package/src/sync/mcp-transports.ts +19 -0
- package/src/sync/openclaw.ts +18 -0
- package/src/sync/opencode.ts +10 -30
- package/src/sync/pi.ts +12 -36
- package/src/sync/qwen.ts +66 -0
- package/src/sync/registry.ts +141 -0
- package/src/sync/skills.ts +21 -0
- package/src/sync/windsurf.ts +59 -0
- package/src/types/kiro.ts +3 -1
- package/src/types/qwen.ts +3 -0
- package/src/types/windsurf.ts +1 -0
- package/src/utils/codex-agents.ts +1 -1
- package/src/utils/detect-tools.ts +4 -13
- package/src/utils/files.ts +7 -0
- package/src/utils/symlink.ts +4 -6
- package/tests/claude-home.test.ts +46 -0
- package/tests/cli.test.ts +102 -0
- package/tests/detect-tools.test.ts +30 -7
- package/tests/sync-codex.test.ts +64 -0
- package/tests/sync-copilot.test.ts +60 -4
- package/tests/sync-droid.test.ts +44 -4
- package/tests/sync-gemini.test.ts +54 -0
- package/tests/sync-kiro.test.ts +83 -0
- package/tests/sync-openclaw.test.ts +51 -0
- package/tests/sync-qwen.test.ts +75 -0
- package/tests/sync-windsurf.test.ts +89 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import os from "os"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import type { ClaudeHomeConfig } from "../parsers/claude-home"
|
|
4
|
+
import { syncToCodex } from "./codex"
|
|
5
|
+
import { syncToCopilot } from "./copilot"
|
|
6
|
+
import { syncToDroid } from "./droid"
|
|
7
|
+
import { syncToGemini } from "./gemini"
|
|
8
|
+
import { syncToKiro } from "./kiro"
|
|
9
|
+
import { syncToOpenClaw } from "./openclaw"
|
|
10
|
+
import { syncToOpenCode } from "./opencode"
|
|
11
|
+
import { syncToPi } from "./pi"
|
|
12
|
+
import { syncToQwen } from "./qwen"
|
|
13
|
+
import { syncToWindsurf } from "./windsurf"
|
|
14
|
+
|
|
15
|
+
function getCopilotHomeRoot(home: string): string {
|
|
16
|
+
return path.join(home, ".copilot")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getGeminiHomeRoot(home: string): string {
|
|
20
|
+
return path.join(home, ".gemini")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type SyncTargetName =
|
|
24
|
+
| "opencode"
|
|
25
|
+
| "codex"
|
|
26
|
+
| "pi"
|
|
27
|
+
| "droid"
|
|
28
|
+
| "copilot"
|
|
29
|
+
| "gemini"
|
|
30
|
+
| "windsurf"
|
|
31
|
+
| "kiro"
|
|
32
|
+
| "qwen"
|
|
33
|
+
| "openclaw"
|
|
34
|
+
|
|
35
|
+
export type SyncTargetDefinition = {
|
|
36
|
+
name: SyncTargetName
|
|
37
|
+
detectPaths: (home: string, cwd: string) => string[]
|
|
38
|
+
resolveOutputRoot: (home: string, cwd: string) => string
|
|
39
|
+
sync: (config: ClaudeHomeConfig, outputRoot: string) => Promise<void>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const syncTargets: SyncTargetDefinition[] = [
|
|
43
|
+
{
|
|
44
|
+
name: "opencode",
|
|
45
|
+
detectPaths: (home, cwd) => [
|
|
46
|
+
path.join(home, ".config", "opencode"),
|
|
47
|
+
path.join(cwd, ".opencode"),
|
|
48
|
+
],
|
|
49
|
+
resolveOutputRoot: (home) => path.join(home, ".config", "opencode"),
|
|
50
|
+
sync: syncToOpenCode,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "codex",
|
|
54
|
+
detectPaths: (home) => [path.join(home, ".codex")],
|
|
55
|
+
resolveOutputRoot: (home) => path.join(home, ".codex"),
|
|
56
|
+
sync: syncToCodex,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "pi",
|
|
60
|
+
detectPaths: (home) => [path.join(home, ".pi")],
|
|
61
|
+
resolveOutputRoot: (home) => path.join(home, ".pi", "agent"),
|
|
62
|
+
sync: syncToPi,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "droid",
|
|
66
|
+
detectPaths: (home) => [path.join(home, ".factory")],
|
|
67
|
+
resolveOutputRoot: (home) => path.join(home, ".factory"),
|
|
68
|
+
sync: syncToDroid,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: "copilot",
|
|
72
|
+
detectPaths: (home, cwd) => [
|
|
73
|
+
getCopilotHomeRoot(home),
|
|
74
|
+
path.join(cwd, ".github", "skills"),
|
|
75
|
+
path.join(cwd, ".github", "agents"),
|
|
76
|
+
path.join(cwd, ".github", "copilot-instructions.md"),
|
|
77
|
+
],
|
|
78
|
+
resolveOutputRoot: (home) => getCopilotHomeRoot(home),
|
|
79
|
+
sync: syncToCopilot,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "gemini",
|
|
83
|
+
detectPaths: (home, cwd) => [
|
|
84
|
+
path.join(cwd, ".gemini"),
|
|
85
|
+
getGeminiHomeRoot(home),
|
|
86
|
+
],
|
|
87
|
+
resolveOutputRoot: (home) => getGeminiHomeRoot(home),
|
|
88
|
+
sync: syncToGemini,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "windsurf",
|
|
92
|
+
detectPaths: (home, cwd) => [
|
|
93
|
+
path.join(home, ".codeium", "windsurf"),
|
|
94
|
+
path.join(cwd, ".windsurf"),
|
|
95
|
+
],
|
|
96
|
+
resolveOutputRoot: (home) => path.join(home, ".codeium", "windsurf"),
|
|
97
|
+
sync: syncToWindsurf,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "kiro",
|
|
101
|
+
detectPaths: (home, cwd) => [
|
|
102
|
+
path.join(home, ".kiro"),
|
|
103
|
+
path.join(cwd, ".kiro"),
|
|
104
|
+
],
|
|
105
|
+
resolveOutputRoot: (home) => path.join(home, ".kiro"),
|
|
106
|
+
sync: syncToKiro,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "qwen",
|
|
110
|
+
detectPaths: (home, cwd) => [
|
|
111
|
+
path.join(home, ".qwen"),
|
|
112
|
+
path.join(cwd, ".qwen"),
|
|
113
|
+
],
|
|
114
|
+
resolveOutputRoot: (home) => path.join(home, ".qwen"),
|
|
115
|
+
sync: syncToQwen,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: "openclaw",
|
|
119
|
+
detectPaths: (home) => [path.join(home, ".openclaw")],
|
|
120
|
+
resolveOutputRoot: (home) => path.join(home, ".openclaw"),
|
|
121
|
+
sync: syncToOpenClaw,
|
|
122
|
+
},
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
export const syncTargetNames = syncTargets.map((target) => target.name)
|
|
126
|
+
|
|
127
|
+
export function isSyncTargetName(value: string): value is SyncTargetName {
|
|
128
|
+
return syncTargetNames.includes(value as SyncTargetName)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function getSyncTarget(name: SyncTargetName): SyncTargetDefinition {
|
|
132
|
+
const target = syncTargets.find((entry) => entry.name === name)
|
|
133
|
+
if (!target) {
|
|
134
|
+
throw new Error(`Unknown sync target: ${name}`)
|
|
135
|
+
}
|
|
136
|
+
return target
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function getDefaultSyncRegistryContext(): { home: string; cwd: string } {
|
|
140
|
+
return { home: os.homedir(), cwd: process.cwd() }
|
|
141
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import path from "path"
|
|
2
|
+
import type { ClaudeSkill } from "../types/claude"
|
|
3
|
+
import { ensureDir } from "../utils/files"
|
|
4
|
+
import { forceSymlink, isValidSkillName } from "../utils/symlink"
|
|
5
|
+
|
|
6
|
+
export async function syncSkills(
|
|
7
|
+
skills: ClaudeSkill[],
|
|
8
|
+
skillsDir: string,
|
|
9
|
+
): Promise<void> {
|
|
10
|
+
await ensureDir(skillsDir)
|
|
11
|
+
|
|
12
|
+
for (const skill of skills) {
|
|
13
|
+
if (!isValidSkillName(skill.name)) {
|
|
14
|
+
console.warn(`Skipping skill with invalid name: ${skill.name}`)
|
|
15
|
+
continue
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const target = path.join(skillsDir, skill.name)
|
|
19
|
+
await forceSymlink(skill.sourceDir, target)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from "path"
|
|
2
|
+
import type { ClaudeHomeConfig } from "../parsers/claude-home"
|
|
3
|
+
import type { ClaudeMcpServer } from "../types/claude"
|
|
4
|
+
import type { WindsurfMcpServerEntry } from "../types/windsurf"
|
|
5
|
+
import { syncWindsurfCommands } from "./commands"
|
|
6
|
+
import { mergeJsonConfigAtKey } from "./json-config"
|
|
7
|
+
import { hasExplicitSseTransport } from "./mcp-transports"
|
|
8
|
+
import { syncSkills } from "./skills"
|
|
9
|
+
|
|
10
|
+
export async function syncToWindsurf(
|
|
11
|
+
config: ClaudeHomeConfig,
|
|
12
|
+
outputRoot: string,
|
|
13
|
+
): Promise<void> {
|
|
14
|
+
await syncSkills(config.skills, path.join(outputRoot, "skills"))
|
|
15
|
+
await syncWindsurfCommands(config, outputRoot, "global")
|
|
16
|
+
|
|
17
|
+
if (Object.keys(config.mcpServers).length > 0) {
|
|
18
|
+
await mergeJsonConfigAtKey({
|
|
19
|
+
configPath: path.join(outputRoot, "mcp_config.json"),
|
|
20
|
+
key: "mcpServers",
|
|
21
|
+
incoming: convertMcpForWindsurf(config.mcpServers),
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function convertMcpForWindsurf(
|
|
27
|
+
servers: Record<string, ClaudeMcpServer>,
|
|
28
|
+
): Record<string, WindsurfMcpServerEntry> {
|
|
29
|
+
const result: Record<string, WindsurfMcpServerEntry> = {}
|
|
30
|
+
|
|
31
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
32
|
+
if (server.command) {
|
|
33
|
+
result[name] = {
|
|
34
|
+
command: server.command,
|
|
35
|
+
args: server.args,
|
|
36
|
+
env: server.env,
|
|
37
|
+
}
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!server.url) {
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const entry: WindsurfMcpServerEntry = {
|
|
46
|
+
headers: server.headers,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (hasExplicitSseTransport(server)) {
|
|
50
|
+
entry.url = server.url
|
|
51
|
+
} else {
|
|
52
|
+
entry.serverUrl = server.url
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
result[name] = entry
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return result
|
|
59
|
+
}
|
package/src/types/kiro.ts
CHANGED
|
@@ -30,9 +30,11 @@ export type KiroSteeringFile = {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export type KiroMcpServer = {
|
|
33
|
-
command
|
|
33
|
+
command?: string
|
|
34
34
|
args?: string[]
|
|
35
35
|
env?: Record<string, string>
|
|
36
|
+
url?: string
|
|
37
|
+
headers?: Record<string, string>
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
export type KiroBundle = {
|
package/src/types/qwen.ts
CHANGED
package/src/types/windsurf.ts
CHANGED
|
@@ -18,7 +18,7 @@ Tool mapping:
|
|
|
18
18
|
- Glob: use rg --files or find
|
|
19
19
|
- LS: use ls via shell_command
|
|
20
20
|
- WebFetch/WebSearch: use curl or Context7 for library docs
|
|
21
|
-
- AskUserQuestion/Question:
|
|
21
|
+
- AskUserQuestion/Question: present choices as a numbered list in chat and wait for a reply number. For multi-select (multiSelect: true), accept comma-separated numbers. Never skip or auto-configure — always wait for the user's response before proceeding.
|
|
22
22
|
- Task/Subagent/Parallel: run sequentially in main thread; use multi_tool_use.parallel for tool calls
|
|
23
23
|
- TodoWrite/TodoRead: use file-based todos in todos/ with file-todos skill
|
|
24
24
|
- Skill: open the referenced SKILL.md and follow it
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import os from "os"
|
|
2
|
-
import path from "path"
|
|
3
2
|
import { pathExists } from "./files"
|
|
3
|
+
import { syncTargets } from "../sync/registry"
|
|
4
4
|
|
|
5
5
|
export type DetectedTool = {
|
|
6
6
|
name: string
|
|
@@ -12,27 +12,18 @@ export async function detectInstalledTools(
|
|
|
12
12
|
home: string = os.homedir(),
|
|
13
13
|
cwd: string = process.cwd(),
|
|
14
14
|
): Promise<DetectedTool[]> {
|
|
15
|
-
const checks: Array<{ name: string; paths: string[] }> = [
|
|
16
|
-
{ name: "opencode", paths: [path.join(home, ".config", "opencode"), path.join(cwd, ".opencode")] },
|
|
17
|
-
{ name: "codex", paths: [path.join(home, ".codex")] },
|
|
18
|
-
{ name: "droid", paths: [path.join(home, ".factory")] },
|
|
19
|
-
{ name: "cursor", paths: [path.join(cwd, ".cursor"), path.join(home, ".cursor")] },
|
|
20
|
-
{ name: "pi", paths: [path.join(home, ".pi")] },
|
|
21
|
-
{ name: "gemini", paths: [path.join(cwd, ".gemini"), path.join(home, ".gemini")] },
|
|
22
|
-
]
|
|
23
|
-
|
|
24
15
|
const results: DetectedTool[] = []
|
|
25
|
-
for (const
|
|
16
|
+
for (const target of syncTargets) {
|
|
26
17
|
let detected = false
|
|
27
18
|
let reason = "not found"
|
|
28
|
-
for (const p of
|
|
19
|
+
for (const p of target.detectPaths(home, cwd)) {
|
|
29
20
|
if (await pathExists(p)) {
|
|
30
21
|
detected = true
|
|
31
22
|
reason = `found ${p}`
|
|
32
23
|
break
|
|
33
24
|
}
|
|
34
25
|
}
|
|
35
|
-
results.push({ name:
|
|
26
|
+
results.push({ name: target.name, detected, reason })
|
|
36
27
|
}
|
|
37
28
|
return results
|
|
38
29
|
}
|
package/src/utils/files.ts
CHANGED
|
@@ -41,6 +41,12 @@ export async function writeText(filePath: string, content: string): Promise<void
|
|
|
41
41
|
await fs.writeFile(filePath, content, "utf8")
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
export async function writeTextSecure(filePath: string, content: string): Promise<void> {
|
|
45
|
+
await ensureDir(path.dirname(filePath))
|
|
46
|
+
await fs.writeFile(filePath, content, { encoding: "utf8", mode: 0o600 })
|
|
47
|
+
await fs.chmod(filePath, 0o600)
|
|
48
|
+
}
|
|
49
|
+
|
|
44
50
|
export async function writeJson(filePath: string, data: unknown): Promise<void> {
|
|
45
51
|
const content = JSON.stringify(data, null, 2)
|
|
46
52
|
await writeText(filePath, content + "\n")
|
|
@@ -51,6 +57,7 @@ export async function writeJsonSecure(filePath: string, data: unknown): Promise<
|
|
|
51
57
|
const content = JSON.stringify(data, null, 2)
|
|
52
58
|
await ensureDir(path.dirname(filePath))
|
|
53
59
|
await fs.writeFile(filePath, content + "\n", { encoding: "utf8", mode: 0o600 })
|
|
60
|
+
await fs.chmod(filePath, 0o600)
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
export async function walkFiles(root: string): Promise<string[]> {
|
package/src/utils/symlink.ts
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "fs/promises"
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Create a symlink, safely replacing any existing symlink at target.
|
|
5
|
-
* Only removes existing symlinks -
|
|
5
|
+
* Only removes existing symlinks - skips real directories with a warning.
|
|
6
6
|
*/
|
|
7
7
|
export async function forceSymlink(source: string, target: string): Promise<void> {
|
|
8
8
|
try {
|
|
@@ -11,11 +11,9 @@ export async function forceSymlink(source: string, target: string): Promise<void
|
|
|
11
11
|
// Safe to remove existing symlink
|
|
12
12
|
await fs.unlink(target)
|
|
13
13
|
} else if (stat.isDirectory()) {
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
`Remove it manually if you want to replace it with a symlink.`
|
|
18
|
-
)
|
|
14
|
+
// Skip real directories rather than deleting them
|
|
15
|
+
console.warn(`Skipping ${target}: a real directory exists there (remove it manually to replace with a symlink).`)
|
|
16
|
+
return
|
|
19
17
|
} else {
|
|
20
18
|
// Regular file - remove it
|
|
21
19
|
await fs.unlink(target)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { promises as fs } from "fs"
|
|
3
|
+
import os from "os"
|
|
4
|
+
import path from "path"
|
|
5
|
+
import { loadClaudeHome } from "../src/parsers/claude-home"
|
|
6
|
+
|
|
7
|
+
describe("loadClaudeHome", () => {
|
|
8
|
+
test("loads personal skills, commands, and MCP servers", async () => {
|
|
9
|
+
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "claude-home-"))
|
|
10
|
+
const skillDir = path.join(tempHome, "skills", "reviewer")
|
|
11
|
+
const commandsDir = path.join(tempHome, "commands")
|
|
12
|
+
|
|
13
|
+
await fs.mkdir(skillDir, { recursive: true })
|
|
14
|
+
await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: reviewer\n---\nReview things.\n")
|
|
15
|
+
|
|
16
|
+
await fs.mkdir(path.join(commandsDir, "workflows"), { recursive: true })
|
|
17
|
+
await fs.writeFile(
|
|
18
|
+
path.join(commandsDir, "workflows", "plan.md"),
|
|
19
|
+
"---\ndescription: Planning command\nargument-hint: \"[feature]\"\n---\nPlan the work.\n",
|
|
20
|
+
)
|
|
21
|
+
await fs.writeFile(
|
|
22
|
+
path.join(commandsDir, "custom.md"),
|
|
23
|
+
"---\nname: custom-command\ndescription: Custom command\nallowed-tools: Bash, Read\n---\nDo custom work.\n",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
await fs.writeFile(
|
|
27
|
+
path.join(tempHome, "settings.json"),
|
|
28
|
+
JSON.stringify({
|
|
29
|
+
mcpServers: {
|
|
30
|
+
context7: { url: "https://mcp.context7.com/mcp" },
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const config = await loadClaudeHome(tempHome)
|
|
36
|
+
|
|
37
|
+
expect(config.skills.map((skill) => skill.name)).toEqual(["reviewer"])
|
|
38
|
+
expect(config.commands?.map((command) => command.name)).toEqual([
|
|
39
|
+
"custom-command",
|
|
40
|
+
"workflows:plan",
|
|
41
|
+
])
|
|
42
|
+
expect(config.commands?.find((command) => command.name === "workflows:plan")?.argumentHint).toBe("[feature]")
|
|
43
|
+
expect(config.commands?.find((command) => command.name === "custom-command")?.allowedTools).toEqual(["Bash", "Read"])
|
|
44
|
+
expect(config.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp")
|
|
45
|
+
})
|
|
46
|
+
})
|
package/tests/cli.test.ts
CHANGED
|
@@ -504,4 +504,106 @@ describe("CLI", () => {
|
|
|
504
504
|
expect(json).toHaveProperty("permission")
|
|
505
505
|
expect(json.permission).not.toBeNull()
|
|
506
506
|
})
|
|
507
|
+
|
|
508
|
+
test("sync --target all detects new sync targets and ignores stale cursor directories", async () => {
|
|
509
|
+
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "cli-sync-home-"))
|
|
510
|
+
const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "cli-sync-cwd-"))
|
|
511
|
+
const repoRoot = path.join(import.meta.dir, "..")
|
|
512
|
+
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
|
|
513
|
+
const claudeSkillsDir = path.join(tempHome, ".claude", "skills", "skill-one")
|
|
514
|
+
const claudeCommandsDir = path.join(tempHome, ".claude", "commands", "workflows")
|
|
515
|
+
|
|
516
|
+
await fs.mkdir(path.dirname(claudeSkillsDir), { recursive: true })
|
|
517
|
+
await fs.cp(fixtureSkillDir, claudeSkillsDir, { recursive: true })
|
|
518
|
+
await fs.mkdir(claudeCommandsDir, { recursive: true })
|
|
519
|
+
await fs.writeFile(
|
|
520
|
+
path.join(claudeCommandsDir, "plan.md"),
|
|
521
|
+
[
|
|
522
|
+
"---",
|
|
523
|
+
"name: workflows:plan",
|
|
524
|
+
"description: Plan work",
|
|
525
|
+
"argument-hint: \"[goal]\"",
|
|
526
|
+
"---",
|
|
527
|
+
"",
|
|
528
|
+
"Plan the work.",
|
|
529
|
+
].join("\n"),
|
|
530
|
+
)
|
|
531
|
+
await fs.writeFile(
|
|
532
|
+
path.join(tempHome, ".claude", "settings.json"),
|
|
533
|
+
JSON.stringify({
|
|
534
|
+
mcpServers: {
|
|
535
|
+
local: { command: "echo", args: ["hello"] },
|
|
536
|
+
remote: { url: "https://example.com/mcp" },
|
|
537
|
+
legacy: { type: "sse", url: "https://example.com/sse" },
|
|
538
|
+
},
|
|
539
|
+
}, null, 2),
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
await fs.mkdir(path.join(tempHome, ".config", "opencode"), { recursive: true })
|
|
543
|
+
await fs.mkdir(path.join(tempHome, ".codex"), { recursive: true })
|
|
544
|
+
await fs.mkdir(path.join(tempHome, ".pi"), { recursive: true })
|
|
545
|
+
await fs.mkdir(path.join(tempHome, ".factory"), { recursive: true })
|
|
546
|
+
await fs.mkdir(path.join(tempHome, ".copilot"), { recursive: true })
|
|
547
|
+
await fs.mkdir(path.join(tempHome, ".gemini"), { recursive: true })
|
|
548
|
+
await fs.mkdir(path.join(tempHome, ".codeium", "windsurf"), { recursive: true })
|
|
549
|
+
await fs.mkdir(path.join(tempHome, ".kiro"), { recursive: true })
|
|
550
|
+
await fs.mkdir(path.join(tempHome, ".qwen"), { recursive: true })
|
|
551
|
+
await fs.mkdir(path.join(tempHome, ".openclaw"), { recursive: true })
|
|
552
|
+
await fs.mkdir(path.join(tempCwd, ".cursor"), { recursive: true })
|
|
553
|
+
|
|
554
|
+
const proc = Bun.spawn([
|
|
555
|
+
"bun",
|
|
556
|
+
"run",
|
|
557
|
+
path.join(repoRoot, "src", "index.ts"),
|
|
558
|
+
"sync",
|
|
559
|
+
"--target",
|
|
560
|
+
"all",
|
|
561
|
+
], {
|
|
562
|
+
cwd: tempCwd,
|
|
563
|
+
stdout: "pipe",
|
|
564
|
+
stderr: "pipe",
|
|
565
|
+
env: {
|
|
566
|
+
...process.env,
|
|
567
|
+
HOME: tempHome,
|
|
568
|
+
},
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
const exitCode = await proc.exited
|
|
572
|
+
const stdout = await new Response(proc.stdout).text()
|
|
573
|
+
const stderr = await new Response(proc.stderr).text()
|
|
574
|
+
|
|
575
|
+
if (exitCode !== 0) {
|
|
576
|
+
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
expect(stdout).toContain("Synced to codex")
|
|
580
|
+
expect(stdout).toContain("Synced to opencode")
|
|
581
|
+
expect(stdout).toContain("Synced to pi")
|
|
582
|
+
expect(stdout).toContain("Synced to droid")
|
|
583
|
+
expect(stdout).toContain("Synced to windsurf")
|
|
584
|
+
expect(stdout).toContain("Synced to kiro")
|
|
585
|
+
expect(stdout).toContain("Synced to qwen")
|
|
586
|
+
expect(stdout).toContain("Synced to openclaw")
|
|
587
|
+
expect(stdout).toContain("Synced to copilot")
|
|
588
|
+
expect(stdout).toContain("Synced to gemini")
|
|
589
|
+
expect(stdout).not.toContain("cursor")
|
|
590
|
+
|
|
591
|
+
expect(await exists(path.join(tempHome, ".config", "opencode", "commands", "workflows:plan.md"))).toBe(true)
|
|
592
|
+
expect(await exists(path.join(tempHome, ".codex", "config.toml"))).toBe(true)
|
|
593
|
+
expect(await exists(path.join(tempHome, ".codex", "prompts", "workflows-plan.md"))).toBe(true)
|
|
594
|
+
expect(await exists(path.join(tempHome, ".codex", "skills", "workflows-plan", "SKILL.md"))).toBe(true)
|
|
595
|
+
expect(await exists(path.join(tempHome, ".pi", "agent", "prompts", "workflows-plan.md"))).toBe(true)
|
|
596
|
+
expect(await exists(path.join(tempHome, ".factory", "commands", "plan.md"))).toBe(true)
|
|
597
|
+
expect(await exists(path.join(tempHome, ".codeium", "windsurf", "mcp_config.json"))).toBe(true)
|
|
598
|
+
expect(await exists(path.join(tempHome, ".codeium", "windsurf", "global_workflows", "workflows-plan.md"))).toBe(true)
|
|
599
|
+
expect(await exists(path.join(tempHome, ".kiro", "settings", "mcp.json"))).toBe(true)
|
|
600
|
+
expect(await exists(path.join(tempHome, ".kiro", "skills", "workflows-plan", "SKILL.md"))).toBe(true)
|
|
601
|
+
expect(await exists(path.join(tempHome, ".qwen", "settings.json"))).toBe(true)
|
|
602
|
+
expect(await exists(path.join(tempHome, ".qwen", "commands", "workflows", "plan.md"))).toBe(true)
|
|
603
|
+
expect(await exists(path.join(tempHome, ".copilot", "mcp-config.json"))).toBe(true)
|
|
604
|
+
expect(await exists(path.join(tempHome, ".copilot", "skills", "workflows-plan", "SKILL.md"))).toBe(true)
|
|
605
|
+
expect(await exists(path.join(tempHome, ".gemini", "settings.json"))).toBe(true)
|
|
606
|
+
expect(await exists(path.join(tempHome, ".gemini", "commands", "workflows", "plan.toml"))).toBe(true)
|
|
607
|
+
expect(await exists(path.join(tempHome, ".openclaw", "skills", "skill-one"))).toBe(true)
|
|
608
|
+
})
|
|
507
609
|
})
|
|
@@ -11,8 +11,9 @@ describe("detectInstalledTools", () => {
|
|
|
11
11
|
|
|
12
12
|
// Create directories for some tools
|
|
13
13
|
await fs.mkdir(path.join(tempHome, ".codex"), { recursive: true })
|
|
14
|
-
await fs.mkdir(path.join(
|
|
15
|
-
await fs.mkdir(path.join(
|
|
14
|
+
await fs.mkdir(path.join(tempHome, ".codeium", "windsurf"), { recursive: true })
|
|
15
|
+
await fs.mkdir(path.join(tempHome, ".gemini"), { recursive: true })
|
|
16
|
+
await fs.mkdir(path.join(tempHome, ".copilot"), { recursive: true })
|
|
16
17
|
|
|
17
18
|
const results = await detectInstalledTools(tempHome, tempCwd)
|
|
18
19
|
|
|
@@ -20,14 +21,18 @@ describe("detectInstalledTools", () => {
|
|
|
20
21
|
expect(codex?.detected).toBe(true)
|
|
21
22
|
expect(codex?.reason).toContain(".codex")
|
|
22
23
|
|
|
23
|
-
const
|
|
24
|
-
expect(
|
|
25
|
-
expect(
|
|
24
|
+
const windsurf = results.find((t) => t.name === "windsurf")
|
|
25
|
+
expect(windsurf?.detected).toBe(true)
|
|
26
|
+
expect(windsurf?.reason).toContain(".codeium/windsurf")
|
|
26
27
|
|
|
27
28
|
const gemini = results.find((t) => t.name === "gemini")
|
|
28
29
|
expect(gemini?.detected).toBe(true)
|
|
29
30
|
expect(gemini?.reason).toContain(".gemini")
|
|
30
31
|
|
|
32
|
+
const copilot = results.find((t) => t.name === "copilot")
|
|
33
|
+
expect(copilot?.detected).toBe(true)
|
|
34
|
+
expect(copilot?.reason).toContain(".copilot")
|
|
35
|
+
|
|
31
36
|
// Tools without directories should not be detected
|
|
32
37
|
const opencode = results.find((t) => t.name === "opencode")
|
|
33
38
|
expect(opencode?.detected).toBe(false)
|
|
@@ -45,7 +50,7 @@ describe("detectInstalledTools", () => {
|
|
|
45
50
|
|
|
46
51
|
const results = await detectInstalledTools(tempHome, tempCwd)
|
|
47
52
|
|
|
48
|
-
expect(results.length).toBe(
|
|
53
|
+
expect(results.length).toBe(10)
|
|
49
54
|
for (const tool of results) {
|
|
50
55
|
expect(tool.detected).toBe(false)
|
|
51
56
|
expect(tool.reason).toBe("not found")
|
|
@@ -59,12 +64,30 @@ describe("detectInstalledTools", () => {
|
|
|
59
64
|
await fs.mkdir(path.join(tempHome, ".config", "opencode"), { recursive: true })
|
|
60
65
|
await fs.mkdir(path.join(tempHome, ".factory"), { recursive: true })
|
|
61
66
|
await fs.mkdir(path.join(tempHome, ".pi"), { recursive: true })
|
|
67
|
+
await fs.mkdir(path.join(tempHome, ".openclaw"), { recursive: true })
|
|
62
68
|
|
|
63
69
|
const results = await detectInstalledTools(tempHome, tempCwd)
|
|
64
70
|
|
|
65
71
|
expect(results.find((t) => t.name === "opencode")?.detected).toBe(true)
|
|
66
72
|
expect(results.find((t) => t.name === "droid")?.detected).toBe(true)
|
|
67
73
|
expect(results.find((t) => t.name === "pi")?.detected).toBe(true)
|
|
74
|
+
expect(results.find((t) => t.name === "openclaw")?.detected).toBe(true)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test("detects copilot from project-specific skills without generic .github false positives", async () => {
|
|
78
|
+
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "detect-copilot-home-"))
|
|
79
|
+
const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-copilot-cwd-"))
|
|
80
|
+
|
|
81
|
+
await fs.mkdir(path.join(tempCwd, ".github"), { recursive: true })
|
|
82
|
+
|
|
83
|
+
let results = await detectInstalledTools(tempHome, tempCwd)
|
|
84
|
+
expect(results.find((t) => t.name === "copilot")?.detected).toBe(false)
|
|
85
|
+
|
|
86
|
+
await fs.mkdir(path.join(tempCwd, ".github", "skills"), { recursive: true })
|
|
87
|
+
|
|
88
|
+
results = await detectInstalledTools(tempHome, tempCwd)
|
|
89
|
+
expect(results.find((t) => t.name === "copilot")?.detected).toBe(true)
|
|
90
|
+
expect(results.find((t) => t.name === "copilot")?.reason).toContain(".github/skills")
|
|
68
91
|
})
|
|
69
92
|
})
|
|
70
93
|
|
|
@@ -74,7 +97,7 @@ describe("getDetectedTargetNames", () => {
|
|
|
74
97
|
const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-names-cwd-"))
|
|
75
98
|
|
|
76
99
|
await fs.mkdir(path.join(tempHome, ".codex"), { recursive: true })
|
|
77
|
-
await fs.mkdir(path.join(
|
|
100
|
+
await fs.mkdir(path.join(tempHome, ".gemini"), { recursive: true })
|
|
78
101
|
|
|
79
102
|
const names = await getDetectedTargetNames(tempHome, tempCwd)
|
|
80
103
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { promises as fs } from "fs"
|
|
3
|
+
import os from "os"
|
|
4
|
+
import path from "path"
|
|
5
|
+
import type { ClaudeHomeConfig } from "../src/parsers/claude-home"
|
|
6
|
+
import { syncToCodex } from "../src/sync/codex"
|
|
7
|
+
|
|
8
|
+
describe("syncToCodex", () => {
|
|
9
|
+
test("writes stdio and remote MCP servers into a managed block without clobbering user config", async () => {
|
|
10
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-codex-"))
|
|
11
|
+
const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one")
|
|
12
|
+
const configPath = path.join(tempRoot, "config.toml")
|
|
13
|
+
|
|
14
|
+
await fs.writeFile(
|
|
15
|
+
configPath,
|
|
16
|
+
[
|
|
17
|
+
"[custom]",
|
|
18
|
+
"enabled = true",
|
|
19
|
+
"",
|
|
20
|
+
"# BEGIN compound-plugin Claude Code MCP",
|
|
21
|
+
"[mcp_servers.old]",
|
|
22
|
+
"command = \"old\"",
|
|
23
|
+
"# END compound-plugin Claude Code MCP",
|
|
24
|
+
"",
|
|
25
|
+
"[post]",
|
|
26
|
+
"value = 2",
|
|
27
|
+
"",
|
|
28
|
+
].join("\n"),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
const config: ClaudeHomeConfig = {
|
|
32
|
+
skills: [
|
|
33
|
+
{
|
|
34
|
+
name: "skill-one",
|
|
35
|
+
sourceDir: fixtureSkillDir,
|
|
36
|
+
skillPath: path.join(fixtureSkillDir, "SKILL.md"),
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
mcpServers: {
|
|
40
|
+
local: { command: "echo", args: ["hello"], env: { KEY: "VALUE" } },
|
|
41
|
+
remote: { url: "https://example.com/mcp", headers: { Authorization: "Bearer token" } },
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await syncToCodex(config, tempRoot)
|
|
46
|
+
|
|
47
|
+
const skillPath = path.join(tempRoot, "skills", "skill-one")
|
|
48
|
+
expect((await fs.lstat(skillPath)).isSymbolicLink()).toBe(true)
|
|
49
|
+
|
|
50
|
+
const content = await fs.readFile(configPath, "utf8")
|
|
51
|
+
expect(content).toContain("[custom]")
|
|
52
|
+
expect(content).toContain("[post]")
|
|
53
|
+
expect(content).not.toContain("[mcp_servers.old]")
|
|
54
|
+
expect(content).toContain("[mcp_servers.local]")
|
|
55
|
+
expect(content).toContain("command = \"echo\"")
|
|
56
|
+
expect(content).toContain("[mcp_servers.remote]")
|
|
57
|
+
expect(content).toContain("url = \"https://example.com/mcp\"")
|
|
58
|
+
expect(content).toContain("http_headers")
|
|
59
|
+
expect(content.match(/# BEGIN compound-plugin Claude Code MCP/g)?.length).toBe(1)
|
|
60
|
+
|
|
61
|
+
const perms = (await fs.stat(configPath)).mode & 0o777
|
|
62
|
+
expect(perms).toBe(0o600)
|
|
63
|
+
})
|
|
64
|
+
})
|