opencastle 0.10.0 → 0.10.1

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 (173) hide show
  1. package/README.md +12 -69
  2. package/dist/cli/doctor.d.ts.map +1 -1
  3. package/dist/cli/doctor.js +13 -7
  4. package/dist/cli/doctor.js.map +1 -1
  5. package/dist/cli/init.d.ts.map +1 -1
  6. package/dist/cli/init.js +2 -1
  7. package/dist/cli/init.js.map +1 -1
  8. package/dist/cli/init.test.d.ts +17 -0
  9. package/dist/cli/init.test.d.ts.map +1 -0
  10. package/dist/cli/init.test.js +881 -0
  11. package/dist/cli/init.test.js.map +1 -0
  12. package/dist/cli/mcp.d.ts +9 -0
  13. package/dist/cli/mcp.d.ts.map +1 -1
  14. package/dist/cli/mcp.js +56 -0
  15. package/dist/cli/mcp.js.map +1 -1
  16. package/dist/cli/stack-config-update.test.d.ts +2 -0
  17. package/dist/cli/stack-config-update.test.d.ts.map +1 -0
  18. package/dist/cli/stack-config-update.test.js +185 -0
  19. package/dist/cli/stack-config-update.test.js.map +1 -0
  20. package/dist/cli/stack-config.d.ts +27 -0
  21. package/dist/cli/stack-config.d.ts.map +1 -1
  22. package/dist/cli/stack-config.js +80 -27
  23. package/dist/cli/stack-config.js.map +1 -1
  24. package/dist/cli/types.d.ts +1 -1
  25. package/dist/cli/types.d.ts.map +1 -1
  26. package/dist/cli/update.d.ts.map +1 -1
  27. package/dist/cli/update.js +184 -17
  28. package/dist/cli/update.js.map +1 -1
  29. package/dist/orchestrator/plugins/astro/config.d.ts +3 -0
  30. package/dist/orchestrator/plugins/astro/config.d.ts.map +1 -0
  31. package/dist/orchestrator/plugins/astro/config.js +27 -0
  32. package/dist/orchestrator/plugins/astro/config.js.map +1 -0
  33. package/dist/orchestrator/plugins/chrome-devtools/config.js +2 -2
  34. package/dist/orchestrator/plugins/chrome-devtools/config.js.map +1 -1
  35. package/dist/orchestrator/plugins/contentful/config.js +1 -1
  36. package/dist/orchestrator/plugins/contentful/config.js.map +1 -1
  37. package/dist/orchestrator/plugins/convex/config.js +1 -1
  38. package/dist/orchestrator/plugins/convex/config.js.map +1 -1
  39. package/dist/orchestrator/plugins/cypress/config.d.ts +3 -0
  40. package/dist/orchestrator/plugins/cypress/config.d.ts.map +1 -0
  41. package/dist/orchestrator/plugins/cypress/config.js +15 -0
  42. package/dist/orchestrator/plugins/cypress/config.js.map +1 -0
  43. package/dist/orchestrator/plugins/figma/config.d.ts +3 -0
  44. package/dist/orchestrator/plugins/figma/config.d.ts.map +1 -0
  45. package/dist/orchestrator/plugins/figma/config.js +33 -0
  46. package/dist/orchestrator/plugins/figma/config.js.map +1 -0
  47. package/dist/orchestrator/plugins/index.d.ts.map +1 -1
  48. package/dist/orchestrator/plugins/index.js +20 -0
  49. package/dist/orchestrator/plugins/index.js.map +1 -1
  50. package/dist/orchestrator/plugins/jira/config.d.ts.map +1 -1
  51. package/dist/orchestrator/plugins/jira/config.js +2 -3
  52. package/dist/orchestrator/plugins/jira/config.js.map +1 -1
  53. package/dist/orchestrator/plugins/linear/config.js +2 -2
  54. package/dist/orchestrator/plugins/linear/config.js.map +1 -1
  55. package/dist/orchestrator/plugins/netlify/config.d.ts +3 -0
  56. package/dist/orchestrator/plugins/netlify/config.d.ts.map +1 -0
  57. package/dist/orchestrator/plugins/netlify/config.js +30 -0
  58. package/dist/orchestrator/plugins/netlify/config.js.map +1 -0
  59. package/dist/orchestrator/plugins/nextjs/config.d.ts +3 -0
  60. package/dist/orchestrator/plugins/nextjs/config.d.ts.map +1 -0
  61. package/dist/orchestrator/plugins/nextjs/config.js +35 -0
  62. package/dist/orchestrator/plugins/nextjs/config.js.map +1 -0
  63. package/dist/orchestrator/plugins/nx/config.d.ts.map +1 -1
  64. package/dist/orchestrator/plugins/nx/config.js +2 -3
  65. package/dist/orchestrator/plugins/nx/config.js.map +1 -1
  66. package/dist/orchestrator/plugins/playwright/config.d.ts +3 -0
  67. package/dist/orchestrator/plugins/playwright/config.d.ts.map +1 -0
  68. package/dist/orchestrator/plugins/playwright/config.js +25 -0
  69. package/dist/orchestrator/plugins/playwright/config.js.map +1 -0
  70. package/dist/orchestrator/plugins/prisma/config.d.ts +3 -0
  71. package/dist/orchestrator/plugins/prisma/config.d.ts.map +1 -0
  72. package/dist/orchestrator/plugins/prisma/config.js +25 -0
  73. package/dist/orchestrator/plugins/prisma/config.js.map +1 -0
  74. package/dist/orchestrator/plugins/resend/config.d.ts +3 -0
  75. package/dist/orchestrator/plugins/resend/config.d.ts.map +1 -0
  76. package/dist/orchestrator/plugins/resend/config.js +46 -0
  77. package/dist/orchestrator/plugins/resend/config.js.map +1 -0
  78. package/dist/orchestrator/plugins/sanity/config.d.ts.map +1 -1
  79. package/dist/orchestrator/plugins/sanity/config.js +1 -2
  80. package/dist/orchestrator/plugins/sanity/config.js.map +1 -1
  81. package/dist/orchestrator/plugins/slack/config.js +1 -1
  82. package/dist/orchestrator/plugins/slack/config.js.map +1 -1
  83. package/dist/orchestrator/plugins/strapi/config.js +1 -1
  84. package/dist/orchestrator/plugins/strapi/config.js.map +1 -1
  85. package/dist/orchestrator/plugins/supabase/config.d.ts.map +1 -1
  86. package/dist/orchestrator/plugins/supabase/config.js +1 -2
  87. package/dist/orchestrator/plugins/supabase/config.js.map +1 -1
  88. package/dist/orchestrator/plugins/teams/config.d.ts.map +1 -1
  89. package/dist/orchestrator/plugins/teams/config.js +1 -2
  90. package/dist/orchestrator/plugins/teams/config.js.map +1 -1
  91. package/dist/orchestrator/plugins/turborepo/config.d.ts +3 -0
  92. package/dist/orchestrator/plugins/turborepo/config.d.ts.map +1 -0
  93. package/dist/orchestrator/plugins/turborepo/config.js +15 -0
  94. package/dist/orchestrator/plugins/turborepo/config.js.map +1 -0
  95. package/dist/orchestrator/plugins/types.d.ts +7 -7
  96. package/dist/orchestrator/plugins/types.d.ts.map +1 -1
  97. package/dist/orchestrator/plugins/vercel/config.d.ts.map +1 -1
  98. package/dist/orchestrator/plugins/vercel/config.js +2 -3
  99. package/dist/orchestrator/plugins/vercel/config.js.map +1 -1
  100. package/dist/orchestrator/plugins/vitest/config.d.ts +3 -0
  101. package/dist/orchestrator/plugins/vitest/config.d.ts.map +1 -0
  102. package/dist/orchestrator/plugins/vitest/config.js +15 -0
  103. package/dist/orchestrator/plugins/vitest/config.js.map +1 -0
  104. package/package.json +1 -1
  105. package/src/cli/doctor.ts +14 -7
  106. package/src/cli/init.test.ts +1141 -0
  107. package/src/cli/init.ts +2 -1
  108. package/src/cli/mcp.ts +77 -1
  109. package/src/cli/stack-config-update.test.ts +210 -0
  110. package/src/cli/stack-config.ts +110 -37
  111. package/src/cli/types.ts +1 -1
  112. package/src/cli/update.ts +230 -23
  113. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  114. package/src/orchestrator/agents/api-designer.agent.md +1 -11
  115. package/src/orchestrator/agents/architect.agent.md +1 -9
  116. package/src/orchestrator/agents/content-engineer.agent.md +1 -5
  117. package/src/orchestrator/agents/copywriter.agent.md +1 -9
  118. package/src/orchestrator/agents/data-expert.agent.md +2 -6
  119. package/src/orchestrator/agents/database-engineer.agent.md +1 -6
  120. package/src/orchestrator/agents/developer.agent.md +2 -12
  121. package/src/orchestrator/agents/devops-expert.agent.md +1 -5
  122. package/src/orchestrator/agents/documentation-writer.agent.md +1 -4
  123. package/src/orchestrator/agents/performance-expert.agent.md +1 -5
  124. package/src/orchestrator/agents/release-manager.agent.md +1 -11
  125. package/src/orchestrator/agents/researcher.agent.md +1 -4
  126. package/src/orchestrator/agents/security-expert.agent.md +2 -7
  127. package/src/orchestrator/agents/seo-specialist.agent.md +1 -10
  128. package/src/orchestrator/agents/testing-expert.agent.md +2 -11
  129. package/src/orchestrator/agents/ui-ux-expert.agent.md +3 -10
  130. package/src/orchestrator/customizations/README.md +2 -1
  131. package/src/orchestrator/customizations/agents/skill-matrix.json +106 -0
  132. package/src/orchestrator/customizations/agents/skill-matrix.md +58 -121
  133. package/src/orchestrator/instructions/general.instructions.md +1 -1
  134. package/src/orchestrator/plugins/astro/SKILL.md +288 -0
  135. package/src/orchestrator/plugins/astro/config.ts +28 -0
  136. package/src/orchestrator/plugins/chrome-devtools/config.ts +2 -2
  137. package/src/orchestrator/plugins/contentful/config.ts +1 -1
  138. package/src/orchestrator/plugins/convex/config.ts +1 -1
  139. package/src/orchestrator/plugins/cypress/SKILL.md +145 -0
  140. package/src/orchestrator/plugins/cypress/config.ts +16 -0
  141. package/src/orchestrator/plugins/figma/SKILL.md +85 -0
  142. package/src/orchestrator/plugins/figma/config.ts +34 -0
  143. package/src/orchestrator/plugins/index.ts +20 -0
  144. package/src/orchestrator/plugins/jira/config.ts +2 -3
  145. package/src/orchestrator/plugins/linear/config.ts +2 -2
  146. package/src/orchestrator/plugins/netlify/SKILL.md +134 -0
  147. package/src/orchestrator/plugins/netlify/config.ts +31 -0
  148. package/src/orchestrator/plugins/nextjs/SKILL.md +376 -0
  149. package/src/orchestrator/plugins/nextjs/config.ts +36 -0
  150. package/src/orchestrator/plugins/nx/config.ts +2 -3
  151. package/src/orchestrator/plugins/playwright/SKILL.md +191 -0
  152. package/src/orchestrator/plugins/playwright/config.ts +26 -0
  153. package/src/orchestrator/plugins/prisma/SKILL.md +137 -0
  154. package/src/orchestrator/plugins/prisma/config.ts +26 -0
  155. package/src/orchestrator/plugins/resend/SKILL.md +187 -0
  156. package/src/orchestrator/plugins/resend/config.ts +47 -0
  157. package/src/orchestrator/plugins/sanity/config.ts +1 -2
  158. package/src/orchestrator/plugins/slack/config.ts +1 -1
  159. package/src/orchestrator/plugins/strapi/config.ts +1 -1
  160. package/src/orchestrator/plugins/supabase/config.ts +1 -2
  161. package/src/orchestrator/plugins/teams/config.ts +1 -2
  162. package/src/orchestrator/plugins/turborepo/SKILL.md +121 -0
  163. package/src/orchestrator/plugins/turborepo/config.ts +16 -0
  164. package/src/orchestrator/plugins/types.ts +7 -7
  165. package/src/orchestrator/plugins/vercel/SKILL.md +99 -0
  166. package/src/orchestrator/plugins/vercel/config.ts +2 -3
  167. package/src/orchestrator/plugins/vitest/SKILL.md +166 -0
  168. package/src/orchestrator/plugins/vitest/config.ts +16 -0
  169. package/src/orchestrator/prompts/bootstrap-customizations.prompt.md +6 -4
  170. package/src/orchestrator/prompts/create-skill.prompt.md +6 -7
  171. package/src/orchestrator/skills/agent-hooks/SKILL.md +2 -2
  172. package/src/orchestrator/skills/memory-merger/SKILL.md +1 -1
  173. package/src/orchestrator/skills/nextjs-patterns/SKILL.md +0 -200
@@ -0,0 +1,1141 @@
1
+ /**
2
+ * Tests for the init command — validates that all IDE adapters generate
3
+ * correct files based on stack selections (tech tools, team tools, IDEs).
4
+ *
5
+ * Tests the dynamic parts:
6
+ * - Excluded agents (no CMS → no content-engineer, no DB → no database-engineer)
7
+ * - Excluded skills (only selected plugin skills are installed)
8
+ * - Plugin skills (SKILL.md from plugin dirs)
9
+ * - MCP config generation per IDE format
10
+ * - Agent tool injection from plugin agentToolMap
11
+ * - Skill matrix transform (database/cms rows filled)
12
+ * - Gitignore block generation
13
+ * - Single-file root documents (CLAUDE.md, AGENTS.md)
14
+ * - Cursor .mdc conversion
15
+ */
16
+
17
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
18
+ import { mkdtemp, readFile, readdir, rm, mkdir, writeFile } from 'node:fs/promises'
19
+ import { join, resolve } from 'node:path'
20
+ import { tmpdir } from 'node:os'
21
+ import { existsSync } from 'node:fs'
22
+ import type { StackConfig, RepoInfo } from './types.js'
23
+ import { updateGitignore } from './gitignore.js'
24
+ import {
25
+ getExcludedSkills,
26
+ getExcludedAgents,
27
+ getIncludedMcpServers,
28
+ getRequiredMcpEnvVars,
29
+ getAgentToolInjections,
30
+ getCustomizationsTransform,
31
+ } from './stack-config.js'
32
+ import { ALL_PLUGIN_SKILL_NAMES } from '../orchestrator/plugins/index.js'
33
+ import { IDE_ADAPTERS } from './adapters/index.js'
34
+
35
+ // ── Helpers ────────────────────────────────────────────────────
36
+
37
+ /** The real package root — tests run against the actual source tree. */
38
+ const PKG_ROOT = resolve(import.meta.dirname, '../..')
39
+
40
+ /** Read a JSON file from disk. */
41
+ async function readJson<T = unknown>(path: string): Promise<T> {
42
+ return JSON.parse(await readFile(path, 'utf8')) as T
43
+ }
44
+
45
+ /** Recursively list all files in a directory (relative paths). */
46
+ async function listFilesRecursive(dir: string, prefix = ''): Promise<string[]> {
47
+ if (!existsSync(dir)) return []
48
+ const entries = await readdir(dir, { withFileTypes: true })
49
+ const files: string[] = []
50
+ for (const entry of entries) {
51
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name
52
+ if (entry.isDirectory()) {
53
+ files.push(...await listFilesRecursive(join(dir, entry.name), rel))
54
+ } else {
55
+ files.push(rel)
56
+ }
57
+ }
58
+ return files.sort()
59
+ }
60
+
61
+ // ── Stack fixtures ─────────────────────────────────────────────
62
+
63
+ const STACK_EMPTY: StackConfig = {
64
+ ides: ['vscode'],
65
+ techTools: [],
66
+ teamTools: [],
67
+ }
68
+
69
+ const STACK_SANITY_LINEAR: StackConfig = {
70
+ ides: ['vscode'],
71
+ techTools: ['sanity'],
72
+ teamTools: ['linear'],
73
+ }
74
+
75
+ const STACK_SUPABASE_SLACK: StackConfig = {
76
+ ides: ['vscode'],
77
+ techTools: ['supabase'],
78
+ teamTools: ['slack'],
79
+ }
80
+
81
+ const STACK_FULL: StackConfig = {
82
+ ides: ['vscode', 'cursor', 'claude-code', 'opencode'],
83
+ techTools: ['sanity', 'supabase', 'vercel'],
84
+ teamTools: ['linear', 'slack'],
85
+ }
86
+
87
+ const EMPTY_REPO_INFO: RepoInfo = {}
88
+
89
+ // ═══════════════════════════════════════════════════════════════
90
+ // § 1 Stack Config Logic (pure functions — no filesystem)
91
+ // ═══════════════════════════════════════════════════════════════
92
+
93
+ describe('stack-config: getExcludedAgents', () => {
94
+ it('excludes content-engineer when no CMS tool is selected', () => {
95
+ const excluded = getExcludedAgents(STACK_EMPTY)
96
+ expect(excluded.has('content-engineer.agent.md')).toBe(true)
97
+ expect(excluded.has('database-engineer.agent.md')).toBe(true)
98
+ })
99
+
100
+ it('includes content-engineer when a CMS tool is selected', () => {
101
+ const excluded = getExcludedAgents(STACK_SANITY_LINEAR)
102
+ expect(excluded.has('content-engineer.agent.md')).toBe(false)
103
+ // No DB selected → database-engineer still excluded
104
+ expect(excluded.has('database-engineer.agent.md')).toBe(true)
105
+ })
106
+
107
+ it('includes database-engineer when a DB tool is selected', () => {
108
+ const excluded = getExcludedAgents(STACK_SUPABASE_SLACK)
109
+ expect(excluded.has('database-engineer.agent.md')).toBe(false)
110
+ // No CMS selected → content-engineer still excluded
111
+ expect(excluded.has('content-engineer.agent.md')).toBe(true)
112
+ })
113
+
114
+ it('includes both when CMS and DB are selected', () => {
115
+ const excluded = getExcludedAgents(STACK_FULL)
116
+ expect(excluded.has('content-engineer.agent.md')).toBe(false)
117
+ expect(excluded.has('database-engineer.agent.md')).toBe(false)
118
+ })
119
+ })
120
+
121
+ describe('stack-config: getExcludedSkills', () => {
122
+ it('excludes all plugin skills when nothing is selected', () => {
123
+ const excluded = getExcludedSkills(STACK_EMPTY)
124
+ // Every plugin-specific skill should be excluded
125
+ for (const skill of ALL_PLUGIN_SKILL_NAMES) {
126
+ expect(excluded.has(skill)).toBe(true)
127
+ }
128
+ })
129
+
130
+ it('includes only selected plugin skills', () => {
131
+ const excluded = getExcludedSkills(STACK_SANITY_LINEAR)
132
+ expect(excluded.has('sanity-cms')).toBe(false)
133
+ expect(excluded.has('linear-task-management')).toBe(false)
134
+ // Unselected skills should still be excluded
135
+ expect(excluded.has('supabase-database')).toBe(true)
136
+ expect(excluded.has('slack-notifications')).toBe(true)
137
+ expect(excluded.has('vercel-deployment')).toBe(true)
138
+ })
139
+ })
140
+
141
+ describe('stack-config: getIncludedMcpServers', () => {
142
+ it('returns empty set when nothing selected', () => {
143
+ const servers = getIncludedMcpServers(STACK_EMPTY)
144
+ expect(servers.size).toBe(0)
145
+ })
146
+
147
+ it('includes servers for selected tech and team tools', () => {
148
+ const servers = getIncludedMcpServers(STACK_SANITY_LINEAR)
149
+ expect(servers.has('Sanity')).toBe(true)
150
+ expect(servers.has('Linear')).toBe(true)
151
+ expect(servers.has('Supabase')).toBe(false)
152
+ })
153
+
154
+ it('auto-includes Vercel when detected in deployment', () => {
155
+ const repoInfo: RepoInfo = { deployment: ['vercel'] }
156
+ const servers = getIncludedMcpServers(STACK_EMPTY, repoInfo)
157
+ expect(servers.has('Vercel')).toBe(true)
158
+ })
159
+
160
+ it('auto-includes NX when monorepo detected and not in stack', () => {
161
+ const repoInfo: RepoInfo = { monorepo: 'nx' }
162
+ const servers = getIncludedMcpServers(STACK_EMPTY, repoInfo)
163
+ expect(servers.has('Nx')).toBe(true)
164
+ })
165
+ })
166
+
167
+ describe('stack-config: getRequiredMcpEnvVars', () => {
168
+ it('returns empty when no tools need env vars', () => {
169
+ // Sanity uses OAuth (no env vars), so only Linear needs one
170
+ const vars = getRequiredMcpEnvVars({
171
+ ides: ['vscode'],
172
+ techTools: ['sanity'],
173
+ teamTools: [],
174
+ })
175
+ expect(vars).toHaveLength(0)
176
+ })
177
+
178
+ it('returns LINEAR_API_KEY when linear is selected', () => {
179
+ const vars = getRequiredMcpEnvVars(STACK_SANITY_LINEAR)
180
+ expect(vars).toEqual(
181
+ expect.arrayContaining([
182
+ expect.objectContaining({ envVar: 'LINEAR_API_KEY' }),
183
+ ])
184
+ )
185
+ })
186
+
187
+ it('returns SLACK_MCP_XOXB_TOKEN when slack is selected', () => {
188
+ const vars = getRequiredMcpEnvVars(STACK_SUPABASE_SLACK)
189
+ expect(vars).toEqual(
190
+ expect.arrayContaining([
191
+ expect.objectContaining({ envVar: 'SLACK_MCP_XOXB_TOKEN' }),
192
+ ])
193
+ )
194
+ })
195
+ })
196
+
197
+ describe('stack-config: getAgentToolInjections', () => {
198
+ it('returns empty map when no tools selected', () => {
199
+ const injections = getAgentToolInjections(STACK_EMPTY)
200
+ expect(injections.size).toBe(0)
201
+ })
202
+
203
+ it('injects sanity tools into content-engineer when sanity selected', () => {
204
+ const injections = getAgentToolInjections(STACK_SANITY_LINEAR)
205
+ const contentTools = injections.get('content-engineer')
206
+ expect(contentTools).toBeDefined()
207
+ expect(contentTools).toContain('sanity/get_schema')
208
+ expect(contentTools).toContain('sanity/query_documents')
209
+ })
210
+
211
+ it('injects linear tools into team-lead when linear selected', () => {
212
+ const injections = getAgentToolInjections(STACK_SANITY_LINEAR)
213
+ const teamLeadTools = injections.get('team-lead')
214
+ expect(teamLeadTools).toBeDefined()
215
+ expect(teamLeadTools).toContain('linear/create_issue')
216
+ expect(teamLeadTools).toContain('linear/list_issues')
217
+ })
218
+
219
+ it('injects supabase tools into database-engineer when supabase selected', () => {
220
+ const injections = getAgentToolInjections(STACK_SUPABASE_SLACK)
221
+ const dbTools = injections.get('database-engineer')
222
+ expect(dbTools).toBeDefined()
223
+ expect(dbTools).toContain('supabase/apply_migration')
224
+ expect(dbTools).toContain('supabase/execute_sql')
225
+ })
226
+
227
+ it('aggregates tools from multiple plugins per agent', () => {
228
+ const injections = getAgentToolInjections(STACK_FULL)
229
+ const teamLeadTools = injections.get('team-lead')!
230
+ // Linear + Slack tools on team-lead
231
+ expect(teamLeadTools).toContain('linear/create_issue')
232
+ expect(teamLeadTools).toContain('slack/*')
233
+ })
234
+ })
235
+
236
+ describe('stack-config: getCustomizationsTransform', () => {
237
+ const emptyMatrix = JSON.stringify({
238
+ bindings: {
239
+ database: { entries: [], description: 'Schema' },
240
+ cms: { entries: [], description: 'CMS' },
241
+ },
242
+ agents: {},
243
+ }, null, 2) + '\n'
244
+
245
+ it('fills database slot in skill-matrix.json when DB tool is selected', () => {
246
+ const transform = getCustomizationsTransform(STACK_SUPABASE_SLACK)
247
+ const result = transform(emptyMatrix, 'skill-matrix.json')
248
+ expect(result).toContain('Supabase')
249
+ expect(result).toContain('supabase-database')
250
+ })
251
+
252
+ it('fills CMS slot in skill-matrix.json when CMS tool is selected', () => {
253
+ const transform = getCustomizationsTransform(STACK_SANITY_LINEAR)
254
+ const result = transform(emptyMatrix, 'skill-matrix.json')
255
+ expect(result).toContain('Sanity')
256
+ expect(result).toContain('sanity-cms')
257
+ })
258
+
259
+ it('leaves slots empty when no DB or CMS selected', () => {
260
+ const transform = getCustomizationsTransform(STACK_EMPTY)
261
+ const result = transform(emptyMatrix, 'skill-matrix.json')
262
+ const data = JSON.parse(result as string)
263
+ expect(data.bindings.database.entries).toEqual([])
264
+ expect(data.bindings.cms.entries).toEqual([])
265
+ })
266
+
267
+ it('passes through non-skill-matrix files unchanged', () => {
268
+ const transform = getCustomizationsTransform(STACK_FULL)
269
+ const input = '# Some other file\nContent here'
270
+ const result = transform(input, 'something-else.md')
271
+ expect(result).toBe(input)
272
+ })
273
+ })
274
+
275
+ // ═══════════════════════════════════════════════════════════════
276
+ // § 2 Gitignore Generation
277
+ // ═══════════════════════════════════════════════════════════════
278
+
279
+ describe('gitignore generation', () => {
280
+ let tempDir: string
281
+
282
+ beforeEach(async () => {
283
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-init-test-'))
284
+ })
285
+
286
+ afterEach(async () => {
287
+ await rm(tempDir, { recursive: true, force: true })
288
+ })
289
+
290
+ it('creates .gitignore with framework paths ignored and customizable un-ignored', async () => {
291
+ const managed = {
292
+ framework: ['.github/copilot-instructions.md', '.github/agents/'],
293
+ customizable: ['.github/customizations/', '.vscode/mcp.json'],
294
+ }
295
+
296
+ await updateGitignore(tempDir, managed)
297
+ const content = await readFile(join(tempDir, '.gitignore'), 'utf8')
298
+
299
+ // Framework paths should be ignored
300
+ expect(content).toContain('.github/copilot-instructions.md')
301
+ expect(content).toContain('.github/agents/')
302
+ // Customizable paths should be un-ignored
303
+ expect(content).toContain('!.github/customizations/')
304
+ expect(content).toContain('!.vscode/mcp.json')
305
+ // Markers should be present
306
+ expect(content).toContain('# >>> OpenCastle managed (do not edit) >>>')
307
+ expect(content).toContain('# <<< OpenCastle managed <<<')
308
+ })
309
+
310
+ it('replaces existing block on re-init', async () => {
311
+ const managed1 = {
312
+ framework: ['.github/agents/'],
313
+ customizable: ['.vscode/mcp.json'],
314
+ }
315
+ await updateGitignore(tempDir, managed1)
316
+
317
+ const managed2 = {
318
+ framework: ['.github/agents/', '.github/skills/'],
319
+ customizable: ['.vscode/mcp.json', '.github/customizations/'],
320
+ }
321
+ const result = await updateGitignore(tempDir, managed2)
322
+ expect(result).toBe('updated')
323
+
324
+ const content = await readFile(join(tempDir, '.gitignore'), 'utf8')
325
+ expect(content).toContain('.github/skills/')
326
+ expect(content).toContain('!.github/customizations/')
327
+ // Only one managed block
328
+ const startCount = (content.match(/>>> OpenCastle managed/g) ?? []).length
329
+ expect(startCount).toBe(1)
330
+ })
331
+ })
332
+
333
+ // ═══════════════════════════════════════════════════════════════
334
+ // § 3 VS Code Adapter — Full Install Validation
335
+ // ═══════════════════════════════════════════════════════════════
336
+
337
+ describe('VS Code adapter install', () => {
338
+ let tempDir: string
339
+
340
+ beforeEach(async () => {
341
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-vscode-test-'))
342
+ })
343
+
344
+ afterEach(async () => {
345
+ await rm(tempDir, { recursive: true, force: true })
346
+ })
347
+
348
+ it('creates all expected framework directories', async () => {
349
+ const adapter = await IDE_ADAPTERS['vscode']()
350
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO)
351
+
352
+ const githubDir = join(tempDir, '.github')
353
+ expect(existsSync(join(githubDir, 'copilot-instructions.md'))).toBe(true)
354
+ expect(existsSync(join(githubDir, 'agents'))).toBe(true)
355
+ expect(existsSync(join(githubDir, 'instructions'))).toBe(true)
356
+ expect(existsSync(join(githubDir, 'skills'))).toBe(true)
357
+ expect(existsSync(join(githubDir, 'agent-workflows'))).toBe(true)
358
+ expect(existsSync(join(githubDir, 'prompts'))).toBe(true)
359
+ expect(existsSync(join(githubDir, 'customizations'))).toBe(true)
360
+ expect(existsSync(join(tempDir, '.vscode', 'mcp.json'))).toBe(true)
361
+ })
362
+
363
+ it('excludes content-engineer and database-engineer agents when no CMS/DB', async () => {
364
+ const adapter = await IDE_ADAPTERS['vscode']()
365
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO)
366
+
367
+ const agentsDir = join(tempDir, '.github', 'agents')
368
+ const agents = await readdir(agentsDir)
369
+
370
+ expect(agents).not.toContain('content-engineer.agent.md')
371
+ expect(agents).not.toContain('database-engineer.agent.md')
372
+ // Others should still be present
373
+ expect(agents).toContain('developer.agent.md')
374
+ expect(agents).toContain('team-lead.agent.md')
375
+ expect(agents).toContain('architect.agent.md')
376
+ })
377
+
378
+ it('includes content-engineer when CMS tool is selected', async () => {
379
+ const adapter = await IDE_ADAPTERS['vscode']()
380
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO)
381
+
382
+ const agents = await readdir(join(tempDir, '.github', 'agents'))
383
+ expect(agents).toContain('content-engineer.agent.md')
384
+ expect(agents).not.toContain('database-engineer.agent.md')
385
+ })
386
+
387
+ it('includes database-engineer when DB tool is selected', async () => {
388
+ const adapter = await IDE_ADAPTERS['vscode']()
389
+ await adapter.install(PKG_ROOT, tempDir, STACK_SUPABASE_SLACK, EMPTY_REPO_INFO)
390
+
391
+ const agents = await readdir(join(tempDir, '.github', 'agents'))
392
+ expect(agents).toContain('database-engineer.agent.md')
393
+ expect(agents).not.toContain('content-engineer.agent.md')
394
+ })
395
+
396
+ it('excludes unselected plugin skills from skills directory', async () => {
397
+ const adapter = await IDE_ADAPTERS['vscode']()
398
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO)
399
+
400
+ const skillsDir = join(tempDir, '.github', 'skills')
401
+ const skills = await readdir(skillsDir)
402
+
403
+ // Selected plugin skills should be present
404
+ expect(skills).toContain('sanity')
405
+ expect(skills).toContain('linear')
406
+ // Unselected plugin skills should NOT be present
407
+ expect(skills).not.toContain('supabase')
408
+ expect(skills).not.toContain('slack')
409
+ expect(skills).not.toContain('vercel')
410
+
411
+ // Core skills (non-plugin) should always be present
412
+ expect(skills).toContain('accessibility-standards')
413
+ expect(skills).toContain('self-improvement')
414
+ expect(skills).toContain('testing-workflow')
415
+ })
416
+
417
+ it('excludes unselected core skills that map to plugins', async () => {
418
+ const adapter = await IDE_ADAPTERS['vscode']()
419
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO)
420
+
421
+ const skills = await readdir(join(tempDir, '.github', 'skills'))
422
+ // Plugin-linked skill directories should be absent if tool not selected
423
+ // (The core skills directory names don't match plugin IDs — they're separate)
424
+ // But plugin SKILL.md dirs should not exist
425
+ expect(skills).not.toContain('sanity')
426
+ expect(skills).not.toContain('linear')
427
+ expect(skills).not.toContain('supabase')
428
+ })
429
+
430
+ it('injects plugin tools into agent frontmatter', async () => {
431
+ const adapter = await IDE_ADAPTERS['vscode']()
432
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO)
433
+
434
+ // Read content-engineer agent — should have sanity tools injected
435
+ const contentEngineer = await readFile(
436
+ join(tempDir, '.github', 'agents', 'content-engineer.agent.md'),
437
+ 'utf8'
438
+ )
439
+ expect(contentEngineer).toContain("'sanity/get_schema'")
440
+ expect(contentEngineer).toContain("'sanity/query_documents'")
441
+ expect(contentEngineer).toContain("'sanity/deploy_schema'")
442
+
443
+ // Read team-lead agent — should have linear tools injected
444
+ const teamLead = await readFile(
445
+ join(tempDir, '.github', 'agents', 'team-lead.agent.md'),
446
+ 'utf8'
447
+ )
448
+ expect(teamLead).toContain("'linear/create_issue'")
449
+ expect(teamLead).toContain("'linear/list_issues'")
450
+ expect(teamLead).toContain("'linear/update_issue'")
451
+ })
452
+
453
+ it('does NOT inject tools when no plugins selected', async () => {
454
+ const adapter = await IDE_ADAPTERS['vscode']()
455
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO)
456
+
457
+ const teamLead = await readFile(
458
+ join(tempDir, '.github', 'agents', 'team-lead.agent.md'),
459
+ 'utf8'
460
+ )
461
+ expect(teamLead).not.toContain('linear/')
462
+ expect(teamLead).not.toContain('sanity/')
463
+ expect(teamLead).not.toContain('supabase/')
464
+ })
465
+
466
+ it('generates VS Code MCP config with correct format (servers + inputs)', async () => {
467
+ const adapter = await IDE_ADAPTERS['vscode']()
468
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO)
469
+
470
+ const mcpConfig = await readJson<Record<string, unknown>>(
471
+ join(tempDir, '.vscode', 'mcp.json')
472
+ )
473
+
474
+ // VS Code format uses "servers" key
475
+ expect(mcpConfig).toHaveProperty('servers')
476
+ const servers = mcpConfig.servers as Record<string, unknown>
477
+ expect(servers).toHaveProperty('Sanity')
478
+ expect(servers).toHaveProperty('Linear')
479
+
480
+ // Sanity uses HTTP
481
+ const sanityServer = servers.Sanity as Record<string, unknown>
482
+ expect(sanityServer.type).toBe('http')
483
+ expect(sanityServer.url).toBe('https://mcp.sanity.io')
484
+
485
+ // Linear uses stdio
486
+ const linearServer = servers.Linear as Record<string, unknown>
487
+ expect(linearServer.type).toBe('stdio')
488
+ expect(linearServer.command).toBe('npx')
489
+ expect(linearServer.args).toContain('-y')
490
+ expect(linearServer.args).toContain('@mseep/linear-mcp')
491
+ })
492
+
493
+ it('generates empty MCP config when no tools selected', async () => {
494
+ const adapter = await IDE_ADAPTERS['vscode']()
495
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO)
496
+
497
+ const mcpConfig = await readJson<Record<string, unknown>>(
498
+ join(tempDir, '.vscode', 'mcp.json')
499
+ )
500
+ expect(mcpConfig).toHaveProperty('servers')
501
+ const servers = mcpConfig.servers as Record<string, unknown>
502
+ expect(Object.keys(servers)).toHaveLength(0)
503
+ })
504
+
505
+ it('fills skill-matrix.json with selected DB and CMS', async () => {
506
+ const adapter = await IDE_ADAPTERS['vscode']()
507
+ await adapter.install(PKG_ROOT, tempDir, STACK_FULL, EMPTY_REPO_INFO)
508
+
509
+ const skillMatrix = await readFile(
510
+ join(tempDir, '.github', 'customizations', 'agents', 'skill-matrix.json'),
511
+ 'utf8'
512
+ )
513
+ const data = JSON.parse(skillMatrix)
514
+ expect(data.bindings.database.entries).toEqual(
515
+ expect.arrayContaining([expect.objectContaining({ name: 'Supabase', skill: 'supabase-database' })])
516
+ )
517
+ expect(data.bindings.cms.entries).toEqual(
518
+ expect.arrayContaining([expect.objectContaining({ name: 'Sanity', skill: 'sanity-cms' })])
519
+ )
520
+ })
521
+
522
+ it('leaves skill-matrix.json database/cms slots empty when none selected', async () => {
523
+ const adapter = await IDE_ADAPTERS['vscode']()
524
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO)
525
+
526
+ const skillMatrix = await readFile(
527
+ join(tempDir, '.github', 'customizations', 'agents', 'skill-matrix.json'),
528
+ 'utf8'
529
+ )
530
+ const data = JSON.parse(skillMatrix)
531
+ expect(data.bindings.database.entries).toEqual([])
532
+ expect(data.bindings.cms.entries).toEqual([])
533
+ })
534
+
535
+ it('getManagedPaths returns expected structure', async () => {
536
+ const adapter = await IDE_ADAPTERS['vscode']()
537
+ const paths = adapter.getManagedPaths()
538
+
539
+ expect(paths.framework).toContain('.github/copilot-instructions.md')
540
+ expect(paths.framework).toContain('.github/agents/')
541
+ expect(paths.framework).toContain('.github/instructions/')
542
+ expect(paths.framework).toContain('.github/skills/')
543
+ expect(paths.framework).toContain('.github/agent-workflows/')
544
+ expect(paths.framework).toContain('.github/prompts/')
545
+
546
+ expect(paths.customizable).toContain('.github/customizations/')
547
+ expect(paths.customizable).toContain('.vscode/mcp.json')
548
+ })
549
+ })
550
+
551
+ // ═══════════════════════════════════════════════════════════════
552
+ // § 4 Cursor Adapter — .mdc Conversion & Format Validation
553
+ // ═══════════════════════════════════════════════════════════════
554
+
555
+ describe('Cursor adapter install', () => {
556
+ let tempDir: string
557
+
558
+ beforeEach(async () => {
559
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-cursor-test-'))
560
+ })
561
+
562
+ afterEach(async () => {
563
+ await rm(tempDir, { recursive: true, force: true })
564
+ })
565
+
566
+ it('creates .cursorrules with intro text', async () => {
567
+ const adapter = await IDE_ADAPTERS['cursor']()
568
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO)
569
+
570
+ const content = await readFile(join(tempDir, '.cursorrules'), 'utf8')
571
+ expect(content).toContain('# Project Instructions')
572
+ expect(content).toContain('.cursor/rules/')
573
+ })
574
+
575
+ it('converts instruction files to .mdc with alwaysApply: true', async () => {
576
+ const adapter = await IDE_ADAPTERS['cursor']()
577
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO)
578
+
579
+ const rulesDir = join(tempDir, '.cursor', 'rules')
580
+ const generalMdc = await readFile(join(rulesDir, 'general.mdc'), 'utf8')
581
+
582
+ // Should have .mdc frontmatter
583
+ expect(generalMdc).toMatch(/^---\n/)
584
+ expect(generalMdc).toContain('alwaysApply: true')
585
+ // Should still contain the original body content
586
+ expect(generalMdc).toContain('Coding Standards')
587
+ })
588
+
589
+ it('converts agent files to .mdc in agents/ subdirectory', async () => {
590
+ const adapter = await IDE_ADAPTERS['cursor']()
591
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO)
592
+
593
+ const agentsDir = join(tempDir, '.cursor', 'rules', 'agents')
594
+ const agents = await readdir(agentsDir)
595
+
596
+ // .agent.md → .mdc
597
+ expect(agents).toContain('developer.mdc')
598
+ expect(agents).toContain('team-lead.mdc')
599
+ expect(agents).not.toContain('content-engineer.mdc') // no CMS
600
+ expect(agents).not.toContain('database-engineer.mdc') // no DB
601
+
602
+ // Validate .mdc structure
603
+ const devAgent = await readFile(join(agentsDir, 'developer.mdc'), 'utf8')
604
+ expect(devAgent).toMatch(/^---\n/)
605
+ expect(devAgent).toContain('description:')
606
+ })
607
+
608
+ it('includes content-engineer.mdc when CMS selected', async () => {
609
+ const adapter = await IDE_ADAPTERS['cursor']()
610
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO)
611
+
612
+ const agents = await readdir(join(tempDir, '.cursor', 'rules', 'agents'))
613
+ expect(agents).toContain('content-engineer.mdc')
614
+ })
615
+
616
+ it('converts skills to .mdc in skills/ subdirectory', async () => {
617
+ const adapter = await IDE_ADAPTERS['cursor']()
618
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO)
619
+
620
+ const skillsDir = join(tempDir, '.cursor', 'rules', 'skills')
621
+ const skills = await readdir(skillsDir)
622
+
623
+ // Core skills should be present
624
+ expect(skills).toContain('self-improvement.mdc')
625
+ expect(skills).toContain('testing-workflow.mdc')
626
+
627
+ // Selected plugin skills as .mdc
628
+ expect(skills).toContain('sanity.mdc')
629
+ expect(skills).toContain('linear.mdc')
630
+
631
+ // Unselected plugin skills should not be present
632
+ expect(skills).not.toContain('supabase.mdc')
633
+ expect(skills).not.toContain('slack.mdc')
634
+ })
635
+
636
+ it('generates Cursor MCP config with mcpServers format', async () => {
637
+ const adapter = await IDE_ADAPTERS['cursor']()
638
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO)
639
+
640
+ const mcpConfig = await readJson<Record<string, unknown>>(
641
+ join(tempDir, '.cursor', 'mcp.json')
642
+ )
643
+
644
+ // Cursor format uses "mcpServers" key (not "servers")
645
+ expect(mcpConfig).toHaveProperty('mcpServers')
646
+ expect(mcpConfig).not.toHaveProperty('servers')
647
+
648
+ const servers = mcpConfig.mcpServers as Record<string, Record<string, unknown>>
649
+
650
+ // HTTP servers get url only (no type field)
651
+ expect(servers.Sanity).toBeDefined()
652
+ expect(servers.Sanity.url).toBe('https://mcp.sanity.io')
653
+ expect(servers.Sanity).not.toHaveProperty('type')
654
+
655
+ // stdio servers get command + args (no type field)
656
+ expect(servers.Linear).toBeDefined()
657
+ expect(servers.Linear.command).toBe('npx')
658
+ expect(servers.Linear.args).toContain('@mseep/linear-mcp')
659
+ expect(servers.Linear).not.toHaveProperty('type')
660
+ })
661
+
662
+ it('getManagedPaths returns expected Cursor paths', async () => {
663
+ const adapter = await IDE_ADAPTERS['cursor']()
664
+ const paths = adapter.getManagedPaths()
665
+
666
+ expect(paths.framework).toContain('.cursorrules')
667
+ expect(paths.framework).toContain('.cursor/rules/agents/')
668
+ expect(paths.framework).toContain('.cursor/rules/skills/')
669
+ expect(paths.framework).toContain('.cursor/rules/general.mdc')
670
+ expect(paths.framework).toContain('.cursor/rules/ai-optimization.mdc')
671
+
672
+ expect(paths.customizable).toContain('.cursor/rules/customizations/')
673
+ expect(paths.customizable).toContain('.cursor/mcp.json')
674
+ })
675
+ })
676
+
677
+ // ═══════════════════════════════════════════════════════════════
678
+ // § 5 Claude Code Adapter — Single-File Root Document
679
+ // ═══════════════════════════════════════════════════════════════
680
+
681
+ describe('Claude Code adapter install', () => {
682
+ let tempDir: string
683
+
684
+ beforeEach(async () => {
685
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-claude-test-'))
686
+ })
687
+
688
+ afterEach(async () => {
689
+ await rm(tempDir, { recursive: true, force: true })
690
+ })
691
+
692
+ it('creates CLAUDE.md with embedded instructions', async () => {
693
+ const adapter = await IDE_ADAPTERS['claude-code']()
694
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO)
695
+
696
+ expect(existsSync(join(tempDir, 'CLAUDE.md'))).toBe(true)
697
+
698
+ const content = await readFile(join(tempDir, 'CLAUDE.md'), 'utf8')
699
+ // Should contain merged instruction content
700
+ expect(content).toContain('# Project Instructions')
701
+ expect(content).toContain('Coding Standards')
702
+ expect(content).toContain('.claude/skills/')
703
+ expect(content).toContain('.claude/agents/')
704
+ })
705
+
706
+ it('CLAUDE.md lists all non-excluded agents', async () => {
707
+ const adapter = await IDE_ADAPTERS['claude-code']()
708
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO)
709
+
710
+ const content = await readFile(join(tempDir, 'CLAUDE.md'), 'utf8')
711
+ expect(content).toContain('## Agent Definitions')
712
+ expect(content).toContain('**Developer**')
713
+ expect(content).toContain('**Team Lead**')
714
+ // Should NOT list excluded agents
715
+ expect(content).not.toContain('**Content Engineer**')
716
+ expect(content).not.toContain('**Database Engineer**')
717
+ })
718
+
719
+ it('CLAUDE.md includes content engineer when CMS selected', async () => {
720
+ const adapter = await IDE_ADAPTERS['claude-code']()
721
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO)
722
+
723
+ const content = await readFile(join(tempDir, 'CLAUDE.md'), 'utf8')
724
+ expect(content).toContain('**Content Engineer**')
725
+ expect(content).not.toContain('**Database Engineer**')
726
+ })
727
+
728
+ it('CLAUDE.md lists available skills (including selected plugins)', async () => {
729
+ const adapter = await IDE_ADAPTERS['claude-code']()
730
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO)
731
+
732
+ const content = await readFile(join(tempDir, 'CLAUDE.md'), 'utf8')
733
+ expect(content).toContain('## Available Skills')
734
+ expect(content).toContain('**self-improvement**')
735
+ expect(content).toContain('**sanity**')
736
+ expect(content).toContain('**linear**')
737
+ // Unselected plugin skills should NOT appear in skill index
738
+ expect(content).not.toMatch(/\*\*supabase\*\*/)
739
+ })
740
+
741
+ it('strips frontmatter from agent files in .claude/agents/', async () => {
742
+ const adapter = await IDE_ADAPTERS['claude-code']()
743
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO)
744
+
745
+ const agentsDir = join(tempDir, '.claude', 'agents')
746
+ const agents = await readdir(agentsDir)
747
+ expect(agents).toContain('developer.agent.md')
748
+ expect(agents).not.toContain('content-engineer.agent.md')
749
+ expect(agents).not.toContain('database-engineer.agent.md')
750
+
751
+ const devAgent = await readFile(join(agentsDir, 'developer.agent.md'), 'utf8')
752
+ // Should NOT start with frontmatter
753
+ expect(devAgent).not.toMatch(/^---\n/)
754
+ // Should contain the body content (starts with comment or heading)
755
+ expect(devAgent).toContain('Developer')
756
+ })
757
+
758
+ it('creates skills as flat .md files stripped of frontmatter', async () => {
759
+ const adapter = await IDE_ADAPTERS['claude-code']()
760
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO)
761
+
762
+ const skillsDir = join(tempDir, '.claude', 'skills')
763
+ const skills = await readdir(skillsDir)
764
+
765
+ expect(skills).toContain('self-improvement.md')
766
+ expect(skills).toContain('sanity.md')
767
+ expect(skills).toContain('linear.md')
768
+ expect(skills).not.toContain('supabase.md')
769
+
770
+ // Verify frontmatter is stripped
771
+ const skillContent = await readFile(join(skillsDir, 'self-improvement.md'), 'utf8')
772
+ expect(skillContent).not.toMatch(/^---\n/)
773
+ })
774
+
775
+ it('generates Claude Code MCP config with mcpServers format', async () => {
776
+ const adapter = await IDE_ADAPTERS['claude-code']()
777
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO)
778
+
779
+ const mcpConfig = await readJson<Record<string, unknown>>(
780
+ join(tempDir, '.claude', 'mcp.json')
781
+ )
782
+ expect(mcpConfig).toHaveProperty('mcpServers')
783
+ expect(mcpConfig).not.toHaveProperty('servers')
784
+ })
785
+
786
+ it('creates prompts in .claude/commands/', async () => {
787
+ const adapter = await IDE_ADAPTERS['claude-code']()
788
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO)
789
+
790
+ const commandsDir = join(tempDir, '.claude', 'commands')
791
+ expect(existsSync(commandsDir)).toBe(true)
792
+ const commands = await readdir(commandsDir)
793
+ // Should have prompt files
794
+ expect(commands.length).toBeGreaterThan(0)
795
+ // All should be .md files
796
+ expect(commands.every((f) => f.endsWith('.md'))).toBe(true)
797
+ })
798
+
799
+ it('creates workflows as commands with workflow- prefix', async () => {
800
+ const adapter = await IDE_ADAPTERS['claude-code']()
801
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO)
802
+
803
+ const commandsDir = join(tempDir, '.claude', 'commands')
804
+ const commands = await readdir(commandsDir)
805
+ // Workflow files should have the "workflow-" prefix
806
+ const workflows = commands.filter((f) => f.startsWith('workflow-'))
807
+ expect(workflows.length).toBeGreaterThan(0)
808
+ })
809
+
810
+ it('getManagedPaths includes CLAUDE.md and .claude dirs', async () => {
811
+ const adapter = await IDE_ADAPTERS['claude-code']()
812
+ const paths = adapter.getManagedPaths()
813
+
814
+ expect(paths.framework).toContain('CLAUDE.md')
815
+ expect(paths.framework).toContain('.claude/agents/')
816
+ expect(paths.framework).toContain('.claude/skills/')
817
+ expect(paths.framework).toContain('.claude/commands/')
818
+
819
+ expect(paths.customizable).toContain('.claude/customizations/')
820
+ expect(paths.customizable).toContain('.claude/mcp.json')
821
+ })
822
+ })
823
+
824
+ // ═══════════════════════════════════════════════════════════════
825
+ // § 6 OpenCode Adapter — Single-File Root Document
826
+ // ═══════════════════════════════════════════════════════════════
827
+
828
+ describe('OpenCode adapter install', () => {
829
+ let tempDir: string
830
+
831
+ beforeEach(async () => {
832
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-opencode-test-'))
833
+ })
834
+
835
+ afterEach(async () => {
836
+ await rm(tempDir, { recursive: true, force: true })
837
+ })
838
+
839
+ it('creates AGENTS.md with embedded instructions', async () => {
840
+ const adapter = await IDE_ADAPTERS['opencode']()
841
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO)
842
+
843
+ expect(existsSync(join(tempDir, 'AGENTS.md'))).toBe(true)
844
+
845
+ const content = await readFile(join(tempDir, 'AGENTS.md'), 'utf8')
846
+ expect(content).toContain('# Project Instructions')
847
+ expect(content).toContain('.opencode/skills/')
848
+ expect(content).toContain('.opencode/agents/')
849
+ })
850
+
851
+ it('creates files in .opencode/ directory structure', async () => {
852
+ const adapter = await IDE_ADAPTERS['opencode']()
853
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO)
854
+
855
+ expect(existsSync(join(tempDir, '.opencode', 'agents'))).toBe(true)
856
+ expect(existsSync(join(tempDir, '.opencode', 'skills'))).toBe(true)
857
+ expect(existsSync(join(tempDir, '.opencode', 'prompts'))).toBe(true)
858
+ expect(existsSync(join(tempDir, '.opencode', 'workflows'))).toBe(true)
859
+ expect(existsSync(join(tempDir, '.opencode', 'customizations'))).toBe(true)
860
+ })
861
+
862
+ it('generates OpenCode MCP config with mcp format', async () => {
863
+ const adapter = await IDE_ADAPTERS['opencode']()
864
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO)
865
+
866
+ const mcpConfig = await readJson<Record<string, unknown>>(
867
+ join(tempDir, 'opencode.json')
868
+ )
869
+
870
+ // OpenCode format uses "mcp" key
871
+ expect(mcpConfig).toHaveProperty('mcp')
872
+ expect(mcpConfig).not.toHaveProperty('servers')
873
+ expect(mcpConfig).not.toHaveProperty('mcpServers')
874
+
875
+ const mcp = mcpConfig.mcp as Record<string, Record<string, unknown>>
876
+
877
+ // HTTP servers → type: 'remote'
878
+ expect(mcp.Sanity).toBeDefined()
879
+ expect(mcp.Sanity.type).toBe('remote')
880
+ expect(mcp.Sanity.url).toBe('https://mcp.sanity.io')
881
+
882
+ // stdio servers → type: 'local', command as array
883
+ expect(mcp.Linear).toBeDefined()
884
+ expect(mcp.Linear.type).toBe('local')
885
+ expect(mcp.Linear.command).toEqual(['npx', '-y', '@mseep/linear-mcp'])
886
+ })
887
+
888
+ it('workflows do NOT have prefix in opencode adapter', async () => {
889
+ const adapter = await IDE_ADAPTERS['opencode']()
890
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO)
891
+
892
+ const wfDir = join(tempDir, '.opencode', 'workflows')
893
+ if (existsSync(wfDir)) {
894
+ const workflows = await readdir(wfDir)
895
+ // OpenCode config has workflowPrefix: '' — no prefix
896
+ const prefixed = workflows.filter((f) => f.startsWith('workflow-'))
897
+ expect(prefixed).toHaveLength(0)
898
+ }
899
+ })
900
+
901
+ it('getManagedPaths includes AGENTS.md and .opencode dirs', async () => {
902
+ const adapter = await IDE_ADAPTERS['opencode']()
903
+ const paths = adapter.getManagedPaths()
904
+
905
+ expect(paths.framework).toContain('AGENTS.md')
906
+ expect(paths.framework).toContain('.opencode/agents/')
907
+ expect(paths.framework).toContain('.opencode/skills/')
908
+ expect(paths.framework).toContain('.opencode/prompts/')
909
+ expect(paths.framework).toContain('.opencode/workflows/')
910
+
911
+ expect(paths.customizable).toContain('.opencode/customizations/')
912
+ expect(paths.customizable).toContain('opencode.json')
913
+ })
914
+ })
915
+
916
+ // ═══════════════════════════════════════════════════════════════
917
+ // § 7 Cross-Adapter MCP Format Consistency
918
+ // ═══════════════════════════════════════════════════════════════
919
+
920
+ describe('MCP config format per IDE', () => {
921
+ let tempDir: string
922
+
923
+ beforeEach(async () => {
924
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-mcp-test-'))
925
+ })
926
+
927
+ afterEach(async () => {
928
+ await rm(tempDir, { recursive: true, force: true })
929
+ })
930
+
931
+ const stack = STACK_SANITY_LINEAR
932
+
933
+ it('all IDEs include the same MCP servers (same tools = same servers)', async () => {
934
+ const serversByIde: Record<string, string[]> = {}
935
+
936
+ for (const ideId of ['vscode', 'cursor', 'claude-code', 'opencode'] as const) {
937
+ const dir = await mkdtemp(join(tmpdir(), `opencastle-mcp-${ideId}-`))
938
+ try {
939
+ const adapter = await IDE_ADAPTERS[ideId]()
940
+ await adapter.install(PKG_ROOT, dir, stack, EMPTY_REPO_INFO)
941
+
942
+ const paths: Record<string, string> = {
943
+ vscode: join(dir, '.vscode', 'mcp.json'),
944
+ cursor: join(dir, '.cursor', 'mcp.json'),
945
+ 'claude-code': join(dir, '.claude', 'mcp.json'),
946
+ opencode: join(dir, 'opencode.json'),
947
+ }
948
+
949
+ const config = await readJson<Record<string, unknown>>(paths[ideId])
950
+ const containerKey =
951
+ ideId === 'opencode' ? 'mcp' :
952
+ ideId === 'vscode' ? 'servers' :
953
+ 'mcpServers'
954
+
955
+ const servers = config[containerKey] as Record<string, unknown>
956
+ serversByIde[ideId] = Object.keys(servers).sort()
957
+ } finally {
958
+ await rm(dir, { recursive: true, force: true })
959
+ }
960
+ }
961
+
962
+ // All IDEs should have the same server names
963
+ const vsCodeServers = serversByIde['vscode']
964
+ expect(serversByIde['cursor']).toEqual(vsCodeServers)
965
+ expect(serversByIde['claude-code']).toEqual(vsCodeServers)
966
+ expect(serversByIde['opencode']).toEqual(vsCodeServers)
967
+ })
968
+ })
969
+
970
+ // ═══════════════════════════════════════════════════════════════
971
+ // § 8 Cross-Adapter Agent/Skill Parity
972
+ // ═══════════════════════════════════════════════════════════════
973
+
974
+ describe('agent and skill parity across adapters', () => {
975
+ let tempDir: string
976
+
977
+ beforeEach(async () => {
978
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-parity-test-'))
979
+ })
980
+
981
+ afterEach(async () => {
982
+ await rm(tempDir, { recursive: true, force: true })
983
+ })
984
+
985
+ it('all IDEs install the same number of agents for a given stack', async () => {
986
+ const agentCountByIde: Record<string, number> = {}
987
+
988
+ for (const ideId of ['vscode', 'cursor', 'claude-code', 'opencode'] as const) {
989
+ const dir = await mkdtemp(join(tmpdir(), `opencastle-parity-${ideId}-`))
990
+ try {
991
+ const adapter = await IDE_ADAPTERS[ideId]()
992
+ await adapter.install(PKG_ROOT, dir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO)
993
+
994
+ const agentPaths: Record<string, string> = {
995
+ vscode: join(dir, '.github', 'agents'),
996
+ cursor: join(dir, '.cursor', 'rules', 'agents'),
997
+ 'claude-code': join(dir, '.claude', 'agents'),
998
+ opencode: join(dir, '.opencode', 'agents'),
999
+ }
1000
+
1001
+ const agents = await readdir(agentPaths[ideId])
1002
+ agentCountByIde[ideId] = agents.length
1003
+ } finally {
1004
+ await rm(dir, { recursive: true, force: true })
1005
+ }
1006
+ }
1007
+
1008
+ // All IDEs should have the same agent count
1009
+ const vscodeCount = agentCountByIde['vscode']
1010
+ expect(agentCountByIde['cursor']).toBe(vscodeCount)
1011
+ expect(agentCountByIde['claude-code']).toBe(vscodeCount)
1012
+ expect(agentCountByIde['opencode']).toBe(vscodeCount)
1013
+ })
1014
+ })
1015
+
1016
+ // ═══════════════════════════════════════════════════════════════
1017
+ // § 9 Idempotency — Re-install Does Not Duplicate
1018
+ // ═══════════════════════════════════════════════════════════════
1019
+
1020
+ describe('install idempotency', () => {
1021
+ let tempDir: string
1022
+
1023
+ beforeEach(async () => {
1024
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-idempotent-test-'))
1025
+ })
1026
+
1027
+ afterEach(async () => {
1028
+ await rm(tempDir, { recursive: true, force: true })
1029
+ })
1030
+
1031
+ it('second install skips already-existing files (scaffold-once semantics)', async () => {
1032
+ const adapter = await IDE_ADAPTERS['vscode']()
1033
+
1034
+ // First install
1035
+ const firstResult = await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO)
1036
+ expect(firstResult.created.length).toBeGreaterThan(0)
1037
+
1038
+ // Second install — same stack
1039
+ const secondResult = await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO)
1040
+ // Created files should now be skipped
1041
+ expect(secondResult.created.length).toBe(0)
1042
+ expect(secondResult.skipped.length).toBeGreaterThan(0)
1043
+ })
1044
+ })
1045
+
1046
+ // ═══════════════════════════════════════════════════════════════
1047
+ // § 10 Full Stack — Complex Configuration
1048
+ // ═══════════════════════════════════════════════════════════════
1049
+
1050
+ describe('full stack configuration', () => {
1051
+ let tempDir: string
1052
+
1053
+ beforeEach(async () => {
1054
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-fullstack-test-'))
1055
+ })
1056
+
1057
+ afterEach(async () => {
1058
+ await rm(tempDir, { recursive: true, force: true })
1059
+ })
1060
+
1061
+ it('installs with sanity + supabase + vercel + linear + slack', async () => {
1062
+ const stack: StackConfig = {
1063
+ ides: ['vscode'],
1064
+ techTools: ['sanity', 'supabase', 'vercel'],
1065
+ teamTools: ['linear', 'slack'],
1066
+ }
1067
+
1068
+ const adapter = await IDE_ADAPTERS['vscode']()
1069
+ const result = await adapter.install(PKG_ROOT, tempDir, stack, EMPTY_REPO_INFO)
1070
+
1071
+ expect(result.created.length).toBeGreaterThan(0)
1072
+
1073
+ // Both conditional agents should be included
1074
+ const agents = await readdir(join(tempDir, '.github', 'agents'))
1075
+ expect(agents).toContain('content-engineer.agent.md')
1076
+ expect(agents).toContain('database-engineer.agent.md')
1077
+
1078
+ // All 5 plugin skills should be installed
1079
+ const skills = await readdir(join(tempDir, '.github', 'skills'))
1080
+ expect(skills).toContain('sanity')
1081
+ expect(skills).toContain('supabase')
1082
+ expect(skills).toContain('vercel')
1083
+ expect(skills).toContain('linear')
1084
+ expect(skills).toContain('slack')
1085
+
1086
+ // MCP config should have all 5 servers
1087
+ const mcpConfig = await readJson<Record<string, unknown>>(
1088
+ join(tempDir, '.vscode', 'mcp.json')
1089
+ )
1090
+ const servers = mcpConfig.servers as Record<string, unknown>
1091
+ expect(Object.keys(servers).sort()).toEqual(
1092
+ ['Linear', 'Sanity', 'Slack', 'Supabase', 'Vercel'].sort()
1093
+ )
1094
+
1095
+ // Agent tool injection — content-engineer should have sanity tools
1096
+ const ceContent = await readFile(
1097
+ join(tempDir, '.github', 'agents', 'content-engineer.agent.md'),
1098
+ 'utf8'
1099
+ )
1100
+ expect(ceContent).toContain("'sanity/get_schema'")
1101
+
1102
+ // Agent tool injection — database-engineer should have supabase tools
1103
+ const deContent = await readFile(
1104
+ join(tempDir, '.github', 'agents', 'database-engineer.agent.md'),
1105
+ 'utf8'
1106
+ )
1107
+ expect(deContent).toContain("'supabase/apply_migration'")
1108
+
1109
+ // Skill matrix should be filled
1110
+ const skillMatrix = await readFile(
1111
+ join(tempDir, '.github', 'customizations', 'agents', 'skill-matrix.json'),
1112
+ 'utf8'
1113
+ )
1114
+ const matrixData = JSON.parse(skillMatrix)
1115
+ expect(matrixData.bindings.database.entries).toEqual(
1116
+ expect.arrayContaining([expect.objectContaining({ name: 'Supabase', skill: 'supabase-database' })])
1117
+ )
1118
+ expect(matrixData.bindings.cms.entries).toEqual(
1119
+ expect.arrayContaining([expect.objectContaining({ name: 'Sanity', skill: 'sanity-cms' })])
1120
+ )
1121
+ })
1122
+
1123
+ it('auto-detected vercel in repoInfo adds Vercel MCP server without explicit selection', async () => {
1124
+ const stack: StackConfig = {
1125
+ ides: ['vscode'],
1126
+ techTools: ['sanity'],
1127
+ teamTools: [],
1128
+ }
1129
+ const repoInfo: RepoInfo = { deployment: ['vercel'] }
1130
+
1131
+ const adapter = await IDE_ADAPTERS['vscode']()
1132
+ await adapter.install(PKG_ROOT, tempDir, stack, repoInfo)
1133
+
1134
+ const mcpConfig = await readJson<Record<string, unknown>>(
1135
+ join(tempDir, '.vscode', 'mcp.json')
1136
+ )
1137
+ const servers = mcpConfig.servers as Record<string, unknown>
1138
+ expect(servers).toHaveProperty('Vercel')
1139
+ expect(servers).toHaveProperty('Sanity')
1140
+ })
1141
+ })