@markbrutx/promptbook-cli 0.2.0 → 0.4.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.
@@ -0,0 +1,221 @@
1
+ import { relative, sep } from "node:path";
2
+ import { watch as chokidarWatch } from "chokidar";
3
+ import type { ParsedArgs } from "../args.js";
4
+ import { requirePromptsDir } from "../config.js";
5
+ import { colorEnabled, type IO } from "../io.js";
6
+ import { makeStyle, type Style } from "../style.js";
7
+ import { type Book, loadWorkspace } from "../workspace.js";
8
+ import { bundleOne } from "./bundle.js";
9
+
10
+ /** Folders inside a book whose edits warrant a rebuild (mirrors core's loader layout). */
11
+ const BOOK_DIRS = ["fragments", "rules", "code-prompts"];
12
+
13
+ /** Single-book root files we re-bundle on touch (config edits change the assembly). */
14
+ const BOOK_FILES = ["promptbook.json"];
15
+
16
+ /** Debounce window per book: a burst of edits collapses into one rebuild. */
17
+ const DEBOUNCE_MS = 250;
18
+
19
+ interface RebuildStats {
20
+ bytes: number;
21
+ ms: number;
22
+ }
23
+
24
+ /** Local-time clock prefix (`hours:minutes:seconds`); slices off Date#toTimeString's TZ tail. */
25
+ function clock(): string {
26
+ return new Date().toTimeString().slice(0, 8);
27
+ }
28
+
29
+ /** True when `event` happened inside one of the book's watched folders (or is a root file). */
30
+ function eventInBook(book: Book, eventPath: string): boolean {
31
+ const rel = relative(book.dir, eventPath);
32
+ if (rel === "" || rel.startsWith("..")) {
33
+ return false;
34
+ }
35
+ const parts = rel.split(sep);
36
+ if (parts.length === 1) {
37
+ return BOOK_FILES.includes(parts[0] as string);
38
+ }
39
+ return BOOK_DIRS.includes(parts[0] as string);
40
+ }
41
+
42
+ /** Ignore artifacts, tests, fixtures, and everything VCS / dep manager touches. */
43
+ function shouldIgnore(eventPath: string): boolean {
44
+ const lower = eventPath.toLowerCase();
45
+ if (lower.endsWith("book.generated.ts")) {
46
+ return true;
47
+ }
48
+ if (lower.endsWith(".test.ts") || lower.endsWith(".test.js")) {
49
+ return true;
50
+ }
51
+ const segments = eventPath.split(sep);
52
+ for (const segment of segments) {
53
+ if (segment === "node_modules" || segment === ".git" || segment === "fixtures") {
54
+ return true;
55
+ }
56
+ }
57
+ return false;
58
+ }
59
+
60
+ /** Wrap `io` so `bundleOne`'s `wrote <path>` chatter is dropped and the artifact size is captured. */
61
+ function captureBytes(io: IO): { sink: IO; bytes: () => number } {
62
+ let bytes = 0;
63
+ const sink: IO = {
64
+ ...io,
65
+ stderr(text) {
66
+ if (text.startsWith("wrote ")) {
67
+ return;
68
+ }
69
+ io.stderr(text);
70
+ },
71
+ async writeFile(path, contents) {
72
+ bytes = contents.length;
73
+ await io.writeFile(path, contents);
74
+ },
75
+ };
76
+ return { sink, bytes: () => bytes };
77
+ }
78
+
79
+ function emitEvent(
80
+ io: IO,
81
+ args: ParsedArgs,
82
+ style: Style,
83
+ event: "started" | "bundled" | "error" | "stopped",
84
+ payload: Record<string, unknown> = {},
85
+ ): void {
86
+ if (args.json) {
87
+ io.stderr(`${JSON.stringify({ event, ts: clock(), ...payload })}\n`);
88
+ return;
89
+ }
90
+ const ts = style.dim(`[${clock()}]`);
91
+ if (event === "started") {
92
+ const books = payload.books as string[];
93
+ io.stderr(`${ts} watching ${books.length} book(s): ${books.join(", ")}\n`);
94
+ return;
95
+ }
96
+ if (event === "bundled") {
97
+ io.stderr(`${ts} ${payload.book} bundled (${payload.bytes} B, ${payload.ms}ms)\n`);
98
+ return;
99
+ }
100
+ if (event === "stopped") {
101
+ io.stderr("stopped\n");
102
+ return;
103
+ }
104
+ io.stderr(`${ts} ${payload.book} ${style.red("ERROR")}: ${payload.message}\n`);
105
+ }
106
+
107
+ async function rebuild(io: IO, args: ParsedArgs, book: Book): Promise<RebuildStats | Error> {
108
+ const start = Date.now();
109
+ const capture = captureBytes(io);
110
+ try {
111
+ const code = await bundleOne(capture.sink, args, book.dir, book.name, true);
112
+ if (code !== 0) {
113
+ return new Error(`bundle exited with code ${code}`);
114
+ }
115
+ return { bytes: capture.bytes(), ms: Date.now() - start };
116
+ } catch (error) {
117
+ return error as Error;
118
+ }
119
+ }
120
+
121
+ /** Run one rebuild and emit its bundled/error event. */
122
+ async function rebuildAndReport(io: IO, args: ParsedArgs, style: Style, book: Book): Promise<void> {
123
+ const result = await rebuild(io, args, book);
124
+ if (result instanceof Error) {
125
+ emitEvent(io, args, style, "error", { book: book.name, message: result.message });
126
+ } else {
127
+ emitEvent(io, args, style, "bundled", { book: book.name, bytes: result.bytes, ms: result.ms });
128
+ }
129
+ }
130
+
131
+ /**
132
+ * `watch [<dir>]`: rebuild `book.generated.ts` whenever fragments, rules,
133
+ * compositions, code-prompts or `promptbook.json` change. Streams one short
134
+ * line per rebuild to stderr (`[clock] <book> bundled (<bytes> B, <ms>ms)`);
135
+ * stdout stays empty (the contract: stdout = payload, watch has no payload).
136
+ *
137
+ * Discovers every book under the prompts folder and rebuilds each once on
138
+ * startup, then debounces per-book events with a 250 ms window so a burst of
139
+ * edits collapses into one rebuild. SIGINT / SIGTERM closes the watcher and
140
+ * exits 0. Honors `--plain`, `--exclude-code-prompts`, `--json`, and the
141
+ * config / `--dir` resolution chain.
142
+ *
143
+ * `--out <file>` only works in a single-book workspace; the multi-book mode
144
+ * always writes each book's artifact next to its sources.
145
+ */
146
+ export async function cmdWatch(args: ParsedArgs, io: IO): Promise<number> {
147
+ if (args.check) {
148
+ io.stderr("error: --check is not supported by watch (run `bundle --check --all` from CI)\n");
149
+ return 1;
150
+ }
151
+
152
+ const promptsDir = await requirePromptsDir(io, args.operands[0] ?? args.dir);
153
+ if (promptsDir === null) {
154
+ return 1;
155
+ }
156
+
157
+ const workspace = await loadWorkspace(io, promptsDir);
158
+ if (workspace.books.length === 0) {
159
+ io.stderr(`error: no books found under ${promptsDir}\n`);
160
+ return 1;
161
+ }
162
+ if (args.out !== undefined && workspace.books.length > 1) {
163
+ io.stderr("error: --out requires a single book; drop --out to write each book's book.generated.ts\n");
164
+ return 1;
165
+ }
166
+
167
+ const style = makeStyle(colorEnabled(io));
168
+ emitEvent(io, args, style, "started", { books: workspace.books.map((b) => b.name) });
169
+
170
+ // Initial pass runs in parallel: each book writes its own artifact, no contention.
171
+ await Promise.all(workspace.books.map((book) => rebuildAndReport(io, args, style, book)));
172
+
173
+ const watcher = chokidarWatch(promptsDir, {
174
+ ignoreInitial: true,
175
+ awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
176
+ ignored: (eventPath) => shouldIgnore(eventPath),
177
+ });
178
+
179
+ const timers = new Map<string, NodeJS.Timeout>();
180
+ const scheduleRebuild = (book: Book): void => {
181
+ const existing = timers.get(book.name);
182
+ if (existing !== undefined) {
183
+ clearTimeout(existing);
184
+ }
185
+ const timer = setTimeout(() => {
186
+ timers.delete(book.name);
187
+ void rebuildAndReport(io, args, style, book);
188
+ }, DEBOUNCE_MS);
189
+ timers.set(book.name, timer);
190
+ };
191
+
192
+ watcher.on("all", (_event, eventPath) => {
193
+ const book = workspace.books.find((b) => eventInBook(b, eventPath));
194
+ if (book === undefined) {
195
+ return;
196
+ }
197
+ scheduleRebuild(book);
198
+ });
199
+
200
+ // Hold the promise open until SIGINT / SIGTERM closes the watcher.
201
+ return new Promise<number>((resolveDone) => {
202
+ let stopped = false;
203
+ const stop = async (): Promise<void> => {
204
+ if (stopped) {
205
+ return;
206
+ }
207
+ stopped = true;
208
+ for (const timer of timers.values()) {
209
+ clearTimeout(timer);
210
+ }
211
+ timers.clear();
212
+ await watcher.close();
213
+ emitEvent(io, args, style, "stopped");
214
+ process.off("SIGINT", stop);
215
+ process.off("SIGTERM", stop);
216
+ resolveDone(0);
217
+ };
218
+ process.once("SIGINT", stop);
219
+ process.once("SIGTERM", stop);
220
+ });
221
+ }
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { resolve as resolvePath } from "node:path";
1
+ import { dirname, resolve as resolvePath } from "node:path";
2
2
  import type { Context, ContextValue } from "@markbrutx/promptbook-core";
3
3
  import type { IO } from "./io.js";
4
4
 
@@ -90,6 +90,14 @@ interface PromptbookConfig {
90
90
  eval?: unknown;
91
91
  }
92
92
 
93
+ /** A loaded `promptbook.json`: parsed data plus the directory that held it. */
94
+ export interface LoadedConfig {
95
+ /** Parsed JSON object (or `{}` when no file was found or parsing failed). */
96
+ data: PromptbookConfig;
97
+ /** Absolute directory where the config was found; `undefined` if nothing matched up the tree. */
98
+ dir?: string;
99
+ }
100
+
93
101
  /** lint options sourced from the `lint` section of `promptbook.json`. */
94
102
  export interface LintConfig {
95
103
  maxTokens?: number;
@@ -97,30 +105,45 @@ export interface LintConfig {
97
105
  }
98
106
 
99
107
  /**
100
- * Read and parse `promptbook.json` from cwd once. Missing, unreadable or
101
- * malformed config yields an empty object, so callers treat it as best-effort
102
- * and layer flags on top. Pass the result to {@link resolvePromptsDir} and
103
- * {@link lintConfigFrom} to avoid re-reading the file per command.
108
+ * Walk up from `io.cwd()` to find the first `promptbook.json`, parse it, and
109
+ * return its data + the directory it lived in. Walking up (rather than only
110
+ * checking cwd) is what makes `promptbook` work like `git`/`biome`/`eslint`:
111
+ * one config at the repo root reaches every subfolder. Path-valued keys
112
+ * (currently just `promptsDir`) are resolved relative to {@link LoadedConfig.dir}
113
+ * by {@link resolvePromptsDir}, not relative to wherever the shell happens to
114
+ * be — so `pnpm exec` snapping cwd to a workspace package cannot break the
115
+ * lookup. Missing, unreadable or malformed files yield an empty config
116
+ * (best-effort), so callers can layer flags on top.
104
117
  */
105
- export async function loadConfig(io: IO): Promise<PromptbookConfig> {
106
- const configPath = resolvePath(io.cwd(), "promptbook.json");
107
- let raw: string;
108
- try {
109
- raw = await io.fs.readFile(configPath);
110
- } catch {
111
- return {};
112
- }
113
- try {
114
- const parsed = JSON.parse(raw) as unknown;
115
- return isJsonObject(parsed) ? parsed : {};
116
- } catch {
117
- return {};
118
+ export async function loadConfig(io: IO): Promise<LoadedConfig> {
119
+ let dir = resolvePath(io.cwd());
120
+ for (;;) {
121
+ const configPath = resolvePath(dir, "promptbook.json");
122
+ let raw: string | undefined;
123
+ try {
124
+ raw = await io.fs.readFile(configPath);
125
+ } catch {
126
+ // not found at this level; try the parent
127
+ }
128
+ if (raw !== undefined) {
129
+ try {
130
+ const parsed = JSON.parse(raw) as unknown;
131
+ return { data: isJsonObject(parsed) ? parsed : {}, dir };
132
+ } catch {
133
+ return { data: {}, dir };
134
+ }
135
+ }
136
+ const parent = dirname(dir);
137
+ if (parent === dir) {
138
+ return { data: {} };
139
+ }
140
+ dir = parent;
118
141
  }
119
142
  }
120
143
 
121
144
  /** Extract the `lint` section from an already-loaded config. */
122
- export function lintConfigFrom(config: PromptbookConfig): LintConfig {
123
- const section = config.lint;
145
+ export function lintConfigFrom(loaded: LoadedConfig): LintConfig {
146
+ const section = loaded.data.lint;
124
147
  if (!isJsonObject(section)) {
125
148
  return {};
126
149
  }
@@ -146,8 +169,8 @@ export interface EvalConfig {
146
169
  }
147
170
 
148
171
  /** Extract the `eval` section from an already-loaded config. */
149
- export function evalConfigFrom(config: PromptbookConfig): EvalConfig {
150
- const section = config.eval;
172
+ export function evalConfigFrom(loaded: LoadedConfig): EvalConfig {
173
+ const section = loaded.data.eval;
151
174
  if (!isJsonObject(section)) {
152
175
  return {};
153
176
  }
@@ -162,21 +185,22 @@ export function evalConfigFrom(config: PromptbookConfig): EvalConfig {
162
185
  }
163
186
 
164
187
  /**
165
- * Resolve the prompts folder by priority: `--dir` flag > `promptbook.json`
166
- * (`promptsDir` key) in cwd > `./prompts`. All results are absolute. Pass a
167
- * preloaded `config` to reuse a single read; otherwise it is loaded here.
188
+ * Resolve the prompts folder by priority:
189
+ * 1. `--dir <path>` relative to **cwd** (explicit per-invocation override).
190
+ * 2. `promptbook.json` `promptsDir` relative to **the config file's directory**
191
+ * (so the value can stay stable while the user shells around in subfolders).
192
+ * 3. `./prompts` — relative to cwd (back-compat default when no config exists).
193
+ *
194
+ * All results are absolute. Pass a preloaded {@link LoadedConfig} to reuse a
195
+ * single read; otherwise it is loaded here.
168
196
  */
169
- export async function resolvePromptsDir(
170
- io: IO,
171
- dirFlag?: string,
172
- config?: PromptbookConfig,
173
- ): Promise<string> {
197
+ export async function resolvePromptsDir(io: IO, dirFlag?: string, loaded?: LoadedConfig): Promise<string> {
174
198
  if (dirFlag !== undefined) {
175
199
  return resolvePath(io.cwd(), dirFlag);
176
200
  }
177
- const resolved = config ?? (await loadConfig(io));
178
- if (typeof resolved.promptsDir === "string") {
179
- return resolvePath(io.cwd(), resolved.promptsDir);
201
+ const resolved = loaded ?? (await loadConfig(io));
202
+ if (typeof resolved.data.promptsDir === "string" && resolved.dir !== undefined) {
203
+ return resolvePath(resolved.dir, resolved.data.promptsDir);
180
204
  }
181
205
  return resolvePath(io.cwd(), "prompts");
182
206
  }
@@ -198,9 +222,9 @@ async function dirExists(io: IO, dir: string): Promise<boolean> {
198
222
  export async function requirePromptsDir(
199
223
  io: IO,
200
224
  dirFlag?: string,
201
- config?: PromptbookConfig,
225
+ loaded?: LoadedConfig,
202
226
  ): Promise<string | null> {
203
- const promptsDir = await resolvePromptsDir(io, dirFlag, config);
227
+ const promptsDir = await resolvePromptsDir(io, dirFlag, loaded);
204
228
  if (!(await dirExists(io, promptsDir))) {
205
229
  io.stderr(`error: prompts folder not found: ${promptsDir}\n`);
206
230
  return null;
package/src/run.ts CHANGED
@@ -8,6 +8,7 @@ import { cmdLint } from "./commands/lint.js";
8
8
  import { cmdLs } from "./commands/ls.js";
9
9
  import { cmdResolve } from "./commands/resolve.js";
10
10
  import { cmdView } from "./commands/view.js";
11
+ import { cmdWatch } from "./commands/watch.js";
11
12
  import { defaultIO, type IO } from "./io.js";
12
13
 
13
14
  const HELP = `promptbook — compose prompts from reusable fragments
@@ -19,7 +20,8 @@ Commands:
19
20
  resolve [<book>/]<prompt> Assemble a prompt and print it to stdout (--all: every book)
20
21
  lint [<prompt>] Run static checks; with no prompt, book rules only
21
22
  eval [<name|glob>] Run fixtures through a model adapter, report pass-rate
22
- bundle [<dir>] Compile a prompts folder into an importable book module
23
+ bundle [<dir>] Compile a prompts folder into an importable book module (--all/--check)
24
+ watch [<dir>] Rebuild book.generated.ts as fragments/rules/compositions change
23
25
  view Start the local web viewer over the workspace (book switcher)
24
26
  annotations <action> Drain the viewer's feedback queue: list | resolve <id> | clear
25
27
  ls List compositions and fragments (--all: cross-book inventory)
@@ -39,13 +41,15 @@ Options:
39
41
  --samples N eval: default samples per fixture (default 1; a fixture's own samples wins)
40
42
  --threshold R eval: a fixture passes when passRate >= R (default 1)
41
43
  --lint eval: run a static lint gate over every variant first
42
- -o, --out <file> bundle: write the generated module to a file (default: stdout)
43
- --plain bundle: emit a plain module (no type-only import; e.g. for Deno)
44
+ -o, --out <file> bundle/watch: write to a file (default: stdout for bundle, <bookDir>/book.generated.ts for watch/--all)
45
+ --plain bundle/watch: emit a plain module (no type-only import; e.g. for Deno)
46
+ --check bundle: compare with the existing output; exit 1 on drift or missing artifact
47
+ --exclude-code-prompts bundle/watch: serialize code-prompts as an empty map (runtime-lean bundle)
44
48
  --port N view: port for the viewer server (default: a free port)
45
49
  --no-open view: do not open the browser after starting
46
50
  --fragments ls: list fragments only
47
51
  --compositions ls: list compositions only
48
- --all ls/resolve: span every book in the workspace
52
+ --all ls/resolve/bundle: span every book in the workspace
49
53
  -h, --help Show this help
50
54
  -v, --version Show the version
51
55
 
@@ -98,6 +102,8 @@ export async function run(argv: string[], io: IO = defaultIO()): Promise<number>
98
102
  return cmdEval(args, io);
99
103
  case "bundle":
100
104
  return cmdBundle(args, io);
105
+ case "watch":
106
+ return cmdWatch(args, io);
101
107
  case "view":
102
108
  return cmdView(args, io);
103
109
  case "annotations":