lean-spec 0.1.0 → 0.1.2

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.js CHANGED
@@ -1,1928 +1,189 @@
1
+ import {
2
+ addTemplate,
3
+ archiveSpec,
4
+ backfillTimestamps,
5
+ boardCommand,
6
+ checkSpecs,
7
+ copyTemplate,
8
+ createSpec,
9
+ depsCommand,
10
+ filesCommand,
11
+ ganttCommand,
12
+ initProject,
13
+ listSpecs,
14
+ listTemplates,
15
+ mcpCommand,
16
+ openCommand,
17
+ removeTemplate,
18
+ searchCommand,
19
+ showTemplate,
20
+ statsCommand,
21
+ timelineCommand,
22
+ updateSpec,
23
+ validateCommand,
24
+ viewCommand
25
+ } from "./chunk-OXTU3PN4.js";
26
+ import "./chunk-S4YNQ5KE.js";
27
+
1
28
  // src/cli.ts
2
29
  import { Command } from "commander";
3
30
 
4
- // src/commands/create.ts
5
- import * as fs3 from "fs/promises";
6
- import * as path3 from "path";
7
- import chalk from "chalk";
8
-
9
- // src/config.ts
10
- import * as fs from "fs/promises";
11
- import * as path from "path";
12
- var DEFAULT_CONFIG = {
13
- template: "spec-template.md",
14
- templates: {
15
- default: "spec-template.md"
16
- },
17
- specsDir: "specs",
18
- structure: {
19
- pattern: "{date}/{seq}-{name}/",
20
- dateFormat: "YYYYMMDD",
21
- sequenceDigits: 3,
22
- defaultFile: "README.md"
23
- },
24
- features: {
25
- aiAgents: true,
26
- examples: true
27
- }
28
- };
29
- async function loadConfig(cwd = process.cwd()) {
30
- const configPath = path.join(cwd, ".lspec", "config.json");
31
- try {
32
- const content = await fs.readFile(configPath, "utf-8");
33
- const userConfig = JSON.parse(content);
34
- return { ...DEFAULT_CONFIG, ...userConfig };
35
- } catch {
36
- return DEFAULT_CONFIG;
37
- }
38
- }
39
- async function saveConfig(config, cwd = process.cwd()) {
40
- const configDir = path.join(cwd, ".lspec");
41
- const configPath = path.join(configDir, "config.json");
42
- await fs.mkdir(configDir, { recursive: true });
43
- await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
44
- }
45
- function getToday(format = "YYYYMMDD") {
46
- const now = /* @__PURE__ */ new Date();
47
- const year = now.getFullYear();
48
- const month = String(now.getMonth() + 1).padStart(2, "0");
49
- const day = String(now.getDate()).padStart(2, "0");
50
- switch (format) {
51
- case "YYYYMMDD":
52
- return `${year}${month}${day}`;
53
- case "YYYY-MM-DD":
54
- return `${year}-${month}-${day}`;
55
- case "YYYY/MM":
56
- return `${year}/${month}`;
57
- default:
58
- return `${year}${month}${day}`;
59
- }
60
- }
61
-
62
- // src/utils/path-helpers.ts
63
- import * as fs2 from "fs/promises";
64
- import * as path2 from "path";
65
- async function getNextSeq(dateDir, digits) {
66
- try {
67
- const entries = await fs2.readdir(dateDir, { withFileTypes: true });
68
- const seqNumbers = entries.filter((e) => e.isDirectory() && /^\d{2,3}-.+/.test(e.name)).map((e) => parseInt(e.name.split("-")[0], 10)).filter((n) => !isNaN(n));
69
- if (seqNumbers.length === 0) {
70
- return "1".padStart(digits, "0");
71
- }
72
- const maxSeq = Math.max(...seqNumbers);
73
- return String(maxSeq + 1).padStart(digits, "0");
74
- } catch {
75
- return "1".padStart(digits, "0");
76
- }
77
- }
78
- async function resolveSpecPath(specPath, cwd, specsDir) {
79
- if (path2.isAbsolute(specPath)) {
80
- try {
81
- await fs2.access(specPath);
82
- return specPath;
83
- } catch {
84
- return null;
85
- }
86
- }
87
- const cwdPath = path2.resolve(cwd, specPath);
88
- try {
89
- await fs2.access(cwdPath);
90
- return cwdPath;
91
- } catch {
92
- }
93
- const specsPath = path2.join(specsDir, specPath);
94
- try {
95
- await fs2.access(specsPath);
96
- return specsPath;
97
- } catch {
98
- }
99
- const specName = specPath.replace(/^.*\//, "");
100
- try {
101
- const entries = await fs2.readdir(specsDir, { withFileTypes: true });
102
- const dateDirs = entries.filter((e) => e.isDirectory() && e.name !== "archived");
103
- for (const dateDir of dateDirs) {
104
- const testPath = path2.join(specsDir, dateDir.name, specName);
105
- try {
106
- await fs2.access(testPath);
107
- return testPath;
108
- } catch {
109
- }
110
- }
111
- } catch {
112
- }
113
- return null;
114
- }
115
-
116
- // src/commands/create.ts
117
- async function createSpec(name, options = {}) {
118
- const config = await loadConfig();
119
- const cwd = process.cwd();
120
- const today = getToday(config.structure.dateFormat);
121
- const specsDir = path3.join(cwd, config.specsDir);
122
- const dateDir = path3.join(specsDir, today);
123
- await fs3.mkdir(dateDir, { recursive: true });
124
- const seq = await getNextSeq(dateDir, config.structure.sequenceDigits);
125
- const specDir = path3.join(dateDir, `${seq}-${name}`);
126
- const specFile = path3.join(specDir, config.structure.defaultFile);
127
- try {
128
- await fs3.access(specDir);
129
- console.log(chalk.yellow(`Warning: Spec already exists: ${specDir}`));
130
- process.exit(1);
131
- } catch {
132
- }
133
- await fs3.mkdir(specDir, { recursive: true });
134
- const templatesDir = path3.join(cwd, ".lspec", "templates");
135
- let templateName;
136
- if (options.template) {
137
- if (config.templates?.[options.template]) {
138
- templateName = config.templates[options.template];
139
- } else {
140
- console.error(chalk.red(`Template not found: ${options.template}`));
141
- console.error(chalk.gray(`Available templates: ${Object.keys(config.templates || {}).join(", ")}`));
142
- process.exit(1);
143
- }
144
- } else {
145
- templateName = config.template || "spec-template.md";
146
- }
147
- const templatePath = path3.join(templatesDir, templateName);
148
- let content;
149
- try {
150
- const template = await fs3.readFile(templatePath, "utf-8");
151
- const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
152
- const title = options.title || name;
153
- content = template.replace(/{name}/g, title).replace(/{date}/g, date);
154
- if (options.tags || options.priority || options.assignee) {
155
- const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
156
- if (frontmatterMatch) {
157
- let frontmatter = frontmatterMatch[1];
158
- if (options.tags && options.tags.length > 0) {
159
- frontmatter = frontmatter.replace(/tags: \[\]/, `tags: [${options.tags.join(", ")}]`);
160
- }
161
- if (options.priority) {
162
- frontmatter = frontmatter.replace(/priority: medium/, `priority: ${options.priority}`);
163
- }
164
- if (options.assignee) {
165
- frontmatter = frontmatter.replace(/(priority: \w+)/, `$1
166
- assignee: ${options.assignee}`);
167
- }
168
- content = content.replace(/^---\n[\s\S]*?\n---/, `---
169
- ${frontmatter}
170
- ---`);
171
- }
172
- }
173
- if (options.description) {
174
- content = content.replace(
175
- /## Overview\s+<!-- What are we solving\? Why now\? -->/,
176
- `## Overview
177
-
178
- ${options.description}`
179
- );
180
- }
181
- } catch (error) {
182
- console.error(chalk.red("Error: Template not found!"));
183
- console.error(chalk.gray(`Expected: ${templatePath}`));
184
- console.error(chalk.yellow("Run: lspec init"));
185
- process.exit(1);
186
- }
187
- await fs3.writeFile(specFile, content, "utf-8");
188
- console.log(chalk.green(`\u2713 Created: ${specDir}/`));
189
- console.log(chalk.gray(` Edit: ${specFile}`));
190
- }
191
-
192
- // src/commands/archive.ts
193
- import * as fs4 from "fs/promises";
194
- import * as path4 from "path";
195
- import chalk2 from "chalk";
196
- async function archiveSpec(specPath) {
197
- const config = await loadConfig();
198
- const cwd = process.cwd();
199
- const specsDir = path4.join(cwd, config.specsDir);
200
- const resolvedPath = path4.resolve(specPath);
201
- try {
202
- await fs4.access(resolvedPath);
203
- } catch {
204
- console.error(chalk2.red(`Error: Spec not found: ${specPath}`));
205
- process.exit(1);
206
- }
207
- const parentDir = path4.dirname(resolvedPath);
208
- const dateFolder = path4.basename(parentDir);
209
- const archiveDir = path4.join(specsDir, "archived", dateFolder);
210
- await fs4.mkdir(archiveDir, { recursive: true });
211
- const specName = path4.basename(resolvedPath);
212
- const archivePath = path4.join(archiveDir, specName);
213
- await fs4.rename(resolvedPath, archivePath);
214
- console.log(chalk2.green(`\u2713 Archived: ${archivePath}`));
215
- }
216
-
217
- // src/commands/list.ts
218
- import * as fs7 from "fs/promises";
219
- import * as path7 from "path";
220
- import chalk5 from "chalk";
221
-
222
- // src/utils/ui.ts
223
- import ora from "ora";
224
- import chalk3 from "chalk";
225
- async function withSpinner(text, fn, options) {
226
- const spinner = ora(text).start();
227
- try {
228
- const result = await fn();
229
- spinner.succeed(options?.successText || text);
230
- return result;
231
- } catch (error) {
232
- spinner.fail(options?.failText || `${text} failed`);
233
- throw error;
234
- }
235
- }
236
-
237
- // src/spec-loader.ts
238
- import * as fs6 from "fs/promises";
239
- import * as path6 from "path";
240
-
241
- // src/frontmatter.ts
242
- import * as fs5 from "fs/promises";
243
- import * as path5 from "path";
244
- import matter from "gray-matter";
245
- import dayjs from "dayjs";
246
- async function parseFrontmatter(filePath) {
247
- try {
248
- const content = await fs5.readFile(filePath, "utf-8");
249
- const parsed = matter(content);
250
- if (!parsed.data || Object.keys(parsed.data).length === 0) {
251
- return parseFallbackFields(content);
252
- }
253
- if (!parsed.data.status) {
254
- console.warn(`Warning: Missing required field 'status' in ${filePath}`);
255
- return null;
256
- }
257
- if (!parsed.data.created) {
258
- console.warn(`Warning: Missing required field 'created' in ${filePath}`);
259
- return null;
260
- }
261
- const validStatuses = ["planned", "in-progress", "complete", "archived"];
262
- if (!validStatuses.includes(parsed.data.status)) {
263
- console.warn(`Warning: Invalid status '${parsed.data.status}' in ${filePath}. Valid values: ${validStatuses.join(", ")}`);
264
- }
265
- if (parsed.data.priority) {
266
- const validPriorities = ["low", "medium", "high", "critical"];
267
- if (!validPriorities.includes(parsed.data.priority)) {
268
- console.warn(`Warning: Invalid priority '${parsed.data.priority}' in ${filePath}. Valid values: ${validPriorities.join(", ")}`);
269
- }
270
- }
271
- const knownFields = [
272
- "status",
273
- "created",
274
- "tags",
275
- "priority",
276
- "related",
277
- "depends_on",
278
- "updated",
279
- "completed",
280
- "assignee",
281
- "reviewer",
282
- "issue",
283
- "pr",
284
- "epic",
285
- "breaking"
286
- ];
287
- const unknownFields = Object.keys(parsed.data).filter((k) => !knownFields.includes(k));
288
- if (unknownFields.length > 0) {
289
- console.warn(`Info: Unknown fields in ${filePath}: ${unknownFields.join(", ")}`);
290
- }
291
- return parsed.data;
292
- } catch (error) {
293
- console.error(`Error parsing frontmatter from ${filePath}:`, error);
294
- return null;
295
- }
296
- }
297
- function parseFallbackFields(content) {
298
- const statusMatch = content.match(/\*\*Status\*\*:\s*(?:📅\s*)?(\w+(?:-\w+)?)/i);
299
- const createdMatch = content.match(/\*\*Created\*\*:\s*(\d{4}-\d{2}-\d{2})/);
300
- if (statusMatch && createdMatch) {
301
- const status = statusMatch[1].toLowerCase().replace(/\s+/g, "-");
302
- const created = createdMatch[1];
303
- return {
304
- status,
305
- created
306
- };
307
- }
308
- return null;
309
- }
310
- async function updateFrontmatter(filePath, updates) {
311
- const content = await fs5.readFile(filePath, "utf-8");
312
- const parsed = matter(content);
313
- const newData = { ...parsed.data, ...updates };
314
- if (updates.status === "complete" && !newData.completed) {
315
- newData.completed = dayjs().format("YYYY-MM-DD");
316
- }
317
- if ("updated" in parsed.data) {
318
- newData.updated = dayjs().format("YYYY-MM-DD");
31
+ // src/utils/cli-helpers.ts
32
+ function parseCustomFieldOptions(fieldOptions) {
33
+ const customFields = {};
34
+ if (!fieldOptions) {
35
+ return customFields;
319
36
  }
320
- let updatedContent = parsed.content;
321
- updatedContent = updateVisualMetadata(updatedContent, newData);
322
- const newContent = matter.stringify(updatedContent, newData);
323
- await fs5.writeFile(filePath, newContent, "utf-8");
324
- }
325
- function updateVisualMetadata(content, frontmatter) {
326
- const statusEmoji = getStatusEmojiPlain(frontmatter.status);
327
- const statusLabel = frontmatter.status.charAt(0).toUpperCase() + frontmatter.status.slice(1).replace("-", " ");
328
- const created = dayjs(frontmatter.created).format("YYYY-MM-DD");
329
- let metadataLine = `> **Status**: ${statusEmoji} ${statusLabel}`;
330
- if (frontmatter.priority) {
331
- const priorityLabel = frontmatter.priority.charAt(0).toUpperCase() + frontmatter.priority.slice(1);
332
- metadataLine += ` \xB7 **Priority**: ${priorityLabel}`;
333
- }
334
- metadataLine += ` \xB7 **Created**: ${created}`;
335
- if (frontmatter.tags && frontmatter.tags.length > 0) {
336
- metadataLine += ` \xB7 **Tags**: ${frontmatter.tags.join(", ")}`;
337
- }
338
- let secondLine = "";
339
- if (frontmatter.assignee || frontmatter.reviewer) {
340
- const assignee = frontmatter.assignee || "TBD";
341
- const reviewer = frontmatter.reviewer || "TBD";
342
- secondLine = `
343
- > **Assignee**: ${assignee} \xB7 **Reviewer**: ${reviewer}`;
344
- }
345
- const metadataPattern = /^>\s+\*\*Status\*\*:.*(?:\n>\s+\*\*Assignee\*\*:.*)?/m;
346
- if (metadataPattern.test(content)) {
347
- return content.replace(metadataPattern, metadataLine + secondLine);
348
- } else {
349
- const titleMatch = content.match(/^#\s+.+$/m);
350
- if (titleMatch) {
351
- const insertPos = titleMatch.index + titleMatch[0].length;
352
- return content.slice(0, insertPos) + "\n\n" + metadataLine + secondLine + "\n" + content.slice(insertPos);
37
+ for (const field of fieldOptions) {
38
+ const [key, ...valueParts] = field.split("=");
39
+ if (key && valueParts.length > 0) {
40
+ const value = valueParts.join("=");
41
+ customFields[key.trim()] = value.trim();
353
42
  }
354
43
  }
355
- return content;
356
- }
357
- function getStatusEmojiPlain(status) {
358
- switch (status) {
359
- case "planned":
360
- return "\u{1F4C5}";
361
- case "in-progress":
362
- return "\u{1F528}";
363
- case "complete":
364
- return "\u2705";
365
- case "archived":
366
- return "\u{1F4E6}";
367
- default:
368
- return "\u{1F4C4}";
369
- }
370
- }
371
- async function getSpecFile(specDir, defaultFile = "README.md") {
372
- const specFile = path5.join(specDir, defaultFile);
373
- try {
374
- await fs5.access(specFile);
375
- return specFile;
376
- } catch {
377
- return null;
378
- }
379
- }
380
- function matchesFilter(frontmatter, filter) {
381
- if (filter.status) {
382
- const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
383
- if (!statuses.includes(frontmatter.status)) {
384
- return false;
385
- }
386
- }
387
- if (filter.tags && filter.tags.length > 0) {
388
- if (!frontmatter.tags || frontmatter.tags.length === 0) {
389
- return false;
390
- }
391
- const hasAllTags = filter.tags.every((tag) => frontmatter.tags.includes(tag));
392
- if (!hasAllTags) {
393
- return false;
394
- }
395
- }
396
- if (filter.priority) {
397
- const priorities = Array.isArray(filter.priority) ? filter.priority : [filter.priority];
398
- if (!frontmatter.priority || !priorities.includes(frontmatter.priority)) {
399
- return false;
400
- }
401
- }
402
- if (filter.assignee) {
403
- if (frontmatter.assignee !== filter.assignee) {
404
- return false;
405
- }
406
- }
407
- return true;
408
- }
409
-
410
- // src/spec-loader.ts
411
- async function loadSubFiles(specDir, options = {}) {
412
- const subFiles = [];
413
- try {
414
- const entries = await fs6.readdir(specDir, { withFileTypes: true });
415
- for (const entry of entries) {
416
- if (entry.name === "README.md") continue;
417
- if (entry.isDirectory()) continue;
418
- const filePath = path6.join(specDir, entry.name);
419
- const stat4 = await fs6.stat(filePath);
420
- const ext = path6.extname(entry.name).toLowerCase();
421
- const isDocument = ext === ".md";
422
- const subFile = {
423
- name: entry.name,
424
- path: filePath,
425
- size: stat4.size,
426
- type: isDocument ? "document" : "asset"
427
- };
428
- if (isDocument && options.includeContent) {
429
- subFile.content = await fs6.readFile(filePath, "utf-8");
430
- }
431
- subFiles.push(subFile);
432
- }
433
- } catch (error) {
434
- return [];
435
- }
436
- return subFiles.sort((a, b) => {
437
- if (a.type !== b.type) {
438
- return a.type === "document" ? -1 : 1;
439
- }
440
- return a.name.localeCompare(b.name);
441
- });
442
- }
443
- async function loadAllSpecs(options = {}) {
444
- const config = await loadConfig();
445
- const cwd = process.cwd();
446
- const specsDir = path6.join(cwd, config.specsDir);
447
- const specs = [];
448
- try {
449
- await fs6.access(specsDir);
450
- } catch {
451
- return [];
452
- }
453
- const entries = await fs6.readdir(specsDir, { withFileTypes: true });
454
- const dateDirs = entries.filter((e) => e.isDirectory() && e.name !== "archived").sort((a, b) => b.name.localeCompare(a.name));
455
- for (const dir of dateDirs) {
456
- const dateDir = path6.join(specsDir, dir.name);
457
- const specEntries = await fs6.readdir(dateDir, { withFileTypes: true });
458
- const specDirs = specEntries.filter((s) => s.isDirectory());
459
- for (const spec of specDirs) {
460
- const specDir = path6.join(dateDir, spec.name);
461
- const specFile = await getSpecFile(specDir, config.structure.defaultFile);
462
- if (!specFile) continue;
463
- const frontmatter = await parseFrontmatter(specFile);
464
- if (!frontmatter) continue;
465
- if (options.filter && !matchesFilter(frontmatter, options.filter)) {
466
- continue;
467
- }
468
- const specInfo = {
469
- path: `${dir.name}/${spec.name}`,
470
- fullPath: specDir,
471
- filePath: specFile,
472
- name: spec.name,
473
- date: dir.name,
474
- frontmatter
475
- };
476
- if (options.includeContent) {
477
- specInfo.content = await fs6.readFile(specFile, "utf-8");
478
- }
479
- if (options.includeSubFiles) {
480
- specInfo.subFiles = await loadSubFiles(specDir, {
481
- includeContent: options.includeContent
482
- });
483
- }
484
- specs.push(specInfo);
485
- }
486
- }
487
- if (options.includeArchived) {
488
- const archivedPath = path6.join(specsDir, "archived");
489
- try {
490
- await fs6.access(archivedPath);
491
- const archivedEntries = await fs6.readdir(archivedPath, { withFileTypes: true });
492
- const archivedDirs = archivedEntries.filter((e) => e.isDirectory()).sort((a, b) => b.name.localeCompare(a.name));
493
- for (const dir of archivedDirs) {
494
- const dateDir = path6.join(archivedPath, dir.name);
495
- const specEntries = await fs6.readdir(dateDir, { withFileTypes: true });
496
- const specDirs = specEntries.filter((s) => s.isDirectory());
497
- for (const spec of specDirs) {
498
- const specDir = path6.join(dateDir, spec.name);
499
- const specFile = await getSpecFile(specDir, config.structure.defaultFile);
500
- if (!specFile) continue;
501
- const frontmatter = await parseFrontmatter(specFile);
502
- if (!frontmatter) continue;
503
- if (options.filter && !matchesFilter(frontmatter, options.filter)) {
504
- continue;
505
- }
506
- const specInfo = {
507
- path: `archived/${dir.name}/${spec.name}`,
508
- fullPath: specDir,
509
- filePath: specFile,
510
- name: spec.name,
511
- date: dir.name,
512
- frontmatter
513
- };
514
- if (options.includeContent) {
515
- specInfo.content = await fs6.readFile(specFile, "utf-8");
516
- }
517
- if (options.includeSubFiles) {
518
- specInfo.subFiles = await loadSubFiles(specDir, {
519
- includeContent: options.includeContent
520
- });
521
- }
522
- specs.push(specInfo);
523
- }
524
- }
525
- } catch {
526
- }
527
- }
528
- return specs;
529
- }
530
- async function getSpec(specPath) {
531
- const config = await loadConfig();
532
- const cwd = process.cwd();
533
- const specsDir = path6.join(cwd, config.specsDir);
534
- let fullPath;
535
- if (path6.isAbsolute(specPath)) {
536
- fullPath = specPath;
537
- } else {
538
- fullPath = path6.join(specsDir, specPath);
539
- }
540
- try {
541
- await fs6.access(fullPath);
542
- } catch {
543
- return null;
544
- }
545
- const specFile = await getSpecFile(fullPath, config.structure.defaultFile);
546
- if (!specFile) return null;
547
- const frontmatter = await parseFrontmatter(specFile);
548
- if (!frontmatter) return null;
549
- const content = await fs6.readFile(specFile, "utf-8");
550
- const relativePath = path6.relative(specsDir, fullPath);
551
- const parts = relativePath.split(path6.sep);
552
- const date = parts[0] === "archived" ? parts[1] : parts[0];
553
- const name = parts[parts.length - 1];
554
- return {
555
- path: relativePath,
556
- fullPath,
557
- filePath: specFile,
558
- name,
559
- date,
560
- frontmatter,
561
- content
562
- };
563
- }
564
-
565
- // src/utils/spec-helpers.ts
566
- import chalk4 from "chalk";
567
- function getStatusEmoji(status) {
568
- switch (status) {
569
- case "planned":
570
- return chalk4.gray("\u{1F4C5}");
571
- case "in-progress":
572
- return chalk4.yellow("\u{1F528}");
573
- case "complete":
574
- return chalk4.green("\u2705");
575
- case "archived":
576
- return chalk4.gray("\u{1F4E6}");
577
- default:
578
- return "";
579
- }
580
- }
581
- function getPriorityLabel(priority) {
582
- switch (priority) {
583
- case "low":
584
- return chalk4.gray("low");
585
- case "medium":
586
- return chalk4.blue("med");
587
- case "high":
588
- return chalk4.yellow("high");
589
- case "critical":
590
- return chalk4.red("CRIT");
591
- default:
592
- return "";
593
- }
594
- }
595
-
596
- // src/commands/list.ts
597
- async function listSpecs(options = {}) {
598
- const config = await loadConfig();
599
- const cwd = process.cwd();
600
- const specsDir = path7.join(cwd, config.specsDir);
601
- try {
602
- await fs7.access(specsDir);
603
- } catch {
604
- console.log("");
605
- console.log("No specs directory found. Initialize with: lspec init");
606
- console.log("");
607
- return;
608
- }
609
- const filter = {};
610
- if (options.status) filter.status = options.status;
611
- if (options.tags) filter.tags = options.tags;
612
- if (options.priority) filter.priority = options.priority;
613
- if (options.assignee) filter.assignee = options.assignee;
614
- const specs = await withSpinner(
615
- "Loading specs...",
616
- () => loadAllSpecs({
617
- includeArchived: options.showArchived || false,
618
- filter
619
- })
620
- );
621
- console.log("");
622
- console.log(chalk5.green("=== Specs ==="));
623
- console.log("");
624
- if (specs.length === 0) {
625
- if (Object.keys(filter).length > 0) {
626
- console.log("No specs match the specified filters.");
627
- } else {
628
- console.log("No specs found. Create one with: lspec create <name>");
629
- }
630
- console.log("");
631
- return;
632
- }
633
- const byDate = /* @__PURE__ */ new Map();
634
- for (const spec of specs) {
635
- const dateMatch = spec.path.match(/^(\d{8})\//);
636
- const dateKey = dateMatch ? dateMatch[1] : "unknown";
637
- if (!byDate.has(dateKey)) {
638
- byDate.set(dateKey, []);
639
- }
640
- byDate.get(dateKey).push(spec);
641
- }
642
- const sortedDates = Array.from(byDate.keys()).sort((a, b) => b.localeCompare(a));
643
- for (const date of sortedDates) {
644
- const dateSpecs = byDate.get(date);
645
- console.log(chalk5.cyan(`${date}/`));
646
- for (const spec of dateSpecs) {
647
- const specName = spec.path.replace(/^\d{8}\//, "").replace(/\/$/, "");
648
- let line = ` ${specName}/`;
649
- const meta = [];
650
- meta.push(getStatusEmoji(spec.frontmatter.status));
651
- if (spec.frontmatter.priority) {
652
- meta.push(getPriorityLabel(spec.frontmatter.priority));
653
- }
654
- if (spec.frontmatter.tags && spec.frontmatter.tags.length > 0) {
655
- meta.push(chalk5.gray(`[${spec.frontmatter.tags.join(", ")}]`));
656
- }
657
- if (meta.length > 0) {
658
- line += ` ${meta.join(" ")}`;
659
- }
660
- console.log(line);
661
- }
662
- console.log("");
663
- }
664
- console.log("");
665
- }
666
-
667
- // src/commands/update.ts
668
- import * as path8 from "path";
669
- import chalk6 from "chalk";
670
- async function updateSpec(specPath, updates) {
671
- const config = await loadConfig();
672
- const cwd = process.cwd();
673
- const specsDir = path8.join(cwd, config.specsDir);
674
- const resolvedPath = await resolveSpecPath(specPath, cwd, specsDir);
675
- if (!resolvedPath) {
676
- console.error(chalk6.red(`Error: Spec not found: ${specPath}`));
677
- console.error(chalk6.gray(`Tried: ${specPath}, specs/${specPath}, and searching in date directories`));
678
- process.exit(1);
679
- }
680
- const specFile = await getSpecFile(resolvedPath, config.structure.defaultFile);
681
- if (!specFile) {
682
- console.error(chalk6.red(`Error: No spec file found in: ${specPath}`));
683
- process.exit(1);
684
- }
685
- await updateFrontmatter(specFile, updates);
686
- console.log(chalk6.green(`\u2713 Updated: ${path8.relative(cwd, resolvedPath)}`));
687
- const updatedFields = Object.keys(updates).join(", ");
688
- console.log(chalk6.gray(` Fields: ${updatedFields}`));
689
- }
690
-
691
- // src/commands/templates.ts
692
- import * as fs8 from "fs/promises";
693
- import * as path9 from "path";
694
- import chalk7 from "chalk";
695
- async function listTemplates(cwd = process.cwd()) {
696
- const config = await loadConfig(cwd);
697
- const templatesDir = path9.join(cwd, ".lspec", "templates");
698
- console.log("");
699
- console.log(chalk7.green("=== Project Templates ==="));
700
- console.log("");
701
- try {
702
- await fs8.access(templatesDir);
703
- } catch {
704
- console.log(chalk7.yellow("No templates directory found."));
705
- console.log(chalk7.gray("Run: lspec init"));
706
- console.log("");
707
- return;
708
- }
709
- const files = await fs8.readdir(templatesDir);
710
- const templateFiles = files.filter((f) => f.endsWith(".md"));
711
- if (templateFiles.length === 0) {
712
- console.log(chalk7.yellow("No templates found."));
713
- console.log("");
714
- return;
715
- }
716
- if (config.templates && Object.keys(config.templates).length > 0) {
717
- console.log(chalk7.cyan("Registered:"));
718
- for (const [name, file] of Object.entries(config.templates)) {
719
- const isDefault = config.template === file;
720
- const marker = isDefault ? chalk7.green("\u2713 (default)") : "";
721
- console.log(` ${chalk7.bold(name)}: ${file} ${marker}`);
722
- }
723
- console.log("");
724
- }
725
- console.log(chalk7.cyan("Available files:"));
726
- for (const file of templateFiles) {
727
- const filePath = path9.join(templatesDir, file);
728
- const stat4 = await fs8.stat(filePath);
729
- const sizeKB = (stat4.size / 1024).toFixed(1);
730
- console.log(` ${file} (${sizeKB} KB)`);
731
- }
732
- console.log("");
733
- console.log(chalk7.gray("Use templates with: lspec create <name> --template=<template-name>"));
734
- console.log("");
735
- }
736
- async function showTemplate(templateName, cwd = process.cwd()) {
737
- const config = await loadConfig(cwd);
738
- if (!config.templates?.[templateName]) {
739
- console.error(chalk7.red(`Template not found: ${templateName}`));
740
- console.error(chalk7.gray(`Available: ${Object.keys(config.templates || {}).join(", ")}`));
741
- process.exit(1);
742
- }
743
- const templatesDir = path9.join(cwd, ".lspec", "templates");
744
- const templateFile = config.templates[templateName];
745
- const templatePath = path9.join(templatesDir, templateFile);
746
- try {
747
- const content = await fs8.readFile(templatePath, "utf-8");
748
- console.log("");
749
- console.log(chalk7.cyan(`=== Template: ${templateName} (${templateFile}) ===`));
750
- console.log("");
751
- console.log(content);
752
- console.log("");
753
- } catch (error) {
754
- console.error(chalk7.red(`Error reading template: ${templateFile}`));
755
- console.error(error);
756
- process.exit(1);
757
- }
758
- }
759
- async function addTemplate(name, file, cwd = process.cwd()) {
760
- const config = await loadConfig(cwd);
761
- const templatesDir = path9.join(cwd, ".lspec", "templates");
762
- const templatePath = path9.join(templatesDir, file);
763
- try {
764
- await fs8.access(templatePath);
765
- } catch {
766
- console.error(chalk7.red(`Template file not found: ${file}`));
767
- console.error(chalk7.gray(`Expected at: ${templatePath}`));
768
- console.error(
769
- chalk7.yellow("Create the file first or use: lspec templates copy <source> <target>")
770
- );
771
- process.exit(1);
772
- }
773
- if (!config.templates) {
774
- config.templates = {};
775
- }
776
- if (config.templates[name]) {
777
- console.log(chalk7.yellow(`Warning: Template '${name}' already exists, updating...`));
778
- }
779
- config.templates[name] = file;
780
- await saveConfig(config, cwd);
781
- console.log(chalk7.green(`\u2713 Added template: ${name} \u2192 ${file}`));
782
- console.log(chalk7.gray(` Use with: lspec create <spec-name> --template=${name}`));
783
- }
784
- async function removeTemplate(name, cwd = process.cwd()) {
785
- const config = await loadConfig(cwd);
786
- if (!config.templates?.[name]) {
787
- console.error(chalk7.red(`Template not found: ${name}`));
788
- console.error(chalk7.gray(`Available: ${Object.keys(config.templates || {}).join(", ")}`));
789
- process.exit(1);
790
- }
791
- if (name === "default") {
792
- console.error(chalk7.red("Cannot remove default template"));
793
- process.exit(1);
794
- }
795
- const file = config.templates[name];
796
- delete config.templates[name];
797
- await saveConfig(config, cwd);
798
- console.log(chalk7.green(`\u2713 Removed template: ${name}`));
799
- console.log(chalk7.gray(` Note: Template file ${file} still exists in .lspec/templates/`));
800
- }
801
- async function copyTemplate(source, target, cwd = process.cwd()) {
802
- const config = await loadConfig(cwd);
803
- const templatesDir = path9.join(cwd, ".lspec", "templates");
804
- let sourceFile;
805
- if (config.templates?.[source]) {
806
- sourceFile = config.templates[source];
807
- } else {
808
- sourceFile = source;
809
- }
810
- const sourcePath = path9.join(templatesDir, sourceFile);
811
- try {
812
- await fs8.access(sourcePath);
813
- } catch {
814
- console.error(chalk7.red(`Source template not found: ${source}`));
815
- console.error(chalk7.gray(`Expected at: ${sourcePath}`));
816
- process.exit(1);
817
- }
818
- const targetFile = target.endsWith(".md") ? target : `${target}.md`;
819
- const targetPath = path9.join(templatesDir, targetFile);
820
- await fs8.copyFile(sourcePath, targetPath);
821
- console.log(chalk7.green(`\u2713 Copied: ${sourceFile} \u2192 ${targetFile}`));
822
- if (!config.templates) {
823
- config.templates = {};
824
- }
825
- const templateName = target.replace(/\.md$/, "");
826
- config.templates[templateName] = targetFile;
827
- await saveConfig(config, cwd);
828
- console.log(chalk7.green(`\u2713 Registered template: ${templateName}`));
829
- console.log(chalk7.gray(` Edit: ${targetPath}`));
830
- console.log(chalk7.gray(` Use with: lspec create <spec-name> --template=${templateName}`));
831
- }
832
-
833
- // src/commands/init.ts
834
- import * as fs10 from "fs/promises";
835
- import * as path11 from "path";
836
- import { fileURLToPath } from "url";
837
- import chalk9 from "chalk";
838
- import { select } from "@inquirer/prompts";
839
-
840
- // src/utils/template-helpers.ts
841
- import * as fs9 from "fs/promises";
842
- import * as path10 from "path";
843
- import chalk8 from "chalk";
844
- async function detectExistingSystemPrompts(cwd) {
845
- const commonFiles = [
846
- "AGENTS.md",
847
- ".cursorrules",
848
- ".github/copilot-instructions.md"
849
- ];
850
- const found = [];
851
- for (const file of commonFiles) {
852
- try {
853
- await fs9.access(path10.join(cwd, file));
854
- found.push(file);
855
- } catch {
856
- }
857
- }
858
- return found;
859
- }
860
- async function handleExistingFiles(action, existingFiles, templateDir, cwd, variables = {}) {
861
- for (const file of existingFiles) {
862
- const filePath = path10.join(cwd, file);
863
- const templateFilePath = path10.join(templateDir, "files", file);
864
- try {
865
- await fs9.access(templateFilePath);
866
- } catch {
867
- continue;
868
- }
869
- if (action === "merge" && file === "AGENTS.md") {
870
- const existing = await fs9.readFile(filePath, "utf-8");
871
- let template = await fs9.readFile(templateFilePath, "utf-8");
872
- for (const [key, value] of Object.entries(variables)) {
873
- template = template.replace(new RegExp(`\\{${key}\\}`, "g"), value);
874
- }
875
- const merged = `${existing}
876
-
877
- ---
878
-
879
- ## LeanSpec Integration
880
-
881
- ${template.split("\n").slice(1).join("\n")}`;
882
- await fs9.writeFile(filePath, merged, "utf-8");
883
- console.log(chalk8.green(`\u2713 Merged LeanSpec section into ${file}`));
884
- } else if (action === "backup") {
885
- const backupPath = `${filePath}.backup`;
886
- await fs9.rename(filePath, backupPath);
887
- console.log(chalk8.yellow(`\u2713 Backed up ${file} \u2192 ${file}.backup`));
888
- let content = await fs9.readFile(templateFilePath, "utf-8");
889
- for (const [key, value] of Object.entries(variables)) {
890
- content = content.replace(new RegExp(`\\{${key}\\}`, "g"), value);
891
- }
892
- await fs9.writeFile(filePath, content, "utf-8");
893
- console.log(chalk8.green(`\u2713 Created new ${file}`));
894
- }
895
- }
896
- }
897
- async function copyDirectory(src, dest, skipFiles = [], variables = {}) {
898
- await fs9.mkdir(dest, { recursive: true });
899
- const entries = await fs9.readdir(src, { withFileTypes: true });
900
- for (const entry of entries) {
901
- const srcPath = path10.join(src, entry.name);
902
- const destPath = path10.join(dest, entry.name);
903
- if (skipFiles.includes(entry.name)) {
904
- continue;
905
- }
906
- if (entry.isDirectory()) {
907
- await copyDirectory(srcPath, destPath, skipFiles, variables);
908
- } else {
909
- try {
910
- await fs9.access(destPath);
911
- } catch {
912
- let content = await fs9.readFile(srcPath, "utf-8");
913
- for (const [key, value] of Object.entries(variables)) {
914
- content = content.replace(new RegExp(`\\{${key}\\}`, "g"), value);
915
- }
916
- await fs9.writeFile(destPath, content, "utf-8");
917
- }
918
- }
919
- }
920
- }
921
- async function getProjectName(cwd) {
922
- try {
923
- const packageJsonPath = path10.join(cwd, "package.json");
924
- const content = await fs9.readFile(packageJsonPath, "utf-8");
925
- const pkg = JSON.parse(content);
926
- if (pkg.name) {
927
- return pkg.name;
928
- }
929
- } catch {
930
- }
931
- return path10.basename(cwd);
932
- }
933
-
934
- // src/commands/init.ts
935
- var __dirname2 = path11.dirname(fileURLToPath(import.meta.url));
936
- var TEMPLATES_DIR = path11.join(__dirname2, "..", "templates");
937
- async function initProject() {
938
- const cwd = process.cwd();
939
- try {
940
- await fs10.access(path11.join(cwd, ".lspec", "config.json"));
941
- console.log(chalk9.yellow("LeanSpec already initialized in this directory."));
942
- console.log(chalk9.gray("To reinitialize, delete .lspec/ directory first."));
943
- return;
944
- } catch {
945
- }
946
- console.log("");
947
- console.log(chalk9.green("Welcome to LeanSpec!"));
948
- console.log("");
949
- const setupMode = await select({
950
- message: "How would you like to set up?",
951
- choices: [
952
- {
953
- name: "Quick start (recommended)",
954
- value: "quick",
955
- description: "Use standard template, start immediately"
956
- },
957
- {
958
- name: "Choose template",
959
- value: "template",
960
- description: "Pick from: minimal, standard, enterprise"
961
- },
962
- {
963
- name: "Customize everything",
964
- value: "custom",
965
- description: "Full control over structure and settings"
966
- }
967
- ]
968
- });
969
- let templateName = "standard";
970
- if (setupMode === "template") {
971
- templateName = await select({
972
- message: "Select template:",
973
- choices: [
974
- { name: "minimal", value: "minimal", description: "Just folder structure, no extras" },
975
- { name: "standard", value: "standard", description: "Recommended - includes AGENTS.md" },
976
- {
977
- name: "enterprise",
978
- value: "enterprise",
979
- description: "Governance with approvals and compliance"
980
- }
981
- ]
982
- });
983
- } else if (setupMode === "custom") {
984
- console.log(chalk9.yellow("Full customization coming soon. Using standard for now."));
985
- }
986
- const templateDir = path11.join(TEMPLATES_DIR, templateName);
987
- const templateConfigPath = path11.join(templateDir, "config.json");
988
- let templateConfig;
989
- try {
990
- const content = await fs10.readFile(templateConfigPath, "utf-8");
991
- templateConfig = JSON.parse(content).config;
992
- } catch {
993
- console.error(chalk9.red(`Error: Template not found: ${templateName}`));
994
- process.exit(1);
995
- }
996
- const templatesDir = path11.join(cwd, ".lspec", "templates");
997
- try {
998
- await fs10.mkdir(templatesDir, { recursive: true });
999
- } catch (error) {
1000
- console.error(chalk9.red("Error creating templates directory:"), error);
1001
- process.exit(1);
1002
- }
1003
- const templateSpecPath = path11.join(templateDir, "spec-template.md");
1004
- const targetSpecPath = path11.join(templatesDir, "spec-template.md");
1005
- try {
1006
- await fs10.copyFile(templateSpecPath, targetSpecPath);
1007
- console.log(chalk9.green("\u2713 Created .lspec/templates/spec-template.md"));
1008
- } catch (error) {
1009
- console.error(chalk9.red("Error copying template:"), error);
1010
- process.exit(1);
1011
- }
1012
- templateConfig.template = "spec-template.md";
1013
- templateConfig.templates = {
1014
- default: "spec-template.md"
1015
- };
1016
- await saveConfig(templateConfig, cwd);
1017
- console.log(chalk9.green("\u2713 Created .lspec/config.json"));
1018
- const existingFiles = await detectExistingSystemPrompts(cwd);
1019
- let skipFiles = [];
1020
- if (existingFiles.length > 0) {
1021
- console.log("");
1022
- console.log(chalk9.yellow(`Found existing: ${existingFiles.join(", ")}`));
1023
- const action = await select({
1024
- message: "How would you like to proceed?",
1025
- choices: [
1026
- {
1027
- name: "Merge - Add LeanSpec section to existing files",
1028
- value: "merge",
1029
- description: "Appends LeanSpec guidance to your existing AGENTS.md"
1030
- },
1031
- {
1032
- name: "Backup - Save existing and create new",
1033
- value: "backup",
1034
- description: "Renames existing files to .backup and creates fresh ones"
1035
- },
1036
- {
1037
- name: "Skip - Keep existing files as-is",
1038
- value: "skip",
1039
- description: "Only adds .lspec config and specs/ directory"
1040
- }
1041
- ]
1042
- });
1043
- const projectName2 = await getProjectName(cwd);
1044
- await handleExistingFiles(action, existingFiles, templateDir, cwd, { project_name: projectName2 });
1045
- if (action === "skip") {
1046
- skipFiles = existingFiles;
1047
- }
1048
- }
1049
- const projectName = await getProjectName(cwd);
1050
- const filesDir = path11.join(templateDir, "files");
1051
- try {
1052
- await copyDirectory(filesDir, cwd, skipFiles, { project_name: projectName });
1053
- console.log(chalk9.green("\u2713 Initialized project structure"));
1054
- } catch (error) {
1055
- console.error(chalk9.red("Error copying template files:"), error);
1056
- process.exit(1);
1057
- }
1058
- console.log("");
1059
- console.log(chalk9.green("\u2713 LeanSpec initialized!"));
1060
- console.log("");
1061
- console.log("Next steps:");
1062
- console.log(chalk9.gray(" - Review and customize AGENTS.md"));
1063
- console.log(chalk9.gray(" - Check out example spec in specs/"));
1064
- console.log(chalk9.gray(" - Create your first spec: lspec create my-feature"));
1065
- console.log("");
1066
- }
1067
-
1068
- // src/commands/files.ts
1069
- import * as fs11 from "fs/promises";
1070
- import * as path12 from "path";
1071
- import chalk10 from "chalk";
1072
- async function filesCommand(specPath, options = {}) {
1073
- const config = await loadConfig();
1074
- const cwd = process.cwd();
1075
- const specsDir = path12.join(cwd, config.specsDir);
1076
- const resolvedPath = await resolveSpecPath(specPath, cwd, specsDir);
1077
- if (!resolvedPath) {
1078
- console.error(chalk10.red(`Spec not found: ${specPath}`));
1079
- console.error(
1080
- chalk10.gray("Try using the full path or spec name (e.g., 001-my-spec)")
1081
- );
1082
- process.exit(1);
1083
- }
1084
- const spec = await getSpec(resolvedPath);
1085
- if (!spec) {
1086
- console.error(chalk10.red(`Could not load spec: ${specPath}`));
1087
- process.exit(1);
1088
- }
1089
- const subFiles = await loadSubFiles(spec.fullPath);
1090
- console.log("");
1091
- console.log(chalk10.cyan(`\u{1F4C4} Files in ${spec.name}`));
1092
- console.log("");
1093
- console.log(chalk10.green("Required:"));
1094
- const readmeStat = await fs11.stat(spec.filePath);
1095
- const readmeSize = formatSize(readmeStat.size);
1096
- console.log(chalk10.green(` \u2713 README.md (${readmeSize}) Main spec`));
1097
- console.log("");
1098
- let filteredFiles = subFiles;
1099
- if (options.type === "docs") {
1100
- filteredFiles = subFiles.filter((f) => f.type === "document");
1101
- } else if (options.type === "assets") {
1102
- filteredFiles = subFiles.filter((f) => f.type === "asset");
1103
- }
1104
- if (filteredFiles.length === 0) {
1105
- console.log(chalk10.gray("No additional files"));
1106
- console.log("");
1107
- return;
1108
- }
1109
- const documents = filteredFiles.filter((f) => f.type === "document");
1110
- const assets = filteredFiles.filter((f) => f.type === "asset");
1111
- if (documents.length > 0 && (!options.type || options.type === "docs")) {
1112
- console.log(chalk10.cyan("Documents:"));
1113
- for (const file of documents) {
1114
- const size = formatSize(file.size);
1115
- console.log(chalk10.cyan(` \u2713 ${file.name.padEnd(20)} (${size})`));
1116
- }
1117
- console.log("");
1118
- }
1119
- if (assets.length > 0 && (!options.type || options.type === "assets")) {
1120
- console.log(chalk10.yellow("Assets:"));
1121
- for (const file of assets) {
1122
- const size = formatSize(file.size);
1123
- console.log(chalk10.yellow(` \u2713 ${file.name.padEnd(20)} (${size})`));
1124
- }
1125
- console.log("");
1126
- }
1127
- const totalFiles = filteredFiles.length + 1;
1128
- const totalSize = formatSize(
1129
- readmeStat.size + filteredFiles.reduce((sum, f) => sum + f.size, 0)
1130
- );
1131
- console.log(chalk10.gray(`Total: ${totalFiles} files, ${totalSize}`));
1132
- console.log("");
1133
- }
1134
- function formatSize(bytes) {
1135
- if (bytes < 1024) {
1136
- return `${bytes} B`;
1137
- } else if (bytes < 1024 * 1024) {
1138
- return `${(bytes / 1024).toFixed(1)} KB`;
1139
- } else {
1140
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1141
- }
1142
- }
1143
-
1144
- // src/commands/board.ts
1145
- import React2 from "react";
1146
- import { render } from "ink";
1147
-
1148
- // src/components/Board.tsx
1149
- import React from "react";
1150
- import { Box, Text } from "ink";
1151
- var STATUS_CONFIG = {
1152
- planned: { emoji: "\u{1F4C5}", label: "Planned", color: "gray" },
1153
- "in-progress": { emoji: "\u{1F528}", label: "In Progress", color: "yellow" },
1154
- complete: { emoji: "\u2705", label: "Complete", color: "green" },
1155
- archived: { emoji: "\u{1F4E6}", label: "Archived", color: "gray" }
1156
- };
1157
- var Column = ({ title, emoji, specs, expanded, color }) => {
1158
- const width = 60;
1159
- const count = specs.length;
1160
- const header = `${emoji} ${title} (${count})`;
1161
- const padding = Math.max(0, width - header.length - 4);
1162
- return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, null, "\u250C\u2500 ", header, " ", "\u2500".repeat(padding), "\u2510"), expanded && specs.length > 0 ? specs.map((spec, index) => /* @__PURE__ */ React.createElement(Box, { key: spec.path, flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, null, "\u2502 ", /* @__PURE__ */ React.createElement(Text, { color: "cyan" }, spec.path.padEnd(width - 2)), "\u2502"), (spec.frontmatter.tags?.length || spec.frontmatter.priority || spec.frontmatter.assignee) && (() => {
1163
- const parts = [];
1164
- if (spec.frontmatter.tags?.length) {
1165
- parts.push(`[${spec.frontmatter.tags.join(", ")}]`);
1166
- }
1167
- if (spec.frontmatter.priority) {
1168
- parts.push(`priority: ${spec.frontmatter.priority}`);
1169
- }
1170
- if (spec.frontmatter.assignee) {
1171
- parts.push(`assignee: ${spec.frontmatter.assignee}`);
1172
- }
1173
- const metaText = parts.join(" ");
1174
- const paddingNeeded = Math.max(0, width - 2 - metaText.length);
1175
- return /* @__PURE__ */ React.createElement(Text, null, "\u2502 ", /* @__PURE__ */ React.createElement(Text, { dimColor: true }, metaText), " ".repeat(paddingNeeded), "\u2502");
1176
- })(), index < specs.length - 1 && /* @__PURE__ */ React.createElement(Text, null, "\u2502 ", " ".repeat(width - 2), "\u2502"))) : !expanded && specs.length > 0 ? /* @__PURE__ */ React.createElement(Text, null, "\u2502 ", /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "(collapsed, use --show-complete to expand)"), " ".repeat(Math.max(0, width - 47)), "\u2502") : /* @__PURE__ */ React.createElement(Text, null, "\u2502 ", /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "(no specs)"), " ".repeat(Math.max(0, width - 13)), "\u2502"), /* @__PURE__ */ React.createElement(Text, null, "\u2514", "\u2500".repeat(width), "\u2518"));
1177
- };
1178
- var Board = ({ specs, showComplete, filter }) => {
1179
- const columns = {
1180
- planned: [],
1181
- "in-progress": [],
1182
- complete: [],
1183
- archived: []
1184
- };
1185
- for (const spec of specs) {
1186
- const status = columns[spec.frontmatter.status] !== void 0 ? spec.frontmatter.status : "planned";
1187
- columns[status].push(spec);
1188
- }
1189
- return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column" }, /* @__PURE__ */ React.createElement(Box, { marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, { bold: true, color: "green" }, "\u{1F4CB} Spec Board")), filter && (filter.tag || filter.assignee) && /* @__PURE__ */ React.createElement(Box, { marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "Filtered by: ", filter.tag && `tag=${filter.tag}`, filter.tag && filter.assignee && ", ", filter.assignee && `assignee=${filter.assignee}`)), /* @__PURE__ */ React.createElement(
1190
- Column,
1191
- {
1192
- title: STATUS_CONFIG.planned.label,
1193
- emoji: STATUS_CONFIG.planned.emoji,
1194
- specs: columns.planned,
1195
- expanded: true,
1196
- color: STATUS_CONFIG.planned.color
1197
- }
1198
- ), /* @__PURE__ */ React.createElement(
1199
- Column,
1200
- {
1201
- title: STATUS_CONFIG["in-progress"].label,
1202
- emoji: STATUS_CONFIG["in-progress"].emoji,
1203
- specs: columns["in-progress"],
1204
- expanded: true,
1205
- color: STATUS_CONFIG["in-progress"].color
1206
- }
1207
- ), /* @__PURE__ */ React.createElement(
1208
- Column,
1209
- {
1210
- title: STATUS_CONFIG.complete.label,
1211
- emoji: STATUS_CONFIG.complete.emoji,
1212
- specs: columns.complete,
1213
- expanded: showComplete || false,
1214
- color: STATUS_CONFIG.complete.color
1215
- }
1216
- ));
1217
- };
1218
-
1219
- // src/commands/board.ts
1220
- async function boardCommand(options) {
1221
- const filter = {};
1222
- if (options.tag) {
1223
- filter.tags = [options.tag];
1224
- }
1225
- if (options.assignee) {
1226
- filter.assignee = options.assignee;
1227
- }
1228
- const specs = await withSpinner(
1229
- "Loading specs...",
1230
- () => loadAllSpecs({
1231
- includeArchived: false,
1232
- filter
1233
- })
1234
- );
1235
- if (specs.length === 0) {
1236
- console.log("No specs found.");
1237
- return;
1238
- }
1239
- const filterOptions = {
1240
- tag: options.tag,
1241
- assignee: options.assignee
1242
- };
1243
- render(
1244
- React2.createElement(Board, {
1245
- specs,
1246
- showComplete: options.showComplete,
1247
- filter: options.tag || options.assignee ? filterOptions : void 0
1248
- })
1249
- );
1250
- }
1251
-
1252
- // src/commands/stats.ts
1253
- import React4 from "react";
1254
- import { render as render2 } from "ink";
1255
-
1256
- // src/components/StatsDisplay.tsx
1257
- import React3 from "react";
1258
- import { Box as Box2, Text as Text2 } from "ink";
1259
- var StatsDisplay = ({ specs, filter }) => {
1260
- const statusCounts = {
1261
- planned: 0,
1262
- "in-progress": 0,
1263
- complete: 0,
1264
- archived: 0
1265
- };
1266
- const priorityCounts = {
1267
- low: 0,
1268
- medium: 0,
1269
- high: 0,
1270
- critical: 0
1271
- };
1272
- const tagCounts = {};
1273
- for (const spec of specs) {
1274
- statusCounts[spec.frontmatter.status]++;
1275
- if (spec.frontmatter.priority) {
1276
- priorityCounts[spec.frontmatter.priority]++;
1277
- }
1278
- if (spec.frontmatter.tags) {
1279
- for (const tag of spec.frontmatter.tags) {
1280
- tagCounts[tag] = (tagCounts[tag] || 0) + 1;
1281
- }
1282
- }
1283
- }
1284
- const topTags = Object.entries(tagCounts).sort((a, b) => b[1] - a[1]).slice(0, 5);
1285
- const totalWithPriority = Object.values(priorityCounts).reduce((sum, count) => sum + count, 0);
1286
- return /* @__PURE__ */ React3.createElement(Box2, { flexDirection: "column" }, /* @__PURE__ */ React3.createElement(Box2, { marginBottom: 1 }, /* @__PURE__ */ React3.createElement(Text2, { bold: true, color: "green" }, "\u{1F4CA} Spec Statistics")), filter && (filter.tag || filter.assignee) && /* @__PURE__ */ React3.createElement(Box2, { marginBottom: 1 }, /* @__PURE__ */ React3.createElement(Text2, { dimColor: true }, "Filtered by: ", filter.tag && `tag=${filter.tag}`, filter.tag && filter.assignee && ", ", filter.assignee && `assignee=${filter.assignee}`)), /* @__PURE__ */ React3.createElement(Box2, { flexDirection: "column", marginBottom: 1 }, /* @__PURE__ */ React3.createElement(Text2, { bold: true }, "Status:"), /* @__PURE__ */ React3.createElement(Text2, null, " \u{1F4C5} Planned: ", /* @__PURE__ */ React3.createElement(Text2, { color: "cyan" }, statusCounts.planned.toString().padStart(3))), /* @__PURE__ */ React3.createElement(Text2, null, " \u{1F528} In Progress: ", /* @__PURE__ */ React3.createElement(Text2, { color: "yellow" }, statusCounts["in-progress"].toString().padStart(3))), /* @__PURE__ */ React3.createElement(Text2, null, " \u2705 Complete: ", /* @__PURE__ */ React3.createElement(Text2, { color: "green" }, statusCounts.complete.toString().padStart(3))), /* @__PURE__ */ React3.createElement(Text2, null, " \u{1F4E6} Archived: ", /* @__PURE__ */ React3.createElement(Text2, { dimColor: true }, statusCounts.archived.toString().padStart(3)))), totalWithPriority > 0 && /* @__PURE__ */ React3.createElement(Box2, { flexDirection: "column", marginBottom: 1 }, /* @__PURE__ */ React3.createElement(Text2, { bold: true }, "Priority:"), priorityCounts.critical > 0 && /* @__PURE__ */ React3.createElement(Text2, null, " \u{1F534} Critical: ", /* @__PURE__ */ React3.createElement(Text2, { color: "red" }, priorityCounts.critical.toString().padStart(3))), priorityCounts.high > 0 && /* @__PURE__ */ React3.createElement(Text2, null, " \u{1F7E1} High: ", /* @__PURE__ */ React3.createElement(Text2, { color: "yellow" }, priorityCounts.high.toString().padStart(3))), priorityCounts.medium > 0 && /* @__PURE__ */ React3.createElement(Text2, null, " \u{1F7E0} Medium: ", /* @__PURE__ */ React3.createElement(Text2, { color: "blue" }, priorityCounts.medium.toString().padStart(3))), priorityCounts.low > 0 && /* @__PURE__ */ React3.createElement(Text2, null, " \u{1F7E2} Low: ", /* @__PURE__ */ React3.createElement(Text2, { dimColor: true }, priorityCounts.low.toString().padStart(3)))), topTags.length > 0 && /* @__PURE__ */ React3.createElement(Box2, { flexDirection: "column", marginBottom: 1 }, /* @__PURE__ */ React3.createElement(Text2, { bold: true }, "Tags (top ", topTags.length, "):"), topTags.map(([tag, count]) => /* @__PURE__ */ React3.createElement(Text2, { key: tag }, " ", tag.padEnd(20), " ", /* @__PURE__ */ React3.createElement(Text2, { color: "cyan" }, count.toString().padStart(3))))), /* @__PURE__ */ React3.createElement(Box2, null, /* @__PURE__ */ React3.createElement(Text2, { bold: true }, "Total Specs: ", /* @__PURE__ */ React3.createElement(Text2, { color: "green" }, specs.length.toString()))));
1287
- };
1288
-
1289
- // src/commands/stats.ts
1290
- async function statsCommand(options) {
1291
- const filter = {};
1292
- if (options.tag) {
1293
- filter.tags = [options.tag];
1294
- }
1295
- if (options.assignee) {
1296
- filter.assignee = options.assignee;
1297
- }
1298
- const specs = await withSpinner(
1299
- "Loading specs...",
1300
- () => loadAllSpecs({
1301
- includeArchived: true,
1302
- filter
1303
- })
1304
- );
1305
- if (specs.length === 0) {
1306
- console.log("No specs found.");
1307
- return;
1308
- }
1309
- if (options.json) {
1310
- const statusCounts = {
1311
- planned: 0,
1312
- "in-progress": 0,
1313
- complete: 0,
1314
- archived: 0
1315
- };
1316
- const priorityCounts = {
1317
- low: 0,
1318
- medium: 0,
1319
- high: 0,
1320
- critical: 0
1321
- };
1322
- const tagCounts = {};
1323
- for (const spec of specs) {
1324
- statusCounts[spec.frontmatter.status]++;
1325
- if (spec.frontmatter.priority) {
1326
- priorityCounts[spec.frontmatter.priority]++;
1327
- }
1328
- if (spec.frontmatter.tags) {
1329
- for (const tag of spec.frontmatter.tags) {
1330
- tagCounts[tag] = (tagCounts[tag] || 0) + 1;
1331
- }
1332
- }
1333
- }
1334
- const data = {
1335
- total: specs.length,
1336
- status: statusCounts,
1337
- priority: priorityCounts,
1338
- tags: tagCounts,
1339
- filter
1340
- };
1341
- console.log(JSON.stringify(data, null, 2));
1342
- return;
1343
- }
1344
- const filterOptions = {
1345
- tag: options.tag,
1346
- assignee: options.assignee
1347
- };
1348
- render2(
1349
- React4.createElement(StatsDisplay, {
1350
- specs,
1351
- filter: options.tag || options.assignee ? filterOptions : void 0
1352
- })
1353
- );
1354
- }
1355
-
1356
- // src/commands/search.ts
1357
- import chalk11 from "chalk";
1358
- async function searchCommand(query, options) {
1359
- const filter = {};
1360
- if (options.status) filter.status = options.status;
1361
- if (options.tag) filter.tags = [options.tag];
1362
- if (options.priority) filter.priority = options.priority;
1363
- if (options.assignee) filter.assignee = options.assignee;
1364
- const specs = await withSpinner(
1365
- "Searching specs...",
1366
- () => loadAllSpecs({
1367
- includeArchived: true,
1368
- includeContent: true,
1369
- filter
1370
- })
1371
- );
1372
- if (specs.length === 0) {
1373
- console.log("No specs found matching filters.");
1374
- return;
1375
- }
1376
- const results = [];
1377
- const queryLower = query.toLowerCase();
1378
- for (const spec of specs) {
1379
- if (!spec.content) continue;
1380
- const matches = [];
1381
- const lines = spec.content.split("\n");
1382
- for (let i = 0; i < lines.length; i++) {
1383
- const line = lines[i];
1384
- if (line.toLowerCase().includes(queryLower)) {
1385
- const contextStart = Math.max(0, i - 1);
1386
- const contextEnd = Math.min(lines.length - 1, i + 1);
1387
- const context = lines.slice(contextStart, contextEnd + 1);
1388
- const matchLine = context[i - contextStart];
1389
- const highlighted = highlightMatch(matchLine, query);
1390
- matches.push(highlighted);
1391
- }
1392
- }
1393
- if (matches.length > 0) {
1394
- results.push({ spec, matches });
1395
- }
1396
- }
1397
- if (results.length === 0) {
1398
- console.log("");
1399
- console.log(chalk11.yellow(`\u{1F50D} No specs found matching "${query}"`));
1400
- if (Object.keys(filter).length > 0) {
1401
- const filters = [];
1402
- if (options.status) filters.push(`status=${options.status}`);
1403
- if (options.tag) filters.push(`tag=${options.tag}`);
1404
- if (options.priority) filters.push(`priority=${options.priority}`);
1405
- if (options.assignee) filters.push(`assignee=${options.assignee}`);
1406
- console.log(chalk11.gray(`With filters: ${filters.join(", ")}`));
1407
- }
1408
- console.log("");
1409
- return;
1410
- }
1411
- console.log("");
1412
- console.log(chalk11.green(`\u{1F50D} Found ${results.length} spec${results.length === 1 ? "" : "s"} matching "${query}"`));
1413
- if (Object.keys(filter).length > 0) {
1414
- const filters = [];
1415
- if (options.status) filters.push(`status=${options.status}`);
1416
- if (options.tag) filters.push(`tag=${options.tag}`);
1417
- if (options.priority) filters.push(`priority=${options.priority}`);
1418
- if (options.assignee) filters.push(`assignee=${options.assignee}`);
1419
- console.log(chalk11.gray(`With filters: ${filters.join(", ")}`));
1420
- }
1421
- console.log("");
1422
- for (const result of results) {
1423
- const { spec, matches } = result;
1424
- console.log(chalk11.cyan(`${spec.frontmatter.status === "in-progress" ? "\u{1F528}" : spec.frontmatter.status === "complete" ? "\u2705" : "\u{1F4C5}"} ${spec.path}`));
1425
- const meta = [];
1426
- if (spec.frontmatter.priority) {
1427
- const priorityEmoji = spec.frontmatter.priority === "critical" ? "\u{1F534}" : spec.frontmatter.priority === "high" ? "\u{1F7E1}" : spec.frontmatter.priority === "medium" ? "\u{1F7E0}" : "\u{1F7E2}";
1428
- meta.push(`${priorityEmoji} ${spec.frontmatter.priority}`);
1429
- }
1430
- if (spec.frontmatter.tags && spec.frontmatter.tags.length > 0) {
1431
- meta.push(`[${spec.frontmatter.tags.join(", ")}]`);
1432
- }
1433
- if (meta.length > 0) {
1434
- console.log(chalk11.gray(` ${meta.join(" \u2022 ")}`));
1435
- }
1436
- const maxMatches = 3;
1437
- for (let i = 0; i < Math.min(matches.length, maxMatches); i++) {
1438
- console.log(` ${chalk11.gray("Match:")} ${matches[i].trim()}`);
1439
- }
1440
- if (matches.length > maxMatches) {
1441
- console.log(chalk11.gray(` ... and ${matches.length - maxMatches} more match${matches.length - maxMatches === 1 ? "" : "es"}`));
1442
- }
1443
- console.log("");
1444
- }
1445
- }
1446
- function highlightMatch(text, query) {
1447
- const regex = new RegExp(`(${escapeRegex(query)})`, "gi");
1448
- return text.replace(regex, chalk11.yellow("$1"));
1449
- }
1450
- function escapeRegex(str) {
1451
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1452
- }
1453
-
1454
- // src/commands/deps.ts
1455
- import chalk12 from "chalk";
1456
- async function depsCommand(specPath, options) {
1457
- const spec = await getSpec(specPath);
1458
- if (!spec) {
1459
- console.error(chalk12.red(`Error: Spec not found: ${specPath}`));
1460
- process.exit(1);
1461
- }
1462
- const allSpecs = await loadAllSpecs({ includeArchived: true });
1463
- const specMap = /* @__PURE__ */ new Map();
1464
- for (const s of allSpecs) {
1465
- specMap.set(s.path, s);
1466
- }
1467
- const dependsOn = findDependencies(spec, specMap);
1468
- const blocks = findBlocking(spec, allSpecs);
1469
- const related = findRelated(spec, specMap);
1470
- if (options.json) {
1471
- const data = {
1472
- spec: spec.path,
1473
- dependsOn: dependsOn.map((s) => ({ path: s.path, status: s.frontmatter.status })),
1474
- blocks: blocks.map((s) => ({ path: s.path, status: s.frontmatter.status })),
1475
- related: related.map((s) => ({ path: s.path, status: s.frontmatter.status })),
1476
- chain: buildDependencyChain(spec, specMap, options.depth || 3)
1477
- };
1478
- console.log(JSON.stringify(data, null, 2));
1479
- return;
1480
- }
1481
- console.log("");
1482
- console.log(chalk12.green(`\u{1F4E6} Dependencies for ${chalk12.cyan(spec.path)}`));
1483
- console.log("");
1484
- console.log(chalk12.bold("Depends On:"));
1485
- if (dependsOn.length > 0) {
1486
- for (const dep of dependsOn) {
1487
- const status = getStatusIndicator(dep.frontmatter.status);
1488
- console.log(` \u2192 ${dep.path} ${status}`);
1489
- }
1490
- } else {
1491
- console.log(chalk12.gray(" (none)"));
1492
- }
1493
- console.log("");
1494
- console.log(chalk12.bold("Blocks:"));
1495
- if (blocks.length > 0) {
1496
- for (const blocked of blocks) {
1497
- const status = getStatusIndicator(blocked.frontmatter.status);
1498
- console.log(` \u2190 ${blocked.path} ${status}`);
1499
- }
1500
- } else {
1501
- console.log(chalk12.gray(" (none)"));
1502
- }
1503
- console.log("");
1504
- if (related.length > 0) {
1505
- console.log(chalk12.bold("Related:"));
1506
- for (const rel of related) {
1507
- const status = getStatusIndicator(rel.frontmatter.status);
1508
- console.log(` \u27F7 ${rel.path} ${status}`);
1509
- }
1510
- console.log("");
1511
- }
1512
- if (options.graph || dependsOn.length > 0) {
1513
- console.log(chalk12.bold("Dependency Chain:"));
1514
- const chain = buildDependencyChain(spec, specMap, options.depth || 3);
1515
- displayChain(chain, 0);
1516
- console.log("");
1517
- }
1518
- }
1519
- function findDependencies(spec, specMap) {
1520
- if (!spec.frontmatter.depends_on) return [];
1521
- const deps = [];
1522
- for (const depPath of spec.frontmatter.depends_on) {
1523
- const dep = specMap.get(depPath);
1524
- if (dep) {
1525
- deps.push(dep);
1526
- } else {
1527
- for (const [path13, s] of specMap.entries()) {
1528
- if (path13.includes(depPath)) {
1529
- deps.push(s);
1530
- break;
1531
- }
1532
- }
1533
- }
1534
- }
1535
- return deps;
1536
- }
1537
- function findBlocking(spec, allSpecs) {
1538
- const blocks = [];
1539
- for (const other of allSpecs) {
1540
- if (other.path === spec.path) continue;
1541
- if (other.frontmatter.depends_on) {
1542
- for (const depPath of other.frontmatter.depends_on) {
1543
- if (depPath === spec.path || spec.path.includes(depPath)) {
1544
- blocks.push(other);
1545
- break;
1546
- }
1547
- }
1548
- }
1549
- }
1550
- return blocks;
1551
- }
1552
- function findRelated(spec, specMap) {
1553
- if (!spec.frontmatter.related) return [];
1554
- const related = [];
1555
- for (const relPath of spec.frontmatter.related) {
1556
- const rel = specMap.get(relPath);
1557
- if (rel) {
1558
- related.push(rel);
1559
- } else {
1560
- for (const [path13, s] of specMap.entries()) {
1561
- if (path13.includes(relPath)) {
1562
- related.push(s);
1563
- break;
1564
- }
1565
- }
1566
- }
1567
- }
1568
- return related;
1569
- }
1570
- function buildDependencyChain(spec, specMap, maxDepth, currentDepth = 0, visited = /* @__PURE__ */ new Set()) {
1571
- const node = {
1572
- spec,
1573
- dependencies: []
1574
- };
1575
- if (visited.has(spec.path)) {
1576
- return node;
1577
- }
1578
- visited.add(spec.path);
1579
- if (currentDepth >= maxDepth) {
1580
- return node;
1581
- }
1582
- const deps = findDependencies(spec, specMap);
1583
- for (const dep of deps) {
1584
- node.dependencies.push(buildDependencyChain(dep, specMap, maxDepth, currentDepth + 1, visited));
1585
- }
1586
- return node;
1587
- }
1588
- function displayChain(node, level) {
1589
- const indent = " ".repeat(level);
1590
- const status = getStatusIndicator(node.spec.frontmatter.status);
1591
- const name = level === 0 ? chalk12.cyan(node.spec.path) : node.spec.path;
1592
- console.log(`${indent}${name} ${status}`);
1593
- for (const dep of node.dependencies) {
1594
- const prefix = " ".repeat(level) + "\u2514\u2500 ";
1595
- const depStatus = getStatusIndicator(dep.spec.frontmatter.status);
1596
- console.log(`${prefix}${dep.spec.path} ${depStatus}`);
1597
- for (const nestedDep of dep.dependencies) {
1598
- displayChain(nestedDep, level + 2);
1599
- }
1600
- }
1601
- }
1602
- function getStatusIndicator(status) {
1603
- switch (status) {
1604
- case "planned":
1605
- return chalk12.gray("[planned]");
1606
- case "in-progress":
1607
- return chalk12.yellow("[in-progress]");
1608
- case "complete":
1609
- return chalk12.green("\u2713");
1610
- case "archived":
1611
- return chalk12.gray("[archived]");
1612
- default:
1613
- return "";
1614
- }
1615
- }
1616
-
1617
- // src/commands/timeline.ts
1618
- import chalk13 from "chalk";
1619
- import dayjs2 from "dayjs";
1620
- async function timelineCommand(options) {
1621
- const days = options.days || 30;
1622
- const specs = await loadAllSpecs({
1623
- includeArchived: true
1624
- });
1625
- if (specs.length === 0) {
1626
- console.log("No specs found.");
1627
- return;
1628
- }
1629
- const today = dayjs2();
1630
- const startDate = today.subtract(days, "day");
1631
- const createdByDate = {};
1632
- const completedByDate = {};
1633
- const createdByMonth = {};
1634
- for (const spec of specs) {
1635
- const created = dayjs2(spec.frontmatter.created);
1636
- if (created.isAfter(startDate)) {
1637
- const dateKey = created.format("YYYY-MM-DD");
1638
- createdByDate[dateKey] = (createdByDate[dateKey] || 0) + 1;
1639
- }
1640
- const monthKey = created.format("MMM YYYY");
1641
- createdByMonth[monthKey] = (createdByMonth[monthKey] || 0) + 1;
1642
- if (spec.frontmatter.completed) {
1643
- const completed = dayjs2(spec.frontmatter.completed);
1644
- if (completed.isAfter(startDate)) {
1645
- const dateKey = completed.format("YYYY-MM-DD");
1646
- completedByDate[dateKey] = (completedByDate[dateKey] || 0) + 1;
1647
- }
1648
- }
1649
- }
1650
- console.log("");
1651
- console.log(chalk13.green(`\u{1F4C8} Spec Timeline (Last ${days} Days)`));
1652
- console.log("");
1653
- const allDates = /* @__PURE__ */ new Set([...Object.keys(createdByDate), ...Object.keys(completedByDate)]);
1654
- const sortedDates = Array.from(allDates).sort();
1655
- if (sortedDates.length > 0) {
1656
- for (const date of sortedDates) {
1657
- const created = createdByDate[date] || 0;
1658
- const completed = completedByDate[date] || 0;
1659
- const createdBar = "\u2588".repeat(created);
1660
- const completedBar = "\u2588".repeat(completed);
1661
- let line = `${date} `;
1662
- if (created > 0) {
1663
- line += `${chalk13.blue(createdBar)} ${created} created`;
1664
- }
1665
- if (completed > 0) {
1666
- if (created > 0) line += " ";
1667
- line += `${chalk13.green(completedBar)} ${completed} completed`;
1668
- }
1669
- console.log(line);
1670
- }
1671
- console.log("");
1672
- }
1673
- const sortedMonths = Object.entries(createdByMonth).sort((a, b) => {
1674
- const dateA = dayjs2(a[0], "MMM YYYY");
1675
- const dateB = dayjs2(b[0], "MMM YYYY");
1676
- return dateB.diff(dateA);
1677
- }).slice(0, 6);
1678
- if (sortedMonths.length > 0) {
1679
- console.log(chalk13.bold("Created by Month:"));
1680
- for (const [month, count] of sortedMonths) {
1681
- console.log(` ${month}: ${chalk13.cyan(count.toString())} specs`);
1682
- }
1683
- console.log("");
1684
- }
1685
- const last7Days = specs.filter((s) => {
1686
- if (!s.frontmatter.completed) return false;
1687
- const completed = dayjs2(s.frontmatter.completed);
1688
- return completed.isAfter(today.subtract(7, "day"));
1689
- }).length;
1690
- const last30Days = specs.filter((s) => {
1691
- if (!s.frontmatter.completed) return false;
1692
- const completed = dayjs2(s.frontmatter.completed);
1693
- return completed.isAfter(today.subtract(30, "day"));
1694
- }).length;
1695
- console.log(chalk13.bold("Completion Rate:"));
1696
- console.log(` Last 7 days: ${chalk13.green(last7Days.toString())} specs completed`);
1697
- console.log(` Last 30 days: ${chalk13.green(last30Days.toString())} specs completed`);
1698
- console.log("");
1699
- if (options.byTag) {
1700
- const tagStats = {};
1701
- for (const spec of specs) {
1702
- const created = dayjs2(spec.frontmatter.created);
1703
- const isInRange = created.isAfter(startDate);
1704
- if (isInRange && spec.frontmatter.tags) {
1705
- for (const tag of spec.frontmatter.tags) {
1706
- if (!tagStats[tag]) tagStats[tag] = { created: 0, completed: 0 };
1707
- tagStats[tag].created++;
1708
- if (spec.frontmatter.completed) {
1709
- const completed = dayjs2(spec.frontmatter.completed);
1710
- if (completed.isAfter(startDate)) {
1711
- tagStats[tag].completed++;
1712
- }
1713
- }
1714
- }
1715
- }
1716
- }
1717
- const sortedTags = Object.entries(tagStats).sort((a, b) => b[1].created - a[1].created).slice(0, 10);
1718
- if (sortedTags.length > 0) {
1719
- console.log(chalk13.bold("By Tag:"));
1720
- for (const [tag, stats] of sortedTags) {
1721
- console.log(` ${tag.padEnd(20)} ${chalk13.blue(stats.created.toString())} created, ${chalk13.green(stats.completed.toString())} completed`);
1722
- }
1723
- console.log("");
1724
- }
1725
- }
1726
- if (options.byAssignee) {
1727
- const assigneeStats = {};
1728
- for (const spec of specs) {
1729
- if (!spec.frontmatter.assignee) continue;
1730
- const created = dayjs2(spec.frontmatter.created);
1731
- const isInRange = created.isAfter(startDate);
1732
- if (isInRange) {
1733
- const assignee = spec.frontmatter.assignee;
1734
- if (!assigneeStats[assignee]) assigneeStats[assignee] = { created: 0, completed: 0 };
1735
- assigneeStats[assignee].created++;
1736
- if (spec.frontmatter.completed) {
1737
- const completed = dayjs2(spec.frontmatter.completed);
1738
- if (completed.isAfter(startDate)) {
1739
- assigneeStats[assignee].completed++;
1740
- }
1741
- }
1742
- }
1743
- }
1744
- const sortedAssignees = Object.entries(assigneeStats).sort((a, b) => b[1].created - a[1].created);
1745
- if (sortedAssignees.length > 0) {
1746
- console.log(chalk13.bold("By Assignee:"));
1747
- for (const [assignee, stats] of sortedAssignees) {
1748
- console.log(` ${assignee.padEnd(20)} ${chalk13.blue(stats.created.toString())} created, ${chalk13.green(stats.completed.toString())} completed`);
1749
- }
1750
- console.log("");
1751
- }
1752
- }
1753
- }
1754
-
1755
- // src/commands/gantt.ts
1756
- import chalk14 from "chalk";
1757
- import dayjs3 from "dayjs";
1758
- async function ganttCommand(options) {
1759
- const weeks = options.weeks || 4;
1760
- const specs = await loadAllSpecs({
1761
- includeArchived: false
1762
- });
1763
- if (specs.length === 0) {
1764
- console.log("No specs found.");
1765
- return;
1766
- }
1767
- const relevantSpecs = specs.filter((spec) => {
1768
- if (!options.showComplete && spec.frontmatter.status === "complete") {
1769
- return false;
1770
- }
1771
- return spec.frontmatter.due || spec.frontmatter.depends_on || spec.frontmatter.status === "in-progress" || spec.frontmatter.status === "complete";
1772
- });
1773
- if (relevantSpecs.length === 0) {
1774
- console.log("No specs found with due dates or dependencies.");
1775
- console.log(chalk14.gray('Tip: Add a "due: YYYY-MM-DD" field to frontmatter to use gantt view.'));
1776
- return;
1777
- }
1778
- const today = dayjs3();
1779
- const startDate = today.startOf("week");
1780
- const endDate = startDate.add(weeks, "week");
1781
- console.log("");
1782
- console.log(chalk14.green("\u{1F4C5} Gantt Chart"));
1783
- console.log("");
1784
- const timelineHeader = buildTimelineHeader(startDate, weeks);
1785
- console.log(timelineHeader);
1786
- console.log("|" + "--------|".repeat(weeks));
1787
- console.log("");
1788
- for (const spec of relevantSpecs) {
1789
- displaySpecTimeline(spec, startDate, endDate, weeks, specs);
1790
- console.log("");
1791
- }
1792
- }
1793
- function buildTimelineHeader(startDate, weeks) {
1794
- const dates = [];
1795
- for (let i = 0; i < weeks; i++) {
1796
- const date = startDate.add(i, "week");
1797
- dates.push(date.format("MMM D").padEnd(8));
1798
- }
1799
- return dates.join(" ");
1800
- }
1801
- function displaySpecTimeline(spec, startDate, endDate, weeks, allSpecs) {
1802
- console.log(chalk14.cyan(spec.path));
1803
- if (spec.frontmatter.depends_on && spec.frontmatter.depends_on.length > 0) {
1804
- console.log(chalk14.gray(` \u21B3 depends on: ${spec.frontmatter.depends_on.join(", ")}`));
1805
- }
1806
- const bar = buildTimelineBar(spec, startDate, endDate, weeks);
1807
- console.log(bar);
1808
- const meta = [];
1809
- meta.push(getStatusLabel(spec.frontmatter.status));
1810
- if (spec.frontmatter.due) {
1811
- meta.push(`due: ${spec.frontmatter.due}`);
1812
- }
1813
- console.log(chalk14.gray(` (${meta.join(", ")})`));
1814
- }
1815
- function buildTimelineBar(spec, startDate, endDate, weeks) {
1816
- const charsPerWeek = 8;
1817
- const totalChars = weeks * charsPerWeek;
1818
- const created = dayjs3(spec.frontmatter.created);
1819
- const due = spec.frontmatter.due ? dayjs3(spec.frontmatter.due) : null;
1820
- const completed = spec.frontmatter.completed ? dayjs3(spec.frontmatter.completed) : null;
1821
- let specStart = created;
1822
- let specEnd = due || completed;
1823
- if (!specEnd && spec.frontmatter.status !== "complete") {
1824
- specEnd = created.add(2, "week");
1825
- }
1826
- if (!specEnd) {
1827
- const daysFromStart = created.diff(startDate, "day");
1828
- const position = Math.floor(daysFromStart / 7 * charsPerWeek);
1829
- if (position >= 0 && position < totalChars) {
1830
- const bar2 = " ".repeat(position) + "\u25A0" + " ".repeat(totalChars - position - 1);
1831
- return bar2;
1832
- }
1833
- return " ".repeat(totalChars);
1834
- }
1835
- const startDaysFromStart = specStart.diff(startDate, "day");
1836
- const endDaysFromStart = specEnd.diff(startDate, "day");
1837
- const startPos = Math.floor(startDaysFromStart / 7 * charsPerWeek);
1838
- const endPos = Math.floor(endDaysFromStart / 7 * charsPerWeek);
1839
- const barStart = Math.max(0, startPos);
1840
- const barEnd = Math.min(totalChars, endPos);
1841
- const barLength = Math.max(1, barEnd - barStart);
1842
- let fillChar = "\u25A0";
1843
- let emptyChar = "\u25A1";
1844
- let color = chalk14.blue;
1845
- if (spec.frontmatter.status === "complete") {
1846
- fillChar = "\u25A0";
1847
- color = chalk14.green;
1848
- } else if (spec.frontmatter.status === "in-progress") {
1849
- fillChar = "\u25A0";
1850
- emptyChar = "\u25A1";
1851
- color = chalk14.yellow;
1852
- const halfLength = Math.floor(barLength / 2);
1853
- const filled = fillChar.repeat(halfLength);
1854
- const empty = emptyChar.repeat(barLength - halfLength);
1855
- const bar2 = " ".repeat(barStart) + color(filled + empty) + " ".repeat(Math.max(0, totalChars - barEnd));
1856
- return bar2;
1857
- } else {
1858
- fillChar = "\u25A1";
1859
- color = chalk14.gray;
1860
- }
1861
- const bar = " ".repeat(barStart) + color(fillChar.repeat(barLength)) + " ".repeat(Math.max(0, totalChars - barEnd));
1862
- return bar;
1863
- }
1864
- function getStatusLabel(status) {
1865
- switch (status) {
1866
- case "planned":
1867
- return "planned";
1868
- case "in-progress":
1869
- return "in-progress";
1870
- case "complete":
1871
- return "complete";
1872
- case "archived":
1873
- return "archived";
1874
- default:
1875
- return status;
1876
- }
44
+ return customFields;
1877
45
  }
1878
46
 
1879
47
  // src/cli.ts
1880
48
  var program = new Command();
1881
- program.name("lspec").description("Manage LeanSpec documents").version("0.1.0");
1882
- program.command("init").description("Initialize LeanSpec in current directory").action(async () => {
1883
- await initProject();
49
+ program.name("lean-spec").description("Manage LeanSpec documents").version("0.1.2");
50
+ program.addHelpText("after", `
51
+ Command Groups:
52
+
53
+ Core Commands:
54
+ init Initialize LeanSpec in current directory
55
+ create <name> Create new spec in folder structure
56
+ list List all specs
57
+ update <spec> Update spec metadata
58
+ archive <spec> Move spec to archived/
59
+ backfill [specs...] Backfill timestamps from git history
60
+
61
+ Viewing & Navigation:
62
+ view <spec> View spec content
63
+ open <spec> Open spec in editor
64
+ search <query> Full-text search with metadata filters
65
+ files <spec> List files in a spec
66
+
67
+ Project & Analytics:
68
+ board Show Kanban-style board view
69
+ stats Show aggregate statistics
70
+ timeline Show creation/completion over time
71
+ gantt Show timeline with dependencies
72
+ deps <spec> Show dependency graph for a spec
73
+
74
+ Maintenance:
75
+ check Check for sequence conflicts
76
+ validate [specs...] Validate specs for quality issues
77
+ templates Manage spec templates
78
+
79
+ Server:
80
+ mcp Start MCP server for AI assistants
81
+
82
+ Examples:
83
+ $ lean-spec init
84
+ $ lean-spec create my-feature --priority high
85
+ $ lean-spec list --status in-progress
86
+ $ lean-spec view 042
87
+ $ lean-spec backfill --dry-run
88
+ $ lean-spec board --tag backend
89
+ $ lean-spec search "authentication"
90
+ $ lean-spec validate
91
+ $ lean-spec validate --verbose
92
+ $ lean-spec validate --quiet --rule max-lines
93
+ $ lean-spec validate 018 --max-lines 500
94
+ `);
95
+ program.command("archive <spec>").description("Move spec to archived/").action(async (specPath) => {
96
+ await archiveSpec(specPath);
97
+ });
98
+ program.command("backfill [specs...]").description("Backfill timestamps from git history").option("--dry-run", "Show what would be updated without making changes").option("--force", "Overwrite existing timestamp values").option("--assignee", "Include assignee from first commit author").option("--transitions", "Include full status transition history").option("--all", "Include all optional fields (assignee + transitions)").action(async (specs, options) => {
99
+ await backfillTimestamps({
100
+ dryRun: options.dryRun,
101
+ force: options.force,
102
+ includeAssignee: options.assignee || options.all,
103
+ includeTransitions: options.transitions || options.all,
104
+ specs: specs && specs.length > 0 ? specs : void 0
105
+ });
1884
106
  });
1885
- program.command("create <name>").description("Create new spec in folder structure").option("--title <title>", "Set custom title").option("--description <desc>", "Set initial description").option("--tags <tags>", "Set tags (comma-separated)").option("--priority <priority>", "Set priority (low, medium, high, critical)").option("--assignee <name>", "Set assignee").option("--template <template>", "Use a specific template").action(async (name, options) => {
107
+ program.command("board").description("Show Kanban-style board view with project completion summary").option("--complete", "Include complete specs (default: hidden)").option("--simple", "Hide completion summary (kanban only)").option("--completion-only", "Show only completion summary (no kanban)").option("--tag <tag>", "Filter by tag").option("--assignee <name>", "Filter by assignee").action(async (options) => {
108
+ await boardCommand(options);
109
+ });
110
+ program.command("check").description("Check for sequence conflicts").option("-q, --quiet", "Brief output").action(async (options) => {
111
+ const hasNoConflicts = await checkSpecs(options);
112
+ process.exit(hasNoConflicts ? 0 : 1);
113
+ });
114
+ program.command("validate [specs...]").description("Validate specs for quality issues").option("--max-lines <number>", "Custom line limit (default: 400)", parseInt).option("--verbose", "Show passing specs").option("--quiet", "Suppress warnings, only show errors").option("--format <format>", "Output format: default, json, compact", "default").option("--rule <rule>", "Filter by specific rule name (e.g., max-lines, frontmatter)").action(async (specs, options) => {
115
+ const passed = await validateCommand({
116
+ maxLines: options.maxLines,
117
+ specs: specs && specs.length > 0 ? specs : void 0,
118
+ verbose: options.verbose,
119
+ quiet: options.quiet,
120
+ format: options.format,
121
+ rule: options.rule
122
+ });
123
+ process.exit(passed ? 0 : 1);
124
+ });
125
+ program.command("create <name>").description("Create new spec in folder structure").option("--title <title>", "Set custom title").option("--description <desc>", "Set initial description").option("--tags <tags>", "Set tags (comma-separated)").option("--priority <priority>", "Set priority (low, medium, high, critical)").option("--assignee <name>", "Set assignee").option("--template <template>", "Use a specific template").option("--field <name=value...>", "Set custom field (can specify multiple)").option("--no-prefix", "Skip date prefix even if configured").action(async (name, options) => {
126
+ const customFields = parseCustomFieldOptions(options.field);
1886
127
  const createOptions = {
1887
128
  title: options.title,
1888
129
  description: options.description,
1889
130
  tags: options.tags ? options.tags.split(",").map((t) => t.trim()) : void 0,
1890
131
  priority: options.priority,
1891
132
  assignee: options.assignee,
1892
- template: options.template
133
+ template: options.template,
134
+ customFields: Object.keys(customFields).length > 0 ? customFields : void 0,
135
+ noPrefix: options.prefix === false
1893
136
  };
1894
137
  await createSpec(name, createOptions);
1895
138
  });
1896
- program.command("archive <spec-path>").description("Move spec to archived/").action(async (specPath) => {
1897
- await archiveSpec(specPath);
139
+ program.command("deps <spec>").description("Show dependency graph for a spec. Related specs (\u27F7) are shown bidirectionally, depends_on (\u2192) are directional.").option("--depth <n>", "Show N levels deep (default: 3)", parseInt).option("--graph", "ASCII graph visualization").option("--json", "Output as JSON").action(async (specPath, options) => {
140
+ await depsCommand(specPath, options);
141
+ });
142
+ program.command("files <spec>").description("List files in a spec").option("--type <type>", "Filter by type: docs, assets").option("--tree", "Show tree structure").action(async (specPath, options) => {
143
+ await filesCommand(specPath, options);
144
+ });
145
+ program.command("gantt").description("Show timeline with dependencies").option("--weeks <n>", "Show N weeks (default: 4)", parseInt).option("--show-complete", "Include completed specs").option("--critical-path", "Highlight critical path").action(async (options) => {
146
+ await ganttCommand(options);
147
+ });
148
+ program.command("init").description("Initialize LeanSpec in current directory").action(async () => {
149
+ await initProject();
1898
150
  });
1899
- program.command("list").description("List all specs").option("--archived", "Include archived specs").option("--status <status>", "Filter by status (planned, in-progress, complete, archived)").option("--tag <tag...>", "Filter by tag (can specify multiple)").option("--priority <priority>", "Filter by priority (low, medium, high, critical)").option("--assignee <name>", "Filter by assignee").action(async (options) => {
151
+ program.command("list").description("List all specs").option("--archived", "Include archived specs").option("--status <status>", "Filter by status (planned, in-progress, complete, archived)").option("--tag <tag...>", "Filter by tag (can specify multiple)").option("--priority <priority>", "Filter by priority (low, medium, high, critical)").option("--assignee <name>", "Filter by assignee").option("--field <name=value...>", "Filter by custom field (can specify multiple)").option("--sort <field>", "Sort by field (id, created, name, status, priority)", "id").option("--order <order>", "Sort order (asc, desc)", "desc").action(async (options) => {
152
+ const customFields = parseCustomFieldOptions(options.field);
1900
153
  const listOptions = {
1901
154
  showArchived: options.archived,
1902
155
  status: options.status,
1903
156
  tags: options.tag,
1904
157
  priority: options.priority,
1905
- assignee: options.assignee
158
+ assignee: options.assignee,
159
+ customFields: Object.keys(customFields).length > 0 ? customFields : void 0,
160
+ sortBy: options.sort || "id",
161
+ sortOrder: options.order || "desc"
1906
162
  };
1907
163
  await listSpecs(listOptions);
1908
164
  });
1909
- program.command("update <spec-path>").description("Update spec metadata").option("--status <status>", "Set status (planned, in-progress, complete, archived)").option("--priority <priority>", "Set priority (low, medium, high, critical)").option("--tags <tags>", "Set tags (comma-separated)").option("--assignee <name>", "Set assignee").action(async (specPath, options) => {
1910
- const updates = {
165
+ program.command("open <spec>").description("Open spec in editor").option("--editor <editor>", "Specify editor command").action(async (specPath, options) => {
166
+ try {
167
+ await openCommand(specPath, {
168
+ editor: options.editor
169
+ });
170
+ } catch (error) {
171
+ console.error("\x1B[31mError:\x1B[0m", error instanceof Error ? error.message : String(error));
172
+ process.exit(1);
173
+ }
174
+ });
175
+ program.command("search <query>").description("Full-text search with metadata filters").option("--status <status>", "Filter by status").option("--tag <tag>", "Filter by tag").option("--priority <priority>", "Filter by priority").option("--assignee <name>", "Filter by assignee").option("--field <name=value...>", "Filter by custom field (can specify multiple)").action(async (query, options) => {
176
+ const customFields = parseCustomFieldOptions(options.field);
177
+ await searchCommand(query, {
1911
178
  status: options.status,
179
+ tag: options.tag,
1912
180
  priority: options.priority,
1913
- tags: options.tags ? options.tags.split(",").map((t) => t.trim()) : void 0,
1914
- assignee: options.assignee
1915
- };
1916
- Object.keys(updates).forEach((key) => {
1917
- if (updates[key] === void 0) {
1918
- delete updates[key];
1919
- }
181
+ assignee: options.assignee,
182
+ customFields: Object.keys(customFields).length > 0 ? customFields : void 0
1920
183
  });
1921
- if (Object.keys(updates).length === 0) {
1922
- console.error("Error: At least one update option required (--status, --priority, --tags, --assignee)");
1923
- process.exit(1);
1924
- }
1925
- await updateSpec(specPath, updates);
184
+ });
185
+ program.command("stats").description("Show aggregate statistics (default: simplified view)").option("--tag <tag>", "Filter by tag").option("--assignee <name>", "Filter by assignee").option("--full", "Show full detailed analytics (all sections)").option("--timeline", "Show only timeline section").option("--velocity", "Show only velocity section").option("--json", "Output as JSON").action(async (options) => {
186
+ await statsCommand(options);
1926
187
  });
1927
188
  var templatesCmd = program.command("templates").description("Manage spec templates");
1928
189
  templatesCmd.command("list").description("List available templates").action(async () => {
@@ -1943,26 +204,43 @@ templatesCmd.command("copy <source> <target>").description("Copy a template to c
1943
204
  templatesCmd.action(async () => {
1944
205
  await listTemplates();
1945
206
  });
1946
- program.command("stats").description("Show aggregate statistics").option("--tag <tag>", "Filter by tag").option("--assignee <name>", "Filter by assignee").option("--json", "Output as JSON").action(async (options) => {
1947
- await statsCommand(options);
1948
- });
1949
- program.command("board").description("Show Kanban-style board view").option("--show-complete", "Expand complete column").option("--tag <tag>", "Filter by tag").option("--assignee <name>", "Filter by assignee").action(async (options) => {
1950
- await boardCommand(options);
1951
- });
1952
207
  program.command("timeline").description("Show creation/completion over time").option("--days <n>", "Show last N days (default: 30)", parseInt).option("--by-tag", "Group by tag").option("--by-assignee", "Group by assignee").action(async (options) => {
1953
208
  await timelineCommand(options);
1954
209
  });
1955
- program.command("deps <spec-path>").description("Show dependency graph for a spec").option("--depth <n>", "Show N levels deep (default: 3)", parseInt).option("--graph", "ASCII graph visualization").option("--json", "Output as JSON").action(async (specPath, options) => {
1956
- await depsCommand(specPath, options);
1957
- });
1958
- program.command("search <query>").description("Full-text search with metadata filters").option("--status <status>", "Filter by status").option("--tag <tag>", "Filter by tag").option("--priority <priority>", "Filter by priority").option("--assignee <name>", "Filter by assignee").action(async (query, options) => {
1959
- await searchCommand(query, options);
210
+ program.command("update <spec>").description("Update spec metadata").option("--status <status>", "Set status (planned, in-progress, complete, archived)").option("--priority <priority>", "Set priority (low, medium, high, critical)").option("--tags <tags>", "Set tags (comma-separated)").option("--assignee <name>", "Set assignee").option("--field <name=value...>", "Set custom field (can specify multiple)").action(async (specPath, options) => {
211
+ const customFields = parseCustomFieldOptions(options.field);
212
+ const updates = {
213
+ status: options.status,
214
+ priority: options.priority,
215
+ tags: options.tags ? options.tags.split(",").map((t) => t.trim()) : void 0,
216
+ assignee: options.assignee,
217
+ customFields: Object.keys(customFields).length > 0 ? customFields : void 0
218
+ };
219
+ Object.keys(updates).forEach((key) => {
220
+ if (updates[key] === void 0) {
221
+ delete updates[key];
222
+ }
223
+ });
224
+ if (Object.keys(updates).length === 0) {
225
+ console.error("Error: At least one update option required (--status, --priority, --tags, --assignee, --field)");
226
+ process.exit(1);
227
+ }
228
+ await updateSpec(specPath, updates);
1960
229
  });
1961
- program.command("files <spec-path>").description("List files in a spec").option("--type <type>", "Filter by type: docs, assets").option("--tree", "Show tree structure").action(async (specPath, options) => {
1962
- await filesCommand(specPath, options);
230
+ program.command("view <spec>").description('View spec content (supports sub-specs like "045/DESIGN.md")').option("--raw", "Output raw markdown (for piping/scripting)").option("--json", "Output as JSON").option("--no-color", "Disable colors").action(async (specPath, options) => {
231
+ try {
232
+ await viewCommand(specPath, {
233
+ raw: options.raw,
234
+ json: options.json,
235
+ noColor: options.color === false
236
+ });
237
+ } catch (error) {
238
+ console.error("\x1B[31mError:\x1B[0m", error instanceof Error ? error.message : String(error));
239
+ process.exit(1);
240
+ }
1963
241
  });
1964
- program.command("gantt").description("Show timeline with dependencies").option("--weeks <n>", "Show N weeks (default: 4)", parseInt).option("--show-complete", "Include completed specs").option("--critical-path", "Highlight critical path").action(async (options) => {
1965
- await ganttCommand(options);
242
+ program.command("mcp").description("Start MCP server for AI assistants (Claude Desktop, Cline, etc.)").action(async () => {
243
+ await mcpCommand();
1966
244
  });
1967
245
  program.parse();
1968
246
  //# sourceMappingURL=cli.js.map