opencastle 0.19.0 → 0.20.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 (48) 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 +10 -0
  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 +16 -1
  16. package/dist/cli/adapters/single-file-base.js.map +1 -1
  17. package/dist/cli/adapters/vscode.d.ts +2 -1
  18. package/dist/cli/adapters/vscode.d.ts.map +1 -1
  19. package/dist/cli/adapters/vscode.js +10 -0
  20. package/dist/cli/adapters/vscode.js.map +1 -1
  21. package/dist/cli/doctor.d.ts +12 -1
  22. package/dist/cli/doctor.d.ts.map +1 -1
  23. package/dist/cli/doctor.js +83 -93
  24. package/dist/cli/doctor.js.map +1 -1
  25. package/dist/cli/doctor.test.d.ts +2 -0
  26. package/dist/cli/doctor.test.d.ts.map +1 -0
  27. package/dist/cli/doctor.test.js +213 -0
  28. package/dist/cli/doctor.test.js.map +1 -0
  29. package/dist/cli/types.d.ts +14 -0
  30. package/dist/cli/types.d.ts.map +1 -1
  31. package/dist/cli/types.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/cli/adapters/claude-code.ts +2 -2
  34. package/src/cli/adapters/cursor.ts +12 -1
  35. package/src/cli/adapters/opencode.ts +2 -2
  36. package/src/cli/adapters/single-file-base.ts +17 -2
  37. package/src/cli/adapters/vscode.ts +12 -1
  38. package/src/cli/doctor.test.ts +245 -0
  39. package/src/cli/doctor.ts +94 -97
  40. package/src/cli/types.ts +15 -0
  41. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  42. package/src/dashboard/scripts/generate-seed-data.ts +4 -4
  43. package/src/dashboard/seed-data/delegations.ndjson +15 -15
  44. package/src/dashboard/seed-data/events.ndjson +15 -15
  45. package/src/orchestrator/agents/team-lead.agent.md +5 -0
  46. package/src/orchestrator/prompts/implement-feature.prompt.md +1 -1
  47. package/src/orchestrator/skills/agent-memory/SKILL.md +1 -1
  48. package/src/orchestrator/skills/context-map/SKILL.md +3 -3
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs'
4
4
  import { copyDir, getOrchestratorRoot, removeDirIfExists, getPluginsRoot, getPluginSkillEntries } from '../copy.js'
5
5
  import { scaffoldMcpConfig } from '../mcp.js'
6
6
  import { getExcludedSkills, getExcludedAgents, getCustomizationsTransform, getIncludedPluginIds } from '../stack-config.js'
7
- import type { CopyResults, ManagedPaths, RepoInfo, StackConfig } from '../types.js'
7
+ import type { CopyResults, DoctorCheck, ManagedPaths, RepoInfo, StackConfig } from '../types.js'
8
8
  import { splitFrontmatter, parseFrontmatterString } from './frontmatter.js'
9
9
 
10
10
  /**
@@ -317,6 +317,17 @@ export function getManagedPaths(): ManagedPaths {
317
317
  }
318
318
  }
319
319
 
320
+ export function getDoctorChecks(): DoctorCheck[] {
321
+ return [
322
+ { label: 'Cursor rules file', path: '.cursorrules', type: 'file' },
323
+ { label: 'Instruction rules', path: '.cursor/rules/', type: 'dir', countContents: true, countFilter: '.mdc' },
324
+ { label: 'Agent rules', path: '.cursor/rules/agents/', type: 'dir', countContents: true, countFilter: '.mdc' },
325
+ { label: 'Skill rules', path: '.cursor/rules/skills/', type: 'dir', countContents: true, countFilter: '.mdc' },
326
+ { label: 'Workflow rules', path: '.cursor/rules/agent-workflows/', type: 'dir', countContents: true },
327
+ { label: 'Prompt rules', path: '.cursor/rules/prompts/', type: 'dir', countContents: true },
328
+ ]
329
+ }
330
+
320
331
  // ─── Internal helpers ─────────────────────────────────────────────
321
332
 
322
333
  interface ConvertDirOptions {
@@ -16,7 +16,7 @@ import { createSingleFileAdapter } from './single-file-base.js'
16
16
 
17
17
  export const IDE_ID = 'opencode'
18
18
 
19
- const { install, update, getManagedPaths } = createSingleFileAdapter({
19
+ const { install, update, getManagedPaths, getDoctorChecks } = createSingleFileAdapter({
20
20
  rootFile: 'AGENTS.md',
21
21
  dotDir: '.opencode',
22
22
  mcpConfigPath: 'opencode.json',
@@ -27,5 +27,5 @@ const { install, update, getManagedPaths } = createSingleFileAdapter({
27
27
  frameworkDirs: ['agents', 'skills', 'prompts', 'workflows'],
28
28
  })
29
29
 
30
- export { install, update, getManagedPaths }
30
+ export { install, update, getManagedPaths, getDoctorChecks }
31
31
 
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs'
4
4
  import { copyDir, getOrchestratorRoot, getPluginsRoot, getPluginSkillEntries } from '../copy.js'
5
5
  import { scaffoldMcpConfig } from '../mcp.js'
6
6
  import { getExcludedSkills, getExcludedAgents, getCustomizationsTransform, getIncludedPluginIds } from '../stack-config.js'
7
- import type { CopyResults, IdeAdapter, IdeChoice, ManagedPaths, RepoInfo, StackConfig } from '../types.js'
7
+ import type { CopyResults, DoctorCheck, IdeAdapter, IdeChoice, ManagedPaths, RepoInfo, StackConfig } from '../types.js'
8
8
  import { stripFrontmatter, parseFrontmatterMeta } from './frontmatter.js'
9
9
 
10
10
  /**
@@ -316,5 +316,20 @@ export function createSingleFileAdapter(config: SingleFileAdapterConfig): IdeAda
316
316
  }
317
317
  }
318
318
 
319
- return { install, update, getManagedPaths }
319
+ function getDoctorChecks(): DoctorCheck[] {
320
+ const checks: DoctorCheck[] = [
321
+ { label: 'Root instructions file', path: config.rootFile, type: 'file' },
322
+ { label: 'Agent definitions', path: `${config.dotDir}/agents/`, type: 'dir', countContents: true, countFilter: '.md' },
323
+ { label: 'Skills directory', path: `${config.dotDir}/skills/`, type: 'dir', countContents: true, countFilter: '.md' },
324
+ ]
325
+ if (config.promptsDir === config.workflowsDir) {
326
+ checks.push({ label: 'Commands directory', path: `${config.dotDir}/${config.promptsDir}/`, type: 'dir', countContents: true })
327
+ } else {
328
+ checks.push({ label: 'Prompts directory', path: `${config.dotDir}/${config.promptsDir}/`, type: 'dir', countContents: true })
329
+ checks.push({ label: 'Workflows directory', path: `${config.dotDir}/${config.workflowsDir}/`, type: 'dir', countContents: true })
330
+ }
331
+ return checks
332
+ }
333
+
334
+ return { install, update, getManagedPaths, getDoctorChecks }
320
335
  }
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs'
4
4
  import { copyDir, getOrchestratorRoot, removeDirIfExists, getPluginsRoot, getPluginSkillEntries } from '../copy.js'
5
5
  import { scaffoldMcpConfig } from '../mcp.js'
6
6
  import { getExcludedSkills, getExcludedAgents, getCustomizationsTransform, getIncludedPluginIds, getAgentTransform } from '../stack-config.js'
7
- import type { CopyResults, CopyDirOptions, ManagedPaths, RepoInfo, StackConfig } from '../types.js'
7
+ import type { CopyResults, CopyDirOptions, DoctorCheck, ManagedPaths, RepoInfo, StackConfig } from '../types.js'
8
8
 
9
9
  /**
10
10
  * VS Code / GitHub Copilot adapter.
@@ -200,3 +200,14 @@ export function getManagedPaths(): ManagedPaths {
200
200
  ],
201
201
  }
202
202
  }
203
+
204
+ export function getDoctorChecks(): DoctorCheck[] {
205
+ return [
206
+ { label: 'Copilot instructions', path: '.github/copilot-instructions.md', type: 'file' },
207
+ { label: 'Instruction files', path: '.github/instructions/', type: 'dir', countContents: true, countFilter: '.md' },
208
+ { label: 'Agent definitions', path: '.github/agents/', type: 'dir', countContents: true, countFilter: '.agent.md' },
209
+ { label: 'Skills directory', path: '.github/skills/', type: 'dir', countContents: true },
210
+ { label: 'Agent workflows', path: '.github/agent-workflows/', type: 'dir', countContents: true },
211
+ { label: 'Prompts directory', path: '.github/prompts/', type: 'dir', countContents: true },
212
+ ]
213
+ }
@@ -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
 
@@ -43,6 +45,7 @@ async function checkSkillMatrix(projectRoot: string): Promise<CheckResult> {
43
45
  if (!existsSync(path)) {
44
46
  return { ok: false, label: 'Skill matrix', detail: 'File not found at .github/customizations/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,41 +62,6 @@ 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
66
  const dir = resolve(projectRoot, '.github', 'customizations', 'logs');
99
67
  if (!existsSync(dir)) {
@@ -102,7 +70,6 @@ async function checkLogs(projectRoot: string): Promise<CheckResult> {
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/types.ts CHANGED
@@ -94,11 +94,26 @@ export interface ManagedPaths {
94
94
  customizable: string[];
95
95
  }
96
96
 
97
+ /** Structure check for the doctor command — describes expected files/dirs per IDE. */
98
+ export interface DoctorCheck {
99
+ /** Human-friendly label for the check */
100
+ label: string;
101
+ /** Relative path to a file or directory (ending with /) to check exists */
102
+ path: string;
103
+ /** 'file' or 'dir' */
104
+ type: 'file' | 'dir';
105
+ /** If true, counts the contents and reports count */
106
+ countContents?: boolean;
107
+ /** File extension filter when counting (e.g. '.md', '.mdc', '.agent.md') */
108
+ countFilter?: string;
109
+ }
110
+
97
111
  /** IDE adapter interface (init/update commands). */
98
112
  export interface IdeAdapter {
99
113
  install(_pkgRoot: string, _projectRoot: string, _stack?: StackConfig, _repoInfo?: RepoInfo): Promise<CopyResults>;
100
114
  update(_pkgRoot: string, _projectRoot: string, _stack?: StackConfig): Promise<CopyResults>;
101
115
  getManagedPaths(): ManagedPaths;
116
+ getDoctorChecks(): DoctorCheck[];
102
117
  }
103
118
 
104
119
  /** Select prompt option. */
@@ -1,25 +1,25 @@
1
1
  {
2
- "hash": "37afd6de",
2
+ "hash": "1338aa44",
3
3
  "configHash": "30f8ea04",
4
- "lockfileHash": "fc91404e",
5
- "browserHash": "4ed40789",
4
+ "lockfileHash": "56ae5b5d",
5
+ "browserHash": "aaeb6157",
6
6
  "optimized": {
7
7
  "astro > cssesc": {
8
8
  "src": "../../../../../node_modules/cssesc/cssesc.js",
9
9
  "file": "astro___cssesc.js",
10
- "fileHash": "797719d6",
10
+ "fileHash": "2e7de42e",
11
11
  "needsInterop": true
12
12
  },
13
13
  "astro > aria-query": {
14
14
  "src": "../../../../../node_modules/aria-query/lib/index.js",
15
15
  "file": "astro___aria-query.js",
16
- "fileHash": "92b9b174",
16
+ "fileHash": "4d71b8ca",
17
17
  "needsInterop": true
18
18
  },
19
19
  "astro > axobject-query": {
20
20
  "src": "../../../../../node_modules/axobject-query/lib/index.js",
21
21
  "file": "astro___axobject-query.js",
22
- "fileHash": "b81bf886",
22
+ "fileHash": "aa82361e",
23
23
  "needsInterop": true
24
24
  }
25
25
  },