@llman-sdd/cli 0.3.1 → 0.5.1

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.
@@ -0,0 +1,389 @@
1
+ import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ import {
5
+ CLOSE_OUT_TASK_HINT,
6
+ closeOutTaskLines,
7
+ archiveChange,
8
+ attachChange,
9
+ changeDiff,
10
+ changeDiffInfo,
11
+ deriveChangeId,
12
+ finalizeChange,
13
+ harvestAcrossWorktrees,
14
+ archiveTaskGate,
15
+ isCleanTree,
16
+ newChange,
17
+ nextUniqueNumber,
18
+ renderChangeIdTemplate,
19
+ splitVerb,
20
+ startChange,
21
+ validateAllSpecs,
22
+ decideCloseOutHarness,
23
+ extractFrontmatter,
24
+ readNeedsSpecsChange,
25
+ } from '@llman-sdd/core';
26
+ import { Option, type Command } from 'commander';
27
+
28
+ import {
29
+ CliError,
30
+ loadCliConfig,
31
+ loadSpecEntries,
32
+ newIo,
33
+ resolveChangeIdOrExit,
34
+ } from '../cli-shared.ts';
35
+ import { makeCliHarnessRunner } from '../harness.ts';
36
+ import { makeCliGit } from '../io.ts';
37
+
38
+ function runValidateSweep(): boolean {
39
+ const report = validateAllSpecs(loadSpecEntries(), newIo());
40
+ return report.verdicts.some((v) => !v.ok);
41
+ }
42
+
43
+ /** r81: run or refuse the acceptance command before any merge or rename. */
44
+ function enforceCloseOutHarness(id: string, noCheck: boolean): void {
45
+ const proposalPath = `llmanspec/changes/${id}/proposal.md`;
46
+ const proposal = existsSync(proposalPath) ? readFileSync(proposalPath, 'utf8') : '';
47
+ const needsSpecsChange = readNeedsSpecsChange(extractFrontmatter(proposal));
48
+ const hasExecutable = loadSpecEntries().some(
49
+ (entry) =>
50
+ entry.doc.rules.some((r) => r.scenarios.some((s) => s.runnable && s.stepCount > 0)) ||
51
+ entry.doc.orphans.some((s) => s.runnable && s.stepCount > 0),
52
+ );
53
+ const runCommand = loadCliConfig()?.bdd?.run_command ?? null;
54
+ const decision = decideCloseOutHarness({
55
+ noCheck,
56
+ needsSpecsChange,
57
+ hasExecutable,
58
+ runCommand,
59
+ nested: process.env.LLMAN_SDD_HARNESS_ACTIVE === '1',
60
+ });
61
+ if (decision.kind === 'abort') {
62
+ throw new CliError(`close-out aborted: ${decision.message}`);
63
+ }
64
+ if (decision.kind === 'skip') {
65
+ if (decision.announce) console.error('bdd harness skipped: --no-check');
66
+ return;
67
+ }
68
+ const result = makeCliHarnessRunner().run(decision.command, process.cwd());
69
+ if (result.spawnError !== undefined || result.exitCode !== 0) {
70
+ const why = result.spawnError ?? `exit ${result.exitCode}`;
71
+ throw new CliError(`close-out aborted: bdd harness failed (${why})`);
72
+ }
73
+ }
74
+
75
+ /** `--method squash|ff` guard shared by `change archive` / `change finalize`. */
76
+ function assertMergeMethod(method: string | undefined): void {
77
+ if (method !== undefined && method !== 'squash' && method !== 'ff') {
78
+ throw new CliError(`invalid --method: ${method}`);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Unified `change new --from` id derivation (r44/r60). With a configured
84
+ * `change_id.template` the id is rendered through it (Strict: referencing an
85
+ * unprovided variable throws, surfacing in both dry-run and real paths); the
86
+ * heuristic slug is otherwise returned unchanged unless an explicit `--verb`
87
+ * is given, in which case the override is applied as `{verb}-{subject}` (r44).
88
+ */
89
+ /**
90
+ * r35: `change next-id` / change-id harvest scan must skip symlinks and dot
91
+ * directories. `lstat` (never `stat`) reports the link itself, so a symlink —
92
+ * even one pointing at a directory — is not a directory here and is neither
93
+ * counted nor recursed into. The next-id harvest is the single consumer that
94
+ * requires this no-follow semantics cycle-wide.
95
+ */
96
+ function isDirectoryNoFollow(path: string): boolean {
97
+ return lstatSync(resolve(path)).isDirectory();
98
+ }
99
+ function deriveNewId(description: string, verb: string | undefined): string {
100
+ const slug = deriveChangeId(description);
101
+ const cliConfig = loadCliConfig();
102
+ const template = cliConfig?.change_id?.template;
103
+ if (template) {
104
+ const { verb: v, subject } = splitVerb(slug, verb);
105
+ return renderChangeIdTemplate(template, {
106
+ llman_sdd_unique_id: nextUniqueNumber(
107
+ {
108
+ listDir: (p) => readdirSync(resolve(p)),
109
+ isDirectory: isDirectoryNoFollow,
110
+ },
111
+ 'llmanspec',
112
+ ),
113
+ verb: v,
114
+ subject,
115
+ date: new Date().toISOString().slice(0, 10),
116
+ });
117
+ }
118
+ if (verb !== undefined) {
119
+ const { verb: v, subject } = splitVerb(slug, verb);
120
+ return `${v}-${subject}`;
121
+ }
122
+ return slug;
123
+ }
124
+
125
+ export function registerChange(program: Command): void {
126
+ const change = program
127
+ .command('change')
128
+ .description('Change lifecycle: new / start / attach / next-id / diff / finalize');
129
+
130
+ change
131
+ .command('new')
132
+ .description('Create a change draft (exactly one of <id> or --from)')
133
+ .argument('[id]')
134
+ .option('--from <description>', 'description the id is derived from')
135
+ .option('--verb <verb>', 'explicit verb for change_id.template rendering')
136
+ .option('--force', 'overwrite an existing proposal.md')
137
+ .option('--dry-run', 'print the resulting id without creating anything')
138
+ .action(
139
+ (
140
+ id: string | undefined,
141
+ options: { from?: string; verb?: string; force?: boolean; dryRun?: boolean },
142
+ ) => {
143
+ if ((id === undefined) === (options.from === undefined)) {
144
+ throw new CliError('<CHANGE> and --from are mutually exclusive; pass one or the other');
145
+ }
146
+ if (options.dryRun) {
147
+ console.log(id ?? deriveNewId(options.from as string, options.verb));
148
+ return;
149
+ }
150
+ if (options.from !== undefined) {
151
+ const derived = deriveNewId(options.from, options.verb);
152
+ console.log(`derived change id: ${derived}`);
153
+ const io = newIo();
154
+ const result = newChange(io, { id: derived, force: options.force });
155
+ console.log(`./${result.path}`);
156
+ return;
157
+ }
158
+ const io = newIo();
159
+ const result = newChange(io, { id, force: options.force });
160
+ console.log(`./${result.path}`);
161
+ },
162
+ );
163
+
164
+ change
165
+ .command('start')
166
+ .description('Bind the change to a new feature branch (clean tree + default branch gates)')
167
+ .argument('<id>')
168
+ .option(
169
+ '--branch-prefix <prefix>',
170
+ 'feature branch prefix (default: sdd.branch_prefix config, then sdd/)',
171
+ )
172
+ .option(
173
+ '--base <branch>',
174
+ 'explicit fork-source branch to record (must exist and differ from the new branch; exempts the default-branch gate)',
175
+ )
176
+ .option(
177
+ '--worktree',
178
+ 'create the branch in a dedicated worktree (sdd.worktree_root / sdd.worktree_naming config) instead of switching this checkout',
179
+ )
180
+ .action((id: string, options: { branchPrefix?: string; base?: string; worktree?: boolean }) => {
181
+ const resolved = resolveChangeIdOrExit(program, id);
182
+ const git = makeCliGit(process.cwd());
183
+ const config = loadCliConfig();
184
+ const result = startChange(git, newIo(), resolved.id, {
185
+ branchPrefix: options.branchPrefix ?? config?.sdd?.branch_prefix ?? 'sdd/',
186
+ base: options.base,
187
+ worktree: options.worktree,
188
+ worktreeRoot: config?.sdd?.worktree_root ?? undefined,
189
+ worktreeNaming: config?.sdd?.worktree_naming ?? undefined,
190
+ });
191
+ console.log(
192
+ `started change \`${resolved.id}\` → branch \`${result.branch}\` base \`${result.baseSha}\` base-branch \`${result.baseBranch}\``,
193
+ );
194
+ if (result.worktreePath !== undefined) console.log(`worktree ${result.worktreePath}`);
195
+ });
196
+
197
+ change
198
+ .command('attach')
199
+ .description('Bind the change to the current feature branch')
200
+ .argument('<id>')
201
+ .option('--force', 'rebind an already attached change to the current branch')
202
+ .option('--base <branch>', 'explicit fork-point branch to record')
203
+ .action((id: string, options: { force?: boolean; base?: string }) => {
204
+ const resolved = resolveChangeIdOrExit(program, id);
205
+ const result = attachChange(makeCliGit(process.cwd()), newIo(), resolved.id, {
206
+ force: options.force,
207
+ base: options.base,
208
+ });
209
+ console.log(
210
+ `attached change \`${resolved.id}\` → branch \`${result.branch}\` base \`${result.baseSha}\` base-branch \`${result.baseBranch}\``,
211
+ );
212
+ });
213
+
214
+ change
215
+ .command('next-id')
216
+ .description('Preview the next free change id number (read-only)')
217
+ .option('--json', 'emit {maxNumber, nextNumber, warnings}')
218
+ .action((options: { json?: boolean }) => {
219
+ // r35: the scan covers this tree plus every linked git worktree's own
220
+ // llmanspec/ tree (degrades to the current tree when worktree list fails).
221
+ const harvest = harvestAcrossWorktrees(
222
+ makeCliGit(process.cwd()),
223
+ {
224
+ listDir: (p) => readdirSync(resolve(p)),
225
+ isDirectory: isDirectoryNoFollow,
226
+ },
227
+ 'llmanspec',
228
+ );
229
+ if (options.json) {
230
+ console.log(JSON.stringify(harvest, null, 2));
231
+ return;
232
+ }
233
+ if (harvest.maxNumber === null) console.log('no numbered change dirs found in tree');
234
+ else console.log(`max number in tree: ${harvest.maxNumber}`);
235
+ console.log(`next free number: ${harvest.nextNumber}`);
236
+ for (const w of harvest.warnings) console.error(`warning: ${w}`);
237
+ });
238
+
239
+ change
240
+ .command('archive')
241
+ .description('Independent seal-off: task gates + merge + archive rename + commit')
242
+ .argument('<id>')
243
+ .option('--into <branch>', 'target branch to merge into')
244
+ .option('--method <method>', 'merge method: squash | ff')
245
+ .option('--dry-run', 'print the rename plan only')
246
+ .addOption(new Option('--force', 'skip task and git gates').hideHelp())
247
+ .action(
248
+ (
249
+ id: string,
250
+ options: { into?: string; method?: string; dryRun?: boolean; force?: boolean },
251
+ ) => {
252
+ assertMergeMethod(options.method);
253
+ const resolved = resolveChangeIdOrExit(program, id);
254
+ id = resolved.id;
255
+ const io = newIo();
256
+ if (options.dryRun) {
257
+ const date = new Date().toISOString().slice(0, 10);
258
+ console.log(
259
+ `Would move llmanspec/changes/${id} -> llmanspec/changes/archive/${date}-${id}`,
260
+ );
261
+ return;
262
+ }
263
+ // r40 task gate: single core implementation (archiveTaskGate); the
264
+ // CLI only renders the blocked output + options list, byte-identical.
265
+ if (!options.force) {
266
+ const tasksPath = `llmanspec/changes/${id}/tasks.md`;
267
+ const gate = archiveTaskGate(
268
+ existsSync(tasksPath) ? readFileSync(tasksPath, 'utf8') : null,
269
+ );
270
+ if (gate.blocked) {
271
+ console.error(`Archive blocked: ${gate.pendingLines.length} unchecked task(s).`);
272
+ for (const line of gate.pendingLines) {
273
+ console.error(` - [ ] ${line.replace(/^-\s+\[ \]\s*/u, '').trim()}`);
274
+ }
275
+ // D9: point at a close-out pseudo-task with the single shared hint.
276
+ const closeOut = closeOutTaskLines(gate.pendingLines);
277
+ if (closeOut.length > 0) {
278
+ console.error(
279
+ ` task "${closeOut[0]?.replace(/^-\s+\[ \]\s*/u, '').trim() ?? ''}" looks like a close-out step; ${CLOSE_OUT_TASK_HINT}`,
280
+ );
281
+ }
282
+ console.error(
283
+ 'Options:\n 1. Complete the remaining tasks\n 2. Use --force to archive anyway (not recommended)',
284
+ );
285
+ throw new Error('archive blocked by unchecked tasks');
286
+ }
287
+ if (!isCleanTree(makeCliGit(process.cwd()))) {
288
+ throw new CliError('`change archive` requires a clean working tree');
289
+ }
290
+ }
291
+ enforceCloseOutHarness(id, false);
292
+ const result = archiveChange(makeCliGit(process.cwd()), io, id, {
293
+ into: options.into,
294
+ method: options.method as 'squash' | 'ff' | undefined,
295
+ force: options.force,
296
+ skipCleanTree: true,
297
+ today: new Date().toISOString().slice(0, 10),
298
+ });
299
+ const archiveName = (result.archiveDir ?? '').slice(
300
+ (result.archiveDir ?? '').lastIndexOf('/') + 1,
301
+ );
302
+ console.log(`Change '${id}' archived as '${archiveName}'.`);
303
+ if (result.executedIn !== null) {
304
+ console.log(`executed in target worktree ${result.executedIn}`);
305
+ }
306
+ },
307
+ );
308
+
309
+ change
310
+ .command('diff')
311
+ .description('Print the bound branch diff vs base')
312
+ .argument('<id>')
313
+ .option('--json', 'emit {change, branch, base, commitCount}')
314
+ .option('--export-patch <path>', 'write the diff to a file instead of stdout')
315
+ .action((id: string, options: { json?: boolean; exportPatch?: string }) => {
316
+ const resolved = resolveChangeIdOrExit(program, id);
317
+ id = resolved.id;
318
+ const git = makeCliGit(process.cwd());
319
+ if (options.json) {
320
+ const info = changeDiffInfo(git, newIo(), id);
321
+ console.log(JSON.stringify(info, null, 2));
322
+ return;
323
+ }
324
+ const diff = changeDiff(git, newIo(), id);
325
+ if (options.exportPatch !== undefined) {
326
+ // writeText creates parent dirs — exporting into a fresh path like
327
+ // out/change.patch must not ENOENT.
328
+ newIo().writeText(options.exportPatch, diff);
329
+ console.log(`wrote ${options.exportPatch}`);
330
+ return;
331
+ }
332
+ console.log(diff);
333
+ });
334
+
335
+ change
336
+ .command('finalize')
337
+ .description('Validate, merge the feature branch, archive docs, close out with one commit')
338
+ .argument('<id>')
339
+ .option('--into <branch>', 'merge target override (defaults to base_branch)')
340
+ .option(
341
+ '--method <method>',
342
+ 'merge method: squash | ff (default: sdd.merge_method config, then squash)',
343
+ )
344
+ .option('--no-check', 'skip the pre-merge validation sweep')
345
+ .option('--no-commit', 'skip the close-out commit (manual/CI history)')
346
+ .action(
347
+ (
348
+ id: string,
349
+ options: { into?: string; method?: string; check?: boolean; commit?: boolean },
350
+ ) => {
351
+ assertMergeMethod(options.method);
352
+ if (options.check !== false) {
353
+ const failed = runValidateSweep();
354
+ if (failed) {
355
+ throw new CliError(
356
+ 'finalize aborted: validation sweep failed (use --no-check to skip)',
357
+ );
358
+ }
359
+ }
360
+ const resolved = resolveChangeIdOrExit(program, id);
361
+ id = resolved.id;
362
+ enforceCloseOutHarness(id, options.check === false);
363
+ const config = loadCliConfig();
364
+ const method = options.method ?? config?.sdd?.merge_method ?? 'squash';
365
+ const result = finalizeChange(makeCliGit(process.cwd()), newIo(), id, {
366
+ into: options.into,
367
+ method: method as 'squash' | 'ff',
368
+ today: new Date().toISOString().slice(0, 10),
369
+ noCommit: options.commit === false,
370
+ });
371
+ for (const w of result.warnings) console.error(`[WARNING] ${w}`);
372
+ if (options.commit === false) {
373
+ console.log(
374
+ `finalized \`${id}\` → ${result.archiveDir} on ${result.target} (close-out commit skipped — run: git add -A && git commit -m "archive(sdd): ${id}")`,
375
+ );
376
+ if (result.executedIn !== null) {
377
+ console.log(`executed in target worktree ${result.executedIn}`);
378
+ }
379
+ return;
380
+ }
381
+ console.log(
382
+ `finalized \`${id}\` → ${result.archiveDir} (commit "${result.commitSubject}" on ${result.target})`,
383
+ );
384
+ if (result.executedIn !== null) {
385
+ console.log(`executed in target worktree ${result.executedIn}`);
386
+ }
387
+ },
388
+ );
389
+ }
@@ -0,0 +1,39 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ import { renderConfigOverview, renderMachine, skillsJson } from '@llman-sdd/core';
4
+ import type { Command } from 'commander';
5
+
6
+ import { addReportOutputOptions, resolveOutMode, skillDesc } from '../cli-shared.ts';
7
+
8
+ export function registerConfig(program: Command): void {
9
+ const configCmd = program
10
+ .command('config')
11
+ .description('Project configuration commands (view/edit config.yaml)');
12
+
13
+ configCmd.description('Print a read-only llmanspec/config.yaml overview').action(() => {
14
+ const source = readFileSync('llmanspec/config.yaml', 'utf8');
15
+ console.log(renderConfigOverview(source).join('\n'));
16
+ });
17
+
18
+ const skills = configCmd.command('skills').description('Manage extra_skills (non-interactive)');
19
+ addReportOutputOptions(skills);
20
+ skills.action((options: { json?: boolean; output?: string; compactJson?: boolean }) => {
21
+ const path = 'llmanspec/config.yaml';
22
+ const info = skillsJson(readFileSync(path, 'utf8'));
23
+ const mode = resolveOutMode(options.output, options.json, options.compactJson);
24
+ if (mode !== 'human') {
25
+ console.log(renderMachine(info, mode));
26
+ return;
27
+ }
28
+ const enabled = info.enabled;
29
+ const available = info.available;
30
+ console.log('Enabled optional skills:');
31
+ if (enabled.length === 0) console.log(' (none)');
32
+ else for (const s of enabled) console.log(` [x] ${s}`);
33
+ console.log();
34
+ console.log('Available but not enabled:');
35
+ for (const s of available) {
36
+ if (!enabled.includes(s)) console.log(` [ ] ${s} — ${skillDesc(s)}`);
37
+ }
38
+ });
39
+ }
@@ -0,0 +1,59 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ import {
4
+ loadTreeWithAutoRebuild,
5
+ resolveChatConfig,
6
+ runContextRetrieval,
7
+ unavailableResult,
8
+ } from '@llman-sdd/core';
9
+ import type { Command } from 'commander';
10
+
11
+ import { CliError, loadSpecEntries, newIo, resolveBackend } from '../cli-shared.ts';
12
+
13
+ // (err path below converted to CliError; resolveBackend may also throw CliError)
14
+
15
+ export function registerContext(program: Command): void {
16
+ program
17
+ .command('context')
18
+ .description('Get specs relevant to a task (agent-oriented, pageindex agentic retrieval)')
19
+ .option('--task <task>', 'natural language task description')
20
+ .option('--paths <paths>', 'comma-separated file paths')
21
+ .option('--top <n>', 'max entries per tier', '5')
22
+ .option('--backend <name>', 'retrieval backend (pageindex only)')
23
+ .action(async (options: { task?: string; paths?: string; top?: string; backend?: string }) => {
24
+ resolveBackend(options.backend);
25
+ if (!options.task && !options.paths) {
26
+ throw new CliError('at least one of --task or --paths is required');
27
+ }
28
+ const config = resolveChatConfig(process.env as Record<string, string | undefined>);
29
+ // r62: lazy refresh runs BEFORE the chat-model gate (predecessor r97 — the index
30
+ // self-heals even when retrieval subsequently fails with api_error).
31
+ const refresh = loadTreeWithAutoRebuild(newIo(), 'llmanspec/specs', loadSpecEntries(), {
32
+ chatModel: process.env.LLMAN_SDD_INDEX_CHAT_MODEL ?? '',
33
+ });
34
+ if (refresh.tree === null || refresh.error !== null) {
35
+ const failed = unavailableResult();
36
+ failed.status.errorKind = 'index_rebuild_failed';
37
+ failed.status.qualityNote =
38
+ refresh.error ?? 'index rebuild failed — run `llman-sdd index rebuild`';
39
+ console.log(JSON.stringify(failed, null, 2));
40
+ return;
41
+ }
42
+ const tree = refresh.tree;
43
+ if (config === null) {
44
+ // predecessor parity: unavailable/error JSON on stdout, exit 0.
45
+ console.log(JSON.stringify(unavailableResult(), null, 2));
46
+ return;
47
+ }
48
+ const result = await runContextRetrieval({
49
+ config,
50
+ task: options.task ?? '',
51
+ paths: options.paths,
52
+ top: Number(options.top),
53
+ tree,
54
+ readFile: (p) => readFileSync(p, 'utf8'),
55
+ root: process.cwd(),
56
+ });
57
+ console.log(JSON.stringify(result, null, 2));
58
+ });
59
+ }
@@ -0,0 +1,35 @@
1
+ import { graphMermaid } from '@llman-sdd/core';
2
+ import type { Command } from 'commander';
3
+
4
+ import { cliMaxScanDepth, CliError, newIo } from '../cli-shared.ts';
5
+
6
+ export function registerGraph(program: Command): void {
7
+ program
8
+ .command('graph')
9
+ .description('Generate a change dependency graph (mermaid)')
10
+ .argument('[change]', 'seed change id (BFS over depends_on)')
11
+ .option('--format <format>', 'output format', 'mermaid')
12
+ .option('--scope <scope>', 'active | archived | all (comma-combined)', 'active')
13
+ .option(
14
+ '--depth <n>',
15
+ 'expansion depth: 0 = scope only, 1 = direct deps (default), N = recursive',
16
+ )
17
+ .action(
18
+ (change: string | undefined, options: { format: string; scope?: string; depth?: string }) => {
19
+ if (options.format !== 'mermaid') {
20
+ throw new CliError(`unsupported --format: ${options.format} (mermaid only)`, 2);
21
+ }
22
+ const depth = options.depth !== undefined ? Number(options.depth) : undefined;
23
+ if (depth !== undefined && (!Number.isInteger(depth) || depth < 0)) {
24
+ throw new CliError(`invalid --depth: ${options.depth}`);
25
+ }
26
+ const graphOpts = {
27
+ scope: options.scope,
28
+ depth: depth ?? 1,
29
+ seed: change,
30
+ maxScanDepth: cliMaxScanDepth(program),
31
+ };
32
+ console.log(graphMermaid(newIo(), process.cwd(), graphOpts).join('\n'));
33
+ },
34
+ );
35
+ }
@@ -0,0 +1,43 @@
1
+ import { checkIndexFreshness, rebuildIndex, renderMachine } from '@llman-sdd/core';
2
+ import type { Command } from 'commander';
3
+
4
+ import {
5
+ addReportOutputOptions,
6
+ exitWith,
7
+ loadSpecEntries,
8
+ newIo,
9
+ resolveBackend,
10
+ resolveOutMode,
11
+ } from '../cli-shared.ts';
12
+
13
+ export function registerIndex(program: Command): void {
14
+ const indexCmd = program
15
+ .command('index')
16
+ .description('Index management commands (rebuild, check freshness)');
17
+
18
+ indexCmd
19
+ .command('rebuild')
20
+ .description('Rebuild the pageindex tree from spec IR (no LLM)')
21
+ .option('--backend <name>', 'index backend (pageindex only)')
22
+ .action((options: { backend?: string }) => {
23
+ resolveBackend(options.backend);
24
+ const result = rebuildIndex(newIo(), 'llmanspec/specs', loadSpecEntries(), {
25
+ chatModel: process.env.LLMAN_SDD_INDEX_CHAT_MODEL ?? '',
26
+ });
27
+ for (const line of result.lines.slice(0, -1)) console.error(line);
28
+ if (result.lines.length > 0) console.log(result.lines.at(-1));
29
+ });
30
+
31
+ const check = indexCmd.command('check').description('Check index freshness without rebuilding');
32
+ addReportOutputOptions(check);
33
+ check.action((options: { output?: string; json?: boolean; compactJson?: boolean }) => {
34
+ const result = checkIndexFreshness(newIo(), 'llmanspec/specs');
35
+ const mode = resolveOutMode(options.output, options.json, options.compactJson);
36
+ if (mode !== 'human') {
37
+ console.log(renderMachine({ fresh: result.fresh, notes: result.lines }, mode));
38
+ } else {
39
+ for (const line of result.lines) console.log(line);
40
+ }
41
+ if (!result.fresh) exitWith(1);
42
+ });
43
+ }
@@ -0,0 +1,37 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ import { runInit } from '@llman-sdd/core';
5
+ import type { Command } from 'commander';
6
+
7
+ import { CliError, templateIo, version } from '../cli-shared.ts';
8
+ import { makeIo } from '../io.ts';
9
+
10
+ export function registerInit(program: Command): void {
11
+ program
12
+ .command('init')
13
+ .description('Initialize llmanspec in your project (--update to refresh existing)')
14
+ .argument('[path]', 'target directory (created when missing; defaults to cwd)')
15
+ .option('--update', 'refresh an existing installation')
16
+ .option('--locale <locale>', 'locale for generated templates (defaults to config or en)')
17
+ .option('--lang <locale>', 'alias of --locale')
18
+ .action(
19
+ (path: string | undefined, options: { update?: boolean; locale?: string; lang?: string }) => {
20
+ if (options.locale !== undefined && options.lang !== undefined) {
21
+ throw new CliError('--locale and --lang are mutually exclusive (they are aliases)');
22
+ }
23
+ let root = process.cwd();
24
+ if (path !== undefined) {
25
+ root = resolve(path);
26
+ mkdirSync(root, { recursive: true });
27
+ }
28
+ const result = runInit(makeIo(root), templateIo, {
29
+ update: options.update ?? false,
30
+ locale: options.locale ?? options.lang,
31
+ version,
32
+ });
33
+ const removed = result.removed.length > 0 ? `, removed: ${result.removed.join(', ')}` : '';
34
+ console.log(`initialized llmanspec (${result.skills.length} skills${removed})`);
35
+ },
36
+ );
37
+ }
@@ -0,0 +1,69 @@
1
+ import {
2
+ collectChanges,
3
+ collectSpecs,
4
+ renderChangesJson,
5
+ renderChangesList,
6
+ renderSpecsJson,
7
+ renderSpecsList,
8
+ } from '@llman-sdd/core';
9
+ import type { Command } from 'commander';
10
+
11
+ import {
12
+ CliError,
13
+ assertCompactJsonPairing,
14
+ addReportOutputOptions,
15
+ cliMaxScanDepth,
16
+ loadSpecEntries,
17
+ newIo,
18
+ resolveOutMode,
19
+ } from '../cli-shared.ts';
20
+
21
+ export function registerList(program: Command): void {
22
+ const list = program
23
+ .command('list')
24
+ .description('List changes or specs')
25
+ .option('--specs', 'list specs instead of changes')
26
+ .option('--sort <order>', 'recent (mtime desc, default) | name');
27
+ addReportOutputOptions(list);
28
+ list.action(
29
+ (options: {
30
+ specs?: boolean;
31
+ json?: boolean;
32
+ compactJson?: boolean;
33
+ output?: string;
34
+ sort?: string;
35
+ }) => {
36
+ if (!assertCompactJsonPairing(options)) return;
37
+ if (options.sort !== undefined && options.sort !== 'recent' && options.sort !== 'name') {
38
+ throw new CliError(`invalid --sort: ${options.sort}`);
39
+ }
40
+ const emit = (text: string): void => {
41
+ console.log(options.compactJson ? text.replaceAll('\n', '') : text);
42
+ };
43
+ if (options.specs) {
44
+ const summaries = collectSpecs(loadSpecEntries());
45
+ const specsMode = resolveOutMode(options.output, options.json, options.compactJson);
46
+ emit(
47
+ specsMode !== 'human'
48
+ ? renderSpecsJson(summaries, specsMode)
49
+ : renderSpecsList(summaries).join('\n'),
50
+ );
51
+ return;
52
+ }
53
+ let changes = collectChanges(newIo(), process.cwd(), new Date(), {
54
+ maxScanDepth: cliMaxScanDepth(program),
55
+ });
56
+ if (options.sort === 'name') {
57
+ changes = [...changes].toSorted((a, b) => a.name.localeCompare(b.name));
58
+ }
59
+ emit(
60
+ (() => {
61
+ const changesMode = resolveOutMode(options.output, options.json, options.compactJson);
62
+ return changesMode !== 'human'
63
+ ? renderChangesJson(changes, changesMode)
64
+ : renderChangesList(changes, new Date()).join('\n');
65
+ })(),
66
+ );
67
+ },
68
+ );
69
+ }