@markbrutx/promptbook-cli 0.1.0 → 0.3.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.
@@ -1,31 +1,123 @@
1
- import type { ResolveResult } from "@markbrutx/promptbook-core";
2
- import { resolve } from "@markbrutx/promptbook-core";
1
+ import type { Context, ResolveResult } from "@markbrutx/promptbook-core";
2
+ import { loadPrompts, resolve, resolveBook } from "@markbrutx/promptbook-core";
3
3
  import type { ParsedArgs } from "../args.js";
4
4
  import { buildContext, requirePromptsDir } from "../config.js";
5
5
  import { colorEnabled, emitWarnings, type IO } from "../io.js";
6
6
  import { renderExplain } from "../render-explain.js";
7
+ import { loadWorkspace, resolveAddress } from "../workspace.js";
8
+
9
+ /** One node of a book's menu in deterministic (name) order, typed by kind. */
10
+ interface BookNode {
11
+ name: string;
12
+ kind: "composition" | "code-prompt";
13
+ }
7
14
 
8
15
  /**
9
- * `resolve <prompt>`: assemble the prompt and print its text to stdout. With
10
- * `--json`, print `{ text, trace }` instead; with `--explain`, additionally
11
- * print the trace to stderr. Warnings always go to stderr so nothing is lost.
16
+ * `resolve --all`: assemble every composition of every book in the workspace.
17
+ * Missing variables become stderr warnings, never throws, so the run always
18
+ * completes. `--json` emits a `{ "book/comp": value }` map (compositions carry
19
+ * `text`+`trace`; code-prompts ride along as inventory `{kind, samples}` so
20
+ * builders are not silently dropped); plain prints each composition under a
21
+ * `=== book/comp ===` header and notes the skipped code-prompts. Order is by
22
+ * book then by node name.
12
23
  */
13
- export async function cmdResolve(args: ParsedArgs, io: IO): Promise<number> {
14
- const prompt = args.operands[0];
15
- if (prompt === undefined) {
16
- io.stderr('error: resolve requires a <prompt> name. Run "promptbook --help".\n');
17
- return 1;
24
+ async function cmdResolveAll(io: IO, root: string, context: Context, json: boolean): Promise<number> {
25
+ const workspace = await loadWorkspace(io, root);
26
+ const blocks: string[] = [];
27
+ const skipped: string[] = [];
28
+ const map: Record<string, unknown> = {};
29
+
30
+ // Load every book up front (concurrently); iterate the sorted result for
31
+ // deterministic output, matching `ls --all`.
32
+ const loadedBooks = await Promise.all(
33
+ workspace.books.map(async (book) => ({ book, loaded: await loadPrompts(book.dir, io.fs) })),
34
+ );
35
+
36
+ for (const { book, loaded } of loadedBooks) {
37
+ emitWarnings(
38
+ io,
39
+ loaded.warnings.map((w) => `${book.name}: ${w}`),
40
+ );
41
+ const nodes: BookNode[] = [
42
+ ...[...loaded.compositions.keys()].map((name): BookNode => ({ name, kind: "composition" })),
43
+ ...[...loaded.codePrompts.keys()].map((name): BookNode => ({ name, kind: "code-prompt" })),
44
+ ].sort((a, b) => a.name.localeCompare(b.name));
45
+
46
+ for (const node of nodes) {
47
+ const key = `${book.name}/${node.name}`;
48
+ if (node.kind === "code-prompt") {
49
+ const codePrompt = loaded.codePrompts.get(node.name);
50
+ skipped.push(key);
51
+ if (json) {
52
+ map[key] = { kind: "code-prompt", samples: codePrompt?.samples ?? [] };
53
+ }
54
+ continue;
55
+ }
56
+ const { text, trace } = resolveBook(loaded, node.name, context);
57
+ emitWarnings(
58
+ io,
59
+ trace.warnings.map((w) => `${key}: ${w}`),
60
+ );
61
+ if (json) {
62
+ map[key] = { kind: "composition", text, trace };
63
+ } else {
64
+ blocks.push(`=== ${key} ===\n${text}`);
65
+ }
66
+ }
18
67
  }
19
68
 
69
+ if (json) {
70
+ io.stdout(`${JSON.stringify(map, null, 2)}\n`);
71
+ return 0;
72
+ }
73
+ io.stdout(`${blocks.join("\n\n")}\n`);
74
+ if (skipped.length > 0) {
75
+ io.stderr(`note: skipped ${skipped.length} code-prompt(s) (not resolvable): ${skipped.join(", ")}\n`);
76
+ }
77
+ return 0;
78
+ }
79
+
80
+ /**
81
+ * `resolve [<book>/]<prompt>`: assemble the prompt and print its text to stdout.
82
+ * A `<book>/` prefix addresses a specific book; a bare name resolves by
83
+ * uniqueness across the workspace (and works as before in a single-book root).
84
+ * `--json` prints `{ text, trace }`; `--explain` adds the trace to stderr;
85
+ * `--all` assembles every composition of every book. Warnings always go to
86
+ * stderr so nothing is lost.
87
+ */
88
+ export async function cmdResolve(args: ParsedArgs, io: IO): Promise<number> {
20
89
  const promptsDir = await requirePromptsDir(io, args.dir);
21
90
  if (promptsDir === null) {
22
91
  return 1;
23
92
  }
24
93
 
94
+ let context: Context;
95
+ try {
96
+ context = await buildContext(io, args.ctx, args.contextFile);
97
+ } catch (error) {
98
+ io.stderr(`error: ${(error as Error).message}\n`);
99
+ return 1;
100
+ }
101
+
102
+ if (args.all) {
103
+ return cmdResolveAll(io, promptsDir, context, args.json);
104
+ }
105
+
106
+ const operand = args.operands[0];
107
+ if (operand === undefined) {
108
+ io.stderr('error: resolve requires a <prompt> name (or --all). Run "promptbook --help".\n');
109
+ return 1;
110
+ }
111
+
25
112
  let result: ResolveResult;
26
113
  try {
27
- const context = await buildContext(io, args.ctx, args.contextFile);
28
- result = await resolve({ promptsDir, prompt, context, fs: io.fs });
114
+ const workspace = await loadWorkspace(io, promptsDir);
115
+ const { book, comp, loaded } = await resolveAddress(io, workspace, operand);
116
+ // Bare-name resolution already loaded the matched book — reuse it; otherwise load now.
117
+ result =
118
+ loaded !== undefined
119
+ ? resolveBook(loaded, comp, context)
120
+ : await resolve({ promptsDir: book.dir, prompt: comp, context, fs: io.fs });
29
121
  } catch (error) {
30
122
  io.stderr(`error: ${(error as Error).message}\n`);
31
123
  return 1;
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
@@ -16,13 +16,16 @@ Usage:
16
16
  promptbook <command> [options]
17
17
 
18
18
  Commands:
19
- resolve <prompt> Assemble a prompt and print it to stdout
19
+ resolve [<book>/]<prompt> Assemble a prompt and print it to stdout (--all: every book)
20
20
  lint [<prompt>] Run static checks; with no prompt, book rules only
21
21
  eval [<name|glob>] Run fixtures through a model adapter, report pass-rate
22
22
  bundle [<dir>] Compile a prompts folder into an importable book module
23
- view Start the local web viewer over the prompts folder
23
+ view Start the local web viewer over the workspace (book switcher)
24
24
  annotations <action> Drain the viewer's feedback queue: list | resolve <id> | clear
25
- ls List compositions and fragments
25
+ ls List compositions and fragments (--all: cross-book inventory)
26
+
27
+ A <book>/<comp> operand addresses one book in a multi-book workspace; a bare
28
+ name resolves by uniqueness. With a single-book --dir, names work unqualified.
26
29
 
27
30
  Options:
28
31
  --dir <path> Prompts folder (default: promptbook.json promptsDir, else ./prompts)
@@ -42,6 +45,7 @@ Options:
42
45
  --no-open view: do not open the browser after starting
43
46
  --fragments ls: list fragments only
44
47
  --compositions ls: list compositions only
48
+ --all ls/resolve: span every book in the workspace
45
49
  -h, --help Show this help
46
50
  -v, --version Show the version
47
51
 
@@ -0,0 +1,144 @@
1
+ import { basename, join, relative } from "node:path";
2
+ import type { PromptBook } from "@markbrutx/promptbook-core";
3
+ import { loadPrompts } from "@markbrutx/promptbook-core";
4
+ import type { IO } from "./io.js";
5
+
6
+ /** A discovered prompts book: its folder name and absolute directory. */
7
+ export interface Book {
8
+ name: string;
9
+ dir: string;
10
+ }
11
+
12
+ /** A workspace root and the books found directly under it (sorted by name). */
13
+ export interface Workspace {
14
+ root: string;
15
+ books: Book[];
16
+ }
17
+
18
+ /** A `<book>/<comp>` operand split into its parts (`book` absent for bare names). */
19
+ export interface Address {
20
+ book?: string;
21
+ comp: string;
22
+ }
23
+
24
+ /** A bare/qualified operand resolved to a concrete book + composition name. */
25
+ export interface ResolvedAddress {
26
+ book: Book;
27
+ comp: string;
28
+ /** The matched book, already loaded during bare-name resolution (reuse to skip a reload). */
29
+ loaded?: PromptBook;
30
+ }
31
+
32
+ /** Folder names that are book internals or noise, never workspace sub-books. */
33
+ const NON_BOOK_DIRS = new Set(["node_modules", "fragments", "rules", "code-prompts"]);
34
+
35
+ /** Markers that make a folder a loadable book (a config or a loadable form). */
36
+ const BOOK_MARKERS = ["promptbook.json", "rules", "code-prompts"];
37
+
38
+ /** True when `dir` looks like a prompts book (has a config or a loadable form). */
39
+ export async function isBook(io: IO, dir: string): Promise<boolean> {
40
+ let entries: string[];
41
+ try {
42
+ entries = await io.fs.readDir(dir);
43
+ } catch {
44
+ return false;
45
+ }
46
+ return BOOK_MARKERS.some((marker) => entries.includes(marker));
47
+ }
48
+
49
+ /**
50
+ * Find the books directly under `rootDir`: each non-internal subfolder that is
51
+ * itself a book. One level deep, not recursive; sorted by name for a stable
52
+ * menu. Dot/underscore folders and book internals are skipped.
53
+ */
54
+ export async function discoverBooks(io: IO, rootDir: string): Promise<Book[]> {
55
+ let entries: string[];
56
+ try {
57
+ entries = await io.fs.readDir(rootDir);
58
+ } catch {
59
+ return [];
60
+ }
61
+ const books: Book[] = [];
62
+ for (const name of [...entries].sort()) {
63
+ if (name.startsWith(".") || name.startsWith("_") || NON_BOOK_DIRS.has(name)) {
64
+ continue;
65
+ }
66
+ const dir = join(rootDir, name);
67
+ if (await isBook(io, dir)) {
68
+ books.push({ name, dir });
69
+ }
70
+ }
71
+ return books;
72
+ }
73
+
74
+ /**
75
+ * Read a workspace from `rootDir`. When the root is itself a book it is the only
76
+ * book (back-compat single-book path, named after the folder); otherwise the
77
+ * root is a workspace of its sub-books.
78
+ */
79
+ export async function loadWorkspace(io: IO, rootDir: string): Promise<Workspace> {
80
+ if (await isBook(io, rootDir)) {
81
+ return { root: rootDir, books: [{ name: basename(rootDir), dir: rootDir }] };
82
+ }
83
+ return { root: rootDir, books: await discoverBooks(io, rootDir) };
84
+ }
85
+
86
+ /** Split an operand on the first `/`: `book/comp` (comp may itself contain `/`). */
87
+ export function parseAddress(operand: string): Address {
88
+ const slash = operand.indexOf("/");
89
+ if (slash === -1) {
90
+ return { comp: operand };
91
+ }
92
+ return { book: operand.slice(0, slash), comp: operand.slice(slash + 1) };
93
+ }
94
+
95
+ /** A book's directory relative to the workspace root (`.` when it is the root). */
96
+ export function bookDir(workspace: Workspace, book: Book): string {
97
+ return relative(workspace.root, book.dir) || ".";
98
+ }
99
+
100
+ /**
101
+ * Resolve an operand to a concrete book + composition. A `<book>/<comp>` prefix
102
+ * that names a known book addresses it directly; otherwise the whole operand is
103
+ * a bare composition name, resolved by uniqueness across the workspace's books
104
+ * (a single-book workspace always uses its one book). Throws a clear error when
105
+ * a bare name is missing or ambiguous.
106
+ */
107
+ export async function resolveAddress(
108
+ io: IO,
109
+ workspace: Workspace,
110
+ operand: string,
111
+ ): Promise<ResolvedAddress> {
112
+ const address = parseAddress(operand);
113
+ if (address.book !== undefined) {
114
+ const book = workspace.books.find((b) => b.name === address.book);
115
+ if (book !== undefined) {
116
+ return { book, comp: address.comp };
117
+ }
118
+ // Prefix names no book: fall through and treat the whole operand as a
119
+ // (possibly path-like) bare composition name.
120
+ }
121
+
122
+ if (workspace.books.length === 1) {
123
+ return { book: workspace.books[0] as Book, comp: operand };
124
+ }
125
+
126
+ const matches: { book: Book; loaded: PromptBook }[] = [];
127
+ for (const book of workspace.books) {
128
+ const loaded = await loadPrompts(book.dir, io.fs);
129
+ if (loaded.compositions.has(operand)) {
130
+ matches.push({ book, loaded });
131
+ }
132
+ }
133
+ const [first] = matches;
134
+ if (matches.length === 1 && first !== undefined) {
135
+ return { book: first.book, comp: operand, loaded: first.loaded };
136
+ }
137
+ if (matches.length === 0) {
138
+ throw new Error(`Unknown prompt "${operand}" in any book. Qualify it as <book>/<comp>.`);
139
+ }
140
+ const names = matches.map((m) => m.book.name).join(", ");
141
+ throw new Error(
142
+ `Ambiguous prompt "${operand}"; found in ${matches.length} books (${names}). Qualify it as <book>/<comp>.`,
143
+ );
144
+ }