@tuned-tensor/cli 0.4.19 → 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 = [
@@ -1153,27 +1166,425 @@ function registerDatasetsCommands(parent) {
1153
1166
  });
1154
1167
  }
1155
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
+
1156
1567
  // src/commands/models.ts
1157
1568
  import { spawn as spawn2, execFileSync } from "child_process";
1158
1569
  import { createHash } from "crypto";
1159
1570
  import {
1160
- existsSync as existsSync4,
1571
+ existsSync as existsSync5,
1161
1572
  mkdirSync as mkdirSync3,
1162
- statSync as statSync3,
1573
+ statSync as statSync4,
1163
1574
  createWriteStream,
1164
1575
  unlinkSync,
1165
- readFileSync as readFileSync6,
1576
+ readFileSync as readFileSync7,
1166
1577
  writeFileSync as writeFileSync3,
1167
1578
  readdirSync,
1168
1579
  rmSync
1169
1580
  } from "fs";
1170
1581
  import { homedir as homedir2 } from "os";
1171
- 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";
1172
1583
  import { Readable, Transform } from "stream";
1173
1584
  import { pipeline } from "stream/promises";
1174
1585
 
1175
1586
  // src/commands/init.ts
1176
- 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";
1177
1588
  import { resolve } from "path";
1178
1589
  var DEFAULT_SPEC_FILE = "tunedtensor.json";
1179
1590
  var SCAFFOLD = {
@@ -1191,7 +1602,7 @@ var SCAFFOLD = {
1191
1602
  function registerInitCommand(parent) {
1192
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) => {
1193
1604
  const filePath = resolve(cmdOpts.file);
1194
- if (existsSync3(filePath)) {
1605
+ if (existsSync4(filePath)) {
1195
1606
  printWarning(`${cmdOpts.file} already exists. Use tt eval to validate it or edit it directly.`);
1196
1607
  return;
1197
1608
  }
@@ -1208,14 +1619,14 @@ function registerInitCommand(parent) {
1208
1619
  }
1209
1620
  function loadSpec(filePath) {
1210
1621
  const resolved = resolve(filePath || DEFAULT_SPEC_FILE);
1211
- if (!existsSync3(resolved)) {
1622
+ if (!existsSync4(resolved)) {
1212
1623
  printError(
1213
1624
  `Spec file not found: ${filePath || DEFAULT_SPEC_FILE}
1214
1625
  Run \`tt init\` to create one.`
1215
1626
  );
1216
1627
  process.exit(1);
1217
1628
  }
1218
- const raw = JSON.parse(readFileSync5(resolved, "utf-8"));
1629
+ const raw = JSON.parse(readFileSync6(resolved, "utf-8"));
1219
1630
  return raw;
1220
1631
  }
1221
1632
 
@@ -1230,7 +1641,7 @@ var COMPLETION_ROUTES = /* @__PURE__ */ new Set(["/v1/chat/completions", "/chat/
1230
1641
  function parsePositiveInteger(value, fallback) {
1231
1642
  return Number.isInteger(value) && value >= 0 ? value : fallback;
1232
1643
  }
1233
- function sleep(ms) {
1644
+ function sleep2(ms) {
1234
1645
  return new Promise((resolve4) => setTimeout(resolve4, ms));
1235
1646
  }
1236
1647
  function readRequestBody(req) {
@@ -1552,7 +1963,7 @@ var ManagedModelServer = class {
1552
1963
  child.kill("SIGTERM");
1553
1964
  await Promise.race([
1554
1965
  new Promise((resolve4) => child.once("exit", () => resolve4())),
1555
- sleep(5e3).then(() => {
1966
+ sleep2(5e3).then(() => {
1556
1967
  if (child.exitCode == null && child.signalCode == null) child.kill("SIGKILL");
1557
1968
  })
1558
1969
  ]);
@@ -1582,7 +1993,7 @@ var ManagedModelServer = class {
1582
1993
  const health = await this.checkHealth();
1583
1994
  if (health.ok) return;
1584
1995
  if (!this.child) break;
1585
- await sleep(50);
1996
+ await sleep2(50);
1586
1997
  }
1587
1998
  throw new Error("Model server did not become healthy within 120 seconds");
1588
1999
  }
@@ -2091,7 +2502,7 @@ if __name__ == "__main__":
2091
2502
  `;
2092
2503
  function resolveOutputPath(output, filename) {
2093
2504
  if (!output) return filename;
2094
- if (existsSync4(output) && statSync3(output).isDirectory()) {
2505
+ if (existsSync5(output) && statSync4(output).isDirectory()) {
2095
2506
  return join2(output, filename);
2096
2507
  }
2097
2508
  return output;
@@ -2212,7 +2623,7 @@ function runtimePythonPath(cacheDir) {
2212
2623
  function resolveServePython(cacheDir, explicitPython) {
2213
2624
  if (explicitPython) return explicitPython;
2214
2625
  const managedPython = runtimePythonPath(cacheDir);
2215
- return existsSync4(managedPython) ? managedPython : "python3";
2626
+ return existsSync5(managedPython) ? managedPython : "python3";
2216
2627
  }
2217
2628
  function commandExists(command) {
2218
2629
  try {
@@ -2291,11 +2702,28 @@ function safeSegment(value) {
2291
2702
  return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "model";
2292
2703
  }
2293
2704
  function directoryHasFiles(path) {
2294
- 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
+ }
2295
2722
  }
2296
2723
  function extractArchive(archivePath, targetDir, force) {
2297
2724
  if (directoryHasFiles(targetDir) && !force) return targetDir;
2298
- if (existsSync4(targetDir)) rmSync(targetDir, { recursive: true, force: true });
2725
+ validateArchiveEntries(archivePath);
2726
+ if (existsSync5(targetDir)) rmSync(targetDir, { recursive: true, force: true });
2299
2727
  mkdirSync3(targetDir, { recursive: true });
2300
2728
  execFileSync("tar", ["-xzf", archivePath, "-C", targetDir], { stdio: "inherit" });
2301
2729
  return targetDir;
@@ -2322,21 +2750,21 @@ function findSpecPath(explicitPath, modelPath) {
2322
2750
  const candidates = explicitPath ? [explicitPath] : [join2(process.cwd(), DEFAULT_SPEC_FILE), join2(modelPath, DEFAULT_SPEC_FILE)];
2323
2751
  for (const candidate of candidates) {
2324
2752
  const resolved = resolve2(candidate);
2325
- if (existsSync4(resolved) && statSync3(resolved).isFile()) return resolved;
2753
+ if (existsSync5(resolved) && statSync4(resolved).isFile()) return resolved;
2326
2754
  }
2327
2755
  return null;
2328
2756
  }
2329
2757
  function loadSystemPromptFromSpec(specPath) {
2330
- const spec = JSON.parse(readFileSync6(specPath, "utf8"));
2758
+ const spec = JSON.parse(readFileSync7(specPath, "utf8"));
2331
2759
  return buildSystemPrompt(spec);
2332
2760
  }
2333
2761
  function loadJsonSchemaForServe(schemaPath) {
2334
2762
  const resolved = resolve2(schemaPath);
2335
- if (!existsSync4(resolved) || !statSync3(resolved).isFile()) {
2763
+ if (!existsSync5(resolved) || !statSync4(resolved).isFile()) {
2336
2764
  throw new Error(`JSON schema file not found: ${schemaPath}`);
2337
2765
  }
2338
2766
  try {
2339
- const schema = JSON.parse(readFileSync6(resolved, "utf8"));
2767
+ const schema = JSON.parse(readFileSync7(resolved, "utf8"));
2340
2768
  return JSON.stringify(schema);
2341
2769
  } catch (err) {
2342
2770
  throw new Error(`JSON schema file is not valid JSON: ${schemaPath}`);
@@ -2350,9 +2778,9 @@ function writeReferenceServerScript(cacheDir) {
2350
2778
  return scriptPath;
2351
2779
  }
2352
2780
  async function resolveServeTarget(target, opts, cacheDir, forceDownload) {
2353
- if (existsSync4(target)) {
2781
+ if (existsSync5(target)) {
2354
2782
  const resolved = resolve2(target);
2355
- const stats = statSync3(resolved);
2783
+ const stats = statSync4(resolved);
2356
2784
  if (stats.isDirectory()) {
2357
2785
  return {
2358
2786
  modelPath: resolved,
@@ -2376,7 +2804,7 @@ async function resolveServeTarget(target, opts, cacheDir, forceDownload) {
2376
2804
  const modelCacheDir = join2(cacheDir, fullId);
2377
2805
  const archivePath = join2(modelCacheDir, data.filename);
2378
2806
  const extractDir = join2(modelCacheDir, "model");
2379
- if (!existsSync4(archivePath) || forceDownload) {
2807
+ if (!existsSync5(archivePath) || forceDownload) {
2380
2808
  await downloadUrlToFile(data.url, archivePath);
2381
2809
  }
2382
2810
  extractArchive(archivePath, extractDir, forceDownload);
@@ -2480,7 +2908,7 @@ function ollamaSlug(name) {
2480
2908
  }
2481
2909
  function firstExisting(candidates) {
2482
2910
  for (const candidate of candidates) {
2483
- if (candidate && existsSync4(candidate) && statSync3(candidate).isFile()) {
2911
+ if (candidate && existsSync5(candidate) && statSync4(candidate).isFile()) {
2484
2912
  return resolve2(candidate);
2485
2913
  }
2486
2914
  }
@@ -2492,7 +2920,7 @@ function llamaCppDir(explicit) {
2492
2920
  function resolveConvertScript(opts, required) {
2493
2921
  if (opts.convertScript) {
2494
2922
  const resolved = resolve2(opts.convertScript);
2495
- if (required && !existsSync4(resolved)) {
2923
+ if (required && !existsSync5(resolved)) {
2496
2924
  throw new Error(`Conversion script not found: ${opts.convertScript}`);
2497
2925
  }
2498
2926
  return resolved;
@@ -2511,7 +2939,7 @@ function resolveConvertScript(opts, required) {
2511
2939
  function resolveQuantizeBin(opts, required) {
2512
2940
  if (opts.quantizeBin) {
2513
2941
  const resolved = resolve2(opts.quantizeBin);
2514
- if (required && !existsSync4(resolved)) {
2942
+ if (required && !existsSync5(resolved)) {
2515
2943
  throw new Error(`llama-quantize binary not found: ${opts.quantizeBin}`);
2516
2944
  }
2517
2945
  return resolved;
@@ -2646,7 +3074,7 @@ function registerModelsCommands(parent) {
2646
3074
  opts
2647
3075
  );
2648
3076
  const outputPath = resolveOutputPath(cmdOpts.output, data.filename);
2649
- if (existsSync4(outputPath) && !cmdOpts.force) {
3077
+ if (existsSync5(outputPath) && !cmdOpts.force) {
2650
3078
  throw new Error(`Output file already exists: ${outputPath}. Use --force to overwrite.`);
2651
3079
  }
2652
3080
  const bytes = await downloadUrlToFile(data.url, outputPath);
@@ -2755,7 +3183,7 @@ function registerModelsCommands(parent) {
2755
3183
  ollama: ollamaInfo
2756
3184
  };
2757
3185
  if (printOnly) return printExportPlan(plan);
2758
- if (existsSync4(finalPath) && !cmdOpts.force) {
3186
+ if (existsSync5(finalPath) && !cmdOpts.force) {
2759
3187
  throw new Error(`Output already exists: ${finalPath}. Use --force to overwrite.`);
2760
3188
  }
2761
3189
  mkdirSync3(outputDir, { recursive: true });
@@ -2776,7 +3204,7 @@ function registerModelsCommands(parent) {
2776
3204
  finalPath,
2777
3205
  quantPlan.quantizeType
2778
3206
  ]);
2779
- if (!cmdOpts.keepIntermediate && intermediatePath && existsSync4(intermediatePath)) {
3207
+ if (!cmdOpts.keepIntermediate && intermediatePath && existsSync5(intermediatePath)) {
2780
3208
  rmSync(intermediatePath, { force: true });
2781
3209
  }
2782
3210
  }
@@ -2829,10 +3257,10 @@ function registerModelsCommands(parent) {
2829
3257
  ]);
2830
3258
  return;
2831
3259
  }
2832
- if (existsSync4(venvDir) && cmdOpts.force) {
3260
+ if (existsSync5(venvDir) && cmdOpts.force) {
2833
3261
  rmSync(venvDir, { recursive: true, force: true });
2834
3262
  }
2835
- if (!existsSync4(venvPython)) {
3263
+ if (!existsSync5(venvPython)) {
2836
3264
  mkdirSync3(cacheDir, { recursive: true });
2837
3265
  execFileSync(commands[0][0], commands[0].slice(1), { stdio: "inherit" });
2838
3266
  }
@@ -2973,15 +3401,15 @@ var KIND_LABELS = {
2973
3401
  refund: "Refund",
2974
3402
  adjustment: "Adjustment"
2975
3403
  };
2976
- function formatCents2(cents) {
3404
+ function formatCents3(cents) {
2977
3405
  const sign = cents < 0 ? "-" : "";
2978
3406
  const abs = Math.abs(cents);
2979
3407
  return `${sign}$${(abs / 100).toFixed(2)}`;
2980
3408
  }
2981
3409
  function formatSigned(cents) {
2982
- if (cents > 0) return chalk3.green(`+${formatCents2(cents)}`);
2983
- if (cents < 0) return chalk3.red(formatCents2(cents));
2984
- return formatCents2(cents);
3410
+ if (cents > 0) return chalk3.green(`+${formatCents3(cents)}`);
3411
+ if (cents < 0) return chalk3.red(formatCents3(cents));
3412
+ return formatCents3(cents);
2985
3413
  }
2986
3414
  function registerBalanceCommands(parent) {
2987
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) => {
@@ -2995,10 +3423,10 @@ function registerBalanceCommands(parent) {
2995
3423
  return printJson({ balance, transactions });
2996
3424
  }
2997
3425
  const lowBalance = balance.balance_cents < 100;
2998
- const balanceLine = lowBalance ? chalk3.red(formatCents2(balance.balance_cents)) + chalk3.dim(" (low)") : chalk3.bold.green(formatCents2(balance.balance_cents));
3426
+ const balanceLine = lowBalance ? chalk3.red(formatCents3(balance.balance_cents)) + chalk3.dim(" (low)") : chalk3.bold.green(formatCents3(balance.balance_cents));
2999
3427
  printDetail([
3000
3428
  ["Credits", balanceLine],
3001
- ["Lifetime top-ups", formatCents2(balance.lifetime_topup_cents)]
3429
+ ["Lifetime top-ups", formatCents3(balance.lifetime_topup_cents)]
3002
3430
  ]);
3003
3431
  if (transactions && transactions.length > 0) {
3004
3432
  console.log(`
@@ -3009,7 +3437,7 @@ ${chalk3.bold("Recent transactions")}`);
3009
3437
  formatDate(row.created_at),
3010
3438
  KIND_LABELS[row.kind] ?? row.kind,
3011
3439
  formatSigned(row.amount_cents),
3012
- formatCents2(row.balance_after_cents),
3440
+ formatCents3(row.balance_after_cents),
3013
3441
  row.reference_id ? shortId(row.reference_id) : chalk3.dim("\u2014")
3014
3442
  ])
3015
3443
  );
@@ -3307,7 +3735,7 @@ function printValidation(checks) {
3307
3735
  }
3308
3736
 
3309
3737
  // src/commands/push.ts
3310
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
3738
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "fs";
3311
3739
  import { resolve as resolve3 } from "path";
3312
3740
  function registerPushCommand(parent) {
3313
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) => {
@@ -3327,7 +3755,7 @@ function registerPushCommand(parent) {
3327
3755
  } else {
3328
3756
  const res = await post("/behavior-specs", body, opts);
3329
3757
  data = res.data;
3330
- const raw = JSON.parse(readFileSync7(filePath, "utf-8"));
3758
+ const raw = JSON.parse(readFileSync8(filePath, "utf-8"));
3331
3759
  raw.id = data.id;
3332
3760
  writeFileSync4(filePath, JSON.stringify(raw, null, 2) + "\n");
3333
3761
  }
@@ -3340,7 +3768,7 @@ function registerPushCommand(parent) {
3340
3768
 
3341
3769
  // src/index.ts
3342
3770
  var program = new Command();
3343
- program.name("tt").description("Tuned Tensor CLI \u2014 fine-tune and evaluate LLMs").version("0.4.19").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(
3344
3772
  "-u, --base-url <url>",
3345
3773
  "API base URL (default: https://tunedtensor.com)"
3346
3774
  ).option("--json", "Output raw JSON").option("--no-color", "Disable colors").hook("preAction", (_thisCommand, actionCommand) => {
@@ -3354,6 +3782,7 @@ registerAuthCommands(program);
3354
3782
  registerSpecsCommands(program);
3355
3783
  registerRunsCommands(program);
3356
3784
  registerDatasetsCommands(program);
3785
+ registerLabelCommands(program);
3357
3786
  registerModelsCommands(program);
3358
3787
  registerBalanceCommands(program);
3359
3788
  registerTopupCommands(program);