@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.github/workflows/publish.yml +20 -10
- package/.releaserc.json +31 -0
- package/AGENTS.md +6 -1
- package/CHANGELOG.md +76 -0
- package/CLAUDE.md +16 -3
- package/README.md +83 -16
- package/bun.lock +977 -0
- package/docs/plans/2026-02-14-feat-auto-detect-install-and-gemini-sync-plan.md +360 -0
- package/docs/plans/2026-02-25-feat-windsurf-global-scope-support-plan.md +627 -0
- package/docs/plans/2026-03-01-feat-ce-command-aliases-backwards-compatible-deprecation-plan.md +261 -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/plans/feature_opencode-commands-as-md-and-config-merge.md +574 -0
- package/docs/solutions/adding-converter-target-providers.md +693 -0
- package/docs/solutions/plugin-versioning-requirements.md +7 -3
- package/docs/specs/windsurf.md +477 -0
- package/package.json +10 -4
- package/plans/landing-page-launchkit-refresh.md +2 -2
- package/plugins/compound-engineering/.claude-plugin/plugin.json +2 -2
- package/plugins/compound-engineering/CHANGELOG.md +82 -1
- package/plugins/compound-engineering/CLAUDE.md +14 -7
- package/plugins/compound-engineering/README.md +10 -7
- package/plugins/compound-engineering/agents/research/git-history-analyzer.md +1 -1
- package/plugins/compound-engineering/agents/research/learnings-researcher.md +1 -1
- package/plugins/compound-engineering/agents/review/code-simplicity-reviewer.md +1 -1
- package/plugins/compound-engineering/commands/ce/brainstorm.md +145 -0
- package/plugins/compound-engineering/commands/ce/compound.md +240 -0
- package/plugins/compound-engineering/commands/ce/plan.md +636 -0
- package/plugins/compound-engineering/commands/ce/review.md +525 -0
- package/plugins/compound-engineering/commands/ce/work.md +470 -0
- package/plugins/compound-engineering/commands/create-agent-skill.md +1 -1
- package/plugins/compound-engineering/commands/deepen-plan.md +6 -6
- package/plugins/compound-engineering/commands/deploy-docs.md +1 -1
- package/plugins/compound-engineering/commands/feature-video.md +15 -6
- package/plugins/compound-engineering/commands/heal-skill.md +1 -1
- package/plugins/compound-engineering/commands/lfg.md +3 -3
- package/plugins/compound-engineering/commands/slfg.md +3 -3
- package/plugins/compound-engineering/commands/test-xcode.md +2 -2
- package/plugins/compound-engineering/commands/workflows/brainstorm.md +4 -123
- package/plugins/compound-engineering/commands/workflows/compound.md +4 -234
- package/plugins/compound-engineering/commands/workflows/plan.md +4 -562
- package/plugins/compound-engineering/commands/workflows/review.md +4 -522
- package/plugins/compound-engineering/commands/workflows/work.md +4 -448
- package/plugins/compound-engineering/skills/brainstorming/SKILL.md +3 -3
- 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/document-review/SKILL.md +1 -1
- package/plugins/compound-engineering/skills/file-todos/SKILL.md +1 -1
- package/plugins/compound-engineering/skills/git-worktree/SKILL.md +5 -5
- package/plugins/compound-engineering/skills/proof/SKILL.md +185 -0
- package/plugins/compound-engineering/skills/resolve-pr-parallel/SKILL.md +1 -1
- package/plugins/compound-engineering/skills/setup/SKILL.md +8 -2
- package/src/commands/convert.ts +101 -24
- package/src/commands/install.ts +102 -45
- package/src/commands/sync.ts +43 -62
- package/src/converters/claude-to-openclaw.ts +240 -0
- package/src/converters/claude-to-opencode.ts +12 -10
- package/src/converters/claude-to-qwen.ts +238 -0
- package/src/converters/claude-to-windsurf.ts +205 -0
- 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 +135 -0
- 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/targets/index.ts +60 -1
- package/src/targets/openclaw.ts +96 -0
- package/src/targets/opencode.ts +76 -10
- package/src/targets/qwen.ts +64 -0
- package/src/targets/windsurf.ts +104 -0
- package/src/types/kiro.ts +3 -1
- package/src/types/openclaw.ts +52 -0
- package/src/types/opencode.ts +7 -8
- package/src/types/qwen.ts +51 -0
- package/src/types/windsurf.ts +35 -0
- package/src/utils/codex-agents.ts +1 -1
- package/src/utils/detect-tools.ts +37 -0
- package/src/utils/files.ts +14 -0
- package/src/utils/resolve-output.ts +50 -0
- package/src/utils/secrets.ts +24 -0
- package/src/utils/symlink.ts +4 -6
- package/tests/claude-home.test.ts +46 -0
- package/tests/cli.test.ts +180 -0
- package/tests/converter.test.ts +43 -10
- package/tests/detect-tools.test.ts +119 -0
- package/tests/openclaw-converter.test.ts +200 -0
- package/tests/opencode-writer.test.ts +142 -5
- package/tests/qwen-converter.test.ts +238 -0
- package/tests/resolve-output.test.ts +131 -0
- 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 +160 -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
- package/tests/windsurf-converter.test.ts +573 -0
- package/tests/windsurf-writer.test.ts +359 -0
- package/docs/css/docs.css +0 -675
- package/docs/css/style.css +0 -2886
- package/docs/index.html +0 -1046
- package/docs/js/main.js +0 -225
- package/docs/pages/agents.html +0 -649
- package/docs/pages/changelog.html +0 -534
- package/docs/pages/commands.html +0 -523
- package/docs/pages/getting-started.html +0 -582
- package/docs/pages/mcp-servers.html +0 -409
- 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
|
|
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
|
+
}
|
package/src/types/opencode.ts
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/src/utils/files.ts
CHANGED
|
@@ -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
|
+
}
|
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
|
+
})
|