@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.
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/bin/promptbook.ts +4 -0
- package/dist/bin/promptbook.d.ts +3 -0
- package/dist/bin/promptbook.d.ts.map +1 -0
- package/dist/bin/promptbook.js +4 -0
- package/dist/bin/promptbook.js.map +1 -0
- package/dist/src/args.d.ts +43 -0
- package/dist/src/args.d.ts.map +1 -0
- package/dist/src/args.js +96 -0
- package/dist/src/args.js.map +1 -0
- package/dist/src/commands/annotations.d.ts +10 -0
- package/dist/src/commands/annotations.d.ts.map +1 -0
- package/dist/src/commands/annotations.js +92 -0
- package/dist/src/commands/annotations.js.map +1 -0
- package/dist/src/commands/bundle.d.ts +13 -0
- package/dist/src/commands/bundle.d.ts.map +1 -0
- package/dist/src/commands/bundle.js +61 -0
- package/dist/src/commands/bundle.js.map +1 -0
- package/dist/src/commands/eval.d.ts +13 -0
- package/dist/src/commands/eval.d.ts.map +1 -0
- package/dist/src/commands/eval.js +113 -0
- package/dist/src/commands/eval.js.map +1 -0
- package/dist/src/commands/lint.d.ts +11 -0
- package/dist/src/commands/lint.d.ts.map +1 -0
- package/dist/src/commands/lint.js +66 -0
- package/dist/src/commands/lint.js.map +1 -0
- package/dist/src/commands/ls.d.ts +11 -0
- package/dist/src/commands/ls.d.ts.map +1 -0
- package/dist/src/commands/ls.js +84 -0
- package/dist/src/commands/ls.js.map +1 -0
- package/dist/src/commands/resolve.d.ts +9 -0
- package/dist/src/commands/resolve.d.ts.map +1 -0
- package/dist/src/commands/resolve.js +41 -0
- package/dist/src/commands/resolve.js.map +1 -0
- package/dist/src/commands/view.d.ts +30 -0
- package/dist/src/commands/view.d.ts.map +1 -0
- package/dist/src/commands/view.js +51 -0
- package/dist/src/commands/view.js.map +1 -0
- package/dist/src/config.d.ts +56 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +175 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/io.d.ts +43 -0
- package/dist/src/io.d.ts.map +1 -0
- package/dist/src/io.js +37 -0
- package/dist/src/io.js.map +1 -0
- package/dist/src/render-eval.d.ts +8 -0
- package/dist/src/render-eval.d.ts.map +1 -0
- package/dist/src/render-eval.js +51 -0
- package/dist/src/render-eval.js.map +1 -0
- package/dist/src/render-explain.d.ts +8 -0
- package/dist/src/render-explain.d.ts.map +1 -0
- package/dist/src/render-explain.js +57 -0
- package/dist/src/render-explain.js.map +1 -0
- package/dist/src/render-lint.d.ts +8 -0
- package/dist/src/render-lint.d.ts.map +1 -0
- package/dist/src/render-lint.js +57 -0
- package/dist/src/render-lint.js.map +1 -0
- package/dist/src/run.d.ts +8 -0
- package/dist/src/run.d.ts.map +1 -0
- package/dist/src/run.js +105 -0
- package/dist/src/run.js.map +1 -0
- package/dist/src/style.d.ts +16 -0
- package/dist/src/style.d.ts.map +1 -0
- package/dist/src/style.js +22 -0
- package/dist/src/style.js.map +1 -0
- package/package.json +50 -0
- package/src/args.ts +145 -0
- package/src/commands/annotations.ts +107 -0
- package/src/commands/bundle.ts +71 -0
- package/src/commands/eval.ts +137 -0
- package/src/commands/lint.ts +71 -0
- package/src/commands/ls.ts +90 -0
- package/src/commands/resolve.ts +47 -0
- package/src/commands/view.ts +82 -0
- package/src/config.ts +209 -0
- package/src/index.ts +3 -0
- package/src/io.ts +77 -0
- package/src/render-eval.ts +55 -0
- package/src/render-explain.ts +64 -0
- package/src/render-lint.ts +63 -0
- package/src/run.ts +107 -0
- 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
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
|
+
}
|