opencastle 0.24.1 → 0.26.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/dist/cli/bootstrap.d.ts +8 -0
- package/dist/cli/bootstrap.d.ts.map +1 -0
- package/dist/cli/bootstrap.js +358 -0
- package/dist/cli/bootstrap.js.map +1 -0
- package/dist/cli/bootstrap.test.d.ts +6 -0
- package/dist/cli/bootstrap.test.d.ts.map +1 -0
- package/dist/cli/bootstrap.test.js +196 -0
- package/dist/cli/bootstrap.test.js.map +1 -0
- package/dist/cli/detect.d.ts +6 -1
- package/dist/cli/detect.d.ts.map +1 -1
- package/dist/cli/detect.js +18 -0
- package/dist/cli/detect.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +21 -10
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/prompt.js +1 -1
- package/dist/cli/prompt.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/bootstrap.test.ts +286 -0
- package/src/cli/bootstrap.ts +472 -0
- package/src/cli/detect.ts +22 -1
- package/src/cli/init.ts +23 -13
- package/src/cli/prompt.ts +1 -1
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/orchestrator/agents/team-lead.agent.md +4 -2
- package/src/orchestrator/customizations/README.md +3 -3
- package/src/orchestrator/customizations/agents/agent-registry.md +1 -1
- package/src/orchestrator/customizations/project/docs-structure.md +1 -1
- package/src/orchestrator/customizations/project/roadmap.md +1 -1
- package/src/orchestrator/customizations/project/tracker-config.md +1 -1
- package/src/orchestrator/customizations/project.instructions.md +2 -2
- package/src/orchestrator/customizations/stack/api-config.md +1 -1
- package/src/orchestrator/customizations/stack/cms-config.md +2 -2
- package/src/orchestrator/customizations/stack/data-pipeline-config.md +1 -1
- package/src/orchestrator/customizations/stack/database-config.md +2 -2
- package/src/orchestrator/customizations/stack/deployment-config.md +1 -1
- package/src/orchestrator/customizations/stack/notifications-config.md +1 -1
- package/src/orchestrator/customizations/stack/testing-config.md +1 -1
- package/src/orchestrator/instructions/general.instructions.md +2 -0
- package/src/orchestrator/prompts/bootstrap-customizations.prompt.md +127 -132
- package/src/orchestrator/skills/agent-hooks/SKILL.md +7 -2
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { readFile, writeFile, unlink, readdir } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import type { RepoInfo, StackConfig } from './types.js'
|
|
5
|
+
|
|
6
|
+
export interface BootstrapResult {
|
|
7
|
+
populated: string[]
|
|
8
|
+
removed: string[]
|
|
9
|
+
renamed: string[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ── Internal types ─────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
interface PackageJson {
|
|
15
|
+
name?: string
|
|
16
|
+
description?: string
|
|
17
|
+
scripts?: Record<string, string>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface WorkspacePkg {
|
|
21
|
+
path: string
|
|
22
|
+
name: string
|
|
23
|
+
description?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Utilities ──────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
async function tryReadJson<T>(p: string): Promise<T | null> {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(await readFile(p, 'utf8')) as T
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Replace the first empty table row within a named markdown section.
|
|
38
|
+
* Scopes the search from `sectionMarker` to the next `## ` heading.
|
|
39
|
+
*/
|
|
40
|
+
function fillTableSection(
|
|
41
|
+
content: string,
|
|
42
|
+
sectionMarker: string,
|
|
43
|
+
emptyRow: string,
|
|
44
|
+
rows: string[],
|
|
45
|
+
): string {
|
|
46
|
+
if (rows.length === 0) return content
|
|
47
|
+
const sIdx = content.indexOf(sectionMarker)
|
|
48
|
+
if (sIdx === -1) return content
|
|
49
|
+
const nextSectionIdx = content.indexOf('\n## ', sIdx + sectionMarker.length)
|
|
50
|
+
const end = nextSectionIdx === -1 ? content.length : nextSectionIdx
|
|
51
|
+
const slice = content.slice(sIdx, end)
|
|
52
|
+
const needle = '\n' + emptyRow
|
|
53
|
+
const needleIdx = slice.indexOf(needle)
|
|
54
|
+
if (needleIdx === -1) return content
|
|
55
|
+
const abs = sIdx + needleIdx + 1 // position of first char of emptyRow (skip \n)
|
|
56
|
+
return content.slice(0, abs) + rows.join('\n') + content.slice(abs + emptyRow.length)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function replaceMarker(content: string, marker: string, replacement: string): string {
|
|
60
|
+
if (!content.includes(marker)) return content
|
|
61
|
+
return content.replace(marker, replacement)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function filterConfigFiles(configFiles: string[] | undefined, patterns: string[]): string[] {
|
|
65
|
+
if (!configFiles?.length) return []
|
|
66
|
+
return configFiles.filter(f => patterns.some(p => f === p || f.endsWith('/' + p) || f.includes(p)))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Workspace scanning ─────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
async function scanWorkspace(projectRoot: string): Promise<WorkspacePkg[]> {
|
|
72
|
+
const packages: WorkspacePkg[] = []
|
|
73
|
+
for (const dir of ['apps', 'packages', 'libs']) {
|
|
74
|
+
const dirPath = join(projectRoot, dir)
|
|
75
|
+
if (!existsSync(dirPath)) continue
|
|
76
|
+
let entries: Array<{ name: string; isDirectory(): boolean }> = []
|
|
77
|
+
try {
|
|
78
|
+
entries = await readdir(dirPath, { withFileTypes: true })
|
|
79
|
+
} catch {
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
for (const e of entries) {
|
|
83
|
+
if (!e.isDirectory()) continue
|
|
84
|
+
const pkg = await tryReadJson<PackageJson>(join(dirPath, e.name, 'package.json'))
|
|
85
|
+
packages.push({
|
|
86
|
+
path: `${dir}/${e.name}`,
|
|
87
|
+
name: pkg?.name ?? `${dir}/${e.name}`,
|
|
88
|
+
description: pkg?.description,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return packages
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Builders ───────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
function buildStackRows(info: RepoInfo): string[] {
|
|
98
|
+
const rows: string[] = []
|
|
99
|
+
const addList = (layer: string, values: string[] | undefined) => {
|
|
100
|
+
if (!values?.length) return
|
|
101
|
+
for (const v of values) rows.push(`| ${layer} | ${v} | <!-- TODO: verify --> | |`)
|
|
102
|
+
}
|
|
103
|
+
if (info.language) rows.push(`| Language | ${info.language} | | |`)
|
|
104
|
+
if (info.packageManager) rows.push(`| Package Manager | ${info.packageManager} | | |`)
|
|
105
|
+
addList('Framework', info.frameworks)
|
|
106
|
+
addList('Database', info.databases)
|
|
107
|
+
addList('CMS', info.cms)
|
|
108
|
+
addList('Deployment', info.deployment)
|
|
109
|
+
addList('Testing', info.testing)
|
|
110
|
+
addList('CI/CD', info.cicd)
|
|
111
|
+
addList('Styling', info.styling)
|
|
112
|
+
addList('Auth', info.auth)
|
|
113
|
+
return rows
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function buildKeyCommandsBlock(pm: string, scripts: Record<string, string>): string {
|
|
117
|
+
const commands = (['dev', 'build', 'test', 'lint', 'start'] as const)
|
|
118
|
+
.filter(k => scripts[k])
|
|
119
|
+
.map(k => `${pm} run ${k}`)
|
|
120
|
+
if (commands.length === 0) return ''
|
|
121
|
+
return `**Package manager:** \`${pm}\`\n\n\`\`\`bash\n${commands.join('\n')}\n\`\`\``
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Populators ─────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
async function populateProjectInstructions(
|
|
127
|
+
opencastleDir: string,
|
|
128
|
+
projectRoot: string,
|
|
129
|
+
info: RepoInfo,
|
|
130
|
+
pkg: PackageJson,
|
|
131
|
+
result: BootstrapResult,
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
const filePath = join(opencastleDir, 'project.instructions.md')
|
|
134
|
+
if (!existsSync(filePath)) return
|
|
135
|
+
let content = await readFile(filePath, 'utf8')
|
|
136
|
+
const orig = content
|
|
137
|
+
|
|
138
|
+
// Overview: project name + description
|
|
139
|
+
if (pkg.name || pkg.description) {
|
|
140
|
+
const parts: string[] = []
|
|
141
|
+
if (pkg.name) parts.push(`**Project:** ${pkg.name}`)
|
|
142
|
+
if (pkg.description) parts.push(`**Description:** ${pkg.description}`)
|
|
143
|
+
content = replaceMarker(
|
|
144
|
+
content,
|
|
145
|
+
'<!-- Project name, description, and current status -->',
|
|
146
|
+
parts.join('\n\n'),
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Tech stack table
|
|
151
|
+
content = fillTableSection(content, '## Tech Stack', '| | | | |', buildStackRows(info))
|
|
152
|
+
|
|
153
|
+
// Key commands
|
|
154
|
+
if (pkg.scripts) {
|
|
155
|
+
const pm = info.packageManager ?? 'npm'
|
|
156
|
+
const cmdBlock = buildKeyCommandsBlock(pm, pkg.scripts)
|
|
157
|
+
if (cmdBlock) {
|
|
158
|
+
content = replaceMarker(
|
|
159
|
+
content,
|
|
160
|
+
'<!-- Package manager and common development commands -->',
|
|
161
|
+
'<!-- Package manager and common development commands -->\n\n' + cmdBlock,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Monorepo workspace packages
|
|
167
|
+
if (info.monorepo) {
|
|
168
|
+
const workspaces = await scanWorkspace(projectRoot)
|
|
169
|
+
if (workspaces.length > 0) {
|
|
170
|
+
const pkgRows = workspaces.map(w => {
|
|
171
|
+
const cell =
|
|
172
|
+
w.name !== w.path ? `\`${w.path}\` (\`${w.name}\`)` : `\`${w.path}\``
|
|
173
|
+
return `| ${cell} | ${w.description ?? '<!-- TODO: verify -->'} |`
|
|
174
|
+
})
|
|
175
|
+
content = fillTableSection(content, '## Project Structure', '| | |', pkgRows)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (content !== orig) {
|
|
180
|
+
await writeFile(filePath, content, 'utf8')
|
|
181
|
+
result.populated.push('project.instructions.md')
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function populateTestingConfig(
|
|
186
|
+
opencastleDir: string,
|
|
187
|
+
info: RepoInfo,
|
|
188
|
+
result: BootstrapResult,
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
const filePath = join(opencastleDir, 'stack', 'testing-config.md')
|
|
191
|
+
if (!existsSync(filePath)) return
|
|
192
|
+
|
|
193
|
+
if (!info.testing?.length) {
|
|
194
|
+
await unlink(filePath)
|
|
195
|
+
result.removed.push('stack/testing-config.md')
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let content = await readFile(filePath, 'utf8')
|
|
200
|
+
const orig = content
|
|
201
|
+
|
|
202
|
+
const introLine = 'Project-specific testing details referenced by the `browser-testing` skill.'
|
|
203
|
+
if (content.includes(introLine)) {
|
|
204
|
+
const cfg = filterConfigFiles(info.configFiles, [
|
|
205
|
+
'vitest.config.ts',
|
|
206
|
+
'vitest.config.js',
|
|
207
|
+
'jest.config.ts',
|
|
208
|
+
'jest.config.js',
|
|
209
|
+
'playwright.config.ts',
|
|
210
|
+
'playwright.config.js',
|
|
211
|
+
])
|
|
212
|
+
let addition = `\n\n**Test frameworks:** ${info.testing.join(', ')}`
|
|
213
|
+
if (cfg.length > 0) {
|
|
214
|
+
addition += `\n\n**Config files:** ${cfg.map(f => `\`${f}\``).join(', ')}`
|
|
215
|
+
}
|
|
216
|
+
content = content.replace(introLine, introLine + addition)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (content !== orig) {
|
|
220
|
+
await writeFile(filePath, content, 'utf8')
|
|
221
|
+
result.populated.push('stack/testing-config.md')
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function populateDeploymentConfig(
|
|
226
|
+
opencastleDir: string,
|
|
227
|
+
info: RepoInfo,
|
|
228
|
+
result: BootstrapResult,
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
const filePath = join(opencastleDir, 'stack', 'deployment-config.md')
|
|
231
|
+
if (!existsSync(filePath)) return
|
|
232
|
+
|
|
233
|
+
if (!info.deployment?.length) {
|
|
234
|
+
await unlink(filePath)
|
|
235
|
+
result.removed.push('stack/deployment-config.md')
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let content = await readFile(filePath, 'utf8')
|
|
240
|
+
const orig = content
|
|
241
|
+
|
|
242
|
+
const archMarker =
|
|
243
|
+
'<!-- Describe the deployment platform, CI/CD pipeline, and trigger mechanism. -->'
|
|
244
|
+
if (content.includes(archMarker)) {
|
|
245
|
+
const cfg = filterConfigFiles(info.configFiles, [
|
|
246
|
+
'vercel.json',
|
|
247
|
+
'netlify.toml',
|
|
248
|
+
'Dockerfile',
|
|
249
|
+
'docker-compose.yml',
|
|
250
|
+
'docker-compose.yaml',
|
|
251
|
+
'fly.toml',
|
|
252
|
+
'render.yaml',
|
|
253
|
+
])
|
|
254
|
+
let addition = `**Platform:** ${info.deployment.join(', ')}`
|
|
255
|
+
if (cfg.length > 0) {
|
|
256
|
+
addition += `\n\n**Config files:** ${cfg.map(f => `\`${f}\``).join(', ')}`
|
|
257
|
+
}
|
|
258
|
+
content = content.replace(archMarker, addition + '\n\n' + archMarker)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (content !== orig) {
|
|
262
|
+
await writeFile(filePath, content, 'utf8')
|
|
263
|
+
result.populated.push('stack/deployment-config.md')
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function handleDatabaseConfig(
|
|
268
|
+
opencastleDir: string,
|
|
269
|
+
info: RepoInfo,
|
|
270
|
+
result: BootstrapResult,
|
|
271
|
+
): Promise<void> {
|
|
272
|
+
const filePath = join(opencastleDir, 'stack', 'database-config.md')
|
|
273
|
+
if (!existsSync(filePath)) return
|
|
274
|
+
|
|
275
|
+
if (!info.databases?.length) {
|
|
276
|
+
await unlink(filePath)
|
|
277
|
+
result.removed.push('stack/database-config.md')
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let content = await readFile(filePath, 'utf8')
|
|
282
|
+
const provider = info.databases[0]
|
|
283
|
+
|
|
284
|
+
const integrationMarker =
|
|
285
|
+
'<!-- Auth library path, migration directory, session pattern, role system overview. -->'
|
|
286
|
+
if (content.includes(integrationMarker)) {
|
|
287
|
+
const cfg = filterConfigFiles(info.configFiles, [
|
|
288
|
+
'supabase/config.toml',
|
|
289
|
+
'prisma/schema.prisma',
|
|
290
|
+
'drizzle.config.ts',
|
|
291
|
+
'drizzle.config.js',
|
|
292
|
+
])
|
|
293
|
+
let addition = `**Provider:** ${provider}`
|
|
294
|
+
if (cfg.length > 0) {
|
|
295
|
+
addition += `\n\n**Config files:** ${cfg.map(f => `\`${f}\``).join(', ')}`
|
|
296
|
+
}
|
|
297
|
+
addition += '\n\n<!-- TODO: verify -->'
|
|
298
|
+
content = content.replace(integrationMarker, addition + '\n\n' + integrationMarker)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (info.databases.length === 1) {
|
|
302
|
+
const newName = `${provider}-config.md`
|
|
303
|
+
const newPath = join(opencastleDir, 'stack', newName)
|
|
304
|
+
await writeFile(newPath, content, 'utf8')
|
|
305
|
+
await unlink(filePath)
|
|
306
|
+
result.renamed.push(`stack/database-config.md \u2192 stack/${newName}`)
|
|
307
|
+
} else {
|
|
308
|
+
await writeFile(filePath, content, 'utf8')
|
|
309
|
+
result.populated.push('stack/database-config.md')
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function handleCmsConfig(
|
|
314
|
+
opencastleDir: string,
|
|
315
|
+
info: RepoInfo,
|
|
316
|
+
result: BootstrapResult,
|
|
317
|
+
): Promise<void> {
|
|
318
|
+
const filePath = join(opencastleDir, 'stack', 'cms-config.md')
|
|
319
|
+
if (!existsSync(filePath)) return
|
|
320
|
+
|
|
321
|
+
if (!info.cms?.length) {
|
|
322
|
+
await unlink(filePath)
|
|
323
|
+
result.removed.push('stack/cms-config.md')
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let content = await readFile(filePath, 'utf8')
|
|
328
|
+
const provider = info.cms[0]
|
|
329
|
+
|
|
330
|
+
const configMarker = '<!-- CMS project IDs, dataset, API version, studio location, etc. -->'
|
|
331
|
+
if (content.includes(configMarker)) {
|
|
332
|
+
const cfg = filterConfigFiles(info.configFiles, [
|
|
333
|
+
'sanity.config.ts',
|
|
334
|
+
'sanity.config.js',
|
|
335
|
+
'.contentful.json',
|
|
336
|
+
'payload.config.ts',
|
|
337
|
+
])
|
|
338
|
+
let addition = `**Provider:** ${provider}`
|
|
339
|
+
if (cfg.length > 0) {
|
|
340
|
+
addition += `\n\n**Config files:** ${cfg.map(f => `\`${f}\``).join(', ')}`
|
|
341
|
+
}
|
|
342
|
+
addition += '\n\n<!-- TODO: verify -->'
|
|
343
|
+
content = content.replace(configMarker, addition + '\n\n' + configMarker)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (info.cms.length === 1) {
|
|
347
|
+
const newName = `${provider}-config.md`
|
|
348
|
+
const newPath = join(opencastleDir, 'stack', newName)
|
|
349
|
+
await writeFile(newPath, content, 'utf8')
|
|
350
|
+
await unlink(filePath)
|
|
351
|
+
result.renamed.push(`stack/cms-config.md \u2192 stack/${newName}`)
|
|
352
|
+
} else {
|
|
353
|
+
await writeFile(filePath, content, 'utf8')
|
|
354
|
+
result.populated.push('stack/cms-config.md')
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function removeNotificationsIfUnused(
|
|
359
|
+
opencastleDir: string,
|
|
360
|
+
info: RepoInfo,
|
|
361
|
+
result: BootstrapResult,
|
|
362
|
+
): Promise<void> {
|
|
363
|
+
const filePath = join(opencastleDir, 'stack', 'notifications-config.md')
|
|
364
|
+
if (!existsSync(filePath) || info.notifications?.length) return
|
|
365
|
+
await unlink(filePath)
|
|
366
|
+
result.removed.push('stack/notifications-config.md')
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function handleApiConfig(
|
|
370
|
+
opencastleDir: string,
|
|
371
|
+
info: RepoInfo,
|
|
372
|
+
result: BootstrapResult,
|
|
373
|
+
): Promise<void> {
|
|
374
|
+
const filePath = join(opencastleDir, 'stack', 'api-config.md')
|
|
375
|
+
if (!existsSync(filePath)) return
|
|
376
|
+
|
|
377
|
+
if (!info.frameworks?.length) {
|
|
378
|
+
await unlink(filePath)
|
|
379
|
+
result.removed.push('stack/api-config.md')
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let content = await readFile(filePath, 'utf8')
|
|
384
|
+
const orig = content
|
|
385
|
+
|
|
386
|
+
const initMarker =
|
|
387
|
+
'<!-- Populated by `opencastle init` based on detected API routes and Server Actions. -->'
|
|
388
|
+
if (content.includes(initMarker)) {
|
|
389
|
+
const frameworkList = info.frameworks.join(', ')
|
|
390
|
+
content = content.replace(
|
|
391
|
+
initMarker,
|
|
392
|
+
`<!-- Populated by \`opencastle init\`. Framework: ${frameworkList} -->`,
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (content !== orig) {
|
|
397
|
+
await writeFile(filePath, content, 'utf8')
|
|
398
|
+
result.populated.push('stack/api-config.md')
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function removeDataPipelineConfig(
|
|
403
|
+
opencastleDir: string,
|
|
404
|
+
result: BootstrapResult,
|
|
405
|
+
): Promise<void> {
|
|
406
|
+
const filePath = join(opencastleDir, 'stack', 'data-pipeline-config.md')
|
|
407
|
+
if (!existsSync(filePath)) return
|
|
408
|
+
await unlink(filePath)
|
|
409
|
+
result.removed.push('stack/data-pipeline-config.md')
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const TRACKER_TOOLS = new Set<string>(['linear', 'jira'])
|
|
413
|
+
|
|
414
|
+
async function handleTrackerConfig(
|
|
415
|
+
opencastleDir: string,
|
|
416
|
+
info: RepoInfo,
|
|
417
|
+
stack: StackConfig,
|
|
418
|
+
result: BootstrapResult,
|
|
419
|
+
): Promise<void> {
|
|
420
|
+
const filePath = join(opencastleDir, 'project', 'tracker-config.md')
|
|
421
|
+
if (!existsSync(filePath)) return
|
|
422
|
+
|
|
423
|
+
const tracker =
|
|
424
|
+
stack.teamTools.find(t => TRACKER_TOOLS.has(t)) ??
|
|
425
|
+
info.pm?.find(p => TRACKER_TOOLS.has(p))
|
|
426
|
+
|
|
427
|
+
if (!tracker) {
|
|
428
|
+
await unlink(filePath)
|
|
429
|
+
result.removed.push('project/tracker-config.md')
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
let content = await readFile(filePath, 'utf8')
|
|
434
|
+
const displayName = tracker.charAt(0).toUpperCase() + tracker.slice(1)
|
|
435
|
+
|
|
436
|
+
content = content.replace('# Task Tracker Configuration', `# ${displayName} Configuration`)
|
|
437
|
+
|
|
438
|
+
const renameComment =
|
|
439
|
+
'<!-- Populated by `opencastle init`.\n Rename this file to match your tracker: linear-config.md, jira-config.md, etc. -->'
|
|
440
|
+
content = content.replace(renameComment + '\n', '')
|
|
441
|
+
|
|
442
|
+
const newName = `${tracker}-config.md`
|
|
443
|
+
const newPath = join(opencastleDir, 'project', newName)
|
|
444
|
+
await writeFile(newPath, content, 'utf8')
|
|
445
|
+
await unlink(filePath)
|
|
446
|
+
result.renamed.push(`project/tracker-config.md \u2192 project/${newName}`)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ── Main export ────────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
export async function bootstrapCustomizations(
|
|
452
|
+
projectRoot: string,
|
|
453
|
+
repoInfo: RepoInfo,
|
|
454
|
+
stack: StackConfig,
|
|
455
|
+
): Promise<BootstrapResult> {
|
|
456
|
+
const opencastleDir = join(projectRoot, '.opencastle')
|
|
457
|
+
const result: BootstrapResult = { populated: [], removed: [], renamed: [] }
|
|
458
|
+
|
|
459
|
+
const pkg = (await tryReadJson<PackageJson>(join(projectRoot, 'package.json'))) ?? {}
|
|
460
|
+
|
|
461
|
+
await populateProjectInstructions(opencastleDir, projectRoot, repoInfo, pkg, result)
|
|
462
|
+
await populateTestingConfig(opencastleDir, repoInfo, result)
|
|
463
|
+
await populateDeploymentConfig(opencastleDir, repoInfo, result)
|
|
464
|
+
await handleDatabaseConfig(opencastleDir, repoInfo, result)
|
|
465
|
+
await handleCmsConfig(opencastleDir, repoInfo, result)
|
|
466
|
+
await removeNotificationsIfUnused(opencastleDir, repoInfo, result)
|
|
467
|
+
await handleApiConfig(opencastleDir, repoInfo, result)
|
|
468
|
+
await removeDataPipelineConfig(opencastleDir, result)
|
|
469
|
+
await handleTrackerConfig(opencastleDir, repoInfo, stack, result)
|
|
470
|
+
|
|
471
|
+
return result
|
|
472
|
+
}
|
package/src/cli/detect.ts
CHANGED
|
@@ -1,7 +1,28 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { readFile, readdir, access } from 'node:fs/promises';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
|
-
import type { RepoInfo, StackConfig } from './types.js';
|
|
4
|
+
import type { IdeChoice, RepoInfo, StackConfig } from './types.js';
|
|
5
|
+
|
|
6
|
+
// ── IDE detection ───────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Detect which IDE the CLI is running from, based on environment variables.
|
|
10
|
+
* Returns the IdeChoice value or undefined if unknown.
|
|
11
|
+
*/
|
|
12
|
+
export function detectCurrentIde(): IdeChoice | undefined {
|
|
13
|
+
const env = process.env;
|
|
14
|
+
|
|
15
|
+
// Cursor sets its own TERM_PROGRAM or CURSOR-specific env vars
|
|
16
|
+
if (env.CURSOR_TRACE_DIR || env.CURSOR_CHANNEL) return 'cursor';
|
|
17
|
+
|
|
18
|
+
// VS Code sets TERM_PROGRAM=vscode in its integrated terminal
|
|
19
|
+
if (env.TERM_PROGRAM === 'vscode') return 'vscode';
|
|
20
|
+
|
|
21
|
+
// Claude Code — check for CLAUDE_* env vars set by the CLI
|
|
22
|
+
if (env.CLAUDE_CODE === '1' || env.CLAUDE_PROJECT_ROOT) return 'claude-code';
|
|
23
|
+
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
5
26
|
|
|
6
27
|
// ── Detection rules ───────────────────────────────────────────
|
|
7
28
|
|
package/src/cli/init.ts
CHANGED
|
@@ -7,15 +7,11 @@ import { removeDirIfExists, copyDir, getOrchestratorRoot } from './copy.js'
|
|
|
7
7
|
import { updateGitignore } from './gitignore.js'
|
|
8
8
|
import { getRequiredMcpEnvVars, getCustomizationsTransform } from './stack-config.js'
|
|
9
9
|
import { TECH_PLUGINS, TEAM_PLUGINS } from '../orchestrator/plugins/index.js'
|
|
10
|
-
import { detectRepoInfo, mergeStackIntoRepoInfo, formatRepoInfo, buildDetectedToolsSet } from './detect.js'
|
|
10
|
+
import { detectRepoInfo, mergeStackIntoRepoInfo, formatRepoInfo, buildDetectedToolsSet, detectCurrentIde } from './detect.js'
|
|
11
11
|
import { IDE_ADAPTERS } from './adapters/index.js'
|
|
12
12
|
import { IDE_LABELS } from './types.js'
|
|
13
13
|
import type { CliContext, IdeChoice, TechTool, TeamTool, StackConfig } from './types.js'
|
|
14
|
-
|
|
15
|
-
/** OSC 8 terminal hyperlink — clickable in VS Code integrated terminal and modern terminals */
|
|
16
|
-
function termLink(text: string, url: string): string {
|
|
17
|
-
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`
|
|
18
|
-
}
|
|
14
|
+
import { bootstrapCustomizations } from './bootstrap.js'
|
|
19
15
|
|
|
20
16
|
export default async function init({ pkgRoot, args }: CliContext): Promise<void> {
|
|
21
17
|
const projectRoot = process.cwd()
|
|
@@ -57,26 +53,31 @@ export default async function init({ pkgRoot, args }: CliContext): Promise<void>
|
|
|
57
53
|
|
|
58
54
|
// ── IDE (single select) ────────────────────────────────────────
|
|
59
55
|
console.log(` ${c.bold('── IDEs ──────────────────────────────────────')}`)
|
|
56
|
+
const detectedIde = detectCurrentIde()
|
|
60
57
|
const selectedIde = await select('Which IDE do you use?', [
|
|
61
58
|
{
|
|
62
59
|
label: 'VS Code',
|
|
63
60
|
hint: 'GitHub Copilot agents, instructions, skills',
|
|
64
61
|
value: 'vscode',
|
|
62
|
+
...(detectedIde === 'vscode' && { selected: true }),
|
|
65
63
|
},
|
|
66
64
|
{
|
|
67
65
|
label: 'Cursor',
|
|
68
66
|
hint: '.cursorrules & .cursor/rules/*.mdc',
|
|
69
67
|
value: 'cursor',
|
|
68
|
+
...(detectedIde === 'cursor' && { selected: true }),
|
|
70
69
|
},
|
|
71
70
|
{
|
|
72
71
|
label: 'Claude Code',
|
|
73
72
|
hint: 'CLAUDE.md & .claude/ commands, skills',
|
|
74
73
|
value: 'claude-code',
|
|
74
|
+
...(detectedIde === 'claude-code' && { selected: true }),
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
77
|
label: 'OpenCode',
|
|
78
78
|
hint: 'AGENTS.md & opencode.json',
|
|
79
79
|
value: 'opencode',
|
|
80
|
+
...(detectedIde === 'opencode' && { selected: true }),
|
|
80
81
|
},
|
|
81
82
|
])
|
|
82
83
|
const ides = [selectedIde]
|
|
@@ -227,6 +228,22 @@ export default async function init({ pkgRoot, args }: CliContext): Promise<void>
|
|
|
227
228
|
totalSkipped += sub.skipped.length
|
|
228
229
|
}
|
|
229
230
|
|
|
231
|
+
// ── Project scan ────────────────────────────────────────────────
|
|
232
|
+
console.log(`\n ${c.dim('Configuring project...')}`)
|
|
233
|
+
const bootstrapResult = await bootstrapCustomizations(projectRoot, combinedRepoInfo, stack)
|
|
234
|
+
|
|
235
|
+
if (bootstrapResult.populated.length > 0) {
|
|
236
|
+
console.log(` ${c.green('✓')} Populated ${c.bold(String(bootstrapResult.populated.length))} config files`)
|
|
237
|
+
}
|
|
238
|
+
if (bootstrapResult.renamed.length > 0) {
|
|
239
|
+
for (const r of bootstrapResult.renamed) {
|
|
240
|
+
console.log(` ${c.dim('→')} Renamed ${r}`)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (bootstrapResult.removed.length > 0) {
|
|
244
|
+
console.log(` ${c.dim('→')} Removed ${bootstrapResult.removed.length} unused template(s)`)
|
|
245
|
+
}
|
|
246
|
+
|
|
230
247
|
// ── Write manifest ──────────────────────────────────────────────
|
|
231
248
|
const manifest = createManifest(pkg.version, ides[0], ides)
|
|
232
249
|
manifest.managedPaths = allManagedPaths
|
|
@@ -317,13 +334,6 @@ export default async function init({ pkgRoot, args }: CliContext): Promise<void>
|
|
|
317
334
|
)
|
|
318
335
|
}
|
|
319
336
|
step++
|
|
320
|
-
const bootstrapLabel = ides.includes('vscode')
|
|
321
|
-
? termLink(c.cyan('"Bootstrap Customizations"'), 'vscode://GitHub.Copilot-Chat/chat?mode=Team%20Lead%20(OpenCastle)&prompt=%2Fbootstrap-customizations')
|
|
322
|
-
: c.cyan('"Bootstrap Customizations"')
|
|
323
|
-
console.log(
|
|
324
|
-
` ${step}. Run the ${bootstrapLabel} prompt to configure for your project`
|
|
325
|
-
)
|
|
326
|
-
step++
|
|
327
337
|
console.log(` ${step}. Commit the .opencastle/ folder to your repository`)
|
|
328
338
|
console.log()
|
|
329
339
|
|
package/src/cli/prompt.ts
CHANGED
|
@@ -188,7 +188,7 @@ function selectInteractive(
|
|
|
188
188
|
options: SelectOption[]
|
|
189
189
|
): Promise<string> {
|
|
190
190
|
return new Promise<string>((resolve) => {
|
|
191
|
-
let cursor = 0;
|
|
191
|
+
let cursor = Math.max(0, options.findIndex((o) => o.selected));
|
|
192
192
|
const maxVisible = Math.max(3, Math.min(options.length, (process.stdout.rows || 24) - 4));
|
|
193
193
|
let lastRenderedLines = 0;
|
|
194
194
|
|
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
{
|
|
2
|
-
"hash": "
|
|
2
|
+
"hash": "939c6a62",
|
|
3
3
|
"configHash": "30f8ea04",
|
|
4
|
-
"lockfileHash": "
|
|
5
|
-
"browserHash": "
|
|
4
|
+
"lockfileHash": "2a42269f",
|
|
5
|
+
"browserHash": "10c140c1",
|
|
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": "9fc34a31",
|
|
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": "e0152e5e",
|
|
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": "c3b40a68",
|
|
23
23
|
"needsInterop": true
|
|
24
24
|
}
|
|
25
25
|
},
|
|
@@ -152,7 +152,7 @@ The convoy engine is the **mandatory** execution mechanism for all project-relat
|
|
|
152
152
|
| Work type | Approach |
|
|
153
153
|
|-----------|----------|
|
|
154
154
|
| Features, bug fixes, refactors (any subtask count) | **Convoy execution** — always generate a `.convoy.yml` spec, even for 1-task fixes |
|
|
155
|
-
| Utility prompts (`
|
|
155
|
+
| Utility prompts (`create-skill`, `generate-convoy`, `brainstorm`, `quick-refinement`) | **Direct** — these are meta/tooling operations, not project code changes |
|
|
156
156
|
|
|
157
157
|
### How to generate a convoy spec
|
|
158
158
|
|
|
@@ -247,8 +247,10 @@ For each task:
|
|
|
247
247
|
- High-stakes: panel review (load panel-majority-vote skill)
|
|
248
248
|
- Discovered issues tracked (not silently ignored)
|
|
249
249
|
- Lessons captured (if agent retried anything)
|
|
250
|
+
- Agent expertise updated (AGENT-EXPERTISE.md)
|
|
251
|
+
- Knowledge graph appended (KNOWLEDGE-GRAPH.md)
|
|
250
252
|
6. PASS → log review (⛔ hard gate — do NOT proceed until logged), move issue → Done
|
|
251
|
-
FAIL → re-delegate with failure details (max 3 attempts)
|
|
253
|
+
FAIL → re-delegate with failure details (max 3 attempts → log DLQ in AGENT-FAILURES.md)
|
|
252
254
|
```
|
|
253
255
|
|
|
254
256
|
Fast review auto-PASS: research-only tasks, docs-only, or ≤10 lines across ≤2 files with all deterministic gates passing.
|
|
@@ -32,7 +32,7 @@ Skills reference these files with relative links like `../../.opencastle/stack/a
|
|
|
32
32
|
|
|
33
33
|
| File | Purpose |
|
|
34
34
|
|------|---------|
|
|
35
|
-
| _(created by `
|
|
35
|
+
| _(created by `opencastle init` based on detected technologies)_ | |
|
|
36
36
|
|
|
37
37
|
### `project/` — Project management config
|
|
38
38
|
|
|
@@ -41,7 +41,7 @@ Skills reference these files with relative links like `../../.opencastle/stack/a
|
|
|
41
41
|
| `docs-structure.md` | Project documentation directory tree and practices |
|
|
42
42
|
| `roadmap.md` | Project roadmap with planned features and their status |
|
|
43
43
|
| `decisions.md` | Architecture Decision Records (ADRs) for the project |
|
|
44
|
-
| _(task tracker config created by `
|
|
44
|
+
| _(task tracker config created by `opencastle init`)_ | |
|
|
45
45
|
|
|
46
46
|
### `logs/` — Append-only NDJSON session logs
|
|
47
47
|
|
|
@@ -58,4 +58,4 @@ Update these files when the project changes — new tables, new API routes, new
|
|
|
58
58
|
|
|
59
59
|
## Bootstrap
|
|
60
60
|
|
|
61
|
-
Run
|
|
61
|
+
Run `npx opencastle init` to auto-discover the project's structure and populate these files. It scans for frameworks, databases, CMS, deployment config, and task tracking, then generates the appropriate `stack/` and `project/` files.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
Project-specific agent-to-model assignments and scope examples referenced by the `team-lead-reference` skill.
|
|
5
5
|
|
|
6
|
-
<!-- Populated by
|
|
6
|
+
<!-- Populated by `opencastle init` based on project structure. -->
|
|
7
7
|
|
|
8
8
|
## Specialist Agent Registry
|
|
9
9
|
|