@fission-ai/openspec 0.2.0 → 0.4.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 +156 -110
- package/dist/core/config.d.ts +7 -5
- package/dist/core/config.js +4 -4
- package/dist/core/configurators/agents.d.ts +8 -0
- package/dist/core/configurators/agents.js +15 -0
- package/dist/core/configurators/registry.js +3 -0
- package/dist/core/configurators/slash/opencode.d.ts +9 -0
- package/dist/core/configurators/slash/opencode.js +36 -0
- package/dist/core/configurators/slash/registry.js +3 -0
- package/dist/core/init.d.ts +28 -0
- package/dist/core/init.js +358 -50
- package/dist/core/parsers/change-parser.js +3 -2
- package/dist/core/parsers/markdown-parser.d.ts +1 -0
- package/dist/core/parsers/markdown-parser.js +5 -1
- package/dist/core/parsers/requirement-blocks.js +10 -5
- package/dist/core/templates/claude-template.d.ts +1 -1
- package/dist/core/templates/claude-template.js +1 -105
- package/dist/core/templates/index.d.ts +1 -0
- package/dist/core/templates/index.js +3 -0
- package/dist/core/templates/slash-command-templates.js +1 -1
- package/dist/core/update.js +9 -2
- package/package.json +3 -3
package/dist/core/init.js
CHANGED
|
@@ -1,65 +1,307 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
|
-
import {
|
|
2
|
+
import { createPrompt, isBackspaceKey, isDownKey, isEnterKey, isSpaceKey, isUpKey, useKeypress, usePagination, useState, } from '@inquirer/core';
|
|
3
|
+
import chalk from 'chalk';
|
|
3
4
|
import ora from 'ora';
|
|
4
5
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
5
6
|
import { TemplateManager } from './templates/index.js';
|
|
6
7
|
import { ToolRegistry } from './configurators/registry.js';
|
|
7
8
|
import { SlashCommandRegistry } from './configurators/slash/registry.js';
|
|
8
|
-
import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
|
|
9
|
+
import { AI_TOOLS, OPENSPEC_DIR_NAME, } from './config.js';
|
|
10
|
+
const PROGRESS_SPINNER = {
|
|
11
|
+
interval: 80,
|
|
12
|
+
frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],
|
|
13
|
+
};
|
|
14
|
+
const PALETTE = {
|
|
15
|
+
white: chalk.hex('#f4f4f4'),
|
|
16
|
+
lightGray: chalk.hex('#c8c8c8'),
|
|
17
|
+
midGray: chalk.hex('#8a8a8a'),
|
|
18
|
+
darkGray: chalk.hex('#4a4a4a'),
|
|
19
|
+
};
|
|
20
|
+
const LETTER_MAP = {
|
|
21
|
+
O: [' ████ ', '██ ██', '██ ██', '██ ██', ' ████ '],
|
|
22
|
+
P: ['█████ ', '██ ██', '█████ ', '██ ', '██ '],
|
|
23
|
+
E: ['██████', '██ ', '█████ ', '██ ', '██████'],
|
|
24
|
+
N: ['██ ██', '███ ██', '██ ███', '██ ██', '██ ██'],
|
|
25
|
+
S: [' █████', '██ ', ' ████ ', ' ██', '█████ '],
|
|
26
|
+
C: [' █████', '██ ', '██ ', '██ ', ' █████'],
|
|
27
|
+
' ': [' ', ' ', ' ', ' ', ' '],
|
|
28
|
+
};
|
|
29
|
+
const sanitizeToolLabel = (raw) => raw.replace(/✅/gu, '✔').trim();
|
|
30
|
+
const parseToolLabel = (raw) => {
|
|
31
|
+
const sanitized = sanitizeToolLabel(raw);
|
|
32
|
+
const match = sanitized.match(/^(.*?)\s*\((.+)\)$/u);
|
|
33
|
+
if (!match) {
|
|
34
|
+
return { primary: sanitized };
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
primary: match[1].trim(),
|
|
38
|
+
annotation: match[2].trim(),
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
const toolSelectionWizard = createPrompt((config, done) => {
|
|
42
|
+
const totalSteps = 3;
|
|
43
|
+
const [step, setStep] = useState('intro');
|
|
44
|
+
const [cursor, setCursor] = useState(0);
|
|
45
|
+
const [selected, setSelected] = useState(() => config.initialSelected ?? []);
|
|
46
|
+
const [error, setError] = useState(null);
|
|
47
|
+
const selectedSet = new Set(selected);
|
|
48
|
+
const pageSize = Math.max(Math.min(config.choices.length, 7), 1);
|
|
49
|
+
const updateSelected = (next) => {
|
|
50
|
+
const ordered = config.choices
|
|
51
|
+
.map((choice) => choice.value)
|
|
52
|
+
.filter((value) => next.has(value));
|
|
53
|
+
setSelected(ordered);
|
|
54
|
+
};
|
|
55
|
+
const page = usePagination({
|
|
56
|
+
items: config.choices,
|
|
57
|
+
active: cursor,
|
|
58
|
+
pageSize,
|
|
59
|
+
loop: config.choices.length > 1,
|
|
60
|
+
renderItem: ({ item, isActive }) => {
|
|
61
|
+
const isSelected = selectedSet.has(item.value);
|
|
62
|
+
const cursorSymbol = isActive
|
|
63
|
+
? PALETTE.white('›')
|
|
64
|
+
: PALETTE.midGray(' ');
|
|
65
|
+
const indicator = isSelected
|
|
66
|
+
? PALETTE.white('◉')
|
|
67
|
+
: PALETTE.midGray('○');
|
|
68
|
+
const nameColor = isActive ? PALETTE.white : PALETTE.midGray;
|
|
69
|
+
const label = `${nameColor(item.label.primary)}${item.configured ? PALETTE.midGray(' (already configured)') : ''}`;
|
|
70
|
+
return `${cursorSymbol} ${indicator} ${label}`;
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
useKeypress((key) => {
|
|
74
|
+
if (step === 'intro') {
|
|
75
|
+
if (isEnterKey(key)) {
|
|
76
|
+
setStep('select');
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (step === 'select') {
|
|
81
|
+
if (isUpKey(key)) {
|
|
82
|
+
const previousIndex = cursor <= 0 ? config.choices.length - 1 : cursor - 1;
|
|
83
|
+
setCursor(previousIndex);
|
|
84
|
+
setError(null);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (isDownKey(key)) {
|
|
88
|
+
const nextIndex = cursor >= config.choices.length - 1 ? 0 : cursor + 1;
|
|
89
|
+
setCursor(nextIndex);
|
|
90
|
+
setError(null);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (isSpaceKey(key)) {
|
|
94
|
+
const current = config.choices[cursor];
|
|
95
|
+
if (!current)
|
|
96
|
+
return;
|
|
97
|
+
const next = new Set(selected);
|
|
98
|
+
if (next.has(current.value)) {
|
|
99
|
+
next.delete(current.value);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
next.add(current.value);
|
|
103
|
+
}
|
|
104
|
+
updateSelected(next);
|
|
105
|
+
setError(null);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (isEnterKey(key)) {
|
|
109
|
+
if (selected.length === 0) {
|
|
110
|
+
setError('Select at least one AI tool to continue.');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
setStep('review');
|
|
114
|
+
setError(null);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (key.name === 'escape') {
|
|
118
|
+
setSelected([]);
|
|
119
|
+
setError(null);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (step === 'review') {
|
|
124
|
+
if (isEnterKey(key)) {
|
|
125
|
+
const finalSelection = config.choices
|
|
126
|
+
.map((choice) => choice.value)
|
|
127
|
+
.filter((value) => selectedSet.has(value));
|
|
128
|
+
done(finalSelection);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (isBackspaceKey(key) || key.name === 'escape') {
|
|
132
|
+
setStep('select');
|
|
133
|
+
setError(null);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
const selectedNames = config.choices
|
|
138
|
+
.filter((choice) => selectedSet.has(choice.value))
|
|
139
|
+
.map((choice) => choice.label.primary);
|
|
140
|
+
const stepIndex = step === 'intro' ? 1 : step === 'select' ? 2 : 3;
|
|
141
|
+
const lines = [];
|
|
142
|
+
lines.push(PALETTE.midGray(`Step ${stepIndex}/${totalSteps}`));
|
|
143
|
+
lines.push('');
|
|
144
|
+
if (step === 'intro') {
|
|
145
|
+
const introHeadline = config.extendMode
|
|
146
|
+
? 'Extend your OpenSpec tooling'
|
|
147
|
+
: 'Configure your OpenSpec tooling';
|
|
148
|
+
const introBody = config.extendMode
|
|
149
|
+
? 'We detected an existing setup. We will help you refresh or add integrations.'
|
|
150
|
+
: "Let's get your AI assistants connected so they understand OpenSpec.";
|
|
151
|
+
lines.push(PALETTE.white(introHeadline));
|
|
152
|
+
lines.push(PALETTE.midGray(introBody));
|
|
153
|
+
lines.push('');
|
|
154
|
+
lines.push(PALETTE.midGray('Press Enter to continue.'));
|
|
155
|
+
}
|
|
156
|
+
else if (step === 'select') {
|
|
157
|
+
lines.push(PALETTE.white(config.baseMessage));
|
|
158
|
+
lines.push(PALETTE.midGray('Use ↑/↓ to move · Space to toggle · Enter to review selections.'));
|
|
159
|
+
lines.push('');
|
|
160
|
+
lines.push(page);
|
|
161
|
+
lines.push('');
|
|
162
|
+
if (selectedNames.length === 0) {
|
|
163
|
+
lines.push(`${PALETTE.midGray('Selected')}: ${PALETTE.midGray('None selected yet')}`);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
lines.push(PALETTE.midGray('Selected:'));
|
|
167
|
+
selectedNames.forEach((name) => {
|
|
168
|
+
lines.push(` ${PALETTE.white('-')} ${PALETTE.white(name)}`);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
lines.push(PALETTE.white('Review selections'));
|
|
174
|
+
lines.push(PALETTE.midGray('Press Enter to confirm or Backspace to adjust.'));
|
|
175
|
+
lines.push('');
|
|
176
|
+
if (selectedNames.length === 0) {
|
|
177
|
+
lines.push(PALETTE.midGray('No tools selected. Press Backspace to return.'));
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
selectedNames.forEach((name) => {
|
|
181
|
+
lines.push(`${PALETTE.white('▌')} ${PALETTE.white(name)}`);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (error) {
|
|
186
|
+
return [lines.join('\n'), chalk.red(error)];
|
|
187
|
+
}
|
|
188
|
+
return lines.join('\n');
|
|
189
|
+
});
|
|
9
190
|
export class InitCommand {
|
|
191
|
+
prompt;
|
|
192
|
+
constructor(options = {}) {
|
|
193
|
+
this.prompt = options.prompt ?? ((config) => toolSelectionWizard(config));
|
|
194
|
+
}
|
|
10
195
|
async execute(targetPath) {
|
|
11
196
|
const projectPath = path.resolve(targetPath);
|
|
12
197
|
const openspecDir = OPENSPEC_DIR_NAME;
|
|
13
198
|
const openspecPath = path.join(projectPath, openspecDir);
|
|
14
199
|
// Validation happens silently in the background
|
|
15
|
-
await this.validate(projectPath, openspecPath);
|
|
200
|
+
const extendMode = await this.validate(projectPath, openspecPath);
|
|
201
|
+
const existingToolStates = await this.getExistingToolStates(projectPath);
|
|
202
|
+
this.renderBanner(extendMode);
|
|
16
203
|
// Get configuration (after validation to avoid prompts if validation fails)
|
|
17
|
-
const config = await this.getConfiguration();
|
|
204
|
+
const config = await this.getConfiguration(existingToolStates, extendMode);
|
|
205
|
+
if (config.aiTools.length === 0) {
|
|
206
|
+
if (extendMode) {
|
|
207
|
+
throw new Error(`OpenSpec seems to already be initialized at ${openspecPath}.\n` +
|
|
208
|
+
`Use 'openspec update' to update the structure.`);
|
|
209
|
+
}
|
|
210
|
+
throw new Error('You must select at least one AI tool to configure.');
|
|
211
|
+
}
|
|
212
|
+
const availableTools = AI_TOOLS.filter((tool) => tool.available);
|
|
213
|
+
const selectedIds = new Set(config.aiTools);
|
|
214
|
+
const selectedTools = availableTools.filter((tool) => selectedIds.has(tool.value));
|
|
215
|
+
const created = selectedTools.filter((tool) => !existingToolStates[tool.value]);
|
|
216
|
+
const refreshed = selectedTools.filter((tool) => existingToolStates[tool.value]);
|
|
217
|
+
const skippedExisting = availableTools.filter((tool) => !selectedIds.has(tool.value) && existingToolStates[tool.value]);
|
|
218
|
+
const skipped = availableTools.filter((tool) => !selectedIds.has(tool.value) && !existingToolStates[tool.value]);
|
|
18
219
|
// Step 1: Create directory structure
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
220
|
+
if (!extendMode) {
|
|
221
|
+
const structureSpinner = this.startSpinner('Creating OpenSpec structure...');
|
|
222
|
+
await this.createDirectoryStructure(openspecPath);
|
|
223
|
+
await this.generateFiles(openspecPath, config);
|
|
224
|
+
structureSpinner.stopAndPersist({
|
|
225
|
+
symbol: PALETTE.white('▌'),
|
|
226
|
+
text: PALETTE.white('OpenSpec structure created'),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
ora({ stream: process.stdout }).info(PALETTE.midGray('ℹ OpenSpec already initialized. Skipping base scaffolding.'));
|
|
231
|
+
}
|
|
23
232
|
// Step 2: Configure AI tools
|
|
24
|
-
const toolSpinner =
|
|
233
|
+
const toolSpinner = this.startSpinner('Configuring AI tools...');
|
|
25
234
|
await this.configureAITools(projectPath, openspecDir, config.aiTools);
|
|
26
|
-
toolSpinner.
|
|
235
|
+
toolSpinner.stopAndPersist({
|
|
236
|
+
symbol: PALETTE.white('▌'),
|
|
237
|
+
text: PALETTE.white('AI tools configured'),
|
|
238
|
+
});
|
|
27
239
|
// Success message
|
|
28
|
-
this.displaySuccessMessage(
|
|
240
|
+
this.displaySuccessMessage(selectedTools, created, refreshed, skippedExisting, skipped, extendMode);
|
|
29
241
|
}
|
|
30
|
-
async validate(projectPath,
|
|
31
|
-
|
|
32
|
-
if (await FileSystemUtils.directoryExists(openspecPath)) {
|
|
33
|
-
throw new Error(`OpenSpec seems to already be initialized at ${openspecPath}.\n` +
|
|
34
|
-
`Use 'openspec update' to update the structure.`);
|
|
35
|
-
}
|
|
242
|
+
async validate(projectPath, _openspecPath) {
|
|
243
|
+
const extendMode = await FileSystemUtils.directoryExists(_openspecPath);
|
|
36
244
|
// Check write permissions
|
|
37
|
-
if (!await FileSystemUtils.ensureWritePermissions(projectPath)) {
|
|
245
|
+
if (!(await FileSystemUtils.ensureWritePermissions(projectPath))) {
|
|
38
246
|
throw new Error(`Insufficient permissions to write to ${projectPath}`);
|
|
39
247
|
}
|
|
248
|
+
return extendMode;
|
|
40
249
|
}
|
|
41
|
-
async getConfiguration() {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
250
|
+
async getConfiguration(existingTools, extendMode) {
|
|
251
|
+
const selectedTools = await this.promptForAITools(existingTools, extendMode);
|
|
252
|
+
return { aiTools: selectedTools };
|
|
253
|
+
}
|
|
254
|
+
async promptForAITools(existingTools, extendMode) {
|
|
255
|
+
const availableTools = AI_TOOLS.filter((tool) => tool.available);
|
|
256
|
+
if (availableTools.length === 0) {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
const baseMessage = extendMode
|
|
260
|
+
? 'Which AI tools would you like to add or refresh?'
|
|
261
|
+
: 'Which AI tools do you use?';
|
|
262
|
+
const initialSelected = extendMode
|
|
263
|
+
? availableTools
|
|
264
|
+
.filter((tool) => existingTools[tool.value])
|
|
265
|
+
.map((tool) => tool.value)
|
|
266
|
+
: [];
|
|
267
|
+
return this.prompt({
|
|
268
|
+
extendMode,
|
|
269
|
+
baseMessage,
|
|
270
|
+
choices: availableTools.map((tool) => ({
|
|
50
271
|
value: tool.value,
|
|
51
|
-
|
|
52
|
-
|
|
272
|
+
label: parseToolLabel(tool.name),
|
|
273
|
+
configured: Boolean(existingTools[tool.value]),
|
|
274
|
+
})),
|
|
275
|
+
initialSelected,
|
|
53
276
|
});
|
|
54
|
-
|
|
55
|
-
|
|
277
|
+
}
|
|
278
|
+
async getExistingToolStates(projectPath) {
|
|
279
|
+
const states = {};
|
|
280
|
+
for (const tool of AI_TOOLS) {
|
|
281
|
+
states[tool.value] = await this.isToolConfigured(projectPath, tool.value);
|
|
282
|
+
}
|
|
283
|
+
return states;
|
|
284
|
+
}
|
|
285
|
+
async isToolConfigured(projectPath, toolId) {
|
|
286
|
+
const configFile = ToolRegistry.get(toolId)?.configFileName;
|
|
287
|
+
if (configFile &&
|
|
288
|
+
(await FileSystemUtils.fileExists(path.join(projectPath, configFile))))
|
|
289
|
+
return true;
|
|
290
|
+
const slashConfigurator = SlashCommandRegistry.get(toolId);
|
|
291
|
+
if (!slashConfigurator)
|
|
292
|
+
return false;
|
|
293
|
+
for (const target of slashConfigurator.getTargets()) {
|
|
294
|
+
if (await FileSystemUtils.fileExists(path.join(projectPath, target.path)))
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
return false;
|
|
56
298
|
}
|
|
57
299
|
async createDirectoryStructure(openspecPath) {
|
|
58
300
|
const directories = [
|
|
59
301
|
openspecPath,
|
|
60
302
|
path.join(openspecPath, 'specs'),
|
|
61
303
|
path.join(openspecPath, 'changes'),
|
|
62
|
-
path.join(openspecPath, 'changes', 'archive')
|
|
304
|
+
path.join(openspecPath, 'changes', 'archive'),
|
|
63
305
|
];
|
|
64
306
|
for (const dir of directories) {
|
|
65
307
|
await FileSystemUtils.createDirectory(dir);
|
|
@@ -90,25 +332,91 @@ export class InitCommand {
|
|
|
90
332
|
}
|
|
91
333
|
}
|
|
92
334
|
}
|
|
93
|
-
displaySuccessMessage(
|
|
335
|
+
displaySuccessMessage(selectedTools, created, refreshed, skippedExisting, skipped, extendMode) {
|
|
94
336
|
console.log(); // Empty line for spacing
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
console.log(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
337
|
+
const successHeadline = extendMode
|
|
338
|
+
? 'OpenSpec tool configuration updated!'
|
|
339
|
+
: 'OpenSpec initialized successfully!';
|
|
340
|
+
ora().succeed(PALETTE.white(successHeadline));
|
|
341
|
+
console.log();
|
|
342
|
+
console.log(PALETTE.lightGray('Tool summary:'));
|
|
343
|
+
const summaryLines = [
|
|
344
|
+
created.length
|
|
345
|
+
? `${PALETTE.white('▌')} ${PALETTE.white('Created:')} ${this.formatToolNames(created)}`
|
|
346
|
+
: null,
|
|
347
|
+
refreshed.length
|
|
348
|
+
? `${PALETTE.lightGray('▌')} ${PALETTE.lightGray('Refreshed:')} ${this.formatToolNames(refreshed)}`
|
|
349
|
+
: null,
|
|
350
|
+
skippedExisting.length
|
|
351
|
+
? `${PALETTE.midGray('▌')} ${PALETTE.midGray('Skipped (already configured):')} ${this.formatToolNames(skippedExisting)}`
|
|
352
|
+
: null,
|
|
353
|
+
skipped.length
|
|
354
|
+
? `${PALETTE.darkGray('▌')} ${PALETTE.darkGray('Skipped:')} ${this.formatToolNames(skipped)}`
|
|
355
|
+
: null,
|
|
356
|
+
].filter((line) => Boolean(line));
|
|
357
|
+
for (const line of summaryLines) {
|
|
358
|
+
console.log(line);
|
|
359
|
+
}
|
|
360
|
+
console.log();
|
|
361
|
+
console.log(PALETTE.midGray('Use `openspec update` to refresh shared OpenSpec instructions in the future.'));
|
|
362
|
+
// Get the selected tool name(s) for display
|
|
363
|
+
const toolName = this.formatToolNames(selectedTools);
|
|
364
|
+
console.log();
|
|
365
|
+
console.log(`Next steps - Copy these prompts to ${toolName}:`);
|
|
366
|
+
console.log(chalk.gray('────────────────────────────────────────────────────────────'));
|
|
367
|
+
console.log(PALETTE.white('1. Populate your project context:'));
|
|
368
|
+
console.log(PALETTE.lightGray(' "Please read openspec/project.md and help me fill it out'));
|
|
369
|
+
console.log(PALETTE.lightGray(' with details about my project, tech stack, and conventions"\n'));
|
|
370
|
+
console.log(PALETTE.white('2. Create your first change proposal:'));
|
|
371
|
+
console.log(PALETTE.lightGray(' "I want to add [YOUR FEATURE HERE]. Please create an'));
|
|
372
|
+
console.log(PALETTE.lightGray(' OpenSpec change proposal for this feature"\n'));
|
|
373
|
+
console.log(PALETTE.white('3. Learn the OpenSpec workflow:'));
|
|
374
|
+
console.log(PALETTE.lightGray(' "Please explain the OpenSpec workflow from openspec/AGENTS.md'));
|
|
375
|
+
console.log(PALETTE.lightGray(' and how I should work with you on this project"'));
|
|
376
|
+
console.log(PALETTE.darkGray('────────────────────────────────────────────────────────────\n'));
|
|
377
|
+
}
|
|
378
|
+
formatToolNames(tools) {
|
|
379
|
+
const names = tools
|
|
380
|
+
.map((tool) => tool.successLabel ?? tool.name)
|
|
381
|
+
.filter((name) => Boolean(name));
|
|
382
|
+
if (names.length === 0)
|
|
383
|
+
return PALETTE.lightGray('your AI assistant');
|
|
384
|
+
if (names.length === 1)
|
|
385
|
+
return PALETTE.white(names[0]);
|
|
386
|
+
const base = names.slice(0, -1).map((name) => PALETTE.white(name));
|
|
387
|
+
const last = PALETTE.white(names[names.length - 1]);
|
|
388
|
+
return `${base.join(PALETTE.midGray(', '))}${base.length ? PALETTE.midGray(', and ') : ''}${last}`;
|
|
389
|
+
}
|
|
390
|
+
renderBanner(_extendMode) {
|
|
391
|
+
const rows = ['', '', '', '', ''];
|
|
392
|
+
for (const char of 'OPENSPEC') {
|
|
393
|
+
const glyph = LETTER_MAP[char] ?? LETTER_MAP[' '];
|
|
394
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
395
|
+
rows[i] += `${glyph[i]} `;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const rowStyles = [
|
|
399
|
+
PALETTE.white,
|
|
400
|
+
PALETTE.lightGray,
|
|
401
|
+
PALETTE.midGray,
|
|
402
|
+
PALETTE.lightGray,
|
|
403
|
+
PALETTE.white,
|
|
404
|
+
];
|
|
405
|
+
console.log();
|
|
406
|
+
rows.forEach((row, index) => {
|
|
407
|
+
console.log(rowStyles[index](row.replace(/\s+$/u, '')));
|
|
408
|
+
});
|
|
409
|
+
console.log();
|
|
410
|
+
console.log(PALETTE.white('Welcome to OpenSpec!'));
|
|
411
|
+
console.log();
|
|
412
|
+
}
|
|
413
|
+
startSpinner(text) {
|
|
414
|
+
return ora({
|
|
415
|
+
text,
|
|
416
|
+
stream: process.stdout,
|
|
417
|
+
color: 'gray',
|
|
418
|
+
spinner: PROGRESS_SPINNER,
|
|
419
|
+
}).start();
|
|
112
420
|
}
|
|
113
421
|
}
|
|
114
422
|
//# sourceMappingURL=init.js.map
|
|
@@ -124,7 +124,7 @@ export class ChangeParser extends MarkdownParser {
|
|
|
124
124
|
}
|
|
125
125
|
parseRenames(content) {
|
|
126
126
|
const renames = [];
|
|
127
|
-
const lines = content.split('\n');
|
|
127
|
+
const lines = ChangeParser.normalizeContent(content).split('\n');
|
|
128
128
|
let currentRename = {};
|
|
129
129
|
for (const line of lines) {
|
|
130
130
|
const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
|
|
@@ -146,7 +146,8 @@ export class ChangeParser extends MarkdownParser {
|
|
|
146
146
|
return renames;
|
|
147
147
|
}
|
|
148
148
|
parseSectionsFromContent(content) {
|
|
149
|
-
const
|
|
149
|
+
const normalizedContent = ChangeParser.normalizeContent(content);
|
|
150
|
+
const lines = normalizedContent.split('\n');
|
|
150
151
|
const sections = [];
|
|
151
152
|
const stack = [];
|
|
152
153
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -9,6 +9,7 @@ export declare class MarkdownParser {
|
|
|
9
9
|
private lines;
|
|
10
10
|
private currentLine;
|
|
11
11
|
constructor(content: string);
|
|
12
|
+
protected static normalizeContent(content: string): string;
|
|
12
13
|
parseSpec(name: string): Spec;
|
|
13
14
|
parseChange(name: string): Change;
|
|
14
15
|
protected parseSections(): Section[];
|
|
@@ -2,9 +2,13 @@ export class MarkdownParser {
|
|
|
2
2
|
lines;
|
|
3
3
|
currentLine;
|
|
4
4
|
constructor(content) {
|
|
5
|
-
|
|
5
|
+
const normalized = MarkdownParser.normalizeContent(content);
|
|
6
|
+
this.lines = normalized.split('\n');
|
|
6
7
|
this.currentLine = 0;
|
|
7
8
|
}
|
|
9
|
+
static normalizeContent(content) {
|
|
10
|
+
return content.replace(/\r\n?/g, '\n');
|
|
11
|
+
}
|
|
8
12
|
parseSpec(name) {
|
|
9
13
|
const sections = this.parseSections();
|
|
10
14
|
const purpose = this.findSection(sections, 'Purpose')?.content || '';
|
|
@@ -6,7 +6,8 @@ const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/;
|
|
|
6
6
|
* Extracts the Requirements section from a spec file and parses requirement blocks.
|
|
7
7
|
*/
|
|
8
8
|
export function extractRequirementsSection(content) {
|
|
9
|
-
const
|
|
9
|
+
const normalized = normalizeLineEndings(content);
|
|
10
|
+
const lines = normalized.split('\n');
|
|
10
11
|
const reqHeaderIndex = lines.findIndex(l => /^##\s+Requirements\s*$/i.test(l));
|
|
11
12
|
if (reqHeaderIndex === -1) {
|
|
12
13
|
// No requirements section; create an empty one at the end
|
|
@@ -70,11 +71,15 @@ export function extractRequirementsSection(content) {
|
|
|
70
71
|
after: after.startsWith('\n') ? after : '\n' + after,
|
|
71
72
|
};
|
|
72
73
|
}
|
|
74
|
+
function normalizeLineEndings(content) {
|
|
75
|
+
return content.replace(/\r\n?/g, '\n');
|
|
76
|
+
}
|
|
73
77
|
/**
|
|
74
78
|
* Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
|
|
75
79
|
*/
|
|
76
80
|
export function parseDeltaSpec(content) {
|
|
77
|
-
const
|
|
81
|
+
const normalized = normalizeLineEndings(content);
|
|
82
|
+
const sections = splitTopLevelSections(normalized);
|
|
78
83
|
const added = parseRequirementBlocksFromSection(sections['ADDED Requirements'] || '');
|
|
79
84
|
const modified = parseRequirementBlocksFromSection(sections['MODIFIED Requirements'] || '');
|
|
80
85
|
const removedNames = parseRemovedNames(sections['REMOVED Requirements'] || '');
|
|
@@ -103,7 +108,7 @@ function splitTopLevelSections(content) {
|
|
|
103
108
|
function parseRequirementBlocksFromSection(sectionBody) {
|
|
104
109
|
if (!sectionBody)
|
|
105
110
|
return [];
|
|
106
|
-
const lines = sectionBody.split('\n');
|
|
111
|
+
const lines = normalizeLineEndings(sectionBody).split('\n');
|
|
107
112
|
const blocks = [];
|
|
108
113
|
let i = 0;
|
|
109
114
|
while (i < lines.length) {
|
|
@@ -133,7 +138,7 @@ function parseRemovedNames(sectionBody) {
|
|
|
133
138
|
if (!sectionBody)
|
|
134
139
|
return [];
|
|
135
140
|
const names = [];
|
|
136
|
-
const lines = sectionBody.split('\n');
|
|
141
|
+
const lines = normalizeLineEndings(sectionBody).split('\n');
|
|
137
142
|
for (const line of lines) {
|
|
138
143
|
const m = line.match(REQUIREMENT_HEADER_REGEX);
|
|
139
144
|
if (m) {
|
|
@@ -152,7 +157,7 @@ function parseRenamedPairs(sectionBody) {
|
|
|
152
157
|
if (!sectionBody)
|
|
153
158
|
return [];
|
|
154
159
|
const pairs = [];
|
|
155
|
-
const lines = sectionBody.split('\n');
|
|
160
|
+
const lines = normalizeLineEndings(sectionBody).split('\n');
|
|
156
161
|
let current = {};
|
|
157
162
|
for (const line of lines) {
|
|
158
163
|
const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export
|
|
1
|
+
export { agentsTemplate as claudeTemplate } from './agents-template.js';
|
|
2
2
|
//# sourceMappingURL=claude-template.d.ts.map
|
|
@@ -1,106 +1,2 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
3
|
-
This project uses OpenSpec for spec-driven development. Specifications are the source of truth.
|
|
4
|
-
|
|
5
|
-
See @openspec/AGENTS.md for detailed conventions and guidelines.
|
|
6
|
-
|
|
7
|
-
## Three-Stage Workflow
|
|
8
|
-
|
|
9
|
-
### Stage 1: Creating Changes
|
|
10
|
-
Create proposal for: features, breaking changes, architecture changes
|
|
11
|
-
Skip proposal for: bug fixes, typos, non-breaking updates
|
|
12
|
-
|
|
13
|
-
### Stage 2: Implementing Changes
|
|
14
|
-
1. Read proposal.md to understand the change
|
|
15
|
-
2. Read design.md if it exists for technical context
|
|
16
|
-
3. Read tasks.md for implementation checklist
|
|
17
|
-
4. Complete tasks one by one
|
|
18
|
-
5. Mark each task complete immediately: \`- [x]\`
|
|
19
|
-
6. Validate strictly: \`openspec validate [change] --strict\`
|
|
20
|
-
7. Approval gate: Do not start implementation until the proposal is approved
|
|
21
|
-
|
|
22
|
-
### Stage 3: Archiving
|
|
23
|
-
After deployment, use \`openspec archive [change]\` (add \`--skip-specs\` for tooling-only changes)
|
|
24
|
-
|
|
25
|
-
## Before Any Task
|
|
26
|
-
|
|
27
|
-
**Always:**
|
|
28
|
-
- Check existing specs: \`openspec list --specs\`
|
|
29
|
-
- Check active changes: \`openspec list\`
|
|
30
|
-
- Read relevant specs before creating new ones
|
|
31
|
-
- Prefer modifying existing specs over creating duplicates
|
|
32
|
-
|
|
33
|
-
## CLI Quick Reference
|
|
34
|
-
|
|
35
|
-
\`\`\`bash
|
|
36
|
-
# Essential
|
|
37
|
-
openspec list # Active changes
|
|
38
|
-
openspec list --specs # Existing specifications
|
|
39
|
-
openspec show [item] # View details
|
|
40
|
-
openspec validate --strict # Validate thoroughly
|
|
41
|
-
openspec archive [change] # Archive after deployment
|
|
42
|
-
|
|
43
|
-
# Interactive
|
|
44
|
-
openspec show # Prompts for selection
|
|
45
|
-
openspec validate # Bulk validation
|
|
46
|
-
|
|
47
|
-
# Debugging
|
|
48
|
-
openspec show [change] --json --deltas-only
|
|
49
|
-
\`\`\`
|
|
50
|
-
|
|
51
|
-
## Creating Changes
|
|
52
|
-
|
|
53
|
-
1. **Directory:** \`changes/[change-id]/\`
|
|
54
|
-
- Change ID naming: kebab-case, verb-led (\`add-\`, \`update-\`, \`remove-\`, \`refactor-\`), unique (append \`-2\`, \`-3\` if needed)
|
|
55
|
-
2. **Files:**
|
|
56
|
-
- \`proposal.md\` - Why, what, impact
|
|
57
|
-
- \`tasks.md\` - Implementation checklist
|
|
58
|
-
- \`design.md\` - Only if needed (cross-cutting, new deps/data model, security/perf/migration complexity, or high ambiguity)
|
|
59
|
-
- \`specs/[capability]/spec.md\` - Delta changes (ADDED/MODIFIED/REMOVED). For multiple capabilities, include multiple files.
|
|
60
|
-
3. **If ambiguous:** ask 1–2 clarifying questions before scaffolding
|
|
61
|
-
|
|
62
|
-
## Search Guidance
|
|
63
|
-
- Enumerate specs: \`openspec spec list --long\` (or \`--json\`)
|
|
64
|
-
- Enumerate changes: \`openspec list\`
|
|
65
|
-
- Show details: \`openspec show <spec-id> --type spec\`, \`openspec show <change-id> --json --deltas-only\`
|
|
66
|
-
- Full-text search (use ripgrep): \`rg -n "Requirement:|Scenario:" openspec/specs\`
|
|
67
|
-
|
|
68
|
-
## Critical: Scenario Format
|
|
69
|
-
|
|
70
|
-
**CORRECT:**
|
|
71
|
-
\`\`\`markdown
|
|
72
|
-
#### Scenario: User login
|
|
73
|
-
- **WHEN** valid credentials
|
|
74
|
-
- **THEN** return token
|
|
75
|
-
\`\`\`
|
|
76
|
-
|
|
77
|
-
**WRONG:** Using bullets (- **Scenario**), bold (**Scenario:**), or ### headers
|
|
78
|
-
|
|
79
|
-
Every requirement MUST have scenarios using \`#### Scenario:\` format.
|
|
80
|
-
|
|
81
|
-
## Complexity Management
|
|
82
|
-
|
|
83
|
-
**Default to minimal:**
|
|
84
|
-
- <100 lines of new code
|
|
85
|
-
- Single-file implementations
|
|
86
|
-
- No frameworks without justification
|
|
87
|
-
- Boring, proven patterns
|
|
88
|
-
|
|
89
|
-
**Only add complexity with:**
|
|
90
|
-
- Performance data showing need
|
|
91
|
-
- Concrete scale requirements (>1000 users)
|
|
92
|
-
- Multiple proven use cases
|
|
93
|
-
|
|
94
|
-
## Troubleshooting
|
|
95
|
-
|
|
96
|
-
**"Change must have at least one delta"**
|
|
97
|
-
- Check \`changes/[name]/specs/\` exists
|
|
98
|
-
- Verify operation prefixes (## ADDED Requirements)
|
|
99
|
-
|
|
100
|
-
**"Requirement must have at least one scenario"**
|
|
101
|
-
- Use \`#### Scenario:\` format (4 hashtags)
|
|
102
|
-
- Don't use bullets or bold
|
|
103
|
-
|
|
104
|
-
**Debug:** \`openspec show [change] --json --deltas-only\`
|
|
105
|
-
`;
|
|
1
|
+
export { agentsTemplate as claudeTemplate } from './agents-template.js';
|
|
106
2
|
//# sourceMappingURL=claude-template.js.map
|