@llman-sdd/cli 0.1.2 → 0.1.4

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 +79 -119
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.4",
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.4",
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
  });
@@ -314,19 +315,26 @@ archive
314
315
  .option('--list', 'list entries already in the cold-backup archive')
315
316
  .action(
316
317
  async (options: { before?: string; keepRecent: string; dryRun?: boolean; list?: boolean }) => {
317
- const sz = await makeWasmSevenZip();
318
- const root = process.cwd();
319
- const io = makeIo(root);
320
- if (options.list) {
321
- for (const line of await runList(io, sz, root)) console.log(line);
322
- return;
318
+ try {
319
+ const sz = await makeWasmSevenZip();
320
+ const root = process.cwd();
321
+ const io = makeIo(root);
322
+ if (options.list) {
323
+ for (const line of await runList(io, sz, root)) console.log(line);
324
+ return;
325
+ }
326
+ const result = await runFreeze(io, sz, root, {
327
+ before: options.before,
328
+ keepRecent: Number(options.keepRecent),
329
+ dryRun: options.dryRun,
330
+ });
331
+ for (const line of result.lines) console.log(line);
332
+ } catch (error) {
333
+ // Emscripten aborts (e.g. wasm load failure) throw raw RuntimeErrors —
334
+ // keep the CLI surface one-line like thaw does.
335
+ console.error((error as Error).message);
336
+ process.exitCode = 1;
323
337
  }
324
- const result = await runFreeze(io, sz, root, {
325
- before: options.before,
326
- keepRecent: Number(options.keepRecent),
327
- dryRun: options.dryRun,
328
- });
329
- for (const line of result.lines) console.log(line);
330
338
  },
331
339
  );
332
340
 
@@ -351,7 +359,7 @@ archive
351
359
  for (const line of result.lines) console.log(line);
352
360
  } catch (error) {
353
361
  console.error((error as Error).message);
354
- process.exit(1);
362
+ process.exitCode = 1;
355
363
  }
356
364
  });
357
365
 
@@ -364,30 +372,24 @@ review
364
372
  .option('--json', 'emit structured JSON (signals + summary)')
365
373
  .option('--export-html <path>', 'write a self-contained HTML report')
366
374
  .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
375
  const config = existsSync('llmanspec/config.yaml')
372
376
  ? loadConfig(readFileSync('llmanspec/config.yaml', 'utf8'))
373
377
  : null;
374
378
  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());
379
+ const io = makeIo(process.cwd());
380
+ const activeChanges = collectChanges(io, process.cwd(), new Date());
380
381
  const result = buildReview(
381
382
  {
382
- entries,
383
- bindings: bindings as TagBinding[],
384
- boundChangeCount: boundCount,
383
+ entries: loadSpecEntries(),
384
+ bindings: bindings.map((b) => ({ kind: 'tags', tags: b.tags })),
385
+ boundChangeCount: activeChanges.filter((c) => c.hasBinding).length,
385
386
  activeChanges,
386
387
  },
387
- makeIo(process.cwd()),
388
+ io,
388
389
  );
389
390
  if (options.exportHtml !== undefined) {
390
- writeFileSync(options.exportHtml, renderReviewHtml(result));
391
+ const template = templateIo.readText(join(TEMPLATES_ROOT, 'shared', 'review.html'));
392
+ writeFileSync(options.exportHtml, renderReviewHtml(template, result));
391
393
  console.log(`wrote ${options.exportHtml}`);
392
394
  }
393
395
  if (options.json) {
@@ -395,45 +397,8 @@ review
395
397
  } else {
396
398
  console.log(result.lines.join('\n'));
397
399
  }
398
- if (result.exitCode !== 0) process.exit(result.exitCode);
399
- });
400
-
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 });
400
+ if (result.exitCode !== 0) process.exitCode = result.exitCode;
429
401
  });
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
402
 
438
403
  const indexCmd = program
439
404
  .command('index')
@@ -443,12 +408,7 @@ indexCmd
443
408
  .command('rebuild')
444
409
  .description('Rebuild the pageindex tree from spec IR (no LLM)')
445
410
  .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, {
411
+ const result = rebuildIndex(makeIo(process.cwd()), 'llmanspec/specs', loadSpecEntries(), {
452
412
  chatModel: process.env.LLMAN_SDD_INDEX_CHAT_MODEL ?? '',
453
413
  });
454
414
  for (const line of result.lines) console.log(line);
@@ -458,9 +418,9 @@ indexCmd
458
418
  .command('check')
459
419
  .description('Check index freshness without rebuilding')
460
420
  .action(() => {
461
- const result = checkIndexFreshness(makeIo(process.cwd()), process.cwd(), 'llmanspec/specs');
421
+ const result = checkIndexFreshness(makeIo(process.cwd()), 'llmanspec/specs');
462
422
  for (const line of result.lines) console.log(line);
463
- if (!result.fresh) process.exit(1);
423
+ if (!result.fresh) process.exitCode = 1;
464
424
  });
465
425
 
466
426
  program
@@ -469,10 +429,11 @@ program
469
429
  .option('--task <task>', 'natural language task description')
470
430
  .option('--paths <paths>', 'comma-separated file paths')
471
431
  .option('--top <n>', 'max entries per tier', '5')
472
- .action((options: { task?: string; paths?: string; top?: string }) => {
432
+ .action(async (options: { task?: string; paths?: string; top?: string }) => {
473
433
  if (!options.task && !options.paths) {
474
434
  console.error('at least one of --task or --paths is required');
475
- process.exit(1);
435
+ process.exitCode = 1;
436
+ return;
476
437
  }
477
438
  const config = resolveChatConfig(process.env as Record<string, string | undefined>);
478
439
  if (config === null) {
@@ -480,14 +441,14 @@ program
480
441
  console.log(JSON.stringify(unavailableResult(), null, 2));
481
442
  return;
482
443
  }
483
- const tree = loadTree(makeIo(process.cwd()), process.cwd());
444
+ const tree = loadTree(makeIo(process.cwd()));
484
445
  if (tree === null) {
485
446
  const missing = unavailableResult();
486
447
  missing.status.qualityNote = 'index missing — run `llman-sdd index rebuild` first';
487
448
  console.log(JSON.stringify(missing, null, 2));
488
449
  return;
489
450
  }
490
- void runContextRetrieval({
451
+ const result = await runContextRetrieval({
491
452
  config,
492
453
  task: options.task ?? '',
493
454
  paths: options.paths,
@@ -495,9 +456,8 @@ program
495
456
  tree,
496
457
  readFile: (p) => readFileSync(p, 'utf8'),
497
458
  root: process.cwd(),
498
- }).then((resolved) => {
499
- console.log(JSON.stringify(resolved, null, 2));
500
459
  });
460
+ console.log(JSON.stringify(result, null, 2));
501
461
  });
502
462
 
503
463
  async function main(): Promise<void> {