opencastle 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +11 -77
  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 +7 -2
  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 +31 -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 +8 -2
  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 +32 -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,881 @@
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
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
17
+ import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
18
+ import { join, resolve } from 'node:path';
19
+ import { tmpdir } from 'node:os';
20
+ import { existsSync } from 'node:fs';
21
+ import { updateGitignore } from './gitignore.js';
22
+ import { getExcludedSkills, getExcludedAgents, getIncludedMcpServers, getRequiredMcpEnvVars, getAgentToolInjections, getCustomizationsTransform, } from './stack-config.js';
23
+ import { ALL_PLUGIN_SKILL_NAMES } from '../orchestrator/plugins/index.js';
24
+ import { IDE_ADAPTERS } from './adapters/index.js';
25
+ // ── Helpers ────────────────────────────────────────────────────
26
+ /** The real package root — tests run against the actual source tree. */
27
+ const PKG_ROOT = resolve(import.meta.dirname, '../..');
28
+ /** Read a JSON file from disk. */
29
+ async function readJson(path) {
30
+ return JSON.parse(await readFile(path, 'utf8'));
31
+ }
32
+ /** Recursively list all files in a directory (relative paths). */
33
+ async function listFilesRecursive(dir, prefix = '') {
34
+ if (!existsSync(dir))
35
+ return [];
36
+ const entries = await readdir(dir, { withFileTypes: true });
37
+ const files = [];
38
+ for (const entry of entries) {
39
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
40
+ if (entry.isDirectory()) {
41
+ files.push(...await listFilesRecursive(join(dir, entry.name), rel));
42
+ }
43
+ else {
44
+ files.push(rel);
45
+ }
46
+ }
47
+ return files.sort();
48
+ }
49
+ // ── Stack fixtures ─────────────────────────────────────────────
50
+ const STACK_EMPTY = {
51
+ ides: ['vscode'],
52
+ techTools: [],
53
+ teamTools: [],
54
+ };
55
+ const STACK_SANITY_LINEAR = {
56
+ ides: ['vscode'],
57
+ techTools: ['sanity'],
58
+ teamTools: ['linear'],
59
+ };
60
+ const STACK_SUPABASE_SLACK = {
61
+ ides: ['vscode'],
62
+ techTools: ['supabase'],
63
+ teamTools: ['slack'],
64
+ };
65
+ const STACK_FULL = {
66
+ ides: ['vscode', 'cursor', 'claude-code', 'opencode'],
67
+ techTools: ['sanity', 'supabase', 'vercel'],
68
+ teamTools: ['linear', 'slack'],
69
+ };
70
+ const EMPTY_REPO_INFO = {};
71
+ // ═══════════════════════════════════════════════════════════════
72
+ // § 1 Stack Config Logic (pure functions — no filesystem)
73
+ // ═══════════════════════════════════════════════════════════════
74
+ describe('stack-config: getExcludedAgents', () => {
75
+ it('excludes content-engineer when no CMS tool is selected', () => {
76
+ const excluded = getExcludedAgents(STACK_EMPTY);
77
+ expect(excluded.has('content-engineer.agent.md')).toBe(true);
78
+ expect(excluded.has('database-engineer.agent.md')).toBe(true);
79
+ });
80
+ it('includes content-engineer when a CMS tool is selected', () => {
81
+ const excluded = getExcludedAgents(STACK_SANITY_LINEAR);
82
+ expect(excluded.has('content-engineer.agent.md')).toBe(false);
83
+ // No DB selected → database-engineer still excluded
84
+ expect(excluded.has('database-engineer.agent.md')).toBe(true);
85
+ });
86
+ it('includes database-engineer when a DB tool is selected', () => {
87
+ const excluded = getExcludedAgents(STACK_SUPABASE_SLACK);
88
+ expect(excluded.has('database-engineer.agent.md')).toBe(false);
89
+ // No CMS selected → content-engineer still excluded
90
+ expect(excluded.has('content-engineer.agent.md')).toBe(true);
91
+ });
92
+ it('includes both when CMS and DB are selected', () => {
93
+ const excluded = getExcludedAgents(STACK_FULL);
94
+ expect(excluded.has('content-engineer.agent.md')).toBe(false);
95
+ expect(excluded.has('database-engineer.agent.md')).toBe(false);
96
+ });
97
+ });
98
+ describe('stack-config: getExcludedSkills', () => {
99
+ it('excludes all plugin skills when nothing is selected', () => {
100
+ const excluded = getExcludedSkills(STACK_EMPTY);
101
+ // Every plugin-specific skill should be excluded
102
+ for (const skill of ALL_PLUGIN_SKILL_NAMES) {
103
+ expect(excluded.has(skill)).toBe(true);
104
+ }
105
+ });
106
+ it('includes only selected plugin skills', () => {
107
+ const excluded = getExcludedSkills(STACK_SANITY_LINEAR);
108
+ expect(excluded.has('sanity-cms')).toBe(false);
109
+ expect(excluded.has('linear-task-management')).toBe(false);
110
+ // Unselected skills should still be excluded
111
+ expect(excluded.has('supabase-database')).toBe(true);
112
+ expect(excluded.has('slack-notifications')).toBe(true);
113
+ expect(excluded.has('vercel-deployment')).toBe(true);
114
+ });
115
+ });
116
+ describe('stack-config: getIncludedMcpServers', () => {
117
+ it('returns empty set when nothing selected', () => {
118
+ const servers = getIncludedMcpServers(STACK_EMPTY);
119
+ expect(servers.size).toBe(0);
120
+ });
121
+ it('includes servers for selected tech and team tools', () => {
122
+ const servers = getIncludedMcpServers(STACK_SANITY_LINEAR);
123
+ expect(servers.has('Sanity')).toBe(true);
124
+ expect(servers.has('Linear')).toBe(true);
125
+ expect(servers.has('Supabase')).toBe(false);
126
+ });
127
+ it('auto-includes Vercel when detected in deployment', () => {
128
+ const repoInfo = { deployment: ['vercel'] };
129
+ const servers = getIncludedMcpServers(STACK_EMPTY, repoInfo);
130
+ expect(servers.has('Vercel')).toBe(true);
131
+ });
132
+ it('auto-includes NX when monorepo detected and not in stack', () => {
133
+ const repoInfo = { monorepo: 'nx' };
134
+ const servers = getIncludedMcpServers(STACK_EMPTY, repoInfo);
135
+ expect(servers.has('Nx')).toBe(true);
136
+ });
137
+ });
138
+ describe('stack-config: getRequiredMcpEnvVars', () => {
139
+ it('returns empty when no tools need env vars', () => {
140
+ // Sanity uses OAuth (no env vars), so only Linear needs one
141
+ const vars = getRequiredMcpEnvVars({
142
+ ides: ['vscode'],
143
+ techTools: ['sanity'],
144
+ teamTools: [],
145
+ });
146
+ expect(vars).toHaveLength(0);
147
+ });
148
+ it('returns LINEAR_API_KEY when linear is selected', () => {
149
+ const vars = getRequiredMcpEnvVars(STACK_SANITY_LINEAR);
150
+ expect(vars).toEqual(expect.arrayContaining([
151
+ expect.objectContaining({ envVar: 'LINEAR_API_KEY' }),
152
+ ]));
153
+ });
154
+ it('returns SLACK_MCP_XOXB_TOKEN when slack is selected', () => {
155
+ const vars = getRequiredMcpEnvVars(STACK_SUPABASE_SLACK);
156
+ expect(vars).toEqual(expect.arrayContaining([
157
+ expect.objectContaining({ envVar: 'SLACK_MCP_XOXB_TOKEN' }),
158
+ ]));
159
+ });
160
+ });
161
+ describe('stack-config: getAgentToolInjections', () => {
162
+ it('returns empty map when no tools selected', () => {
163
+ const injections = getAgentToolInjections(STACK_EMPTY);
164
+ expect(injections.size).toBe(0);
165
+ });
166
+ it('injects sanity tools into content-engineer when sanity selected', () => {
167
+ const injections = getAgentToolInjections(STACK_SANITY_LINEAR);
168
+ const contentTools = injections.get('content-engineer');
169
+ expect(contentTools).toBeDefined();
170
+ expect(contentTools).toContain('sanity/get_schema');
171
+ expect(contentTools).toContain('sanity/query_documents');
172
+ });
173
+ it('injects linear tools into team-lead when linear selected', () => {
174
+ const injections = getAgentToolInjections(STACK_SANITY_LINEAR);
175
+ const teamLeadTools = injections.get('team-lead');
176
+ expect(teamLeadTools).toBeDefined();
177
+ expect(teamLeadTools).toContain('linear/create_issue');
178
+ expect(teamLeadTools).toContain('linear/list_issues');
179
+ });
180
+ it('injects supabase tools into database-engineer when supabase selected', () => {
181
+ const injections = getAgentToolInjections(STACK_SUPABASE_SLACK);
182
+ const dbTools = injections.get('database-engineer');
183
+ expect(dbTools).toBeDefined();
184
+ expect(dbTools).toContain('supabase/apply_migration');
185
+ expect(dbTools).toContain('supabase/execute_sql');
186
+ });
187
+ it('aggregates tools from multiple plugins per agent', () => {
188
+ const injections = getAgentToolInjections(STACK_FULL);
189
+ const teamLeadTools = injections.get('team-lead');
190
+ // Linear + Slack tools on team-lead
191
+ expect(teamLeadTools).toContain('linear/create_issue');
192
+ expect(teamLeadTools).toContain('slack/*');
193
+ });
194
+ });
195
+ describe('stack-config: getCustomizationsTransform', () => {
196
+ const emptyMatrix = JSON.stringify({
197
+ bindings: {
198
+ database: { entries: [], description: 'Schema' },
199
+ cms: { entries: [], description: 'CMS' },
200
+ },
201
+ agents: {},
202
+ }, null, 2) + '\n';
203
+ it('fills database slot in skill-matrix.json when DB tool is selected', () => {
204
+ const transform = getCustomizationsTransform(STACK_SUPABASE_SLACK);
205
+ const result = transform(emptyMatrix, 'skill-matrix.json');
206
+ expect(result).toContain('Supabase');
207
+ expect(result).toContain('supabase-database');
208
+ });
209
+ it('fills CMS slot in skill-matrix.json when CMS tool is selected', () => {
210
+ const transform = getCustomizationsTransform(STACK_SANITY_LINEAR);
211
+ const result = transform(emptyMatrix, 'skill-matrix.json');
212
+ expect(result).toContain('Sanity');
213
+ expect(result).toContain('sanity-cms');
214
+ });
215
+ it('leaves slots empty when no DB or CMS selected', () => {
216
+ const transform = getCustomizationsTransform(STACK_EMPTY);
217
+ const result = transform(emptyMatrix, 'skill-matrix.json');
218
+ const data = JSON.parse(result);
219
+ expect(data.bindings.database.entries).toEqual([]);
220
+ expect(data.bindings.cms.entries).toEqual([]);
221
+ });
222
+ it('passes through non-skill-matrix files unchanged', () => {
223
+ const transform = getCustomizationsTransform(STACK_FULL);
224
+ const input = '# Some other file\nContent here';
225
+ const result = transform(input, 'something-else.md');
226
+ expect(result).toBe(input);
227
+ });
228
+ });
229
+ // ═══════════════════════════════════════════════════════════════
230
+ // § 2 Gitignore Generation
231
+ // ═══════════════════════════════════════════════════════════════
232
+ describe('gitignore generation', () => {
233
+ let tempDir;
234
+ beforeEach(async () => {
235
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-init-test-'));
236
+ });
237
+ afterEach(async () => {
238
+ await rm(tempDir, { recursive: true, force: true });
239
+ });
240
+ it('creates .gitignore with framework paths ignored and customizable un-ignored', async () => {
241
+ const managed = {
242
+ framework: ['.github/copilot-instructions.md', '.github/agents/'],
243
+ customizable: ['.github/customizations/', '.vscode/mcp.json'],
244
+ };
245
+ await updateGitignore(tempDir, managed);
246
+ const content = await readFile(join(tempDir, '.gitignore'), 'utf8');
247
+ // Framework paths should be ignored
248
+ expect(content).toContain('.github/copilot-instructions.md');
249
+ expect(content).toContain('.github/agents/');
250
+ // Customizable paths should be un-ignored
251
+ expect(content).toContain('!.github/customizations/');
252
+ expect(content).toContain('!.vscode/mcp.json');
253
+ // Markers should be present
254
+ expect(content).toContain('# >>> OpenCastle managed (do not edit) >>>');
255
+ expect(content).toContain('# <<< OpenCastle managed <<<');
256
+ });
257
+ it('replaces existing block on re-init', async () => {
258
+ const managed1 = {
259
+ framework: ['.github/agents/'],
260
+ customizable: ['.vscode/mcp.json'],
261
+ };
262
+ await updateGitignore(tempDir, managed1);
263
+ const managed2 = {
264
+ framework: ['.github/agents/', '.github/skills/'],
265
+ customizable: ['.vscode/mcp.json', '.github/customizations/'],
266
+ };
267
+ const result = await updateGitignore(tempDir, managed2);
268
+ expect(result).toBe('updated');
269
+ const content = await readFile(join(tempDir, '.gitignore'), 'utf8');
270
+ expect(content).toContain('.github/skills/');
271
+ expect(content).toContain('!.github/customizations/');
272
+ // Only one managed block
273
+ const startCount = (content.match(/>>> OpenCastle managed/g) ?? []).length;
274
+ expect(startCount).toBe(1);
275
+ });
276
+ });
277
+ // ═══════════════════════════════════════════════════════════════
278
+ // § 3 VS Code Adapter — Full Install Validation
279
+ // ═══════════════════════════════════════════════════════════════
280
+ describe('VS Code adapter install', () => {
281
+ let tempDir;
282
+ beforeEach(async () => {
283
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-vscode-test-'));
284
+ });
285
+ afterEach(async () => {
286
+ await rm(tempDir, { recursive: true, force: true });
287
+ });
288
+ it('creates all expected framework directories', async () => {
289
+ const adapter = await IDE_ADAPTERS['vscode']();
290
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO);
291
+ const githubDir = join(tempDir, '.github');
292
+ expect(existsSync(join(githubDir, 'copilot-instructions.md'))).toBe(true);
293
+ expect(existsSync(join(githubDir, 'agents'))).toBe(true);
294
+ expect(existsSync(join(githubDir, 'instructions'))).toBe(true);
295
+ expect(existsSync(join(githubDir, 'skills'))).toBe(true);
296
+ expect(existsSync(join(githubDir, 'agent-workflows'))).toBe(true);
297
+ expect(existsSync(join(githubDir, 'prompts'))).toBe(true);
298
+ expect(existsSync(join(githubDir, 'customizations'))).toBe(true);
299
+ expect(existsSync(join(tempDir, '.vscode', 'mcp.json'))).toBe(true);
300
+ });
301
+ it('excludes content-engineer and database-engineer agents when no CMS/DB', async () => {
302
+ const adapter = await IDE_ADAPTERS['vscode']();
303
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO);
304
+ const agentsDir = join(tempDir, '.github', 'agents');
305
+ const agents = await readdir(agentsDir);
306
+ expect(agents).not.toContain('content-engineer.agent.md');
307
+ expect(agents).not.toContain('database-engineer.agent.md');
308
+ // Others should still be present
309
+ expect(agents).toContain('developer.agent.md');
310
+ expect(agents).toContain('team-lead.agent.md');
311
+ expect(agents).toContain('architect.agent.md');
312
+ });
313
+ it('includes content-engineer when CMS tool is selected', async () => {
314
+ const adapter = await IDE_ADAPTERS['vscode']();
315
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO);
316
+ const agents = await readdir(join(tempDir, '.github', 'agents'));
317
+ expect(agents).toContain('content-engineer.agent.md');
318
+ expect(agents).not.toContain('database-engineer.agent.md');
319
+ });
320
+ it('includes database-engineer when DB tool is selected', async () => {
321
+ const adapter = await IDE_ADAPTERS['vscode']();
322
+ await adapter.install(PKG_ROOT, tempDir, STACK_SUPABASE_SLACK, EMPTY_REPO_INFO);
323
+ const agents = await readdir(join(tempDir, '.github', 'agents'));
324
+ expect(agents).toContain('database-engineer.agent.md');
325
+ expect(agents).not.toContain('content-engineer.agent.md');
326
+ });
327
+ it('excludes unselected plugin skills from skills directory', async () => {
328
+ const adapter = await IDE_ADAPTERS['vscode']();
329
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO);
330
+ const skillsDir = join(tempDir, '.github', 'skills');
331
+ const skills = await readdir(skillsDir);
332
+ // Selected plugin skills should be present
333
+ expect(skills).toContain('sanity');
334
+ expect(skills).toContain('linear');
335
+ // Unselected plugin skills should NOT be present
336
+ expect(skills).not.toContain('supabase');
337
+ expect(skills).not.toContain('slack');
338
+ expect(skills).not.toContain('vercel');
339
+ // Core skills (non-plugin) should always be present
340
+ expect(skills).toContain('accessibility-standards');
341
+ expect(skills).toContain('self-improvement');
342
+ expect(skills).toContain('testing-workflow');
343
+ });
344
+ it('excludes unselected core skills that map to plugins', async () => {
345
+ const adapter = await IDE_ADAPTERS['vscode']();
346
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO);
347
+ const skills = await readdir(join(tempDir, '.github', 'skills'));
348
+ // Plugin-linked skill directories should be absent if tool not selected
349
+ // (The core skills directory names don't match plugin IDs — they're separate)
350
+ // But plugin SKILL.md dirs should not exist
351
+ expect(skills).not.toContain('sanity');
352
+ expect(skills).not.toContain('linear');
353
+ expect(skills).not.toContain('supabase');
354
+ });
355
+ it('injects plugin tools into agent frontmatter', async () => {
356
+ const adapter = await IDE_ADAPTERS['vscode']();
357
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO);
358
+ // Read content-engineer agent — should have sanity tools injected
359
+ const contentEngineer = await readFile(join(tempDir, '.github', 'agents', 'content-engineer.agent.md'), 'utf8');
360
+ expect(contentEngineer).toContain("'sanity/get_schema'");
361
+ expect(contentEngineer).toContain("'sanity/query_documents'");
362
+ expect(contentEngineer).toContain("'sanity/deploy_schema'");
363
+ // Read team-lead agent — should have linear tools injected
364
+ const teamLead = await readFile(join(tempDir, '.github', 'agents', 'team-lead.agent.md'), 'utf8');
365
+ expect(teamLead).toContain("'linear/create_issue'");
366
+ expect(teamLead).toContain("'linear/list_issues'");
367
+ expect(teamLead).toContain("'linear/update_issue'");
368
+ });
369
+ it('does NOT inject tools when no plugins selected', async () => {
370
+ const adapter = await IDE_ADAPTERS['vscode']();
371
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO);
372
+ const teamLead = await readFile(join(tempDir, '.github', 'agents', 'team-lead.agent.md'), 'utf8');
373
+ expect(teamLead).not.toContain('linear/');
374
+ expect(teamLead).not.toContain('sanity/');
375
+ expect(teamLead).not.toContain('supabase/');
376
+ });
377
+ it('generates VS Code MCP config with correct format (servers + inputs)', async () => {
378
+ const adapter = await IDE_ADAPTERS['vscode']();
379
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO);
380
+ const mcpConfig = await readJson(join(tempDir, '.vscode', 'mcp.json'));
381
+ // VS Code format uses "servers" key
382
+ expect(mcpConfig).toHaveProperty('servers');
383
+ const servers = mcpConfig.servers;
384
+ expect(servers).toHaveProperty('Sanity');
385
+ expect(servers).toHaveProperty('Linear');
386
+ // Sanity uses HTTP
387
+ const sanityServer = servers.Sanity;
388
+ expect(sanityServer.type).toBe('http');
389
+ expect(sanityServer.url).toBe('https://mcp.sanity.io');
390
+ // Linear uses stdio
391
+ const linearServer = servers.Linear;
392
+ expect(linearServer.type).toBe('stdio');
393
+ expect(linearServer.command).toBe('npx');
394
+ expect(linearServer.args).toContain('-y');
395
+ expect(linearServer.args).toContain('@mseep/linear-mcp');
396
+ });
397
+ it('generates empty MCP config when no tools selected', async () => {
398
+ const adapter = await IDE_ADAPTERS['vscode']();
399
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO);
400
+ const mcpConfig = await readJson(join(tempDir, '.vscode', 'mcp.json'));
401
+ expect(mcpConfig).toHaveProperty('servers');
402
+ const servers = mcpConfig.servers;
403
+ expect(Object.keys(servers)).toHaveLength(0);
404
+ });
405
+ it('fills skill-matrix.json with selected DB and CMS', async () => {
406
+ const adapter = await IDE_ADAPTERS['vscode']();
407
+ await adapter.install(PKG_ROOT, tempDir, STACK_FULL, EMPTY_REPO_INFO);
408
+ const skillMatrix = await readFile(join(tempDir, '.github', 'customizations', 'agents', 'skill-matrix.json'), 'utf8');
409
+ const data = JSON.parse(skillMatrix);
410
+ expect(data.bindings.database.entries).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'Supabase', skill: 'supabase-database' })]));
411
+ expect(data.bindings.cms.entries).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'Sanity', skill: 'sanity-cms' })]));
412
+ });
413
+ it('leaves skill-matrix.json database/cms slots empty when none selected', async () => {
414
+ const adapter = await IDE_ADAPTERS['vscode']();
415
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO);
416
+ const skillMatrix = await readFile(join(tempDir, '.github', 'customizations', 'agents', 'skill-matrix.json'), 'utf8');
417
+ const data = JSON.parse(skillMatrix);
418
+ expect(data.bindings.database.entries).toEqual([]);
419
+ expect(data.bindings.cms.entries).toEqual([]);
420
+ });
421
+ it('getManagedPaths returns expected structure', async () => {
422
+ const adapter = await IDE_ADAPTERS['vscode']();
423
+ const paths = adapter.getManagedPaths();
424
+ expect(paths.framework).toContain('.github/copilot-instructions.md');
425
+ expect(paths.framework).toContain('.github/agents/');
426
+ expect(paths.framework).toContain('.github/instructions/');
427
+ expect(paths.framework).toContain('.github/skills/');
428
+ expect(paths.framework).toContain('.github/agent-workflows/');
429
+ expect(paths.framework).toContain('.github/prompts/');
430
+ expect(paths.customizable).toContain('.github/customizations/');
431
+ expect(paths.customizable).toContain('.vscode/mcp.json');
432
+ });
433
+ });
434
+ // ═══════════════════════════════════════════════════════════════
435
+ // § 4 Cursor Adapter — .mdc Conversion & Format Validation
436
+ // ═══════════════════════════════════════════════════════════════
437
+ describe('Cursor adapter install', () => {
438
+ let tempDir;
439
+ beforeEach(async () => {
440
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-cursor-test-'));
441
+ });
442
+ afterEach(async () => {
443
+ await rm(tempDir, { recursive: true, force: true });
444
+ });
445
+ it('creates .cursorrules with intro text', async () => {
446
+ const adapter = await IDE_ADAPTERS['cursor']();
447
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO);
448
+ const content = await readFile(join(tempDir, '.cursorrules'), 'utf8');
449
+ expect(content).toContain('# Project Instructions');
450
+ expect(content).toContain('.cursor/rules/');
451
+ });
452
+ it('converts instruction files to .mdc with alwaysApply: true', async () => {
453
+ const adapter = await IDE_ADAPTERS['cursor']();
454
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO);
455
+ const rulesDir = join(tempDir, '.cursor', 'rules');
456
+ const generalMdc = await readFile(join(rulesDir, 'general.mdc'), 'utf8');
457
+ // Should have .mdc frontmatter
458
+ expect(generalMdc).toMatch(/^---\n/);
459
+ expect(generalMdc).toContain('alwaysApply: true');
460
+ // Should still contain the original body content
461
+ expect(generalMdc).toContain('Coding Standards');
462
+ });
463
+ it('converts agent files to .mdc in agents/ subdirectory', async () => {
464
+ const adapter = await IDE_ADAPTERS['cursor']();
465
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO);
466
+ const agentsDir = join(tempDir, '.cursor', 'rules', 'agents');
467
+ const agents = await readdir(agentsDir);
468
+ // .agent.md → .mdc
469
+ expect(agents).toContain('developer.mdc');
470
+ expect(agents).toContain('team-lead.mdc');
471
+ expect(agents).not.toContain('content-engineer.mdc'); // no CMS
472
+ expect(agents).not.toContain('database-engineer.mdc'); // no DB
473
+ // Validate .mdc structure
474
+ const devAgent = await readFile(join(agentsDir, 'developer.mdc'), 'utf8');
475
+ expect(devAgent).toMatch(/^---\n/);
476
+ expect(devAgent).toContain('description:');
477
+ });
478
+ it('includes content-engineer.mdc when CMS selected', async () => {
479
+ const adapter = await IDE_ADAPTERS['cursor']();
480
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO);
481
+ const agents = await readdir(join(tempDir, '.cursor', 'rules', 'agents'));
482
+ expect(agents).toContain('content-engineer.mdc');
483
+ });
484
+ it('converts skills to .mdc in skills/ subdirectory', async () => {
485
+ const adapter = await IDE_ADAPTERS['cursor']();
486
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO);
487
+ const skillsDir = join(tempDir, '.cursor', 'rules', 'skills');
488
+ const skills = await readdir(skillsDir);
489
+ // Core skills should be present
490
+ expect(skills).toContain('self-improvement.mdc');
491
+ expect(skills).toContain('testing-workflow.mdc');
492
+ // Selected plugin skills as .mdc
493
+ expect(skills).toContain('sanity.mdc');
494
+ expect(skills).toContain('linear.mdc');
495
+ // Unselected plugin skills should not be present
496
+ expect(skills).not.toContain('supabase.mdc');
497
+ expect(skills).not.toContain('slack.mdc');
498
+ });
499
+ it('generates Cursor MCP config with mcpServers format', async () => {
500
+ const adapter = await IDE_ADAPTERS['cursor']();
501
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO);
502
+ const mcpConfig = await readJson(join(tempDir, '.cursor', 'mcp.json'));
503
+ // Cursor format uses "mcpServers" key (not "servers")
504
+ expect(mcpConfig).toHaveProperty('mcpServers');
505
+ expect(mcpConfig).not.toHaveProperty('servers');
506
+ const servers = mcpConfig.mcpServers;
507
+ // HTTP servers get url only (no type field)
508
+ expect(servers.Sanity).toBeDefined();
509
+ expect(servers.Sanity.url).toBe('https://mcp.sanity.io');
510
+ expect(servers.Sanity).not.toHaveProperty('type');
511
+ // stdio servers get command + args (no type field)
512
+ expect(servers.Linear).toBeDefined();
513
+ expect(servers.Linear.command).toBe('npx');
514
+ expect(servers.Linear.args).toContain('@mseep/linear-mcp');
515
+ expect(servers.Linear).not.toHaveProperty('type');
516
+ });
517
+ it('getManagedPaths returns expected Cursor paths', async () => {
518
+ const adapter = await IDE_ADAPTERS['cursor']();
519
+ const paths = adapter.getManagedPaths();
520
+ expect(paths.framework).toContain('.cursorrules');
521
+ expect(paths.framework).toContain('.cursor/rules/agents/');
522
+ expect(paths.framework).toContain('.cursor/rules/skills/');
523
+ expect(paths.framework).toContain('.cursor/rules/general.mdc');
524
+ expect(paths.framework).toContain('.cursor/rules/ai-optimization.mdc');
525
+ expect(paths.customizable).toContain('.cursor/rules/customizations/');
526
+ expect(paths.customizable).toContain('.cursor/mcp.json');
527
+ });
528
+ });
529
+ // ═══════════════════════════════════════════════════════════════
530
+ // § 5 Claude Code Adapter — Single-File Root Document
531
+ // ═══════════════════════════════════════════════════════════════
532
+ describe('Claude Code adapter install', () => {
533
+ let tempDir;
534
+ beforeEach(async () => {
535
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-claude-test-'));
536
+ });
537
+ afterEach(async () => {
538
+ await rm(tempDir, { recursive: true, force: true });
539
+ });
540
+ it('creates CLAUDE.md with embedded instructions', async () => {
541
+ const adapter = await IDE_ADAPTERS['claude-code']();
542
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO);
543
+ expect(existsSync(join(tempDir, 'CLAUDE.md'))).toBe(true);
544
+ const content = await readFile(join(tempDir, 'CLAUDE.md'), 'utf8');
545
+ // Should contain merged instruction content
546
+ expect(content).toContain('# Project Instructions');
547
+ expect(content).toContain('Coding Standards');
548
+ expect(content).toContain('.claude/skills/');
549
+ expect(content).toContain('.claude/agents/');
550
+ });
551
+ it('CLAUDE.md lists all non-excluded agents', async () => {
552
+ const adapter = await IDE_ADAPTERS['claude-code']();
553
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO);
554
+ const content = await readFile(join(tempDir, 'CLAUDE.md'), 'utf8');
555
+ expect(content).toContain('## Agent Definitions');
556
+ expect(content).toContain('**Developer**');
557
+ expect(content).toContain('**Team Lead**');
558
+ // Should NOT list excluded agents
559
+ expect(content).not.toContain('**Content Engineer**');
560
+ expect(content).not.toContain('**Database Engineer**');
561
+ });
562
+ it('CLAUDE.md includes content engineer when CMS selected', async () => {
563
+ const adapter = await IDE_ADAPTERS['claude-code']();
564
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO);
565
+ const content = await readFile(join(tempDir, 'CLAUDE.md'), 'utf8');
566
+ expect(content).toContain('**Content Engineer**');
567
+ expect(content).not.toContain('**Database Engineer**');
568
+ });
569
+ it('CLAUDE.md lists available skills (including selected plugins)', async () => {
570
+ const adapter = await IDE_ADAPTERS['claude-code']();
571
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO);
572
+ const content = await readFile(join(tempDir, 'CLAUDE.md'), 'utf8');
573
+ expect(content).toContain('## Available Skills');
574
+ expect(content).toContain('**self-improvement**');
575
+ expect(content).toContain('**sanity**');
576
+ expect(content).toContain('**linear**');
577
+ // Unselected plugin skills should NOT appear in skill index
578
+ expect(content).not.toMatch(/\*\*supabase\*\*/);
579
+ });
580
+ it('strips frontmatter from agent files in .claude/agents/', async () => {
581
+ const adapter = await IDE_ADAPTERS['claude-code']();
582
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO);
583
+ const agentsDir = join(tempDir, '.claude', 'agents');
584
+ const agents = await readdir(agentsDir);
585
+ expect(agents).toContain('developer.agent.md');
586
+ expect(agents).not.toContain('content-engineer.agent.md');
587
+ expect(agents).not.toContain('database-engineer.agent.md');
588
+ const devAgent = await readFile(join(agentsDir, 'developer.agent.md'), 'utf8');
589
+ // Should NOT start with frontmatter
590
+ expect(devAgent).not.toMatch(/^---\n/);
591
+ // Should contain the body content (starts with comment or heading)
592
+ expect(devAgent).toContain('Developer');
593
+ });
594
+ it('creates skills as flat .md files stripped of frontmatter', async () => {
595
+ const adapter = await IDE_ADAPTERS['claude-code']();
596
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO);
597
+ const skillsDir = join(tempDir, '.claude', 'skills');
598
+ const skills = await readdir(skillsDir);
599
+ expect(skills).toContain('self-improvement.md');
600
+ expect(skills).toContain('sanity.md');
601
+ expect(skills).toContain('linear.md');
602
+ expect(skills).not.toContain('supabase.md');
603
+ // Verify frontmatter is stripped
604
+ const skillContent = await readFile(join(skillsDir, 'self-improvement.md'), 'utf8');
605
+ expect(skillContent).not.toMatch(/^---\n/);
606
+ });
607
+ it('generates Claude Code MCP config with mcpServers format', async () => {
608
+ const adapter = await IDE_ADAPTERS['claude-code']();
609
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO);
610
+ const mcpConfig = await readJson(join(tempDir, '.claude', 'mcp.json'));
611
+ expect(mcpConfig).toHaveProperty('mcpServers');
612
+ expect(mcpConfig).not.toHaveProperty('servers');
613
+ });
614
+ it('creates prompts in .claude/commands/', async () => {
615
+ const adapter = await IDE_ADAPTERS['claude-code']();
616
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO);
617
+ const commandsDir = join(tempDir, '.claude', 'commands');
618
+ expect(existsSync(commandsDir)).toBe(true);
619
+ const commands = await readdir(commandsDir);
620
+ // Should have prompt files
621
+ expect(commands.length).toBeGreaterThan(0);
622
+ // All should be .md files
623
+ expect(commands.every((f) => f.endsWith('.md'))).toBe(true);
624
+ });
625
+ it('creates workflows as commands with workflow- prefix', async () => {
626
+ const adapter = await IDE_ADAPTERS['claude-code']();
627
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO);
628
+ const commandsDir = join(tempDir, '.claude', 'commands');
629
+ const commands = await readdir(commandsDir);
630
+ // Workflow files should have the "workflow-" prefix
631
+ const workflows = commands.filter((f) => f.startsWith('workflow-'));
632
+ expect(workflows.length).toBeGreaterThan(0);
633
+ });
634
+ it('getManagedPaths includes CLAUDE.md and .claude dirs', async () => {
635
+ const adapter = await IDE_ADAPTERS['claude-code']();
636
+ const paths = adapter.getManagedPaths();
637
+ expect(paths.framework).toContain('CLAUDE.md');
638
+ expect(paths.framework).toContain('.claude/agents/');
639
+ expect(paths.framework).toContain('.claude/skills/');
640
+ expect(paths.framework).toContain('.claude/commands/');
641
+ expect(paths.customizable).toContain('.claude/customizations/');
642
+ expect(paths.customizable).toContain('.claude/mcp.json');
643
+ });
644
+ });
645
+ // ═══════════════════════════════════════════════════════════════
646
+ // § 6 OpenCode Adapter — Single-File Root Document
647
+ // ═══════════════════════════════════════════════════════════════
648
+ describe('OpenCode adapter install', () => {
649
+ let tempDir;
650
+ beforeEach(async () => {
651
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-opencode-test-'));
652
+ });
653
+ afterEach(async () => {
654
+ await rm(tempDir, { recursive: true, force: true });
655
+ });
656
+ it('creates AGENTS.md with embedded instructions', async () => {
657
+ const adapter = await IDE_ADAPTERS['opencode']();
658
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO);
659
+ expect(existsSync(join(tempDir, 'AGENTS.md'))).toBe(true);
660
+ const content = await readFile(join(tempDir, 'AGENTS.md'), 'utf8');
661
+ expect(content).toContain('# Project Instructions');
662
+ expect(content).toContain('.opencode/skills/');
663
+ expect(content).toContain('.opencode/agents/');
664
+ });
665
+ it('creates files in .opencode/ directory structure', async () => {
666
+ const adapter = await IDE_ADAPTERS['opencode']();
667
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO);
668
+ expect(existsSync(join(tempDir, '.opencode', 'agents'))).toBe(true);
669
+ expect(existsSync(join(tempDir, '.opencode', 'skills'))).toBe(true);
670
+ expect(existsSync(join(tempDir, '.opencode', 'prompts'))).toBe(true);
671
+ expect(existsSync(join(tempDir, '.opencode', 'workflows'))).toBe(true);
672
+ expect(existsSync(join(tempDir, '.opencode', 'customizations'))).toBe(true);
673
+ });
674
+ it('generates OpenCode MCP config with mcp format', async () => {
675
+ const adapter = await IDE_ADAPTERS['opencode']();
676
+ await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO);
677
+ const mcpConfig = await readJson(join(tempDir, 'opencode.json'));
678
+ // OpenCode format uses "mcp" key
679
+ expect(mcpConfig).toHaveProperty('mcp');
680
+ expect(mcpConfig).not.toHaveProperty('servers');
681
+ expect(mcpConfig).not.toHaveProperty('mcpServers');
682
+ const mcp = mcpConfig.mcp;
683
+ // HTTP servers → type: 'remote'
684
+ expect(mcp.Sanity).toBeDefined();
685
+ expect(mcp.Sanity.type).toBe('remote');
686
+ expect(mcp.Sanity.url).toBe('https://mcp.sanity.io');
687
+ // stdio servers → type: 'local', command as array
688
+ expect(mcp.Linear).toBeDefined();
689
+ expect(mcp.Linear.type).toBe('local');
690
+ expect(mcp.Linear.command).toEqual(['npx', '-y', '@mseep/linear-mcp']);
691
+ });
692
+ it('workflows do NOT have prefix in opencode adapter', async () => {
693
+ const adapter = await IDE_ADAPTERS['opencode']();
694
+ await adapter.install(PKG_ROOT, tempDir, STACK_EMPTY, EMPTY_REPO_INFO);
695
+ const wfDir = join(tempDir, '.opencode', 'workflows');
696
+ if (existsSync(wfDir)) {
697
+ const workflows = await readdir(wfDir);
698
+ // OpenCode config has workflowPrefix: '' — no prefix
699
+ const prefixed = workflows.filter((f) => f.startsWith('workflow-'));
700
+ expect(prefixed).toHaveLength(0);
701
+ }
702
+ });
703
+ it('getManagedPaths includes AGENTS.md and .opencode dirs', async () => {
704
+ const adapter = await IDE_ADAPTERS['opencode']();
705
+ const paths = adapter.getManagedPaths();
706
+ expect(paths.framework).toContain('AGENTS.md');
707
+ expect(paths.framework).toContain('.opencode/agents/');
708
+ expect(paths.framework).toContain('.opencode/skills/');
709
+ expect(paths.framework).toContain('.opencode/prompts/');
710
+ expect(paths.framework).toContain('.opencode/workflows/');
711
+ expect(paths.customizable).toContain('.opencode/customizations/');
712
+ expect(paths.customizable).toContain('opencode.json');
713
+ });
714
+ });
715
+ // ═══════════════════════════════════════════════════════════════
716
+ // § 7 Cross-Adapter MCP Format Consistency
717
+ // ═══════════════════════════════════════════════════════════════
718
+ describe('MCP config format per IDE', () => {
719
+ let tempDir;
720
+ beforeEach(async () => {
721
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-mcp-test-'));
722
+ });
723
+ afterEach(async () => {
724
+ await rm(tempDir, { recursive: true, force: true });
725
+ });
726
+ const stack = STACK_SANITY_LINEAR;
727
+ it('all IDEs include the same MCP servers (same tools = same servers)', async () => {
728
+ const serversByIde = {};
729
+ for (const ideId of ['vscode', 'cursor', 'claude-code', 'opencode']) {
730
+ const dir = await mkdtemp(join(tmpdir(), `opencastle-mcp-${ideId}-`));
731
+ try {
732
+ const adapter = await IDE_ADAPTERS[ideId]();
733
+ await adapter.install(PKG_ROOT, dir, stack, EMPTY_REPO_INFO);
734
+ const paths = {
735
+ vscode: join(dir, '.vscode', 'mcp.json'),
736
+ cursor: join(dir, '.cursor', 'mcp.json'),
737
+ 'claude-code': join(dir, '.claude', 'mcp.json'),
738
+ opencode: join(dir, 'opencode.json'),
739
+ };
740
+ const config = await readJson(paths[ideId]);
741
+ const containerKey = ideId === 'opencode' ? 'mcp' :
742
+ ideId === 'vscode' ? 'servers' :
743
+ 'mcpServers';
744
+ const servers = config[containerKey];
745
+ serversByIde[ideId] = Object.keys(servers).sort();
746
+ }
747
+ finally {
748
+ await rm(dir, { recursive: true, force: true });
749
+ }
750
+ }
751
+ // All IDEs should have the same server names
752
+ const vsCodeServers = serversByIde['vscode'];
753
+ expect(serversByIde['cursor']).toEqual(vsCodeServers);
754
+ expect(serversByIde['claude-code']).toEqual(vsCodeServers);
755
+ expect(serversByIde['opencode']).toEqual(vsCodeServers);
756
+ });
757
+ });
758
+ // ═══════════════════════════════════════════════════════════════
759
+ // § 8 Cross-Adapter Agent/Skill Parity
760
+ // ═══════════════════════════════════════════════════════════════
761
+ describe('agent and skill parity across adapters', () => {
762
+ let tempDir;
763
+ beforeEach(async () => {
764
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-parity-test-'));
765
+ });
766
+ afterEach(async () => {
767
+ await rm(tempDir, { recursive: true, force: true });
768
+ });
769
+ it('all IDEs install the same number of agents for a given stack', async () => {
770
+ const agentCountByIde = {};
771
+ for (const ideId of ['vscode', 'cursor', 'claude-code', 'opencode']) {
772
+ const dir = await mkdtemp(join(tmpdir(), `opencastle-parity-${ideId}-`));
773
+ try {
774
+ const adapter = await IDE_ADAPTERS[ideId]();
775
+ await adapter.install(PKG_ROOT, dir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO);
776
+ const agentPaths = {
777
+ vscode: join(dir, '.github', 'agents'),
778
+ cursor: join(dir, '.cursor', 'rules', 'agents'),
779
+ 'claude-code': join(dir, '.claude', 'agents'),
780
+ opencode: join(dir, '.opencode', 'agents'),
781
+ };
782
+ const agents = await readdir(agentPaths[ideId]);
783
+ agentCountByIde[ideId] = agents.length;
784
+ }
785
+ finally {
786
+ await rm(dir, { recursive: true, force: true });
787
+ }
788
+ }
789
+ // All IDEs should have the same agent count
790
+ const vscodeCount = agentCountByIde['vscode'];
791
+ expect(agentCountByIde['cursor']).toBe(vscodeCount);
792
+ expect(agentCountByIde['claude-code']).toBe(vscodeCount);
793
+ expect(agentCountByIde['opencode']).toBe(vscodeCount);
794
+ });
795
+ });
796
+ // ═══════════════════════════════════════════════════════════════
797
+ // § 9 Idempotency — Re-install Does Not Duplicate
798
+ // ═══════════════════════════════════════════════════════════════
799
+ describe('install idempotency', () => {
800
+ let tempDir;
801
+ beforeEach(async () => {
802
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-idempotent-test-'));
803
+ });
804
+ afterEach(async () => {
805
+ await rm(tempDir, { recursive: true, force: true });
806
+ });
807
+ it('second install skips already-existing files (scaffold-once semantics)', async () => {
808
+ const adapter = await IDE_ADAPTERS['vscode']();
809
+ // First install
810
+ const firstResult = await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO);
811
+ expect(firstResult.created.length).toBeGreaterThan(0);
812
+ // Second install — same stack
813
+ const secondResult = await adapter.install(PKG_ROOT, tempDir, STACK_SANITY_LINEAR, EMPTY_REPO_INFO);
814
+ // Created files should now be skipped
815
+ expect(secondResult.created.length).toBe(0);
816
+ expect(secondResult.skipped.length).toBeGreaterThan(0);
817
+ });
818
+ });
819
+ // ═══════════════════════════════════════════════════════════════
820
+ // § 10 Full Stack — Complex Configuration
821
+ // ═══════════════════════════════════════════════════════════════
822
+ describe('full stack configuration', () => {
823
+ let tempDir;
824
+ beforeEach(async () => {
825
+ tempDir = await mkdtemp(join(tmpdir(), 'opencastle-fullstack-test-'));
826
+ });
827
+ afterEach(async () => {
828
+ await rm(tempDir, { recursive: true, force: true });
829
+ });
830
+ it('installs with sanity + supabase + vercel + linear + slack', async () => {
831
+ const stack = {
832
+ ides: ['vscode'],
833
+ techTools: ['sanity', 'supabase', 'vercel'],
834
+ teamTools: ['linear', 'slack'],
835
+ };
836
+ const adapter = await IDE_ADAPTERS['vscode']();
837
+ const result = await adapter.install(PKG_ROOT, tempDir, stack, EMPTY_REPO_INFO);
838
+ expect(result.created.length).toBeGreaterThan(0);
839
+ // Both conditional agents should be included
840
+ const agents = await readdir(join(tempDir, '.github', 'agents'));
841
+ expect(agents).toContain('content-engineer.agent.md');
842
+ expect(agents).toContain('database-engineer.agent.md');
843
+ // All 5 plugin skills should be installed
844
+ const skills = await readdir(join(tempDir, '.github', 'skills'));
845
+ expect(skills).toContain('sanity');
846
+ expect(skills).toContain('supabase');
847
+ expect(skills).toContain('vercel');
848
+ expect(skills).toContain('linear');
849
+ expect(skills).toContain('slack');
850
+ // MCP config should have all 5 servers
851
+ const mcpConfig = await readJson(join(tempDir, '.vscode', 'mcp.json'));
852
+ const servers = mcpConfig.servers;
853
+ expect(Object.keys(servers).sort()).toEqual(['Linear', 'Sanity', 'Slack', 'Supabase', 'Vercel'].sort());
854
+ // Agent tool injection — content-engineer should have sanity tools
855
+ const ceContent = await readFile(join(tempDir, '.github', 'agents', 'content-engineer.agent.md'), 'utf8');
856
+ expect(ceContent).toContain("'sanity/get_schema'");
857
+ // Agent tool injection — database-engineer should have supabase tools
858
+ const deContent = await readFile(join(tempDir, '.github', 'agents', 'database-engineer.agent.md'), 'utf8');
859
+ expect(deContent).toContain("'supabase/apply_migration'");
860
+ // Skill matrix should be filled
861
+ const skillMatrix = await readFile(join(tempDir, '.github', 'customizations', 'agents', 'skill-matrix.json'), 'utf8');
862
+ const matrixData = JSON.parse(skillMatrix);
863
+ expect(matrixData.bindings.database.entries).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'Supabase', skill: 'supabase-database' })]));
864
+ expect(matrixData.bindings.cms.entries).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'Sanity', skill: 'sanity-cms' })]));
865
+ });
866
+ it('auto-detected vercel in repoInfo adds Vercel MCP server without explicit selection', async () => {
867
+ const stack = {
868
+ ides: ['vscode'],
869
+ techTools: ['sanity'],
870
+ teamTools: [],
871
+ };
872
+ const repoInfo = { deployment: ['vercel'] };
873
+ const adapter = await IDE_ADAPTERS['vscode']();
874
+ await adapter.install(PKG_ROOT, tempDir, stack, repoInfo);
875
+ const mcpConfig = await readJson(join(tempDir, '.vscode', 'mcp.json'));
876
+ const servers = mcpConfig.servers;
877
+ expect(servers).toHaveProperty('Vercel');
878
+ expect(servers).toHaveProperty('Sanity');
879
+ });
880
+ });
881
+ //# sourceMappingURL=init.test.js.map