orchestr8 2.8.0 → 3.1.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/.blueprint/agents/AGENT_BA_CASS.md +22 -34
- package/.blueprint/agents/AGENT_DEVELOPER_CODEY.md +25 -28
- package/.blueprint/agents/AGENT_SPECIFICATION_ALEX.md +10 -0
- package/.blueprint/agents/AGENT_TESTER_NIGEL.md +9 -3
- package/.blueprint/agents/WHAT_WE_STAND_FOR.md +64 -0
- package/.blueprint/features/feature_interactive-alex/FEATURE_SPEC.md +263 -0
- package/.blueprint/features/feature_interactive-alex/IMPLEMENTATION_PLAN.md +69 -0
- package/.blueprint/features/feature_interactive-alex/handoff-alex.md +19 -0
- package/.blueprint/features/feature_interactive-alex/handoff-cass.md +21 -0
- package/.blueprint/features/feature_interactive-alex/handoff-nigel.md +19 -0
- package/.blueprint/features/feature_interactive-alex/story-flag-routing.md +54 -0
- package/.blueprint/features/feature_interactive-alex/story-iterative-drafting.md +65 -0
- package/.blueprint/features/feature_interactive-alex/story-pipeline-integration.md +66 -0
- package/.blueprint/features/feature_interactive-alex/story-session-lifecycle.md +75 -0
- package/.blueprint/features/feature_interactive-alex/story-system-spec-creation.md +57 -0
- package/.blueprint/prompts/codey-implement-runtime.md +1 -1
- package/.blueprint/prompts/nigel-runtime.md +1 -1
- package/.blueprint/ways_of_working/DEVELOPMENT_RITUAL.md +4 -4
- package/README.md +31 -0
- package/SKILL.md +35 -1
- package/bin/cli.js +28 -0
- package/package.json +2 -2
- package/src/index.js +61 -1
- package/src/init.js +21 -3
- package/src/interactive.js +338 -0
- package/src/stack.js +320 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const SESSION_STATES = {
|
|
5
|
+
IDLE: 'idle',
|
|
6
|
+
GATHERING: 'gathering',
|
|
7
|
+
QUESTIONING: 'questioning',
|
|
8
|
+
DRAFTING: 'drafting',
|
|
9
|
+
FINALIZING: 'finalizing'
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const SECTION_ORDER = ['intent', 'scope', 'actors', 'behaviour', 'dependencies'];
|
|
13
|
+
|
|
14
|
+
const MIN_REQUIRED_SECTIONS = ['intent', 'scope', 'actors'];
|
|
15
|
+
|
|
16
|
+
const SYSTEM_SPEC_QUESTIONS = ['purpose', 'actors', 'boundaries', 'rules'];
|
|
17
|
+
|
|
18
|
+
function parseFlags(args) {
|
|
19
|
+
return {
|
|
20
|
+
interactive: args.includes('--interactive'),
|
|
21
|
+
pauseAfter: args.find(a => a.startsWith('--pause-after='))?.split('=')[1] || null
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function shouldEnterInteractiveMode(flags, hasSystemSpec, hasFeatureSpec) {
|
|
26
|
+
if (flags.interactive) return { interactive: true, target: 'feature' };
|
|
27
|
+
if (!hasSystemSpec) return { interactive: true, target: 'system' };
|
|
28
|
+
if (!hasFeatureSpec) return { interactive: true, target: 'feature' };
|
|
29
|
+
return { interactive: false, target: null };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createSession(target) {
|
|
33
|
+
const sections = target === 'system'
|
|
34
|
+
? { purpose: null, actors: null, boundaries: null, rules: null }
|
|
35
|
+
: { intent: null, scope: null, actors: null, behaviour: null, dependencies: null };
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
target,
|
|
39
|
+
state: SESSION_STATES.IDLE,
|
|
40
|
+
sections,
|
|
41
|
+
current: target === 'system' ? 'purpose' : 'intent',
|
|
42
|
+
revisionCount: 0,
|
|
43
|
+
questionCount: 0,
|
|
44
|
+
startedAt: new Date().toISOString(),
|
|
45
|
+
aborted: false,
|
|
46
|
+
specWritten: false,
|
|
47
|
+
context: {},
|
|
48
|
+
feedback: []
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getSessionProgress(session) {
|
|
53
|
+
const sectionList = Object.keys(session.sections);
|
|
54
|
+
const complete = Object.values(session.sections).filter(
|
|
55
|
+
s => s === 'complete' || s === 'TBD'
|
|
56
|
+
).length;
|
|
57
|
+
const remaining = sectionList.length - complete;
|
|
58
|
+
return { complete, remaining, total: sectionList.length };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function handleCommand(session, command) {
|
|
62
|
+
const cmd = command.trim().toLowerCase();
|
|
63
|
+
const parts = command.trim().split(/\s+/);
|
|
64
|
+
const baseCmd = parts[0].toLowerCase();
|
|
65
|
+
|
|
66
|
+
if (baseCmd === '/approve' || baseCmd === 'yes') {
|
|
67
|
+
session.sections[session.current] = 'complete';
|
|
68
|
+
const nextSection = getNextSection(session);
|
|
69
|
+
if (nextSection) {
|
|
70
|
+
session.current = nextSection;
|
|
71
|
+
return { action: 'next', section: nextSection };
|
|
72
|
+
}
|
|
73
|
+
return { action: 'finalize' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (baseCmd === '/change') {
|
|
77
|
+
const feedback = parts.slice(1).join(' ');
|
|
78
|
+
session.revisionCount++;
|
|
79
|
+
session.feedback.push({ section: session.current, feedback });
|
|
80
|
+
return { action: 'revise', feedback };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (baseCmd === '/skip') {
|
|
84
|
+
session.sections[session.current] = 'TBD';
|
|
85
|
+
const nextSection = getNextSection(session);
|
|
86
|
+
if (nextSection) {
|
|
87
|
+
session.current = nextSection;
|
|
88
|
+
return { action: 'next', section: nextSection };
|
|
89
|
+
}
|
|
90
|
+
return { action: 'finalize' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (baseCmd === '/restart') {
|
|
94
|
+
session.sections[session.current] = null;
|
|
95
|
+
return { action: 'restart', section: session.current };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (baseCmd === '/abort') {
|
|
99
|
+
session.aborted = true;
|
|
100
|
+
return { action: 'abort' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (baseCmd === '/done') {
|
|
104
|
+
if (canFinalize(session)) {
|
|
105
|
+
return { action: 'finalize' };
|
|
106
|
+
}
|
|
107
|
+
return { action: 'incomplete', missing: getMissingSections(session) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { action: 'unknown', command: baseCmd };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getNextSection(session) {
|
|
114
|
+
const order = session.target === 'system'
|
|
115
|
+
? SYSTEM_SPEC_QUESTIONS
|
|
116
|
+
: SECTION_ORDER;
|
|
117
|
+
|
|
118
|
+
for (const section of order) {
|
|
119
|
+
const status = session.sections[section];
|
|
120
|
+
if (status !== 'complete' && status !== 'TBD') {
|
|
121
|
+
return section;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function markSectionComplete(session, section) {
|
|
128
|
+
if (section in session.sections) {
|
|
129
|
+
session.sections[section] = 'complete';
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function markSectionTBD(session, section) {
|
|
136
|
+
if (section in session.sections) {
|
|
137
|
+
session.sections[section] = 'TBD';
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function gatherContext(basePath = '.') {
|
|
144
|
+
const context = {
|
|
145
|
+
systemSpec: null,
|
|
146
|
+
businessContext: [],
|
|
147
|
+
templates: []
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const systemSpecPath = path.join(basePath, '.blueprint/system_specification/SYSTEM_SPEC.md');
|
|
151
|
+
if (fs.existsSync(systemSpecPath)) {
|
|
152
|
+
context.systemSpec = fs.readFileSync(systemSpecPath, 'utf8');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const businessContextDir = path.join(basePath, '.business_context');
|
|
156
|
+
if (fs.existsSync(businessContextDir)) {
|
|
157
|
+
const files = fs.readdirSync(businessContextDir);
|
|
158
|
+
for (const file of files) {
|
|
159
|
+
if (file.endsWith('.md')) {
|
|
160
|
+
const content = fs.readFileSync(path.join(businessContextDir, file), 'utf8');
|
|
161
|
+
context.businessContext.push({ file, content });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const templatesDir = path.join(basePath, '.blueprint/templates');
|
|
167
|
+
if (fs.existsSync(templatesDir)) {
|
|
168
|
+
const files = fs.readdirSync(templatesDir);
|
|
169
|
+
for (const file of files) {
|
|
170
|
+
if (file.endsWith('.md')) {
|
|
171
|
+
const content = fs.readFileSync(path.join(templatesDir, file), 'utf8');
|
|
172
|
+
context.templates.push({ file, content });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return context;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function identifyGaps(session, userDescription) {
|
|
181
|
+
const gaps = [];
|
|
182
|
+
const sectionKeys = session.target === 'system'
|
|
183
|
+
? SYSTEM_SPEC_QUESTIONS
|
|
184
|
+
: SECTION_ORDER;
|
|
185
|
+
|
|
186
|
+
for (const section of sectionKeys) {
|
|
187
|
+
const hasKey = `has${section.charAt(0).toUpperCase() + section.slice(1)}`;
|
|
188
|
+
if (!userDescription[hasKey]) {
|
|
189
|
+
gaps.push(section);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return gaps.slice(0, 4);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function generateQuestions(gaps) {
|
|
197
|
+
const questionTemplates = {
|
|
198
|
+
intent: 'What is the primary goal or purpose of this feature?',
|
|
199
|
+
scope: 'What boundaries define what is in scope vs out of scope?',
|
|
200
|
+
actors: 'Who are the users or systems that will interact with this?',
|
|
201
|
+
behaviour: 'What are the key behaviours or flows to support?',
|
|
202
|
+
dependencies: 'What does this depend on or what depends on it?',
|
|
203
|
+
purpose: 'What is the overall purpose of this system?',
|
|
204
|
+
boundaries: 'What are the system boundaries and integration points?',
|
|
205
|
+
rules: 'What business rules or constraints apply?'
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
return gaps.slice(0, 4).map(gap => ({
|
|
209
|
+
section: gap,
|
|
210
|
+
question: questionTemplates[gap] || `What information is needed for ${gap}?`
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function getMissingSections(session) {
|
|
215
|
+
const required = session.target === 'system'
|
|
216
|
+
? ['purpose', 'actors', 'boundaries']
|
|
217
|
+
: MIN_REQUIRED_SECTIONS;
|
|
218
|
+
|
|
219
|
+
return required.filter(section => {
|
|
220
|
+
const status = session.sections[section];
|
|
221
|
+
return status !== 'complete' && status !== 'TBD';
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function canFinalize(session) {
|
|
226
|
+
const required = session.target === 'system'
|
|
227
|
+
? ['purpose', 'actors', 'boundaries']
|
|
228
|
+
: MIN_REQUIRED_SECTIONS;
|
|
229
|
+
|
|
230
|
+
return required.every(section => {
|
|
231
|
+
const status = session.sections[section];
|
|
232
|
+
return status === 'complete' || status === 'TBD';
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function generateSpec(session) {
|
|
237
|
+
const sections = [];
|
|
238
|
+
const sectionOrder = session.target === 'system'
|
|
239
|
+
? SYSTEM_SPEC_QUESTIONS
|
|
240
|
+
: SECTION_ORDER;
|
|
241
|
+
|
|
242
|
+
const title = session.target === 'system' ? 'System Spec' : 'Feature Spec';
|
|
243
|
+
sections.push(`# ${title}`);
|
|
244
|
+
|
|
245
|
+
for (const sectionName of sectionOrder) {
|
|
246
|
+
const status = session.sections[sectionName];
|
|
247
|
+
const heading = sectionName.charAt(0).toUpperCase() + sectionName.slice(1);
|
|
248
|
+
sections.push(`## ${heading}`);
|
|
249
|
+
|
|
250
|
+
if (status === 'TBD') {
|
|
251
|
+
sections.push('TBD');
|
|
252
|
+
} else if (status === 'complete') {
|
|
253
|
+
sections.push(`[Content for ${sectionName}]`);
|
|
254
|
+
} else {
|
|
255
|
+
sections.push('TBD');
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
sections.push('');
|
|
260
|
+
sections.push('_Created via interactive session_');
|
|
261
|
+
|
|
262
|
+
return sections.join('\n');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function writeSpec(session, outputPath) {
|
|
266
|
+
const content = generateSpec(session);
|
|
267
|
+
const dir = path.dirname(outputPath);
|
|
268
|
+
|
|
269
|
+
if (!fs.existsSync(dir)) {
|
|
270
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
fs.writeFileSync(outputPath, content);
|
|
274
|
+
session.specWritten = true;
|
|
275
|
+
return outputPath;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function generateHandoff(session, slug) {
|
|
279
|
+
const progress = getSessionProgress(session);
|
|
280
|
+
const endedAt = new Date().toISOString();
|
|
281
|
+
const startedAt = new Date(session.startedAt);
|
|
282
|
+
const durationMs = new Date(endedAt) - startedAt;
|
|
283
|
+
|
|
284
|
+
const lines = [
|
|
285
|
+
'## Handoff Summary',
|
|
286
|
+
'**For:** Cass',
|
|
287
|
+
`**Feature:** ${slug}`,
|
|
288
|
+
'',
|
|
289
|
+
'### Key Decisions',
|
|
290
|
+
`- Interactive mode used for ${session.target} spec creation`,
|
|
291
|
+
`- ${progress.complete}/${progress.total} sections completed`,
|
|
292
|
+
session.revisionCount > 0 ? `- ${session.revisionCount} revision(s) made` : null,
|
|
293
|
+
'',
|
|
294
|
+
'### Files Created',
|
|
295
|
+
session.target === 'system'
|
|
296
|
+
? '- .blueprint/system_specification/SYSTEM_SPEC.md'
|
|
297
|
+
: `- .blueprint/features/feature_${slug}/FEATURE_SPEC.md`,
|
|
298
|
+
'',
|
|
299
|
+
'### Open Questions',
|
|
300
|
+
progress.remaining > 0 ? `- ${progress.remaining} section(s) marked TBD` : '- None',
|
|
301
|
+
'',
|
|
302
|
+
'### Critical Context',
|
|
303
|
+
`Session duration: ${Math.round(durationMs / 1000)}s, Questions asked: ${session.questionCount}, Revisions: ${session.revisionCount}`
|
|
304
|
+
].filter(line => line !== null);
|
|
305
|
+
|
|
306
|
+
return lines.join('\n');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function getOutputPath(session, slug, basePath = '.') {
|
|
310
|
+
if (session.target === 'system') {
|
|
311
|
+
return path.join(basePath, '.blueprint/system_specification/SYSTEM_SPEC.md');
|
|
312
|
+
}
|
|
313
|
+
return path.join(basePath, `.blueprint/features/feature_${slug}/FEATURE_SPEC.md`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
module.exports = {
|
|
317
|
+
SESSION_STATES,
|
|
318
|
+
SECTION_ORDER,
|
|
319
|
+
MIN_REQUIRED_SECTIONS,
|
|
320
|
+
SYSTEM_SPEC_QUESTIONS,
|
|
321
|
+
parseFlags,
|
|
322
|
+
shouldEnterInteractiveMode,
|
|
323
|
+
createSession,
|
|
324
|
+
getSessionProgress,
|
|
325
|
+
handleCommand,
|
|
326
|
+
getNextSection,
|
|
327
|
+
markSectionComplete,
|
|
328
|
+
markSectionTBD,
|
|
329
|
+
gatherContext,
|
|
330
|
+
identifyGaps,
|
|
331
|
+
generateQuestions,
|
|
332
|
+
getMissingSections,
|
|
333
|
+
canFinalize,
|
|
334
|
+
generateSpec,
|
|
335
|
+
writeSpec,
|
|
336
|
+
generateHandoff,
|
|
337
|
+
getOutputPath
|
|
338
|
+
};
|
package/src/stack.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const CONFIG_FILE = '.claude/stack-config.json';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns the default (empty) stack configuration.
|
|
8
|
+
*/
|
|
9
|
+
function getDefaultStackConfig() {
|
|
10
|
+
return {
|
|
11
|
+
language: '',
|
|
12
|
+
runtime: '',
|
|
13
|
+
packageManager: '',
|
|
14
|
+
frameworks: [],
|
|
15
|
+
testRunner: '',
|
|
16
|
+
testCommand: '',
|
|
17
|
+
linter: '',
|
|
18
|
+
tools: []
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Ensures the .claude directory exists.
|
|
24
|
+
*/
|
|
25
|
+
function ensureConfigDir() {
|
|
26
|
+
const dir = path.dirname(CONFIG_FILE);
|
|
27
|
+
if (!fs.existsSync(dir)) {
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Reads the stack config from file.
|
|
34
|
+
* Returns defaults if file is missing or corrupted.
|
|
35
|
+
*/
|
|
36
|
+
function readStackConfig() {
|
|
37
|
+
ensureConfigDir();
|
|
38
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
39
|
+
return getDefaultStackConfig();
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const content = fs.readFileSync(CONFIG_FILE, 'utf8');
|
|
43
|
+
return JSON.parse(content);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return getDefaultStackConfig();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Writes the stack config to file.
|
|
51
|
+
*/
|
|
52
|
+
function writeStackConfig(config) {
|
|
53
|
+
ensureConfigDir();
|
|
54
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resets stack config to defaults by writing empty config to file.
|
|
59
|
+
*/
|
|
60
|
+
function resetStackConfig() {
|
|
61
|
+
writeStackConfig(getDefaultStackConfig());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const VALID_KEYS = [
|
|
65
|
+
'language', 'runtime', 'packageManager',
|
|
66
|
+
'frameworks', 'testRunner', 'testCommand',
|
|
67
|
+
'linter', 'tools'
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const ARRAY_KEYS = ['frameworks', 'tools'];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Sets a config value by key.
|
|
74
|
+
* Array keys (frameworks, tools) accept JSON array strings.
|
|
75
|
+
* @param {string} key - Config key
|
|
76
|
+
* @param {string} value - New value (string or JSON array string)
|
|
77
|
+
*/
|
|
78
|
+
function setStackConfigValue(key, value) {
|
|
79
|
+
if (!VALID_KEYS.includes(key)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Unknown config key: ${key}. Valid keys: ${VALID_KEYS.join(', ')}`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const config = readStackConfig();
|
|
86
|
+
|
|
87
|
+
if (ARRAY_KEYS.includes(key)) {
|
|
88
|
+
// Try parsing as JSON array
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(value);
|
|
91
|
+
if (!Array.isArray(parsed)) {
|
|
92
|
+
throw new Error(`${key} must be a JSON array, e.g. '["express","react"]'`);
|
|
93
|
+
}
|
|
94
|
+
config[key] = parsed;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (err.message.includes('must be a JSON array')) throw err;
|
|
97
|
+
throw new Error(`${key} must be a valid JSON array, e.g. '["express","react"]'`);
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
config[key] = value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
writeStackConfig(config);
|
|
104
|
+
const display = Array.isArray(config[key]) ? JSON.stringify(config[key]) : config[key];
|
|
105
|
+
console.log(`Set ${key} = ${display}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Auto-detect tech stack from project files.
|
|
110
|
+
* Scans for manifest files and infers configuration.
|
|
111
|
+
* @param {string} projectDir - Directory to scan (defaults to cwd)
|
|
112
|
+
* @returns {object} Detected stack config
|
|
113
|
+
*/
|
|
114
|
+
function detectStackConfig(projectDir) {
|
|
115
|
+
const dir = projectDir || process.cwd();
|
|
116
|
+
const config = getDefaultStackConfig();
|
|
117
|
+
|
|
118
|
+
const exists = (file) => fs.existsSync(path.join(dir, file));
|
|
119
|
+
const readJSON = (file) => {
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const readText = (file) => {
|
|
127
|
+
try {
|
|
128
|
+
return fs.readFileSync(path.join(dir, file), 'utf8');
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Node.js / JavaScript detection
|
|
135
|
+
if (exists('package.json')) {
|
|
136
|
+
config.language = 'JavaScript';
|
|
137
|
+
config.runtime = 'Node.js';
|
|
138
|
+
|
|
139
|
+
const pkg = readJSON('package.json');
|
|
140
|
+
if (pkg) {
|
|
141
|
+
// Runtime version from engines
|
|
142
|
+
if (pkg.engines && pkg.engines.node) {
|
|
143
|
+
config.runtime = `Node.js ${pkg.engines.node}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const deps = pkg.dependencies || {};
|
|
147
|
+
const devDeps = pkg.devDependencies || {};
|
|
148
|
+
const allDeps = { ...deps, ...devDeps };
|
|
149
|
+
|
|
150
|
+
// Frameworks
|
|
151
|
+
const frameworkNames = [
|
|
152
|
+
'express', 'fastify', 'koa', 'react', 'next',
|
|
153
|
+
'vue', 'angular', 'hapi', 'nest', '@hapi/hapi', '@nestjs/core'
|
|
154
|
+
];
|
|
155
|
+
const detected = [];
|
|
156
|
+
for (const fw of frameworkNames) {
|
|
157
|
+
if (fw in deps || fw in devDeps) {
|
|
158
|
+
// Normalize package names to friendly names
|
|
159
|
+
if (fw === '@hapi/hapi') detected.push('hapi');
|
|
160
|
+
else if (fw === '@nestjs/core') detected.push('nest');
|
|
161
|
+
else detected.push(fw);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (detected.length > 0) config.frameworks = detected;
|
|
165
|
+
|
|
166
|
+
// Test runner
|
|
167
|
+
const testRunners = ['jest', 'mocha', 'vitest', 'ava'];
|
|
168
|
+
for (const tr of testRunners) {
|
|
169
|
+
if (tr in devDeps || tr in deps) {
|
|
170
|
+
config.testRunner = tr;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Test command from scripts
|
|
176
|
+
if (pkg.scripts && pkg.scripts.test) {
|
|
177
|
+
config.testCommand = pkg.scripts.test;
|
|
178
|
+
// Also try to detect test runner from test script if not found in deps
|
|
179
|
+
if (!config.testRunner) {
|
|
180
|
+
for (const tr of testRunners) {
|
|
181
|
+
if (pkg.scripts.test.includes(tr)) {
|
|
182
|
+
config.testRunner = tr;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (!config.testCommand) {
|
|
189
|
+
config.testCommand = 'npm test';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Linter
|
|
193
|
+
const linters = ['eslint', 'biome', 'oxlint', '@biomejs/biome'];
|
|
194
|
+
for (const l of linters) {
|
|
195
|
+
if (l in devDeps || l in deps) {
|
|
196
|
+
if (l === '@biomejs/biome') config.linter = 'biome';
|
|
197
|
+
else config.linter = l;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Tools
|
|
203
|
+
const toolNames = ['nodemon', 'supertest', 'prettier', 'typescript'];
|
|
204
|
+
const detectedTools = [];
|
|
205
|
+
for (const t of toolNames) {
|
|
206
|
+
if (t in devDeps || t in deps) {
|
|
207
|
+
detectedTools.push(t);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (detectedTools.length > 0) config.tools = detectedTools;
|
|
211
|
+
|
|
212
|
+
// Package manager from lockfiles
|
|
213
|
+
if (exists('yarn.lock')) {
|
|
214
|
+
config.packageManager = 'yarn';
|
|
215
|
+
} else if (exists('pnpm-lock.yaml')) {
|
|
216
|
+
config.packageManager = 'pnpm';
|
|
217
|
+
} else {
|
|
218
|
+
config.packageManager = 'npm';
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// TypeScript overrides JavaScript
|
|
224
|
+
if (exists('tsconfig.json')) {
|
|
225
|
+
config.language = 'TypeScript';
|
|
226
|
+
if (!config.runtime) config.runtime = 'Node.js';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Python detection
|
|
230
|
+
if (exists('pyproject.toml') || exists('requirements.txt')) {
|
|
231
|
+
config.language = 'Python';
|
|
232
|
+
config.runtime = 'Python 3.x';
|
|
233
|
+
|
|
234
|
+
const pyproject = readText('pyproject.toml');
|
|
235
|
+
if (pyproject) {
|
|
236
|
+
// Test runner
|
|
237
|
+
if (pyproject.includes('pytest')) config.testRunner = 'pytest';
|
|
238
|
+
else if (pyproject.includes('unittest')) config.testRunner = 'unittest';
|
|
239
|
+
|
|
240
|
+
// Linter/tools
|
|
241
|
+
if (pyproject.includes('ruff')) config.linter = 'ruff';
|
|
242
|
+
else if (pyproject.includes('flake8')) config.linter = 'flake8';
|
|
243
|
+
|
|
244
|
+
const pyTools = [];
|
|
245
|
+
if (pyproject.includes('black')) pyTools.push('black');
|
|
246
|
+
if (pyproject.includes('mypy')) pyTools.push('mypy');
|
|
247
|
+
if (pyTools.length > 0) config.tools = pyTools;
|
|
248
|
+
|
|
249
|
+
// Frameworks
|
|
250
|
+
const pyFrameworks = [];
|
|
251
|
+
if (pyproject.includes('django')) pyFrameworks.push('django');
|
|
252
|
+
if (pyproject.includes('flask')) pyFrameworks.push('flask');
|
|
253
|
+
if (pyproject.includes('fastapi')) pyFrameworks.push('fastapi');
|
|
254
|
+
if (pyFrameworks.length > 0) config.frameworks = pyFrameworks;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Package manager
|
|
258
|
+
if (exists('poetry.lock')) config.packageManager = 'poetry';
|
|
259
|
+
else if (exists('Pipfile.lock') || exists('Pipfile')) config.packageManager = 'pipenv';
|
|
260
|
+
else config.packageManager = 'pip';
|
|
261
|
+
|
|
262
|
+
if (!config.testCommand && config.testRunner) {
|
|
263
|
+
config.testCommand = config.testRunner === 'pytest' ? 'pytest' : 'python -m unittest';
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Go detection
|
|
268
|
+
if (exists('go.mod')) {
|
|
269
|
+
config.language = 'Go';
|
|
270
|
+
config.runtime = 'Go';
|
|
271
|
+
config.testRunner = 'go test';
|
|
272
|
+
config.testCommand = 'go test ./...';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Rust detection
|
|
276
|
+
if (exists('Cargo.toml')) {
|
|
277
|
+
config.language = 'Rust';
|
|
278
|
+
config.runtime = 'Rust';
|
|
279
|
+
config.packageManager = 'cargo';
|
|
280
|
+
config.testRunner = 'cargo test';
|
|
281
|
+
config.testCommand = 'cargo test';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Ruby detection
|
|
285
|
+
if (exists('Gemfile')) {
|
|
286
|
+
config.language = 'Ruby';
|
|
287
|
+
config.runtime = 'Ruby';
|
|
288
|
+
config.packageManager = 'bundler';
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return config;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Displays the current stack configuration.
|
|
296
|
+
*/
|
|
297
|
+
function displayStackConfig() {
|
|
298
|
+
const config = readStackConfig();
|
|
299
|
+
console.log('\nStack Configuration\n');
|
|
300
|
+
console.log(` language: ${config.language || '(not set)'}`);
|
|
301
|
+
console.log(` runtime: ${config.runtime || '(not set)'}`);
|
|
302
|
+
console.log(` packageManager: ${config.packageManager || '(not set)'}`);
|
|
303
|
+
console.log(` frameworks: ${config.frameworks.length > 0 ? config.frameworks.join(', ') : '(not set)'}`);
|
|
304
|
+
console.log(` testRunner: ${config.testRunner || '(not set)'}`);
|
|
305
|
+
console.log(` testCommand: ${config.testCommand || '(not set)'}`);
|
|
306
|
+
console.log(` linter: ${config.linter || '(not set)'}`);
|
|
307
|
+
console.log(` tools: ${config.tools.length > 0 ? config.tools.join(', ') : '(not set)'}`);
|
|
308
|
+
console.log('\nTo change: orchestr8 stack-config set <key> <value>');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
module.exports = {
|
|
312
|
+
CONFIG_FILE,
|
|
313
|
+
getDefaultStackConfig,
|
|
314
|
+
readStackConfig,
|
|
315
|
+
writeStackConfig,
|
|
316
|
+
resetStackConfig,
|
|
317
|
+
setStackConfigValue,
|
|
318
|
+
detectStackConfig,
|
|
319
|
+
displayStackConfig
|
|
320
|
+
};
|