@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
package/src/config.ts ADDED
@@ -0,0 +1,209 @@
1
+ import { resolve as resolvePath } from "node:path";
2
+ import type { Context, ContextValue } from "@markbrutx/promptbook-core";
3
+ import type { IO } from "./io.js";
4
+
5
+ const NUMERIC = /^-?\d+(?:\.\d+)?$/;
6
+
7
+ /** True for a plain JSON object: an object that is neither null nor an array. */
8
+ function isJsonObject(value: unknown): value is Record<string, unknown> {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+
12
+ /**
13
+ * Coerce a raw `--ctx` value to a {@link ContextValue}: `true`/`false` become
14
+ * booleans, integer/decimal literals become numbers, everything else stays a
15
+ * string. For values that must stay strings (e.g. "123"), use `--context-file`.
16
+ */
17
+ export function coerceScalar(raw: string): ContextValue {
18
+ if (raw === "true") {
19
+ return true;
20
+ }
21
+ if (raw === "false") {
22
+ return false;
23
+ }
24
+ if (NUMERIC.test(raw)) {
25
+ return Number(raw);
26
+ }
27
+ return raw;
28
+ }
29
+
30
+ /** Parse repeated `key=value` pairs into a context bag, coercing each value. */
31
+ export function parseCtxPairs(pairs: string[]): Context {
32
+ const context: Context = {};
33
+ for (const pair of pairs) {
34
+ const eq = pair.indexOf("=");
35
+ if (eq === -1) {
36
+ throw new Error(`invalid --ctx "${pair}"; expected key=value`);
37
+ }
38
+ const key = pair.slice(0, eq);
39
+ if (key === "") {
40
+ throw new Error(`invalid --ctx "${pair}"; key is empty`);
41
+ }
42
+ context[key] = coerceScalar(pair.slice(eq + 1));
43
+ }
44
+ return context;
45
+ }
46
+
47
+ function parseContextFile(raw: string, path: string): Context {
48
+ let data: unknown;
49
+ try {
50
+ data = JSON.parse(raw);
51
+ } catch (error) {
52
+ throw new Error(`context file "${path}" is not valid JSON: ${(error as Error).message}`);
53
+ }
54
+ if (!isJsonObject(data)) {
55
+ throw new Error(`context file "${path}" must be a JSON object`);
56
+ }
57
+ const context: Context = {};
58
+ for (const [key, value] of Object.entries(data)) {
59
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
60
+ context[key] = value;
61
+ } else {
62
+ throw new Error(`context file "${path}" key "${key}" must be a string, number or boolean`);
63
+ }
64
+ }
65
+ return context;
66
+ }
67
+
68
+ /**
69
+ * Build the resolve context: `--context-file` first, then `--ctx` pairs layered
70
+ * on top so explicit flags win over the file.
71
+ */
72
+ export async function buildContext(io: IO, pairs: string[], contextFile?: string): Promise<Context> {
73
+ let fileContext: Context = {};
74
+ if (contextFile !== undefined) {
75
+ const path = resolvePath(io.cwd(), contextFile);
76
+ let raw: string;
77
+ try {
78
+ raw = await io.fs.readFile(path);
79
+ } catch {
80
+ throw new Error(`context file not found: ${path}`);
81
+ }
82
+ fileContext = parseContextFile(raw, path);
83
+ }
84
+ return { ...fileContext, ...parseCtxPairs(pairs) };
85
+ }
86
+
87
+ interface PromptbookConfig {
88
+ promptsDir?: unknown;
89
+ lint?: unknown;
90
+ eval?: unknown;
91
+ }
92
+
93
+ /** lint options sourced from the `lint` section of `promptbook.json`. */
94
+ export interface LintConfig {
95
+ maxTokens?: number;
96
+ bannedTokens?: string[];
97
+ }
98
+
99
+ /**
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.
104
+ */
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
+ }
119
+ }
120
+
121
+ /** Extract the `lint` section from an already-loaded config. */
122
+ export function lintConfigFrom(config: PromptbookConfig): LintConfig {
123
+ const section = config.lint;
124
+ if (!isJsonObject(section)) {
125
+ return {};
126
+ }
127
+ const lint: LintConfig = {};
128
+ if (typeof section.maxTokens === "number") {
129
+ lint.maxTokens = section.maxTokens;
130
+ }
131
+ if (Array.isArray(section.bannedTokens)) {
132
+ lint.bannedTokens = section.bannedTokens.filter((token): token is string => typeof token === "string");
133
+ }
134
+ return lint;
135
+ }
136
+
137
+ /** Convenience wrapper: load config and extract its `lint` section. */
138
+ export async function loadLintConfig(io: IO): Promise<LintConfig> {
139
+ return lintConfigFrom(await loadConfig(io));
140
+ }
141
+
142
+ /** eval options sourced from the `eval` section of `promptbook.json`. */
143
+ export interface EvalConfig {
144
+ model?: string;
145
+ baseUrl?: string;
146
+ }
147
+
148
+ /** Extract the `eval` section from an already-loaded config. */
149
+ export function evalConfigFrom(config: PromptbookConfig): EvalConfig {
150
+ const section = config.eval;
151
+ if (!isJsonObject(section)) {
152
+ return {};
153
+ }
154
+ const evalConfig: EvalConfig = {};
155
+ if (typeof section.model === "string") {
156
+ evalConfig.model = section.model;
157
+ }
158
+ if (typeof section.baseUrl === "string") {
159
+ evalConfig.baseUrl = section.baseUrl;
160
+ }
161
+ return evalConfig;
162
+ }
163
+
164
+ /**
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.
168
+ */
169
+ export async function resolvePromptsDir(
170
+ io: IO,
171
+ dirFlag?: string,
172
+ config?: PromptbookConfig,
173
+ ): Promise<string> {
174
+ if (dirFlag !== undefined) {
175
+ return resolvePath(io.cwd(), dirFlag);
176
+ }
177
+ const resolved = config ?? (await loadConfig(io));
178
+ if (typeof resolved.promptsDir === "string") {
179
+ return resolvePath(io.cwd(), resolved.promptsDir);
180
+ }
181
+ return resolvePath(io.cwd(), "prompts");
182
+ }
183
+
184
+ /** Whether a directory can be listed; used to give a clear missing-folder error. */
185
+ async function dirExists(io: IO, dir: string): Promise<boolean> {
186
+ try {
187
+ await io.fs.readDir(dir);
188
+ return true;
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Resolve the prompts folder and confirm it exists. On a missing folder, write
196
+ * a clear error to stderr and return null so the caller can exit non-zero.
197
+ */
198
+ export async function requirePromptsDir(
199
+ io: IO,
200
+ dirFlag?: string,
201
+ config?: PromptbookConfig,
202
+ ): Promise<string | null> {
203
+ const promptsDir = await resolvePromptsDir(io, dirFlag, config);
204
+ if (!(await dirExists(io, promptsDir))) {
205
+ io.stderr(`error: prompts folder not found: ${promptsDir}\n`);
206
+ return null;
207
+ }
208
+ return promptsDir;
209
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export type { ParsedArgs } from "./args.js";
2
+ export type { IO } from "./io.js";
3
+ export { run } from "./run.js";
package/src/io.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import type { FsAdapter, ModelAdapter } from "@markbrutx/promptbook-core";
4
+ import { nodeFs } from "@markbrutx/promptbook-core";
5
+
6
+ /** Options the `eval` command needs to build a model adapter. */
7
+ export interface AdapterOptions {
8
+ model?: string;
9
+ apiKey?: string;
10
+ baseUrl?: string;
11
+ }
12
+
13
+ /**
14
+ * Injectable side-effect surface for the CLI.
15
+ *
16
+ * Keeping stdout/stderr/cwd/fs behind this interface lets {@link run} be driven
17
+ * from tests without spawning a process. The stream contract is fixed:
18
+ * **stdout = payload** (prompt text or JSON), **stderr = explanations/errors**.
19
+ */
20
+ export interface IO {
21
+ /** Write payload bytes verbatim (the command appends its own newlines). */
22
+ stdout(text: string): void;
23
+ /** Write explanations, warnings and errors verbatim. */
24
+ stderr(text: string): void;
25
+ /** Write a file to disk, creating parent directories as needed (e.g. `bundle -o`). */
26
+ writeFile(path: string, contents: string): Promise<void>;
27
+ /** Current working directory, used to resolve relative paths and config. */
28
+ cwd(): string;
29
+ /** Environment bag, consulted for `NO_COLOR`. */
30
+ env: Record<string, string | undefined>;
31
+ /** Filesystem adapter passed straight through to the core. */
32
+ fs: FsAdapter;
33
+ /** Whether color is allowed when `NO_COLOR` is unset (true on a TTY). */
34
+ colorDefault: boolean;
35
+ /**
36
+ * Build the model adapter for `eval`. Tests inject a fake so no network or
37
+ * key is needed; when unset, the command falls back to the OpenRouter
38
+ * adapter built from flags/config/env.
39
+ */
40
+ makeAdapter?(options: AdapterOptions): ModelAdapter;
41
+ }
42
+
43
+ /** Real-process IO: stdout/stderr streams, Node fs, live env and cwd. */
44
+ export function defaultIO(): IO {
45
+ return {
46
+ stdout(text) {
47
+ process.stdout.write(text);
48
+ },
49
+ stderr(text) {
50
+ process.stderr.write(text);
51
+ },
52
+ async writeFile(path, contents) {
53
+ await mkdir(dirname(path), { recursive: true });
54
+ await writeFile(path, contents);
55
+ },
56
+ cwd: () => process.cwd(),
57
+ env: process.env,
58
+ fs: nodeFs(),
59
+ colorDefault: Boolean(process.stderr.isTTY),
60
+ };
61
+ }
62
+
63
+ /** Emit each warning to stderr with the standard `warning:` prefix. */
64
+ export function emitWarnings(io: IO, warnings: string[]): void {
65
+ for (const warning of warnings) {
66
+ io.stderr(`warning: ${warning}\n`);
67
+ }
68
+ }
69
+
70
+ /** Resolve whether colored output is allowed: off if `NO_COLOR` is set. */
71
+ export function colorEnabled(io: IO): boolean {
72
+ const flag = io.env.NO_COLOR;
73
+ if (flag !== undefined && flag !== "") {
74
+ return false;
75
+ }
76
+ return io.colorDefault;
77
+ }
@@ -0,0 +1,55 @@
1
+ import type { AssertionResult, EvalReport, FixtureResult } from "@markbrutx/promptbook-core";
2
+ import { makeStyle, plural, type Style } from "./style.js";
3
+
4
+ function rate(value: number): string {
5
+ return value.toFixed(2);
6
+ }
7
+
8
+ /** One line per fixture plus indented failure details for the failed ones. */
9
+ function renderFixture(s: Style, fixture: FixtureResult, threshold: number): string[] {
10
+ const ok = fixture.passRate >= threshold;
11
+ const mark = ok ? s.green("✓") : s.red("✗");
12
+ const head = ` ${mark} ${fixture.name} passRate ${rate(fixture.passRate)} (${fixture.passes}/${fixture.samples})`;
13
+ const lines = [head];
14
+ if (!ok) {
15
+ for (const failure of dedupeFailures(fixture.failures)) {
16
+ lines.push(` ${s.red(failure.type)}: ${failure.message}`);
17
+ if (failure.excerpt !== undefined && failure.excerpt !== "") {
18
+ lines.push(` ${s.dim(`output: ${failure.excerpt}`)}`);
19
+ }
20
+ }
21
+ }
22
+ return lines;
23
+ }
24
+
25
+ /** Collapse identical failures (same type + message) repeated across samples. */
26
+ function dedupeFailures(failures: AssertionResult[]): AssertionResult[] {
27
+ const seen = new Set<string>();
28
+ const unique: AssertionResult[] = [];
29
+ for (const failure of failures) {
30
+ const key = `${failure.type}\u0000${failure.message}`;
31
+ if (!seen.has(key)) {
32
+ seen.add(key);
33
+ unique.push(failure);
34
+ }
35
+ }
36
+ return unique;
37
+ }
38
+
39
+ /**
40
+ * Render an {@link EvalReport} as a human-readable block: a per-fixture line
41
+ * (pass/fail mark, passRate, samples) with indented failing assertions and
42
+ * output excerpts, then a summary line gated by `threshold`.
43
+ */
44
+ export function renderEvalReport(report: EvalReport, threshold: number, color: boolean): string {
45
+ const s = makeStyle(color);
46
+ const lines: string[] = [s.bold("eval")];
47
+ for (const fixture of report.results) {
48
+ lines.push(...renderFixture(s, fixture, threshold));
49
+ }
50
+ const total = report.results.length;
51
+ const summary = `${report.passed}/${total} fixtures passed (threshold ${rate(threshold)})`;
52
+ const colored = report.failed > 0 ? s.red(summary) : s.green(summary);
53
+ lines.push(`summary: ${colored}, ${plural(report.failed, "failure")}`);
54
+ return `${lines.join("\n")}\n`;
55
+ }
@@ -0,0 +1,64 @@
1
+ import type { ContextValue, Trace } from "@markbrutx/promptbook-core";
2
+ import { formatContext, makeStyle } from "./style.js";
3
+
4
+ function whenLabel(when: Record<string, ContextValue>): string {
5
+ const label = formatContext(when);
6
+ return label === "" ? "always" : label;
7
+ }
8
+
9
+ /**
10
+ * Render an explain {@link Trace} as a human-readable block for stderr: which
11
+ * rules fired (and why not), the final order, the replace/add/forbid effects,
12
+ * and a highlighted "no rule matched" section for unmatched context axes.
13
+ */
14
+ export function renderExplain(trace: Trace, color: boolean): string {
15
+ const s = makeStyle(color);
16
+ const lines: string[] = [];
17
+
18
+ const contextLabel = whenLabel(trace.context);
19
+ lines.push(`${s.bold(`resolve "${trace.prompt}"`)}${s.dim(` · context: ${contextLabel}`)}`);
20
+
21
+ lines.push(s.bold("rules:"));
22
+ if (trace.rules.length === 0) {
23
+ lines.push(` ${s.dim("(none)")}`);
24
+ }
25
+ for (const rule of trace.rules) {
26
+ const head = `#${rule.index} ${rule.action} ${s.dim(`[${whenLabel(rule.when)}]`)}`;
27
+ if (rule.fired) {
28
+ lines.push(` ${s.green("✓")} ${head} → ${rule.effect ?? ""}`);
29
+ } else {
30
+ lines.push(` ${s.red("✗")} ${head} ${s.dim(`— ${rule.reason ?? "did not match"}`)}`);
31
+ }
32
+ }
33
+
34
+ lines.push(`${s.bold("final order:")} ${trace.finalOrder.join(" → ") || "(empty)"}`);
35
+
36
+ if (trace.replaced.length > 0) {
37
+ lines.push(s.bold("replaced:"));
38
+ for (const entry of trace.replaced) {
39
+ lines.push(` ${entry.from} → ${entry.to} ${s.dim(`(#${entry.ruleIndex})`)}`);
40
+ }
41
+ }
42
+ if (trace.added.length > 0) {
43
+ lines.push(s.bold("added:"));
44
+ for (const entry of trace.added) {
45
+ const anchor = entry.after !== undefined ? ` after ${entry.after}` : "";
46
+ lines.push(` ${entry.id}${anchor} ${s.dim(`(#${entry.ruleIndex})`)}`);
47
+ }
48
+ }
49
+ if (trace.forbidden.length > 0) {
50
+ lines.push(s.bold("forbidden:"));
51
+ for (const entry of trace.forbidden) {
52
+ lines.push(` ${entry.id} ${s.dim(`(#${entry.ruleIndex})`)}`);
53
+ }
54
+ }
55
+
56
+ if (trace.unmatchedAxes.length > 0) {
57
+ lines.push(s.warn(s.bold("unmatched axes:")));
58
+ for (const axis of trace.unmatchedAxes) {
59
+ lines.push(` ${s.warn(`⚠ no rules matched for ${axis.key}=${axis.value}`)}`);
60
+ }
61
+ }
62
+
63
+ return `${lines.join("\n")}\n`;
64
+ }
@@ -0,0 +1,63 @@
1
+ import type { LintFinding, LintReport, Severity } from "@markbrutx/promptbook-core";
2
+ import { makeStyle, plural, type Style } from "./style.js";
3
+
4
+ const SEVERITY_ORDER: Severity[] = ["error", "warning", "info"];
5
+
6
+ function location(finding: LintFinding): string {
7
+ const parts: string[] = [];
8
+ if (finding.fragmentId !== undefined) {
9
+ parts.push(`fragment: ${finding.fragmentId}`);
10
+ }
11
+ if (finding.ruleIndex !== undefined) {
12
+ parts.push(`rule #${finding.ruleIndex}`);
13
+ }
14
+ return parts.length > 0 ? ` (${parts.join(", ")})` : "";
15
+ }
16
+
17
+ function colorize(s: Style, severity: Severity, text: string): string {
18
+ if (severity === "error") {
19
+ return s.red(text);
20
+ }
21
+ if (severity === "warning") {
22
+ return s.warn(text);
23
+ }
24
+ return s.dim(text);
25
+ }
26
+
27
+ /**
28
+ * Render a {@link LintReport} as a human-readable block grouped by severity.
29
+ * A clean report is a single green line; otherwise a summary line is followed
30
+ * by `errors:`/`warnings:`/`info:` sections listing `ruleId: message`.
31
+ */
32
+ export function renderLintReport(report: LintReport, label: string, color: boolean): string {
33
+ const s = makeStyle(color);
34
+ const head = s.bold(`lint "${label}"`);
35
+ if (report.findings.length === 0) {
36
+ return `${head} ${s.green("— no findings")}\n`;
37
+ }
38
+
39
+ const infoCount = report.findings.length - report.errorCount - report.warningCount;
40
+ const summary: string[] = [];
41
+ if (report.errorCount > 0) {
42
+ summary.push(s.red(plural(report.errorCount, "error")));
43
+ }
44
+ if (report.warningCount > 0) {
45
+ summary.push(s.warn(plural(report.warningCount, "warning")));
46
+ }
47
+ if (infoCount > 0) {
48
+ summary.push(s.dim(plural(infoCount, "info")));
49
+ }
50
+
51
+ const lines: string[] = [`${head} — ${summary.join(", ")}`];
52
+ for (const severity of SEVERITY_ORDER) {
53
+ const group = report.findings.filter((finding) => finding.severity === severity);
54
+ if (group.length === 0) {
55
+ continue;
56
+ }
57
+ lines.push(colorize(s, severity, s.bold(`${severity}s:`)));
58
+ for (const finding of group) {
59
+ lines.push(` ${finding.ruleId}: ${finding.message}${location(finding)}`);
60
+ }
61
+ }
62
+ return `${lines.join("\n")}\n`;
63
+ }
package/src/run.ts ADDED
@@ -0,0 +1,107 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { parseCliArgs } from "./args.js";
4
+ import { cmdAnnotations } from "./commands/annotations.js";
5
+ import { cmdBundle } from "./commands/bundle.js";
6
+ import { cmdEval } from "./commands/eval.js";
7
+ import { cmdLint } from "./commands/lint.js";
8
+ import { cmdLs } from "./commands/ls.js";
9
+ import { cmdResolve } from "./commands/resolve.js";
10
+ import { cmdView } from "./commands/view.js";
11
+ import { defaultIO, type IO } from "./io.js";
12
+
13
+ const HELP = `promptbook — compose prompts from reusable fragments
14
+
15
+ Usage:
16
+ promptbook <command> [options]
17
+
18
+ Commands:
19
+ resolve <prompt> Assemble a prompt and print it to stdout
20
+ lint [<prompt>] Run static checks; with no prompt, book rules only
21
+ 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
+ view Start the local web viewer over the prompts folder
24
+ annotations <action> Drain the viewer's feedback queue: list | resolve <id> | clear
25
+ ls List compositions and fragments
26
+
27
+ Options:
28
+ --dir <path> Prompts folder (default: promptbook.json promptsDir, else ./prompts)
29
+ --ctx key=value Context value, repeatable (coerced to boolean/number/string)
30
+ --context-file <json> Merge context from a JSON file (--ctx overrides it)
31
+ --explain resolve: print the resolution trace to stderr
32
+ --json Emit machine-readable JSON on stdout
33
+ --max-tokens N lint: token-budget ceiling (overrides promptbook.json)
34
+ --strict lint: exit non-zero on warnings too
35
+ --model <id> eval: model id for the adapter (overrides promptbook.json)
36
+ --samples N eval: default samples per fixture (default 1; a fixture's own samples wins)
37
+ --threshold R eval: a fixture passes when passRate >= R (default 1)
38
+ --lint eval: run a static lint gate over every variant first
39
+ -o, --out <file> bundle: write the generated module to a file (default: stdout)
40
+ --plain bundle: emit a plain module (no type-only import; e.g. for Deno)
41
+ --port N view: port for the viewer server (default: a free port)
42
+ --no-open view: do not open the browser after starting
43
+ --fragments ls: list fragments only
44
+ --compositions ls: list compositions only
45
+ -h, --help Show this help
46
+ -v, --version Show the version
47
+
48
+ Streams: stdout = payload (prompt text or JSON); stderr = explanations and errors.
49
+ `;
50
+
51
+ function readVersion(): string {
52
+ try {
53
+ const path = fileURLToPath(new URL("../../package.json", import.meta.url));
54
+ const pkg = JSON.parse(readFileSync(path, "utf8")) as { version?: string };
55
+ return pkg.version ?? "0.0.0";
56
+ } catch {
57
+ return "0.0.0";
58
+ }
59
+ }
60
+
61
+ /**
62
+ * CLI entry point. Parses argv, handles `--help`/`--version`, then dispatches
63
+ * to a subcommand. Returns the process exit code; never calls `process.exit`
64
+ * so it stays testable. `io` injects all side effects (streams, fs, env, cwd).
65
+ */
66
+ export async function run(argv: string[], io: IO = defaultIO()): Promise<number> {
67
+ let args: ReturnType<typeof parseCliArgs>;
68
+ try {
69
+ args = parseCliArgs(argv);
70
+ } catch (error) {
71
+ io.stderr(`error: ${(error as Error).message}\n`);
72
+ return 1;
73
+ }
74
+
75
+ if (args.help) {
76
+ io.stdout(HELP);
77
+ return 0;
78
+ }
79
+ if (args.version) {
80
+ io.stdout(`${readVersion()}\n`);
81
+ return 0;
82
+ }
83
+ if (args.command === undefined) {
84
+ io.stdout(HELP);
85
+ return 0;
86
+ }
87
+
88
+ switch (args.command) {
89
+ case "resolve":
90
+ return cmdResolve(args, io);
91
+ case "lint":
92
+ return cmdLint(args, io);
93
+ case "eval":
94
+ return cmdEval(args, io);
95
+ case "bundle":
96
+ return cmdBundle(args, io);
97
+ case "view":
98
+ return cmdView(args, io);
99
+ case "annotations":
100
+ return cmdAnnotations(args, io);
101
+ case "ls":
102
+ return cmdLs(args, io);
103
+ default:
104
+ io.stderr(`error: unknown command "${args.command}". Run "promptbook --help".\n`);
105
+ return 1;
106
+ }
107
+ }
package/src/style.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { ContextValue } from "@markbrutx/promptbook-core";
2
+
3
+ /** Render a context/when bag as `k=v, k=v` (empty string when empty). */
4
+ export function formatContext(context: Record<string, ContextValue>): string {
5
+ return Object.entries(context)
6
+ .map(([key, value]) => `${key}=${value}`)
7
+ .join(", ");
8
+ }
9
+
10
+ /** Minimal ANSI styling, used by the explain and lint renderers. */
11
+ export interface Style {
12
+ bold(text: string): string;
13
+ dim(text: string): string;
14
+ green(text: string): string;
15
+ red(text: string): string;
16
+ warn(text: string): string;
17
+ }
18
+
19
+ /** Pluralize `word` by `count`, e.g. `plural(2, "error")` -> "2 errors". */
20
+ export function plural(count: number, word: string): string {
21
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
22
+ }
23
+
24
+ /** Build a {@link Style}; when `color` is false every helper is a no-op. */
25
+ export function makeStyle(color: boolean): Style {
26
+ const wrap =
27
+ (code: string) =>
28
+ (text: string): string =>
29
+ color ? `\x1b[${code}m${text}\x1b[0m` : text;
30
+ return {
31
+ bold: wrap("1"),
32
+ dim: wrap("2"),
33
+ green: wrap("32"),
34
+ red: wrap("31"),
35
+ warn: wrap("33"),
36
+ };
37
+ }