@opum-ai/lore 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.
- package/LICENSE +21 -0
- package/README.md +306 -0
- package/bin/lore.cjs +109 -0
- package/package.json +67 -0
- package/src/adapters/backlog.ts +1084 -0
- package/src/adapters/git.ts +221 -0
- package/src/cli.ts +667 -0
- package/src/commands/agent.ts +301 -0
- package/src/commands/agents.ts +302 -0
- package/src/commands/args.ts +209 -0
- package/src/commands/changed.ts +70 -0
- package/src/commands/check.ts +1031 -0
- package/src/commands/codex-bridge.ts +49 -0
- package/src/commands/concurrency.ts +48 -0
- package/src/commands/context.ts +292 -0
- package/src/commands/discover.ts +89 -0
- package/src/commands/explorer.ts +253 -0
- package/src/commands/export.ts +93 -0
- package/src/commands/fswrite.ts +928 -0
- package/src/commands/graph.ts +291 -0
- package/src/commands/help.ts +151 -0
- package/src/commands/impact.ts +59 -0
- package/src/commands/init.ts +583 -0
- package/src/commands/instructions.ts +91 -0
- package/src/commands/link.ts +929 -0
- package/src/commands/new.ts +476 -0
- package/src/commands/orphans.ts +457 -0
- package/src/commands/path.ts +67 -0
- package/src/commands/provenance.ts +68 -0
- package/src/commands/query.ts +312 -0
- package/src/commands/reconcile-shared.ts +280 -0
- package/src/commands/rename.ts +585 -0
- package/src/commands/replace.ts +320 -0
- package/src/commands/scaffold.ts +346 -0
- package/src/commands/schema.ts +293 -0
- package/src/commands/snapshot.ts +130 -0
- package/src/commands/supersede.ts +400 -0
- package/src/commands/sync.ts +371 -0
- package/src/commands/tasks.ts +271 -0
- package/src/commands/traversal.ts +151 -0
- package/src/commands/validate.ts +226 -0
- package/src/config.ts +598 -0
- package/src/core/agent-bridge.ts +287 -0
- package/src/core/agent-context.ts +498 -0
- package/src/core/agent-profile.ts +447 -0
- package/src/core/bundle.ts +893 -0
- package/src/core/check.ts +853 -0
- package/src/core/codex-bridge.ts +100 -0
- package/src/core/concept.ts +597 -0
- package/src/core/consumer-scaffold.ts +433 -0
- package/src/core/context.ts +271 -0
- package/src/core/explorer-contract.ts +441 -0
- package/src/core/explorer-qualification.ts +58 -0
- package/src/core/explorer.ts +518 -0
- package/src/core/finding.ts +31 -0
- package/src/core/graph.ts +201 -0
- package/src/core/indexes.ts +436 -0
- package/src/core/instructions.ts +209 -0
- package/src/core/ladybug-driver.ts +1795 -0
- package/src/core/ladybug-lifecycle.ts +1178 -0
- package/src/core/ladybug-native.ts +95 -0
- package/src/core/ladybug-source.ts +667 -0
- package/src/core/links.ts +681 -0
- package/src/core/log.ts +253 -0
- package/src/core/managed-block.ts +540 -0
- package/src/core/manifest.ts +718 -0
- package/src/core/order.ts +13 -0
- package/src/core/profile.ts +1007 -0
- package/src/core/projection.ts +195 -0
- package/src/core/query.ts +542 -0
- package/src/core/reconcile.ts +236 -0
- package/src/core/replace.ts +419 -0
- package/src/core/retrieval.ts +213 -0
- package/src/core/rewrite.ts +940 -0
- package/src/core/scaffold.ts +255 -0
- package/src/core/schema.ts +366 -0
- package/src/core/snapshot-runtime.ts +52 -0
- package/src/core/snapshot-store.ts +287 -0
- package/src/core/snapshot.ts +711 -0
- package/src/core/template.ts +429 -0
- package/src/core/traversal.ts +487 -0
- package/src/core/validate.ts +517 -0
- package/src/core/workspace-contract.ts +473 -0
- package/src/core/workspace-projection.ts +365 -0
- package/src/core/workspace-retrieval.ts +196 -0
- package/src/core/workspace-source.ts +174 -0
- package/src/errors.ts +697 -0
- package/src/meta.ts +7 -0
- package/src/output.ts +589 -0
- package/src/scripts/upstream-backlog-watch.ts +288 -0
- package/src/state.ts +390 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands/init.ts — `lore init`: scaffold an empty, conformant OKF bundle, and fold the rest of
|
|
3
|
+
* onboarding into the SAME one command (LORE-260): the Claude Code agent bridge, downstream
|
|
4
|
+
* doc-site scaffolds (mkdocs/docusaurus/obsidian), and a backlog-coupling capability check —
|
|
5
|
+
* replacing the old `init` → `agents` → external `lore-setup.sh` → manual Obsidian sequence.
|
|
6
|
+
*
|
|
7
|
+
* ## The locked design decision (2026-07-24)
|
|
8
|
+
*
|
|
9
|
+
* A **bare** `lore init` on an interactive terminal runs a guided wizard that asks about each
|
|
10
|
+
* configurable consumer; it is **TTY-gated** — when stdin OR stderr is not a TTY (CI, pipes, a
|
|
11
|
+
* test, or a caller that redirects only one stream), `--json` was requested, or ANY of this
|
|
12
|
+
* command's own flags is passed, it runs fully **non-interactively** with defaults and no prompt
|
|
13
|
+
* can ever block it (the npm-init pattern: interactive on a bare TTY invocation, `-y`/non-TTY skips
|
|
14
|
+
* prompts). **Both stdin and stderr must be real terminals** — every wizard question is written to
|
|
15
|
+
* stderr (cli-contract §4: stdout stays exclusively `init`'s own envelope), so gating on stdin alone
|
|
16
|
+
* would leave the wizard blocked-but-invisible behind a redirected stderr (review round 2,
|
|
17
|
+
* BLOCKING-1 — confirmed live: `lore init >/dev/null 2>&1` under a pty hung forever with zero
|
|
18
|
+
* output). `--json` is a third, independent veto: a machine-readable run must never prompt even at a
|
|
19
|
+
* genuinely interactive terminal. Every wizard question maps 1:1 to a flag (`--agents`,
|
|
20
|
+
* `--scaffold <target>`, `--obsidian`, `--no-backlog`/`--check-backlog`), so a script gets the exact
|
|
21
|
+
* same outcome as answering the wizard, with zero prompts. This is documented in
|
|
22
|
+
* [ADR-0017](../../docs/adr/0017-interactive-init-wizard-tty-gated.md) (an amendment to ADR-0004/
|
|
23
|
+
* ADR-0005's non-interactive CLI contract).
|
|
24
|
+
*
|
|
25
|
+
* **EOF (Ctrl-D) mid-wizard is a `usage` error, not a silent exit 0** (review round 2, BLOCKING-2):
|
|
26
|
+
* `readline/promises`' `rl.question()` never settles on stdin EOF, so a naive implementation left the
|
|
27
|
+
* wizard's promise abandoned forever — the process would exit 0 with `process.exitCode` never set,
|
|
28
|
+
* zero stdout bytes even under `--json` (a parse error for a `| jq` consumer expecting either a valid
|
|
29
|
+
* envelope or a classified failure), and a half-applied run (the base scaffold already written,
|
|
30
|
+
* nothing else). {@link createRealPrompter} now races every question against the readline
|
|
31
|
+
* interface's own `close` event and throws a `usage` {@link LoreError} on an early close, so the run
|
|
32
|
+
* exits non-zero with a rendered diagnostic instead — chosen over silently falling back to each
|
|
33
|
+
* question's default because BLOCKING-1's lesson applies here too: never silently do something the
|
|
34
|
+
* user couldn't see coming.
|
|
35
|
+
*
|
|
36
|
+
* **The non-interactive default is UNCHANGED from before this task**: with no flags and a non-TTY
|
|
37
|
+
* stdin (the automatic case for every existing caller — CI, `lore-setup.sh`, this file's own
|
|
38
|
+
* pre-LORE-260 tests), `lore init` does exactly what it always did — scaffold `docs/`/`.lore/` and
|
|
39
|
+
* nothing else. The agent bridge, scaffolds, and the backlog check are strictly opt-in via flags (or
|
|
40
|
+
* the wizard); this is what keeps the docker e2e harness's existing bare `lore init` calls, and
|
|
41
|
+
* every pre-existing unit test, byte-for-byte compatible with the prior behavior.
|
|
42
|
+
*
|
|
43
|
+
* The base bundle scaffold (this file's original, sole responsibility) is the thin command layer
|
|
44
|
+
* over the pure {@link buildScaffold} (lore-design §2.2, §3.1): it resolves the repo root and a
|
|
45
|
+
* clock, asks core for the intended bytes, and applies them to the filesystem **idempotently**.
|
|
46
|
+
* The load-bearing behavior is idempotency (AC#2/AC#3): every file is created only when **absent**
|
|
47
|
+
* (an atomic `wx` write, so there is no time-of-check/time-of-use race and no clobber of a user's
|
|
48
|
+
* edits), and directories are `mkdir -p` (already-exists is not an error). The agent bridge and
|
|
49
|
+
* scaffold steps reuse the SAME idempotent primitives `lore agents`/`lore scaffold` ship
|
|
50
|
+
* ({@link applyAgentsBridge}/{@link applyScaffold}) rather than duplicating their logic, so a second
|
|
51
|
+
* run of any combination of flags is a no-op wherever the first run already finished.
|
|
52
|
+
*
|
|
53
|
+
* The interactive wizard's TTY gate and its I/O are both **injectable** ({@link InitOptions.stdinIsTTY}
|
|
54
|
+
* / {@link InitOptions.prompter}), never read from `process.stdin` at this call site — a test drives
|
|
55
|
+
* the wizard path by passing `stdinIsTTY: true` plus a scripted {@link InitPrompter}, never a real
|
|
56
|
+
* terminal. `runInit` itself stays a plain (non-`async`) function returning `number | Promise<number>`
|
|
57
|
+
* (mirroring `commands/check.ts`'s own `runCheck`): the common, fully-synchronous path (no flags, no
|
|
58
|
+
* backlog check) returns a plain number exactly as before LORE-260, and only the wizard or an
|
|
59
|
+
* actually-requested backlog check return a `Promise`.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { join } from "node:path";
|
|
63
|
+
import * as readline from "node:readline/promises";
|
|
64
|
+
import type { BacklogAdapter } from "../adapters/backlog";
|
|
65
|
+
import { loadProfile } from "../core/profile";
|
|
66
|
+
import { buildScaffold } from "../core/scaffold";
|
|
67
|
+
import { ANSI, EXIT_OK, LoreError, paint, WarningCollector, type Writer } from "../errors";
|
|
68
|
+
import { emit, type OutputContext, type Renderable } from "../output";
|
|
69
|
+
import { type AgentsResult, applyAgentsBridge, bridgeActionColor, renderTrailer } from "./agents";
|
|
70
|
+
import { optionValues, parseCommandArgs, usage } from "./args";
|
|
71
|
+
import { applyCodexBridge, type CodexBridgeResult } from "./codex-bridge";
|
|
72
|
+
import { assertNoSymlinkInPath, createIfAbsent, ensureDir } from "./fswrite";
|
|
73
|
+
import { defaultAdapter } from "./link";
|
|
74
|
+
import { applyScaffold, TARGETS as SCAFFOLD_TARGETS, type ScaffoldResult } from "./scaffold";
|
|
75
|
+
|
|
76
|
+
/** The backlog-coupling capability check's outcome, folded into {@link InitResult} when it ran. */
|
|
77
|
+
export interface InitBacklogCheck {
|
|
78
|
+
/** Always `true` when this field is present at all — kept explicit (vs. field presence alone) so a `--json` consumer can branch without an `in` check. */
|
|
79
|
+
readonly checked: true;
|
|
80
|
+
/** Whether a `--json`-capable `backlog` was found on PATH. */
|
|
81
|
+
readonly capable: boolean;
|
|
82
|
+
/** The reported `backlog --version`, when capable. */
|
|
83
|
+
readonly version?: string;
|
|
84
|
+
/** The advisory message (also written to stderr) when NOT capable. */
|
|
85
|
+
readonly warning?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The result of a `lore init` run: the base scaffold, plus whichever optional consumers ran. */
|
|
89
|
+
export interface InitResult {
|
|
90
|
+
/** The repo root the bundle was initialized in. */
|
|
91
|
+
root: string;
|
|
92
|
+
/** Repo-relative POSIX paths created this run, in scaffold order (base OKF bundle only). */
|
|
93
|
+
created: string[];
|
|
94
|
+
/** Repo-relative POSIX paths that already existed and were left untouched (base OKF bundle only). */
|
|
95
|
+
skipped: string[];
|
|
96
|
+
/** Whether the interactive wizard ran this invocation. */
|
|
97
|
+
interactive: boolean;
|
|
98
|
+
/** The agent bridge's result, present iff this run set it up (wizard "yes", or `--agents`). */
|
|
99
|
+
agents?: AgentsResult;
|
|
100
|
+
/** Codex bridge result, present iff Codex setup was selected or explicitly requested. */
|
|
101
|
+
codex?: CodexBridgeResult;
|
|
102
|
+
/** One entry per downstream doc-site/vault actually scaffolded this run (wizard picks, `--scaffold`, `--obsidian`); empty when none were requested. */
|
|
103
|
+
scaffolds: ScaffoldResult[];
|
|
104
|
+
/** The backlog-coupling capability check's outcome, present iff it ran this invocation. */
|
|
105
|
+
backlog?: InitBacklogCheck;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The interactive wizard's minimal prompt vocabulary — confirm (yes/no) and choose (one of a fixed list). Injected so the wizard is unit-testable without a real terminal. */
|
|
109
|
+
export interface InitPrompter {
|
|
110
|
+
/** Ask a yes/no question; an empty answer (bare Enter) resolves to `defaultValue`. */
|
|
111
|
+
confirm(question: string, defaultValue: boolean): Promise<boolean>;
|
|
112
|
+
/** Ask the user to pick one of `choices`; an empty or unrecognized answer resolves to `defaultValue`. */
|
|
113
|
+
choose(question: string, choices: readonly string[], defaultValue: string): Promise<string>;
|
|
114
|
+
/** Release the prompter's I/O resources (the real implementation's `readline` interface). */
|
|
115
|
+
close(): void;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Options for {@link runInit}; `root`, `clock`, the streams, the TTY gate, the prompter, and the backlog adapter are all injectable for tests. */
|
|
119
|
+
export interface InitOptions {
|
|
120
|
+
/** The repo root to initialize. */
|
|
121
|
+
root: string;
|
|
122
|
+
/** The resolved output mode/color (from `output.ts`). */
|
|
123
|
+
output: OutputContext;
|
|
124
|
+
/** The command's normalized tokens from Commander. */
|
|
125
|
+
args?: readonly string[];
|
|
126
|
+
/** Clock seam for the root index timestamp (and a fresh scaffold's timestamp); defaults to the real wall clock. */
|
|
127
|
+
clock?: () => Date;
|
|
128
|
+
/** stdout sink; defaults to `process.stdout`. */
|
|
129
|
+
stdout?: Writer;
|
|
130
|
+
/** stderr sink for the backlog-check advisory; defaults to `process.stderr`. */
|
|
131
|
+
stderr?: Writer;
|
|
132
|
+
/**
|
|
133
|
+
* Whether STDIN is an interactive terminal — one half of the wizard's TTY gate (AC#2). Resolved
|
|
134
|
+
* once at the CLI boundary (`cli.ts`'s `run`, mirroring its own `isTTY`/`stderrIsTTY` handling) and
|
|
135
|
+
* handed in here as a plain boolean; this module never reads `process.stdin.isTTY` itself. Defaults
|
|
136
|
+
* to `false` (non-interactive) so an omitted value can never accidentally enable a blocking prompt —
|
|
137
|
+
* the safe default for every existing caller (tests, `lore-setup.sh`, CI) that predates this flag.
|
|
138
|
+
*/
|
|
139
|
+
stdinIsTTY?: boolean;
|
|
140
|
+
/**
|
|
141
|
+
* Whether STDERR is an interactive terminal — the wizard's OTHER required condition (review
|
|
142
|
+
* round 2, BLOCKING-1). Every wizard question is written to stderr (cli-contract §4: stdout stays
|
|
143
|
+
* exclusively `init`'s own envelope), so `stdinIsTTY` alone is not sufficient — a caller that
|
|
144
|
+
* redirects only stderr (`lore init >out 2>/dev/null`, or the universal shell idiom
|
|
145
|
+
* `cmd >/dev/null 2>&1` that `lore-setup.sh` itself uses) still has a readable stdin, and would
|
|
146
|
+
* otherwise block forever on a prompt nobody can see. Resolved once at the CLI boundary exactly
|
|
147
|
+
* like {@link stdinIsTTY} (`cli.ts` already computes this for the error-color gate; LORE-260 round
|
|
148
|
+
* 2 threads the SAME resolved value here instead of leaving it unset). Defaults to `false` for the
|
|
149
|
+
* same "never accidentally enable a blocking prompt" reason.
|
|
150
|
+
*/
|
|
151
|
+
stderrIsTTY?: boolean;
|
|
152
|
+
/**
|
|
153
|
+
* Whether `--json` was requested for this run. A machine-readable invocation must never prompt —
|
|
154
|
+
* even sitting at a real, fully-interactive terminal (review round 2, BLOCKING-1's sibling
|
|
155
|
+
* finding) — since a script piping `--json` output can never answer a wizard question. Kept as its
|
|
156
|
+
* own explicit boolean rather than derived from {@link InitOptions.output}'s `mode` (which governs
|
|
157
|
+
* rendering only, per `output.ts`'s documented single-responsibility split): `output.mode` is
|
|
158
|
+
* always `"json"` in exactly this case for a real `cli.ts` invocation (`resolveMode` maps `--json`
|
|
159
|
+
* to `mode: "json"` unconditionally), but a unit test may also hand-build a `mode: "json"`
|
|
160
|
+
* `OutputContext` purely so it can `JSON.parse` the result for assertions while still wanting the
|
|
161
|
+
* wizard to run (see `test/init.test.ts`'s wizard-path tests) — a separate flag lets that stay
|
|
162
|
+
* possible without conflating "how do we render" with "was `--json` actually on the command line".
|
|
163
|
+
* Defaults to `false` (not requested) when omitted.
|
|
164
|
+
*/
|
|
165
|
+
jsonRequested?: boolean;
|
|
166
|
+
/** The interactive wizard's I/O seam; defaults to a real `node:readline/promises` session over stdin/stderr. Injected in tests so the wizard never touches a real terminal. */
|
|
167
|
+
prompter?: InitPrompter;
|
|
168
|
+
/** The Backlog adapter for the coupling capability check; defaults to the real `backlog` binary on PATH. */
|
|
169
|
+
adapter?: BacklogAdapter;
|
|
170
|
+
/** Injectable executable discovery for the interactive agent choices. */
|
|
171
|
+
agentAvailability?: () => AgentAvailability;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface AgentAvailability {
|
|
175
|
+
readonly claude: boolean;
|
|
176
|
+
readonly codex: boolean;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The parsed, validated `lore init` arguments. */
|
|
180
|
+
interface InitArgs {
|
|
181
|
+
/** `--yes` (or its `--non-interactive` alias, NIT-2): force the non-interactive path (with defaults) even on a TTY — the npm-init `-y` equivalent. */
|
|
182
|
+
yes: boolean;
|
|
183
|
+
/** `--agents`: also set up the Claude Code agent bridge. */
|
|
184
|
+
agents: boolean;
|
|
185
|
+
/** `--codex`: set up the Codex bridge. */
|
|
186
|
+
codex: boolean;
|
|
187
|
+
/** `--scaffold <target>` (repeatable) and/or `--obsidian`, deduped; targets from {@link SCAFFOLD_TARGETS}. */
|
|
188
|
+
scaffolds: string[];
|
|
189
|
+
/** `--no-backlog`: skip the backlog-coupling capability check entirely. */
|
|
190
|
+
noBacklog: boolean;
|
|
191
|
+
/** `--check-backlog`: run the backlog-coupling capability check even with no other flag requesting it. */
|
|
192
|
+
checkBacklog: boolean;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Run `lore init` against `options.root`: scaffold the base bundle idempotently (unchanged from
|
|
197
|
+
* before LORE-260), then resolve the optional consumers — via the interactive wizard on a bare TTY
|
|
198
|
+
* invocation, or from flags otherwise — apply whichever were chosen (also idempotently), render the
|
|
199
|
+
* result, and return the exit code. A filesystem permission failure throws a `denied` {@link
|
|
200
|
+
* LoreError}; a scaffold collision throws `conflict`; a bad flag throws `usage`; any other
|
|
201
|
+
* unexpected IO error propagates to the CLI's top-level handler.
|
|
202
|
+
*
|
|
203
|
+
* Stays a plain (non-`async`) function, like `commands/check.ts`'s `runCheck`: the common path (no
|
|
204
|
+
* flags, no implied backlog check) returns a plain `number`, so every pre-LORE-260 synchronous
|
|
205
|
+
* caller — this file's own original tests, and the router's `--bogus`/positional usage-error paths —
|
|
206
|
+
* is untouched. Only the wizard, or a backlog check actually requested/implied, return a `Promise`.
|
|
207
|
+
*/
|
|
208
|
+
export function runInit(options: InitOptions): number | Promise<number> {
|
|
209
|
+
const parsed = parseInitArgs(options.args ?? []);
|
|
210
|
+
const stdinIsTTY = options.stdinIsTTY ?? false;
|
|
211
|
+
// BLOCKING-1 (review round 2): BOTH streams must be a real terminal — every wizard question is
|
|
212
|
+
// written to stderr, so a redirected stderr with a still-TTY stdin must never engage the wizard
|
|
213
|
+
// (the reader can't see the prompt to answer it). `--json` is an independent third veto: a
|
|
214
|
+
// machine-readable run must never prompt even at a genuinely interactive terminal.
|
|
215
|
+
const stderrIsTTY = options.stderrIsTTY ?? false;
|
|
216
|
+
const jsonRequested = options.jsonRequested ?? false;
|
|
217
|
+
const interactive = stdinIsTTY && stderrIsTTY && !jsonRequested && !anyFlagGiven(parsed);
|
|
218
|
+
|
|
219
|
+
const clock = options.clock ?? (() => new Date());
|
|
220
|
+
// Honor a pre-existing `.lore/profile.toml` so `init` scaffolds schemas for a project's custom
|
|
221
|
+
// types; with none present this is the built-in story-convention profile (zero-config).
|
|
222
|
+
const profile = loadProfile({ root: options.root });
|
|
223
|
+
const plan = buildScaffold({ timestamp: clock().toISOString(), profile });
|
|
224
|
+
|
|
225
|
+
for (const dir of plan.dirs) {
|
|
226
|
+
// LORE-77/LORE-93: ensureDir itself refuses a pre-existing symlink at (or above) this
|
|
227
|
+
// directory before its mkdirSync gets a chance to transparently walk through it.
|
|
228
|
+
ensureDir(options.root, dir);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const created: string[] = [];
|
|
232
|
+
const skipped: string[] = [];
|
|
233
|
+
for (const file of plan.files) {
|
|
234
|
+
assertNoSymlinkInPath(options.root, file.path);
|
|
235
|
+
if (createIfAbsent(join(options.root, file.path), file.contents, file.path)) {
|
|
236
|
+
created.push(file.path);
|
|
237
|
+
} else {
|
|
238
|
+
skipped.push(file.path);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const base = { root: options.root, created, skipped };
|
|
242
|
+
|
|
243
|
+
if (interactive) {
|
|
244
|
+
return runInteractiveWizard(options, base);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const scaffoldTargets = [...new Set(parsed.scaffolds)];
|
|
248
|
+
const agents = parsed.agents ? applyAgentsBridge({ root: options.root, force: false, check: false }) : undefined;
|
|
249
|
+
const codex = parsed.codex ? applyCodexBridge({ root: options.root, force: false, check: false }) : undefined;
|
|
250
|
+
const scaffolds = scaffoldTargets.map((target) => applyScaffold({ root: options.root, target, force: false, clock }));
|
|
251
|
+
|
|
252
|
+
// The backlog check is advisory-only (never fails the run) and, off-TTY/via-flags, runs only when
|
|
253
|
+
// it's actually relevant: explicitly requested (`--check-backlog`), or implied by onboarding a
|
|
254
|
+
// consumer that depends on the coupling (`--agents`/`--scaffold`/`--obsidian`) — unless the user
|
|
255
|
+
// opted all the way out with `--no-backlog`. A completely bare `lore init` therefore never spawns
|
|
256
|
+
// a `backlog` subprocess, exactly as before this task.
|
|
257
|
+
const shouldCheckBacklog =
|
|
258
|
+
parsed.checkBacklog || (!parsed.noBacklog && (parsed.agents || parsed.codex || scaffoldTargets.length > 0));
|
|
259
|
+
if (!shouldCheckBacklog) {
|
|
260
|
+
const result: InitResult = { ...base, interactive: false, agents, codex, scaffolds, backlog: undefined };
|
|
261
|
+
emit(initRenderable(result), options.output, options.stdout);
|
|
262
|
+
return EXIT_OK;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const warnings = new WarningCollector();
|
|
266
|
+
return probeBacklogCapability(options, warnings).then((backlog) => {
|
|
267
|
+
warnings.flush({ color: options.output.color, stderr: options.stderr ?? process.stderr });
|
|
268
|
+
const result: InitResult = { ...base, interactive: false, agents, codex, scaffolds, backlog };
|
|
269
|
+
emit(initRenderable(result), options.output, options.stdout);
|
|
270
|
+
return EXIT_OK;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The interactive wizard (AC#1): ask the three fold-in questions over the injected {@link
|
|
276
|
+
* InitPrompter} (or a real `readline` session over stdin/stderr — prompts and the whole UI live on
|
|
277
|
+
* **stderr**, per cli-contract §4: stdout must stay exclusively `init`'s own envelope), apply
|
|
278
|
+
* whichever were chosen (idempotently, via the exact same core primitives the flag path uses), then
|
|
279
|
+
* ALWAYS run the backlog-coupling detection (a fact-check, not a choice — mirrors AC#1's "detection"
|
|
280
|
+
* wording, not a fourth question) before rendering the combined result.
|
|
281
|
+
*/
|
|
282
|
+
async function runInteractiveWizard(
|
|
283
|
+
options: InitOptions,
|
|
284
|
+
base: { root: string; created: string[]; skipped: string[] },
|
|
285
|
+
): Promise<number> {
|
|
286
|
+
const prompter = options.prompter ?? createRealPrompter();
|
|
287
|
+
const scaffoldTargets: string[] = [];
|
|
288
|
+
let wantAgents = false;
|
|
289
|
+
let wantCodex = false;
|
|
290
|
+
try {
|
|
291
|
+
const available = detectAgentAvailability(options);
|
|
292
|
+
if (available.claude) {
|
|
293
|
+
wantAgents = await prompter.confirm("Set up the Claude Code agent bridge (SKILL.md + CLAUDE.md nudge)?", true);
|
|
294
|
+
}
|
|
295
|
+
if (available.codex) {
|
|
296
|
+
wantCodex = await prompter.confirm("Set up the Codex agent bridge (SKILL.md + AGENTS.md nudge)?", true);
|
|
297
|
+
}
|
|
298
|
+
const site = await prompter.choose("Scaffold a downstream docs site?", ["none", "mkdocs", "docusaurus"], "none");
|
|
299
|
+
if (site !== "none") {
|
|
300
|
+
scaffoldTargets.push(site);
|
|
301
|
+
}
|
|
302
|
+
const wantObsidian = await prompter.confirm("Also scaffold an Obsidian vault config (docs/.obsidian)?", false);
|
|
303
|
+
if (wantObsidian) {
|
|
304
|
+
scaffoldTargets.push("obsidian");
|
|
305
|
+
}
|
|
306
|
+
} finally {
|
|
307
|
+
prompter.close();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const clock = options.clock ?? (() => new Date());
|
|
311
|
+
const agents = wantAgents ? applyAgentsBridge({ root: options.root, force: false, check: false }) : undefined;
|
|
312
|
+
const codex = wantCodex ? applyCodexBridge({ root: options.root, force: false, check: false }) : undefined;
|
|
313
|
+
const scaffolds = scaffoldTargets.map((target) => applyScaffold({ root: options.root, target, force: false, clock }));
|
|
314
|
+
|
|
315
|
+
const warnings = new WarningCollector();
|
|
316
|
+
const backlog = await probeBacklogCapability(options, warnings);
|
|
317
|
+
warnings.flush({ color: options.output.color, stderr: options.stderr ?? process.stderr });
|
|
318
|
+
|
|
319
|
+
const result: InitResult = { ...base, interactive: true, agents, codex, scaffolds, backlog };
|
|
320
|
+
emit(initRenderable(result), options.output, options.stdout);
|
|
321
|
+
return EXIT_OK;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* A real, interactive {@link InitPrompter} over the given streams (defaulting to
|
|
326
|
+
* `process.stdin`/`process.stderr`) — constructed only when the wizard actually runs and no
|
|
327
|
+
* test-injected prompter was given. `streams` is a parameter (not hard-coded) purely so a unit test
|
|
328
|
+
* can exercise this function's own EOF handling over a fake stream pair, never a real terminal.
|
|
329
|
+
*
|
|
330
|
+
* **BLOCKING-2 (review round 2):** `readline/promises`' `rl.question()` never settles when its input
|
|
331
|
+
* stream hits EOF (Ctrl-D, or stdin simply closing) — confirmed live: a pending `question()` call
|
|
332
|
+
* neither resolves nor rejects, so a naive implementation left the wizard's promise abandoned
|
|
333
|
+
* forever, `finally { prompter.close() }` never ran, and the process fell through to exit `0` with
|
|
334
|
+
* `process.exitCode` unset and zero stdout bytes (a broken `--json | jq` contract on top of a
|
|
335
|
+
* half-applied run). Every `question()` call is now raced against the readline interface's own
|
|
336
|
+
* `close` event via {@link ask}: the interface closes on EOF regardless of whether `question()`
|
|
337
|
+
* itself ever settles, so the race always resolves. **Disposition: error out, not silently default**
|
|
338
|
+
* (documented in ADR-0017 and this task's Implementation Notes) — an early close throws a `usage`
|
|
339
|
+
* {@link LoreError} rather than silently resolving to each question's default value, for the same
|
|
340
|
+
* reason as BLOCKING-1: a user who hits Ctrl-D gets no visual confirmation of what happened, so
|
|
341
|
+
* guessing an answer on their behalf and proceeding is exactly the kind of invisible side effect
|
|
342
|
+
* BLOCKING-1 already ruled out. The rejection propagates out of `confirm`/`choose`, unwinds
|
|
343
|
+
* `runInteractiveWizard`'s `try`/`finally` (which still calls `prompter.close()` — safe here since
|
|
344
|
+
* `rl.close()` is idempotent and the interface is already closing), and reaches `cli.ts`'s async
|
|
345
|
+
* error path, which renders the diagnostic and maps `usage` to exit `2`.
|
|
346
|
+
*
|
|
347
|
+
* The `closedEarly` promise is given a standalone `.catch(() => {})` in addition to being raced,
|
|
348
|
+
* because the *normal* (non-EOF) completion path also ends in `prompter.close()` — every question
|
|
349
|
+
* answered, `runInteractiveWizard`'s `finally` calls `close()` intentionally, which emits `close` for
|
|
350
|
+
* the FIRST time in that path and would otherwise reject an unobserved promise (an unhandled
|
|
351
|
+
* rejection) after every race has already settled successfully.
|
|
352
|
+
*/
|
|
353
|
+
export function createRealPrompter(
|
|
354
|
+
streams: { input: NodeJS.ReadableStream; output: NodeJS.WritableStream } = {
|
|
355
|
+
input: process.stdin,
|
|
356
|
+
output: process.stderr,
|
|
357
|
+
},
|
|
358
|
+
): InitPrompter {
|
|
359
|
+
const rl = readline.createInterface({ input: streams.input, output: streams.output });
|
|
360
|
+
const closedEarly = new Promise<never>((_resolve, reject) => {
|
|
361
|
+
rl.once("close", () => {
|
|
362
|
+
reject(
|
|
363
|
+
new LoreError(
|
|
364
|
+
"usage",
|
|
365
|
+
"stdin closed or the wizard was interrupted before it finished (EOF/Ctrl-D or Ctrl-C)",
|
|
366
|
+
"answer every prompt, or run prompt-free with `lore init --yes` (or --claude/--codex/--scaffold <target>/--obsidian/--no-backlog/--check-backlog)",
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
// Prevents an "unhandled promise rejection" once the wizard finishes normally and its own
|
|
372
|
+
// `prompter.close()` call fires `close` for the first time, after every `ask()` race is already
|
|
373
|
+
// settled and nothing is awaiting `closedEarly` anymore — see the doc comment above.
|
|
374
|
+
closedEarly.catch(() => {});
|
|
375
|
+
|
|
376
|
+
/** Race one `rl.question()` call against the interface's own `close` event (see the doc above). */
|
|
377
|
+
function ask(promptText: string): Promise<string> {
|
|
378
|
+
return Promise.race([rl.question(promptText), closedEarly]);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
async confirm(question, defaultValue) {
|
|
383
|
+
const suffix = defaultValue ? "Y/n" : "y/N";
|
|
384
|
+
const raw = (await ask(`${question} [${suffix}] `)).trim().toLowerCase();
|
|
385
|
+
if (raw === "") {
|
|
386
|
+
return defaultValue;
|
|
387
|
+
}
|
|
388
|
+
return raw === "y" || raw === "yes";
|
|
389
|
+
},
|
|
390
|
+
async choose(question, choices, defaultValue) {
|
|
391
|
+
const raw = (await ask(`${question} (${choices.join("/")}) [${defaultValue}] `)).trim().toLowerCase();
|
|
392
|
+
return choices.includes(raw) ? raw : defaultValue;
|
|
393
|
+
},
|
|
394
|
+
close() {
|
|
395
|
+
rl.close();
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Run the backlog-coupling capability check (AC#1/AC#4): probe the injected {@link
|
|
402
|
+
* InitOptions.adapter} (defaulting to the real `backlog` binary on PATH). Never throws — a
|
|
403
|
+
* missing/incapable binary is recorded as `warnings` advisory (stderr) plus the returned {@link
|
|
404
|
+
* InitBacklogCheck}, never a failed `lore init` run, since the base scaffold (and any agent
|
|
405
|
+
* bridge/doc-site scaffold already applied) succeeded regardless of whether Backlog.md coupling is
|
|
406
|
+
* available yet.
|
|
407
|
+
*/
|
|
408
|
+
async function probeBacklogCapability(options: InitOptions, warnings: WarningCollector): Promise<InitBacklogCheck> {
|
|
409
|
+
const adapter = options.adapter ?? defaultAdapter(options.root);
|
|
410
|
+
try {
|
|
411
|
+
const capability = await adapter.probe();
|
|
412
|
+
return { checked: true, capable: true, version: capability.version };
|
|
413
|
+
} catch (err) {
|
|
414
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
415
|
+
const hint = err instanceof LoreError ? err.hint : undefined;
|
|
416
|
+
warnings.add(`backlog coupling unavailable: ${message}${hint ? ` — ${hint}` : ""}`);
|
|
417
|
+
return { checked: true, capable: false, warning: message };
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Whether any of `lore init`'s own flags was passed — the signal that overrides a bare-TTY invocation into the non-interactive path (AC#2). */
|
|
422
|
+
function anyFlagGiven(parsed: InitArgs): boolean {
|
|
423
|
+
return (
|
|
424
|
+
parsed.yes ||
|
|
425
|
+
parsed.agents ||
|
|
426
|
+
parsed.codex ||
|
|
427
|
+
parsed.scaffolds.length > 0 ||
|
|
428
|
+
parsed.noBacklog ||
|
|
429
|
+
parsed.checkBacklog
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Parse `init`'s tokens: no positionals (unchanged from before LORE-260 — a bare/`--`-terminated
|
|
435
|
+
* positional is still a `usage` error, byte-identical wording to the router's old blanket
|
|
436
|
+
* `rejectCommandArgs` guard so every pre-existing regression test keeps passing), plus the boolean
|
|
437
|
+
* `--yes` (alias `--non-interactive`, NIT-2)/`--agents`/`--obsidian`/`--no-backlog`/`--check-backlog`
|
|
438
|
+
* and the repeatable value flag `--scaffold <target>`. An unknown flag, an invalid `--scaffold`
|
|
439
|
+
* target, a stray positional, or the mutually-exclusive `--no-backlog`+`--check-backlog` pair all
|
|
440
|
+
* throw a `usage` {@link LoreError} (exit `2`) before any scaffold work runs.
|
|
441
|
+
*/
|
|
442
|
+
function parseInitArgs(args: readonly string[]): InitArgs {
|
|
443
|
+
const parsed = parseCommandArgs(args, "init");
|
|
444
|
+
const yes = parsed.flags.has("yes") || parsed.flags.has("non-interactive");
|
|
445
|
+
const agents = parsed.flags.has("agents") || parsed.flags.has("claude");
|
|
446
|
+
const codex = parsed.flags.has("codex");
|
|
447
|
+
const noBacklog = parsed.flags.has("no-backlog");
|
|
448
|
+
const checkBacklog = parsed.flags.has("check-backlog");
|
|
449
|
+
const scaffolds: string[] = [];
|
|
450
|
+
for (const value of optionValues(parsed, "scaffold")) {
|
|
451
|
+
if (value === "") {
|
|
452
|
+
throw usage("--scaffold needs a value", "pass a value, e.g. `--scaffold mkdocs`");
|
|
453
|
+
}
|
|
454
|
+
if (!SCAFFOLD_TARGETS.has(value)) {
|
|
455
|
+
throw usage(`unknown scaffold target "${value}"`, `valid targets are ${[...SCAFFOLD_TARGETS].join(", ")}`);
|
|
456
|
+
}
|
|
457
|
+
if (!scaffolds.includes(value)) scaffolds.push(value);
|
|
458
|
+
}
|
|
459
|
+
if (parsed.flags.has("obsidian") && !scaffolds.includes("obsidian")) scaffolds.push("obsidian");
|
|
460
|
+
if (parsed.positionals.length > 0) {
|
|
461
|
+
// Byte-identical wording to the router's pre-LORE-260 `rejectCommandArgs` guard (cli.ts), which
|
|
462
|
+
// used to reject EVERY token this command received — `lore init` still takes no positionals.
|
|
463
|
+
throw usage(
|
|
464
|
+
`\`lore init\` takes no arguments, got "${parsed.positionals[0]}"`,
|
|
465
|
+
"run `lore init` with no positional arguments",
|
|
466
|
+
{ command: "init", unexpected: [...parsed.positionals] },
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
if (noBacklog && checkBacklog) {
|
|
470
|
+
throw usage(
|
|
471
|
+
"--no-backlog and --check-backlog are mutually exclusive",
|
|
472
|
+
"pass at most one of --no-backlog / --check-backlog",
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
return { yes, agents, codex, scaffolds, noBacklog, checkBacklog };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Detect installed agents without making absence or a broken PATH fatal to onboarding. */
|
|
479
|
+
function detectAgentAvailability(options: InitOptions): AgentAvailability {
|
|
480
|
+
if (options.agentAvailability) return options.agentAvailability();
|
|
481
|
+
try {
|
|
482
|
+
return { claude: Bun.which("claude") !== null, codex: Bun.which("codex") !== null };
|
|
483
|
+
} catch {
|
|
484
|
+
return { claude: false, codex: false };
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** The per-result-type rendering bundle for `init` (output.ts dispatches on the mode). */
|
|
489
|
+
function initRenderable(data: InitResult): Renderable<InitResult> {
|
|
490
|
+
return { kind: "init", data, pretty: renderPretty, plain: renderPlain };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Human view: the base scaffold summary, then a section per optional consumer that ran this run. */
|
|
494
|
+
function renderPretty(data: InitResult, opts: { color: boolean }): string {
|
|
495
|
+
const head = data.created.length
|
|
496
|
+
? `Initialized lore bundle at ${data.root}`
|
|
497
|
+
: `lore bundle already initialized at ${data.root} (nothing to create)`;
|
|
498
|
+
const lines = [head];
|
|
499
|
+
for (const path of data.created) {
|
|
500
|
+
lines.push(` ${paint("+", ANSI.green, opts.color)} ${path}`);
|
|
501
|
+
}
|
|
502
|
+
for (const path of data.skipped) {
|
|
503
|
+
lines.push(` ${paint(`· ${path} (exists)`, ANSI.dim, opts.color)}`);
|
|
504
|
+
}
|
|
505
|
+
if (data.agents) {
|
|
506
|
+
lines.push("Claude Code bridge:");
|
|
507
|
+
for (const file of data.agents.files) {
|
|
508
|
+
// "protected" is a warning, not a success (LORE-260 review round 2, MINOR-4): a hand-edited
|
|
509
|
+
// file was left untouched, which is meaningfully different from "unchanged" (nothing to do)
|
|
510
|
+
// and must not be painted the same green as an actual write. Shared with `lore agents`' own
|
|
511
|
+
// renderer (LORE-267) so the two commands cannot diverge on this mapping again.
|
|
512
|
+
lines.push(` ${paint(file.action, bridgeActionColor(file.action), opts.color)} ${file.path}`);
|
|
513
|
+
}
|
|
514
|
+
// Reuse `lore agents`' own trailer verbatim (MINOR-4) rather than dropping it: a `protected`
|
|
515
|
+
// file with no visible remedy reads as silent success (LORE-129 established this line as
|
|
516
|
+
// load-bearing).
|
|
517
|
+
const agentsTrailer = renderTrailer(data.agents);
|
|
518
|
+
if (agentsTrailer !== undefined) {
|
|
519
|
+
lines.push(paint(agentsTrailer, ANSI.yellow, opts.color));
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (data.codex) {
|
|
523
|
+
lines.push("Codex bridge:");
|
|
524
|
+
for (const file of data.codex.files) {
|
|
525
|
+
lines.push(` ${paint(file.action, bridgeActionColor(file.action), opts.color)} ${file.path}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
for (const scaffold of data.scaffolds) {
|
|
529
|
+
lines.push(`Scaffold (${scaffold.target}):`);
|
|
530
|
+
if (scaffold.files.length === 0) {
|
|
531
|
+
lines.push(` ${paint("already up to date", ANSI.dim, opts.color)}`);
|
|
532
|
+
}
|
|
533
|
+
for (const file of scaffold.files) {
|
|
534
|
+
lines.push(` ${paint(file.action, ANSI.green, opts.color)} ${file.path}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (data.backlog) {
|
|
538
|
+
lines.push(
|
|
539
|
+
data.backlog.capable
|
|
540
|
+
? `backlog: --json-capable${data.backlog.version ? ` (v${data.backlog.version})` : ""}`
|
|
541
|
+
: paint("backlog: not --json-capable — see the warning above (coupling unavailable)", ANSI.yellow, opts.color),
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
if (data.interactive) {
|
|
545
|
+
lines.push("Run `lore instructions` for the canonical agent loop.");
|
|
546
|
+
}
|
|
547
|
+
return lines.join("\n");
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** ANSI-free, diff-stable view: one line per base-scaffold path, then one line per optional-consumer action. */
|
|
551
|
+
function renderPlain(data: InitResult): string {
|
|
552
|
+
const lines = [...data.created.map((path) => `created ${path}`), ...data.skipped.map((path) => `exists ${path}`)];
|
|
553
|
+
if (data.agents) {
|
|
554
|
+
for (const file of data.agents.files) {
|
|
555
|
+
lines.push(`agents-${file.action} ${file.path}`);
|
|
556
|
+
}
|
|
557
|
+
const agentsTrailer = renderTrailer(data.agents);
|
|
558
|
+
if (agentsTrailer !== undefined) {
|
|
559
|
+
lines.push(agentsTrailer);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (data.codex) {
|
|
563
|
+
for (const file of data.codex.files) {
|
|
564
|
+
lines.push(`codex-${file.action} ${file.path}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
for (const scaffold of data.scaffolds) {
|
|
568
|
+
// NIT-1 (review round 2): an already-up-to-date scaffold produced NO line at all in plain mode
|
|
569
|
+
// (renderPretty said "already up to date"; renderPlain said nothing), so a --plain consumer
|
|
570
|
+
// couldn't tell the step ran versus never having been requested.
|
|
571
|
+
if (scaffold.files.length === 0) {
|
|
572
|
+
lines.push(`scaffold-${scaffold.target} up-to-date`);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
for (const file of scaffold.files) {
|
|
576
|
+
lines.push(`scaffold-${scaffold.target}-${file.action} ${file.path}`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (data.backlog) {
|
|
580
|
+
lines.push(data.backlog.capable ? "backlog capable" : "backlog incapable");
|
|
581
|
+
}
|
|
582
|
+
return lines.join("\n");
|
|
583
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands/instructions.ts — `lore instructions [<topic>]` (cli-surface §instructions).
|
|
3
|
+
*
|
|
4
|
+
* The thin, read-only layer over the static topic registry in
|
|
5
|
+
* `core/instructions.ts`: it parses the one optional positional, looks up the
|
|
6
|
+
* topic, and emits it. There is no bundle to load and no config to read, so
|
|
7
|
+
* unlike most commands this one needs neither `root` nor a `WarningCollector`.
|
|
8
|
+
*
|
|
9
|
+
* With no `<topic>` it prints `overview` (the canonical loop + a topic index);
|
|
10
|
+
* an unrecognized `<topic>` is a `not_found` error (exit 3) whose hint lists
|
|
11
|
+
* the valid keys. Argument parsing goes through `args.ts`'s shared Commander parser
|
|
12
|
+
* with no known flags, so any `-`-prefixed token or a second positional is a
|
|
13
|
+
* `usage` error (exit 2), matching every other command's strict parsing.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { findInstructionTopic, INSTRUCTION_TOPICS, type InstructionTopic } from "../core/instructions";
|
|
17
|
+
import { ANSI, EXIT_OK, LoreError, paint, type Writer } from "../errors";
|
|
18
|
+
import { emit, type OutputContext, type Renderable } from "../output";
|
|
19
|
+
import { parseCommandArgs, usage } from "./args";
|
|
20
|
+
|
|
21
|
+
/** Options for {@link runInstructions}; the streams are injectable for tests. */
|
|
22
|
+
export interface InstructionsOptions {
|
|
23
|
+
/** The resolved output mode/color (from `output.ts`). */
|
|
24
|
+
output: OutputContext;
|
|
25
|
+
/** The command's normalized positional tokens from Commander. */
|
|
26
|
+
args: readonly string[];
|
|
27
|
+
/** stdout sink; defaults to `process.stdout`. */
|
|
28
|
+
stdout?: Writer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The `instructions.text` payload: the requested topic plus the full topic index, so a `--json` caller can discover the other keys without a second request. */
|
|
32
|
+
export interface InstructionsData {
|
|
33
|
+
/** The topic actually served (`overview` when no `<topic>` was given). */
|
|
34
|
+
topic: string;
|
|
35
|
+
/** The topic's one-line heading. */
|
|
36
|
+
title: string;
|
|
37
|
+
/** The topic's full guidance body. */
|
|
38
|
+
body: string;
|
|
39
|
+
/** Every topic `lore instructions` can serve, in index order. */
|
|
40
|
+
topics: ReadonlyArray<{ key: string; title: string }>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Run `lore instructions`: parse the optional `<topic>`, look it up, emit it, and return `0`. */
|
|
44
|
+
export function runInstructions(options: InstructionsOptions): number {
|
|
45
|
+
const key = parseInstructionsArgs(options.args);
|
|
46
|
+
const topic = findInstructionTopic(key);
|
|
47
|
+
if (topic === undefined) {
|
|
48
|
+
const validKeys = INSTRUCTION_TOPICS.map((t) => t.key).join(", ");
|
|
49
|
+
throw new LoreError(
|
|
50
|
+
"not_found",
|
|
51
|
+
`unknown instructions topic "${key}"`,
|
|
52
|
+
`valid topics: ${validKeys}; run \`lore instructions\` for the overview`,
|
|
53
|
+
{ topic: key },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
emit(instructionsRenderable(topic), options.output, options.stdout);
|
|
57
|
+
return EXIT_OK;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Parse `instructions`' tokens via the shared parser (no known flags): at most one positional (the topic key, defaulting to `overview`); any flag or second positional is a `usage` error. */
|
|
61
|
+
function parseInstructionsArgs(args: readonly string[]): string {
|
|
62
|
+
const { positionals } = parseCommandArgs(args, "instructions");
|
|
63
|
+
if (positionals.length > 1) {
|
|
64
|
+
throw usage(`unexpected argument "${positionals[1]}"`, "run `lore instructions [<topic>]`");
|
|
65
|
+
}
|
|
66
|
+
return positionals[0] ?? "overview";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Build the `instructions.text` {@link Renderable} for one topic. */
|
|
70
|
+
function instructionsRenderable(topic: InstructionTopic): Renderable<InstructionsData> {
|
|
71
|
+
const data: InstructionsData = {
|
|
72
|
+
topic: topic.key,
|
|
73
|
+
title: topic.title,
|
|
74
|
+
body: topic.body,
|
|
75
|
+
topics: INSTRUCTION_TOPICS.map(({ key, title }) => ({ key, title })),
|
|
76
|
+
};
|
|
77
|
+
return { kind: "instructions.text", data, pretty: renderPretty, plain: renderPlain };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `<title>` heading (painted when `color`) + a blank line + the body. Shared by pretty/plain so the two differ only in color. */
|
|
81
|
+
function render(data: InstructionsData, color: boolean): string {
|
|
82
|
+
return `${paint(data.title, ANSI.green, color)}\n\n${data.body}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderPretty(data: InstructionsData, opts: { color: boolean }): string {
|
|
86
|
+
return render(data, opts.color);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderPlain(data: InstructionsData): string {
|
|
90
|
+
return render(data, false);
|
|
91
|
+
}
|