@llman-sdd/cli 0.3.0 → 0.5.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.
- package/package.json +2 -2
- package/src/cli-shared.ts +190 -0
- package/src/commands/archive.ts +114 -0
- package/src/commands/change.ts +389 -0
- package/src/commands/config.ts +39 -0
- package/src/commands/context.ts +59 -0
- package/src/commands/graph.ts +35 -0
- package/src/commands/index.ts +43 -0
- package/src/commands/init.ts +37 -0
- package/src/commands/list.ts +69 -0
- package/src/commands/project.ts +64 -0
- package/src/commands/review.ts +83 -0
- package/src/commands/show.ts +268 -0
- package/src/commands/spec.ts +181 -0
- package/src/commands/validate.ts +459 -0
- package/src/harness.ts +31 -0
- package/src/io.ts +4 -0
- package/src/main.ts +58 -1420
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llman-sdd/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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.
|
|
30
|
+
"@llman-sdd/core": "0.5.0",
|
|
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
|
+
}
|