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.
- package/README.md +18 -9
- package/dist/cli/adapters/claude-code.d.ts +2 -2
- package/dist/cli/adapters/claude-code.d.ts.map +1 -1
- package/dist/cli/adapters/claude-code.js +2 -2
- package/dist/cli/adapters/claude-code.js.map +1 -1
- package/dist/cli/adapters/cursor.d.ts +2 -1
- package/dist/cli/adapters/cursor.d.ts.map +1 -1
- package/dist/cli/adapters/cursor.js +10 -0
- package/dist/cli/adapters/cursor.js.map +1 -1
- package/dist/cli/adapters/opencode.d.ts +2 -2
- package/dist/cli/adapters/opencode.d.ts.map +1 -1
- package/dist/cli/adapters/opencode.js +2 -2
- package/dist/cli/adapters/opencode.js.map +1 -1
- package/dist/cli/adapters/single-file-base.d.ts.map +1 -1
- package/dist/cli/adapters/single-file-base.js +16 -1
- package/dist/cli/adapters/single-file-base.js.map +1 -1
- package/dist/cli/adapters/vscode.d.ts +2 -1
- package/dist/cli/adapters/vscode.d.ts.map +1 -1
- package/dist/cli/adapters/vscode.js +10 -0
- package/dist/cli/adapters/vscode.js.map +1 -1
- package/dist/cli/doctor.d.ts +12 -1
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +83 -93
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/doctor.test.d.ts +2 -0
- package/dist/cli/doctor.test.d.ts.map +1 -0
- package/dist/cli/doctor.test.js +213 -0
- package/dist/cli/doctor.test.js.map +1 -0
- package/dist/cli/types.d.ts +14 -0
- package/dist/cli/types.d.ts.map +1 -1
- package/dist/cli/types.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/adapters/claude-code.ts +2 -2
- package/src/cli/adapters/cursor.ts +12 -1
- package/src/cli/adapters/opencode.ts +2 -2
- package/src/cli/adapters/single-file-base.ts +17 -2
- package/src/cli/adapters/vscode.ts +12 -1
- package/src/cli/doctor.test.ts +245 -0
- package/src/cli/doctor.ts +94 -97
- package/src/cli/types.ts +15 -0
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/dashboard/scripts/generate-seed-data.ts +4 -4
- package/src/dashboard/seed-data/delegations.ndjson +15 -15
- package/src/dashboard/seed-data/events.ndjson +15 -15
- package/src/orchestrator/agents/team-lead.agent.md +5 -0
- package/src/orchestrator/prompts/implement-feature.prompt.md +1 -1
- package/src/orchestrator/skills/agent-memory/SKILL.md +1 -1
- 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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
121
|
+
return { ok: true, label: check.label };
|
|
170
122
|
}
|
|
171
123
|
|
|
172
|
-
|
|
173
|
-
if (
|
|
174
|
-
return { ok: false, label:
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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": "
|
|
2
|
+
"hash": "1338aa44",
|
|
3
3
|
"configHash": "30f8ea04",
|
|
4
|
-
"lockfileHash": "
|
|
5
|
-
"browserHash": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
22
|
+
"fileHash": "aa82361e",
|
|
23
23
|
"needsInterop": true
|
|
24
24
|
}
|
|
25
25
|
},
|