@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llman-sdd/cli",
3
- "version": "0.3.1",
3
+ "version": "0.5.1",
4
4
  "description": "Spec-driven development workflow CLI (the `llman-sdd` command)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,7 +27,7 @@
27
27
  "build": "bun run ./scripts/build-binary.ts"
28
28
  },
29
29
  "dependencies": {
30
- "@llman-sdd/core": "0.3.1",
30
+ "@llman-sdd/core": "0.5.1",
31
31
  "commander": "^15.0.0"
32
32
  },
33
33
  "engines": {
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Leaf module shared by every command group: version/templateIo wiring, config
3
+ * readers, change-id resolution, output-mode helpers and misc constants.
4
+ * Command modules depend one-way on this file; it must not import any of them.
5
+ */
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+
8
+ import {
9
+ discoverSpecs,
10
+ embeddedTemplates,
11
+ loadConfig,
12
+ makeEmbeddedTemplateIo,
13
+ resolveChangeId,
14
+ type TemplateIo,
15
+ } from '@llman-sdd/core';
16
+ import type { Command } from 'commander';
17
+
18
+ import pkg from '../package.json' with { type: 'json' };
19
+ import { makeIo } from './io.ts';
20
+
21
+ // Re-exported so leaf consumers (e.g. regression gates under tests/) can type
22
+ // against the commander surface without resolving 'commander' themselves.
23
+ export type { Command } from 'commander';
24
+
25
+ // Version SSOT is this package's package.json (inlined at compile time — a
26
+ // single-file binary has no on-disk package.json to read); binary builds
27
+ // additionally override it through the LLMAN_SDD_VERSION define injected by
28
+ // scripts/build-binary.ts (git tag > package version).
29
+
30
+ // Injected at binary build time by scripts/build-binary.ts; falls back to the
31
+ // package version when running from source.
32
+ export const version = process.env.LLMAN_SDD_VERSION ?? pkg.version;
33
+
34
+ // Compiled single-file binaries have no on-disk templates and Bun <= 1.4.x has
35
+ // no embedding mechanism, so scripts/build-binary.ts injects the template
36
+ // table through the literal define `process.env.LLMAN_SDD_EMBEDDED_TEMPLATES`
37
+ // (define only rewrites literal member access — the read must stay here, not
38
+ // in core); every non-compiled run keeps reading the real filesystem (npm/
39
+ // source/Node). Both init and `review --export-html` resolve through this one
40
+ // seam.
41
+ const embedded = embeddedTemplates(process.env.LLMAN_SDD_EMBEDDED_TEMPLATES);
42
+ export const templateIo: TemplateIo = embedded
43
+ ? makeEmbeddedTemplateIo(embedded)
44
+ : {
45
+ exists: (p) => existsSync(p),
46
+ readText: (p) => readFileSync(p, 'utf8'),
47
+ };
48
+
49
+ /** CLI io rooted at the launch cwd; call at action time so cwd is read per command. */
50
+ export function newIo(): ReturnType<typeof makeIo> {
51
+ return makeIo(process.cwd());
52
+ }
53
+
54
+ /**
55
+ * D5: unified error export. Domain/usage errors thrown from command actions
56
+ * surface as a single `Error: <message>` line via main.ts with the carried
57
+ * exit code — commands must NOT set `process.exitCode` directly.
58
+ */
59
+ export class CliError extends Error {
60
+ readonly exitCode: number;
61
+ constructor(message: string, exitCode = 1) {
62
+ super(message);
63
+ this.exitCode = exitCode;
64
+ }
65
+ }
66
+
67
+ /** Single exit-code write point for non-error result paths (e.g. sweep verdicts). */
68
+ export function exitWith(code: number): void {
69
+ process.exitCode = code;
70
+ }
71
+
72
+ /** Parse all capability specs under llmanspec/specs via core discovery. */
73
+ export function loadSpecEntries(): ReturnType<typeof discoverSpecs> {
74
+ return discoverSpecs('llmanspec/specs', newIo());
75
+ }
76
+
77
+ export function loadCliConfig(): ReturnType<typeof loadConfig> | null {
78
+ return existsSync('llmanspec/config.yaml')
79
+ ? loadConfig(readFileSync('llmanspec/config.yaml', 'utf8'))
80
+ : null;
81
+ }
82
+
83
+ export function cliMaxScanDepth(program: Command): number {
84
+ const raw = program.opts().maxScanDepth as string | undefined;
85
+ const n = raw !== undefined ? Number(raw) : 8;
86
+ if (!Number.isInteger(n) || n < 1) {
87
+ throw new CliError(`--max-scan-depth must be >= 1 (got ${raw})`, 2);
88
+ }
89
+ return n;
90
+ }
91
+
92
+ /**
93
+ * r61: shared predecessor r112 change id resolution for every change-taking command.
94
+ * Emits the `(prefix match)` hint on stderr; fails by throwing CliError (the
95
+ * unified exit renders the single `Error: <message>` line and exit code 1).
96
+ */
97
+ export function resolveChangeIdOrExit(
98
+ program: Command,
99
+ input: string,
100
+ opts: { suppressHint?: boolean } = {},
101
+ ): { id: string; viaPrefix: boolean } {
102
+ const resolved = resolveChangeId(newIo(), process.cwd(), input, {
103
+ maxScanDepth: cliMaxScanDepth(program),
104
+ });
105
+ if (resolved.viaPrefix && opts.suppressHint !== true) {
106
+ console.error(`'${input}' -> '${resolved.id}' (prefix match)`);
107
+ }
108
+ return resolved;
109
+ }
110
+
111
+ export type OutMode = 'toon' | 'json' | 'compact-json' | 'human';
112
+
113
+ /**
114
+ * D4: shared output-flag surface for every report command. Mounts the unified
115
+ * `--output <toon|json|compact-json|human>` plus the predecessor-compatibility alias
116
+ * flags (`--json` / `--compact-json`), so report commands no longer duplicate
117
+ * `.option('--output', ...)` registrations. `outputHint` lets a command keep a
118
+ * richer value-domain description (show's legacy JSON modifiers).
119
+ */
120
+ export function addReportOutputOptions(cmd: Command, opts: { outputHint?: string } = {}): void {
121
+ cmd
122
+ .option(
123
+ '--output <mode>',
124
+ opts.outputHint ?? 'report format: toon (default) | json | compact-json | human',
125
+ )
126
+ .option('--json', 'emit structured JSON (v1 compatibility alias)')
127
+ .option('--compact-json', 'emit single-line JSON (v1 compatibility alias)');
128
+ }
129
+
130
+ /**
131
+ * toon-default-output: explicit `--output` wins, then the predecessor-parity legacy
132
+ * flags, then the toon default. Legacy `--compact-json` keeps its predecessor guard
133
+ * (must pair with `--json`) — standalone compact goes through `--output`.
134
+ * Invalid `--output` values are usage errors (exit 2) and throw CliError.
135
+ */
136
+ export function resolveOutMode(
137
+ output: string | undefined,
138
+ legacyJson: boolean | undefined,
139
+ legacyCompact: boolean | undefined,
140
+ ): OutMode {
141
+ const modes: readonly string[] = ['toon', 'json', 'compact-json', 'human'];
142
+ if (output !== undefined) {
143
+ if (!modes.includes(output)) {
144
+ throw new CliError(`invalid --output: ${output} (toon | json | compact-json | human)`, 2);
145
+ }
146
+ return output as OutMode;
147
+ }
148
+ if (legacyCompact) return 'compact-json';
149
+ if (legacyJson) return 'json';
150
+ return 'toon';
151
+ }
152
+
153
+ /**
154
+ * Legacy `--compact-json` guard (see resolveOutMode): standalone compact is
155
+ * rejected (usage error, exit 2) unless paired with `--json` or an explicit
156
+ * `--output`.
157
+ */
158
+ export function assertCompactJsonPairing(options: {
159
+ compactJson?: boolean;
160
+ json?: boolean;
161
+ output?: string;
162
+ }): boolean {
163
+ if (options.compactJson === true && options.json !== true && options.output === undefined) {
164
+ throw new CliError('--compact-json requires --json', 2);
165
+ }
166
+ return true;
167
+ }
168
+
169
+ export const SKILL_DESCRIPTIONS: Record<string, string> = {
170
+ 'llman-sdd-continue': 'Fill in missing change artifacts',
171
+ 'llman-sdd-ff': 'Fast-forward propose: planning shell → Branch binding → Specs landing',
172
+ 'llman-sdd-validate': 'Standalone validation skill',
173
+ 'llman-sdd-arch-review': 'Scan shallow modules for deepening candidates',
174
+ 'llman-sdd-wayfinder': 'Plan large foggy work as a decision map',
175
+ 'llman-sdd-research': 'Delegate external research to a background agent',
176
+ };
177
+ export const skillDesc = (name: string): string => SKILL_DESCRIPTIONS[name] ?? '';
178
+
179
+ export function resolveBackend(flag: string | undefined): 'pageindex' {
180
+ const chosen = flag ?? process.env.LLMAN_SDD_INDEX_BACKEND ?? 'pageindex';
181
+ if (chosen === 'rag') {
182
+ throw new Error(
183
+ 'Backend `rag` is no longer supported. Use the default pageindex backend instead:\nSet `LLMAN_SDD_INDEX_CHAT_MODEL` to a tool-calling chat model, then\nrun `llman-sdd index rebuild`.',
184
+ );
185
+ }
186
+ if (chosen !== 'pageindex') {
187
+ throw new Error(`Unsupported backend: ${chosen}`);
188
+ }
189
+ return 'pageindex';
190
+ }
@@ -0,0 +1,114 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ import {
5
+ nonMainCheckoutWarning,
6
+ probeMainCheckout,
7
+ runFreeze,
8
+ runList,
9
+ runThaw,
10
+ makeWasmSevenZip,
11
+ type SevenZipPort,
12
+ } from '@llman-sdd/core';
13
+ import type { Command } from 'commander';
14
+
15
+ import { CliError } from '../cli-shared.ts';
16
+ import { makeCliGit, makeIo } from '../io.ts';
17
+
18
+ /**
19
+ * r24: warn (never block) when freeze/thaw runs outside the main checkout —
20
+ * the worktree NOT holding the default branch. Probe failures skip silently.
21
+ */
22
+ function warnIfNotMainCheckout(root: string): void {
23
+ const warning = nonMainCheckoutWarning(probeMainCheckout(makeCliGit(root)));
24
+ if (warning !== null) console.log(warning);
25
+ }
26
+
27
+ /**
28
+ * Compiled binaries have no on-disk 7zz.wasm (Emscripten would probe $bunfs
29
+ * and abort), so scripts/build-binary.ts injects it as base64 through the
30
+ * literal define `process.env.LLMAN_SDD_EMBEDDED_7ZZ_WASM_B64` (define only
31
+ * rewrites literal member access — the read must stay here, not in core);
32
+ * unset in source/npm/Node runs → the 7z-wasm glue loads the .wasm from disk.
33
+ * Directory creation for extraction is likewise injected (core stays pure).
34
+ */
35
+ function makeEmbeddedSevenZip(): Promise<SevenZipPort> {
36
+ return makeWasmSevenZip({
37
+ wasmB64: process.env.LLMAN_SDD_EMBEDDED_7ZZ_WASM_B64,
38
+ mkdirp: (dir) => mkdirSync(dir, { recursive: true }),
39
+ });
40
+ }
41
+
42
+ export function registerArchive(program: Command): void {
43
+ const archive = program
44
+ .command('archive')
45
+ .description(
46
+ 'Archive workflow commands (cold backup). Prefer `change finalize` to seal a change',
47
+ );
48
+
49
+ archive
50
+ .command('freeze')
51
+ .description('Freeze dated archived changes into a single 7z cold backup')
52
+ .option('--before <date>', 'freeze entries older than this date (YYYY-MM-DD)')
53
+ .option('--keep-recent <n>', 'keep N most recent candidates unfrozen', '0')
54
+ .option('--dry-run', 'list candidates without freezing')
55
+ .option('--list', 'list entries already in the cold-backup archive')
56
+ .action(
57
+ async (options: {
58
+ before?: string;
59
+ keepRecent: string;
60
+ dryRun?: boolean;
61
+ list?: boolean;
62
+ }) => {
63
+ try {
64
+ const root = process.cwd();
65
+ warnIfNotMainCheckout(root);
66
+ const sz = await makeEmbeddedSevenZip();
67
+ const io = makeIo(root);
68
+ if (options.list) {
69
+ for (const line of await runList(io, sz, root)) console.log(line);
70
+ return;
71
+ }
72
+ const result = await runFreeze(io, sz, root, {
73
+ before: options.before,
74
+ keepRecent: Number(options.keepRecent),
75
+ dryRun: options.dryRun,
76
+ });
77
+ for (const line of result.lines) console.log(line);
78
+ } catch (error) {
79
+ // Emscripten aborts (e.g. wasm load failure) throw raw RuntimeErrors —
80
+ // keep the CLI surface one-line like thaw does.
81
+ throw new CliError((error as Error).message);
82
+ }
83
+ },
84
+ );
85
+
86
+ archive
87
+ .command('thaw')
88
+ .description('Restore archived change directories from the cold-backup archive')
89
+ .requiredOption(
90
+ '--change <name>',
91
+ 'archived change directory to restore (repeatable)',
92
+ (v: string, prev: string[]) => {
93
+ prev.push(v);
94
+ return prev;
95
+ },
96
+ [] as string[],
97
+ )
98
+ .option('--dest <path>', 'restore into this directory (created when missing)')
99
+ .action(async (options: { change: string[]; dest?: string }) => {
100
+ if (options.dest !== undefined) {
101
+ mkdirSync(resolve(options.dest), { recursive: true });
102
+ }
103
+ const root = process.cwd();
104
+ warnIfNotMainCheckout(root);
105
+ const sz = await makeEmbeddedSevenZip();
106
+ const io = makeIo(root);
107
+ try {
108
+ const result = await runThaw(io, sz, root, options.change, { dest: options.dest });
109
+ for (const line of result.lines) console.log(line);
110
+ } catch (error) {
111
+ throw new CliError((error as Error).message);
112
+ }
113
+ });
114
+ }