@llman-sdd/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +37 -0
  2. package/src/io.ts +70 -0
  3. package/src/main.ts +507 -0
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@llman-sdd/cli",
3
+ "version": "0.1.0",
4
+ "description": "Spec-driven development workflow CLI",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+ssh://git@github.com/StrayDragon/llman-sdd.git",
9
+ "directory": "apps/cli"
10
+ },
11
+ "bin": {
12
+ "llman-sdd": "./src/main.ts"
13
+ },
14
+ "files": [
15
+ "src"
16
+ ],
17
+ "type": "module",
18
+ "exports": {
19
+ ".": "./src/main.ts"
20
+ },
21
+ "scripts": {
22
+ "lint": "bun run --cwd ../.. lint",
23
+ "lint:fix": "bun run --cwd ../.. lint:fix",
24
+ "format:check": "bun run --cwd ../.. format:check",
25
+ "format:write": "bun run --cwd ../.. format:write",
26
+ "typecheck": "bun run --cwd ../.. typecheck",
27
+ "build": "bun run ./scripts/build-binary.ts"
28
+ },
29
+ "dependencies": {
30
+ "@llman-sdd/core": "0.1.0",
31
+ "commander": "^15.0.0"
32
+ },
33
+ "engines": {
34
+ "bun": ">=1.4.0",
35
+ "node": ">=24.0.0"
36
+ }
37
+ }
package/src/io.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Single CLI-side IO adapter: one root-relative CliIo object over node:fs
3
+ * that structurally satisfies every core port (FsIo, DiscoveryIo, IndexIo,
4
+ * ChangeFsIo / GraphFsIo / ShowFsIo, HashIo, FreezeIo). Relative paths resolve
5
+ * against root; absolute paths pass through untouched.
6
+ */
7
+ import {
8
+ existsSync,
9
+ mkdirSync,
10
+ readdirSync,
11
+ readFileSync,
12
+ renameSync,
13
+ rmSync,
14
+ statSync,
15
+ writeFileSync,
16
+ } from 'node:fs';
17
+ import { isAbsolute, join } from 'node:path';
18
+
19
+ import { makeSpawnGit, type GitLike } from '@llman-sdd/core';
20
+
21
+ export interface CliIo {
22
+ exists(path: string): boolean;
23
+ readText(path: string): string;
24
+ writeText(path: string, content: string): void;
25
+ rename(from: string, to: string): void;
26
+ listDir(path: string): string[];
27
+ isDirectory(path: string): boolean;
28
+ remove(path: string): void;
29
+ mkdirp(path: string): void;
30
+ processAlive(pid: number): boolean;
31
+ mtimeMs(path: string): number;
32
+ removeDir(path: string): void;
33
+ moveDir(from: string, to: string): void;
34
+ }
35
+
36
+ export function makeIo(root: string): CliIo {
37
+ const full = (p: string): string => (isAbsolute(p) ? p : join(root, p));
38
+ const parentOf = (p: string): string => p.slice(0, p.lastIndexOf('/')) || '.';
39
+ return {
40
+ exists: (p) => existsSync(full(p)),
41
+ readText: (p) => readFileSync(full(p), 'utf8'),
42
+ writeText: (p, content) => {
43
+ mkdirSync(parentOf(full(p)), { recursive: true });
44
+ writeFileSync(full(p), content);
45
+ },
46
+ rename: (from, to) => {
47
+ mkdirSync(parentOf(full(to)), { recursive: true });
48
+ renameSync(full(from), full(to));
49
+ },
50
+ listDir: (p) => readdirSync(full(p)),
51
+ isDirectory: (p) => existsSync(full(p)) && statSync(full(p)).isDirectory(),
52
+ remove: (p) => rmSync(full(p), { force: true }),
53
+ mkdirp: (p) => mkdirSync(full(p), { recursive: true }),
54
+ processAlive: (pid) => {
55
+ try {
56
+ process.kill(pid, 0);
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ },
62
+ mtimeMs: (p) => statSync(full(p)).mtimeMs,
63
+ removeDir: (p) => rmSync(full(p), { recursive: true, force: true }),
64
+ moveDir: (from, to) => renameSync(full(from), full(to)),
65
+ };
66
+ }
67
+
68
+ export function makeCliGit(root: string): GitLike {
69
+ return makeSpawnGit(root);
70
+ }
package/src/main.ts ADDED
@@ -0,0 +1,507 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process';
3
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ import {
7
+ VERSION,
8
+ attachChange,
9
+ buildReview,
10
+ collectChanges,
11
+ collectSpecs,
12
+ graphMermaid,
13
+ nextReqId,
14
+ changeDiff,
15
+ deriveChangeId,
16
+ finalizeChange,
17
+ loadConfig,
18
+ loadTree,
19
+ newChange,
20
+ parseCapability,
21
+ renderChangesJson,
22
+ runContextRetrieval,
23
+ renderChangesList,
24
+ renderSpecsJson,
25
+ renderSpecsList,
26
+ runInit,
27
+ scaffoldSpec,
28
+ showChangeJson,
29
+ startChange,
30
+ makeWasmSevenZip,
31
+ checkIndexFreshness,
32
+ rebuildIndex,
33
+ runFreeze,
34
+ runList,
35
+ runThaw,
36
+ validateAllSpecs,
37
+ type TagBinding,
38
+ type GitLike,
39
+ resolveChatConfig,
40
+ unavailableResult,
41
+ } from '@llman-sdd/core';
42
+ import { Command } from 'commander';
43
+
44
+ import { makeCliGit, makeIo } from './io.ts';
45
+
46
+ // Injected at binary build time by scripts/build-binary.ts; falls back to the
47
+ // package version when running from source.
48
+ const version = process.env.LLMAN_SDD_VERSION ?? VERSION;
49
+
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;
59
+ }
60
+
61
+ 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()));
67
+ for (const line of report.lines) console.log(line);
68
+
69
+ let failed = report.failed;
70
+ if (options.check && existsSync('llmanspec/config.yaml')) {
71
+ const config = loadConfig(readFileSync('llmanspec/config.yaml', 'utf8'));
72
+ const runCommand = config.bdd?.run_command;
73
+ if (runCommand) {
74
+ const proc = spawnSync(runCommand, { shell: true, stdio: 'inherit' });
75
+ if ((proc.status ?? 1) !== 0) failed = true;
76
+ }
77
+ }
78
+ return failed ? 1 : 0;
79
+ }
80
+
81
+ const program = new Command();
82
+
83
+ program.name('llman-sdd').description('Spec-driven development workflow').version(version);
84
+
85
+ program
86
+ .command('init')
87
+ .description('Initialize llmanspec in your project (--update to refresh existing)')
88
+ .option('--update', 'refresh an existing installation')
89
+ .option('--locale <locale>', 'locale for generated templates (defaults to config or en)')
90
+ .action((options: { update?: boolean; locale?: string }) => {
91
+ const result = runInit(process.cwd(), {
92
+ update: options.update ?? false,
93
+ locale: options.locale,
94
+ version,
95
+ });
96
+ const removed = result.removed.length > 0 ? `, removed: ${result.removed.join(', ')}` : '';
97
+ console.log(`initialized llmanspec (${result.skills.length} skills${removed})`);
98
+ });
99
+
100
+ program
101
+ .command('validate')
102
+ .description('Validate specs under llmanspec/specs (structural gates)')
103
+ .option('--specs', 'validate specs (default and only scope for now)')
104
+ .option('--no-check', 'skip the bdd.run_command check (structural validation only)')
105
+ .action((options: { specs?: boolean; check: boolean }) => {
106
+ process.exit(runValidateSpecs(options));
107
+ });
108
+
109
+ const change = program
110
+ .command('change')
111
+ .description('Change lifecycle: new / start / attach / next-id / diff / finalize');
112
+
113
+ change
114
+ .command('new')
115
+ .description('Create a change draft (exactly one of <id> or --from)')
116
+ .argument('[id]')
117
+ .option('--from <description>', 'description the id is derived from')
118
+ .action((id: string | undefined, options: { from?: string }) => {
119
+ if ((id === undefined) === (options.from === undefined)) {
120
+ console.error('<CHANGE> and --from are mutually exclusive; pass one or the other');
121
+ process.exit(1);
122
+ }
123
+ const io = makeIo(process.cwd());
124
+ const result = newChange(io, { id, from: options.from });
125
+ if (options.from !== undefined) console.log(`derived change id: ${result.id}`);
126
+ console.log(result.path);
127
+ });
128
+
129
+ change
130
+ .command('start')
131
+ .description('Bind the change to a new feature branch (clean tree + default branch gates)')
132
+ .argument('<id>')
133
+ .option('--branch-prefix <prefix>', 'feature branch prefix', 'sdd/')
134
+ .action((id: string, options: { branchPrefix: string }) => {
135
+ const git: GitLike = makeCliGit(process.cwd());
136
+ const result = startChange(git, makeIo(process.cwd()), id, {
137
+ branchPrefix: options.branchPrefix,
138
+ });
139
+ console.log(
140
+ `started change \`${id}\` → branch \`${result.branch}\` (base ${result.baseBranch}@${result.baseSha.slice(0, 7)})`,
141
+ );
142
+ });
143
+
144
+ change
145
+ .command('attach')
146
+ .description('Bind the change to the current branch (no gates)')
147
+ .argument('<id>')
148
+ .action((id: string) => {
149
+ const result = attachChange(makeCliGit(process.cwd()), makeIo(process.cwd()), id);
150
+ console.log(`attached change \`${id}\` → branch \`${result.branch}\``);
151
+ });
152
+
153
+ change
154
+ .command('next-id')
155
+ .description('Preview the derived change id for a description')
156
+ .requiredOption('--from <description>', 'description the id is derived from')
157
+ .action((options: { from: string }) => {
158
+ console.log(deriveChangeId(options.from));
159
+ });
160
+
161
+ change
162
+ .command('diff')
163
+ .description('Print the bound branch diff vs base')
164
+ .argument('<id>')
165
+ .action((id: string) => {
166
+ console.log(changeDiff(makeCliGit(process.cwd()), makeIo(process.cwd()), id));
167
+ });
168
+
169
+ change
170
+ .command('finalize')
171
+ .description('Merge the feature branch, archive docs, and close out with one commit')
172
+ .argument('<id>')
173
+ .option('--into <branch>', 'merge target override (defaults to base_branch)')
174
+ .option('--method <method>', 'merge method: squash (default) or ff', 'squash')
175
+ .action((id: string, options: { into?: string; method: string }) => {
176
+ if (options.method !== 'squash' && options.method !== 'ff') {
177
+ console.error(`invalid --method: ${options.method}`);
178
+ process.exit(1);
179
+ }
180
+ const result = finalizeChange(makeCliGit(process.cwd()), makeIo(process.cwd()), id, {
181
+ into: options.into,
182
+ method: options.method,
183
+ });
184
+ for (const w of result.warnings) console.error(`[WARNING] ${w}`);
185
+ console.log(
186
+ `finalized \`${id}\` → ${result.archiveDir} (commit "${result.commitSubject}" on ${result.target})`,
187
+ );
188
+ });
189
+
190
+ program
191
+ .command('list')
192
+ .description('List changes or specs')
193
+ .option('--specs', 'list specs instead of changes')
194
+ .option('--json', 'machine-readable output')
195
+ .action((options: { specs?: boolean; json?: boolean }) => {
196
+ 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);
202
+ console.log(
203
+ options.json ? renderSpecsJson(summaries) : renderSpecsList(summaries).join('\n'),
204
+ );
205
+ return;
206
+ }
207
+ const changes = collectChanges(makeIo(process.cwd()), process.cwd(), new Date());
208
+ console.log(
209
+ options.json ? renderChangesJson(changes) : renderChangesList(changes, new Date()).join('\n'),
210
+ );
211
+ });
212
+
213
+ program
214
+ .command('show')
215
+ .description('Show a change (JSON) or a spec (text)')
216
+ .argument('<item>')
217
+ .option('--output <format>', 'output format: json, meta-only, reqs-only, no-scenarios')
218
+ .option('--type <itemType>', 'item type hint: change|spec')
219
+ .action((item: string, options: { output?: string; type?: string }) => {
220
+ const isSpec =
221
+ options.type === 'spec' || existsSync(join('llmanspec', 'specs', `${item}.feature`));
222
+ if (isSpec) {
223
+ const path = join('llmanspec', 'specs', `${item}.feature`);
224
+ if (!existsSync(path)) {
225
+ console.error(`spec not found: ${item}`);
226
+ process.exit(1);
227
+ }
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 morphology = summary
234
+ ? `\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
+ console.log(`## Spec\n${readFileSync(path, 'utf8').trimEnd()}${morphology}`);
237
+ return;
238
+ }
239
+ if (options.output !== 'json') {
240
+ console.error('only --output json is supported for changes (text format pending)');
241
+ process.exit(1);
242
+ }
243
+ const io = makeIo(process.cwd());
244
+ const result = showChangeJson(
245
+ {
246
+ io,
247
+ discovery: makeIo(process.cwd()),
248
+ root: process.cwd(),
249
+ specsDir: 'llmanspec/specs',
250
+ now: new Date(),
251
+ },
252
+ item,
253
+ );
254
+ console.log(JSON.stringify(result, null, 2));
255
+ });
256
+
257
+ program
258
+ .command('graph')
259
+ .description('Generate a change dependency graph (mermaid)')
260
+ .option('--format <format>', 'output format', 'mermaid')
261
+ .action((options: { format: string }) => {
262
+ if (options.format !== 'mermaid') {
263
+ console.error(`unsupported format: ${options.format}`);
264
+ process.exit(1);
265
+ }
266
+ console.log(graphMermaid(makeIo(process.cwd()), process.cwd()).join('\n'));
267
+ });
268
+
269
+ const spec = program.command('spec').description('Spec authoring helpers');
270
+
271
+ spec
272
+ .command('skeleton')
273
+ .description('Generate a single-track spec skeleton for a capability')
274
+ .argument('<capability>')
275
+ .action((capability: string) => {
276
+ const locale = existsSync('llmanspec/config.yaml')
277
+ ? loadConfig(readFileSync('llmanspec/config.yaml', 'utf8')).locale
278
+ : 'en';
279
+ const path = scaffoldSpec(makeIo(process.cwd()), 'llmanspec/specs', capability, locale);
280
+ console.log(`wrote ${path}`);
281
+ });
282
+
283
+ spec
284
+ .command('next-req-id')
285
+ .description('Allocate the next free global req id (rN)')
286
+ .action(() => {
287
+ console.log(nextReqId(makeIo(process.cwd()), 'llmanspec/specs'));
288
+ });
289
+
290
+ const project = program.command('project').description('Project management commands');
291
+
292
+ project
293
+ .command('migrate')
294
+ .description('Legacy migration entry (informational only)')
295
+ .action(() => {
296
+ console.log('legacy 迁移实现(spec.toon / specs-flatten 等)不随本工具提供;');
297
+ console.log(
298
+ '本工具直接读取既有 llmanspec 布局(config.yaml / specs/*.feature / changes/),零迁移可读。',
299
+ );
300
+ });
301
+
302
+ const archive = program
303
+ .command('archive')
304
+ .description(
305
+ 'Archive workflow commands (cold backup). Prefer `change finalize` to seal a change',
306
+ );
307
+
308
+ archive
309
+ .command('freeze')
310
+ .description('Freeze dated archived changes into a single 7z cold backup')
311
+ .option('--before <date>', 'freeze entries older than this date (YYYY-MM-DD)')
312
+ .option('--keep-recent <n>', 'keep N most recent candidates unfrozen', '0')
313
+ .option('--dry-run', 'list candidates without freezing')
314
+ .option('--list', 'list entries already in the cold-backup archive')
315
+ .action(
316
+ 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;
323
+ }
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
+ },
331
+ );
332
+
333
+ archive
334
+ .command('thaw')
335
+ .description('Restore archived change directories from the cold-backup archive')
336
+ .requiredOption(
337
+ '--change <name>',
338
+ 'archived change directory to restore (repeatable)',
339
+ (v: string, prev: string[]) => {
340
+ prev.push(v);
341
+ return prev;
342
+ },
343
+ [] as string[],
344
+ )
345
+ .action(async (options: { change: string[] }) => {
346
+ const sz = await makeWasmSevenZip();
347
+ const root = process.cwd();
348
+ const io = makeIo(root);
349
+ try {
350
+ const result = await runThaw(io, sz, root, options.change);
351
+ for (const line of result.lines) console.log(line);
352
+ } catch (error) {
353
+ console.error((error as Error).message);
354
+ process.exit(1);
355
+ }
356
+ });
357
+
358
+ const review = program
359
+ .command('review')
360
+ .description('Aggregate review: pending/manual/unbound/stale signals plus a validate sweep');
361
+
362
+ review
363
+ .option('--capability <capability>', 'restrict the sweep to one capability/spec id')
364
+ .option('--json', 'emit structured JSON (signals + summary)')
365
+ .option('--export-html <path>', 'write a self-contained HTML report')
366
+ .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
+ const config = existsSync('llmanspec/config.yaml')
372
+ ? loadConfig(readFileSync('llmanspec/config.yaml', 'utf8'))
373
+ : null;
374
+ 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());
380
+ const result = buildReview(
381
+ {
382
+ entries,
383
+ bindings: bindings as TagBinding[],
384
+ boundChangeCount: boundCount,
385
+ activeChanges,
386
+ },
387
+ makeIo(process.cwd()),
388
+ );
389
+ if (options.exportHtml !== undefined) {
390
+ writeFileSync(options.exportHtml, renderReviewHtml(result));
391
+ console.log(`wrote ${options.exportHtml}`);
392
+ }
393
+ if (options.json) {
394
+ console.log(JSON.stringify({ signals: result.signals, summary: result.summary }, null, 2));
395
+ } else {
396
+ console.log(result.lines.join('\n'));
397
+ }
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 });
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
+ const indexCmd = program
439
+ .command('index')
440
+ .description('Index management commands (rebuild, check freshness)');
441
+
442
+ indexCmd
443
+ .command('rebuild')
444
+ .description('Rebuild the pageindex tree from spec IR (no LLM)')
445
+ .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, {
452
+ chatModel: process.env.LLMAN_SDD_INDEX_CHAT_MODEL ?? '',
453
+ });
454
+ for (const line of result.lines) console.log(line);
455
+ });
456
+
457
+ indexCmd
458
+ .command('check')
459
+ .description('Check index freshness without rebuilding')
460
+ .action(() => {
461
+ const result = checkIndexFreshness(makeIo(process.cwd()), process.cwd(), 'llmanspec/specs');
462
+ for (const line of result.lines) console.log(line);
463
+ if (!result.fresh) process.exit(1);
464
+ });
465
+
466
+ program
467
+ .command('context')
468
+ .description('Get specs relevant to a task (agent-oriented, pageindex agentic retrieval)')
469
+ .option('--task <task>', 'natural language task description')
470
+ .option('--paths <paths>', 'comma-separated file paths')
471
+ .option('--top <n>', 'max entries per tier', '5')
472
+ .action((options: { task?: string; paths?: string; top?: string }) => {
473
+ if (!options.task && !options.paths) {
474
+ console.error('at least one of --task or --paths is required');
475
+ process.exit(1);
476
+ }
477
+ const config = resolveChatConfig(process.env as Record<string, string | undefined>);
478
+ if (config === null) {
479
+ // v1 parity: unavailable/error JSON on stdout, exit 0.
480
+ console.log(JSON.stringify(unavailableResult(), null, 2));
481
+ return;
482
+ }
483
+ const tree = loadTree(makeIo(process.cwd()), process.cwd());
484
+ if (tree === null) {
485
+ const missing = unavailableResult();
486
+ missing.status.qualityNote = 'index missing — run `llman-sdd index rebuild` first';
487
+ console.log(JSON.stringify(missing, null, 2));
488
+ return;
489
+ }
490
+ void runContextRetrieval({
491
+ config,
492
+ task: options.task ?? '',
493
+ paths: options.paths,
494
+ top: Number(options.top),
495
+ tree,
496
+ readFile: (p) => readFileSync(p, 'utf8'),
497
+ root: process.cwd(),
498
+ }).then((resolved) => {
499
+ console.log(JSON.stringify(resolved, null, 2));
500
+ });
501
+ });
502
+
503
+ async function main(): Promise<void> {
504
+ await program.parseAsync(process.argv);
505
+ }
506
+
507
+ await main();