@fission-ai/openspec 0.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/README.md +119 -0
- package/bin/openspec.js +3 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +240 -0
- package/dist/commands/change.d.ts +35 -0
- package/dist/commands/change.js +276 -0
- package/dist/commands/show.d.ts +14 -0
- package/dist/commands/show.js +131 -0
- package/dist/commands/spec.d.ts +15 -0
- package/dist/commands/spec.js +224 -0
- package/dist/commands/validate.d.ts +23 -0
- package/dist/commands/validate.js +275 -0
- package/dist/core/archive.d.ts +15 -0
- package/dist/core/archive.js +529 -0
- package/dist/core/config.d.ts +14 -0
- package/dist/core/config.js +12 -0
- package/dist/core/configurators/base.d.ts +7 -0
- package/dist/core/configurators/base.js +2 -0
- package/dist/core/configurators/claude.d.ts +8 -0
- package/dist/core/configurators/claude.js +15 -0
- package/dist/core/configurators/registry.d.ts +9 -0
- package/dist/core/configurators/registry.js +22 -0
- package/dist/core/converters/json-converter.d.ts +6 -0
- package/dist/core/converters/json-converter.js +48 -0
- package/dist/core/diff.d.ts +11 -0
- package/dist/core/diff.js +193 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/init.d.ts +10 -0
- package/dist/core/init.js +109 -0
- package/dist/core/list.d.ts +4 -0
- package/dist/core/list.js +89 -0
- package/dist/core/parsers/change-parser.d.ts +13 -0
- package/dist/core/parsers/change-parser.js +192 -0
- package/dist/core/parsers/markdown-parser.d.ts +21 -0
- package/dist/core/parsers/markdown-parser.js +183 -0
- package/dist/core/parsers/requirement-blocks.d.ts +31 -0
- package/dist/core/parsers/requirement-blocks.js +173 -0
- package/dist/core/schemas/base.schema.d.ts +13 -0
- package/dist/core/schemas/base.schema.js +13 -0
- package/dist/core/schemas/change.schema.d.ts +73 -0
- package/dist/core/schemas/change.schema.js +31 -0
- package/dist/core/schemas/index.d.ts +4 -0
- package/dist/core/schemas/index.js +4 -0
- package/dist/core/schemas/spec.schema.d.ts +18 -0
- package/dist/core/schemas/spec.schema.js +15 -0
- package/dist/core/templates/claude-template.d.ts +2 -0
- package/dist/core/templates/claude-template.js +96 -0
- package/dist/core/templates/index.d.ts +11 -0
- package/dist/core/templates/index.js +21 -0
- package/dist/core/templates/project-template.d.ts +8 -0
- package/dist/core/templates/project-template.js +32 -0
- package/dist/core/templates/readme-template.d.ts +2 -0
- package/dist/core/templates/readme-template.js +519 -0
- package/dist/core/update.d.ts +4 -0
- package/dist/core/update.js +47 -0
- package/dist/core/validation/constants.d.ts +34 -0
- package/dist/core/validation/constants.js +40 -0
- package/dist/core/validation/types.d.ts +18 -0
- package/dist/core/validation/types.js +2 -0
- package/dist/core/validation/validator.d.ts +32 -0
- package/dist/core/validation/validator.js +355 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/utils/file-system.d.ts +10 -0
- package/dist/utils/file-system.js +83 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.js +2 -0
- package/dist/utils/interactive.d.ts +2 -0
- package/dist/utils/interactive.js +8 -0
- package/dist/utils/item-discovery.d.ts +3 -0
- package/dist/utils/item-discovery.js +49 -0
- package/dist/utils/match.d.ts +3 -0
- package/dist/utils/match.js +22 -0
- package/dist/utils/task-progress.d.ts +8 -0
- package/dist/utils/task-progress.js +36 -0
- package/package.json +68 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { MarkdownParser } from '../parsers/markdown-parser.js';
|
|
4
|
+
import { ChangeParser } from '../parsers/change-parser.js';
|
|
5
|
+
export class JsonConverter {
|
|
6
|
+
convertSpecToJson(filePath) {
|
|
7
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
8
|
+
const parser = new MarkdownParser(content);
|
|
9
|
+
const specName = this.extractNameFromPath(filePath);
|
|
10
|
+
const spec = parser.parseSpec(specName);
|
|
11
|
+
const jsonSpec = {
|
|
12
|
+
...spec,
|
|
13
|
+
metadata: {
|
|
14
|
+
...spec.metadata,
|
|
15
|
+
sourcePath: filePath,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
return JSON.stringify(jsonSpec, null, 2);
|
|
19
|
+
}
|
|
20
|
+
async convertChangeToJson(filePath) {
|
|
21
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
22
|
+
const changeName = this.extractNameFromPath(filePath);
|
|
23
|
+
const changeDir = path.dirname(filePath);
|
|
24
|
+
const parser = new ChangeParser(content, changeDir);
|
|
25
|
+
const change = await parser.parseChangeWithDeltas(changeName);
|
|
26
|
+
const jsonChange = {
|
|
27
|
+
...change,
|
|
28
|
+
metadata: {
|
|
29
|
+
...change.metadata,
|
|
30
|
+
sourcePath: filePath,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
return JSON.stringify(jsonChange, null, 2);
|
|
34
|
+
}
|
|
35
|
+
extractNameFromPath(filePath) {
|
|
36
|
+
const parts = filePath.split('/');
|
|
37
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
38
|
+
if (parts[i] === 'specs' || parts[i] === 'changes') {
|
|
39
|
+
if (i < parts.length - 1) {
|
|
40
|
+
return parts[i + 1];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const fileName = parts[parts.length - 1];
|
|
45
|
+
return fileName.replace('.md', '');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=json-converter.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare class DiffCommand {
|
|
2
|
+
private filesChanged;
|
|
3
|
+
private linesAdded;
|
|
4
|
+
private linesRemoved;
|
|
5
|
+
execute(changeName?: string): Promise<void>;
|
|
6
|
+
private selectChange;
|
|
7
|
+
private showDiffs;
|
|
8
|
+
private walkAndDiff;
|
|
9
|
+
private diffFile;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=diff.d.ts.map
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { diffStringsUnified } from 'jest-diff';
|
|
5
|
+
import { select } from '@inquirer/prompts';
|
|
6
|
+
import { Validator } from './validation/validator.js';
|
|
7
|
+
// Constants
|
|
8
|
+
const ARCHIVE_DIR = 'archive';
|
|
9
|
+
const MARKDOWN_EXT = '.md';
|
|
10
|
+
const OPENSPEC_DIR = 'openspec';
|
|
11
|
+
const CHANGES_DIR = 'changes';
|
|
12
|
+
const SPECS_DIR = 'specs';
|
|
13
|
+
export class DiffCommand {
|
|
14
|
+
filesChanged = 0;
|
|
15
|
+
linesAdded = 0;
|
|
16
|
+
linesRemoved = 0;
|
|
17
|
+
async execute(changeName) {
|
|
18
|
+
const changesDir = path.join(process.cwd(), OPENSPEC_DIR, CHANGES_DIR);
|
|
19
|
+
try {
|
|
20
|
+
await fs.access(changesDir);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new Error('No OpenSpec changes directory found');
|
|
24
|
+
}
|
|
25
|
+
if (!changeName) {
|
|
26
|
+
changeName = await this.selectChange(changesDir);
|
|
27
|
+
if (!changeName)
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const changeDir = path.join(changesDir, changeName);
|
|
31
|
+
try {
|
|
32
|
+
await fs.access(changeDir);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new Error(`Change '${changeName}' not found`);
|
|
36
|
+
}
|
|
37
|
+
const changeSpecsDir = path.join(changeDir, SPECS_DIR);
|
|
38
|
+
try {
|
|
39
|
+
await fs.access(changeSpecsDir);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
console.log(`No spec changes found for '${changeName}'`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
// Validate specs and show warnings (non-blocking)
|
|
46
|
+
const validator = new Validator();
|
|
47
|
+
let hasWarnings = false;
|
|
48
|
+
try {
|
|
49
|
+
const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true });
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (entry.isDirectory()) {
|
|
52
|
+
const specFile = path.join(changeSpecsDir, entry.name, 'spec.md');
|
|
53
|
+
try {
|
|
54
|
+
await fs.access(specFile);
|
|
55
|
+
const report = await validator.validateSpec(specFile);
|
|
56
|
+
if (report.issues.length > 0) {
|
|
57
|
+
const warnings = report.issues.filter(i => i.level === 'WARNING');
|
|
58
|
+
const errors = report.issues.filter(i => i.level === 'ERROR');
|
|
59
|
+
if (errors.length > 0 || warnings.length > 0) {
|
|
60
|
+
if (!hasWarnings) {
|
|
61
|
+
console.log(chalk.yellow('\n⚠️ Validation warnings found:'));
|
|
62
|
+
hasWarnings = true;
|
|
63
|
+
}
|
|
64
|
+
console.log(chalk.yellow(`\n ${entry.name}/spec.md:`));
|
|
65
|
+
for (const issue of errors) {
|
|
66
|
+
console.log(chalk.red(` ✗ ${issue.message}`));
|
|
67
|
+
}
|
|
68
|
+
for (const issue of warnings) {
|
|
69
|
+
console.log(chalk.yellow(` ⚠ ${issue.message}`));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Spec file doesn't exist, skip validation
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (hasWarnings) {
|
|
80
|
+
console.log(chalk.yellow('\nConsider fixing these issues before archiving.\n'));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// No specs directory, skip validation
|
|
85
|
+
}
|
|
86
|
+
// Reset counters
|
|
87
|
+
this.filesChanged = 0;
|
|
88
|
+
this.linesAdded = 0;
|
|
89
|
+
this.linesRemoved = 0;
|
|
90
|
+
await this.showDiffs(changeSpecsDir);
|
|
91
|
+
// Show summary
|
|
92
|
+
if (this.filesChanged > 0) {
|
|
93
|
+
console.log(chalk.bold(`\n📊 Summary: ${this.filesChanged} file(s) changed, ${chalk.green(`+${this.linesAdded}`)} ${chalk.red(`-${this.linesRemoved}`)}`));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async selectChange(changesDir) {
|
|
97
|
+
const entries = await fs.readdir(changesDir, { withFileTypes: true });
|
|
98
|
+
const changes = entries
|
|
99
|
+
.filter(entry => entry.isDirectory() && entry.name !== ARCHIVE_DIR)
|
|
100
|
+
.map(entry => entry.name);
|
|
101
|
+
if (changes.length === 0) {
|
|
102
|
+
console.log('No changes found');
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
console.log('Available changes:');
|
|
106
|
+
const choices = changes.map((name) => ({
|
|
107
|
+
name: name,
|
|
108
|
+
value: name
|
|
109
|
+
}));
|
|
110
|
+
const answer = await select({
|
|
111
|
+
message: 'Select a change',
|
|
112
|
+
choices
|
|
113
|
+
});
|
|
114
|
+
return answer;
|
|
115
|
+
}
|
|
116
|
+
async showDiffs(changeSpecsDir) {
|
|
117
|
+
const currentSpecsDir = path.join(process.cwd(), OPENSPEC_DIR, SPECS_DIR);
|
|
118
|
+
await this.walkAndDiff(changeSpecsDir, currentSpecsDir, '');
|
|
119
|
+
}
|
|
120
|
+
async walkAndDiff(changeDir, currentDir, relativePath) {
|
|
121
|
+
const entries = await fs.readdir(path.join(changeDir, relativePath), { withFileTypes: true });
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
const entryPath = path.join(relativePath, entry.name);
|
|
124
|
+
if (entry.isDirectory()) {
|
|
125
|
+
await this.walkAndDiff(changeDir, currentDir, entryPath);
|
|
126
|
+
}
|
|
127
|
+
else if (entry.isFile() && entry.name.endsWith(MARKDOWN_EXT)) {
|
|
128
|
+
await this.diffFile(path.join(changeDir, entryPath), path.join(currentDir, entryPath), entryPath);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async diffFile(changePath, currentPath, displayPath) {
|
|
133
|
+
let changeContent = '';
|
|
134
|
+
let currentContent = '';
|
|
135
|
+
let isNewFile = false;
|
|
136
|
+
let isDeleted = false;
|
|
137
|
+
try {
|
|
138
|
+
changeContent = await fs.readFile(changePath, 'utf-8');
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
changeContent = '';
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
currentContent = await fs.readFile(currentPath, 'utf-8');
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
currentContent = '';
|
|
148
|
+
isNewFile = true;
|
|
149
|
+
}
|
|
150
|
+
if (changeContent === currentContent) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (changeContent === '' && currentContent !== '') {
|
|
154
|
+
isDeleted = true;
|
|
155
|
+
}
|
|
156
|
+
// Enhanced header with file status
|
|
157
|
+
console.log(chalk.bold.cyan(`\n${'═'.repeat(60)}`));
|
|
158
|
+
console.log(chalk.bold.cyan(`📄 ${displayPath}`));
|
|
159
|
+
if (isNewFile) {
|
|
160
|
+
console.log(chalk.green(` Status: NEW FILE`));
|
|
161
|
+
}
|
|
162
|
+
else if (isDeleted) {
|
|
163
|
+
console.log(chalk.red(` Status: DELETED`));
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
console.log(chalk.yellow(` Status: MODIFIED`));
|
|
167
|
+
}
|
|
168
|
+
// Use jest-diff for the actual diff with custom options
|
|
169
|
+
const diffOptions = {
|
|
170
|
+
aAnnotation: 'Current',
|
|
171
|
+
bAnnotation: 'Proposed',
|
|
172
|
+
aColor: chalk.red,
|
|
173
|
+
bColor: chalk.green,
|
|
174
|
+
commonColor: chalk.gray,
|
|
175
|
+
contextLines: 3,
|
|
176
|
+
expand: false,
|
|
177
|
+
includeChangeCounts: true,
|
|
178
|
+
};
|
|
179
|
+
const diff = diffStringsUnified(currentContent, changeContent, diffOptions);
|
|
180
|
+
// Count lines for statistics (approximate)
|
|
181
|
+
const addedLines = (diff.match(/^\+[^+]/gm) || []).length;
|
|
182
|
+
const removedLines = (diff.match(/^-[^-]/gm) || []).length;
|
|
183
|
+
console.log(chalk.gray(` Lines: ${chalk.green(`+${addedLines}`)} ${chalk.red(`-${removedLines}`)}`));
|
|
184
|
+
console.log(chalk.bold.cyan(`${'─'.repeat(60)}\n`));
|
|
185
|
+
// Display the diff
|
|
186
|
+
console.log(diff);
|
|
187
|
+
// Update counters
|
|
188
|
+
this.filesChanged++;
|
|
189
|
+
this.linesAdded += addedLines;
|
|
190
|
+
this.linesRemoved += removedLines;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=diff.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare class InitCommand {
|
|
2
|
+
execute(targetPath: string): Promise<void>;
|
|
3
|
+
private validate;
|
|
4
|
+
private getConfiguration;
|
|
5
|
+
private createDirectoryStructure;
|
|
6
|
+
private generateFiles;
|
|
7
|
+
private configureAITools;
|
|
8
|
+
private displaySuccessMessage;
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=init.d.ts.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { select } from '@inquirer/prompts';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { FileSystemUtils } from '../utils/file-system.js';
|
|
5
|
+
import { TemplateManager } from './templates/index.js';
|
|
6
|
+
import { ToolRegistry } from './configurators/registry.js';
|
|
7
|
+
import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
|
|
8
|
+
export class InitCommand {
|
|
9
|
+
async execute(targetPath) {
|
|
10
|
+
const projectPath = path.resolve(targetPath);
|
|
11
|
+
const openspecDir = OPENSPEC_DIR_NAME;
|
|
12
|
+
const openspecPath = path.join(projectPath, openspecDir);
|
|
13
|
+
// Validation happens silently in the background
|
|
14
|
+
await this.validate(projectPath, openspecPath);
|
|
15
|
+
// Get configuration (after validation to avoid prompts if validation fails)
|
|
16
|
+
const config = await this.getConfiguration();
|
|
17
|
+
// Step 1: Create directory structure
|
|
18
|
+
const structureSpinner = ora({ text: 'Creating OpenSpec structure...', stream: process.stdout }).start();
|
|
19
|
+
await this.createDirectoryStructure(openspecPath);
|
|
20
|
+
await this.generateFiles(openspecPath, config);
|
|
21
|
+
structureSpinner.succeed('OpenSpec structure created');
|
|
22
|
+
// Step 2: Configure AI tools
|
|
23
|
+
const toolSpinner = ora({ text: 'Configuring AI tools...', stream: process.stdout }).start();
|
|
24
|
+
await this.configureAITools(projectPath, openspecDir, config.aiTools);
|
|
25
|
+
toolSpinner.succeed('AI tools configured');
|
|
26
|
+
// Success message
|
|
27
|
+
this.displaySuccessMessage(openspecDir, config);
|
|
28
|
+
}
|
|
29
|
+
async validate(projectPath, openspecPath) {
|
|
30
|
+
// Check if OpenSpec already exists
|
|
31
|
+
if (await FileSystemUtils.directoryExists(openspecPath)) {
|
|
32
|
+
throw new Error(`OpenSpec seems to already be initialized at ${openspecPath}.\n` +
|
|
33
|
+
`Use 'openspec update' to update the structure.`);
|
|
34
|
+
}
|
|
35
|
+
// Check write permissions
|
|
36
|
+
if (!await FileSystemUtils.ensureWritePermissions(projectPath)) {
|
|
37
|
+
throw new Error(`Insufficient permissions to write to ${projectPath}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async getConfiguration() {
|
|
41
|
+
const config = {
|
|
42
|
+
aiTools: []
|
|
43
|
+
};
|
|
44
|
+
// Single-select for better UX
|
|
45
|
+
const selectedTool = await select({
|
|
46
|
+
message: 'Which AI tool do you use?',
|
|
47
|
+
choices: AI_TOOLS.map(tool => ({
|
|
48
|
+
name: tool.available ? tool.name : `${tool.name} (coming soon)`,
|
|
49
|
+
value: tool.value,
|
|
50
|
+
disabled: !tool.available
|
|
51
|
+
}))
|
|
52
|
+
});
|
|
53
|
+
config.aiTools = [selectedTool];
|
|
54
|
+
return config;
|
|
55
|
+
}
|
|
56
|
+
async createDirectoryStructure(openspecPath) {
|
|
57
|
+
const directories = [
|
|
58
|
+
openspecPath,
|
|
59
|
+
path.join(openspecPath, 'specs'),
|
|
60
|
+
path.join(openspecPath, 'changes'),
|
|
61
|
+
path.join(openspecPath, 'changes', 'archive')
|
|
62
|
+
];
|
|
63
|
+
for (const dir of directories) {
|
|
64
|
+
await FileSystemUtils.createDirectory(dir);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async generateFiles(openspecPath, config) {
|
|
68
|
+
const context = {
|
|
69
|
+
// Could be enhanced with prompts for project details
|
|
70
|
+
};
|
|
71
|
+
const templates = TemplateManager.getTemplates(context);
|
|
72
|
+
for (const template of templates) {
|
|
73
|
+
const filePath = path.join(openspecPath, template.path);
|
|
74
|
+
const content = typeof template.content === 'function'
|
|
75
|
+
? template.content(context)
|
|
76
|
+
: template.content;
|
|
77
|
+
await FileSystemUtils.writeFile(filePath, content);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async configureAITools(projectPath, openspecDir, toolIds) {
|
|
81
|
+
for (const toolId of toolIds) {
|
|
82
|
+
const configurator = ToolRegistry.get(toolId);
|
|
83
|
+
if (configurator && configurator.isAvailable) {
|
|
84
|
+
await configurator.configure(projectPath, openspecDir);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
displaySuccessMessage(openspecDir, config) {
|
|
89
|
+
console.log(); // Empty line for spacing
|
|
90
|
+
ora().succeed('OpenSpec initialized successfully!');
|
|
91
|
+
// Get the selected tool name for display
|
|
92
|
+
const selectedToolId = config.aiTools[0];
|
|
93
|
+
const selectedTool = AI_TOOLS.find(t => t.value === selectedToolId);
|
|
94
|
+
const toolName = selectedTool ? selectedTool.name : 'your AI assistant';
|
|
95
|
+
console.log(`\nNext steps - Copy these prompts to ${toolName}:\n`);
|
|
96
|
+
console.log('────────────────────────────────────────────────────────────');
|
|
97
|
+
console.log('1. Populate your project context:');
|
|
98
|
+
console.log(' "Please read openspec/project.md and help me fill it out');
|
|
99
|
+
console.log(' with details about my project, tech stack, and conventions"\n');
|
|
100
|
+
console.log('2. Create your first change proposal:');
|
|
101
|
+
console.log(' "I want to add [YOUR FEATURE HERE]. Please create an');
|
|
102
|
+
console.log(' OpenSpec change proposal for this feature"\n');
|
|
103
|
+
console.log('3. Learn the OpenSpec workflow:');
|
|
104
|
+
console.log(' "Please explain the OpenSpec workflow from openspec/README.md');
|
|
105
|
+
console.log(' and how I should work with you on this project"');
|
|
106
|
+
console.log('────────────────────────────────────────────────────────────\n');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=init.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { MarkdownParser } from './parsers/markdown-parser.js';
|
|
7
|
+
export class ListCommand {
|
|
8
|
+
async execute(targetPath = '.', mode = 'changes') {
|
|
9
|
+
if (mode === 'changes') {
|
|
10
|
+
const changesDir = path.join(targetPath, 'openspec', 'changes');
|
|
11
|
+
// Check if changes directory exists
|
|
12
|
+
try {
|
|
13
|
+
await fs.access(changesDir);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw new Error("No OpenSpec changes directory found. Run 'openspec init' first.");
|
|
17
|
+
}
|
|
18
|
+
// Get all directories in changes (excluding archive)
|
|
19
|
+
const entries = await fs.readdir(changesDir, { withFileTypes: true });
|
|
20
|
+
const changeDirs = entries
|
|
21
|
+
.filter(entry => entry.isDirectory() && entry.name !== 'archive')
|
|
22
|
+
.map(entry => entry.name);
|
|
23
|
+
if (changeDirs.length === 0) {
|
|
24
|
+
console.log('No active changes found.');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// Collect information about each change
|
|
28
|
+
const changes = [];
|
|
29
|
+
for (const changeDir of changeDirs) {
|
|
30
|
+
const progress = await getTaskProgressForChange(changesDir, changeDir);
|
|
31
|
+
changes.push({
|
|
32
|
+
name: changeDir,
|
|
33
|
+
completedTasks: progress.completed,
|
|
34
|
+
totalTasks: progress.total
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
// Sort alphabetically by name
|
|
38
|
+
changes.sort((a, b) => a.name.localeCompare(b.name));
|
|
39
|
+
// Display results
|
|
40
|
+
console.log('Changes:');
|
|
41
|
+
const padding = ' ';
|
|
42
|
+
const nameWidth = Math.max(...changes.map(c => c.name.length));
|
|
43
|
+
for (const change of changes) {
|
|
44
|
+
const paddedName = change.name.padEnd(nameWidth);
|
|
45
|
+
const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
|
|
46
|
+
console.log(`${padding}${paddedName} ${status}`);
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// specs mode
|
|
51
|
+
const specsDir = path.join(targetPath, 'openspec', 'specs');
|
|
52
|
+
try {
|
|
53
|
+
await fs.access(specsDir);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
console.log('No specs found.');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const entries = await fs.readdir(specsDir, { withFileTypes: true });
|
|
60
|
+
const specDirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
61
|
+
if (specDirs.length === 0) {
|
|
62
|
+
console.log('No specs found.');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const specs = [];
|
|
66
|
+
for (const id of specDirs) {
|
|
67
|
+
const specPath = join(specsDir, id, 'spec.md');
|
|
68
|
+
try {
|
|
69
|
+
const content = readFileSync(specPath, 'utf-8');
|
|
70
|
+
const parser = new MarkdownParser(content);
|
|
71
|
+
const spec = parser.parseSpec(id);
|
|
72
|
+
specs.push({ id, requirementCount: spec.requirements.length });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// If spec cannot be read or parsed, include with 0 count
|
|
76
|
+
specs.push({ id, requirementCount: 0 });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
specs.sort((a, b) => a.id.localeCompare(b.id));
|
|
80
|
+
console.log('Specs:');
|
|
81
|
+
const padding = ' ';
|
|
82
|
+
const nameWidth = Math.max(...specs.map(s => s.id.length));
|
|
83
|
+
for (const spec of specs) {
|
|
84
|
+
const padded = spec.id.padEnd(nameWidth);
|
|
85
|
+
console.log(`${padding}${padded} requirements ${spec.requirementCount}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=list.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { MarkdownParser } from './markdown-parser.js';
|
|
2
|
+
import { Change } from '../schemas/index.js';
|
|
3
|
+
export declare class ChangeParser extends MarkdownParser {
|
|
4
|
+
private changeDir;
|
|
5
|
+
constructor(content: string, changeDir: string);
|
|
6
|
+
parseChangeWithDeltas(name: string): Promise<Change>;
|
|
7
|
+
private parseDeltaSpecs;
|
|
8
|
+
private parseSpecDeltas;
|
|
9
|
+
private parseRenames;
|
|
10
|
+
private parseSectionsFromContent;
|
|
11
|
+
private getContentUntilNextHeaderFromLines;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=change-parser.d.ts.map
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { MarkdownParser } from './markdown-parser.js';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { promises as fs } from 'fs';
|
|
4
|
+
export class ChangeParser extends MarkdownParser {
|
|
5
|
+
changeDir;
|
|
6
|
+
constructor(content, changeDir) {
|
|
7
|
+
super(content);
|
|
8
|
+
this.changeDir = changeDir;
|
|
9
|
+
}
|
|
10
|
+
async parseChangeWithDeltas(name) {
|
|
11
|
+
const sections = this.parseSections();
|
|
12
|
+
const why = this.findSection(sections, 'Why')?.content || '';
|
|
13
|
+
const whatChanges = this.findSection(sections, 'What Changes')?.content || '';
|
|
14
|
+
if (!why) {
|
|
15
|
+
throw new Error('Change must have a Why section');
|
|
16
|
+
}
|
|
17
|
+
if (!whatChanges) {
|
|
18
|
+
throw new Error('Change must have a What Changes section');
|
|
19
|
+
}
|
|
20
|
+
// Parse deltas from the What Changes section (simple format)
|
|
21
|
+
const simpleDeltas = this.parseDeltas(whatChanges);
|
|
22
|
+
// Check if there are spec files with delta format
|
|
23
|
+
const specsDir = path.join(this.changeDir, 'specs');
|
|
24
|
+
const deltaDeltas = await this.parseDeltaSpecs(specsDir);
|
|
25
|
+
// Combine both types of deltas, preferring delta format if available
|
|
26
|
+
const deltas = deltaDeltas.length > 0 ? deltaDeltas : simpleDeltas;
|
|
27
|
+
return {
|
|
28
|
+
name,
|
|
29
|
+
why: why.trim(),
|
|
30
|
+
whatChanges: whatChanges.trim(),
|
|
31
|
+
deltas,
|
|
32
|
+
metadata: {
|
|
33
|
+
version: '1.0.0',
|
|
34
|
+
format: 'openspec-change',
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
async parseDeltaSpecs(specsDir) {
|
|
39
|
+
const deltas = [];
|
|
40
|
+
try {
|
|
41
|
+
const specDirs = await fs.readdir(specsDir, { withFileTypes: true });
|
|
42
|
+
for (const dir of specDirs) {
|
|
43
|
+
if (!dir.isDirectory())
|
|
44
|
+
continue;
|
|
45
|
+
const specName = dir.name;
|
|
46
|
+
const specFile = path.join(specsDir, specName, 'spec.md');
|
|
47
|
+
try {
|
|
48
|
+
const content = await fs.readFile(specFile, 'utf-8');
|
|
49
|
+
const specDeltas = this.parseSpecDeltas(specName, content);
|
|
50
|
+
deltas.push(...specDeltas);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
// Spec file might not exist, which is okay
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
// Specs directory might not exist, which is okay
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
return deltas;
|
|
63
|
+
}
|
|
64
|
+
parseSpecDeltas(specName, content) {
|
|
65
|
+
const deltas = [];
|
|
66
|
+
const sections = this.parseSectionsFromContent(content);
|
|
67
|
+
// Parse ADDED requirements
|
|
68
|
+
const addedSection = this.findSection(sections, 'ADDED Requirements');
|
|
69
|
+
if (addedSection) {
|
|
70
|
+
const requirements = this.parseRequirements(addedSection);
|
|
71
|
+
requirements.forEach(req => {
|
|
72
|
+
deltas.push({
|
|
73
|
+
spec: specName,
|
|
74
|
+
operation: 'ADDED',
|
|
75
|
+
description: `Add requirement: ${req.text}`,
|
|
76
|
+
// Provide both single and plural forms for compatibility
|
|
77
|
+
requirement: req,
|
|
78
|
+
requirements: [req],
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// Parse MODIFIED requirements
|
|
83
|
+
const modifiedSection = this.findSection(sections, 'MODIFIED Requirements');
|
|
84
|
+
if (modifiedSection) {
|
|
85
|
+
const requirements = this.parseRequirements(modifiedSection);
|
|
86
|
+
requirements.forEach(req => {
|
|
87
|
+
deltas.push({
|
|
88
|
+
spec: specName,
|
|
89
|
+
operation: 'MODIFIED',
|
|
90
|
+
description: `Modify requirement: ${req.text}`,
|
|
91
|
+
requirement: req,
|
|
92
|
+
requirements: [req],
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// Parse REMOVED requirements
|
|
97
|
+
const removedSection = this.findSection(sections, 'REMOVED Requirements');
|
|
98
|
+
if (removedSection) {
|
|
99
|
+
const requirements = this.parseRequirements(removedSection);
|
|
100
|
+
requirements.forEach(req => {
|
|
101
|
+
deltas.push({
|
|
102
|
+
spec: specName,
|
|
103
|
+
operation: 'REMOVED',
|
|
104
|
+
description: `Remove requirement: ${req.text}`,
|
|
105
|
+
requirement: req,
|
|
106
|
+
requirements: [req],
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// Parse RENAMED requirements
|
|
111
|
+
const renamedSection = this.findSection(sections, 'RENAMED Requirements');
|
|
112
|
+
if (renamedSection) {
|
|
113
|
+
const renames = this.parseRenames(renamedSection.content);
|
|
114
|
+
renames.forEach(rename => {
|
|
115
|
+
deltas.push({
|
|
116
|
+
spec: specName,
|
|
117
|
+
operation: 'RENAMED',
|
|
118
|
+
description: `Rename requirement from "${rename.from}" to "${rename.to}"`,
|
|
119
|
+
rename,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return deltas;
|
|
124
|
+
}
|
|
125
|
+
parseRenames(content) {
|
|
126
|
+
const renames = [];
|
|
127
|
+
const lines = content.split('\n');
|
|
128
|
+
let currentRename = {};
|
|
129
|
+
for (const line of lines) {
|
|
130
|
+
const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
|
|
131
|
+
const toMatch = line.match(/^\s*-?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
|
|
132
|
+
if (fromMatch) {
|
|
133
|
+
currentRename.from = fromMatch[1].trim();
|
|
134
|
+
}
|
|
135
|
+
else if (toMatch) {
|
|
136
|
+
currentRename.to = toMatch[1].trim();
|
|
137
|
+
if (currentRename.from && currentRename.to) {
|
|
138
|
+
renames.push({
|
|
139
|
+
from: currentRename.from,
|
|
140
|
+
to: currentRename.to,
|
|
141
|
+
});
|
|
142
|
+
currentRename = {};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return renames;
|
|
147
|
+
}
|
|
148
|
+
parseSectionsFromContent(content) {
|
|
149
|
+
const lines = content.split('\n');
|
|
150
|
+
const sections = [];
|
|
151
|
+
const stack = [];
|
|
152
|
+
for (let i = 0; i < lines.length; i++) {
|
|
153
|
+
const line = lines[i];
|
|
154
|
+
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
155
|
+
if (headerMatch) {
|
|
156
|
+
const level = headerMatch[1].length;
|
|
157
|
+
const title = headerMatch[2].trim();
|
|
158
|
+
const contentLines = this.getContentUntilNextHeaderFromLines(lines, i + 1, level);
|
|
159
|
+
const section = {
|
|
160
|
+
level,
|
|
161
|
+
title,
|
|
162
|
+
content: contentLines.join('\n').trim(),
|
|
163
|
+
children: [],
|
|
164
|
+
};
|
|
165
|
+
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
|
|
166
|
+
stack.pop();
|
|
167
|
+
}
|
|
168
|
+
if (stack.length === 0) {
|
|
169
|
+
sections.push(section);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
stack[stack.length - 1].children.push(section);
|
|
173
|
+
}
|
|
174
|
+
stack.push(section);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return sections;
|
|
178
|
+
}
|
|
179
|
+
getContentUntilNextHeaderFromLines(lines, startLine, currentLevel) {
|
|
180
|
+
const contentLines = [];
|
|
181
|
+
for (let i = startLine; i < lines.length; i++) {
|
|
182
|
+
const line = lines[i];
|
|
183
|
+
const headerMatch = line.match(/^(#{1,6})\s+/);
|
|
184
|
+
if (headerMatch && headerMatch[1].length <= currentLevel) {
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
contentLines.push(line);
|
|
188
|
+
}
|
|
189
|
+
return contentLines;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
//# sourceMappingURL=change-parser.js.map
|