@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.
- package/package.json +4 -3
- package/src/io.ts +3 -2
- 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.
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
230
|
+
process.exitCode = 1;
|
|
231
|
+
return;
|
|
227
232
|
}
|
|
228
|
-
const
|
|
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.
|
|
242
|
+
process.exitCode = 1;
|
|
243
|
+
return;
|
|
242
244
|
}
|
|
243
|
-
const io = makeIo(process.cwd());
|
|
244
245
|
const result = showChangeJson(
|
|
245
246
|
{
|
|
246
|
-
io,
|
|
247
|
-
|
|
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.
|
|
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.
|
|
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
|
|
376
|
-
const
|
|
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
|
|
384
|
-
boundChangeCount:
|
|
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
|
-
|
|
381
|
+
io,
|
|
388
382
|
);
|
|
389
383
|
if (options.exportHtml !== undefined) {
|
|
390
|
-
|
|
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.
|
|
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('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
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
|
|
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()),
|
|
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.
|
|
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.
|
|
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())
|
|
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
|
-
|
|
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> {
|