@markbrutx/promptbook-cli 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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +39 -0
  3. package/bin/promptbook.ts +4 -0
  4. package/dist/bin/promptbook.d.ts +3 -0
  5. package/dist/bin/promptbook.d.ts.map +1 -0
  6. package/dist/bin/promptbook.js +4 -0
  7. package/dist/bin/promptbook.js.map +1 -0
  8. package/dist/src/args.d.ts +43 -0
  9. package/dist/src/args.d.ts.map +1 -0
  10. package/dist/src/args.js +96 -0
  11. package/dist/src/args.js.map +1 -0
  12. package/dist/src/commands/annotations.d.ts +10 -0
  13. package/dist/src/commands/annotations.d.ts.map +1 -0
  14. package/dist/src/commands/annotations.js +92 -0
  15. package/dist/src/commands/annotations.js.map +1 -0
  16. package/dist/src/commands/bundle.d.ts +13 -0
  17. package/dist/src/commands/bundle.d.ts.map +1 -0
  18. package/dist/src/commands/bundle.js +61 -0
  19. package/dist/src/commands/bundle.js.map +1 -0
  20. package/dist/src/commands/eval.d.ts +13 -0
  21. package/dist/src/commands/eval.d.ts.map +1 -0
  22. package/dist/src/commands/eval.js +113 -0
  23. package/dist/src/commands/eval.js.map +1 -0
  24. package/dist/src/commands/lint.d.ts +11 -0
  25. package/dist/src/commands/lint.d.ts.map +1 -0
  26. package/dist/src/commands/lint.js +66 -0
  27. package/dist/src/commands/lint.js.map +1 -0
  28. package/dist/src/commands/ls.d.ts +11 -0
  29. package/dist/src/commands/ls.d.ts.map +1 -0
  30. package/dist/src/commands/ls.js +84 -0
  31. package/dist/src/commands/ls.js.map +1 -0
  32. package/dist/src/commands/resolve.d.ts +9 -0
  33. package/dist/src/commands/resolve.d.ts.map +1 -0
  34. package/dist/src/commands/resolve.js +41 -0
  35. package/dist/src/commands/resolve.js.map +1 -0
  36. package/dist/src/commands/view.d.ts +30 -0
  37. package/dist/src/commands/view.d.ts.map +1 -0
  38. package/dist/src/commands/view.js +51 -0
  39. package/dist/src/commands/view.js.map +1 -0
  40. package/dist/src/config.d.ts +56 -0
  41. package/dist/src/config.d.ts.map +1 -0
  42. package/dist/src/config.js +175 -0
  43. package/dist/src/config.js.map +1 -0
  44. package/dist/src/index.d.ts +4 -0
  45. package/dist/src/index.d.ts.map +1 -0
  46. package/dist/src/index.js +2 -0
  47. package/dist/src/index.js.map +1 -0
  48. package/dist/src/io.d.ts +43 -0
  49. package/dist/src/io.d.ts.map +1 -0
  50. package/dist/src/io.js +37 -0
  51. package/dist/src/io.js.map +1 -0
  52. package/dist/src/render-eval.d.ts +8 -0
  53. package/dist/src/render-eval.d.ts.map +1 -0
  54. package/dist/src/render-eval.js +51 -0
  55. package/dist/src/render-eval.js.map +1 -0
  56. package/dist/src/render-explain.d.ts +8 -0
  57. package/dist/src/render-explain.d.ts.map +1 -0
  58. package/dist/src/render-explain.js +57 -0
  59. package/dist/src/render-explain.js.map +1 -0
  60. package/dist/src/render-lint.d.ts +8 -0
  61. package/dist/src/render-lint.d.ts.map +1 -0
  62. package/dist/src/render-lint.js +57 -0
  63. package/dist/src/render-lint.js.map +1 -0
  64. package/dist/src/run.d.ts +8 -0
  65. package/dist/src/run.d.ts.map +1 -0
  66. package/dist/src/run.js +105 -0
  67. package/dist/src/run.js.map +1 -0
  68. package/dist/src/style.d.ts +16 -0
  69. package/dist/src/style.d.ts.map +1 -0
  70. package/dist/src/style.js +22 -0
  71. package/dist/src/style.js.map +1 -0
  72. package/package.json +50 -0
  73. package/src/args.ts +145 -0
  74. package/src/commands/annotations.ts +107 -0
  75. package/src/commands/bundle.ts +71 -0
  76. package/src/commands/eval.ts +137 -0
  77. package/src/commands/lint.ts +71 -0
  78. package/src/commands/ls.ts +90 -0
  79. package/src/commands/resolve.ts +47 -0
  80. package/src/commands/view.ts +82 -0
  81. package/src/config.ts +209 -0
  82. package/src/index.ts +3 -0
  83. package/src/io.ts +77 -0
  84. package/src/render-eval.ts +55 -0
  85. package/src/render-explain.ts +64 -0
  86. package/src/render-lint.ts +63 -0
  87. package/src/run.ts +107 -0
  88. package/src/style.ts +37 -0
@@ -0,0 +1,107 @@
1
+ import { join } from "node:path";
2
+ import type { Annotation } from "@markbrutx/promptbook-core";
3
+ import {
4
+ ANNOTATION_QUEUE_DIR,
5
+ ANNOTATION_QUEUE_FILE,
6
+ parseInbox,
7
+ serializeInbox,
8
+ } from "@markbrutx/promptbook-core";
9
+ import type { ParsedArgs } from "../args.js";
10
+ import { requirePromptsDir } from "../config.js";
11
+ import type { IO } from "../io.js";
12
+ import { formatContext, plural } from "../style.js";
13
+
14
+ function queueFile(promptsDir: string): string {
15
+ return join(promptsDir, ANNOTATION_QUEUE_DIR, ANNOTATION_QUEUE_FILE);
16
+ }
17
+
18
+ /** Read the queue via the injected fs; a missing file reads as empty. */
19
+ async function readQueue(io: IO, file: string): Promise<Annotation[]> {
20
+ try {
21
+ return parseInbox(await io.fs.readFile(file));
22
+ } catch {
23
+ return [];
24
+ }
25
+ }
26
+
27
+ function describeTarget(annotation: Annotation): string {
28
+ const { target } = annotation;
29
+ if (target.prompt !== undefined) {
30
+ const ctx = formatContext(target.context ?? {});
31
+ return ctx === "" ? target.prompt : `${target.prompt} @ ${ctx}`;
32
+ }
33
+ return target.fragmentId ?? "(unknown)";
34
+ }
35
+
36
+ function renderAnnotation(annotation: Annotation): string {
37
+ return [
38
+ `• ${annotation.id} [${describeTarget(annotation)}]`,
39
+ ` anchor: ${annotation.anchor.fragmentId} — “${annotation.anchor.anchorText}”`,
40
+ ` ${annotation.comment}`,
41
+ ].join("\n");
42
+ }
43
+
44
+ async function listAnnotations(args: ParsedArgs, io: IO, file: string): Promise<number> {
45
+ const open = (await readQueue(io, file)).filter((a) => a.status === "open");
46
+ if (args.json) {
47
+ io.stdout(`${JSON.stringify(open, null, 2)}\n`);
48
+ return 0;
49
+ }
50
+ if (open.length === 0) {
51
+ io.stdout("No open annotations.\n");
52
+ return 0;
53
+ }
54
+ io.stdout(`${open.map(renderAnnotation).join("\n\n")}\n`);
55
+ return 0;
56
+ }
57
+
58
+ async function resolveAnnotation(args: ParsedArgs, io: IO, file: string): Promise<number> {
59
+ const id = args.operands[1];
60
+ if (id === undefined || id === "") {
61
+ io.stderr('error: "annotations resolve" needs an <id>.\n');
62
+ return 1;
63
+ }
64
+ const all = await readQueue(io, file);
65
+ const next = all.filter((a) => a.id !== id);
66
+ if (next.length === all.length) {
67
+ io.stderr(`error: no annotation with id "${id}".\n`);
68
+ return 1;
69
+ }
70
+ await io.writeFile(file, serializeInbox(next));
71
+ io.stdout(`Resolved ${id}.\n`);
72
+ return 0;
73
+ }
74
+
75
+ async function clearAnnotations(io: IO, file: string): Promise<number> {
76
+ const open = (await readQueue(io, file)).filter((a) => a.status === "open");
77
+ await io.writeFile(file, "");
78
+ io.stdout(`Cleared ${plural(open.length, "annotation")}.\n`);
79
+ return 0;
80
+ }
81
+
82
+ /**
83
+ * `annotations`: the terminal-agnostic side of the viewer's feedback bridge.
84
+ * `list` prints open annotations (or `--json`), `resolve <id>` removes one, and
85
+ * `clear` empties the queue. All operate on `<dir>/.annotations/inbox.jsonl` —
86
+ * the same file the viewer writes — so any agent can drain it from any terminal.
87
+ */
88
+ export async function cmdAnnotations(args: ParsedArgs, io: IO): Promise<number> {
89
+ const promptsDir = await requirePromptsDir(io, args.dir);
90
+ if (promptsDir === null) {
91
+ return 1;
92
+ }
93
+ const file = queueFile(promptsDir);
94
+ const action = args.operands[0] ?? "list";
95
+
96
+ switch (action) {
97
+ case "list":
98
+ return listAnnotations(args, io, file);
99
+ case "resolve":
100
+ return resolveAnnotation(args, io, file);
101
+ case "clear":
102
+ return clearAnnotations(io, file);
103
+ default:
104
+ io.stderr(`error: unknown annotations action "${action}". Use list, resolve <id> or clear.\n`);
105
+ return 1;
106
+ }
107
+ }
@@ -0,0 +1,71 @@
1
+ import { isAbsolute, relative, resolve as resolvePath, sep } from "node:path";
2
+ import type { PromptBook } from "@markbrutx/promptbook-core";
3
+ import { loadPrompts, serializeBook, serializeBookJson } from "@markbrutx/promptbook-core";
4
+ import type { ParsedArgs } from "../args.js";
5
+ import { requirePromptsDir } from "../config.js";
6
+ import { emitWarnings, type IO } from "../io.js";
7
+
8
+ /** Rewrite an absolute source path to a portable, forward-slash path relative to `dir`. */
9
+ function relativizeSource(sourceFile: string, dir: string): string {
10
+ if (!isAbsolute(sourceFile)) {
11
+ return sourceFile;
12
+ }
13
+ return relative(dir, sourceFile).split(sep).join("/");
14
+ }
15
+
16
+ /** Copy a keyed map, relativizing each value's `sourceFile`. */
17
+ function relativizeMap<T extends { sourceFile: string }>(map: Map<string, T>, dir: string): Map<string, T> {
18
+ return new Map(
19
+ [...map].map(([key, value]) => [key, { ...value, sourceFile: relativizeSource(value.sourceFile, dir) }]),
20
+ );
21
+ }
22
+
23
+ /**
24
+ * Copy the book with every `sourceFile` made relative to the prompts folder, so
25
+ * the generated module carries portable paths instead of one machine's absolute
26
+ * layout. Resolution and lint ignore `sourceFile`, so this never affects output.
27
+ */
28
+ function portableBook(book: PromptBook, dir: string): PromptBook {
29
+ return {
30
+ fragments: relativizeMap(book.fragments, dir),
31
+ compositions: relativizeMap(book.compositions, dir),
32
+ codePrompts: relativizeMap(book.codePrompts, dir),
33
+ warnings: book.warnings,
34
+ };
35
+ }
36
+
37
+ /**
38
+ * `bundle [<dir>]`: load a prompts folder and emit it as a single importable
39
+ * module exporting `book: PromptBook` (so a runtime can import the book instead
40
+ * of reading the disk). Writes to stdout, or to a file with `-o`. `--json`
41
+ * emits a structured dump instead of the TypeScript module.
42
+ *
43
+ * The folder comes from the positional `<dir>`, else `--dir`, else config /
44
+ * `./prompts` (the standard resolution).
45
+ */
46
+ export async function cmdBundle(args: ParsedArgs, io: IO): Promise<number> {
47
+ const promptsDir = await requirePromptsDir(io, args.operands[0] ?? args.dir);
48
+ if (promptsDir === null) {
49
+ return 1;
50
+ }
51
+
52
+ const book = portableBook(await loadPrompts(promptsDir, io.fs), promptsDir);
53
+ emitWarnings(io, book.warnings);
54
+
55
+ const output = args.json ? serializeBookJson(book) : serializeBook(book, { typed: !args.plain });
56
+
57
+ if (args.out === undefined) {
58
+ io.stdout(output);
59
+ return 0;
60
+ }
61
+
62
+ const outPath = resolvePath(io.cwd(), args.out);
63
+ try {
64
+ await io.writeFile(outPath, output);
65
+ } catch (error) {
66
+ io.stderr(`error: cannot write "${outPath}": ${(error as Error).message}\n`);
67
+ return 1;
68
+ }
69
+ io.stderr(`wrote ${outPath}\n`);
70
+ return 0;
71
+ }
@@ -0,0 +1,137 @@
1
+ import type {
2
+ EvalReport,
3
+ Fixture,
4
+ LintFinding,
5
+ LintRule,
6
+ ModelAdapter,
7
+ PromptBook,
8
+ } from "@markbrutx/promptbook-core";
9
+ import {
10
+ defaultRules,
11
+ evaluate,
12
+ lint,
13
+ loadFixtures,
14
+ loadPrompts,
15
+ resolveBook,
16
+ } from "@markbrutx/promptbook-core";
17
+ import { openRouterAdapter } from "@markbrutx/promptbook-openrouter";
18
+ import type { ParsedArgs } from "../args.js";
19
+ import { type EvalConfig, evalConfigFrom, loadConfig, requirePromptsDir } from "../config.js";
20
+ import { type AdapterOptions, colorEnabled, type IO } from "../io.js";
21
+ import { renderEvalReport } from "../render-eval.js";
22
+
23
+ /** Convert a `name|glob` pattern (only `*` is special) into an anchored regex. */
24
+ function globToRegExp(pattern: string): RegExp {
25
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
26
+ return new RegExp(`^${escaped}$`);
27
+ }
28
+
29
+ /** Build the model adapter: the injected one for tests, else OpenRouter. */
30
+ function buildAdapter(io: IO, options: AdapterOptions): ModelAdapter {
31
+ if (io.makeAdapter) {
32
+ return io.makeAdapter(options);
33
+ }
34
+ if (options.model === undefined) {
35
+ throw new Error('eval requires a model: pass --model <id> or set "eval.model" in promptbook.json.');
36
+ }
37
+ const adapterOptions = { model: options.model, apiKey: options.apiKey, baseUrl: options.baseUrl };
38
+ return openRouterAdapter(adapterOptions);
39
+ }
40
+
41
+ /** Lint every resolved variant; return findings with error severity. */
42
+ function lintGate(book: PromptBook, fixtures: Fixture[], rules: LintRule[]): LintFinding[] {
43
+ const errors: LintFinding[] = [];
44
+ for (const fixture of fixtures) {
45
+ const result = resolveBook(book, fixture.prompt, fixture.context ?? {});
46
+ const report = lint({ book, result }, rules);
47
+ for (const finding of report.findings) {
48
+ if (finding.severity === "error") {
49
+ errors.push({ ...finding, message: `${fixture.name}: ${finding.message}` });
50
+ }
51
+ }
52
+ }
53
+ return errors;
54
+ }
55
+
56
+ /**
57
+ * `eval [<name|glob>]`: load fixtures from `<dir>/fixtures`, assemble each
58
+ * prompt, sample it through a model adapter and report pass-rate. A fixture
59
+ * passes when its `passRate >= --threshold` (default 1). Exit is non-zero when
60
+ * any fixture fails the gate or on any error (no key, unknown prompt/fixture,
61
+ * missing folder). `--lint` runs a static gate over every variant first; with
62
+ * any lint error it aborts before spending tokens. Adapter is injectable via
63
+ * `io.makeAdapter` so tests never touch the network.
64
+ */
65
+ export async function cmdEval(args: ParsedArgs, io: IO): Promise<number> {
66
+ const config = await loadConfig(io);
67
+ const promptsDir = await requirePromptsDir(io, args.dir, config);
68
+ if (promptsDir === null) {
69
+ return 1;
70
+ }
71
+
72
+ let fixtures: Fixture[];
73
+ try {
74
+ fixtures = await loadFixtures(promptsDir, io.fs);
75
+ } catch (error) {
76
+ io.stderr(`error: ${(error as Error).message}\n`);
77
+ return 1;
78
+ }
79
+
80
+ const filter = args.operands[0];
81
+ if (filter !== undefined) {
82
+ const re = globToRegExp(filter);
83
+ fixtures = fixtures.filter((f) => re.test(f.name));
84
+ if (fixtures.length === 0) {
85
+ io.stderr(`error: no fixtures match "${filter}".\n`);
86
+ return 1;
87
+ }
88
+ }
89
+ if (fixtures.length === 0) {
90
+ io.stderr(`error: no fixtures found in ${promptsDir}/fixtures.\n`);
91
+ return 1;
92
+ }
93
+
94
+ const book = await loadPrompts(promptsDir, io.fs);
95
+ const threshold = args.threshold ?? 1;
96
+
97
+ let report: EvalReport;
98
+ try {
99
+ if (args.lint) {
100
+ const errors = lintGate(book, fixtures, defaultRules());
101
+ if (errors.length > 0) {
102
+ io.stderr("error: lint gate failed before sampling:\n");
103
+ for (const finding of errors) {
104
+ io.stderr(` ${finding.ruleId}: ${finding.message}\n`);
105
+ }
106
+ return 1;
107
+ }
108
+ }
109
+
110
+ const evalConfig: EvalConfig = evalConfigFrom(config);
111
+ const adapter = buildAdapter(io, {
112
+ model: args.model ?? evalConfig.model,
113
+ apiKey: io.env.OPENROUTER_API_KEY,
114
+ baseUrl: evalConfig.baseUrl,
115
+ });
116
+
117
+ const evalInput = {
118
+ book,
119
+ fixtures,
120
+ adapter,
121
+ passThreshold: threshold,
122
+ ...(args.samples !== undefined ? { samples: args.samples } : {}),
123
+ };
124
+ report = await evaluate(evalInput);
125
+ } catch (error) {
126
+ io.stderr(`error: ${(error as Error).message}\n`);
127
+ return 1;
128
+ }
129
+
130
+ if (args.json) {
131
+ io.stdout(`${JSON.stringify(report, null, 2)}\n`);
132
+ } else {
133
+ io.stdout(renderEvalReport(report, threshold, colorEnabled(io)));
134
+ }
135
+
136
+ return report.failed > 0 ? 1 : 0;
137
+ }
@@ -0,0 +1,71 @@
1
+ import type { DefaultRulesOptions, LintReport } from "@markbrutx/promptbook-core";
2
+ import { defaultRules, lint, loadPrompts, resolveBook } from "@markbrutx/promptbook-core";
3
+ import type { ParsedArgs } from "../args.js";
4
+ import { buildContext, lintConfigFrom, loadConfig, requirePromptsDir } from "../config.js";
5
+ import { colorEnabled, emitWarnings, type IO } from "../io.js";
6
+ import { renderLintReport } from "../render-lint.js";
7
+
8
+ /**
9
+ * `lint [<prompt>]`: run static checks over the prompts folder. With a prompt
10
+ * it resolves under the given context and runs both book- and resolved-scope
11
+ * rules; without one it runs book-scope rules over the whole book. The report
12
+ * (human-readable or `--json`) goes to stdout; warnings go to stderr. Exit is
13
+ * non-zero when any error is found, or any warning under `--strict`.
14
+ */
15
+ export async function cmdLint(args: ParsedArgs, io: IO): Promise<number> {
16
+ const config = await loadConfig(io);
17
+ const promptsDir = await requirePromptsDir(io, args.dir, config);
18
+ if (promptsDir === null) {
19
+ return 1;
20
+ }
21
+
22
+ const lintConfig = lintConfigFrom(config);
23
+ const ruleOptions: DefaultRulesOptions = {};
24
+ const maxTokens = args.maxTokens ?? lintConfig.maxTokens;
25
+ if (maxTokens !== undefined) {
26
+ ruleOptions.maxTokens = maxTokens;
27
+ }
28
+ if (lintConfig.bannedTokens !== undefined) {
29
+ ruleOptions.bannedTokens = lintConfig.bannedTokens;
30
+ }
31
+ const rules = defaultRules(ruleOptions);
32
+
33
+ const book = await loadPrompts(promptsDir, io.fs);
34
+
35
+ const prompt = args.operands[0];
36
+ let report: LintReport;
37
+ let label: string;
38
+ let warnings: string[] = book.warnings;
39
+ try {
40
+ if (prompt !== undefined) {
41
+ const context = await buildContext(io, args.ctx, args.contextFile);
42
+ const result = resolveBook(book, prompt, context);
43
+ // trace.warnings already includes the load-time book.warnings.
44
+ warnings = result.trace.warnings;
45
+ report = lint({ book, result }, rules);
46
+ label = prompt;
47
+ } else {
48
+ report = lint({ book }, rules);
49
+ label = "book";
50
+ }
51
+ } catch (error) {
52
+ io.stderr(`error: ${(error as Error).message}\n`);
53
+ return 1;
54
+ }
55
+
56
+ emitWarnings(io, warnings);
57
+
58
+ if (args.json) {
59
+ io.stdout(`${JSON.stringify(report, null, 2)}\n`);
60
+ } else {
61
+ io.stdout(renderLintReport(report, label, colorEnabled(io)));
62
+ }
63
+
64
+ if (report.errorCount > 0) {
65
+ return 1;
66
+ }
67
+ if (args.strict && report.warningCount > 0) {
68
+ return 1;
69
+ }
70
+ return 0;
71
+ }
@@ -0,0 +1,90 @@
1
+ import { relative } from "node:path";
2
+ import { loadPrompts } from "@markbrutx/promptbook-core";
3
+ import type { ParsedArgs } from "../args.js";
4
+ import { requirePromptsDir } from "../config.js";
5
+ import { emitWarnings, type IO } from "../io.js";
6
+
7
+ /**
8
+ * `ls`: list compositions (name, base length, rule count), code-prompts
9
+ * (name, description, sample count) and fragments (id, kind, tags, source) —
10
+ * the unified menu of every prompt in the book. `--fragments`/`--compositions`
11
+ * narrow the output (code-prompts ride with compositions, both being prompts);
12
+ * `--json` emits a structured list instead of the human-readable tree.
13
+ */
14
+ export async function cmdLs(args: ParsedArgs, io: IO): Promise<number> {
15
+ const promptsDir = await requirePromptsDir(io, args.dir);
16
+ if (promptsDir === null) {
17
+ return 1;
18
+ }
19
+
20
+ const book = await loadPrompts(promptsDir, io.fs);
21
+ emitWarnings(io, book.warnings);
22
+
23
+ const onlyOneSection = args.fragments || args.compositions;
24
+ const showCompositions = args.compositions || !onlyOneSection;
25
+ const showFragments = args.fragments || !onlyOneSection;
26
+
27
+ const compositions = [...book.compositions.values()].sort((a, b) => a.name.localeCompare(b.name));
28
+ const codePrompts = [...book.codePrompts.values()].sort((a, b) => a.name.localeCompare(b.name));
29
+ const fragments = [...book.fragments.values()].sort((a, b) => a.id.localeCompare(b.id));
30
+
31
+ if (args.json) {
32
+ const out: Record<string, unknown> = {};
33
+ if (showCompositions) {
34
+ out.compositions = compositions.map((c) => ({
35
+ name: c.name,
36
+ base: c.base.length,
37
+ rules: c.rules.length,
38
+ }));
39
+ out.codePrompts = codePrompts.map((c) => ({
40
+ name: c.name,
41
+ description: c.description ?? null,
42
+ samples: c.samples.length,
43
+ }));
44
+ }
45
+ if (showFragments) {
46
+ out.fragments = fragments.map((f) => ({
47
+ id: f.id,
48
+ kind: f.kind ?? null,
49
+ tags: f.tags ?? [],
50
+ sourceFile: f.sourceFile,
51
+ }));
52
+ }
53
+ io.stdout(`${JSON.stringify(out, null, 2)}\n`);
54
+ return 0;
55
+ }
56
+
57
+ const lines: string[] = [];
58
+ if (showCompositions) {
59
+ lines.push("compositions:");
60
+ if (compositions.length === 0) {
61
+ lines.push(" (none)");
62
+ }
63
+ for (const c of compositions) {
64
+ lines.push(` ${c.name} base=${c.base.length} rules=${c.rules.length}`);
65
+ }
66
+ if (codePrompts.length > 0) {
67
+ lines.push("");
68
+ lines.push("code-prompts:");
69
+ for (const c of codePrompts) {
70
+ lines.push(` ${c.name} kind=code samples=${c.samples.length}`);
71
+ }
72
+ }
73
+ }
74
+ if (showFragments) {
75
+ if (lines.length > 0) {
76
+ lines.push("");
77
+ }
78
+ lines.push("fragments:");
79
+ if (fragments.length === 0) {
80
+ lines.push(" (none)");
81
+ }
82
+ for (const f of fragments) {
83
+ const tags = f.tags && f.tags.length > 0 ? f.tags.join(",") : "-";
84
+ const source = relative(promptsDir, f.sourceFile) || f.sourceFile;
85
+ lines.push(` ${f.id} kind=${f.kind ?? "-"} tags=${tags} ${source}`);
86
+ }
87
+ }
88
+ io.stdout(`${lines.join("\n")}\n`);
89
+ return 0;
90
+ }
@@ -0,0 +1,47 @@
1
+ import type { ResolveResult } from "@markbrutx/promptbook-core";
2
+ import { resolve } from "@markbrutx/promptbook-core";
3
+ import type { ParsedArgs } from "../args.js";
4
+ import { buildContext, requirePromptsDir } from "../config.js";
5
+ import { colorEnabled, emitWarnings, type IO } from "../io.js";
6
+ import { renderExplain } from "../render-explain.js";
7
+
8
+ /**
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.
12
+ */
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;
18
+ }
19
+
20
+ const promptsDir = await requirePromptsDir(io, args.dir);
21
+ if (promptsDir === null) {
22
+ return 1;
23
+ }
24
+
25
+ let result: ResolveResult;
26
+ try {
27
+ const context = await buildContext(io, args.ctx, args.contextFile);
28
+ result = await resolve({ promptsDir, prompt, context, fs: io.fs });
29
+ } catch (error) {
30
+ io.stderr(`error: ${(error as Error).message}\n`);
31
+ return 1;
32
+ }
33
+
34
+ const { text, trace } = result;
35
+ emitWarnings(io, trace.warnings);
36
+
37
+ if (args.json) {
38
+ io.stdout(`${JSON.stringify({ text, trace }, null, 2)}\n`);
39
+ return 0;
40
+ }
41
+
42
+ if (args.explain) {
43
+ io.stderr(renderExplain(trace, colorEnabled(io)));
44
+ }
45
+ io.stdout(`${text}\n`);
46
+ return 0;
47
+ }
@@ -0,0 +1,82 @@
1
+ import type { ParsedArgs } from "../args.js";
2
+ import { requirePromptsDir } from "../config.js";
3
+ import type { IO } from "../io.js";
4
+
5
+ /** Options forwarded to `@markbrutx/promptbook-viewer`'s `startViewer`. */
6
+ export interface ViewerStartOptions {
7
+ promptsDir: string;
8
+ port?: number;
9
+ open: boolean;
10
+ }
11
+
12
+ /** The subset of the viewer's running handle the CLI uses. */
13
+ export interface ViewerHandle {
14
+ url: string;
15
+ close(): Promise<void>;
16
+ }
17
+
18
+ /**
19
+ * Injectable seam for `view`: how to start the viewer and how to keep the
20
+ * process alive. Defaults dynamically import the optional `@markbrutx/promptbook-viewer`
21
+ * package and block until a termination signal; tests inject fakes so no
22
+ * server or signal handling is needed.
23
+ */
24
+ export interface ViewDeps {
25
+ start(options: ViewerStartOptions): Promise<ViewerHandle>;
26
+ hold(handle: ViewerHandle): Promise<number>;
27
+ }
28
+
29
+ /** Thrown by the default `start` when the optional viewer package is absent. */
30
+ class ViewerNotInstalledError extends Error {}
31
+
32
+ interface ViewerModule {
33
+ startViewer(options: ViewerStartOptions): Promise<ViewerHandle>;
34
+ }
35
+
36
+ const defaultViewDeps: ViewDeps = {
37
+ async start(options) {
38
+ let mod: ViewerModule;
39
+ try {
40
+ mod = (await import("@markbrutx/promptbook-viewer")) as unknown as ViewerModule;
41
+ } catch {
42
+ throw new ViewerNotInstalledError();
43
+ }
44
+ return mod.startViewer(options);
45
+ },
46
+ hold(handle) {
47
+ return new Promise<number>((resolve) => {
48
+ const shutdown = (): void => {
49
+ void handle.close().then(() => resolve(0));
50
+ };
51
+ process.once("SIGINT", shutdown);
52
+ process.once("SIGTERM", shutdown);
53
+ });
54
+ },
55
+ };
56
+
57
+ /**
58
+ * `view`: start the local web viewer over the prompts folder, print its URL,
59
+ * and keep running until interrupted. Render-only; the viewer makes no model
60
+ * calls. `--no-open` skips launching the browser; `--port` pins the port.
61
+ */
62
+ export async function cmdView(args: ParsedArgs, io: IO, deps: ViewDeps = defaultViewDeps): Promise<number> {
63
+ const promptsDir = await requirePromptsDir(io, args.dir);
64
+ if (promptsDir === null) {
65
+ return 1;
66
+ }
67
+
68
+ let handle: ViewerHandle;
69
+ try {
70
+ handle = await deps.start({ promptsDir, port: args.port, open: !args.noOpen });
71
+ } catch (error) {
72
+ if (error instanceof ViewerNotInstalledError) {
73
+ io.stderr('error: the viewer is not installed. Add it with "npm i -D @markbrutx/promptbook-viewer".\n');
74
+ return 1;
75
+ }
76
+ io.stderr(`error: ${(error as Error).message}\n`);
77
+ return 1;
78
+ }
79
+
80
+ io.stdout(`promptbook viewer running at ${handle.url}\n`);
81
+ return deps.hold(handle);
82
+ }