@tuned-tensor/cli 0.4.10 → 0.4.12

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
@@ -595,6 +595,39 @@ function formatEvalScore(run) {
595
595
  const score = getEvalScore(run);
596
596
  return score == null ? void 0 : (score * 100).toFixed(1) + "%";
597
597
  }
598
+ function formatMinutes(minutes) {
599
+ if (minutes == null) return void 0;
600
+ if (minutes < 60) return `${Math.max(1, Math.round(minutes))}m`;
601
+ return `${(minutes / 60).toFixed(1)}h`;
602
+ }
603
+ function formatEpochProgress(diagnostics) {
604
+ const curve = diagnostics.training.curve;
605
+ if (curve.latest_epoch == null) return void 0;
606
+ if (curve.target_epochs == null) return curve.latest_epoch.toFixed(4);
607
+ return `${curve.latest_epoch.toFixed(4)} / ${curve.target_epochs.toFixed(2)}`;
608
+ }
609
+ function printDiagnostics(diagnostics) {
610
+ const curve = diagnostics.training.curve;
611
+ const perFive = curve.epoch_rate_per_minute == null ? void 0 : (curve.epoch_rate_per_minute * 5).toFixed(4);
612
+ printDetail([
613
+ ["Run", shortId(diagnostics.run_id)],
614
+ ["Status", formatStatus(diagnostics.status)],
615
+ ["Stage", diagnostics.stage_label ?? diagnostics.stage ?? void 0],
616
+ ["Summary", diagnostics.summary],
617
+ ["Training", diagnostics.training.state],
618
+ ["Epoch", formatEpochProgress(diagnostics)],
619
+ ["Latest Loss", curve.latest_loss == null ? void 0 : curve.latest_loss.toFixed(4)],
620
+ ["Pace", perFive ? `${perFive} epoch / 5m` : void 0],
621
+ ["ETA", formatMinutes(curve.estimated_minutes_remaining)],
622
+ ["Latest Update", formatDate(diagnostics.training.last_updated_at)]
623
+ ]);
624
+ if (diagnostics.insights.length) {
625
+ console.log("\nInsights:");
626
+ for (const insight of diagnostics.insights) {
627
+ console.log(` - ${insight}`);
628
+ }
629
+ }
630
+ }
598
631
  function registerRunsCommands(parent) {
599
632
  const runs = parent.command("runs").description("Manage runs");
600
633
  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) => {
@@ -673,7 +706,7 @@ Eval Results (${data._evals.length}):`);
673
706
  );
674
707
  }
675
708
  });
676
- 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 Bedrock 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("--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").action(async (specId, cmdOpts) => {
709
+ 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("--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").action(async (specId, cmdOpts) => {
677
710
  const opts = parent.opts();
678
711
  const body = {};
679
712
  if (cmdOpts.augment === false) body.augment = false;
@@ -748,6 +781,17 @@ Eval Results (${data._evals.length}):`);
748
781
  ["Completed", formatDate(run.completed_at)]
749
782
  ]);
750
783
  });
784
+ runs.command("diagnose").description("Show live run diagnostics").argument("<id>", "Run ID (full UUID or 8+ char prefix)").action(async (id) => {
785
+ const opts = parent.opts();
786
+ const fullId = await resolveRunId(id, opts);
787
+ const { data } = await get(
788
+ `/runs/${fullId}/diagnostics`,
789
+ void 0,
790
+ opts
791
+ );
792
+ if (isJsonMode()) return printJson(data);
793
+ printDiagnostics(data);
794
+ });
751
795
  }
752
796
 
753
797
  // src/commands/datasets.ts
@@ -960,17 +1004,374 @@ function registerDatasetsCommands(parent) {
960
1004
  }
961
1005
 
962
1006
  // src/commands/models.ts
963
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, statSync as statSync3, createWriteStream, unlinkSync } from "fs";
964
- import { dirname, join as join2 } from "path";
965
- import { Readable } from "stream";
1007
+ import { spawn, execFileSync } from "child_process";
1008
+ import { createHash } from "crypto";
1009
+ import {
1010
+ existsSync as existsSync4,
1011
+ mkdirSync as mkdirSync2,
1012
+ statSync as statSync3,
1013
+ createWriteStream,
1014
+ unlinkSync,
1015
+ readFileSync as readFileSync6,
1016
+ writeFileSync as writeFileSync3,
1017
+ readdirSync,
1018
+ rmSync
1019
+ } from "fs";
1020
+ import { homedir as homedir2 } from "os";
1021
+ import { dirname, join as join2, resolve as resolve2, basename as basename2 } from "path";
1022
+ import { Readable, Transform } from "stream";
966
1023
  import { pipeline } from "stream/promises";
1024
+
1025
+ // src/commands/init.ts
1026
+ import { existsSync as existsSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
1027
+ import { resolve } from "path";
1028
+ var DEFAULT_SPEC_FILE = "tunedtensor.json";
1029
+ var SCAFFOLD = {
1030
+ name: "My Agent",
1031
+ description: "",
1032
+ base_model: "Qwen/Qwen3.5-2B",
1033
+ system_prompt: "You are a helpful assistant.",
1034
+ guidelines: [],
1035
+ constraints: [],
1036
+ examples: [
1037
+ { input: "Hello", output: "Hi! How can I help you today?" }
1038
+ ],
1039
+ eval_cases: []
1040
+ };
1041
+ function registerInitCommand(parent) {
1042
+ 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) => {
1043
+ const filePath = resolve(cmdOpts.file);
1044
+ if (existsSync3(filePath)) {
1045
+ printWarning(`${cmdOpts.file} already exists. Use tt eval to validate it or edit it directly.`);
1046
+ return;
1047
+ }
1048
+ const spec = { ...SCAFFOLD };
1049
+ if (cmdOpts.name) spec.name = cmdOpts.name;
1050
+ if (cmdOpts.model) spec.base_model = canonicalizeBaseModel(cmdOpts.model);
1051
+ writeFileSync2(filePath, JSON.stringify(spec, null, 2) + "\n");
1052
+ printSuccess(`Created ${cmdOpts.file}`);
1053
+ console.log("\nNext steps:");
1054
+ console.log(" 1. Edit the spec: system_prompt, guidelines, examples");
1055
+ console.log(" 2. Validate spec: tt eval");
1056
+ console.log(" 3. Push to remote: tt push");
1057
+ });
1058
+ }
1059
+ function loadSpec(filePath) {
1060
+ const resolved = resolve(filePath || DEFAULT_SPEC_FILE);
1061
+ if (!existsSync3(resolved)) {
1062
+ printError(
1063
+ `Spec file not found: ${filePath || DEFAULT_SPEC_FILE}
1064
+ Run \`tt init\` to create one.`
1065
+ );
1066
+ process.exit(1);
1067
+ }
1068
+ const raw = JSON.parse(readFileSync5(resolved, "utf-8"));
1069
+ return raw;
1070
+ }
1071
+
1072
+ // src/commands/models.ts
1073
+ var REFERENCE_SERVER_SCRIPT = String.raw`#!/usr/bin/env python3
1074
+ import json
1075
+ import os
1076
+ import time
1077
+ import uuid
1078
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
1079
+
1080
+ MODEL_PATH = os.environ["TT_MODEL_PATH"]
1081
+ MODEL_NAME = os.environ.get("TT_MODEL_NAME", os.path.basename(MODEL_PATH.rstrip("/")) or "tuned-tensor-model")
1082
+ SYSTEM_PROMPT = os.environ.get("TT_SYSTEM_PROMPT", "").strip()
1083
+ HOST = os.environ.get("TT_HOST", "127.0.0.1")
1084
+ PORT = int(os.environ.get("TT_PORT", "8000"))
1085
+ DEFAULT_MAX_TOKENS = int(os.environ.get("TT_MAX_TOKENS", "512"))
1086
+ DEFAULT_TEMPERATURE = float(os.environ.get("TT_TEMPERATURE", "0.7"))
1087
+ TRUST_REMOTE_CODE = os.environ.get("TT_TRUST_REMOTE_CODE", "false").lower() in {"1", "true", "yes", "y"}
1088
+ REQUESTED_DEVICE = os.environ.get("TT_DEVICE", "auto").lower()
1089
+
1090
+
1091
+ def choose_device(torch):
1092
+ if REQUESTED_DEVICE not in {"auto", "cpu", "cuda", "mps"}:
1093
+ raise RuntimeError("TT_DEVICE must be one of: auto, cpu, cuda, mps")
1094
+ if REQUESTED_DEVICE == "cuda" and not torch.cuda.is_available():
1095
+ raise RuntimeError("TT_DEVICE=cuda requested, but CUDA is not available")
1096
+ if REQUESTED_DEVICE == "mps" and not (
1097
+ hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
1098
+ ):
1099
+ raise RuntimeError("TT_DEVICE=mps requested, but Apple MPS is not available")
1100
+ if REQUESTED_DEVICE != "auto":
1101
+ return REQUESTED_DEVICE
1102
+ if torch.cuda.is_available():
1103
+ return "cuda"
1104
+ if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
1105
+ return "mps"
1106
+ return "cpu"
1107
+
1108
+
1109
+ def load_model():
1110
+ try:
1111
+ import torch
1112
+ from transformers import (
1113
+ AutoModelForCausalLM,
1114
+ AutoModelForImageTextToText,
1115
+ AutoProcessor,
1116
+ AutoTokenizer,
1117
+ )
1118
+ except Exception as exc:
1119
+ raise RuntimeError(
1120
+ "Missing Python serving dependencies. Install them with: "
1121
+ "python -m pip install torch transformers accelerate"
1122
+ ) from exc
1123
+
1124
+ try:
1125
+ processor = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=TRUST_REMOTE_CODE)
1126
+ except Exception:
1127
+ processor = AutoProcessor.from_pretrained(MODEL_PATH, trust_remote_code=TRUST_REMOTE_CODE)
1128
+ tokenizer = getattr(processor, "tokenizer", processor)
1129
+ if getattr(tokenizer, "pad_token", None) is None:
1130
+ tokenizer.pad_token = tokenizer.eos_token
1131
+
1132
+ device = choose_device(torch)
1133
+ kwargs = {
1134
+ "trust_remote_code": TRUST_REMOTE_CODE,
1135
+ "torch_dtype": "auto",
1136
+ "low_cpu_mem_usage": True,
1137
+ }
1138
+ if device == "cuda":
1139
+ kwargs["device_map"] = "auto"
1140
+ elif device == "mps":
1141
+ kwargs["torch_dtype"] = torch.float16
1142
+
1143
+ try:
1144
+ model = AutoModelForCausalLM.from_pretrained(MODEL_PATH, **kwargs)
1145
+ except Exception:
1146
+ model = AutoModelForImageTextToText.from_pretrained(MODEL_PATH, **kwargs)
1147
+ if device != "cuda":
1148
+ model.to(device)
1149
+ model.eval()
1150
+ return torch, tokenizer, model, device
1151
+
1152
+
1153
+ torch, tokenizer, model, DEVICE = load_model()
1154
+
1155
+
1156
+ def content_to_text(content):
1157
+ if isinstance(content, str):
1158
+ return content
1159
+ return json.dumps(content, ensure_ascii=False)
1160
+
1161
+
1162
+ def normalize_messages(raw_messages):
1163
+ messages = []
1164
+ for item in raw_messages:
1165
+ if not isinstance(item, dict):
1166
+ continue
1167
+ role = item.get("role")
1168
+ if role not in {"system", "user", "assistant", "tool"}:
1169
+ continue
1170
+ messages.append({"role": role, "content": content_to_text(item.get("content", ""))})
1171
+
1172
+ if SYSTEM_PROMPT and not any(message["role"] == "system" for message in messages):
1173
+ messages.insert(0, {"role": "system", "content": SYSTEM_PROMPT})
1174
+ return messages
1175
+
1176
+
1177
+ def render_prompt(messages):
1178
+ chat_template = getattr(tokenizer, "chat_template", None)
1179
+ if hasattr(tokenizer, "apply_chat_template") and chat_template:
1180
+ return tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
1181
+
1182
+ rendered = []
1183
+ for message in messages:
1184
+ rendered.append(f"{message['role'].upper()}: {message['content']}")
1185
+ rendered.append("ASSISTANT:")
1186
+ return "\n".join(rendered)
1187
+
1188
+
1189
+ def generate_completion(messages, max_tokens, temperature):
1190
+ prompt = render_prompt(messages)
1191
+ inputs = tokenizer(prompt, return_tensors="pt")
1192
+ input_device = getattr(model, "device", None)
1193
+ if input_device is None:
1194
+ input_device = next(model.parameters()).device
1195
+ inputs = {key: value.to(input_device) for key, value in inputs.items()}
1196
+
1197
+ generate_kwargs = {
1198
+ **inputs,
1199
+ "max_new_tokens": max_tokens,
1200
+ "pad_token_id": tokenizer.pad_token_id or tokenizer.eos_token_id,
1201
+ }
1202
+ if temperature > 0:
1203
+ generate_kwargs["do_sample"] = True
1204
+ generate_kwargs["temperature"] = temperature
1205
+ else:
1206
+ generate_kwargs["do_sample"] = False
1207
+
1208
+ with torch.no_grad():
1209
+ output_ids = model.generate(**generate_kwargs)
1210
+
1211
+ prompt_tokens = int(inputs["input_ids"].shape[-1])
1212
+ completion_ids = output_ids[0][prompt_tokens:]
1213
+ content = tokenizer.decode(completion_ids, skip_special_tokens=True).strip()
1214
+ return content, prompt_tokens, int(completion_ids.shape[-1])
1215
+
1216
+
1217
+ class Handler(BaseHTTPRequestHandler):
1218
+ server_version = "TunedTensorReferenceServer/0.1"
1219
+
1220
+ def send_json(self, status, payload):
1221
+ body = json.dumps(payload).encode("utf-8")
1222
+ self.send_response(status)
1223
+ self.send_header("Content-Type", "application/json")
1224
+ self.send_header("Content-Length", str(len(body)))
1225
+ self.send_header("Access-Control-Allow-Origin", "*")
1226
+ self.send_header("Access-Control-Allow-Headers", "authorization, content-type")
1227
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
1228
+ self.end_headers()
1229
+ self.wfile.write(body)
1230
+
1231
+ def do_OPTIONS(self):
1232
+ self.send_json(204, {})
1233
+
1234
+ def do_GET(self):
1235
+ if self.path == "/health":
1236
+ self.send_json(200, {
1237
+ "status": "ok",
1238
+ "model": MODEL_NAME,
1239
+ "spec_prompt": bool(SYSTEM_PROMPT),
1240
+ "device": DEVICE,
1241
+ })
1242
+ return
1243
+ self.send_json(404, {"error": {"message": "Not found"}})
1244
+
1245
+ def do_POST(self):
1246
+ if self.path not in {"/v1/chat/completions", "/chat/completions"}:
1247
+ self.send_json(404, {"error": {"message": "Not found"}})
1248
+ return
1249
+
1250
+ try:
1251
+ length = int(self.headers.get("content-length", "0"))
1252
+ body = json.loads(self.rfile.read(length) or b"{}")
1253
+ if body.get("stream"):
1254
+ self.send_json(400, {"error": {"message": "Streaming is not supported by tt models serve yet."}})
1255
+ return
1256
+
1257
+ raw_messages = body.get("messages")
1258
+ if not isinstance(raw_messages, list) or not raw_messages:
1259
+ self.send_json(400, {"error": {"message": "Request must include a non-empty messages array."}})
1260
+ return
1261
+
1262
+ max_tokens = int(body.get("max_tokens") or DEFAULT_MAX_TOKENS)
1263
+ temperature = float(body.get("temperature") if body.get("temperature") is not None else DEFAULT_TEMPERATURE)
1264
+ messages = normalize_messages(raw_messages)
1265
+ content, prompt_tokens, completion_tokens = generate_completion(messages, max_tokens, temperature)
1266
+
1267
+ self.send_json(200, {
1268
+ "id": "chatcmpl-" + uuid.uuid4().hex,
1269
+ "object": "chat.completion",
1270
+ "created": int(time.time()),
1271
+ "model": body.get("model") or MODEL_NAME,
1272
+ "choices": [{
1273
+ "index": 0,
1274
+ "message": {"role": "assistant", "content": content},
1275
+ "finish_reason": "stop",
1276
+ }],
1277
+ "usage": {
1278
+ "prompt_tokens": prompt_tokens,
1279
+ "completion_tokens": completion_tokens,
1280
+ "total_tokens": prompt_tokens + completion_tokens,
1281
+ },
1282
+ })
1283
+ except Exception as exc:
1284
+ self.send_json(500, {"error": {"message": str(exc)}})
1285
+
1286
+ def log_message(self, fmt, *args):
1287
+ print("%s - %s" % (self.address_string(), fmt % args))
1288
+
1289
+
1290
+ def main():
1291
+ print(f"Serving {MODEL_NAME} from {MODEL_PATH}")
1292
+ print(f"Listening on http://{HOST}:{PORT}/v1/chat/completions")
1293
+ if SYSTEM_PROMPT:
1294
+ print("Behavior spec prompt: enabled")
1295
+ else:
1296
+ print("Behavior spec prompt: disabled")
1297
+ print(f"Device: {DEVICE}")
1298
+ ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
1299
+
1300
+
1301
+ if __name__ == "__main__":
1302
+ main()
1303
+ `;
967
1304
  function resolveOutputPath(output, filename) {
968
1305
  if (!output) return filename;
969
- if (existsSync3(output) && statSync3(output).isDirectory()) {
1306
+ if (existsSync4(output) && statSync3(output).isDirectory()) {
970
1307
  return join2(output, filename);
971
1308
  }
972
1309
  return output;
973
1310
  }
1311
+ function parseContentLength(value) {
1312
+ if (!value) return null;
1313
+ const parsed = Number(value);
1314
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
1315
+ }
1316
+ function formatBytes2(bytes) {
1317
+ const units = ["B", "KB", "MB", "GB", "TB"];
1318
+ let value = bytes;
1319
+ let unit = 0;
1320
+ while (value >= 1024 && unit < units.length - 1) {
1321
+ value /= 1024;
1322
+ unit += 1;
1323
+ }
1324
+ const precision = unit === 0 || value >= 10 ? 0 : 1;
1325
+ return `${value.toFixed(precision)} ${units[unit]}`;
1326
+ }
1327
+ function formatDuration(seconds) {
1328
+ if (!Number.isFinite(seconds) || seconds < 0) return "--";
1329
+ const rounded = Math.max(1, Math.round(seconds));
1330
+ const hours = Math.floor(rounded / 3600);
1331
+ const minutes = Math.floor(rounded % 3600 / 60);
1332
+ const secs = rounded % 60;
1333
+ if (hours > 0) return `${hours}h ${minutes}m`;
1334
+ if (minutes > 0) return `${minutes}m ${secs}s`;
1335
+ return `${secs}s`;
1336
+ }
1337
+ function chunkLength(chunk) {
1338
+ if (typeof chunk === "string") return Buffer.byteLength(chunk);
1339
+ if (chunk instanceof Uint8Array) return chunk.byteLength;
1340
+ return Buffer.byteLength(String(chunk));
1341
+ }
1342
+ function createDownloadProgress(totalBytes, enabled) {
1343
+ const stream = process.stderr;
1344
+ const start = Date.now();
1345
+ let lastRender = 0;
1346
+ let rendered = false;
1347
+ function render(downloadedBytes, force = false) {
1348
+ if (!enabled) return;
1349
+ const now = Date.now();
1350
+ if (!force && now - lastRender < 100 && downloadedBytes !== totalBytes) return;
1351
+ lastRender = now;
1352
+ rendered = true;
1353
+ const elapsedSeconds = Math.max((now - start) / 1e3, 1e-3);
1354
+ const rate = downloadedBytes / elapsedSeconds;
1355
+ const rateText = `${formatBytes2(rate)}/s`;
1356
+ let line;
1357
+ if (totalBytes != null && totalBytes > 0) {
1358
+ const ratio = Math.min(downloadedBytes / totalBytes, 1);
1359
+ const barWidth = 24;
1360
+ const filled = Math.round(ratio * barWidth);
1361
+ const bar = "#".repeat(filled) + "-".repeat(barWidth - filled);
1362
+ const etaSeconds = rate > 0 ? (totalBytes - downloadedBytes) / rate : Number.NaN;
1363
+ line = `Downloading [${bar}] ${(ratio * 100).toFixed(1).padStart(5)}% ${formatBytes2(downloadedBytes)}/${formatBytes2(totalBytes)} ${rateText} ETA ${formatDuration(etaSeconds)}`;
1364
+ } else {
1365
+ line = `Downloading ${formatBytes2(downloadedBytes)} ${rateText} elapsed ${formatDuration(elapsedSeconds)}`;
1366
+ }
1367
+ const columns = stream.columns || 120;
1368
+ stream.write(`\r\x1B[K${line.slice(0, Math.max(columns - 1, 0))}`);
1369
+ }
1370
+ function stop() {
1371
+ if (enabled && rendered) stream.write("\n");
1372
+ }
1373
+ return { render, stop };
1374
+ }
974
1375
  async function downloadUrlToFile(url, outputPath) {
975
1376
  const res = await fetch(url);
976
1377
  if (!res.ok) {
@@ -981,17 +1382,225 @@ async function downloadUrlToFile(url, outputPath) {
981
1382
  }
982
1383
  mkdirSync2(dirname(outputPath), { recursive: true });
983
1384
  const file = createWriteStream(outputPath);
1385
+ const contentLength = parseContentLength(res.headers.get("content-length"));
1386
+ const progress = createDownloadProgress(
1387
+ contentLength,
1388
+ !isJsonMode() && process.stderr.isTTY === true
1389
+ );
1390
+ let downloadedBytes = 0;
1391
+ const progressStream = new Transform({
1392
+ transform(chunk, _encoding, callback) {
1393
+ downloadedBytes += chunkLength(chunk);
1394
+ progress.render(downloadedBytes);
1395
+ callback(null, chunk);
1396
+ }
1397
+ });
984
1398
  try {
985
- await pipeline(Readable.fromWeb(res.body), file);
1399
+ progress.render(0, true);
1400
+ await pipeline(Readable.fromWeb(res.body), progressStream, file);
1401
+ progress.render(downloadedBytes, true);
1402
+ progress.stop();
986
1403
  } catch (err) {
1404
+ progress.stop();
987
1405
  try {
988
1406
  unlinkSync(outputPath);
989
1407
  } catch {
990
1408
  }
991
1409
  throw err;
992
1410
  }
993
- const length = res.headers.get("content-length");
994
- return length ? Number(length) : null;
1411
+ return contentLength;
1412
+ }
1413
+ function defaultCacheDir() {
1414
+ const root = process.env.XDG_CACHE_HOME || join2(homedir2(), ".cache");
1415
+ return join2(root, "tuned-tensor", "models");
1416
+ }
1417
+ function runtimeDir(cacheDir) {
1418
+ return join2(cacheDir, "_runtime");
1419
+ }
1420
+ function runtimePythonPath(cacheDir) {
1421
+ const dir = runtimeDir(cacheDir);
1422
+ return process.platform === "win32" ? join2(dir, "Scripts", "python.exe") : join2(dir, "bin", "python");
1423
+ }
1424
+ function resolveServePython(cacheDir, explicitPython) {
1425
+ if (explicitPython) return explicitPython;
1426
+ const managedPython = runtimePythonPath(cacheDir);
1427
+ return existsSync4(managedPython) ? managedPython : "python3";
1428
+ }
1429
+ function commandExists(command) {
1430
+ try {
1431
+ execFileSync(command, ["--version"], { stdio: "ignore" });
1432
+ return true;
1433
+ } catch {
1434
+ return false;
1435
+ }
1436
+ }
1437
+ function pythonMinorVersion(command) {
1438
+ try {
1439
+ const out = execFileSync(
1440
+ command,
1441
+ ["-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"],
1442
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
1443
+ ).trim();
1444
+ const [major, minor] = out.split(".").map((part) => Number(part));
1445
+ if (major !== 3 || !Number.isFinite(minor)) return null;
1446
+ return minor;
1447
+ } catch {
1448
+ return null;
1449
+ }
1450
+ }
1451
+ function pickRuntimePython(explicitPython) {
1452
+ const candidates = explicitPython ? [explicitPython] : ["python3.12", "python3.11", "python3.10", "python3"];
1453
+ for (const candidate of candidates) {
1454
+ if (!commandExists(candidate)) continue;
1455
+ const minor = pythonMinorVersion(candidate);
1456
+ if (minor != null && minor >= 10 && minor <= 12) return candidate;
1457
+ if (explicitPython) {
1458
+ throw new Error(
1459
+ `${candidate} is Python 3.${minor ?? "?"}. The local serving runtime requires Python 3.10, 3.11, or 3.12 because torch wheels may not be available for newer versions.`
1460
+ );
1461
+ }
1462
+ }
1463
+ throw new Error(
1464
+ "Could not find Python 3.10, 3.11, or 3.12. Install one, then run `tt models setup-runtime --python <path>`."
1465
+ );
1466
+ }
1467
+ function ensureServingRuntime(python, cacheDir) {
1468
+ const check = `
1469
+ import importlib.util
1470
+ import json
1471
+ required = ["torch", "transformers", "accelerate", "safetensors"]
1472
+ missing = [name for name in required if importlib.util.find_spec(name) is None]
1473
+ print(json.dumps({"missing": missing}))
1474
+ `;
1475
+ let missing;
1476
+ try {
1477
+ const out = execFileSync(python, ["-c", check], {
1478
+ encoding: "utf8",
1479
+ stdio: ["ignore", "pipe", "pipe"]
1480
+ });
1481
+ missing = JSON.parse(out).missing;
1482
+ } catch (err) {
1483
+ const suffix = python === runtimePythonPath(cacheDir) ? " Re-run `tt models setup-runtime` to repair it." : " Run `tt models setup-runtime` to create an isolated serving runtime.";
1484
+ throw new Error(`Python serving runtime check failed for ${python}.${suffix}`);
1485
+ }
1486
+ if (missing.length > 0) {
1487
+ const managedPython = runtimePythonPath(cacheDir);
1488
+ const installHint = python === managedPython ? "Run `tt models setup-runtime --force` to repair it." : "Run `tt models setup-runtime` first, or pass --python <path> to a Python environment with the serving dependencies installed.";
1489
+ throw new Error(
1490
+ `Python serving runtime is missing: ${missing.join(", ")}. ${installHint}`
1491
+ );
1492
+ }
1493
+ }
1494
+ function setupRuntimeCommands(python, venvDir, deps) {
1495
+ const venvPython = process.platform === "win32" ? join2(venvDir, "Scripts", "python.exe") : join2(venvDir, "bin", "python");
1496
+ return [
1497
+ [python, "-m", "venv", venvDir],
1498
+ [venvPython, "-m", "pip", "install", "--upgrade", "pip"],
1499
+ [venvPython, "-m", "pip", "install", ...deps]
1500
+ ];
1501
+ }
1502
+ function safeSegment(value) {
1503
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "model";
1504
+ }
1505
+ function directoryHasFiles(path) {
1506
+ return existsSync4(path) && statSync3(path).isDirectory() && readdirSync(path).length > 0;
1507
+ }
1508
+ function extractArchive(archivePath, targetDir, force) {
1509
+ if (directoryHasFiles(targetDir) && !force) return targetDir;
1510
+ if (existsSync4(targetDir)) rmSync(targetDir, { recursive: true, force: true });
1511
+ mkdirSync2(targetDir, { recursive: true });
1512
+ execFileSync("tar", ["-xzf", archivePath, "-C", targetDir], { stdio: "inherit" });
1513
+ return targetDir;
1514
+ }
1515
+ function localArchiveCacheDir(archivePath, cacheDir) {
1516
+ const resolved = resolve2(archivePath);
1517
+ const digest = createHash("sha256").update(resolved).digest("hex").slice(0, 12);
1518
+ return join2(cacheDir, `local-${safeSegment(basename2(archivePath))}-${digest}`);
1519
+ }
1520
+ function buildSystemPrompt(spec) {
1521
+ const parts = [];
1522
+ if (spec.system_prompt?.trim()) parts.push(spec.system_prompt.trim());
1523
+ if (Array.isArray(spec.guidelines) && spec.guidelines.length > 0) {
1524
+ parts.push(`Guidelines:
1525
+ ${spec.guidelines.map((g) => `- ${g}`).join("\n")}`);
1526
+ }
1527
+ if (Array.isArray(spec.constraints) && spec.constraints.length > 0) {
1528
+ parts.push(`Constraints:
1529
+ ${spec.constraints.map((c) => `- ${c}`).join("\n")}`);
1530
+ }
1531
+ return parts.join("\n\n");
1532
+ }
1533
+ function findSpecPath(explicitPath, modelPath) {
1534
+ const candidates = explicitPath ? [explicitPath] : [join2(process.cwd(), DEFAULT_SPEC_FILE), join2(modelPath, DEFAULT_SPEC_FILE)];
1535
+ for (const candidate of candidates) {
1536
+ const resolved = resolve2(candidate);
1537
+ if (existsSync4(resolved) && statSync3(resolved).isFile()) return resolved;
1538
+ }
1539
+ return null;
1540
+ }
1541
+ function loadSystemPromptFromSpec(specPath) {
1542
+ const spec = JSON.parse(readFileSync6(specPath, "utf8"));
1543
+ return buildSystemPrompt(spec);
1544
+ }
1545
+ function writeReferenceServerScript(cacheDir) {
1546
+ const scriptDir = join2(cacheDir, "_server");
1547
+ mkdirSync2(scriptDir, { recursive: true });
1548
+ const scriptPath = join2(scriptDir, "openai_reference_server.py");
1549
+ writeFileSync3(scriptPath, REFERENCE_SERVER_SCRIPT, "utf8");
1550
+ return scriptPath;
1551
+ }
1552
+ async function resolveServeTarget(target, opts, cacheDir, forceDownload) {
1553
+ if (existsSync4(target)) {
1554
+ const resolved = resolve2(target);
1555
+ const stats = statSync3(resolved);
1556
+ if (stats.isDirectory()) {
1557
+ return {
1558
+ modelPath: resolved,
1559
+ modelName: basename2(resolved),
1560
+ source: "local-directory"
1561
+ };
1562
+ }
1563
+ if (stats.isFile() && (resolved.endsWith(".tar.gz") || resolved.endsWith(".tgz"))) {
1564
+ const extractDir2 = join2(localArchiveCacheDir(resolved, cacheDir), "model");
1565
+ return {
1566
+ modelPath: extractArchive(resolved, extractDir2, forceDownload),
1567
+ modelName: basename2(resolved).replace(/\.t(ar\.)?gz$/, ""),
1568
+ source: "local-archive"
1569
+ };
1570
+ }
1571
+ throw new Error(`Unsupported model path: ${target}. Use a model directory or .tar.gz artifact.`);
1572
+ }
1573
+ const fullId = await resolveModelId(target, opts);
1574
+ const { data: model } = await get(`/models/${fullId}`, void 0, opts);
1575
+ const { data } = await get(`/models/${fullId}/download`, void 0, opts);
1576
+ const modelCacheDir = join2(cacheDir, fullId);
1577
+ const archivePath = join2(modelCacheDir, data.filename);
1578
+ const extractDir = join2(modelCacheDir, "model");
1579
+ if (!existsSync4(archivePath) || forceDownload) {
1580
+ await downloadUrlToFile(data.url, archivePath);
1581
+ }
1582
+ extractArchive(archivePath, extractDir, forceDownload);
1583
+ return {
1584
+ modelPath: extractDir,
1585
+ modelName: model.name || fullId,
1586
+ source: "downloaded-model",
1587
+ modelId: fullId
1588
+ };
1589
+ }
1590
+ function quoteCommandPart(part) {
1591
+ if (/^[a-zA-Z0-9_./:=@-]+$/.test(part)) return part;
1592
+ return `'${part.replace(/'/g, "'\\''")}'`;
1593
+ }
1594
+ function printServeCommand(command, args, env) {
1595
+ const envKeys = Object.keys(env).sort();
1596
+ const commandLine = [command, ...args].map(quoteCommandPart).join(" ");
1597
+ if (isJsonMode()) {
1598
+ return printJson({ command, args, env_keys: envKeys, command_line: commandLine });
1599
+ }
1600
+ printDetail([
1601
+ ["Command", commandLine],
1602
+ ["Environment", envKeys.join(", ")]
1603
+ ]);
995
1604
  }
996
1605
  function registerModelsCommands(parent) {
997
1606
  const models = parent.command("models").description("Manage fine-tuned models");
@@ -1037,7 +1646,7 @@ function registerModelsCommands(parent) {
1037
1646
  ["Name", data.name],
1038
1647
  ["Base Model", data.base_model],
1039
1648
  ["Provider", data.provider],
1040
- ["Provider Model", data.provider_model_id],
1649
+ ["Hosted Model", data.provider_model_id],
1041
1650
  ["Description", data.description ?? void 0],
1042
1651
  ["Created", formatDate(data.created_at)]
1043
1652
  ]);
@@ -1051,7 +1660,7 @@ function registerModelsCommands(parent) {
1051
1660
  opts
1052
1661
  );
1053
1662
  const outputPath = resolveOutputPath(cmdOpts.output, data.filename);
1054
- if (existsSync3(outputPath) && !cmdOpts.force) {
1663
+ if (existsSync4(outputPath) && !cmdOpts.force) {
1055
1664
  throw new Error(`Output file already exists: ${outputPath}. Use --force to overwrite.`);
1056
1665
  }
1057
1666
  const bytes = await downloadUrlToFile(data.url, outputPath);
@@ -1067,6 +1676,113 @@ function registerModelsCommands(parent) {
1067
1676
  const size = bytes == null ? "" : ` (${bytes} bytes)`;
1068
1677
  printSuccess(`Model downloaded to ${outputPath}${size}`);
1069
1678
  });
1679
+ models.command("setup-runtime").description("Install an isolated Python runtime for local model serving").option("--python <path>", "Python 3.10-3.12 executable to create the runtime").option("--cache-dir <path>", "Cache directory for the managed runtime").option("-f, --force", "Recreate the runtime if it already exists").option("--print-command", "Print the setup commands without running them").action(async (cmdOpts) => {
1680
+ const cacheDir = resolve2(cmdOpts.cacheDir || defaultCacheDir());
1681
+ const venvDir = runtimeDir(cacheDir);
1682
+ const venvPython = runtimePythonPath(cacheDir);
1683
+ const python = pickRuntimePython(cmdOpts.python);
1684
+ const deps = ["torch", "transformers", "accelerate", "safetensors"];
1685
+ const commands = setupRuntimeCommands(python, venvDir, deps);
1686
+ if (cmdOpts.printCommand) {
1687
+ const commandLines = commands.map((cmd) => cmd.map(quoteCommandPart).join(" "));
1688
+ if (isJsonMode()) {
1689
+ return printJson({
1690
+ python,
1691
+ runtime_dir: venvDir,
1692
+ runtime_python: venvPython,
1693
+ commands: commandLines
1694
+ });
1695
+ }
1696
+ printDetail([
1697
+ ["Python", python],
1698
+ ["Runtime", venvDir],
1699
+ ["Commands", commandLines.join("\n")]
1700
+ ]);
1701
+ return;
1702
+ }
1703
+ if (existsSync4(venvDir) && cmdOpts.force) {
1704
+ rmSync(venvDir, { recursive: true, force: true });
1705
+ }
1706
+ if (!existsSync4(venvPython)) {
1707
+ mkdirSync2(cacheDir, { recursive: true });
1708
+ execFileSync(commands[0][0], commands[0].slice(1), { stdio: "inherit" });
1709
+ }
1710
+ execFileSync(commands[1][0], commands[1].slice(1), { stdio: "inherit" });
1711
+ execFileSync(commands[2][0], commands[2].slice(1), { stdio: "inherit" });
1712
+ ensureServingRuntime(venvPython, cacheDir);
1713
+ if (isJsonMode()) {
1714
+ return printJson({
1715
+ runtime_dir: venvDir,
1716
+ runtime_python: venvPython,
1717
+ installed: true,
1718
+ dependencies: deps
1719
+ });
1720
+ }
1721
+ printSuccess(`Serving runtime ready at ${venvDir}`);
1722
+ console.log(`Use it with: tt models serve <model-id> --spec ${DEFAULT_SPEC_FILE}`);
1723
+ });
1724
+ models.command("serve").description("Serve a downloaded model with an OpenAI-compatible local API").argument("<target>", "Model ID/prefix, model directory, or .tar.gz artifact").option("--spec <path>", "Behavior spec JSON to apply as the default system prompt").option("--no-spec-prompt", "Do not auto-apply a behavior spec system prompt").option("--host <host>", "Host to bind", "127.0.0.1").option("--port <port>", "Port to bind", "8000").option("--python <path>", "Python executable").option("--cache-dir <path>", "Cache directory for downloaded/extracted models").option("--device <device>", "Inference device: auto, cpu, cuda, or mps", "auto").option("--force-download", "Re-download and re-extract model artifacts").option("--max-tokens <n>", "Default max completion tokens", "512").option("--temperature <n>", "Default sampling temperature", "0.7").option("--trust-remote-code", "Pass trust_remote_code=True to transformers").option("--print-command", "Print the underlying Python command without starting it").action(async (target, cmdOpts) => {
1725
+ const opts = parent.opts();
1726
+ const cacheDir = resolve2(cmdOpts.cacheDir || defaultCacheDir());
1727
+ mkdirSync2(cacheDir, { recursive: true });
1728
+ const python = resolveServePython(cacheDir, cmdOpts.python);
1729
+ if (!cmdOpts.printCommand) ensureServingRuntime(python, cacheDir);
1730
+ const serveTarget = await resolveServeTarget(
1731
+ target,
1732
+ opts,
1733
+ cacheDir,
1734
+ Boolean(cmdOpts.forceDownload)
1735
+ );
1736
+ const serverScript = writeReferenceServerScript(cacheDir);
1737
+ let specPath = null;
1738
+ let systemPrompt = "";
1739
+ if (cmdOpts.specPrompt !== false) {
1740
+ specPath = findSpecPath(cmdOpts.spec, serveTarget.modelPath);
1741
+ if (cmdOpts.spec && !specPath) {
1742
+ throw new Error(`Spec file not found: ${cmdOpts.spec}`);
1743
+ }
1744
+ if (specPath) {
1745
+ systemPrompt = loadSystemPromptFromSpec(specPath);
1746
+ }
1747
+ }
1748
+ const env = {
1749
+ TT_MODEL_PATH: serveTarget.modelPath,
1750
+ TT_MODEL_NAME: serveTarget.modelName,
1751
+ TT_HOST: cmdOpts.host,
1752
+ TT_PORT: String(cmdOpts.port),
1753
+ TT_MAX_TOKENS: String(cmdOpts.maxTokens),
1754
+ TT_TEMPERATURE: String(cmdOpts.temperature),
1755
+ TT_TRUST_REMOTE_CODE: cmdOpts.trustRemoteCode ? "true" : "false",
1756
+ TT_DEVICE: String(cmdOpts.device)
1757
+ };
1758
+ if (systemPrompt) env.TT_SYSTEM_PROMPT = systemPrompt;
1759
+ const args = [serverScript];
1760
+ if (cmdOpts.printCommand) return printServeCommand(python, args, env);
1761
+ if (!isJsonMode()) {
1762
+ printSuccess(`Serving ${serveTarget.modelName} from ${serveTarget.modelPath}`);
1763
+ if (systemPrompt && specPath) {
1764
+ printSuccess(`Behavior spec prompt loaded from ${specPath}`);
1765
+ } else if (cmdOpts.specPrompt !== false) {
1766
+ printWarning(
1767
+ `No ${DEFAULT_SPEC_FILE} found. Pass --spec <path> to preserve the behavior prompt.`
1768
+ );
1769
+ }
1770
+ console.log(`OpenAI-compatible endpoint: http://${cmdOpts.host}:${cmdOpts.port}/v1/chat/completions`);
1771
+ console.log(`Health check: http://${cmdOpts.host}:${cmdOpts.port}/health`);
1772
+ console.log(`Python runtime: ${python}`);
1773
+ }
1774
+ await new Promise((resolvePromise, reject) => {
1775
+ const child = spawn(python, args, {
1776
+ stdio: "inherit",
1777
+ env: { ...process.env, ...env }
1778
+ });
1779
+ child.on("error", reject);
1780
+ child.on("exit", (code, signal) => {
1781
+ if (code === 0) return resolvePromise();
1782
+ reject(new Error(`Model server exited with ${signal ?? `code ${code}`}`));
1783
+ });
1784
+ });
1785
+ });
1070
1786
  models.command("delete").description("Delete a model").argument("<id>", "Model ID (full UUID or 4+ char prefix)").action(async (id) => {
1071
1787
  const opts = parent.opts();
1072
1788
  const fullId = await resolveModelId(id, opts);
@@ -1230,53 +1946,6 @@ function registerTopupCommands(parent) {
1230
1946
  });
1231
1947
  }
1232
1948
 
1233
- // src/commands/init.ts
1234
- import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
1235
- import { resolve } from "path";
1236
- var DEFAULT_SPEC_FILE = "tunedtensor.json";
1237
- var SCAFFOLD = {
1238
- name: "My Agent",
1239
- description: "",
1240
- base_model: "Qwen/Qwen3.5-2B",
1241
- system_prompt: "You are a helpful assistant.",
1242
- guidelines: [],
1243
- constraints: [],
1244
- examples: [
1245
- { input: "Hello", output: "Hi! How can I help you today?" }
1246
- ],
1247
- eval_cases: []
1248
- };
1249
- function registerInitCommand(parent) {
1250
- 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) => {
1251
- const filePath = resolve(cmdOpts.file);
1252
- if (existsSync4(filePath)) {
1253
- printWarning(`${cmdOpts.file} already exists. Use tt eval to validate it or edit it directly.`);
1254
- return;
1255
- }
1256
- const spec = { ...SCAFFOLD };
1257
- if (cmdOpts.name) spec.name = cmdOpts.name;
1258
- if (cmdOpts.model) spec.base_model = canonicalizeBaseModel(cmdOpts.model);
1259
- writeFileSync2(filePath, JSON.stringify(spec, null, 2) + "\n");
1260
- printSuccess(`Created ${cmdOpts.file}`);
1261
- console.log("\nNext steps:");
1262
- console.log(" 1. Edit the spec: system_prompt, guidelines, examples");
1263
- console.log(" 2. Validate spec: tt eval");
1264
- console.log(" 3. Push to remote: tt push");
1265
- });
1266
- }
1267
- function loadSpec(filePath) {
1268
- const resolved = resolve(filePath || DEFAULT_SPEC_FILE);
1269
- if (!existsSync4(resolved)) {
1270
- printError(
1271
- `Spec file not found: ${filePath || DEFAULT_SPEC_FILE}
1272
- Run \`tt init\` to create one.`
1273
- );
1274
- process.exit(1);
1275
- }
1276
- const raw = JSON.parse(readFileSync5(resolved, "utf-8"));
1277
- return raw;
1278
- }
1279
-
1280
1949
  // src/commands/eval.ts
1281
1950
  import chalk5 from "chalk";
1282
1951
 
@@ -1388,12 +2057,12 @@ function printValidation(checks) {
1388
2057
  }
1389
2058
 
1390
2059
  // src/commands/push.ts
1391
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
1392
- import { resolve as resolve2 } from "path";
2060
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
2061
+ import { resolve as resolve3 } from "path";
1393
2062
  function registerPushCommand(parent) {
1394
2063
  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) => {
1395
2064
  const opts = parent.opts();
1396
- const filePath = resolve2(cmdOpts.file);
2065
+ const filePath = resolve3(cmdOpts.file);
1397
2066
  const spec = loadSpec(cmdOpts.file);
1398
2067
  const { id, eval_cases, ...rawBody } = spec;
1399
2068
  const body = canonicalizeSpecBaseModel(rawBody);
@@ -1404,9 +2073,9 @@ function registerPushCommand(parent) {
1404
2073
  } else {
1405
2074
  const res = await post("/behavior-specs", body, opts);
1406
2075
  data = res.data;
1407
- const raw = JSON.parse(readFileSync6(filePath, "utf-8"));
2076
+ const raw = JSON.parse(readFileSync7(filePath, "utf-8"));
1408
2077
  raw.id = data.id;
1409
- writeFileSync3(filePath, JSON.stringify(raw, null, 2) + "\n");
2078
+ writeFileSync4(filePath, JSON.stringify(raw, null, 2) + "\n");
1410
2079
  }
1411
2080
  if (isJsonMode()) return printJson(data);
1412
2081
  printSuccess(
@@ -1417,7 +2086,7 @@ function registerPushCommand(parent) {
1417
2086
 
1418
2087
  // src/index.ts
1419
2088
  var program = new Command();
1420
- program.name("tt").description("Tuned Tensor CLI \u2014 fine-tune and evaluate LLMs").version("0.4.10").option("-k, --api-key <key>", "API key (overrides stored key)").option(
2089
+ program.name("tt").description("Tuned Tensor CLI \u2014 fine-tune and evaluate LLMs").version("0.4.12").option("-k, --api-key <key>", "API key (overrides stored key)").option(
1421
2090
  "-u, --base-url <url>",
1422
2091
  "API base URL (default: https://tunedtensor.com)"
1423
2092
  ).option("--json", "Output raw JSON").option("--no-color", "Disable colors").hook("preAction", (_thisCommand, actionCommand) => {