@tuned-tensor/cli 0.4.18 → 0.4.20

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/dist/index.js CHANGED
@@ -122,6 +122,9 @@ function post(path, body, opts) {
122
122
  function put(path, body, opts) {
123
123
  return request("PUT", path, { body, opts });
124
124
  }
125
+ function patch(path, body, opts) {
126
+ return request("PATCH", path, { body, opts });
127
+ }
125
128
  function del(path, opts) {
126
129
  return request("DELETE", path, { opts });
127
130
  }
@@ -297,10 +300,10 @@ var ResolveError = class extends Error {
297
300
  function isFullUuid(value) {
298
301
  return UUID_RE.test(value);
299
302
  }
300
- async function resolvePrefix(prefix, kind, listPath, describe, opts) {
303
+ async function resolvePrefix(prefix, kind, listPath, describe, opts, listCmdOverride) {
301
304
  if (isFullUuid(prefix)) return prefix;
302
305
  const noun = kind;
303
- const listCmd = `tt ${kind}s list --json`;
306
+ const listCmd = listCmdOverride ?? `tt ${kind}s list --json`;
304
307
  if (prefix.length < MIN_PREFIX_LEN) {
305
308
  throw new ResolveError(
306
309
  `${capitalize(noun)} ID prefix "${prefix}" is too short \u2014 provide at least ${MIN_PREFIX_LEN} characters or the full UUID.`
@@ -368,6 +371,16 @@ function resolveModelId(prefix, opts) {
368
371
  opts
369
372
  );
370
373
  }
374
+ function resolveLabelingJobId(prefix, opts) {
375
+ return resolvePrefix(
376
+ prefix,
377
+ "labeling job",
378
+ "/labeling-jobs",
379
+ (j) => `${j.id}${j.name ? ` (${j.name})` : ""}`,
380
+ opts,
381
+ "tt label list --json"
382
+ );
383
+ }
371
384
 
372
385
  // src/base-models.ts
373
386
  var SUPPORTED_BASE_MODELS = [
@@ -617,6 +630,29 @@ function formatMinutes(minutes) {
617
630
  if (minutes < 60) return `${Math.max(1, Math.round(minutes))}m`;
618
631
  return `${(minutes / 60).toFixed(1)}h`;
619
632
  }
633
+ function formatCents(cents) {
634
+ const dollars = cents / 100;
635
+ if (Math.abs(dollars) >= 100) return `$${dollars.toFixed(0)}`;
636
+ return `$${dollars.toFixed(2)}`;
637
+ }
638
+ function formatEstimateRange(estimate) {
639
+ const duration = estimate.duration;
640
+ return `${formatMinutes(duration.estimated_minutes)} (${formatMinutes(duration.range_minutes.low)}-${formatMinutes(duration.range_minutes.high)})`;
641
+ }
642
+ function printRunEstimate(estimate) {
643
+ printDetail([
644
+ ["Estimated Time", formatEstimateRange(estimate)],
645
+ ["Confidence", estimate.duration.confidence],
646
+ ["History Samples", String(estimate.duration.sample_count)],
647
+ ["Basis", estimate.duration.basis.replaceAll("_", " ")],
648
+ ["Estimated Cost", formatCents(estimate.estimated_cost_cents)],
649
+ ["Training Tokens", `${(estimate.estimated_training_tokens / 1e3).toFixed(1)}k`],
650
+ ["Epochs", String(estimate.estimated_epochs)]
651
+ ]);
652
+ console.log(
653
+ "\nDuration is a rough historical range. Final cost uses provider-reported training tokens."
654
+ );
655
+ }
620
656
  function formatEpochProgress(diagnostics) {
621
657
  const curve = diagnostics.training.curve;
622
658
  if (curve.latest_epoch == null) return void 0;
@@ -722,6 +758,46 @@ function printEvalOutputDiagnostics(diagnostics) {
722
758
  }
723
759
  }
724
760
  }
761
+ function addRunConfigurationOptions(command) {
762
+ return command.option("--no-augment", "Disable data augmentation").option("--no-llm-judge", "Disable LLM judging").option("--epochs <n>", "Number of training epochs").option("--lr <rate>", "Learning rate").option("--batch-size <n>", "Batch size").option("--dataset <id>", "Dataset ID to use instead of inline spec examples (full UUID or 4+ char prefix)").option("--parent-model <id>", "Fine-tuned model ID to continue training from (full UUID or 4+ char prefix)").option("--train-ratio <ratio>", "Dataset training split ratio (default: 0.8 when any split ratio is set)").option("--validation-ratio <ratio>", "Dataset validation split ratio (default: 0.1 when any split ratio is set)").option("--test-ratio <ratio>", "Dataset test split ratio (default: 0.1 when any split ratio is set)").option("--lora-rank <n>", "LoRA rank").option("--lora-alpha <n>", "LoRA alpha").option("--max-eval-examples <n>", "Max examples for the primary eval pass").option("--max-test-eval-examples <n>", "Max examples for the secondary test eval pass").option("--long-examples <policy>", "Long training row policy: error, truncate, or skip").option("--max-seq-length <tokens>", "Maximum training sequence length in tokens").option("--max-output-tokens <tokens>", "Desired evaluation output budget in tokens").option("--eval-reserved-output-tokens <tokens>", "Minimum evaluation output tokens reserved per row");
763
+ }
764
+ async function buildRunRequestBody(cmdOpts, opts) {
765
+ const body = {};
766
+ if (cmdOpts.augment === false) body.augment = false;
767
+ if (cmdOpts.dataset) body.dataset_id = await resolveDatasetId(String(cmdOpts.dataset), opts);
768
+ if (cmdOpts.parentModel) {
769
+ body.parent_model_id = await resolveModelId(String(cmdOpts.parentModel), opts);
770
+ }
771
+ const splitRatioOptions = [
772
+ cmdOpts.trainRatio,
773
+ cmdOpts.validationRatio,
774
+ cmdOpts.testRatio
775
+ ];
776
+ if (splitRatioOptions.some((value) => value != null)) {
777
+ body.split_ratios = {
778
+ train: cmdOpts.trainRatio != null ? Number(cmdOpts.trainRatio) : 0.8,
779
+ validation: cmdOpts.validationRatio != null ? Number(cmdOpts.validationRatio) : 0.1,
780
+ test: cmdOpts.testRatio != null ? Number(cmdOpts.testRatio) : 0.1
781
+ };
782
+ }
783
+ const hp = {};
784
+ if (cmdOpts.epochs) hp.n_epochs = Number(cmdOpts.epochs);
785
+ if (cmdOpts.lr) hp.learning_rate = Number(cmdOpts.lr);
786
+ if (cmdOpts.batchSize) hp.batch_size = Number(cmdOpts.batchSize);
787
+ if (cmdOpts.loraRank) hp.lora_rank = Number(cmdOpts.loraRank);
788
+ if (cmdOpts.loraAlpha) hp.lora_alpha = Number(cmdOpts.loraAlpha);
789
+ if (cmdOpts.maxEvalExamples) hp.max_eval_examples = Number(cmdOpts.maxEvalExamples);
790
+ if (cmdOpts.maxTestEvalExamples) hp.max_test_eval_examples = Number(cmdOpts.maxTestEvalExamples);
791
+ if (cmdOpts.longExamples) hp.long_examples = parseLongExamplesPolicy(String(cmdOpts.longExamples));
792
+ if (cmdOpts.maxSeqLength) hp.max_seq_length = Number(cmdOpts.maxSeqLength);
793
+ if (cmdOpts.maxOutputTokens) hp.max_output_tokens = Number(cmdOpts.maxOutputTokens);
794
+ if (cmdOpts.evalReservedOutputTokens) {
795
+ hp.eval_reserved_output_tokens = Number(cmdOpts.evalReservedOutputTokens);
796
+ }
797
+ if (cmdOpts.llmJudge === false) body.use_llm_judge = false;
798
+ if (Object.keys(hp).length) body.hyperparameters = hp;
799
+ return body;
800
+ }
725
801
  function registerRunsCommands(parent) {
726
802
  const runs = parent.command("runs").description("Manage runs");
727
803
  runs.command("list").description("List runs").option("-s, --spec <id>", "Filter by spec ID (full UUID or 8+ char prefix)").option("-p, --page <n>", "Page number", "1").option("--per-page <n>", "Results per page", "20").action(async (cmdOpts) => {
@@ -801,42 +877,25 @@ Eval Results (${data._evals.length}):`);
801
877
  );
802
878
  }
803
879
  });
804
- runs.command("start").description("Start a new run for a behaviour spec").argument("<spec-id>", "Behaviour spec ID (full UUID or 8+ char prefix)").option("--no-augment", "Disable data augmentation").option("--no-llm-judge", "Disable LLM judging").option("--epochs <n>", "Number of training epochs").option("--lr <rate>", "Learning rate").option("--batch-size <n>", "Batch size").option("--dataset <id>", "Dataset ID to use instead of inline spec examples (full UUID or 4+ char prefix)").option("--parent-model <id>", "Fine-tuned model ID to continue training from (full UUID or 4+ char prefix)").option("--train-ratio <ratio>", "Dataset training split ratio (default: 0.8 when any split ratio is set)").option("--validation-ratio <ratio>", "Dataset validation split ratio (default: 0.1 when any split ratio is set)").option("--test-ratio <ratio>", "Dataset test split ratio (default: 0.1 when any split ratio is set)").option("--lora-rank <n>", "LoRA rank").option("--lora-alpha <n>", "LoRA alpha").option("--max-eval-examples <n>", "Max examples for the primary eval pass").option("--max-test-eval-examples <n>", "Max examples for the secondary test eval pass").option("--long-examples <policy>", "Long training row policy: error, truncate, or skip").option("--max-seq-length <tokens>", "Maximum training sequence length in tokens").option("--max-output-tokens <tokens>", "Desired evaluation output budget in tokens").option("--eval-reserved-output-tokens <tokens>", "Minimum evaluation output tokens reserved per row").action(async (specId, cmdOpts) => {
880
+ addRunConfigurationOptions(
881
+ runs.command("estimate").description("Estimate run cost and duration before starting").argument("<spec-id>", "Behaviour spec ID (full UUID or 8+ char prefix)")
882
+ ).action(async (specId, cmdOpts) => {
805
883
  const opts = parent.opts();
806
- const body = {};
807
- if (cmdOpts.augment === false) body.augment = false;
808
- if (cmdOpts.dataset) body.dataset_id = await resolveDatasetId(cmdOpts.dataset, opts);
809
- if (cmdOpts.parentModel) {
810
- body.parent_model_id = await resolveModelId(cmdOpts.parentModel, opts);
811
- }
812
- const splitRatioOptions = [
813
- cmdOpts.trainRatio,
814
- cmdOpts.validationRatio,
815
- cmdOpts.testRatio
816
- ];
817
- if (splitRatioOptions.some((value) => value != null)) {
818
- body.split_ratios = {
819
- train: cmdOpts.trainRatio != null ? Number(cmdOpts.trainRatio) : 0.8,
820
- validation: cmdOpts.validationRatio != null ? Number(cmdOpts.validationRatio) : 0.1,
821
- test: cmdOpts.testRatio != null ? Number(cmdOpts.testRatio) : 0.1
822
- };
823
- }
824
- const hp = {};
825
- if (cmdOpts.epochs) hp.n_epochs = Number(cmdOpts.epochs);
826
- if (cmdOpts.lr) hp.learning_rate = Number(cmdOpts.lr);
827
- if (cmdOpts.batchSize) hp.batch_size = Number(cmdOpts.batchSize);
828
- if (cmdOpts.loraRank) hp.lora_rank = Number(cmdOpts.loraRank);
829
- if (cmdOpts.loraAlpha) hp.lora_alpha = Number(cmdOpts.loraAlpha);
830
- if (cmdOpts.maxEvalExamples) hp.max_eval_examples = Number(cmdOpts.maxEvalExamples);
831
- if (cmdOpts.maxTestEvalExamples) hp.max_test_eval_examples = Number(cmdOpts.maxTestEvalExamples);
832
- if (cmdOpts.longExamples) hp.long_examples = parseLongExamplesPolicy(cmdOpts.longExamples);
833
- if (cmdOpts.maxSeqLength) hp.max_seq_length = Number(cmdOpts.maxSeqLength);
834
- if (cmdOpts.maxOutputTokens) hp.max_output_tokens = Number(cmdOpts.maxOutputTokens);
835
- if (cmdOpts.evalReservedOutputTokens) {
836
- hp.eval_reserved_output_tokens = Number(cmdOpts.evalReservedOutputTokens);
837
- }
838
- if (cmdOpts.llmJudge === false) body.use_llm_judge = false;
839
- if (Object.keys(hp).length) body.hyperparameters = hp;
884
+ const body = await buildRunRequestBody(cmdOpts, opts);
885
+ const fullSpecId = await resolveSpecId(specId, opts);
886
+ const { data } = await post(
887
+ `/behavior-specs/${fullSpecId}/runs/estimate`,
888
+ body,
889
+ opts
890
+ );
891
+ if (isJsonMode()) return printJson(data);
892
+ printRunEstimate(data);
893
+ });
894
+ addRunConfigurationOptions(
895
+ runs.command("start").description("Start a new run for a behaviour spec").argument("<spec-id>", "Behaviour spec ID (full UUID or 8+ char prefix)")
896
+ ).action(async (specId, cmdOpts) => {
897
+ const opts = parent.opts();
898
+ const body = await buildRunRequestBody(cmdOpts, opts);
840
899
  const fullSpecId = await resolveSpecId(specId, opts);
841
900
  const { data } = await post(
842
901
  `/behavior-specs/${fullSpecId}/runs`,
@@ -1107,27 +1166,425 @@ function registerDatasetsCommands(parent) {
1107
1166
  });
1108
1167
  }
1109
1168
 
1169
+ // src/commands/label.ts
1170
+ import { existsSync as existsSync3, readFileSync as readFileSync5, statSync as statSync3 } from "fs";
1171
+ function formatCents2(cents) {
1172
+ return `$${(cents / 100).toFixed(2)}`;
1173
+ }
1174
+ function sleep(ms) {
1175
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
1176
+ }
1177
+ function parseCsvHeader(line) {
1178
+ const fields = [];
1179
+ let current = "";
1180
+ let inQuotes = false;
1181
+ for (let i = 0; i < line.length; i++) {
1182
+ const ch = line[i];
1183
+ if (inQuotes) {
1184
+ if (ch === '"') {
1185
+ if (line[i + 1] === '"') {
1186
+ current += '"';
1187
+ i++;
1188
+ } else {
1189
+ inQuotes = false;
1190
+ }
1191
+ } else {
1192
+ current += ch;
1193
+ }
1194
+ } else if (ch === '"') {
1195
+ inQuotes = true;
1196
+ } else if (ch === ",") {
1197
+ fields.push(current);
1198
+ current = "";
1199
+ } else {
1200
+ current += ch;
1201
+ }
1202
+ }
1203
+ fields.push(current);
1204
+ return fields.map((f) => f.trim());
1205
+ }
1206
+ function validateUnlabeledFile(file, inputColumn) {
1207
+ const lower = file.toLowerCase();
1208
+ if (lower.endsWith(".csv")) {
1209
+ if (!inputColumn) {
1210
+ throw new Error(
1211
+ "CSV uploads need --input-column <name> to say which column holds the input text."
1212
+ );
1213
+ }
1214
+ const firstLine = readFileSync5(file, "utf8").split(/\r?\n/, 1)[0] ?? "";
1215
+ const columns = parseCsvHeader(firstLine);
1216
+ if (!columns.includes(inputColumn)) {
1217
+ throw new Error(
1218
+ `Column "${inputColumn}" not found in CSV header. Available columns: ${columns.join(", ")}`
1219
+ );
1220
+ }
1221
+ return { format: "csv" };
1222
+ }
1223
+ if (!lower.endsWith(".jsonl") && !lower.endsWith(".json")) {
1224
+ throw new Error("Labeling source file must be .jsonl or .csv");
1225
+ }
1226
+ const lines = readFileSync5(file, "utf8").split(/\r?\n/);
1227
+ const errors = [];
1228
+ let rowCount = 0;
1229
+ for (const [index, rawLine] of lines.entries()) {
1230
+ const line = rawLine.trim();
1231
+ if (!line) continue;
1232
+ rowCount += 1;
1233
+ let row;
1234
+ try {
1235
+ row = JSON.parse(line);
1236
+ } catch {
1237
+ errors.push(`Row ${index + 1}: invalid JSON`);
1238
+ continue;
1239
+ }
1240
+ if (!row || typeof row !== "object" || Array.isArray(row) || typeof row.input !== "string") {
1241
+ errors.push(`Row ${index + 1}: expected an object with a string "input" field`);
1242
+ }
1243
+ }
1244
+ if (rowCount === 0) {
1245
+ errors.push("File contains no JSONL rows");
1246
+ }
1247
+ if (errors.length > 0) {
1248
+ const preview = errors.slice(0, 5).join("\n");
1249
+ const suffix = errors.length > 5 ? `
1250
+ ...and ${errors.length - 5} more error(s)` : "";
1251
+ throw new Error(
1252
+ `Invalid labeling source. Each JSONL row must be {"input": "..."}.
1253
+ ${preview}${suffix}`
1254
+ );
1255
+ }
1256
+ return { format: "jsonl" };
1257
+ }
1258
+ function printJobDetail(job, counts) {
1259
+ printDetail([
1260
+ ["ID", job.id],
1261
+ ["Name", job.name],
1262
+ ["Status", formatStatus(job.status)],
1263
+ ["Spec", shortId(job.behavior_spec_id)],
1264
+ ["Teacher", job.teacher_model],
1265
+ ["Rows", String(job.row_count)],
1266
+ ["Labeled", String(job.labeled_count)],
1267
+ ["Failed", String(job.failed_count)],
1268
+ [
1269
+ "Cost",
1270
+ job.actual_cost_cents !== null ? formatCents2(job.actual_cost_cents) : `~${formatCents2(job.est_cost_cents)} (estimated)`
1271
+ ],
1272
+ ["Dataset", job.promoted_dataset_id ? shortId(job.promoted_dataset_id) : void 0],
1273
+ ["Error", job.error ?? void 0],
1274
+ ["Created", formatDate(job.created_at)]
1275
+ ]);
1276
+ if (counts) {
1277
+ console.log(
1278
+ `
1279
+ Review: ${counts.labeled} awaiting \xB7 ${counts.accepted} accepted \xB7 ${counts.edited} edited \xB7 ${counts.rejected} rejected \xB7 ${counts.failed} failed`
1280
+ );
1281
+ }
1282
+ }
1283
+ async function watchJob(jobId, opts) {
1284
+ for (; ; ) {
1285
+ const { data } = await get(
1286
+ `/labeling-jobs/${jobId}`,
1287
+ void 0,
1288
+ opts
1289
+ );
1290
+ if (!isJsonMode()) {
1291
+ if (data.status === "preparing") {
1292
+ process.stdout.write("\rPreparing - parsing the source file... ");
1293
+ } else {
1294
+ const done = data.counts.total - data.counts.pending;
1295
+ process.stdout.write(`\rLabeling ${done}/${data.counts.total} rows `);
1296
+ }
1297
+ }
1298
+ if (data.status !== "preparing" && data.status !== "labeling") {
1299
+ if (!isJsonMode()) process.stdout.write("\n");
1300
+ return data;
1301
+ }
1302
+ await sleep(5e3);
1303
+ }
1304
+ }
1305
+ async function resolveRowIds(jobId, indexes, opts) {
1306
+ const PER_PAGE = 100;
1307
+ const byPage = /* @__PURE__ */ new Map();
1308
+ for (const index of indexes) {
1309
+ const page = Math.floor(index / PER_PAGE) + 1;
1310
+ byPage.set(page, [...byPage.get(page) ?? [], index]);
1311
+ }
1312
+ const found = /* @__PURE__ */ new Map();
1313
+ for (const [page, wanted] of byPage) {
1314
+ const { data } = await get(
1315
+ `/labeling-jobs/${jobId}/rows`,
1316
+ { page, per_page: PER_PAGE },
1317
+ opts
1318
+ );
1319
+ for (const row of data) {
1320
+ if (wanted.includes(row.row_index)) {
1321
+ found.set(row.row_index, row.id);
1322
+ }
1323
+ }
1324
+ }
1325
+ const missing = indexes.filter((i) => !found.has(i));
1326
+ if (missing.length > 0) {
1327
+ throw new Error(`Row index(es) not found: ${missing.join(", ")}`);
1328
+ }
1329
+ return found;
1330
+ }
1331
+ function parseIndexList(value) {
1332
+ const indexes = value.split(",").map((part) => Number.parseInt(part.trim(), 10));
1333
+ if (indexes.some((n) => Number.isNaN(n) || n < 0)) {
1334
+ throw new Error(`Invalid row index list: "${value}" (expected e.g. "0,3,12")`);
1335
+ }
1336
+ return indexes;
1337
+ }
1338
+ function registerLabelCommands(parent) {
1339
+ const label = parent.command("label").description("Teacher-label real data into training datasets");
1340
+ label.command("upload").description("Upload unlabeled inputs (.jsonl or .csv) and start a labeling job").argument("<file>", 'Path to JSONL ({"input": ...} per line) or CSV file').requiredOption("-s, --spec <id>", "Behaviour spec the teacher labels under").option("-c, --input-column <name>", "CSV column that holds the input text").option("-n, --name <name>", "Job name (defaults to filename)").option("--watch", "Block and show progress until labeling finishes").action(async (file, cmdOpts) => {
1341
+ const opts = parent.opts();
1342
+ if (!existsSync3(file)) {
1343
+ printError(`File not found: ${file}`);
1344
+ process.exit(1);
1345
+ }
1346
+ const { format } = validateUnlabeledFile(file, cmdOpts.inputColumn);
1347
+ const specId = await resolveSpecId(cmdOpts.spec, opts);
1348
+ const name = cmdOpts.name || file.split("/").pop().replace(/\.(jsonl|json|csv)$/, "");
1349
+ const fileBytes = readFileSync5(file);
1350
+ const contentType = format === "csv" ? "text/csv" : "application/jsonl";
1351
+ const { data: uploadUrl } = await post(
1352
+ "/labeling-jobs/upload-url",
1353
+ {
1354
+ filename: file.split("/").pop(),
1355
+ size: statSync3(file).size,
1356
+ contentType
1357
+ },
1358
+ opts
1359
+ );
1360
+ const uploadRes = await fetch(uploadUrl.upload_url, {
1361
+ method: uploadUrl.method,
1362
+ headers: uploadUrl.headers ?? { "Content-Type": contentType },
1363
+ body: new Blob([fileBytes], { type: contentType })
1364
+ });
1365
+ if (!uploadRes.ok) {
1366
+ throw new Error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`);
1367
+ }
1368
+ const { data: job } = await post(
1369
+ "/labeling-jobs",
1370
+ {
1371
+ path: uploadUrl.path,
1372
+ name,
1373
+ behavior_spec_id: specId,
1374
+ source_format: format,
1375
+ input_column: cmdOpts.inputColumn ?? null
1376
+ },
1377
+ opts
1378
+ );
1379
+ if (!cmdOpts.watch) {
1380
+ if (isJsonMode()) return printJson(job);
1381
+ printSuccess(
1382
+ `Labeling started: ${job.name} (${shortId(job.id)}) \u2014 est. ${formatCents2(job.est_cost_cents)}. Runs in the cloud; no need to stay connected.`
1383
+ );
1384
+ console.log(`Follow progress with \`tt label watch ${shortId(job.id)}\`.`);
1385
+ return;
1386
+ }
1387
+ const finished = await watchJob(job.id, opts);
1388
+ if (isJsonMode()) return printJson(finished);
1389
+ printSuccess(
1390
+ `Labeling ${finished.status === "awaiting_review" ? "complete" : finished.status}: ${finished.labeled_count}/${finished.row_count} rows labeled.`
1391
+ );
1392
+ console.log(
1393
+ `Review with \`tt label rows ${shortId(job.id)} --status labeled\`, then \`tt label promote ${shortId(job.id)} --name <dataset-name>\`.`
1394
+ );
1395
+ });
1396
+ label.command("watch").description("Watch a labeling job until it is ready for review").argument("<id>", "Labeling job ID (full UUID or 4+ char prefix)").action(async (id) => {
1397
+ const opts = parent.opts();
1398
+ const jobId = await resolveLabelingJobId(id, opts);
1399
+ const job = await watchJob(jobId, opts);
1400
+ if (isJsonMode()) return printJson(job);
1401
+ if (job.status === "awaiting_review") {
1402
+ printSuccess(
1403
+ `Labeling complete: ${job.labeled_count}/${job.row_count} rows labeled, cost ${job.actual_cost_cents !== null ? formatCents2(job.actual_cost_cents) : "n/a"}.`
1404
+ );
1405
+ } else {
1406
+ printError(`Labeling ended with status: ${job.status}${job.error ? ` \u2014 ${job.error}` : ""}`);
1407
+ process.exitCode = 1;
1408
+ }
1409
+ });
1410
+ label.command("list").description("List labeling jobs").option("-p, --page <n>", "Page number", "1").option("--per-page <n>", "Results per page", "20").action(async (cmdOpts) => {
1411
+ const opts = parent.opts();
1412
+ const { data, meta } = await get(
1413
+ "/labeling-jobs",
1414
+ { page: cmdOpts.page, per_page: cmdOpts.perPage },
1415
+ opts
1416
+ );
1417
+ if (isJsonMode()) return printJson({ data, meta });
1418
+ printTable(
1419
+ ["ID", "Name", "Status", "Progress", "Cost", "Created"],
1420
+ data.map((j) => [
1421
+ shortId(j.id),
1422
+ truncate(j.name, 30),
1423
+ formatStatus(j.status),
1424
+ `${j.labeled_count}/${j.row_count}`,
1425
+ j.actual_cost_cents !== null ? formatCents2(j.actual_cost_cents) : `~${formatCents2(j.est_cost_cents)}`,
1426
+ formatDate(j.created_at)
1427
+ ]),
1428
+ meta
1429
+ );
1430
+ });
1431
+ label.command("status").description("Show labeling job details and review progress").argument("<id>", "Labeling job ID (full UUID or 4+ char prefix)").action(async (id) => {
1432
+ const opts = parent.opts();
1433
+ const jobId = await resolveLabelingJobId(id, opts);
1434
+ const { data } = await get(
1435
+ `/labeling-jobs/${jobId}`,
1436
+ void 0,
1437
+ opts
1438
+ );
1439
+ if (isJsonMode()) return printJson(data);
1440
+ printJobDetail(data, data.counts);
1441
+ });
1442
+ label.command("rows").description("List rows in a labeling job").argument("<id>", "Labeling job ID (full UUID or 4+ char prefix)").option(
1443
+ "--status <status>",
1444
+ "Filter by row status (pending|labeled|accepted|edited|rejected|failed)"
1445
+ ).option("-p, --page <n>", "Page number", "1").option("--per-page <n>", "Results per page", "50").action(async (id, cmdOpts) => {
1446
+ const opts = parent.opts();
1447
+ const jobId = await resolveLabelingJobId(id, opts);
1448
+ const query = {
1449
+ page: cmdOpts.page,
1450
+ per_page: cmdOpts.perPage
1451
+ };
1452
+ if (cmdOpts.status) query.status = cmdOpts.status;
1453
+ const { data, meta } = await get(
1454
+ `/labeling-jobs/${jobId}/rows`,
1455
+ query,
1456
+ opts
1457
+ );
1458
+ if (isJsonMode()) return printJson({ data, meta });
1459
+ printTable(
1460
+ ["Row", "Status", "Input", "Output"],
1461
+ data.map((row) => [
1462
+ String(row.row_index),
1463
+ formatStatus(row.status),
1464
+ truncate(row.input.replace(/\s+/g, " "), 40),
1465
+ truncate(
1466
+ (row.final_output ?? row.teacher_output ?? "\u2014").replace(/\s+/g, " "),
1467
+ 40
1468
+ )
1469
+ ]),
1470
+ meta
1471
+ );
1472
+ });
1473
+ label.command("accept").description("Accept teacher-labeled rows").argument("<id>", "Labeling job ID (full UUID or 4+ char prefix)").option("--all", "Accept all unreviewed labeled rows").option("--rows <indexes>", "Comma-separated row indexes (e.g. 0,3,12)").action(async (id, cmdOpts) => {
1474
+ const opts = parent.opts();
1475
+ if (!cmdOpts.all && !cmdOpts.rows) {
1476
+ printError("Provide --all or --rows <indexes>.");
1477
+ process.exit(1);
1478
+ }
1479
+ const jobId = await resolveLabelingJobId(id, opts);
1480
+ if (cmdOpts.all) {
1481
+ const { data } = await post(
1482
+ `/labeling-jobs/${jobId}/rows/bulk`,
1483
+ { action: "accept", all_labeled: true },
1484
+ opts
1485
+ );
1486
+ if (isJsonMode()) return printJson(data);
1487
+ return printSuccess(`Accepted ${data.accepted} rows.`);
1488
+ }
1489
+ const indexes = parseIndexList(cmdOpts.rows);
1490
+ const rowIds = await resolveRowIds(jobId, indexes, opts);
1491
+ for (const index of indexes) {
1492
+ await patch(
1493
+ `/labeling-jobs/${jobId}/rows/${rowIds.get(index)}`,
1494
+ { action: "accept" },
1495
+ opts
1496
+ );
1497
+ }
1498
+ if (isJsonMode()) return printJson({ accepted: indexes.length });
1499
+ printSuccess(`Accepted ${indexes.length} row(s).`);
1500
+ });
1501
+ label.command("reject").description("Reject rows so they are excluded from promotion").argument("<id>", "Labeling job ID (full UUID or 4+ char prefix)").requiredOption("--rows <indexes>", "Comma-separated row indexes (e.g. 0,3,12)").action(async (id, cmdOpts) => {
1502
+ const opts = parent.opts();
1503
+ const jobId = await resolveLabelingJobId(id, opts);
1504
+ const indexes = parseIndexList(cmdOpts.rows);
1505
+ const rowIds = await resolveRowIds(jobId, indexes, opts);
1506
+ for (const index of indexes) {
1507
+ await patch(
1508
+ `/labeling-jobs/${jobId}/rows/${rowIds.get(index)}`,
1509
+ { action: "reject" },
1510
+ opts
1511
+ );
1512
+ }
1513
+ if (isJsonMode()) return printJson({ rejected: indexes.length });
1514
+ printSuccess(`Rejected ${indexes.length} row(s).`);
1515
+ });
1516
+ label.command("edit").description("Replace a row's output with your own").argument("<id>", "Labeling job ID (full UUID or 4+ char prefix)").requiredOption("--row <index>", "Row index to edit").option("-o, --output <text>", "Replacement output text").option("-f, --file <path>", "Read replacement output from a file").action(async (id, cmdOpts) => {
1517
+ const opts = parent.opts();
1518
+ if (!cmdOpts.output && !cmdOpts.file) {
1519
+ printError("Provide --output <text> or --file <path>.");
1520
+ process.exit(1);
1521
+ }
1522
+ const output = cmdOpts.output ?? readFileSync5(cmdOpts.file, "utf8");
1523
+ const jobId = await resolveLabelingJobId(id, opts);
1524
+ const indexes = parseIndexList(cmdOpts.row);
1525
+ const rowIds = await resolveRowIds(jobId, indexes, opts);
1526
+ await patch(
1527
+ `/labeling-jobs/${jobId}/rows/${rowIds.get(indexes[0])}`,
1528
+ { action: "edit", output },
1529
+ opts
1530
+ );
1531
+ if (isJsonMode()) return printJson({ edited: indexes[0] });
1532
+ printSuccess(`Row ${indexes[0]} edited.`);
1533
+ });
1534
+ label.command("promote").description("Promote reviewed rows into a validated dataset").argument("<id>", "Labeling job ID (full UUID or 4+ char prefix)").requiredOption("-n, --name <name>", "Name for the new dataset").option("-d, --description <desc>", "Dataset description").option("--include-unreviewed", "Also include unreviewed labeled rows").action(async (id, cmdOpts) => {
1535
+ const opts = parent.opts();
1536
+ const jobId = await resolveLabelingJobId(id, opts);
1537
+ const { data } = await post(
1538
+ `/labeling-jobs/${jobId}/promote`,
1539
+ {
1540
+ name: cmdOpts.name,
1541
+ description: cmdOpts.description ?? null,
1542
+ include_unreviewed_labeled: Boolean(cmdOpts.includeUnreviewed)
1543
+ },
1544
+ opts
1545
+ );
1546
+ if (isJsonMode()) return printJson(data);
1547
+ printSuccess(
1548
+ `Dataset created: ${data.name} (${shortId(data.id)}) \u2014 ${data.row_count} rows.`
1549
+ );
1550
+ console.log(
1551
+ `Start a run with \`tt runs start <spec-id> --dataset ${shortId(data.id)}\`.`
1552
+ );
1553
+ });
1554
+ label.command("cancel").description("Cancel a labeling job and release unused credits").argument("<id>", "Labeling job ID (full UUID or 4+ char prefix)").action(async (id) => {
1555
+ const opts = parent.opts();
1556
+ const jobId = await resolveLabelingJobId(id, opts);
1557
+ const { data } = await post(
1558
+ `/labeling-jobs/${jobId}/cancel`,
1559
+ {},
1560
+ opts
1561
+ );
1562
+ if (isJsonMode()) return printJson(data);
1563
+ printSuccess(`Labeling job cancelled: ${shortId(jobId)}`);
1564
+ });
1565
+ }
1566
+
1110
1567
  // src/commands/models.ts
1111
1568
  import { spawn as spawn2, execFileSync } from "child_process";
1112
1569
  import { createHash } from "crypto";
1113
1570
  import {
1114
- existsSync as existsSync4,
1571
+ existsSync as existsSync5,
1115
1572
  mkdirSync as mkdirSync3,
1116
- statSync as statSync3,
1573
+ statSync as statSync4,
1117
1574
  createWriteStream,
1118
1575
  unlinkSync,
1119
- readFileSync as readFileSync6,
1576
+ readFileSync as readFileSync7,
1120
1577
  writeFileSync as writeFileSync3,
1121
1578
  readdirSync,
1122
1579
  rmSync
1123
1580
  } from "fs";
1124
1581
  import { homedir as homedir2 } from "os";
1125
- import { dirname as dirname2, join as join2, resolve as resolve2, basename as basename2 } from "path";
1582
+ import { dirname as dirname2, join as join2, resolve as resolve2, basename as basename2, normalize, isAbsolute } from "path";
1126
1583
  import { Readable, Transform } from "stream";
1127
1584
  import { pipeline } from "stream/promises";
1128
1585
 
1129
1586
  // src/commands/init.ts
1130
- import { existsSync as existsSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
1587
+ import { existsSync as existsSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync2 } from "fs";
1131
1588
  import { resolve } from "path";
1132
1589
  var DEFAULT_SPEC_FILE = "tunedtensor.json";
1133
1590
  var SCAFFOLD = {
@@ -1145,7 +1602,7 @@ var SCAFFOLD = {
1145
1602
  function registerInitCommand(parent) {
1146
1603
  parent.command("init").description("Create a local behaviour spec file").option("-n, --name <name>", "Spec name").option("--model <model>", "Base model ID").option("-f, --file <path>", "Output file path", DEFAULT_SPEC_FILE).action(async (cmdOpts) => {
1147
1604
  const filePath = resolve(cmdOpts.file);
1148
- if (existsSync3(filePath)) {
1605
+ if (existsSync4(filePath)) {
1149
1606
  printWarning(`${cmdOpts.file} already exists. Use tt eval to validate it or edit it directly.`);
1150
1607
  return;
1151
1608
  }
@@ -1162,14 +1619,14 @@ function registerInitCommand(parent) {
1162
1619
  }
1163
1620
  function loadSpec(filePath) {
1164
1621
  const resolved = resolve(filePath || DEFAULT_SPEC_FILE);
1165
- if (!existsSync3(resolved)) {
1622
+ if (!existsSync4(resolved)) {
1166
1623
  printError(
1167
1624
  `Spec file not found: ${filePath || DEFAULT_SPEC_FILE}
1168
1625
  Run \`tt init\` to create one.`
1169
1626
  );
1170
1627
  process.exit(1);
1171
1628
  }
1172
- const raw = JSON.parse(readFileSync5(resolved, "utf-8"));
1629
+ const raw = JSON.parse(readFileSync6(resolved, "utf-8"));
1173
1630
  return raw;
1174
1631
  }
1175
1632
 
@@ -1184,7 +1641,7 @@ var COMPLETION_ROUTES = /* @__PURE__ */ new Set(["/v1/chat/completions", "/chat/
1184
1641
  function parsePositiveInteger(value, fallback) {
1185
1642
  return Number.isInteger(value) && value >= 0 ? value : fallback;
1186
1643
  }
1187
- function sleep(ms) {
1644
+ function sleep2(ms) {
1188
1645
  return new Promise((resolve4) => setTimeout(resolve4, ms));
1189
1646
  }
1190
1647
  function readRequestBody(req) {
@@ -1506,7 +1963,7 @@ var ManagedModelServer = class {
1506
1963
  child.kill("SIGTERM");
1507
1964
  await Promise.race([
1508
1965
  new Promise((resolve4) => child.once("exit", () => resolve4())),
1509
- sleep(5e3).then(() => {
1966
+ sleep2(5e3).then(() => {
1510
1967
  if (child.exitCode == null && child.signalCode == null) child.kill("SIGKILL");
1511
1968
  })
1512
1969
  ]);
@@ -1536,7 +1993,7 @@ var ManagedModelServer = class {
1536
1993
  const health = await this.checkHealth();
1537
1994
  if (health.ok) return;
1538
1995
  if (!this.child) break;
1539
- await sleep(50);
1996
+ await sleep2(50);
1540
1997
  }
1541
1998
  throw new Error("Model server did not become healthy within 120 seconds");
1542
1999
  }
@@ -2045,7 +2502,7 @@ if __name__ == "__main__":
2045
2502
  `;
2046
2503
  function resolveOutputPath(output, filename) {
2047
2504
  if (!output) return filename;
2048
- if (existsSync4(output) && statSync3(output).isDirectory()) {
2505
+ if (existsSync5(output) && statSync4(output).isDirectory()) {
2049
2506
  return join2(output, filename);
2050
2507
  }
2051
2508
  return output;
@@ -2166,7 +2623,7 @@ function runtimePythonPath(cacheDir) {
2166
2623
  function resolveServePython(cacheDir, explicitPython) {
2167
2624
  if (explicitPython) return explicitPython;
2168
2625
  const managedPython = runtimePythonPath(cacheDir);
2169
- return existsSync4(managedPython) ? managedPython : "python3";
2626
+ return existsSync5(managedPython) ? managedPython : "python3";
2170
2627
  }
2171
2628
  function commandExists(command) {
2172
2629
  try {
@@ -2245,11 +2702,28 @@ function safeSegment(value) {
2245
2702
  return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "model";
2246
2703
  }
2247
2704
  function directoryHasFiles(path) {
2248
- return existsSync4(path) && statSync3(path).isDirectory() && readdirSync(path).length > 0;
2705
+ return existsSync5(path) && statSync4(path).isDirectory() && readdirSync(path).length > 0;
2706
+ }
2707
+ function validateArchiveEntryPath(entry) {
2708
+ const normalized = normalize(entry).replace(/^[\\/]+/, "");
2709
+ if (!entry.trim() || isAbsolute(entry) || normalized === ".." || normalized.startsWith(`..${"/"}`) || normalized.startsWith(`..${"\\"}`)) {
2710
+ throw new Error(`Unsafe archive entry path: ${entry}`);
2711
+ }
2712
+ return normalized;
2713
+ }
2714
+ function validateArchiveEntries(archivePath) {
2715
+ const listing = execFileSync("tar", ["-tzf", archivePath], {
2716
+ encoding: "utf8",
2717
+ stdio: ["ignore", "pipe", "pipe"]
2718
+ });
2719
+ for (const entry of listing.split(/\r?\n/)) {
2720
+ if (entry.trim()) validateArchiveEntryPath(entry);
2721
+ }
2249
2722
  }
2250
2723
  function extractArchive(archivePath, targetDir, force) {
2251
2724
  if (directoryHasFiles(targetDir) && !force) return targetDir;
2252
- if (existsSync4(targetDir)) rmSync(targetDir, { recursive: true, force: true });
2725
+ validateArchiveEntries(archivePath);
2726
+ if (existsSync5(targetDir)) rmSync(targetDir, { recursive: true, force: true });
2253
2727
  mkdirSync3(targetDir, { recursive: true });
2254
2728
  execFileSync("tar", ["-xzf", archivePath, "-C", targetDir], { stdio: "inherit" });
2255
2729
  return targetDir;
@@ -2276,21 +2750,21 @@ function findSpecPath(explicitPath, modelPath) {
2276
2750
  const candidates = explicitPath ? [explicitPath] : [join2(process.cwd(), DEFAULT_SPEC_FILE), join2(modelPath, DEFAULT_SPEC_FILE)];
2277
2751
  for (const candidate of candidates) {
2278
2752
  const resolved = resolve2(candidate);
2279
- if (existsSync4(resolved) && statSync3(resolved).isFile()) return resolved;
2753
+ if (existsSync5(resolved) && statSync4(resolved).isFile()) return resolved;
2280
2754
  }
2281
2755
  return null;
2282
2756
  }
2283
2757
  function loadSystemPromptFromSpec(specPath) {
2284
- const spec = JSON.parse(readFileSync6(specPath, "utf8"));
2758
+ const spec = JSON.parse(readFileSync7(specPath, "utf8"));
2285
2759
  return buildSystemPrompt(spec);
2286
2760
  }
2287
2761
  function loadJsonSchemaForServe(schemaPath) {
2288
2762
  const resolved = resolve2(schemaPath);
2289
- if (!existsSync4(resolved) || !statSync3(resolved).isFile()) {
2763
+ if (!existsSync5(resolved) || !statSync4(resolved).isFile()) {
2290
2764
  throw new Error(`JSON schema file not found: ${schemaPath}`);
2291
2765
  }
2292
2766
  try {
2293
- const schema = JSON.parse(readFileSync6(resolved, "utf8"));
2767
+ const schema = JSON.parse(readFileSync7(resolved, "utf8"));
2294
2768
  return JSON.stringify(schema);
2295
2769
  } catch (err) {
2296
2770
  throw new Error(`JSON schema file is not valid JSON: ${schemaPath}`);
@@ -2304,9 +2778,9 @@ function writeReferenceServerScript(cacheDir) {
2304
2778
  return scriptPath;
2305
2779
  }
2306
2780
  async function resolveServeTarget(target, opts, cacheDir, forceDownload) {
2307
- if (existsSync4(target)) {
2781
+ if (existsSync5(target)) {
2308
2782
  const resolved = resolve2(target);
2309
- const stats = statSync3(resolved);
2783
+ const stats = statSync4(resolved);
2310
2784
  if (stats.isDirectory()) {
2311
2785
  return {
2312
2786
  modelPath: resolved,
@@ -2330,7 +2804,7 @@ async function resolveServeTarget(target, opts, cacheDir, forceDownload) {
2330
2804
  const modelCacheDir = join2(cacheDir, fullId);
2331
2805
  const archivePath = join2(modelCacheDir, data.filename);
2332
2806
  const extractDir = join2(modelCacheDir, "model");
2333
- if (!existsSync4(archivePath) || forceDownload) {
2807
+ if (!existsSync5(archivePath) || forceDownload) {
2334
2808
  await downloadUrlToFile(data.url, archivePath);
2335
2809
  }
2336
2810
  extractArchive(archivePath, extractDir, forceDownload);
@@ -2434,7 +2908,7 @@ function ollamaSlug(name) {
2434
2908
  }
2435
2909
  function firstExisting(candidates) {
2436
2910
  for (const candidate of candidates) {
2437
- if (candidate && existsSync4(candidate) && statSync3(candidate).isFile()) {
2911
+ if (candidate && existsSync5(candidate) && statSync4(candidate).isFile()) {
2438
2912
  return resolve2(candidate);
2439
2913
  }
2440
2914
  }
@@ -2446,7 +2920,7 @@ function llamaCppDir(explicit) {
2446
2920
  function resolveConvertScript(opts, required) {
2447
2921
  if (opts.convertScript) {
2448
2922
  const resolved = resolve2(opts.convertScript);
2449
- if (required && !existsSync4(resolved)) {
2923
+ if (required && !existsSync5(resolved)) {
2450
2924
  throw new Error(`Conversion script not found: ${opts.convertScript}`);
2451
2925
  }
2452
2926
  return resolved;
@@ -2465,7 +2939,7 @@ function resolveConvertScript(opts, required) {
2465
2939
  function resolveQuantizeBin(opts, required) {
2466
2940
  if (opts.quantizeBin) {
2467
2941
  const resolved = resolve2(opts.quantizeBin);
2468
- if (required && !existsSync4(resolved)) {
2942
+ if (required && !existsSync5(resolved)) {
2469
2943
  throw new Error(`llama-quantize binary not found: ${opts.quantizeBin}`);
2470
2944
  }
2471
2945
  return resolved;
@@ -2600,7 +3074,7 @@ function registerModelsCommands(parent) {
2600
3074
  opts
2601
3075
  );
2602
3076
  const outputPath = resolveOutputPath(cmdOpts.output, data.filename);
2603
- if (existsSync4(outputPath) && !cmdOpts.force) {
3077
+ if (existsSync5(outputPath) && !cmdOpts.force) {
2604
3078
  throw new Error(`Output file already exists: ${outputPath}. Use --force to overwrite.`);
2605
3079
  }
2606
3080
  const bytes = await downloadUrlToFile(data.url, outputPath);
@@ -2709,7 +3183,7 @@ function registerModelsCommands(parent) {
2709
3183
  ollama: ollamaInfo
2710
3184
  };
2711
3185
  if (printOnly) return printExportPlan(plan);
2712
- if (existsSync4(finalPath) && !cmdOpts.force) {
3186
+ if (existsSync5(finalPath) && !cmdOpts.force) {
2713
3187
  throw new Error(`Output already exists: ${finalPath}. Use --force to overwrite.`);
2714
3188
  }
2715
3189
  mkdirSync3(outputDir, { recursive: true });
@@ -2730,7 +3204,7 @@ function registerModelsCommands(parent) {
2730
3204
  finalPath,
2731
3205
  quantPlan.quantizeType
2732
3206
  ]);
2733
- if (!cmdOpts.keepIntermediate && intermediatePath && existsSync4(intermediatePath)) {
3207
+ if (!cmdOpts.keepIntermediate && intermediatePath && existsSync5(intermediatePath)) {
2734
3208
  rmSync(intermediatePath, { force: true });
2735
3209
  }
2736
3210
  }
@@ -2783,10 +3257,10 @@ function registerModelsCommands(parent) {
2783
3257
  ]);
2784
3258
  return;
2785
3259
  }
2786
- if (existsSync4(venvDir) && cmdOpts.force) {
3260
+ if (existsSync5(venvDir) && cmdOpts.force) {
2787
3261
  rmSync(venvDir, { recursive: true, force: true });
2788
3262
  }
2789
- if (!existsSync4(venvPython)) {
3263
+ if (!existsSync5(venvPython)) {
2790
3264
  mkdirSync3(cacheDir, { recursive: true });
2791
3265
  execFileSync(commands[0][0], commands[0].slice(1), { stdio: "inherit" });
2792
3266
  }
@@ -2927,15 +3401,15 @@ var KIND_LABELS = {
2927
3401
  refund: "Refund",
2928
3402
  adjustment: "Adjustment"
2929
3403
  };
2930
- function formatCents(cents) {
3404
+ function formatCents3(cents) {
2931
3405
  const sign = cents < 0 ? "-" : "";
2932
3406
  const abs = Math.abs(cents);
2933
3407
  return `${sign}$${(abs / 100).toFixed(2)}`;
2934
3408
  }
2935
3409
  function formatSigned(cents) {
2936
- if (cents > 0) return chalk3.green(`+${formatCents(cents)}`);
2937
- if (cents < 0) return chalk3.red(formatCents(cents));
2938
- return formatCents(cents);
3410
+ if (cents > 0) return chalk3.green(`+${formatCents3(cents)}`);
3411
+ if (cents < 0) return chalk3.red(formatCents3(cents));
3412
+ return formatCents3(cents);
2939
3413
  }
2940
3414
  function registerBalanceCommands(parent) {
2941
3415
  parent.command("balance").description("Show credit balance and recent transactions").option("-n, --limit <n>", "Number of transactions to show (default 10)", "10").action(async (options) => {
@@ -2949,10 +3423,10 @@ function registerBalanceCommands(parent) {
2949
3423
  return printJson({ balance, transactions });
2950
3424
  }
2951
3425
  const lowBalance = balance.balance_cents < 100;
2952
- const balanceLine = lowBalance ? chalk3.red(formatCents(balance.balance_cents)) + chalk3.dim(" (low)") : chalk3.bold.green(formatCents(balance.balance_cents));
3426
+ const balanceLine = lowBalance ? chalk3.red(formatCents3(balance.balance_cents)) + chalk3.dim(" (low)") : chalk3.bold.green(formatCents3(balance.balance_cents));
2953
3427
  printDetail([
2954
3428
  ["Credits", balanceLine],
2955
- ["Lifetime top-ups", formatCents(balance.lifetime_topup_cents)]
3429
+ ["Lifetime top-ups", formatCents3(balance.lifetime_topup_cents)]
2956
3430
  ]);
2957
3431
  if (transactions && transactions.length > 0) {
2958
3432
  console.log(`
@@ -2963,7 +3437,7 @@ ${chalk3.bold("Recent transactions")}`);
2963
3437
  formatDate(row.created_at),
2964
3438
  KIND_LABELS[row.kind] ?? row.kind,
2965
3439
  formatSigned(row.amount_cents),
2966
- formatCents(row.balance_after_cents),
3440
+ formatCents3(row.balance_after_cents),
2967
3441
  row.reference_id ? shortId(row.reference_id) : chalk3.dim("\u2014")
2968
3442
  ])
2969
3443
  );
@@ -3261,7 +3735,7 @@ function printValidation(checks) {
3261
3735
  }
3262
3736
 
3263
3737
  // src/commands/push.ts
3264
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
3738
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "fs";
3265
3739
  import { resolve as resolve3 } from "path";
3266
3740
  function registerPushCommand(parent) {
3267
3741
  parent.command("push").description("Push local spec to the Tuned Tensor API").option("-f, --file <path>", "Spec file path", DEFAULT_SPEC_FILE).action(async (cmdOpts) => {
@@ -3281,7 +3755,7 @@ function registerPushCommand(parent) {
3281
3755
  } else {
3282
3756
  const res = await post("/behavior-specs", body, opts);
3283
3757
  data = res.data;
3284
- const raw = JSON.parse(readFileSync7(filePath, "utf-8"));
3758
+ const raw = JSON.parse(readFileSync8(filePath, "utf-8"));
3285
3759
  raw.id = data.id;
3286
3760
  writeFileSync4(filePath, JSON.stringify(raw, null, 2) + "\n");
3287
3761
  }
@@ -3294,7 +3768,7 @@ function registerPushCommand(parent) {
3294
3768
 
3295
3769
  // src/index.ts
3296
3770
  var program = new Command();
3297
- program.name("tt").description("Tuned Tensor CLI \u2014 fine-tune and evaluate LLMs").version("0.4.18").option("-k, --api-key <key>", "API key (overrides stored key)").option(
3771
+ program.name("tt").description("Tuned Tensor CLI \u2014 fine-tune and evaluate LLMs").version("0.4.20").option("-k, --api-key <key>", "API key (overrides stored key)").option(
3298
3772
  "-u, --base-url <url>",
3299
3773
  "API base URL (default: https://tunedtensor.com)"
3300
3774
  ).option("--json", "Output raw JSON").option("--no-color", "Disable colors").hook("preAction", (_thisCommand, actionCommand) => {
@@ -3308,6 +3782,7 @@ registerAuthCommands(program);
3308
3782
  registerSpecsCommands(program);
3309
3783
  registerRunsCommands(program);
3310
3784
  registerDatasetsCommands(program);
3785
+ registerLabelCommands(program);
3311
3786
  registerModelsCommands(program);
3312
3787
  registerBalanceCommands(program);
3313
3788
  registerTopupCommands(program);