@llman-sdd/cli 0.1.2 → 0.1.3

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.
Files changed (3) hide show
  1. package/package.json +4 -3
  2. package/src/io.ts +3 -2
  3. package/src/main.ts +60 -107
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llman-sdd/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Spec-driven development workflow CLI (provides both `llman-sdd` and `llmanspec` commands)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,10 +28,11 @@
28
28
  "build": "bun run ./scripts/build-binary.ts"
29
29
  },
30
30
  "dependencies": {
31
- "@llman-sdd/core": "0.1.2",
31
+ "@llman-sdd/core": "0.1.3",
32
32
  "commander": "^15.0.0"
33
33
  },
34
34
  "engines": {
35
- "bun": ">=1.4.0"
35
+ "bun": ">=1.4.0",
36
+ "node": ">=24.0.0"
36
37
  }
37
38
  }
package/src/io.ts CHANGED
@@ -55,8 +55,9 @@ export function makeIo(root: string): CliIo {
55
55
  try {
56
56
  process.kill(pid, 0);
57
57
  return true;
58
- } catch {
59
- return false;
58
+ } catch (error) {
59
+ // EPERM = 进程存在但无权发信号;只有 ESRCH 才能判定死亡。
60
+ return (error as NodeJS.ErrnoException).code !== 'ESRCH';
60
61
  }
61
62
  },
62
63
  mtimeMs: (p) => statSync(full(p)).mtimeMs,
package/src/main.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { spawnSync } from 'node:child_process';
3
- import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
 
6
6
  import {
@@ -9,7 +9,10 @@ import {
9
9
  buildReview,
10
10
  collectChanges,
11
11
  collectSpecs,
12
+ discoverSpecs,
13
+ embeddedTemplates,
12
14
  graphMermaid,
15
+ makeEmbeddedTemplateIo,
13
16
  nextReqId,
14
17
  changeDiff,
15
18
  deriveChangeId,
@@ -17,10 +20,10 @@ import {
17
20
  loadConfig,
18
21
  loadTree,
19
22
  newChange,
20
- parseCapability,
21
23
  renderChangesJson,
22
24
  runContextRetrieval,
23
25
  renderChangesList,
26
+ renderReviewHtml,
24
27
  renderSpecsJson,
25
28
  renderSpecsList,
26
29
  runInit,
@@ -34,10 +37,10 @@ import {
34
37
  runList,
35
38
  runThaw,
36
39
  validateAllSpecs,
37
- type TagBinding,
38
- type GitLike,
40
+ TEMPLATES_ROOT,
39
41
  resolveChatConfig,
40
42
  unavailableResult,
43
+ type TemplateIo,
41
44
  } from '@llman-sdd/core';
42
45
  import { Command } from 'commander';
43
46
 
@@ -47,23 +50,25 @@ import { makeCliGit, makeIo } from './io.ts';
47
50
  // package version when running from source.
48
51
  const version = process.env.LLMAN_SDD_VERSION ?? VERSION;
49
52
 
50
- function collectFeatureFiles(dir: string): string[] {
51
- if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
52
- const out: string[] = [];
53
- for (const name of readdirSync(dir).toSorted()) {
54
- const full = join(dir, name);
55
- if (statSync(full).isDirectory()) out.push(...collectFeatureFiles(full));
56
- else if (name.endsWith('.feature')) out.push(full);
57
- }
58
- return out;
53
+ // Compiled single-file binaries have no on-disk templates and Bun <= 1.4.x has
54
+ // no embedding mechanism, so build-binary.ts injects the template table via
55
+ // define; every non-compiled run keeps reading the real filesystem (npm/source/
56
+ // Node). Both init and `review --export-html` resolve through this one seam.
57
+ const embedded = embeddedTemplates();
58
+ const templateIo: TemplateIo = embedded
59
+ ? makeEmbeddedTemplateIo(embedded)
60
+ : {
61
+ exists: (p) => existsSync(p),
62
+ readText: (p) => readFileSync(p, 'utf8'),
63
+ };
64
+
65
+ /** Parse all capability specs under llmanspec/specs via core discovery. */
66
+ function loadSpecEntries(): ReturnType<typeof discoverSpecs> {
67
+ return discoverSpecs('llmanspec/specs', makeIo(process.cwd()));
59
68
  }
60
69
 
61
70
  function runValidateSpecs(options: { specs?: boolean; check: boolean }): number {
62
- const entries = collectFeatureFiles('llmanspec/specs').map((path) => ({
63
- fileName: path,
64
- doc: parseCapability(readFileSync(path, 'utf8'), path),
65
- }));
66
- const report = validateAllSpecs(entries, makeIo(process.cwd()));
71
+ const report = validateAllSpecs(loadSpecEntries(), makeIo(process.cwd()));
67
72
  for (const line of report.lines) console.log(line);
68
73
 
69
74
  let failed = report.failed;
@@ -88,7 +93,7 @@ program
88
93
  .option('--update', 'refresh an existing installation')
89
94
  .option('--locale <locale>', 'locale for generated templates (defaults to config or en)')
90
95
  .action((options: { update?: boolean; locale?: string }) => {
91
- const result = runInit(process.cwd(), {
96
+ const result = runInit(makeIo(process.cwd()), templateIo, {
92
97
  update: options.update ?? false,
93
98
  locale: options.locale,
94
99
  version,
@@ -103,7 +108,8 @@ program
103
108
  .option('--specs', 'validate specs (default and only scope for now)')
104
109
  .option('--no-check', 'skip the bdd.run_command check (structural validation only)')
105
110
  .action((options: { specs?: boolean; check: boolean }) => {
106
- process.exit(runValidateSpecs(options));
111
+ // Node 下管道 stdout 写入异步,process.exit 会截断输出;exitCode 等价且安全。
112
+ process.exitCode = runValidateSpecs(options);
107
113
  });
108
114
 
109
115
  const change = program
@@ -118,7 +124,8 @@ change
118
124
  .action((id: string | undefined, options: { from?: string }) => {
119
125
  if ((id === undefined) === (options.from === undefined)) {
120
126
  console.error('<CHANGE> and --from are mutually exclusive; pass one or the other');
121
- process.exit(1);
127
+ process.exitCode = 1;
128
+ return;
122
129
  }
123
130
  const io = makeIo(process.cwd());
124
131
  const result = newChange(io, { id, from: options.from });
@@ -132,7 +139,7 @@ change
132
139
  .argument('<id>')
133
140
  .option('--branch-prefix <prefix>', 'feature branch prefix', 'sdd/')
134
141
  .action((id: string, options: { branchPrefix: string }) => {
135
- const git: GitLike = makeCliGit(process.cwd());
142
+ const git = makeCliGit(process.cwd());
136
143
  const result = startChange(git, makeIo(process.cwd()), id, {
137
144
  branchPrefix: options.branchPrefix,
138
145
  });
@@ -175,7 +182,8 @@ change
175
182
  .action((id: string, options: { into?: string; method: string }) => {
176
183
  if (options.method !== 'squash' && options.method !== 'ff') {
177
184
  console.error(`invalid --method: ${options.method}`);
178
- process.exit(1);
185
+ process.exitCode = 1;
186
+ return;
179
187
  }
180
188
  const result = finalizeChange(makeCliGit(process.cwd()), makeIo(process.cwd()), id, {
181
189
  into: options.into,
@@ -194,11 +202,7 @@ program
194
202
  .option('--json', 'machine-readable output')
195
203
  .action((options: { specs?: boolean; json?: boolean }) => {
196
204
  if (options.specs) {
197
- const entries = collectFeatureFiles('llmanspec/specs').map((path) => ({
198
- fileName: path,
199
- doc: parseCapability(readFileSync(path, 'utf8'), path),
200
- }));
201
- const summaries = collectSpecs(entries);
205
+ const summaries = collectSpecs(loadSpecEntries());
202
206
  console.log(
203
207
  options.json ? renderSpecsJson(summaries) : renderSpecsList(summaries).join('\n'),
204
208
  );
@@ -223,13 +227,10 @@ program
223
227
  const path = join('llmanspec', 'specs', `${item}.feature`);
224
228
  if (!existsSync(path)) {
225
229
  console.error(`spec not found: ${item}`);
226
- process.exit(1);
230
+ process.exitCode = 1;
231
+ return;
227
232
  }
228
- const entries = collectFeatureFiles('llmanspec/specs').map((specPath) => ({
229
- fileName: specPath,
230
- doc: parseCapability(readFileSync(specPath, 'utf8'), specPath),
231
- }));
232
- const summary = collectSpecs(entries).find((x) => x.id === item);
233
+ const summary = collectSpecs(loadSpecEntries()).find((x) => x.id === item);
233
234
  const morphology = summary
234
235
  ? `\n\n## Morphology\nruleCount=${summary.morphology.ruleCount} enforced=${summary.morphology.ruleEnforcedCount} manual=${summary.morphology.ruleManualCount} pending=${summary.morphology.rulePendingCount} acceptanceCount=${summary.morphology.acceptanceCount}`
235
236
  : '';
@@ -238,16 +239,15 @@ program
238
239
  }
239
240
  if (options.output !== 'json') {
240
241
  console.error('only --output json is supported for changes (text format pending)');
241
- process.exit(1);
242
+ process.exitCode = 1;
243
+ return;
242
244
  }
243
- const io = makeIo(process.cwd());
244
245
  const result = showChangeJson(
245
246
  {
246
- io,
247
- discovery: makeIo(process.cwd()),
247
+ io: makeIo(process.cwd()),
248
+ git: makeCliGit(process.cwd()),
248
249
  root: process.cwd(),
249
250
  specsDir: 'llmanspec/specs',
250
- now: new Date(),
251
251
  },
252
252
  item,
253
253
  );
@@ -261,7 +261,8 @@ program
261
261
  .action((options: { format: string }) => {
262
262
  if (options.format !== 'mermaid') {
263
263
  console.error(`unsupported format: ${options.format}`);
264
- process.exit(1);
264
+ process.exitCode = 1;
265
+ return;
265
266
  }
266
267
  console.log(graphMermaid(makeIo(process.cwd()), process.cwd()).join('\n'));
267
268
  });
@@ -351,7 +352,7 @@ archive
351
352
  for (const line of result.lines) console.log(line);
352
353
  } catch (error) {
353
354
  console.error((error as Error).message);
354
- process.exit(1);
355
+ process.exitCode = 1;
355
356
  }
356
357
  });
357
358
 
@@ -364,30 +365,24 @@ review
364
365
  .option('--json', 'emit structured JSON (signals + summary)')
365
366
  .option('--export-html <path>', 'write a self-contained HTML report')
366
367
  .action((options: { capability?: string; json?: boolean; exportHtml?: string }) => {
367
- const entries = collectFeatureFiles('llmanspec/specs').map((path) => ({
368
- fileName: path,
369
- doc: parseCapability(readFileSync(path, 'utf8'), path),
370
- }));
371
368
  const config = existsSync('llmanspec/config.yaml')
372
369
  ? loadConfig(readFileSync('llmanspec/config.yaml', 'utf8'))
373
370
  : null;
374
371
  const bindings = config?.bdd?.bindings?.filter((b) => b.kind === 'tags') ?? [];
375
- const changeIo = makeIo(process.cwd());
376
- const boundCount = collectChanges(changeIo, process.cwd(), new Date()).filter(
377
- (c) => c.hasBinding,
378
- ).length;
379
- const activeChanges = collectChanges(changeIo, process.cwd(), new Date());
372
+ const io = makeIo(process.cwd());
373
+ const activeChanges = collectChanges(io, process.cwd(), new Date());
380
374
  const result = buildReview(
381
375
  {
382
- entries,
383
- bindings: bindings as TagBinding[],
384
- boundChangeCount: boundCount,
376
+ entries: loadSpecEntries(),
377
+ bindings: bindings.map((b) => ({ kind: 'tags', tags: b.tags })),
378
+ boundChangeCount: activeChanges.filter((c) => c.hasBinding).length,
385
379
  activeChanges,
386
380
  },
387
- makeIo(process.cwd()),
381
+ io,
388
382
  );
389
383
  if (options.exportHtml !== undefined) {
390
- writeFileSync(options.exportHtml, renderReviewHtml(result));
384
+ const template = templateIo.readText(join(TEMPLATES_ROOT, 'shared', 'review.html'));
385
+ writeFileSync(options.exportHtml, renderReviewHtml(template, result));
391
386
  console.log(`wrote ${options.exportHtml}`);
392
387
  }
393
388
  if (options.json) {
@@ -395,46 +390,9 @@ review
395
390
  } else {
396
391
  console.log(result.lines.join('\n'));
397
392
  }
398
- if (result.exitCode !== 0) process.exit(result.exitCode);
393
+ if (result.exitCode !== 0) process.exitCode = result.exitCode;
399
394
  });
400
395
 
401
- /** Self-contained HTML report via the v1 shared/review.html template. */
402
- function renderReviewHtml(result: {
403
- signals: { kind: string; capability: string; count: number; detail: string }[];
404
- summary: { criticalCount: number; warningCount: number };
405
- }): string {
406
- // main.ts sits at <root>/apps/cli/src — three levels up is the repo root.
407
- const templatePath = join(
408
- import.meta.dirname ?? '.',
409
- '..',
410
- '..',
411
- '..',
412
- 'packages',
413
- 'core',
414
- 'templates',
415
- 'shared',
416
- 'review.html',
417
- );
418
- const template = readFileSync(templatePath, 'utf8');
419
- const esc = (input: string): string =>
420
- input.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
421
- let mermaid = 'graph TD\n';
422
- const sigJson: unknown[] = [];
423
- result.signals.forEach((s, idx) => {
424
- const label = esc(
425
- `${s.capability} [${s.kind}] = ${s.count} — ${s.detail === '' ? 'ok' : s.detail}`,
426
- );
427
- mermaid += ` s${idx}["${label}"]\n`;
428
- sigJson.push({ kind: s.kind, capability: s.capability, count: s.count, detail: s.detail });
429
- });
430
- return template
431
- .replaceAll('__CRITICAL__', String(result.summary.criticalCount))
432
- .replaceAll('__WARNING__', String(result.summary.warningCount))
433
- .replaceAll('__SIGNALS__', JSON.stringify(sigJson))
434
- .replaceAll('__MERMAID__', mermaid)
435
- .replaceAll('__GENERATED__', new Date().toISOString());
436
- }
437
-
438
396
  const indexCmd = program
439
397
  .command('index')
440
398
  .description('Index management commands (rebuild, check freshness)');
@@ -443,12 +401,7 @@ indexCmd
443
401
  .command('rebuild')
444
402
  .description('Rebuild the pageindex tree from spec IR (no LLM)')
445
403
  .action(() => {
446
- const indexIo = makeIo(process.cwd());
447
- const entries = collectFeatureFiles('llmanspec/specs').map((path) => ({
448
- fileName: path,
449
- doc: parseCapability(readFileSync(path, 'utf8'), path),
450
- }));
451
- const result = rebuildIndex(indexIo, process.cwd(), 'llmanspec/specs', entries, {
404
+ const result = rebuildIndex(makeIo(process.cwd()), 'llmanspec/specs', loadSpecEntries(), {
452
405
  chatModel: process.env.LLMAN_SDD_INDEX_CHAT_MODEL ?? '',
453
406
  });
454
407
  for (const line of result.lines) console.log(line);
@@ -458,9 +411,9 @@ indexCmd
458
411
  .command('check')
459
412
  .description('Check index freshness without rebuilding')
460
413
  .action(() => {
461
- const result = checkIndexFreshness(makeIo(process.cwd()), process.cwd(), 'llmanspec/specs');
414
+ const result = checkIndexFreshness(makeIo(process.cwd()), 'llmanspec/specs');
462
415
  for (const line of result.lines) console.log(line);
463
- if (!result.fresh) process.exit(1);
416
+ if (!result.fresh) process.exitCode = 1;
464
417
  });
465
418
 
466
419
  program
@@ -469,10 +422,11 @@ program
469
422
  .option('--task <task>', 'natural language task description')
470
423
  .option('--paths <paths>', 'comma-separated file paths')
471
424
  .option('--top <n>', 'max entries per tier', '5')
472
- .action((options: { task?: string; paths?: string; top?: string }) => {
425
+ .action(async (options: { task?: string; paths?: string; top?: string }) => {
473
426
  if (!options.task && !options.paths) {
474
427
  console.error('at least one of --task or --paths is required');
475
- process.exit(1);
428
+ process.exitCode = 1;
429
+ return;
476
430
  }
477
431
  const config = resolveChatConfig(process.env as Record<string, string | undefined>);
478
432
  if (config === null) {
@@ -480,14 +434,14 @@ program
480
434
  console.log(JSON.stringify(unavailableResult(), null, 2));
481
435
  return;
482
436
  }
483
- const tree = loadTree(makeIo(process.cwd()), process.cwd());
437
+ const tree = loadTree(makeIo(process.cwd()));
484
438
  if (tree === null) {
485
439
  const missing = unavailableResult();
486
440
  missing.status.qualityNote = 'index missing — run `llman-sdd index rebuild` first';
487
441
  console.log(JSON.stringify(missing, null, 2));
488
442
  return;
489
443
  }
490
- void runContextRetrieval({
444
+ const result = await runContextRetrieval({
491
445
  config,
492
446
  task: options.task ?? '',
493
447
  paths: options.paths,
@@ -495,9 +449,8 @@ program
495
449
  tree,
496
450
  readFile: (p) => readFileSync(p, 'utf8'),
497
451
  root: process.cwd(),
498
- }).then((resolved) => {
499
- console.log(JSON.stringify(resolved, null, 2));
500
452
  });
453
+ console.log(JSON.stringify(result, null, 2));
501
454
  });
502
455
 
503
456
  async function main(): Promise<void> {