@every-env/compound-plugin 0.9.0 → 0.12.0

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 (87) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +5 -1
  3. package/CHANGELOG.md +42 -0
  4. package/CLAUDE.md +3 -3
  5. package/README.md +49 -15
  6. package/docs/plans/2026-02-14-feat-auto-detect-install-and-gemini-sync-plan.md +360 -0
  7. package/docs/plans/2026-02-25-feat-windsurf-global-scope-support-plan.md +627 -0
  8. package/docs/plans/2026-03-01-feat-ce-command-aliases-backwards-compatible-deprecation-plan.md +261 -0
  9. package/docs/plans/feature_opencode-commands-as-md-and-config-merge.md +574 -0
  10. package/docs/solutions/adding-converter-target-providers.md +692 -0
  11. package/docs/solutions/plugin-versioning-requirements.md +3 -3
  12. package/docs/specs/windsurf.md +477 -0
  13. package/package.json +1 -1
  14. package/plans/landing-page-launchkit-refresh.md +2 -2
  15. package/plugins/compound-engineering/.claude-plugin/plugin.json +2 -2
  16. package/plugins/compound-engineering/CHANGELOG.md +72 -1
  17. package/plugins/compound-engineering/CLAUDE.md +9 -7
  18. package/plugins/compound-engineering/README.md +10 -7
  19. package/plugins/compound-engineering/agents/research/git-history-analyzer.md +1 -1
  20. package/plugins/compound-engineering/agents/research/learnings-researcher.md +1 -1
  21. package/plugins/compound-engineering/agents/review/code-simplicity-reviewer.md +1 -1
  22. package/plugins/compound-engineering/commands/ce/brainstorm.md +145 -0
  23. package/plugins/compound-engineering/commands/ce/compound.md +240 -0
  24. package/plugins/compound-engineering/commands/ce/plan.md +636 -0
  25. package/plugins/compound-engineering/commands/ce/review.md +525 -0
  26. package/plugins/compound-engineering/commands/ce/work.md +470 -0
  27. package/plugins/compound-engineering/commands/create-agent-skill.md +1 -1
  28. package/plugins/compound-engineering/commands/deepen-plan.md +6 -6
  29. package/plugins/compound-engineering/commands/deploy-docs.md +1 -1
  30. package/plugins/compound-engineering/commands/feature-video.md +15 -6
  31. package/plugins/compound-engineering/commands/heal-skill.md +1 -1
  32. package/plugins/compound-engineering/commands/lfg.md +3 -3
  33. package/plugins/compound-engineering/commands/slfg.md +3 -3
  34. package/plugins/compound-engineering/commands/test-xcode.md +2 -2
  35. package/plugins/compound-engineering/commands/workflows/brainstorm.md +4 -123
  36. package/plugins/compound-engineering/commands/workflows/compound.md +4 -234
  37. package/plugins/compound-engineering/commands/workflows/plan.md +4 -562
  38. package/plugins/compound-engineering/commands/workflows/review.md +4 -522
  39. package/plugins/compound-engineering/commands/workflows/work.md +4 -448
  40. package/plugins/compound-engineering/skills/brainstorming/SKILL.md +3 -3
  41. package/plugins/compound-engineering/skills/document-review/SKILL.md +1 -1
  42. package/plugins/compound-engineering/skills/file-todos/SKILL.md +1 -1
  43. package/plugins/compound-engineering/skills/git-worktree/SKILL.md +5 -5
  44. package/plugins/compound-engineering/skills/proof/SKILL.md +185 -0
  45. package/plugins/compound-engineering/skills/resolve-pr-parallel/SKILL.md +1 -1
  46. package/plugins/compound-engineering/skills/setup/SKILL.md +2 -2
  47. package/src/commands/convert.ts +101 -24
  48. package/src/commands/install.ts +102 -45
  49. package/src/commands/sync.ts +58 -38
  50. package/src/converters/claude-to-openclaw.ts +240 -0
  51. package/src/converters/claude-to-opencode.ts +12 -10
  52. package/src/converters/claude-to-qwen.ts +238 -0
  53. package/src/converters/claude-to-windsurf.ts +205 -0
  54. package/src/sync/gemini.ts +76 -0
  55. package/src/targets/index.ts +60 -1
  56. package/src/targets/openclaw.ts +96 -0
  57. package/src/targets/opencode.ts +76 -10
  58. package/src/targets/qwen.ts +64 -0
  59. package/src/targets/windsurf.ts +104 -0
  60. package/src/types/openclaw.ts +52 -0
  61. package/src/types/opencode.ts +7 -8
  62. package/src/types/qwen.ts +48 -0
  63. package/src/types/windsurf.ts +34 -0
  64. package/src/utils/detect-tools.ts +46 -0
  65. package/src/utils/files.ts +7 -0
  66. package/src/utils/resolve-output.ts +50 -0
  67. package/src/utils/secrets.ts +24 -0
  68. package/tests/cli.test.ts +78 -0
  69. package/tests/converter.test.ts +43 -10
  70. package/tests/detect-tools.test.ts +96 -0
  71. package/tests/openclaw-converter.test.ts +200 -0
  72. package/tests/opencode-writer.test.ts +142 -5
  73. package/tests/qwen-converter.test.ts +238 -0
  74. package/tests/resolve-output.test.ts +131 -0
  75. package/tests/sync-gemini.test.ts +106 -0
  76. package/tests/windsurf-converter.test.ts +573 -0
  77. package/tests/windsurf-writer.test.ts +359 -0
  78. package/docs/css/docs.css +0 -675
  79. package/docs/css/style.css +0 -2886
  80. package/docs/index.html +0 -1046
  81. package/docs/js/main.js +0 -225
  82. package/docs/pages/agents.html +0 -649
  83. package/docs/pages/changelog.html +0 -534
  84. package/docs/pages/commands.html +0 -523
  85. package/docs/pages/getting-started.html +0 -582
  86. package/docs/pages/mcp-servers.html +0 -409
  87. 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
+ }
@@ -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,48 @@
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
+ }
18
+
19
+ export type QwenSetting = {
20
+ name: string
21
+ description: string
22
+ envVar: string
23
+ sensitive?: boolean
24
+ }
25
+
26
+ export type QwenAgentFile = {
27
+ name: string
28
+ content: string
29
+ format: "yaml" | "markdown"
30
+ }
31
+
32
+ export type QwenSkillDir = {
33
+ sourceDir: string
34
+ name: string
35
+ }
36
+
37
+ export type QwenCommandFile = {
38
+ name: string
39
+ content: string
40
+ }
41
+
42
+ export type QwenBundle = {
43
+ config: QwenExtensionConfig
44
+ agents: QwenAgentFile[]
45
+ commandFiles: QwenCommandFile[]
46
+ skillDirs: QwenSkillDir[]
47
+ contextFile?: string
48
+ }
@@ -0,0 +1,34 @@
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
+ headers?: Record<string, string>
23
+ }
24
+
25
+ export type WindsurfMcpConfig = {
26
+ mcpServers: Record<string, WindsurfMcpServerEntry>
27
+ }
28
+
29
+ export type WindsurfBundle = {
30
+ agentSkills: WindsurfGeneratedSkill[]
31
+ commandWorkflows: WindsurfWorkflow[]
32
+ skillDirs: WindsurfSkillDir[]
33
+ mcpConfig: WindsurfMcpConfig | null
34
+ }
@@ -0,0 +1,46 @@
1
+ import os from "os"
2
+ import path from "path"
3
+ import { pathExists } from "./files"
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 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
+ const results: DetectedTool[] = []
25
+ for (const check of checks) {
26
+ let detected = false
27
+ let reason = "not found"
28
+ for (const p of check.paths) {
29
+ if (await pathExists(p)) {
30
+ detected = true
31
+ reason = `found ${p}`
32
+ break
33
+ }
34
+ }
35
+ results.push({ name: check.name, detected, reason })
36
+ }
37
+ return results
38
+ }
39
+
40
+ export async function getDetectedTargetNames(
41
+ home: string = os.homedir(),
42
+ cwd: string = process.cwd(),
43
+ ): Promise<string[]> {
44
+ const tools = await detectInstalledTools(home, cwd)
45
+ return tools.filter((t) => t.detected).map((t) => t.name)
46
+ }
@@ -46,6 +46,13 @@ export async function writeJson(filePath: string, data: unknown): Promise<void>
46
46
  await writeText(filePath, content + "\n")
47
47
  }
48
48
 
49
+ /** Write JSON with restrictive permissions (0o600) for files containing secrets */
50
+ export async function writeJsonSecure(filePath: string, data: unknown): Promise<void> {
51
+ const content = JSON.stringify(data, null, 2)
52
+ await ensureDir(path.dirname(filePath))
53
+ await fs.writeFile(filePath, content + "\n", { encoding: "utf8", mode: 0o600 })
54
+ }
55
+
49
56
  export async function walkFiles(root: string): Promise<string[]> {
50
57
  const entries = await fs.readdir(root, { withFileTypes: true })
51
58
  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/tests/cli.test.ts CHANGED
@@ -426,4 +426,82 @@ describe("CLI", () => {
426
426
  expect(await exists(path.join(piRoot, "prompts", "workflows-review.md"))).toBe(true)
427
427
  expect(await exists(path.join(piRoot, "extensions", "compound-engineering-compat.ts"))).toBe(true)
428
428
  })
429
+
430
+ test("install --to opencode uses permissions:none by default", async () => {
431
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-perms-none-"))
432
+ const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
433
+
434
+ const proc = Bun.spawn([
435
+ "bun",
436
+ "run",
437
+ "src/index.ts",
438
+ "install",
439
+ fixtureRoot,
440
+ "--to",
441
+ "opencode",
442
+ "--output",
443
+ tempRoot,
444
+ ], {
445
+ cwd: path.join(import.meta.dir, ".."),
446
+ stdout: "pipe",
447
+ stderr: "pipe",
448
+ })
449
+
450
+ const exitCode = await proc.exited
451
+ const stdout = await new Response(proc.stdout).text()
452
+ const stderr = await new Response(proc.stderr).text()
453
+
454
+ if (exitCode !== 0) {
455
+ throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
456
+ }
457
+
458
+ expect(stdout).toContain("Installed compound-engineering")
459
+
460
+ const opencodeJsonPath = path.join(tempRoot, "opencode.json")
461
+ const content = await fs.readFile(opencodeJsonPath, "utf-8")
462
+ const json = JSON.parse(content)
463
+
464
+ expect(json).not.toHaveProperty("permission")
465
+ expect(json).not.toHaveProperty("tools")
466
+ })
467
+
468
+ test("install --to opencode --permissions broad writes permission block", async () => {
469
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-perms-broad-"))
470
+ const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
471
+
472
+ const proc = Bun.spawn([
473
+ "bun",
474
+ "run",
475
+ "src/index.ts",
476
+ "install",
477
+ fixtureRoot,
478
+ "--to",
479
+ "opencode",
480
+ "--permissions",
481
+ "broad",
482
+ "--output",
483
+ tempRoot,
484
+ ], {
485
+ cwd: path.join(import.meta.dir, ".."),
486
+ stdout: "pipe",
487
+ stderr: "pipe",
488
+ })
489
+
490
+ const exitCode = await proc.exited
491
+ const stdout = await new Response(proc.stdout).text()
492
+ const stderr = await new Response(proc.stderr).text()
493
+
494
+ if (exitCode !== 0) {
495
+ throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
496
+ }
497
+
498
+ expect(stdout).toContain("Installed compound-engineering")
499
+
500
+ const opencodeJsonPath = path.join(tempRoot, "opencode.json")
501
+ const content = await fs.readFile(opencodeJsonPath, "utf-8")
502
+ const json = JSON.parse(content)
503
+
504
+ expect(json).toHaveProperty("permission")
505
+ expect(json.permission).not.toBeNull()
506
+ })
429
507
  })
@@ -8,7 +8,7 @@ import type { ClaudePlugin } from "../src/types/claude"
8
8
  const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
9
9
 
10
10
  describe("convertClaudeToOpenCode", () => {
11
- test("maps commands, permissions, and agents", async () => {
11
+ test("from-command mode: map allowedTools to global permission block", async () => {
12
12
  const plugin = await loadClaudePlugin(fixtureRoot)
13
13
  const bundle = convertClaudeToOpenCode(plugin, {
14
14
  agentMode: "subagent",
@@ -16,8 +16,9 @@ describe("convertClaudeToOpenCode", () => {
16
16
  permissions: "from-commands",
17
17
  })
18
18
 
19
- expect(bundle.config.command?.["workflows:review"]).toBeDefined()
20
- expect(bundle.config.command?.["plan_review"]).toBeDefined()
19
+ expect(bundle.config.command).toBeUndefined()
20
+ expect(bundle.commandFiles.find((f) => f.name === "workflows:review")).toBeDefined()
21
+ expect(bundle.commandFiles.find((f) => f.name === "plan_review")).toBeDefined()
21
22
 
22
23
  const permission = bundle.config.permission as Record<string, string | Record<string, string>>
23
24
  expect(Object.keys(permission).sort()).toEqual([
@@ -71,8 +72,10 @@ describe("convertClaudeToOpenCode", () => {
71
72
  expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-20250514")
72
73
  expect(parsed.data.temperature).toBe(0.1)
73
74
 
74
- const modelCommand = bundle.config.command?.["workflows:work"]
75
- expect(modelCommand?.model).toBe("openai/gpt-4o")
75
+ const modelCommand = bundle.commandFiles.find((f) => f.name === "workflows:work")
76
+ expect(modelCommand).toBeDefined()
77
+ const commandParsed = parseFrontmatter(modelCommand!.content)
78
+ expect(commandParsed.data.model).toBe("openai/gpt-4o")
76
79
  })
77
80
 
78
81
  test("resolves bare Claude model aliases to full IDs", () => {
@@ -199,7 +202,7 @@ describe("convertClaudeToOpenCode", () => {
199
202
  expect(parsed.data.mode).toBe("primary")
200
203
  })
201
204
 
202
- test("excludes commands with disable-model-invocation from command map", async () => {
205
+ test("excludes commands with disable-model-invocation from commandFiles", async () => {
203
206
  const plugin = await loadClaudePlugin(fixtureRoot)
204
207
  const bundle = convertClaudeToOpenCode(plugin, {
205
208
  agentMode: "subagent",
@@ -208,10 +211,10 @@ describe("convertClaudeToOpenCode", () => {
208
211
  })
209
212
 
210
213
  // deploy-docs has disable-model-invocation: true, should be excluded
211
- expect(bundle.config.command?.["deploy-docs"]).toBeUndefined()
214
+ expect(bundle.commandFiles.find((f) => f.name === "deploy-docs")).toBeUndefined()
212
215
 
213
216
  // Normal commands should still be present
214
- expect(bundle.config.command?.["workflows:review"]).toBeDefined()
217
+ expect(bundle.commandFiles.find((f) => f.name === "workflows:review")).toBeDefined()
215
218
  })
216
219
 
217
220
  test("rewrites .claude/ paths to .opencode/ in command bodies", () => {
@@ -240,10 +243,11 @@ Run \`/compound-engineering-setup\` to create a settings file.`,
240
243
  permissions: "none",
241
244
  })
242
245
 
243
- const template = bundle.config.command?.["review"]?.template ?? ""
246
+ const commandFile = bundle.commandFiles.find((f) => f.name === "review")
247
+ expect(commandFile).toBeDefined()
244
248
 
245
249
  // Tool-agnostic path in project root — no rewriting needed
246
- expect(template).toContain("compound-engineering.local.md")
250
+ expect(commandFile!.content).toContain("compound-engineering.local.md")
247
251
  })
248
252
 
249
253
  test("rewrites .claude/ paths in agent bodies", () => {
@@ -273,4 +277,33 @@ Run \`/compound-engineering-setup\` to create a settings file.`,
273
277
  // Tool-agnostic path in project root — no rewriting needed
274
278
  expect(agentFile!.content).toContain("compound-engineering.local.md")
275
279
  })
280
+
281
+ test("command .md files include description in frontmatter", () => {
282
+ const plugin: ClaudePlugin = {
283
+ root: "/tmp/plugin",
284
+ manifest: { name: "fixture", version: "1.0.0" },
285
+ agents: [],
286
+ commands: [
287
+ {
288
+ name: "test-cmd",
289
+ description: "Test description",
290
+ body: "Do the thing",
291
+ sourcePath: "/tmp/plugin/commands/test-cmd.md",
292
+ },
293
+ ],
294
+ skills: [],
295
+ }
296
+
297
+ const bundle = convertClaudeToOpenCode(plugin, {
298
+ agentMode: "subagent",
299
+ inferTemperature: false,
300
+ permissions: "none",
301
+ })
302
+
303
+ const commandFile = bundle.commandFiles.find((f) => f.name === "test-cmd")
304
+ expect(commandFile).toBeDefined()
305
+ const parsed = parseFrontmatter(commandFile!.content)
306
+ expect(parsed.data.description).toBe("Test description")
307
+ expect(parsed.body).toContain("Do the thing")
308
+ })
276
309
  })