opencastle 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/README.md +18 -9
  2. package/dist/cli/adapters/claude-code.d.ts +2 -2
  3. package/dist/cli/adapters/claude-code.d.ts.map +1 -1
  4. package/dist/cli/adapters/claude-code.js +2 -2
  5. package/dist/cli/adapters/claude-code.js.map +1 -1
  6. package/dist/cli/adapters/cursor.d.ts +2 -1
  7. package/dist/cli/adapters/cursor.d.ts.map +1 -1
  8. package/dist/cli/adapters/cursor.js +13 -12
  9. package/dist/cli/adapters/cursor.js.map +1 -1
  10. package/dist/cli/adapters/opencode.d.ts +2 -2
  11. package/dist/cli/adapters/opencode.d.ts.map +1 -1
  12. package/dist/cli/adapters/opencode.js +2 -2
  13. package/dist/cli/adapters/opencode.js.map +1 -1
  14. package/dist/cli/adapters/single-file-base.d.ts.map +1 -1
  15. package/dist/cli/adapters/single-file-base.js +19 -13
  16. package/dist/cli/adapters/single-file-base.js.map +1 -1
  17. package/dist/cli/adapters/vscode.d.ts +3 -2
  18. package/dist/cli/adapters/vscode.d.ts.map +1 -1
  19. package/dist/cli/adapters/vscode.js +14 -16
  20. package/dist/cli/adapters/vscode.js.map +1 -1
  21. package/dist/cli/dashboard.js +1 -1
  22. package/dist/cli/dashboard.js.map +1 -1
  23. package/dist/cli/doctor.d.ts +12 -1
  24. package/dist/cli/doctor.d.ts.map +1 -1
  25. package/dist/cli/doctor.js +90 -100
  26. package/dist/cli/doctor.js.map +1 -1
  27. package/dist/cli/doctor.test.d.ts +2 -0
  28. package/dist/cli/doctor.test.d.ts.map +1 -0
  29. package/dist/cli/doctor.test.js +213 -0
  30. package/dist/cli/doctor.test.js.map +1 -0
  31. package/dist/cli/eject.js +2 -2
  32. package/dist/cli/eject.js.map +1 -1
  33. package/dist/cli/init.d.ts.map +1 -1
  34. package/dist/cli/init.js +13 -4
  35. package/dist/cli/init.js.map +1 -1
  36. package/dist/cli/init.test.js +27 -15
  37. package/dist/cli/init.test.js.map +1 -1
  38. package/dist/cli/lesson.js +5 -5
  39. package/dist/cli/lesson.js.map +1 -1
  40. package/dist/cli/log.d.ts +1 -1
  41. package/dist/cli/log.d.ts.map +1 -1
  42. package/dist/cli/log.js +5 -5
  43. package/dist/cli/log.js.map +1 -1
  44. package/dist/cli/manifest.d.ts +4 -1
  45. package/dist/cli/manifest.d.ts.map +1 -1
  46. package/dist/cli/manifest.js +16 -5
  47. package/dist/cli/manifest.js.map +1 -1
  48. package/dist/cli/stack-config.d.ts.map +1 -1
  49. package/dist/cli/stack-config.js +2 -14
  50. package/dist/cli/stack-config.js.map +1 -1
  51. package/dist/cli/types.d.ts +14 -0
  52. package/dist/cli/types.d.ts.map +1 -1
  53. package/dist/cli/types.js.map +1 -1
  54. package/dist/cli/update.d.ts.map +1 -1
  55. package/dist/cli/update.js +87 -34
  56. package/dist/cli/update.js.map +1 -1
  57. package/package.json +1 -1
  58. package/src/cli/adapters/claude-code.ts +2 -2
  59. package/src/cli/adapters/cursor.ts +14 -13
  60. package/src/cli/adapters/opencode.ts +2 -2
  61. package/src/cli/adapters/single-file-base.ts +19 -14
  62. package/src/cli/adapters/vscode.ts +16 -17
  63. package/src/cli/dashboard.ts +1 -1
  64. package/src/cli/doctor.test.ts +245 -0
  65. package/src/cli/doctor.ts +101 -104
  66. package/src/cli/eject.ts +2 -2
  67. package/src/cli/init.test.ts +28 -15
  68. package/src/cli/init.ts +14 -4
  69. package/src/cli/lesson.ts +5 -5
  70. package/src/cli/log.ts +5 -5
  71. package/src/cli/manifest.ts +18 -5
  72. package/src/cli/stack-config.ts +2 -14
  73. package/src/cli/types.ts +15 -0
  74. package/src/cli/update.ts +95 -36
  75. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  76. package/src/dashboard/scripts/generate-seed-data.ts +4 -4
  77. package/src/dashboard/seed-data/delegations.ndjson +15 -15
  78. package/src/dashboard/seed-data/events.ndjson +15 -15
  79. package/src/orchestrator/agent-workflows/README.md +1 -1
  80. package/src/orchestrator/agent-workflows/bug-fix.md +4 -4
  81. package/src/orchestrator/agent-workflows/data-pipeline.md +1 -1
  82. package/src/orchestrator/agent-workflows/database-migration.md +4 -4
  83. package/src/orchestrator/agent-workflows/feature-implementation.md +3 -3
  84. package/src/orchestrator/agent-workflows/performance-optimization.md +1 -1
  85. package/src/orchestrator/agent-workflows/refactoring.md +1 -1
  86. package/src/orchestrator/agent-workflows/schema-changes.md +2 -2
  87. package/src/orchestrator/agent-workflows/security-audit.md +4 -4
  88. package/src/orchestrator/agent-workflows/shared-delivery-phase.md +1 -1
  89. package/src/orchestrator/agents/api-designer.agent.md +2 -2
  90. package/src/orchestrator/agents/architect.agent.md +2 -2
  91. package/src/orchestrator/agents/content-engineer.agent.md +2 -2
  92. package/src/orchestrator/agents/copywriter.agent.md +2 -2
  93. package/src/orchestrator/agents/data-expert.agent.md +2 -2
  94. package/src/orchestrator/agents/database-engineer.agent.md +2 -2
  95. package/src/orchestrator/agents/developer.agent.md +2 -2
  96. package/src/orchestrator/agents/devops-expert.agent.md +2 -2
  97. package/src/orchestrator/agents/documentation-writer.agent.md +2 -2
  98. package/src/orchestrator/agents/performance-expert.agent.md +2 -2
  99. package/src/orchestrator/agents/release-manager.agent.md +2 -2
  100. package/src/orchestrator/agents/researcher.agent.md +4 -4
  101. package/src/orchestrator/agents/reviewer.agent.md +1 -1
  102. package/src/orchestrator/agents/security-expert.agent.md +2 -2
  103. package/src/orchestrator/agents/seo-specialist.agent.md +2 -2
  104. package/src/orchestrator/agents/session-guard.agent.md +10 -10
  105. package/src/orchestrator/agents/team-lead.agent.md +8 -3
  106. package/src/orchestrator/agents/testing-expert.agent.md +2 -2
  107. package/src/orchestrator/agents/ui-ux-expert.agent.md +2 -2
  108. package/src/orchestrator/copilot-instructions.md +1 -1
  109. package/src/orchestrator/customizations/AGENT-PERFORMANCE.md +11 -11
  110. package/src/orchestrator/customizations/DISPUTES.md +2 -2
  111. package/src/orchestrator/customizations/README.md +1 -1
  112. package/src/orchestrator/customizations/logs/README.md +1 -1
  113. package/src/orchestrator/customizations/project/docs-structure.md +6 -6
  114. package/src/orchestrator/instructions/ai-optimization.instructions.md +1 -1
  115. package/src/orchestrator/instructions/general.instructions.md +6 -6
  116. package/src/orchestrator/plugins/astro/SKILL.md +1 -1
  117. package/src/orchestrator/plugins/chrome-devtools/SKILL.md +4 -4
  118. package/src/orchestrator/plugins/contentful/SKILL.md +2 -2
  119. package/src/orchestrator/plugins/convex/SKILL.md +2 -2
  120. package/src/orchestrator/plugins/cypress/SKILL.md +2 -2
  121. package/src/orchestrator/plugins/figma/SKILL.md +1 -1
  122. package/src/orchestrator/plugins/jira/SKILL.md +3 -3
  123. package/src/orchestrator/plugins/linear/SKILL.md +2 -2
  124. package/src/orchestrator/plugins/netlify/SKILL.md +2 -2
  125. package/src/orchestrator/plugins/nextjs/SKILL.md +1 -1
  126. package/src/orchestrator/plugins/nx/SKILL.md +1 -1
  127. package/src/orchestrator/plugins/playwright/SKILL.md +2 -2
  128. package/src/orchestrator/plugins/prisma/SKILL.md +2 -2
  129. package/src/orchestrator/plugins/resend/SKILL.md +1 -1
  130. package/src/orchestrator/plugins/sanity/SKILL.md +2 -2
  131. package/src/orchestrator/plugins/slack/SKILL.md +2 -2
  132. package/src/orchestrator/plugins/strapi/SKILL.md +2 -2
  133. package/src/orchestrator/plugins/supabase/SKILL.md +2 -2
  134. package/src/orchestrator/plugins/teams/SKILL.md +1 -1
  135. package/src/orchestrator/plugins/turborepo/SKILL.md +1 -1
  136. package/src/orchestrator/plugins/vercel/SKILL.md +2 -2
  137. package/src/orchestrator/plugins/vitest/SKILL.md +2 -2
  138. package/src/orchestrator/prompts/bootstrap-customizations.prompt.md +8 -8
  139. package/src/orchestrator/prompts/brainstorm.prompt.md +3 -3
  140. package/src/orchestrator/prompts/bug-fix.prompt.md +4 -4
  141. package/src/orchestrator/prompts/create-skill.prompt.md +3 -3
  142. package/src/orchestrator/prompts/generate-convoy.prompt.md +1 -1
  143. package/src/orchestrator/prompts/implement-feature.prompt.md +11 -11
  144. package/src/orchestrator/prompts/quick-refinement.prompt.md +3 -3
  145. package/src/orchestrator/prompts/resolve-pr-comments.prompt.md +1 -1
  146. package/src/orchestrator/skills/accessibility-standards/SKILL.md +1 -1
  147. package/src/orchestrator/skills/agent-hooks/SKILL.md +9 -9
  148. package/src/orchestrator/skills/agent-memory/SKILL.md +5 -5
  149. package/src/orchestrator/skills/api-patterns/SKILL.md +2 -2
  150. package/src/orchestrator/skills/code-commenting/SKILL.md +1 -1
  151. package/src/orchestrator/skills/context-map/SKILL.md +4 -4
  152. package/src/orchestrator/skills/data-engineering/SKILL.md +2 -2
  153. package/src/orchestrator/skills/decomposition/SKILL.md +1 -1
  154. package/src/orchestrator/skills/deployment-infrastructure/SKILL.md +2 -2
  155. package/src/orchestrator/skills/documentation-standards/SKILL.md +2 -2
  156. package/src/orchestrator/skills/fast-review/SKILL.md +2 -2
  157. package/src/orchestrator/skills/frontend-design/SKILL.md +1 -1
  158. package/src/orchestrator/skills/git-workflow/SKILL.md +2 -2
  159. package/src/orchestrator/skills/memory-merger/SKILL.md +3 -3
  160. package/src/orchestrator/skills/observability-logging/SKILL.md +10 -10
  161. package/src/orchestrator/skills/orchestration-protocols/SKILL.md +1 -1
  162. package/src/orchestrator/skills/panel-majority-vote/SKILL.md +2 -2
  163. package/src/orchestrator/skills/panel-majority-vote/panel-report.template.md +1 -1
  164. package/src/orchestrator/skills/performance-optimization/SKILL.md +1 -1
  165. package/src/orchestrator/skills/react-development/SKILL.md +1 -1
  166. package/src/orchestrator/skills/security-hardening/SKILL.md +1 -1
  167. package/src/orchestrator/skills/self-improvement/SKILL.md +2 -2
  168. package/src/orchestrator/skills/seo-patterns/SKILL.md +1 -1
  169. package/src/orchestrator/skills/session-checkpoints/SKILL.md +5 -5
  170. package/src/orchestrator/skills/team-lead-reference/SKILL.md +6 -6
  171. package/src/orchestrator/skills/testing-workflow/SKILL.md +3 -3
  172. package/src/orchestrator/skills/validation-gates/SKILL.md +1 -1
@@ -3,8 +3,8 @@ import { mkdir, readFile, writeFile, copyFile } from 'node:fs/promises'
3
3
  import { existsSync } from 'node:fs'
4
4
  import { copyDir, getOrchestratorRoot, removeDirIfExists, getPluginsRoot, getPluginSkillEntries } from '../copy.js'
5
5
  import { scaffoldMcpConfig } from '../mcp.js'
6
- import { getExcludedSkills, getExcludedAgents, getCustomizationsTransform, getIncludedPluginIds, getAgentTransform } from '../stack-config.js'
7
- import type { CopyResults, CopyDirOptions, ManagedPaths, RepoInfo, StackConfig } from '../types.js'
6
+ import { getExcludedSkills, getExcludedAgents, getIncludedPluginIds, getAgentTransform } from '../stack-config.js'
7
+ import type { CopyResults, CopyDirOptions, DoctorCheck, ManagedPaths, RepoInfo, StackConfig } from '../types.js'
8
8
 
9
9
  /**
10
10
  * VS Code / GitHub Copilot adapter.
@@ -17,7 +17,7 @@ import type { CopyResults, CopyDirOptions, ManagedPaths, RepoInfo, StackConfig }
17
17
  * skills/ → .github/skills/
18
18
  * agent-workflows/ → .github/agent-workflows/
19
19
  * prompts/ → .github/prompts/
20
- * customizations/ → .github/customizations/ (scaffolded once)
20
+ * customizations/ → .opencastle/ (scaffolded once)
21
21
  */
22
22
 
23
23
  export const IDE_ID = 'vscode'
@@ -33,7 +33,7 @@ const FRAMEWORK_DIRS = [
33
33
  ]
34
34
 
35
35
  /** Directories scaffolded once and never overwritten. */
36
- const CUSTOMIZABLE_DIRS = ['customizations']
36
+ const CUSTOMIZABLE_DIRS: string[] = []
37
37
 
38
38
  export async function install(
39
39
  pkgRoot: string,
@@ -99,18 +99,6 @@ export async function install(
99
99
  }
100
100
  }
101
101
 
102
- // Customization templates (scaffold once)
103
- const custTransform = stack ? getCustomizationsTransform(stack) : undefined
104
- for (const dir of CUSTOMIZABLE_DIRS) {
105
- const srcDir = resolve(srcRoot, dir)
106
- if (!existsSync(srcDir)) continue
107
- const destDir = resolve(destRoot, dir)
108
- const sub = await copyDir(srcDir, destDir, { transform: custTransform })
109
- results.copied.push(...sub.copied)
110
- results.skipped.push(...sub.skipped)
111
- results.created.push(...sub.created)
112
- }
113
-
114
102
  // MCP server config → .vscode/mcp.json (scaffold once)
115
103
  const mcpResult = await scaffoldMcpConfig(
116
104
  projectRoot,
@@ -195,8 +183,19 @@ export function getManagedPaths(): ManagedPaths {
195
183
  ...FRAMEWORK_DIRS.map((d) => `.github/${d}/`),
196
184
  ],
197
185
  customizable: [
198
- ...CUSTOMIZABLE_DIRS.map((d) => `.github/${d}/`),
186
+ '.opencastle/',
199
187
  '.vscode/mcp.json',
200
188
  ],
201
189
  }
202
190
  }
191
+
192
+ export function getDoctorChecks(): DoctorCheck[] {
193
+ return [
194
+ { label: 'Copilot instructions', path: '.github/copilot-instructions.md', type: 'file' },
195
+ { label: 'Instruction files', path: '.github/instructions/', type: 'dir', countContents: true, countFilter: '.md' },
196
+ { label: 'Agent definitions', path: '.github/agents/', type: 'dir', countContents: true, countFilter: '.agent.md' },
197
+ { label: 'Skills directory', path: '.github/skills/', type: 'dir', countContents: true },
198
+ { label: 'Agent workflows', path: '.github/agent-workflows/', type: 'dir', countContents: true },
199
+ { label: 'Prompts directory', path: '.github/prompts/', type: 'dir', countContents: true },
200
+ ]
201
+ }
@@ -260,7 +260,7 @@ export default async function dashboard({
260
260
  ' \u{1F4C2} Showing demo data (use without --seed to read project logs)'
261
261
  )
262
262
  } else if (hasLogs) {
263
- console.log(' \u{1F4C2} Reading logs from .github/customizations/logs/')
263
+ console.log(' \u{1F4C2} Reading logs from .opencastle/logs/')
264
264
  } else {
265
265
  console.log(
266
266
  ' \u{1F4A1} No agent logs found. Run agents with OpenCastle to generate data, or use --seed for demo data.'
@@ -0,0 +1,245 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, realpathSync } from 'node:fs'
3
+ import { mkdirSync, writeFileSync, rmSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
6
+ import { runDoctorCheck, checkMcpFromPaths } from './doctor.js'
7
+ import { IDE_ADAPTERS } from './adapters/index.js'
8
+ import type { DoctorCheck } from './types.js'
9
+
10
+ // ── Test helper ───────────────────────────────────────────────
11
+
12
+ function makeTempDir(): string {
13
+ return realpathSync(mkdtempSync(join(tmpdir(), 'doctor-test-')))
14
+ }
15
+
16
+ // ── runDoctorCheck ────────────────────────────────────────────
17
+
18
+ describe('runDoctorCheck — file checks', () => {
19
+ let tmpDir: string
20
+
21
+ beforeEach(() => {
22
+ tmpDir = makeTempDir()
23
+ })
24
+
25
+ afterEach(() => {
26
+ rmSync(tmpDir, { recursive: true, force: true })
27
+ })
28
+
29
+ it('passes when file exists', async () => {
30
+ writeFileSync(join(tmpDir, 'CLAUDE.md'), '# Instructions')
31
+ const check: DoctorCheck = { label: 'Root file', path: 'CLAUDE.md', type: 'file' }
32
+ const result = await runDoctorCheck(tmpDir, check)
33
+ expect(result.ok).toBe(true)
34
+ expect(result.label).toBe('Root file')
35
+ })
36
+
37
+ it('fails when file does not exist', async () => {
38
+ const check: DoctorCheck = { label: 'Root file', path: 'CLAUDE.md', type: 'file' }
39
+ const result = await runDoctorCheck(tmpDir, check)
40
+ expect(result.ok).toBe(false)
41
+ expect(result.detail).toContain('CLAUDE.md not found')
42
+ })
43
+
44
+ it('passes for nested path when file exists', async () => {
45
+ mkdirSync(join(tmpDir, '.github'), { recursive: true })
46
+ writeFileSync(join(tmpDir, '.github', 'copilot-instructions.md'), '# Instructions')
47
+ const check: DoctorCheck = { label: 'Copilot instructions', path: '.github/copilot-instructions.md', type: 'file' }
48
+ const result = await runDoctorCheck(tmpDir, check)
49
+ expect(result.ok).toBe(true)
50
+ })
51
+ })
52
+
53
+ describe('runDoctorCheck — dir checks', () => {
54
+ let tmpDir: string
55
+
56
+ beforeEach(() => {
57
+ tmpDir = makeTempDir()
58
+ })
59
+
60
+ afterEach(() => {
61
+ rmSync(tmpDir, { recursive: true, force: true })
62
+ })
63
+
64
+ it('fails when directory does not exist', async () => {
65
+ const check: DoctorCheck = { label: 'Skills', path: '.claude/skills/', type: 'dir' }
66
+ const result = await runDoctorCheck(tmpDir, check)
67
+ expect(result.ok).toBe(false)
68
+ expect(result.detail).toContain('.claude/skills/ not found')
69
+ })
70
+
71
+ it('passes when directory exists (no countContents)', async () => {
72
+ mkdirSync(join(tmpDir, '.claude', 'skills'), { recursive: true })
73
+ const check: DoctorCheck = { label: 'Skills', path: '.claude/skills/', type: 'dir' }
74
+ const result = await runDoctorCheck(tmpDir, check)
75
+ expect(result.ok).toBe(true)
76
+ })
77
+
78
+ it('fails when dir exists but no files match countFilter', async () => {
79
+ mkdirSync(join(tmpDir, '.github', 'agents'), { recursive: true })
80
+ writeFileSync(join(tmpDir, '.github', 'agents', 'notes.txt'), 'not an agent')
81
+ const check: DoctorCheck = {
82
+ label: 'Agent definitions',
83
+ path: '.github/agents/',
84
+ type: 'dir',
85
+ countContents: true,
86
+ countFilter: '.agent.md',
87
+ }
88
+ const result = await runDoctorCheck(tmpDir, check)
89
+ expect(result.ok).toBe(false)
90
+ expect(result.detail).toContain('No files found')
91
+ })
92
+
93
+ it('passes and reports count when files match countFilter', async () => {
94
+ mkdirSync(join(tmpDir, '.github', 'agents'), { recursive: true })
95
+ writeFileSync(join(tmpDir, '.github', 'agents', 'developer.agent.md'), '# Developer')
96
+ writeFileSync(join(tmpDir, '.github', 'agents', 'reviewer.agent.md'), '# Reviewer')
97
+ const check: DoctorCheck = {
98
+ label: 'Agent definitions',
99
+ path: '.github/agents/',
100
+ type: 'dir',
101
+ countContents: true,
102
+ countFilter: '.agent.md',
103
+ }
104
+ const result = await runDoctorCheck(tmpDir, check)
105
+ expect(result.ok).toBe(true)
106
+ expect(result.detail).toBe('2 file(s)')
107
+ })
108
+
109
+ it('counts all files when no countFilter specified', async () => {
110
+ mkdirSync(join(tmpDir, '.claude', 'skills'), { recursive: true })
111
+ writeFileSync(join(tmpDir, '.claude', 'skills', 'git-workflow.md'), '# Skill')
112
+ writeFileSync(join(tmpDir, '.claude', 'skills', 'testing.md'), '# Skill')
113
+ writeFileSync(join(tmpDir, '.claude', 'skills', 'unused.txt'), 'other')
114
+ const check: DoctorCheck = {
115
+ label: 'Skills',
116
+ path: '.claude/skills/',
117
+ type: 'dir',
118
+ countContents: true,
119
+ }
120
+ const result = await runDoctorCheck(tmpDir, check)
121
+ expect(result.ok).toBe(true)
122
+ expect(result.detail).toBe('3 file(s)')
123
+ })
124
+
125
+ it('fails when directory exists but is empty (countContents)', async () => {
126
+ mkdirSync(join(tmpDir, '.github', 'instructions'), { recursive: true })
127
+ const check: DoctorCheck = {
128
+ label: 'Instruction files',
129
+ path: '.github/instructions/',
130
+ type: 'dir',
131
+ countContents: true,
132
+ countFilter: '.md',
133
+ }
134
+ const result = await runDoctorCheck(tmpDir, check)
135
+ expect(result.ok).toBe(false)
136
+ expect(result.detail).toContain('No files found')
137
+ })
138
+ })
139
+
140
+ // ── checkMcpFromPaths ─────────────────────────────────────────
141
+
142
+ describe('checkMcpFromPaths', () => {
143
+ let tmpDir: string
144
+
145
+ beforeEach(() => {
146
+ tmpDir = makeTempDir()
147
+ })
148
+
149
+ afterEach(() => {
150
+ rmSync(tmpDir, { recursive: true, force: true })
151
+ })
152
+
153
+ it('returns ok when mcp config exists', () => {
154
+ mkdirSync(join(tmpDir, '.vscode'), { recursive: true })
155
+ writeFileSync(join(tmpDir, '.vscode', 'mcp.json'), '{}')
156
+ const result = checkMcpFromPaths(tmpDir, ['.vscode/mcp.json'])
157
+ expect(result.ok).toBe(true)
158
+ expect(result.warning).toBeFalsy()
159
+ })
160
+
161
+ it('returns warning when mcp config does not exist', () => {
162
+ const result = checkMcpFromPaths(tmpDir, ['.vscode/mcp.json'])
163
+ expect(result.ok).toBe(true)
164
+ expect(result.warning).toBe(true)
165
+ expect(result.detail).toContain('MCP tools unavailable')
166
+ })
167
+
168
+ it('returns ok with no warning when paths is empty', () => {
169
+ const result = checkMcpFromPaths(tmpDir, [])
170
+ expect(result.ok).toBe(true)
171
+ expect(result.warning).toBeFalsy()
172
+ })
173
+ })
174
+
175
+ // ── adapter getDoctorChecks ───────────────────────────────────
176
+
177
+ describe('vscode adapter getDoctorChecks', () => {
178
+ it('returns expected checks', async () => {
179
+ const adapter = await IDE_ADAPTERS['vscode']()
180
+ const checks = adapter.getDoctorChecks()
181
+ expect(checks.length).toBeGreaterThan(0)
182
+ expect(checks.find((c) => c.path === '.github/copilot-instructions.md')?.type).toBe('file')
183
+ expect(checks.find((c) => c.path === '.github/agents/')?.countFilter).toBe('.agent.md')
184
+ expect(checks.find((c) => c.path === '.github/instructions/')?.countFilter).toBe('.md')
185
+ expect(checks.find((c) => c.path === '.github/skills/')).toBeDefined()
186
+ })
187
+ })
188
+
189
+ describe('cursor adapter getDoctorChecks', () => {
190
+ it('returns expected checks', async () => {
191
+ const adapter = await IDE_ADAPTERS['cursor']()
192
+ const checks = adapter.getDoctorChecks()
193
+ expect(checks.length).toBeGreaterThan(0)
194
+ expect(checks.find((c) => c.path === '.cursorrules')?.type).toBe('file')
195
+ expect(checks.find((c) => c.path === '.cursor/rules/agents/')?.countFilter).toBe('.mdc')
196
+ expect(checks.find((c) => c.path === '.cursor/rules/skills/')?.countFilter).toBe('.mdc')
197
+ })
198
+ })
199
+
200
+ describe('claude-code adapter getDoctorChecks', () => {
201
+ it('returns expected checks', async () => {
202
+ const adapter = await IDE_ADAPTERS['claude-code']()
203
+ const checks = adapter.getDoctorChecks()
204
+ expect(checks.length).toBeGreaterThan(0)
205
+ expect(checks.find((c) => c.path === 'CLAUDE.md')?.type).toBe('file')
206
+ expect(checks.find((c) => c.path === '.claude/agents/')).toBeDefined()
207
+ expect(checks.find((c) => c.path === '.claude/skills/')).toBeDefined()
208
+ // claude-code: prompts and workflows share 'commands' dir
209
+ expect(checks.find((c) => c.path === '.claude/commands/')).toBeDefined()
210
+ expect(checks.find((c) => c.label === 'Commands directory')).toBeDefined()
211
+ })
212
+ })
213
+
214
+ describe('opencode adapter getDoctorChecks', () => {
215
+ it('returns expected checks', async () => {
216
+ const adapter = await IDE_ADAPTERS['opencode']()
217
+ const checks = adapter.getDoctorChecks()
218
+ expect(checks.length).toBeGreaterThan(0)
219
+ expect(checks.find((c) => c.path === 'AGENTS.md')?.type).toBe('file')
220
+ expect(checks.find((c) => c.path === '.opencode/agents/')).toBeDefined()
221
+ expect(checks.find((c) => c.path === '.opencode/skills/')).toBeDefined()
222
+ // opencode: separate prompts and workflows dirs
223
+ expect(checks.find((c) => c.path === '.opencode/prompts/')).toBeDefined()
224
+ expect(checks.find((c) => c.path === '.opencode/workflows/')).toBeDefined()
225
+ })
226
+ })
227
+
228
+ describe('all adapters satisfy getDoctorChecks interface', () => {
229
+ const ideIds = ['vscode', 'cursor', 'claude-code', 'opencode']
230
+
231
+ for (const ide of ideIds) {
232
+ it(`${ide}: every check has label, path, and type`, async () => {
233
+ const adapter = await IDE_ADAPTERS[ide]()
234
+ const checks = adapter.getDoctorChecks()
235
+ expect(Array.isArray(checks)).toBe(true)
236
+ for (const c of checks) {
237
+ expect(typeof c.label).toBe('string')
238
+ expect(c.label.length).toBeGreaterThan(0)
239
+ expect(typeof c.path).toBe('string')
240
+ expect(c.path.length).toBeGreaterThan(0)
241
+ expect(['file', 'dir']).toContain(c.type)
242
+ }
243
+ })
244
+ }
245
+ })
package/src/cli/doctor.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { resolve } from 'node:path';
2
- import { readFile, access, readdir, writeFile } from 'node:fs/promises';
2
+ import { readdir, writeFile } from 'node:fs/promises';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { readManifest } from './manifest.js';
5
5
  import { getRequiredMcpEnvVars } from './stack-config.js';
6
- import type { CliContext, Manifest, IdeChoice } from './types.js';
6
+ import { IDE_ADAPTERS } from './adapters/index.js';
7
+ import type { CliContext, DoctorCheck, IdeChoice, Manifest } from './types.js';
8
+ import { IDE_LABELS } from './types.js';
7
9
 
8
10
  // ── Styled output helpers ─────────────────────────────────────
9
11
 
@@ -24,25 +26,26 @@ interface CheckResult {
24
26
 
25
27
  function checkManifest(manifest: Manifest | null): CheckResult {
26
28
  if (!manifest) {
27
- return { ok: false, label: 'OpenCastle manifest (.opencastle.json)', detail: 'Not found. Run "npx opencastle init" first.' };
29
+ return { ok: false, label: 'OpenCastle manifest (.opencastle/manifest.json)', detail: 'Not found. Run "npx opencastle init" first.' };
28
30
  }
29
- return { ok: true, label: 'OpenCastle manifest (.opencastle.json)', detail: `v${manifest.version}, IDE: ${manifest.ides?.join(', ') ?? manifest.ide}` };
31
+ return { ok: true, label: 'OpenCastle manifest (.opencastle/manifest.json)', detail: `v${manifest.version}, IDE: ${manifest.ides?.join(', ') ?? manifest.ide}` };
30
32
  }
31
33
 
32
34
  async function checkCustomizations(projectRoot: string): Promise<CheckResult> {
33
- const dir = resolve(projectRoot, '.github', 'customizations');
35
+ const dir = resolve(projectRoot, '.opencastle');
34
36
  if (!existsSync(dir)) {
35
- return { ok: false, label: 'Customizations directory', detail: '.github/customizations/ not found' };
37
+ return { ok: false, label: 'Customizations directory', detail: '.opencastle/ not found' };
36
38
  }
37
39
  const files = await readdir(dir).catch(() => []);
38
40
  return { ok: true, label: 'Customizations directory', detail: `${files.length} entries` };
39
41
  }
40
42
 
41
43
  async function checkSkillMatrix(projectRoot: string): Promise<CheckResult> {
42
- const path = resolve(projectRoot, '.github', 'customizations', 'agents', 'skill-matrix.json');
44
+ const path = resolve(projectRoot, '.opencastle', 'agents', 'skill-matrix.json');
43
45
  if (!existsSync(path)) {
44
- return { ok: false, label: 'Skill matrix', detail: 'File not found at .github/customizations/agents/skill-matrix.json' };
46
+ return { ok: false, label: 'Skill matrix', detail: 'File not found at .opencastle/agents/skill-matrix.json' };
45
47
  }
48
+ const { readFile } = await import('node:fs/promises');
46
49
  const content = await readFile(path, 'utf8');
47
50
  try {
48
51
  const data = JSON.parse(content);
@@ -59,50 +62,14 @@ async function checkSkillMatrix(projectRoot: string): Promise<CheckResult> {
59
62
  }
60
63
  }
61
64
 
62
- async function checkInstructions(projectRoot: string): Promise<CheckResult> {
63
- const dir = resolve(projectRoot, '.github', 'instructions');
64
- if (!existsSync(dir)) {
65
- return { ok: false, label: 'Instruction files', detail: '.github/instructions/ not found' };
66
- }
67
- const files = await readdir(dir).catch(() => []);
68
- const mdFiles = files.filter((f) => f.endsWith('.md'));
69
- if (mdFiles.length === 0) {
70
- return { ok: false, label: 'Instruction files', detail: 'No .md files in .github/instructions/' };
71
- }
72
- return { ok: true, label: 'Instruction files', detail: `${mdFiles.length} instruction files` };
73
- }
74
-
75
- async function checkAgents(projectRoot: string): Promise<CheckResult> {
76
- const dir = resolve(projectRoot, '.github', 'agents');
77
- if (!existsSync(dir)) {
78
- return { ok: false, label: 'Agent definitions', detail: '.github/agents/ directory not found' };
79
- }
80
- const files = await readdir(dir).catch(() => []);
81
- const agentFiles = files.filter((f) => f.endsWith('.agent.md'));
82
- if (agentFiles.length === 0) {
83
- return { ok: false, label: 'Agent definitions', detail: 'No .agent.md files found' };
84
- }
85
- return { ok: true, label: 'Agent definitions', detail: `${agentFiles.length} agents` };
86
- }
87
-
88
- async function checkSkills(projectRoot: string): Promise<CheckResult> {
89
- const dir = resolve(projectRoot, '.github', 'skills');
90
- if (!existsSync(dir)) {
91
- return { ok: false, label: 'Skills directory', detail: '.github/skills/ not found' };
92
- }
93
- const entries = await readdir(dir).catch(() => []);
94
- return { ok: true, label: 'Skills directory', detail: `${entries.length} skills` };
95
- }
96
-
97
65
  async function checkLogs(projectRoot: string): Promise<CheckResult> {
98
- const dir = resolve(projectRoot, '.github', 'customizations', 'logs');
66
+ const dir = resolve(projectRoot, '.opencastle', 'logs');
99
67
  if (!existsSync(dir)) {
100
68
  return { ok: false, label: 'Observability logs', detail: 'logs/ directory not found — dashboard will be empty' };
101
69
  }
102
70
  const required = ['sessions.ndjson', 'delegations.ndjson', 'reviews.ndjson', 'panels.ndjson', 'disputes.ndjson'];
103
71
  const missing = required.filter((f) => !existsSync(resolve(dir, f)));
104
72
  if (missing.length > 0) {
105
- // Auto-create missing log files — they're empty NDJSON stubs
106
73
  for (const file of missing) {
107
74
  await writeFile(resolve(dir, file), '', { flag: 'wx' }).catch(() => {/* already exists */});
108
75
  }
@@ -141,62 +108,51 @@ async function checkDotEnv(projectRoot: string, manifest: Manifest | null): Prom
141
108
  return { ok: true, label: '.env file', detail: 'Present' };
142
109
  }
143
110
 
144
- async function checkIdeConfigs(projectRoot: string, manifest: Manifest | null): Promise<CheckResult> {
145
- if (!manifest) {
146
- return { ok: false, label: 'IDE configuration files', detail: 'No manifest — cannot check' };
147
- }
148
- const ides = manifest.ides ?? [manifest.ide];
149
- const checks: Array<{ ide: string; file: string; found: boolean }> = [];
150
-
151
- for (const ide of ides) {
152
- let configFile: string;
153
- switch (ide as IdeChoice) {
154
- case 'vscode':
155
- configFile = '.github/copilot-instructions.md';
156
- break;
157
- case 'cursor':
158
- configFile = '.cursor/rules/opencastle.mdc';
159
- break;
160
- case 'claude-code':
161
- configFile = '.claude/settings.json';
162
- break;
163
- case 'opencode':
164
- configFile = '.opencode/agents.md';
165
- break;
166
- default:
167
- continue;
111
+ // ── Generic adapter-driven checks ────────────────────────────────
112
+
113
+ /** Run a single DoctorCheck against the filesystem. */
114
+ export async function runDoctorCheck(projectRoot: string, check: DoctorCheck): Promise<CheckResult> {
115
+ const fullPath = resolve(projectRoot, check.path);
116
+
117
+ if (check.type === 'file') {
118
+ if (!existsSync(fullPath)) {
119
+ return { ok: false, label: check.label, detail: `${check.path} not found` };
168
120
  }
169
- checks.push({ ide: ide as string, file: configFile, found: existsSync(resolve(projectRoot, configFile)) });
121
+ return { ok: true, label: check.label };
170
122
  }
171
123
 
172
- const missing = checks.filter((c) => !c.found);
173
- if (missing.length > 0) {
174
- return { ok: false, label: 'IDE configuration files', detail: `Missing: ${missing.map((m) => `${m.ide} (${m.file})`).join(', ')}` };
124
+ // type === 'dir'
125
+ if (!existsSync(fullPath)) {
126
+ return { ok: false, label: check.label, detail: `${check.path} not found` };
175
127
  }
176
- return { ok: true, label: 'IDE configuration files', detail: `${checks.length} IDE(s) configured` };
177
- }
178
128
 
179
- async function checkMcpConfig(projectRoot: string, manifest: Manifest | null): Promise<CheckResult> {
180
- if (!manifest) {
181
- return { ok: false, label: 'MCP configuration', detail: 'No manifest — cannot check' };
182
- }
183
- const ides = manifest.ides ?? [manifest.ide];
184
- const mcpPaths: Record<string, string> = {
185
- vscode: '.vscode/mcp.json',
186
- cursor: '.cursor/mcp.json',
187
- 'claude-code': '.claude/mcp.json',
188
- opencode: 'mcp.json',
189
- };
190
-
191
- const found: string[] = [];
192
- for (const ide of ides) {
193
- const path = mcpPaths[ide as string];
194
- if (path && existsSync(resolve(projectRoot, path))) {
195
- found.push(ide as string);
129
+ if (check.countContents) {
130
+ const entries = await readdir(fullPath).catch(() => [] as string[]);
131
+ const filtered = check.countFilter
132
+ ? entries.filter((e) => e.endsWith(check.countFilter!))
133
+ : entries;
134
+ if (filtered.length === 0) {
135
+ return { ok: false, label: check.label, detail: `No files found in ${check.path}` };
196
136
  }
137
+ return { ok: true, label: check.label, detail: `${filtered.length} file(s)` };
138
+ }
139
+
140
+ return { ok: true, label: check.label };
141
+ }
142
+
143
+ /** Check MCP config presence from the adapter's customizable paths. */
144
+ export function checkMcpFromPaths(projectRoot: string, mcpPaths: string[]): CheckResult {
145
+ if (mcpPaths.length === 0) {
146
+ return { ok: true, label: 'MCP configuration', detail: 'No MCP config path configured' };
197
147
  }
198
- if (found.length === 0 && ides.length > 0) {
199
- return { ok: true, label: 'MCP configuration', detail: 'No MCP config files found (MCP tools will not be available)', warning: true };
148
+ const found = mcpPaths.filter((p) => existsSync(resolve(projectRoot, p)));
149
+ if (found.length === 0) {
150
+ return {
151
+ ok: true,
152
+ label: 'MCP configuration',
153
+ detail: `No MCP config found (${mcpPaths.join(', ')}) — MCP tools unavailable`,
154
+ warning: true,
155
+ };
200
156
  }
201
157
  return { ok: true, label: 'MCP configuration', detail: `${found.length} MCP config(s)` };
202
158
  }
@@ -211,30 +167,71 @@ export default async function doctor({ args: _args }: CliContext): Promise<void>
211
167
 
212
168
  const manifest = await readManifest(projectRoot);
213
169
 
214
- const results: CheckResult[] = [
170
+ // Shared checks (not IDE-specific)
171
+ const sharedResults: CheckResult[] = [
215
172
  checkManifest(manifest),
216
173
  await checkCustomizations(projectRoot),
217
- await checkInstructions(projectRoot),
218
- await checkAgents(projectRoot),
219
- await checkSkills(projectRoot),
220
174
  await checkSkillMatrix(projectRoot),
221
175
  await checkLogs(projectRoot),
222
- await checkIdeConfigs(projectRoot, manifest),
223
- await checkMcpConfig(projectRoot, manifest),
224
176
  checkMcpEnvVars(manifest),
225
177
  await checkDotEnv(projectRoot, manifest),
226
178
  ];
227
179
 
228
- for (const r of results) {
180
+ // IDE-specific checks derived from each adapter
181
+ type IdeGroup = { label: string; results: CheckResult[] };
182
+ const ideGroups: IdeGroup[] = [];
183
+
184
+ if (manifest) {
185
+ const ides = manifest.ides ?? (manifest.ide ? [manifest.ide] : []);
186
+ for (const ide of ides) {
187
+ const loader = IDE_ADAPTERS[ide];
188
+ if (!loader) continue;
189
+ const adapter = await loader();
190
+ const doctorChecks = adapter.getDoctorChecks();
191
+ const managedPaths = adapter.getManagedPaths();
192
+
193
+ const checkResults = await Promise.all(
194
+ doctorChecks.map((c) => runDoctorCheck(projectRoot, c))
195
+ );
196
+
197
+ // MCP config check — non-directory entries in the adapter's customizable paths
198
+ const mcpPaths = managedPaths.customizable.filter((p) => !p.endsWith('/'));
199
+ checkResults.push(checkMcpFromPaths(projectRoot, mcpPaths));
200
+
201
+ ideGroups.push({
202
+ label: IDE_LABELS[ide as IdeChoice] ?? ide,
203
+ results: checkResults,
204
+ });
205
+ }
206
+ }
207
+
208
+ // Print shared results
209
+ for (const r of sharedResults) {
229
210
  const icon = r.ok ? (r.warning ? WARN : PASS) : FAIL;
230
211
  const detail = r.detail ? ` ${DIM(r.detail)}` : '';
231
212
  console.log(` ${icon} ${r.label}${detail}`);
232
213
  }
233
214
 
234
- const failures = results.filter((r) => !r.ok);
235
- const warnings = results.filter((r) => r.ok && r.warning);
215
+ // Print IDE-specific results, grouped with a header when multiple IDEs are configured
216
+ if (ideGroups.length > 0) {
217
+ console.log();
218
+ for (const group of ideGroups) {
219
+ if (ideGroups.length > 1) {
220
+ console.log(` ${BOLD(`[${group.label}]`)}`);
221
+ }
222
+ for (const r of group.results) {
223
+ const icon = r.ok ? (r.warning ? WARN : PASS) : FAIL;
224
+ const detail = r.detail ? ` ${DIM(r.detail)}` : '';
225
+ console.log(` ${icon} ${r.label}${detail}`);
226
+ }
227
+ if (ideGroups.length > 1) console.log();
228
+ }
229
+ }
230
+
231
+ const allResults = [...sharedResults, ...ideGroups.flatMap((g) => g.results)];
232
+ const failures = allResults.filter((r) => !r.ok);
233
+ const warnings = allResults.filter((r) => r.ok && r.warning);
236
234
 
237
- console.log();
238
235
  if (failures.length > 0) {
239
236
  console.log(` ${BOLD(`${failures.length} issue(s) found.`)} Run "npx opencastle init" to fix.\n`);
240
237
  process.exit(1);
package/src/cli/eject.ts CHANGED
@@ -19,7 +19,7 @@ export default async function eject({
19
19
 
20
20
  console.log(`\n 🏰 OpenCastle eject\n`)
21
21
  console.log(' This will:')
22
- console.log(' • Remove .opencastle.json (manifest)')
22
+ console.log(' • Remove .opencastle/manifest.json (manifest)')
23
23
  console.log(' • Keep ALL generated files as standalone')
24
24
  console.log(
25
25
  ' • You can safely uninstall the opencastle package after this\n'
@@ -36,7 +36,7 @@ export default async function eject({
36
36
  return
37
37
  }
38
38
 
39
- await unlink(resolve(projectRoot, '.opencastle.json'))
39
+ await unlink(resolve(projectRoot, '.opencastle', 'manifest.json'))
40
40
 
41
41
  console.log('\n ✓ Ejected. Files are now standalone.')
42
42
  console.log(' You can uninstall: npm uninstall opencastle\n')