@siftline/cli 0.0.1 → 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/README.md CHANGED
@@ -1,16 +1,33 @@
1
1
  # @siftline/cli
2
2
 
3
- The Siftline command line: measure a recipe against its fixtures, then label for real.
4
-
5
- This package is a walking skeleton. The `siftline` bin prints its version and a usage
6
- block; `test` and `label` are named there but not implemented, which is enough to prove
7
- the bin, shebang, build, type, test, pack and publish path. The commands land next.
3
+ The Siftline command line: measure a Recipe against its Fixtures, then label for real.
8
4
 
9
5
  ```sh
10
- npx @siftline/cli --help
6
+ npm install --save-dev @siftline/cli
7
+ export TYPESAFE_API_KEY=...
8
+
9
+ npx siftline test recipe.json fixtures.jsonl
10
+ npx siftline label recipe.json records.jsonl --rules rules.json > decisions.jsonl
11
11
  ```
12
12
 
13
- Exit codes: `0` for `--help` and `--version`, `1` for anything else.
13
+ ```
14
+ support-inbox v1 · jev-1.13.0
15
+
16
+ category 4/5 0.80
17
+ wants_human 5/5 1.00
18
+
19
+ accuracy 0.80 (lowest question)
20
+ unsure 1 of 5 would go to Review
21
+
22
+ fixture:4 category: expected complaint, got question
23
+ ```
24
+
25
+ `test` reports one accuracy per Question and fails CI with `--min-accuracy`. `label` writes
26
+ one Decision line per Record, routed through Rules when `--rules` is given. `npx siftline --help`
27
+ prints the rest.
28
+
29
+ The guide, the options and the exit codes live at
30
+ [docs.siftline.dev](https://docs.siftline.dev/docs/packages/cli).
14
31
 
15
32
  ESM only. Node 22.14 or newer.
16
33
 
package/dist/cli.mjs CHANGED
@@ -1,9 +1,22 @@
1
1
  #!/usr/bin/env node
2
- import { r as run } from "./src-B28Y6vHX.mjs";
2
+ import { i as API_KEY_ENV, t as run } from "./src-lWwTID7-.mjs";
3
+ import { TypeSafeClient } from "@typesafe-ai/sdk";
3
4
  //#region src/cli.ts
4
- const result = run(process.argv.slice(2));
5
- if (result.stdout !== "") process.stdout.write(result.stdout);
6
- if (result.stderr !== "") process.stderr.write(result.stderr);
7
- process.exitCode = result.code;
5
+ const controller = new AbortController();
6
+ process.on("SIGINT", () => {
7
+ controller.abort();
8
+ });
9
+ let client;
10
+ process.exitCode = await run(process.argv.slice(2), {
11
+ client: { systemOne: (request, options) => {
12
+ client ??= new TypeSafeClient({ apiKey: process.env[API_KEY_ENV] });
13
+ return client.systemOne(request, options);
14
+ } },
15
+ stdin: process.stdin,
16
+ stdout: process.stdout,
17
+ stderr: process.stderr,
18
+ env: process.env,
19
+ signal: controller.signal
20
+ });
8
21
  //#endregion
9
22
  export {};
package/dist/index.d.mts CHANGED
@@ -1,24 +1,37 @@
1
- //#region src/index.d.ts
2
- /**
3
- * The published version of `@siftline/cli`, baked in at build time.
4
- *
5
- * Changesets bumps `package.json`; this constant follows it without a second edit.
6
- */
1
+ import { SystemOneClient } from "@siftline/core";
2
+ //#region src/deps.d.ts
3
+ /** `process.stdout` and `process.stderr` satisfy this, and so does a string buffer. */
4
+ interface OutputStream {
5
+ write: (chunk: string) => unknown;
6
+ readonly isTTY?: boolean;
7
+ }
8
+ /** `process.stdin`, or any async iterable of chunks. */
9
+ type InputStream = AsyncIterable<string | Uint8Array>;
10
+ /** The CLI's only seam. The bin wires the process to it; tests wire strings. */
11
+ interface RunDeps {
12
+ client: SystemOneClient;
13
+ stdin: InputStream;
14
+ stdout: OutputStream;
15
+ stderr: OutputStream;
16
+ env: {
17
+ readonly [name: string]: string | undefined;
18
+ };
19
+ signal?: AbortSignal;
20
+ }
21
+ //#endregion
22
+ //#region src/options.d.ts
23
+ export declare const API_KEY_ENV = "TYPESAFE_API_KEY";
24
+ //#endregion
25
+ //#region src/usage.d.ts
26
+ /** The published version of `@siftline/cli`, baked in at build time. */
7
27
  export declare const VERSION: string;
8
- /**
9
- * The usage block. `test` and `label` are named before they exist so that the bin
10
- * wiring is proven — and so that `siftline --help` never lies about what it does.
11
- */
12
28
  export declare const USAGE: string;
13
- /** What one invocation produced: the text to write and the process exit code. */
14
- export interface CliResult {
15
- readonly code: 0 | 1;
16
- readonly stdout: string;
17
- readonly stderr: string;
18
- }
29
+ //#endregion
30
+ //#region src/index.d.ts
19
31
  /**
20
- * Interpret command line arguments. Pure on purpose: the bin is the only place that
21
- * touches the process, so the behaviour stays testable without one.
32
+ * The library door. Every command runs through it, so a test drives the whole CLI with an
33
+ * injected client and string streams and asserts on the exit code and what was written.
22
34
  */
23
- export declare function run(argv: readonly string[]): CliResult;
24
- //#endregion
35
+ export declare function run(argv: readonly string[], deps: RunDeps): Promise<number>;
36
+ //#endregion
37
+ export type { InputStream, OutputStream, RunDeps };
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { n as VERSION, r as run, t as USAGE } from "./src-B28Y6vHX.mjs";
2
- export { USAGE, VERSION, run };
1
+ import { i as API_KEY_ENV, n as USAGE, r as VERSION, t as run } from "./src-lWwTID7-.mjs";
2
+ export { API_KEY_ENV, USAGE, VERSION, run };
@@ -0,0 +1,455 @@
1
+ import { DEFAULT_MAX_IN_FLIGHT, JudgeError, SiftlineError, VERSION, createJudge, parseFixtures, parseRecipe, recordSchema, routeDecision, ruleSchema, serializeDecision, testRecipe, validateFixtures, validateRules } from "@siftline/core";
2
+ import { readFile } from "node:fs/promises";
3
+ import { parseArgs } from "node:util";
4
+ //#region src/deps.ts
5
+ /** A bad invocation. Exit 2, with the usage block. */
6
+ var UsageError = class extends Error {};
7
+ /** A file the arguments named that could not be read or parsed. Exit 2, no usage block. */
8
+ var InputError = class extends Error {};
9
+ function writeLine(stream, line) {
10
+ stream.write(`${line}\n`);
11
+ }
12
+ //#endregion
13
+ //#region src/input.ts
14
+ async function readTextFile(path, what) {
15
+ try {
16
+ return await readFile(path, "utf8");
17
+ } catch (cause) {
18
+ throw new InputError(`cannot read the ${what} ${path}: ${reason(cause)}`, { cause });
19
+ }
20
+ }
21
+ async function readRecipeFile(path) {
22
+ const text = await readTextFile(path, "recipe");
23
+ try {
24
+ return parseRecipe(text);
25
+ } catch (cause) {
26
+ throw new InputError(`${path} is not a valid Recipe: ${reason(cause)}`, { cause });
27
+ }
28
+ }
29
+ async function readFixturesFile(path, recipe) {
30
+ return await readChecked(path, "fixtures", "Fixtures", parseFixtures, (fixtures) => validateFixtures(fixtures, recipe).map((problem) => `fixture ${problem.fixture}: ${problem.problem}`));
31
+ }
32
+ async function readRulesFile(path, recipe) {
33
+ return await readChecked(path, "rules", "Rules", (text) => ruleSchema.array().parse(JSON.parse(text)), (rules) => validateRules(rules, recipe).map((problem) => `rule ${problem.rule}: ${problem.problem}`));
34
+ }
35
+ /** Parse the whole file, then refuse it on the first line that no longer fits the Recipe. */
36
+ async function readChecked(path, what, kind, parse, stale) {
37
+ const text = await readTextFile(path, what);
38
+ let items;
39
+ try {
40
+ items = parse(text);
41
+ } catch (cause) {
42
+ throw new InputError(`${path} does not hold valid ${kind}: ${reason(cause)}`, { cause });
43
+ }
44
+ const problems = stale(items);
45
+ const first = problems[0];
46
+ if (first) throw new InputError(`${path} does not fit the Recipe: ${first}${andMore(problems.length)}`);
47
+ return items;
48
+ }
49
+ /** `-` and an absent path both mean stdin. Every line is parsed before the caller judges any. */
50
+ async function readRecords(path, stdin) {
51
+ const fromStdin = path === void 0 || path === "-";
52
+ const source = fromStdin ? "stdin" : path;
53
+ const text = fromStdin ? await readStdin(stdin) : await readTextFile(path, "records");
54
+ const records = [];
55
+ for (const [offset, line] of text.split("\n").entries()) {
56
+ if (line.trim() === "") continue;
57
+ try {
58
+ records.push(recordSchema.parse(JSON.parse(line)));
59
+ } catch (cause) {
60
+ throw new InputError(`${source} line ${offset + 1} is not a valid Record: ${reason(cause)}`, { cause });
61
+ }
62
+ }
63
+ return records;
64
+ }
65
+ async function readStdin(stream) {
66
+ const decoder = new TextDecoder();
67
+ let text = "";
68
+ for await (const chunk of stream) text += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
69
+ return text + decoder.decode();
70
+ }
71
+ /** Zod's issues by duck typing: the CLI states no opinion on the validator core uses. */
72
+ function issuesOf(value) {
73
+ if (typeof value !== "object" || value === null || !("issues" in value)) return void 0;
74
+ const raw = value.issues;
75
+ if (!Array.isArray(raw)) return void 0;
76
+ const issues = [];
77
+ for (const candidate of raw) if (isIssue(candidate)) issues.push(candidate);
78
+ return issues.length === 0 ? void 0 : issues;
79
+ }
80
+ function isIssue(value) {
81
+ return typeof value === "object" && value !== null && "message" in value && typeof value.message === "string" && "path" in value && Array.isArray(value.path);
82
+ }
83
+ function reason(cause) {
84
+ const issues = issuesOf(cause);
85
+ if (issues) return firstIssue(issues);
86
+ if (cause instanceof Error) {
87
+ const nested = issuesOf(cause.cause);
88
+ return nested ? `${cause.message}: ${firstIssue(nested)}` : cause.message;
89
+ }
90
+ return String(cause);
91
+ }
92
+ /** The first issue with its path, and how many stand behind it. */
93
+ function firstIssue(issues) {
94
+ const first = issues[0];
95
+ if (!first) return "invalid";
96
+ return `${first.path.length === 0 ? "" : `${first.path.join(".")}: `}${first.message}${andMore(issues.length)}`;
97
+ }
98
+ /** The tail every "here is the first of them" sentence in this file ends with. */
99
+ function andMore(count) {
100
+ return count === 1 ? "" : ` (and ${count - 1} more)`;
101
+ }
102
+ //#endregion
103
+ //#region src/options.ts
104
+ const API_KEY_ENV = "TYPESAFE_API_KEY";
105
+ /** Each command hands `parseArgs` only its own flags, so a foreign one is an unknown option. */
106
+ const TEST_FLAGS = {
107
+ "max-in-flight": { type: "string" },
108
+ "min-accuracy": { type: "string" },
109
+ json: { type: "boolean" },
110
+ quiet: { type: "boolean" }
111
+ };
112
+ const LABEL_FLAGS = {
113
+ "max-in-flight": { type: "string" },
114
+ quiet: { type: "boolean" },
115
+ rules: { type: "string" }
116
+ };
117
+ function parseTestArgs(args) {
118
+ const { values, positionals } = parseWith(args, TEST_FLAGS);
119
+ return {
120
+ positionals,
121
+ options: {
122
+ maxInFlight: gateWidth(textValue(values["max-in-flight"])),
123
+ minAccuracy: ratio$1(textValue(values["min-accuracy"])),
124
+ json: values["json"] === true,
125
+ quiet: values["quiet"] === true
126
+ }
127
+ };
128
+ }
129
+ function parseLabelArgs(args) {
130
+ const { values, positionals } = parseWith(args, LABEL_FLAGS);
131
+ return {
132
+ positionals,
133
+ options: {
134
+ maxInFlight: gateWidth(textValue(values["max-in-flight"])),
135
+ quiet: values["quiet"] === true,
136
+ rules: textValue(values["rules"]) ?? null
137
+ }
138
+ };
139
+ }
140
+ function parseWith(args, flags) {
141
+ try {
142
+ const parsed = parseArgs({
143
+ args: [...args],
144
+ options: flags,
145
+ allowPositionals: true,
146
+ strict: true
147
+ });
148
+ return {
149
+ values: parsed.values,
150
+ positionals: parsed.positionals
151
+ };
152
+ } catch (cause) {
153
+ throw new UsageError(firstSentence(cause), { cause });
154
+ }
155
+ }
156
+ /** The key is read here and nowhere else, so it never reaches argv, usage or a log line. */
157
+ function requireApiKey(env) {
158
+ const key = env[API_KEY_ENV];
159
+ if (key === void 0 || key.trim() === "") throw new UsageError(`${API_KEY_ENV} is not set`);
160
+ }
161
+ function textValue(value) {
162
+ return typeof value === "string" ? value : void 0;
163
+ }
164
+ function gateWidth(raw) {
165
+ if (raw === void 0) return DEFAULT_MAX_IN_FLIGHT;
166
+ const value = parseNumber(raw);
167
+ if (value === void 0 || !Number.isInteger(value) || value < 1) throw new UsageError(`--max-in-flight takes an integer of 1 or more, not ${JSON.stringify(raw)}`);
168
+ return value;
169
+ }
170
+ function ratio$1(raw) {
171
+ if (raw === void 0) return null;
172
+ const value = parseNumber(raw);
173
+ if (value === void 0 || value < 0 || value > 1) throw new UsageError(`--min-accuracy takes a ratio from 0 to 1, not ${JSON.stringify(raw)}`);
174
+ return value;
175
+ }
176
+ function parseNumber(raw) {
177
+ if (raw.trim() === "") return void 0;
178
+ const value = Number(raw);
179
+ return Number.isFinite(value) ? value : void 0;
180
+ }
181
+ function firstSentence(cause) {
182
+ const message = cause instanceof Error ? cause.message : String(cause);
183
+ return message.split(". ")[0] ?? message;
184
+ }
185
+ //#endregion
186
+ //#region src/report.ts
187
+ const NA = "n/a";
188
+ /** Wide enough for `0.00` plus the gap before the note beside it. */
189
+ const RATIO_COLUMN = 6;
190
+ function watchDrift(recipeModel, stderr) {
191
+ let message = null;
192
+ return {
193
+ note: (decisionModel) => {
194
+ if (message !== null || decisionModel === recipeModel) return;
195
+ message = `model drift: the Recipe asks for ${recipeModel}, the Decisions came back from ${decisionModel}`;
196
+ writeLine(stderr, `siftline: ${message}`);
197
+ },
198
+ get message() {
199
+ return message;
200
+ }
201
+ };
202
+ }
203
+ /** One line per result, or one per miss. `order` is the Recipe's Question order. */
204
+ function progressLines(result, order) {
205
+ if (result.mismatches.length === 0) return [`ok ${result.id}`];
206
+ return inOrder(result.mismatches, order).map((miss) => `miss ${result.id} ${describeMismatch(miss)}`);
207
+ }
208
+ function renderReport(report, order) {
209
+ const rows = Object.entries(report.questions).map(([question, tally]) => ({
210
+ question,
211
+ counts: tally.asserted === 0 ? NA : `${tally.matched}/${tally.asserted}`,
212
+ accuracy: ratio(tally.accuracy)
213
+ }));
214
+ const labels = Math.max(...rows.map((row) => row.question.length), 8, 6) + 3;
215
+ const counts = Math.max(...rows.map((row) => row.counts.length), 3) + 3;
216
+ const lines = [
217
+ `${report.recipe.name} v${report.recipe.version} · ${report.model === "" ? NA : report.model}`,
218
+ "",
219
+ ...rows.map((row) => `${row.question.padEnd(labels)}${row.counts.padEnd(counts)}${row.accuracy}`),
220
+ "",
221
+ `${"accuracy".padEnd(labels)}${ratio(report.accuracy).padEnd(RATIO_COLUMN)}(lowest question)`,
222
+ `${"unsure".padEnd(labels)}${unsure(report)} of ${report.fixtures.length} would go to Review`
223
+ ];
224
+ const misses = report.fixtures.flatMap((result) => inOrder(result.mismatches, order).map((miss) => `${result.id} ${describeMismatch(miss)}`));
225
+ if (misses.length > 0) lines.push("", ...misses);
226
+ return lines.join("\n");
227
+ }
228
+ function unsure(report) {
229
+ return report.fixtures.filter((result) => result.review).length;
230
+ }
231
+ function ratio(value) {
232
+ return value === null ? NA : value.toFixed(2);
233
+ }
234
+ function describeMismatch(miss) {
235
+ return `${miss.question}: expected ${show(miss.expected)}, got ${show(miss.actual)}`;
236
+ }
237
+ function show(value) {
238
+ return value === void 0 ? "nothing" : String(value);
239
+ }
240
+ function inOrder(mismatches, order) {
241
+ return mismatches.toSorted((a, b) => order.indexOf(a.question) - order.indexOf(b.question));
242
+ }
243
+ //#endregion
244
+ //#region src/label.ts
245
+ async function runLabel(args, deps) {
246
+ const { positionals, options } = parseLabelArgs(args);
247
+ const [recipePath, recordsPath] = positionals;
248
+ if (recipePath === void 0) throw new UsageError("label needs a recipe");
249
+ if (positionals.length > 2) throw new UsageError(`label takes at most two positionals, not ${positionals.length}`);
250
+ const recipe = await readRecipeFile(recipePath);
251
+ const rules = options.rules === null ? null : await readRulesFile(options.rules, recipe);
252
+ return await label(await readRecords(recordsPath, deps.stdin), recipe, rules, deps, options);
253
+ }
254
+ async function label(records, recipe, rules, deps, options) {
255
+ const { maxInFlight } = options;
256
+ const judge = createJudge({
257
+ client: deps.client,
258
+ retry: "patient",
259
+ maxInFlight
260
+ });
261
+ const progress = createProgress(deps.stderr, records.length, options.quiet);
262
+ const drift = watchDrift(recipe.model, deps.stderr);
263
+ const buffered = /* @__PURE__ */ new Map();
264
+ let written = 0;
265
+ const flush = () => {
266
+ while (buffered.has(written)) {
267
+ const line = buffered.get(written) ?? null;
268
+ buffered.delete(written);
269
+ written += 1;
270
+ if (line !== null) writeLine(deps.stdout, line);
271
+ }
272
+ };
273
+ const controller = new AbortController();
274
+ const signal = deps.signal ? AbortSignal.any([deps.signal, controller.signal]) : controller.signal;
275
+ let fatal;
276
+ let failed = false;
277
+ let taken = 0;
278
+ let done = 0;
279
+ const worker = async () => {
280
+ while (fatal === void 0) {
281
+ const index = taken;
282
+ const record = records[index];
283
+ if (record === void 0) return;
284
+ taken += 1;
285
+ try {
286
+ const decision = await judge(record, recipe, { signal });
287
+ drift.note(decision.model);
288
+ buffered.set(index, serializeDecision(rules === null ? decision : routeDecision(decision, rules)));
289
+ } catch (error) {
290
+ if (!(error instanceof JudgeError)) {
291
+ fatal ??= error;
292
+ controller.abort(error);
293
+ return;
294
+ }
295
+ failed = true;
296
+ writeLine(deps.stderr, `${record.id}: ${sentence(error)}`);
297
+ buffered.set(index, null);
298
+ }
299
+ done += 1;
300
+ progress.tick(done);
301
+ flush();
302
+ }
303
+ };
304
+ const width = Math.min(maxInFlight, records.length);
305
+ await Promise.all(Array.from({ length: width }, () => worker()));
306
+ progress.finish();
307
+ if (fatal !== void 0) throw fatal;
308
+ return failed ? 1 : 0;
309
+ }
310
+ const REASONS = {
311
+ max_tokens_exceeded: "state over the token budget",
312
+ api_usage_error: "the API refused the request",
313
+ network: "the call never reached the API",
314
+ timeout: "the call timed out"
315
+ };
316
+ function sentence(error) {
317
+ if (error.reason === "unknown") return error.message;
318
+ if (error.reason === "invalid_answers") return `the answers did not fit the Recipe: ${error.message}`;
319
+ return REASONS[error.reason];
320
+ }
321
+ /** One line per this many Records where the counter cannot be rewritten in place. */
322
+ const PROGRESS_EVERY = 50;
323
+ const SILENT = {
324
+ tick: () => void 0,
325
+ finish: () => void 0
326
+ };
327
+ function createProgress(stream, total, quiet) {
328
+ if (quiet || total === 0) return SILENT;
329
+ if (stream.isTTY === true) return {
330
+ tick: (done) => {
331
+ stream.write(`\r${done}/${total}`);
332
+ },
333
+ finish: () => {
334
+ stream.write("\n");
335
+ }
336
+ };
337
+ return {
338
+ tick: (done) => {
339
+ if (done % PROGRESS_EVERY === 0) writeLine(stream, `${done}/${total}`);
340
+ },
341
+ finish: () => void 0
342
+ };
343
+ }
344
+ //#endregion
345
+ //#region src/test.ts
346
+ async function runTest(args, deps) {
347
+ const { positionals, options } = parseTestArgs(args);
348
+ const [recipePath, fixturesPath] = positionals;
349
+ if (recipePath === void 0 || fixturesPath === void 0) throw new UsageError("test needs a recipe and a fixtures file");
350
+ if (positionals.length > 2) throw new UsageError(`test takes two positionals, not ${positionals.length}`);
351
+ const recipe = await readRecipeFile(recipePath);
352
+ const fixtures = await readFixturesFile(fixturesPath, recipe);
353
+ const order = Object.keys(recipe.questions);
354
+ const judge = createJudge({
355
+ client: deps.client,
356
+ retry: "patient",
357
+ maxInFlight: options.maxInFlight
358
+ });
359
+ const drift = watchDrift(recipe.model, deps.stderr);
360
+ const report = await testRecipe(judge, recipe, fixtures, {
361
+ signal: deps.signal,
362
+ onResult: (result) => {
363
+ drift.note(result.decision.model);
364
+ if (options.quiet) return;
365
+ for (const line of progressLines(result, order)) writeLine(deps.stderr, line);
366
+ }
367
+ });
368
+ if (options.json) writeLine(deps.stdout, JSON.stringify({
369
+ ...report,
370
+ drift: drift.message
371
+ }, null, 2));
372
+ else writeLine(deps.stdout, renderReport(report, order));
373
+ const floor = options.minAccuracy;
374
+ if (floor === null) return 0;
375
+ if (report.accuracy === null) {
376
+ writeLine(deps.stderr, `siftline: no Question was asserted, so there is no accuracy to meet --min-accuracy ${floor}`);
377
+ return 1;
378
+ }
379
+ if (report.accuracy < floor) {
380
+ writeLine(deps.stderr, `siftline: accuracy ${report.accuracy.toFixed(2)} is below --min-accuracy ${floor}`);
381
+ return 1;
382
+ }
383
+ return 0;
384
+ }
385
+ //#endregion
386
+ //#region src/usage.ts
387
+ /** The published version of `@siftline/cli`, baked in at build time. */
388
+ const VERSION$1 = "0.1.0";
389
+ const USAGE = [
390
+ `siftline ${VERSION$1} (engine ${VERSION})`,
391
+ "",
392
+ "Usage",
393
+ " siftline test <recipe.json> <fixtures.jsonl> [--max-in-flight <n>] [--min-accuracy <ratio>] [--json] [--quiet]",
394
+ " siftline label <recipe.json> [records.jsonl | -] [--rules <rules.json>] [--max-in-flight <n>] [--quiet]",
395
+ "",
396
+ "Options",
397
+ " --max-in-flight <n> Records judged at once (default 8)",
398
+ " --min-accuracy <ratio> test: exit 1 when the lowest question accuracy is below this",
399
+ " --json test: print the report as JSON",
400
+ " --rules <rules.json> label: select an Action per Decision with these Rules",
401
+ " --quiet Silence progress (never warnings or errors)",
402
+ " --help Print this help and exit",
403
+ " --version Print the version and exit",
404
+ "",
405
+ "Environment",
406
+ " TYPESAFE_API_KEY Required. Read from the environment only."
407
+ ].join("\n");
408
+ //#endregion
409
+ //#region src/index.ts
410
+ /**
411
+ * The library door. Every command runs through it, so a test drives the whole CLI with an
412
+ * injected client and string streams and asserts on the exit code and what was written.
413
+ */
414
+ async function run(argv, deps) {
415
+ try {
416
+ if (argv.includes("--help")) {
417
+ writeLine(deps.stdout, USAGE);
418
+ return 0;
419
+ }
420
+ if (argv.includes("--version")) {
421
+ writeLine(deps.stdout, VERSION$1);
422
+ return 0;
423
+ }
424
+ const [command, ...rest] = argv;
425
+ if (command === void 0) throw new UsageError("no command given");
426
+ if (command !== "test" && command !== "label") throw new UsageError(`unknown command: ${command}`);
427
+ requireApiKey(deps.env);
428
+ return command === "label" ? await runLabel(rest, deps) : await runTest(rest, deps);
429
+ } catch (error) {
430
+ return exitFor(error, deps);
431
+ }
432
+ }
433
+ /** Every failure's exit code and its one stderr line. */
434
+ function exitFor(error, deps) {
435
+ if (deps.signal?.aborted) {
436
+ writeLine(deps.stderr, "siftline: interrupted");
437
+ return 130;
438
+ }
439
+ if (error instanceof UsageError) {
440
+ writeLine(deps.stderr, `siftline: ${error.message}\n\n${USAGE}`);
441
+ return 2;
442
+ }
443
+ if (error instanceof InputError) {
444
+ writeLine(deps.stderr, `siftline: ${error.message}`);
445
+ return 2;
446
+ }
447
+ if (error instanceof SiftlineError) {
448
+ writeLine(deps.stderr, `siftline: ${error.message}`);
449
+ return error.code === "fixture_invalid" ? 2 : 1;
450
+ }
451
+ writeLine(deps.stderr, `siftline: ${error instanceof Error ? error.message : String(error)}`);
452
+ return 1;
453
+ }
454
+ //#endregion
455
+ export { API_KEY_ENV as i, USAGE as n, VERSION$1 as r, run as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siftline/cli",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "The Siftline command line: measure a recipe against its fixtures, then label for real.",
5
5
  "keywords": [
6
6
  "classification",
@@ -45,7 +45,8 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@siftline/core": "^0.0.2"
48
+ "@siftline/core": "^0.1.0",
49
+ "@typesafe-ai/sdk": "0.6.0"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@arethetypeswrong/core": "0.18.5",
@@ -1,51 +0,0 @@
1
- import { VERSION } from "@siftline/core";
2
- //#endregion
3
- //#region src/index.ts
4
- /**
5
- * The published version of `@siftline/cli`, baked in at build time.
6
- *
7
- * Changesets bumps `package.json`; this constant follows it without a second edit.
8
- */
9
- const VERSION$1 = "0.0.1";
10
- /**
11
- * The usage block. `test` and `label` are named before they exist so that the bin
12
- * wiring is proven — and so that `siftline --help` never lies about what it does.
13
- */
14
- const USAGE = [
15
- `siftline ${VERSION$1} (engine ${VERSION})`,
16
- "",
17
- "Usage",
18
- " siftline <command> [options]",
19
- "",
20
- "Commands",
21
- " test Measure a recipe against its fixtures (not yet implemented)",
22
- " label Label inputs with a recipe (not yet implemented)",
23
- "",
24
- "Options",
25
- " --help Print this help and exit",
26
- " --version Print the version and exit"
27
- ].join("\n");
28
- /**
29
- * Interpret command line arguments. Pure on purpose: the bin is the only place that
30
- * touches the process, so the behaviour stays testable without one.
31
- */
32
- function run(argv) {
33
- const [first, ...rest] = argv;
34
- if (rest.length === 0 && first === "--version") return {
35
- code: 0,
36
- stdout: `${VERSION$1}\n`,
37
- stderr: ""
38
- };
39
- if (rest.length === 0 && first === "--help") return {
40
- code: 0,
41
- stdout: `${USAGE}\n`,
42
- stderr: ""
43
- };
44
- return {
45
- code: 1,
46
- stdout: "",
47
- stderr: `siftline: ${first === void 0 ? "no command given" : `unknown command: ${argv.join(" ")}`}\n\n${USAGE}\n`
48
- };
49
- }
50
- //#endregion
51
- export { VERSION$1 as n, run as r, USAGE as t };