@every-env/compound-plugin 0.9.0 → 2.34.2

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.
Files changed (121) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.github/workflows/publish.yml +20 -10
  3. package/.releaserc.json +31 -0
  4. package/AGENTS.md +6 -1
  5. package/CHANGELOG.md +76 -0
  6. package/CLAUDE.md +16 -3
  7. package/README.md +83 -16
  8. package/bun.lock +977 -0
  9. package/docs/plans/2026-02-14-feat-auto-detect-install-and-gemini-sync-plan.md +360 -0
  10. package/docs/plans/2026-02-25-feat-windsurf-global-scope-support-plan.md +627 -0
  11. package/docs/plans/2026-03-01-feat-ce-command-aliases-backwards-compatible-deprecation-plan.md +261 -0
  12. package/docs/plans/2026-03-01-fix-setup-skill-non-claude-llm-fallback-plan.md +140 -0
  13. package/docs/plans/2026-03-03-feat-sync-claude-mcp-all-supported-providers-plan.md +639 -0
  14. package/docs/plans/feature_opencode-commands-as-md-and-config-merge.md +574 -0
  15. package/docs/solutions/adding-converter-target-providers.md +693 -0
  16. package/docs/solutions/plugin-versioning-requirements.md +7 -3
  17. package/docs/specs/windsurf.md +477 -0
  18. package/package.json +10 -4
  19. package/plans/landing-page-launchkit-refresh.md +2 -2
  20. package/plugins/compound-engineering/.claude-plugin/plugin.json +2 -2
  21. package/plugins/compound-engineering/CHANGELOG.md +82 -1
  22. package/plugins/compound-engineering/CLAUDE.md +14 -7
  23. package/plugins/compound-engineering/README.md +10 -7
  24. package/plugins/compound-engineering/agents/research/git-history-analyzer.md +1 -1
  25. package/plugins/compound-engineering/agents/research/learnings-researcher.md +1 -1
  26. package/plugins/compound-engineering/agents/review/code-simplicity-reviewer.md +1 -1
  27. package/plugins/compound-engineering/commands/ce/brainstorm.md +145 -0
  28. package/plugins/compound-engineering/commands/ce/compound.md +240 -0
  29. package/plugins/compound-engineering/commands/ce/plan.md +636 -0
  30. package/plugins/compound-engineering/commands/ce/review.md +525 -0
  31. package/plugins/compound-engineering/commands/ce/work.md +470 -0
  32. package/plugins/compound-engineering/commands/create-agent-skill.md +1 -1
  33. package/plugins/compound-engineering/commands/deepen-plan.md +6 -6
  34. package/plugins/compound-engineering/commands/deploy-docs.md +1 -1
  35. package/plugins/compound-engineering/commands/feature-video.md +15 -6
  36. package/plugins/compound-engineering/commands/heal-skill.md +1 -1
  37. package/plugins/compound-engineering/commands/lfg.md +3 -3
  38. package/plugins/compound-engineering/commands/slfg.md +3 -3
  39. package/plugins/compound-engineering/commands/test-xcode.md +2 -2
  40. package/plugins/compound-engineering/commands/workflows/brainstorm.md +4 -123
  41. package/plugins/compound-engineering/commands/workflows/compound.md +4 -234
  42. package/plugins/compound-engineering/commands/workflows/plan.md +4 -562
  43. package/plugins/compound-engineering/commands/workflows/review.md +4 -522
  44. package/plugins/compound-engineering/commands/workflows/work.md +4 -448
  45. package/plugins/compound-engineering/skills/brainstorming/SKILL.md +3 -3
  46. package/plugins/compound-engineering/skills/create-agent-skills/workflows/add-workflow.md +6 -0
  47. package/plugins/compound-engineering/skills/create-agent-skills/workflows/create-new-skill.md +6 -0
  48. package/plugins/compound-engineering/skills/document-review/SKILL.md +1 -1
  49. package/plugins/compound-engineering/skills/file-todos/SKILL.md +1 -1
  50. package/plugins/compound-engineering/skills/git-worktree/SKILL.md +5 -5
  51. package/plugins/compound-engineering/skills/proof/SKILL.md +185 -0
  52. package/plugins/compound-engineering/skills/resolve-pr-parallel/SKILL.md +1 -1
  53. package/plugins/compound-engineering/skills/setup/SKILL.md +8 -2
  54. package/src/commands/convert.ts +101 -24
  55. package/src/commands/install.ts +102 -45
  56. package/src/commands/sync.ts +43 -62
  57. package/src/converters/claude-to-openclaw.ts +240 -0
  58. package/src/converters/claude-to-opencode.ts +12 -10
  59. package/src/converters/claude-to-qwen.ts +238 -0
  60. package/src/converters/claude-to-windsurf.ts +205 -0
  61. package/src/index.ts +2 -1
  62. package/src/parsers/claude-home.ts +55 -3
  63. package/src/sync/codex.ts +38 -62
  64. package/src/sync/commands.ts +198 -0
  65. package/src/sync/copilot.ts +14 -36
  66. package/src/sync/droid.ts +50 -9
  67. package/src/sync/gemini.ts +135 -0
  68. package/src/sync/json-config.ts +47 -0
  69. package/src/sync/kiro.ts +49 -0
  70. package/src/sync/mcp-transports.ts +19 -0
  71. package/src/sync/openclaw.ts +18 -0
  72. package/src/sync/opencode.ts +10 -30
  73. package/src/sync/pi.ts +12 -36
  74. package/src/sync/qwen.ts +66 -0
  75. package/src/sync/registry.ts +141 -0
  76. package/src/sync/skills.ts +21 -0
  77. package/src/sync/windsurf.ts +59 -0
  78. package/src/targets/index.ts +60 -1
  79. package/src/targets/openclaw.ts +96 -0
  80. package/src/targets/opencode.ts +76 -10
  81. package/src/targets/qwen.ts +64 -0
  82. package/src/targets/windsurf.ts +104 -0
  83. package/src/types/kiro.ts +3 -1
  84. package/src/types/openclaw.ts +52 -0
  85. package/src/types/opencode.ts +7 -8
  86. package/src/types/qwen.ts +51 -0
  87. package/src/types/windsurf.ts +35 -0
  88. package/src/utils/codex-agents.ts +1 -1
  89. package/src/utils/detect-tools.ts +37 -0
  90. package/src/utils/files.ts +14 -0
  91. package/src/utils/resolve-output.ts +50 -0
  92. package/src/utils/secrets.ts +24 -0
  93. package/src/utils/symlink.ts +4 -6
  94. package/tests/claude-home.test.ts +46 -0
  95. package/tests/cli.test.ts +180 -0
  96. package/tests/converter.test.ts +43 -10
  97. package/tests/detect-tools.test.ts +119 -0
  98. package/tests/openclaw-converter.test.ts +200 -0
  99. package/tests/opencode-writer.test.ts +142 -5
  100. package/tests/qwen-converter.test.ts +238 -0
  101. package/tests/resolve-output.test.ts +131 -0
  102. package/tests/sync-codex.test.ts +64 -0
  103. package/tests/sync-copilot.test.ts +60 -4
  104. package/tests/sync-droid.test.ts +44 -4
  105. package/tests/sync-gemini.test.ts +160 -0
  106. package/tests/sync-kiro.test.ts +83 -0
  107. package/tests/sync-openclaw.test.ts +51 -0
  108. package/tests/sync-qwen.test.ts +75 -0
  109. package/tests/sync-windsurf.test.ts +89 -0
  110. package/tests/windsurf-converter.test.ts +573 -0
  111. package/tests/windsurf-writer.test.ts +359 -0
  112. package/docs/css/docs.css +0 -675
  113. package/docs/css/style.css +0 -2886
  114. package/docs/index.html +0 -1046
  115. package/docs/js/main.js +0 -225
  116. package/docs/pages/agents.html +0 -649
  117. package/docs/pages/changelog.html +0 -534
  118. package/docs/pages/commands.html +0 -523
  119. package/docs/pages/getting-started.html +0 -582
  120. package/docs/pages/mcp-servers.html +0 -409
  121. package/docs/pages/skills.html +0 -611
@@ -0,0 +1,104 @@
1
+ import path from "path"
2
+ import { backupFile, copyDir, ensureDir, pathExists, readJson, writeJsonSecure, writeText } from "../utils/files"
3
+ import { formatFrontmatter } from "../utils/frontmatter"
4
+ import type { WindsurfBundle } from "../types/windsurf"
5
+ import type { TargetScope } from "./index"
6
+
7
+ /**
8
+ * Write a WindsurfBundle directly into outputRoot.
9
+ *
10
+ * Unlike other target writers, this writer expects outputRoot to be the final
11
+ * resolved directory — the CLI handles scope-based nesting (global vs workspace).
12
+ */
13
+ export async function writeWindsurfBundle(outputRoot: string, bundle: WindsurfBundle, scope?: TargetScope): Promise<void> {
14
+ await ensureDir(outputRoot)
15
+
16
+ // Write agent skills (before pass-through copies so pass-through takes precedence on collision)
17
+ if (bundle.agentSkills.length > 0) {
18
+ const skillsDir = path.join(outputRoot, "skills")
19
+ await ensureDir(skillsDir)
20
+ for (const skill of bundle.agentSkills) {
21
+ validatePathSafe(skill.name, "agent skill")
22
+ const destDir = path.join(skillsDir, skill.name)
23
+
24
+ const resolvedDest = path.resolve(destDir)
25
+ if (!resolvedDest.startsWith(path.resolve(skillsDir))) {
26
+ console.warn(`Warning: Agent skill name "${skill.name}" escapes skills/. Skipping.`)
27
+ continue
28
+ }
29
+
30
+ await ensureDir(destDir)
31
+ await writeText(path.join(destDir, "SKILL.md"), skill.content)
32
+ }
33
+ }
34
+
35
+ // Write command workflows (flat in global_workflows/ for global scope, workflows/ for workspace)
36
+ if (bundle.commandWorkflows.length > 0) {
37
+ const workflowsDirName = scope === "global" ? "global_workflows" : "workflows"
38
+ const workflowsDir = path.join(outputRoot, workflowsDirName)
39
+ await ensureDir(workflowsDir)
40
+ for (const workflow of bundle.commandWorkflows) {
41
+ validatePathSafe(workflow.name, "command workflow")
42
+ const content = formatWorkflowContent(workflow.name, workflow.description, workflow.body)
43
+ await writeText(path.join(workflowsDir, `${workflow.name}.md`), content)
44
+ }
45
+ }
46
+
47
+ // Copy pass-through skill directories (after generated skills so copies overwrite on collision)
48
+ if (bundle.skillDirs.length > 0) {
49
+ const skillsDir = path.join(outputRoot, "skills")
50
+ await ensureDir(skillsDir)
51
+ for (const skill of bundle.skillDirs) {
52
+ validatePathSafe(skill.name, "skill directory")
53
+ const destDir = path.join(skillsDir, skill.name)
54
+
55
+ const resolvedDest = path.resolve(destDir)
56
+ if (!resolvedDest.startsWith(path.resolve(skillsDir))) {
57
+ console.warn(`Warning: Skill name "${skill.name}" escapes skills/. Skipping.`)
58
+ continue
59
+ }
60
+
61
+ await copyDir(skill.sourceDir, destDir)
62
+ }
63
+ }
64
+
65
+ // Merge MCP config
66
+ if (bundle.mcpConfig) {
67
+ const mcpPath = path.join(outputRoot, "mcp_config.json")
68
+ const backupPath = await backupFile(mcpPath)
69
+ if (backupPath) {
70
+ console.log(`Backed up existing mcp_config.json to ${backupPath}`)
71
+ }
72
+
73
+ let existingConfig: Record<string, unknown> = {}
74
+ if (await pathExists(mcpPath)) {
75
+ try {
76
+ const parsed = await readJson<unknown>(mcpPath)
77
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
78
+ existingConfig = parsed as Record<string, unknown>
79
+ }
80
+ } catch {
81
+ console.warn("Warning: existing mcp_config.json could not be parsed and will be replaced.")
82
+ }
83
+ }
84
+
85
+ const existingServers =
86
+ existingConfig.mcpServers &&
87
+ typeof existingConfig.mcpServers === "object" &&
88
+ !Array.isArray(existingConfig.mcpServers)
89
+ ? (existingConfig.mcpServers as Record<string, unknown>)
90
+ : {}
91
+ const merged = { ...existingConfig, mcpServers: { ...existingServers, ...bundle.mcpConfig.mcpServers } }
92
+ await writeJsonSecure(mcpPath, merged)
93
+ }
94
+ }
95
+
96
+ function validatePathSafe(name: string, label: string): void {
97
+ if (name.includes("..") || name.includes("/") || name.includes("\\")) {
98
+ throw new Error(`${label} name contains unsafe path characters: ${name}`)
99
+ }
100
+ }
101
+
102
+ function formatWorkflowContent(name: string, description: string, body: string): string {
103
+ return formatFrontmatter({ description }, `# ${name}\n\n${body}`) + "\n"
104
+ }
package/src/types/kiro.ts CHANGED
@@ -30,9 +30,11 @@ export type KiroSteeringFile = {
30
30
  }
31
31
 
32
32
  export type KiroMcpServer = {
33
- command: string
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 = {
@@ -0,0 +1,52 @@
1
+ export type OpenClawPluginManifest = {
2
+ id: string
3
+ name: string
4
+ kind: "tool"
5
+ configSchema?: {
6
+ type: "object"
7
+ additionalProperties: boolean
8
+ properties: Record<string, OpenClawConfigProperty>
9
+ required?: string[]
10
+ }
11
+ uiHints?: Record<string, OpenClawUiHint>
12
+ skills?: string[]
13
+ }
14
+
15
+ export type OpenClawConfigProperty = {
16
+ type: string
17
+ description?: string
18
+ default?: unknown
19
+ }
20
+
21
+ export type OpenClawUiHint = {
22
+ label: string
23
+ sensitive?: boolean
24
+ placeholder?: string
25
+ }
26
+
27
+ export type OpenClawSkillFile = {
28
+ name: string
29
+ content: string
30
+ /** Subdirectory path inside skills/ (e.g. "agent-native-reviewer") */
31
+ dir: string
32
+ }
33
+
34
+ export type OpenClawCommandRegistration = {
35
+ name: string
36
+ description: string
37
+ acceptsArgs: boolean
38
+ /** The prompt body that becomes the command handler response */
39
+ body: string
40
+ }
41
+
42
+ export type OpenClawBundle = {
43
+ manifest: OpenClawPluginManifest
44
+ packageJson: Record<string, unknown>
45
+ entryPoint: string
46
+ skills: OpenClawSkillFile[]
47
+ /** Skill directories to copy verbatim (original Claude skills with references/) */
48
+ skillDirCopies: { sourceDir: string; name: string }[]
49
+ commands: OpenClawCommandRegistration[]
50
+ /** openclaw.json fragment for MCP servers */
51
+ openclawConfig?: Record<string, unknown>
52
+ }
@@ -7,7 +7,6 @@ export type OpenCodeConfig = {
7
7
  tools?: Record<string, boolean>
8
8
  permission?: Record<string, OpenCodePermission | Record<string, OpenCodePermission>>
9
9
  agent?: Record<string, OpenCodeAgentConfig>
10
- command?: Record<string, OpenCodeCommandConfig>
11
10
  mcp?: Record<string, OpenCodeMcpServer>
12
11
  }
13
12
 
@@ -20,13 +19,6 @@ export type OpenCodeAgentConfig = {
20
19
  permission?: Record<string, OpenCodePermission>
21
20
  }
22
21
 
23
- export type OpenCodeCommandConfig = {
24
- description?: string
25
- model?: string
26
- agent?: string
27
- template: string
28
- }
29
-
30
22
  export type OpenCodeMcpServer = {
31
23
  type: "local" | "remote"
32
24
  command?: string[]
@@ -46,9 +38,16 @@ export type OpenCodePluginFile = {
46
38
  content: string
47
39
  }
48
40
 
41
+ export type OpenCodeCommandFile = {
42
+ name: string
43
+ content: string
44
+ }
45
+
49
46
  export type OpenCodeBundle = {
50
47
  config: OpenCodeConfig
51
48
  agents: OpenCodeAgentFile[]
49
+ // Commands are written as individual .md files, not in opencode.json. See ADR-001.
50
+ commandFiles: OpenCodeCommandFile[]
52
51
  plugins: OpenCodePluginFile[]
53
52
  skillDirs: { sourceDir: string; name: string }[]
54
53
  }
@@ -0,0 +1,51 @@
1
+ export type QwenExtensionConfig = {
2
+ name: string
3
+ version: string
4
+ mcpServers?: Record<string, QwenMcpServer>
5
+ contextFileName?: string
6
+ commands?: string
7
+ skills?: string
8
+ agents?: string
9
+ settings?: QwenSetting[]
10
+ }
11
+
12
+ export type QwenMcpServer = {
13
+ command?: string
14
+ args?: string[]
15
+ env?: Record<string, string>
16
+ cwd?: string
17
+ httpUrl?: string
18
+ url?: string
19
+ headers?: Record<string, string>
20
+ }
21
+
22
+ export type QwenSetting = {
23
+ name: string
24
+ description: string
25
+ envVar: string
26
+ sensitive?: boolean
27
+ }
28
+
29
+ export type QwenAgentFile = {
30
+ name: string
31
+ content: string
32
+ format: "yaml" | "markdown"
33
+ }
34
+
35
+ export type QwenSkillDir = {
36
+ sourceDir: string
37
+ name: string
38
+ }
39
+
40
+ export type QwenCommandFile = {
41
+ name: string
42
+ content: string
43
+ }
44
+
45
+ export type QwenBundle = {
46
+ config: QwenExtensionConfig
47
+ agents: QwenAgentFile[]
48
+ commandFiles: QwenCommandFile[]
49
+ skillDirs: QwenSkillDir[]
50
+ contextFile?: string
51
+ }
@@ -0,0 +1,35 @@
1
+ export type WindsurfWorkflow = {
2
+ name: string
3
+ description: string
4
+ body: string
5
+ }
6
+
7
+ export type WindsurfGeneratedSkill = {
8
+ name: string
9
+ content: string
10
+ }
11
+
12
+ export type WindsurfSkillDir = {
13
+ name: string
14
+ sourceDir: string
15
+ }
16
+
17
+ export type WindsurfMcpServerEntry = {
18
+ command?: string
19
+ args?: string[]
20
+ env?: Record<string, string>
21
+ serverUrl?: string
22
+ url?: string
23
+ headers?: Record<string, string>
24
+ }
25
+
26
+ export type WindsurfMcpConfig = {
27
+ mcpServers: Record<string, WindsurfMcpServerEntry>
28
+ }
29
+
30
+ export type WindsurfBundle = {
31
+ agentSkills: WindsurfGeneratedSkill[]
32
+ commandWorkflows: WindsurfWorkflow[]
33
+ skillDirs: WindsurfSkillDir[]
34
+ mcpConfig: WindsurfMcpConfig | null
35
+ }
@@ -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: ask the user in chat
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
@@ -0,0 +1,37 @@
1
+ import os from "os"
2
+ import { pathExists } from "./files"
3
+ import { syncTargets } from "../sync/registry"
4
+
5
+ export type DetectedTool = {
6
+ name: string
7
+ detected: boolean
8
+ reason: string
9
+ }
10
+
11
+ export async function detectInstalledTools(
12
+ home: string = os.homedir(),
13
+ cwd: string = process.cwd(),
14
+ ): Promise<DetectedTool[]> {
15
+ const results: DetectedTool[] = []
16
+ for (const target of syncTargets) {
17
+ let detected = false
18
+ let reason = "not found"
19
+ for (const p of target.detectPaths(home, cwd)) {
20
+ if (await pathExists(p)) {
21
+ detected = true
22
+ reason = `found ${p}`
23
+ break
24
+ }
25
+ }
26
+ results.push({ name: target.name, detected, reason })
27
+ }
28
+ return results
29
+ }
30
+
31
+ export async function getDetectedTargetNames(
32
+ home: string = os.homedir(),
33
+ cwd: string = process.cwd(),
34
+ ): Promise<string[]> {
35
+ const tools = await detectInstalledTools(home, cwd)
36
+ return tools.filter((t) => t.detected).map((t) => t.name)
37
+ }
@@ -41,11 +41,25 @@ 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")
47
53
  }
48
54
 
55
+ /** Write JSON with restrictive permissions (0o600) for files containing secrets */
56
+ export async function writeJsonSecure(filePath: string, data: unknown): Promise<void> {
57
+ const content = JSON.stringify(data, null, 2)
58
+ await ensureDir(path.dirname(filePath))
59
+ await fs.writeFile(filePath, content + "\n", { encoding: "utf8", mode: 0o600 })
60
+ await fs.chmod(filePath, 0o600)
61
+ }
62
+
49
63
  export async function walkFiles(root: string): Promise<string[]> {
50
64
  const entries = await fs.readdir(root, { withFileTypes: true })
51
65
  const results: string[] = []
@@ -0,0 +1,50 @@
1
+ import os from "os"
2
+ import path from "path"
3
+ import type { TargetScope } from "../targets"
4
+
5
+ export function resolveTargetOutputRoot(options: {
6
+ targetName: string
7
+ outputRoot: string
8
+ codexHome: string
9
+ piHome: string
10
+ openclawHome?: string
11
+ qwenHome?: string
12
+ pluginName?: string
13
+ hasExplicitOutput: boolean
14
+ scope?: TargetScope
15
+ }): string {
16
+ const { targetName, outputRoot, codexHome, piHome, openclawHome, qwenHome, pluginName, hasExplicitOutput, scope } = options
17
+ if (targetName === "codex") return codexHome
18
+ if (targetName === "pi") return piHome
19
+ if (targetName === "droid") return path.join(os.homedir(), ".factory")
20
+ if (targetName === "cursor") {
21
+ const base = hasExplicitOutput ? outputRoot : process.cwd()
22
+ return path.join(base, ".cursor")
23
+ }
24
+ if (targetName === "gemini") {
25
+ const base = hasExplicitOutput ? outputRoot : process.cwd()
26
+ return path.join(base, ".gemini")
27
+ }
28
+ if (targetName === "copilot") {
29
+ const base = hasExplicitOutput ? outputRoot : process.cwd()
30
+ return path.join(base, ".github")
31
+ }
32
+ if (targetName === "kiro") {
33
+ const base = hasExplicitOutput ? outputRoot : process.cwd()
34
+ return path.join(base, ".kiro")
35
+ }
36
+ if (targetName === "windsurf") {
37
+ if (hasExplicitOutput) return outputRoot
38
+ if (scope === "global") return path.join(os.homedir(), ".codeium", "windsurf")
39
+ return path.join(process.cwd(), ".windsurf")
40
+ }
41
+ if (targetName === "openclaw") {
42
+ const home = openclawHome ?? path.join(os.homedir(), ".openclaw", "extensions")
43
+ return path.join(home, pluginName ?? "plugin")
44
+ }
45
+ if (targetName === "qwen") {
46
+ const home = qwenHome ?? path.join(os.homedir(), ".qwen", "extensions")
47
+ return path.join(home, pluginName ?? "plugin")
48
+ }
49
+ return outputRoot
50
+ }
@@ -0,0 +1,24 @@
1
+ export const SENSITIVE_PATTERN = /key|token|secret|password|credential|api_key/i
2
+
3
+ /** Check if any MCP servers have env vars that might contain secrets */
4
+ export function hasPotentialSecrets(
5
+ servers: Record<string, { env?: Record<string, string> }>,
6
+ ): boolean {
7
+ for (const server of Object.values(servers)) {
8
+ if (server.env) {
9
+ for (const key of Object.keys(server.env)) {
10
+ if (SENSITIVE_PATTERN.test(key)) return true
11
+ }
12
+ }
13
+ }
14
+ return false
15
+ }
16
+
17
+ /** Return names of MCP servers whose env vars may contain secrets */
18
+ export function findServersWithPotentialSecrets(
19
+ servers: Record<string, { env?: Record<string, string> }>,
20
+ ): string[] {
21
+ return Object.entries(servers)
22
+ .filter(([, s]) => s.env && Object.keys(s.env).some((k) => SENSITIVE_PATTERN.test(k)))
23
+ .map(([name]) => name)
24
+ }
@@ -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 - refuses to delete real directories.
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
- // Refuse to delete real directories
15
- throw new Error(
16
- `Cannot create symlink at ${target}: a real directory exists there. ` +
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
+ })