@genrtl/grtl 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +25 -6
  2. package/dist/index.js +505 -32
  3. package/package.json +6 -2
package/README.md CHANGED
@@ -54,12 +54,31 @@ grtl verification-search "Verify an async FIFO"
54
54
  grtl debug-search "Explain this Vivado CDC warning"
55
55
  ```
56
56
 
57
- Use `--json` for structured output. `--type` accepts `spec2rtl`, `spec2plan`, `verification`, or `debug`; other filters include
58
- `--domain`, `--tool`, `--tool-version`, `--error-type`, `--severity`,
59
- `--interface`, `--target`, `--tag`, `--top-k`, `--min-score`, and
60
- `--workspace-id`.
61
-
62
- ## Development
57
+ Use `--json` for structured output. `--type` accepts `spec2rtl`, `spec2plan`, `verification`, or `debug`; other filters include
58
+ `--domain`, `--tool`, `--tool-version`, `--error-type`, `--severity`,
59
+ `--interface`, `--target`, `--tag`, `--top-k`, `--min-score`, and
60
+ `--workspace-id`.
61
+
62
+ ## CBB Installation
63
+
64
+ Install an exact reusable RTL CBB version into the current project:
65
+
66
+ ```bash
67
+ grtl cbb install cbb_uart@1.2.0
68
+ grtl cbb install cbb_uart@1.2.0 --target rtl/vendor/uart
69
+ ```
70
+
71
+ The command calls the hosted `genrtl_cbb_acquire` MCP tool with
72
+ `GRTL_API_KEY`, downloads the short-lived ZIP artifact, verifies its size and
73
+ SHA-256, safely extracts it, and atomically installs it. The default target is
74
+ `rtl/cbb/<cbb_id>_<version>`.
75
+
76
+ Installed packages are recorded in `.genrtl/cbb-lock.json`. Existing targets
77
+ are left untouched unless `--force` is provided; replacement occurs only after
78
+ the new archive has been fully verified and extracted. Use `--json` for
79
+ machine-readable output.
80
+
81
+ ## Development
63
82
 
64
83
  ```bash
65
84
  pnpm install
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { Command as Command2 } from "commander";
5
- import pc6 from "picocolors";
5
+ import pc7 from "picocolors";
6
6
  import figlet from "figlet";
7
7
 
8
8
  // src/commands/setup.ts
@@ -859,6 +859,9 @@ import { InvalidArgumentError } from "commander";
859
859
  import pc4 from "picocolors";
860
860
  import ora2 from "ora";
861
861
 
862
+ // src/utils/knowledge-api.ts
863
+ import { randomUUID } from "crypto";
864
+
862
865
  // src/constants.ts
863
866
  import { readFileSync } from "fs";
864
867
  import { fileURLToPath } from "url";
@@ -901,7 +904,7 @@ function getStructuredMcpError(content) {
901
904
  code: typeof errorContent.code === "string" ? errorContent.code : void 0
902
905
  };
903
906
  }
904
- async function callGenrtlKnowledgeTool(toolName, input) {
907
+ async function callGenrtlMcpTool(toolName, input) {
905
908
  const apiKey = getApiKey();
906
909
  if (!apiKey) {
907
910
  throw new Error("Authentication required. Set GRTL_API_KEY or GENRTL_API_KEY.");
@@ -933,14 +936,18 @@ async function callGenrtlKnowledgeTool(toolName, input) {
933
936
  if (!result) throw new Error("GenRTL MCP returned an empty result.");
934
937
  if (result.isError) {
935
938
  const structuredError = getStructuredMcpError(result.structuredContent);
936
- const message = structuredError?.error || getMcpErrorMessage(result.content) || "GenRTL knowledge search failed.";
939
+ const message = structuredError?.error || getMcpErrorMessage(result.content) || "GenRTL MCP tool call failed.";
937
940
  throw new Error(structuredError?.code ? `${message} (${structuredError.code})` : message);
938
941
  }
939
942
  if (!result.structuredContent) {
940
- throw new Error("GenRTL MCP response did not include structured knowledge results.");
943
+ throw new Error("GenRTL MCP response did not include structured results.");
941
944
  }
942
945
  return result.structuredContent;
943
946
  }
947
+ async function callGenrtlKnowledgeTool(toolName, input) {
948
+ const requestInput = input.idempotency_key ? input : { ...input, idempotency_key: randomUUID() };
949
+ return callGenrtlMcpTool(toolName, requestInput);
950
+ }
944
951
 
945
952
  // src/commands/knowledge.ts
946
953
  var isTTY = process.stdout.isTTY;
@@ -1085,23 +1092,485 @@ function registerKnowledgeCommands(program2) {
1085
1092
  }
1086
1093
  }
1087
1094
 
1095
+ // src/commands/cbb.ts
1096
+ import ora3 from "ora";
1097
+ import pc5 from "picocolors";
1098
+
1099
+ // src/utils/cbb-install.ts
1100
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
1101
+ import { createWriteStream } from "fs";
1102
+ import {
1103
+ access as access3,
1104
+ mkdir as mkdir4,
1105
+ mkdtemp,
1106
+ open,
1107
+ readFile as readFile3,
1108
+ realpath,
1109
+ rename,
1110
+ rm as rm2,
1111
+ writeFile as writeFile4
1112
+ } from "fs/promises";
1113
+ import { dirname as dirname6, isAbsolute, join as join4, relative, resolve as resolve3, sep } from "path";
1114
+ import { Transform } from "stream";
1115
+ import { pipeline } from "stream/promises";
1116
+ import yauzl from "yauzl";
1117
+ var MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
1118
+ var MAX_EXTRACTED_BYTES = 512 * 1024 * 1024;
1119
+ var MAX_ENTRY_BYTES = 128 * 1024 * 1024;
1120
+ var MAX_COMPRESSION_RATIO = 200;
1121
+ var MAX_ZIP_ENTRIES = 1e4;
1122
+ function pathExists2(path) {
1123
+ return access3(path).then(
1124
+ () => true,
1125
+ () => false
1126
+ );
1127
+ }
1128
+ function safePathPart(value) {
1129
+ return value.replace(/[^A-Za-z0-9._-]+/g, "_");
1130
+ }
1131
+ function parseCbbSpec(spec) {
1132
+ const separator = spec.lastIndexOf("@");
1133
+ if (separator <= 0 || separator === spec.length - 1) {
1134
+ throw new Error("CBB must use the form <cbb_id>@<version>.");
1135
+ }
1136
+ const cbbId = spec.slice(0, separator).trim();
1137
+ const version = spec.slice(separator + 1).trim();
1138
+ if (!cbbId || !version) {
1139
+ throw new Error("CBB must use the form <cbb_id>@<version>.");
1140
+ }
1141
+ return { cbbId, version };
1142
+ }
1143
+ function defaultCbbTarget(cbbId, version) {
1144
+ return `rtl/cbb/${safePathPart(cbbId)}_${safePathPart(version)}`;
1145
+ }
1146
+ function normalizeRelativeTarget(value) {
1147
+ const slashPath = value.trim().replace(/\\/g, "/").replace(/\/+/g, "/");
1148
+ if (!slashPath || slashPath.includes("\0") || slashPath.startsWith("/") || /^[A-Za-z]:/.test(slashPath)) {
1149
+ throw new Error("Target must be a non-empty project-relative path.");
1150
+ }
1151
+ const parts = slashPath.split("/").filter((part) => part && part !== ".");
1152
+ if (parts.length === 0 || parts.some(
1153
+ (part) => part === ".." || part.length > 255 || part.includes(":") || part.endsWith(".") || part.endsWith(" ") || windowsReservedName(part)
1154
+ )) {
1155
+ throw new Error("Target must not escape the project directory.");
1156
+ }
1157
+ return parts.join("/");
1158
+ }
1159
+ function resolveProjectTarget(projectRoot, targetDir) {
1160
+ const targetPath = resolve3(projectRoot, ...targetDir.split("/"));
1161
+ const relativePath = relative(projectRoot, targetPath);
1162
+ if (!relativePath || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
1163
+ throw new Error("Target must resolve inside the project directory.");
1164
+ }
1165
+ return targetPath;
1166
+ }
1167
+ async function readLockfile(projectRoot) {
1168
+ const lockPath = join4(projectRoot, ".genrtl", "cbb-lock.json");
1169
+ if (!await pathExists2(lockPath)) {
1170
+ return { schema_version: 1, packages: {} };
1171
+ }
1172
+ let parsed;
1173
+ try {
1174
+ parsed = JSON.parse(await readFile3(lockPath, "utf8"));
1175
+ } catch {
1176
+ throw new Error(`Invalid CBB lockfile: ${lockPath}`);
1177
+ }
1178
+ if (!parsed || typeof parsed !== "object" || parsed.schema_version !== 1 || !parsed.packages || typeof parsed.packages !== "object") {
1179
+ throw new Error(`Unsupported CBB lockfile format: ${lockPath}`);
1180
+ }
1181
+ return parsed;
1182
+ }
1183
+ async function prepareCbbInstall(projectRootInput, targetInput, cbbId, version, force) {
1184
+ const projectRoot = await realpath(resolve3(projectRootInput));
1185
+ const targetDir = normalizeRelativeTarget(targetInput);
1186
+ const targetPath = resolveProjectTarget(projectRoot, targetDir);
1187
+ if (!force && await pathExists2(targetPath)) {
1188
+ throw new Error(`Target already exists: ${targetPath}. Use --force to replace it.`);
1189
+ }
1190
+ await readLockfile(projectRoot);
1191
+ const projectHash = createHash("sha256").update(projectRoot).digest("hex");
1192
+ const operationHash = createHash("sha256").update(`${projectRoot}\0${targetDir}\0${cbbId}\0${version}`).digest("hex");
1193
+ return {
1194
+ projectRoot,
1195
+ targetDir,
1196
+ targetPath,
1197
+ workspaceId: `cli:${projectHash.slice(0, 40)}`,
1198
+ jobId: `cbb-install:${safePathPart(cbbId)}@${safePathPart(version)}:${operationHash.slice(0, 16)}`,
1199
+ idempotencyKey: `grtl-cbb-install:${operationHash}`
1200
+ };
1201
+ }
1202
+ async function writeAll(file, chunk) {
1203
+ let offset = 0;
1204
+ while (offset < chunk.byteLength) {
1205
+ const { bytesWritten } = await file.write(chunk, offset, chunk.byteLength - offset, null);
1206
+ if (bytesWritten <= 0) throw new Error("Failed to write downloaded artifact.");
1207
+ offset += bytesWritten;
1208
+ }
1209
+ }
1210
+ async function downloadArtifact(descriptor, archivePath) {
1211
+ if (!Number.isSafeInteger(descriptor.file_size) || descriptor.file_size <= 0 || descriptor.file_size > MAX_ARCHIVE_BYTES) {
1212
+ throw new Error("CBB archive size is invalid or exceeds the 512 MiB limit.");
1213
+ }
1214
+ if (!/^[a-fA-F0-9]{64}$/.test(descriptor.sha256)) {
1215
+ throw new Error("CBB archive SHA256 is invalid.");
1216
+ }
1217
+ const response = await fetch(descriptor.artifact_url, {
1218
+ redirect: "follow",
1219
+ headers: { Accept: "application/zip, application/octet-stream" }
1220
+ });
1221
+ if (!response.ok || !response.body) {
1222
+ const message = (await response.text().catch(() => "")).slice(0, 500);
1223
+ throw new Error(
1224
+ `CBB download failed with HTTP ${response.status}${message ? `: ${message}` : ""}`
1225
+ );
1226
+ }
1227
+ const contentLength = Number(response.headers.get("content-length"));
1228
+ if (Number.isFinite(contentLength) && contentLength > MAX_ARCHIVE_BYTES) {
1229
+ throw new Error("CBB download exceeds the 512 MiB limit.");
1230
+ }
1231
+ const hash = createHash("sha256");
1232
+ const file = await open(archivePath, "wx");
1233
+ let downloaded = 0;
1234
+ try {
1235
+ const reader = response.body.getReader();
1236
+ while (true) {
1237
+ const { value, done } = await reader.read();
1238
+ if (done) break;
1239
+ downloaded += value.byteLength;
1240
+ if (downloaded > MAX_ARCHIVE_BYTES || downloaded > descriptor.file_size) {
1241
+ await reader.cancel();
1242
+ throw new Error("CBB download exceeded the declared archive size.");
1243
+ }
1244
+ hash.update(value);
1245
+ await writeAll(file, value);
1246
+ }
1247
+ } finally {
1248
+ await file.close();
1249
+ }
1250
+ if (downloaded !== descriptor.file_size) {
1251
+ throw new Error(
1252
+ `CBB archive size mismatch: expected ${descriptor.file_size}, received ${downloaded}.`
1253
+ );
1254
+ }
1255
+ const actualSha256 = hash.digest("hex");
1256
+ if (actualSha256 !== descriptor.sha256.toLowerCase()) {
1257
+ throw new Error("CBB archive SHA256 verification failed.");
1258
+ }
1259
+ }
1260
+ function windowsReservedName(part) {
1261
+ const stem = part.split(".")[0].toUpperCase();
1262
+ return /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/.test(stem);
1263
+ }
1264
+ function validateZipEntryPath(entryName) {
1265
+ const slashPath = entryName.replace(/\\/g, "/");
1266
+ if (!slashPath || slashPath.includes("\0") || slashPath.startsWith("/") || /^[A-Za-z]:/.test(slashPath) || slashPath.length > 4096) {
1267
+ throw new Error(`Unsafe ZIP entry path: ${entryName}`);
1268
+ }
1269
+ const directory = slashPath.endsWith("/");
1270
+ const parts = slashPath.split("/").filter(Boolean);
1271
+ if (parts.length === 0 || parts.some(
1272
+ (part) => part === "." || part === ".." || part.length > 255 || part.includes(":") || part.endsWith(".") || part.endsWith(" ") || windowsReservedName(part)
1273
+ )) {
1274
+ throw new Error(`Unsafe ZIP entry path: ${entryName}`);
1275
+ }
1276
+ return `${parts.join("/")}${directory ? "/" : ""}`;
1277
+ }
1278
+ function openZip(path) {
1279
+ return new Promise((resolvePromise, reject) => {
1280
+ yauzl.open(path, { lazyEntries: true, autoClose: true }, (error, zipFile) => {
1281
+ if (error || !zipFile) reject(error || new Error("Unable to open ZIP archive."));
1282
+ else resolvePromise(zipFile);
1283
+ });
1284
+ });
1285
+ }
1286
+ function entryFileType(entry) {
1287
+ return entry.externalFileAttributes >>> 16 & 61440;
1288
+ }
1289
+ function extractEntry(zipFile, entry, outputPath, extractedBytes) {
1290
+ return new Promise((resolvePromise, reject) => {
1291
+ zipFile.openReadStream(entry, (error, stream) => {
1292
+ if (error || !stream) {
1293
+ reject(error || new Error(`Unable to read ZIP entry: ${entry.fileName}`));
1294
+ return;
1295
+ }
1296
+ let entryBytes = 0;
1297
+ const limiter = new Transform({
1298
+ transform(chunk, _encoding, callback) {
1299
+ entryBytes += chunk.length;
1300
+ extractedBytes.value += chunk.length;
1301
+ if (entryBytes > MAX_ENTRY_BYTES || extractedBytes.value > MAX_EXTRACTED_BYTES) {
1302
+ callback(new Error("CBB archive exceeds safe extraction limits."));
1303
+ return;
1304
+ }
1305
+ callback(null, chunk);
1306
+ }
1307
+ });
1308
+ pipeline(stream, limiter, createWriteStream(outputPath, { flags: "wx", mode: 420 })).then(
1309
+ () => {
1310
+ if (entryBytes !== entry.uncompressedSize) {
1311
+ reject(new Error(`ZIP entry size mismatch: ${entry.fileName}`));
1312
+ } else {
1313
+ resolvePromise();
1314
+ }
1315
+ },
1316
+ reject
1317
+ );
1318
+ });
1319
+ });
1320
+ }
1321
+ async function extractZipSafely(archivePath, destination) {
1322
+ const zipFile = await openZip(archivePath);
1323
+ if (zipFile.entryCount > MAX_ZIP_ENTRIES) {
1324
+ zipFile.close();
1325
+ throw new Error(`CBB archive contains more than ${MAX_ZIP_ENTRIES} entries.`);
1326
+ }
1327
+ await mkdir4(destination, { recursive: true });
1328
+ const destinationRoot = `${resolve3(destination)}${sep}`;
1329
+ const seenPaths = /* @__PURE__ */ new Set();
1330
+ const extractedBytes = { value: 0 };
1331
+ await new Promise((resolvePromise, reject) => {
1332
+ let settled = false;
1333
+ const fail = (error) => {
1334
+ if (settled) return;
1335
+ settled = true;
1336
+ zipFile.close();
1337
+ reject(error instanceof Error ? error : new Error(String(error)));
1338
+ };
1339
+ zipFile.on("error", fail);
1340
+ zipFile.on("end", () => {
1341
+ if (settled) return;
1342
+ settled = true;
1343
+ resolvePromise();
1344
+ });
1345
+ zipFile.on("entry", (entry) => {
1346
+ void (async () => {
1347
+ if ((entry.generalPurposeBitFlag & 1) !== 0) {
1348
+ throw new Error(`Encrypted ZIP entries are not supported: ${entry.fileName}`);
1349
+ }
1350
+ const normalizedName = validateZipEntryPath(entry.fileName);
1351
+ const collisionKey = normalizedName.toLowerCase();
1352
+ if (seenPaths.has(collisionKey)) {
1353
+ throw new Error(`Duplicate ZIP entry path: ${entry.fileName}`);
1354
+ }
1355
+ seenPaths.add(collisionKey);
1356
+ if (entry.uncompressedSize > MAX_ENTRY_BYTES || extractedBytes.value + entry.uncompressedSize > MAX_EXTRACTED_BYTES) {
1357
+ throw new Error("CBB archive exceeds safe extraction limits.");
1358
+ }
1359
+ if (entry.compressedSize > 0 && entry.uncompressedSize > 1024 * 1024 && entry.uncompressedSize / entry.compressedSize > MAX_COMPRESSION_RATIO) {
1360
+ throw new Error(`Suspicious ZIP compression ratio: ${entry.fileName}`);
1361
+ }
1362
+ const fileType = entryFileType(entry);
1363
+ if (fileType === 40960) {
1364
+ throw new Error(`Symbolic links are not allowed in CBB archives: ${entry.fileName}`);
1365
+ }
1366
+ const isDirectory = normalizedName.endsWith("/") || fileType === 16384;
1367
+ if (fileType !== 0 && fileType !== 16384 && fileType !== 32768) {
1368
+ throw new Error(`Special files are not allowed in CBB archives: ${entry.fileName}`);
1369
+ }
1370
+ const outputPath = resolve3(destination, ...normalizedName.split("/").filter(Boolean));
1371
+ if (!outputPath.startsWith(destinationRoot)) {
1372
+ throw new Error(`ZIP entry escapes the target directory: ${entry.fileName}`);
1373
+ }
1374
+ if (isDirectory) {
1375
+ await mkdir4(outputPath, { recursive: true });
1376
+ } else {
1377
+ await mkdir4(dirname6(outputPath), { recursive: true });
1378
+ await extractEntry(zipFile, entry, outputPath, extractedBytes);
1379
+ }
1380
+ zipFile.readEntry();
1381
+ })().catch(fail);
1382
+ });
1383
+ zipFile.readEntry();
1384
+ });
1385
+ }
1386
+ async function validatePackageManifest(stagingPath, cbbId, version) {
1387
+ for (const filename of ["manifest.json", "cbb.json", "CBB.json"]) {
1388
+ const manifestPath = join4(stagingPath, filename);
1389
+ if (!await pathExists2(manifestPath)) continue;
1390
+ let manifest;
1391
+ try {
1392
+ manifest = JSON.parse(await readFile3(manifestPath, "utf8"));
1393
+ } catch {
1394
+ throw new Error(`Invalid package manifest: ${filename}`);
1395
+ }
1396
+ if (!manifest || typeof manifest !== "object") {
1397
+ throw new Error(`Invalid package manifest: ${filename}`);
1398
+ }
1399
+ const record = manifest;
1400
+ const manifestId = typeof record.id === "string" ? record.id : typeof record.cbb_id === "string" ? record.cbb_id : void 0;
1401
+ if (manifestId && manifestId !== cbbId) {
1402
+ throw new Error(`CBB manifest ID mismatch: expected ${cbbId}, found ${manifestId}.`);
1403
+ }
1404
+ if (typeof record.version === "string" && record.version !== version) {
1405
+ throw new Error(
1406
+ `CBB manifest version mismatch: expected ${version}, found ${record.version}.`
1407
+ );
1408
+ }
1409
+ return;
1410
+ }
1411
+ }
1412
+ async function writeLockfile(projectRoot, cbbId, entry) {
1413
+ const lockfile = await readLockfile(projectRoot);
1414
+ lockfile.packages[cbbId] = entry;
1415
+ const lockDir = join4(projectRoot, ".genrtl");
1416
+ const lockPath = join4(lockDir, "cbb-lock.json");
1417
+ const tempPath = join4(lockDir, `.cbb-lock-${randomUUID2()}.tmp`);
1418
+ const backupPath = join4(lockDir, `.cbb-lock-${randomUUID2()}.bak`);
1419
+ await mkdir4(lockDir, { recursive: true });
1420
+ await writeFile4(tempPath, `${JSON.stringify(lockfile, null, 2)}
1421
+ `, "utf8");
1422
+ const hadLockfile = await pathExists2(lockPath);
1423
+ try {
1424
+ if (hadLockfile) await rename(lockPath, backupPath);
1425
+ await rename(tempPath, lockPath);
1426
+ if (hadLockfile) await rm2(backupPath, { force: true });
1427
+ } catch (error) {
1428
+ await rm2(tempPath, { force: true }).catch(() => {
1429
+ });
1430
+ if (hadLockfile && await pathExists2(backupPath)) {
1431
+ await rename(backupPath, lockPath).catch(() => {
1432
+ });
1433
+ }
1434
+ throw error;
1435
+ }
1436
+ }
1437
+ async function installCbbArtifact(descriptor, plan, force) {
1438
+ if (descriptor.format !== "zip") {
1439
+ throw new Error(`Unsupported CBB artifact format: ${descriptor.format}`);
1440
+ }
1441
+ const targetParent = dirname6(plan.targetPath);
1442
+ await mkdir4(targetParent, { recursive: true });
1443
+ const tempRoot = await mkdtemp(join4(targetParent, ".grtl-cbb-"));
1444
+ const archivePath = join4(tempRoot, "artifact.zip");
1445
+ const stagingPath = join4(tempRoot, "content");
1446
+ const backupPath = `${plan.targetPath}.grtl-backup-${randomUUID2()}`;
1447
+ let backupCreated = false;
1448
+ let installed = false;
1449
+ try {
1450
+ await downloadArtifact(descriptor, archivePath);
1451
+ await extractZipSafely(archivePath, stagingPath);
1452
+ await validatePackageManifest(stagingPath, descriptor.cbb_id, descriptor.version);
1453
+ if (await pathExists2(plan.targetPath)) {
1454
+ if (!force) {
1455
+ throw new Error(`Target already exists: ${plan.targetPath}. Use --force to replace it.`);
1456
+ }
1457
+ await rename(plan.targetPath, backupPath);
1458
+ backupCreated = true;
1459
+ }
1460
+ await rename(stagingPath, plan.targetPath);
1461
+ installed = true;
1462
+ await writeLockfile(plan.projectRoot, descriptor.cbb_id, {
1463
+ version: descriptor.version,
1464
+ target: plan.targetDir,
1465
+ sha256: descriptor.sha256.toLowerCase(),
1466
+ file_size: descriptor.file_size,
1467
+ receipt_id: descriptor.receipt_id,
1468
+ installed_at: (/* @__PURE__ */ new Date()).toISOString()
1469
+ });
1470
+ if (backupCreated) {
1471
+ await rm2(backupPath, { recursive: true, force: true }).catch(() => {
1472
+ });
1473
+ }
1474
+ return {
1475
+ cbb_id: descriptor.cbb_id,
1476
+ version: descriptor.version,
1477
+ target: plan.targetDir,
1478
+ target_path: plan.targetPath,
1479
+ sha256: descriptor.sha256.toLowerCase(),
1480
+ file_size: descriptor.file_size,
1481
+ receipt_id: descriptor.receipt_id,
1482
+ trace_id: descriptor.trace_id
1483
+ };
1484
+ } catch (error) {
1485
+ if (installed) {
1486
+ await rm2(plan.targetPath, { recursive: true, force: true }).catch(() => {
1487
+ });
1488
+ }
1489
+ if (backupCreated && await pathExists2(backupPath)) {
1490
+ await rename(backupPath, plan.targetPath).catch(() => {
1491
+ });
1492
+ }
1493
+ throw error;
1494
+ } finally {
1495
+ await rm2(tempRoot, { recursive: true, force: true }).catch(() => {
1496
+ });
1497
+ }
1498
+ }
1499
+
1500
+ // src/commands/cbb.ts
1501
+ async function installCbb(spec, options) {
1502
+ trackEvent("command", { name: "cbb-install" });
1503
+ const spinner = process.stdout.isTTY && !options.json ? ora3("Preparing CBB installation...").start() : null;
1504
+ try {
1505
+ const { cbbId, version } = parseCbbSpec(spec);
1506
+ const target = options.target || defaultCbbTarget(cbbId, version);
1507
+ const plan = await prepareCbbInstall(
1508
+ options.projectRoot || process.cwd(),
1509
+ target,
1510
+ cbbId,
1511
+ version,
1512
+ options.force || false
1513
+ );
1514
+ spinner && (spinner.text = "Acquiring CBB artifact...");
1515
+ const acquireInput = {
1516
+ cbb_id: cbbId,
1517
+ version,
1518
+ target_dir: plan.targetDir,
1519
+ workspace_id: plan.workspaceId,
1520
+ job_id: plan.jobId,
1521
+ idempotency_key: plan.idempotencyKey
1522
+ };
1523
+ const descriptor = await callGenrtlMcpTool(
1524
+ "genrtl_cbb_acquire",
1525
+ acquireInput
1526
+ );
1527
+ if (descriptor.cbb_id !== cbbId || descriptor.version !== version) {
1528
+ throw new Error("GenRTL returned a CBB artifact that does not match the request.");
1529
+ }
1530
+ spinner && (spinner.text = "Downloading and verifying CBB artifact...");
1531
+ const result = await installCbbArtifact(descriptor, plan, options.force || false);
1532
+ spinner?.succeed(`Installed ${cbbId}@${version}`);
1533
+ if (options.json) {
1534
+ console.log(JSON.stringify(result, null, 2));
1535
+ return;
1536
+ }
1537
+ log.blank();
1538
+ log.success(`Installed ${pc5.bold(`${cbbId}@${version}`)}`);
1539
+ log.item(`Target: ${result.target_path}`);
1540
+ log.item(`SHA256: ${result.sha256}`);
1541
+ log.item(`Lockfile: ${plan.projectRoot}/.genrtl/cbb-lock.json`);
1542
+ log.blank();
1543
+ } catch (error) {
1544
+ const message = error instanceof Error ? error.message : String(error);
1545
+ spinner?.fail(message);
1546
+ if (!spinner) log.error(message);
1547
+ process.exitCode = 1;
1548
+ }
1549
+ }
1550
+ function registerCbbCommands(program2) {
1551
+ const cbb = program2.command("cbb").description("Discover and install reusable RTL CBBs");
1552
+ cbb.command("install").description("Acquire, verify, and install a CBB ZIP into the current project").argument("<cbb>", "CBB coordinate in the form <cbb_id>@<version>").option("-t, --target <dir>", "Project-relative target directory").option("--project-root <dir>", "Project root (default: current directory)").option("-f, --force", "Replace an existing target after successful verification").option("--json", "Output the installation result as JSON").action(async (spec, options) => {
1553
+ await installCbb(spec, options);
1554
+ });
1555
+ }
1556
+
1088
1557
  // src/commands/upgrade.ts
1089
1558
  import { confirm } from "@inquirer/prompts";
1090
1559
  import { spawn } from "child_process";
1091
- import pc5 from "picocolors";
1560
+ import pc6 from "picocolors";
1092
1561
 
1093
1562
  // src/utils/update-check.ts
1094
1563
  import { homedir as homedir2 } from "os";
1095
- import { dirname as dirname6, join as join4 } from "path";
1096
- import { mkdir as mkdir4, readFile as readFile3, writeFile as writeFile4 } from "fs/promises";
1564
+ import { dirname as dirname7, join as join5 } from "path";
1565
+ import { mkdir as mkdir5, readFile as readFile4, writeFile as writeFile5 } from "fs/promises";
1097
1566
  var DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
1098
- var UPDATE_STATE_FILE = join4(homedir2(), ".genrtl", "cli-state.json");
1567
+ var UPDATE_STATE_FILE = join5(homedir2(), ".genrtl", "cli-state.json");
1099
1568
  function getStateFilePath(stateFile) {
1100
1569
  return stateFile ?? UPDATE_STATE_FILE;
1101
1570
  }
1102
1571
  async function readUpdateState(stateFile) {
1103
1572
  try {
1104
- const raw = await readFile3(getStateFilePath(stateFile), "utf-8");
1573
+ const raw = await readFile4(getStateFilePath(stateFile), "utf-8");
1105
1574
  return JSON.parse(raw);
1106
1575
  } catch {
1107
1576
  return {};
@@ -1109,8 +1578,8 @@ async function readUpdateState(stateFile) {
1109
1578
  }
1110
1579
  async function writeUpdateState(state, stateFile) {
1111
1580
  const path = getStateFilePath(stateFile);
1112
- await mkdir4(dirname6(path), { recursive: true });
1113
- await writeFile4(path, JSON.stringify(state, null, 2) + "\n", "utf-8");
1581
+ await mkdir5(dirname7(path), { recursive: true });
1582
+ await writeFile5(path, JSON.stringify(state, null, 2) + "\n", "utf-8");
1114
1583
  }
1115
1584
  function compareVersions(a, b) {
1116
1585
  const normalize = (version) => version.split("-", 1)[0].split(".").map((part) => Number.parseInt(part, 10) || 0);
@@ -1286,20 +1755,20 @@ function registerUpgradeCommand(program2) {
1286
1755
  });
1287
1756
  }
1288
1757
  function runCommand(command, args) {
1289
- return new Promise((resolve3, reject) => {
1758
+ return new Promise((resolve4, reject) => {
1290
1759
  const child = spawn(command, args, {
1291
1760
  stdio: "inherit",
1292
1761
  shell: process.platform === "win32"
1293
1762
  });
1294
1763
  child.on("error", reject);
1295
- child.on("close", (code) => resolve3(code));
1764
+ child.on("close", (code) => resolve4(code));
1296
1765
  });
1297
1766
  }
1298
1767
  async function runUpgradePlan(plan) {
1299
1768
  return runCommand(plan.command, plan.args);
1300
1769
  }
1301
1770
  function showUpgradeFailureHelp(plan) {
1302
- log.info(`Try rerunning: ${pc5.cyan(plan.displayCommand)}`);
1771
+ log.info(`Try rerunning: ${pc6.cyan(plan.displayCommand)}`);
1303
1772
  const isGlobalNpmInstall = (plan.installMethod === "npm-global" || plan.installMethod === "unknown") && plan.command === "npm" && plan.args.includes("-g");
1304
1773
  const isGlobalAltInstall = (plan.installMethod === "pnpm-global" || plan.installMethod === "bun-global") && plan.args.includes("-g");
1305
1774
  if (isGlobalNpmInstall) {
@@ -1326,8 +1795,8 @@ async function maybeShowUpgradeNotice(options = {}) {
1326
1795
  log.blank();
1327
1796
  if (info.upgradePlan.needsExplicitVersion) {
1328
1797
  log.box([
1329
- `${pc5.white(pc5.bold("Update available:"))} ${pc5.green(pc5.bold(`v${info.currentVersion}`))} ${pc5.dim("->")} ${pc5.green(pc5.bold(`v${info.latestVersion}`))}`,
1330
- `${pc5.white("Use")} ${pc5.yellow(pc5.bold(info.upgradePlan.displayCommand))} ${pc5.white("to run the latest version")}`
1798
+ `${pc6.white(pc6.bold("Update available:"))} ${pc6.green(pc6.bold(`v${info.currentVersion}`))} ${pc6.dim("->")} ${pc6.green(pc6.bold(`v${info.latestVersion}`))}`,
1799
+ `${pc6.white("Use")} ${pc6.yellow(pc6.bold(info.upgradePlan.displayCommand))} ${pc6.white("to run the latest version")}`
1331
1800
  ]);
1332
1801
  await markUpdateNotificationShown(info.latestVersion);
1333
1802
  log.blank();
@@ -1335,18 +1804,18 @@ async function maybeShowUpgradeNotice(options = {}) {
1335
1804
  }
1336
1805
  if (!info.upgradePlan.canRun) {
1337
1806
  log.box([
1338
- `${pc5.white(pc5.bold("Update available:"))} ${pc5.green(pc5.bold(`v${info.currentVersion}`))} ${pc5.dim("->")} ${pc5.green(pc5.bold(`v${info.latestVersion}`))}`,
1339
- `${pc5.white("Run")} ${pc5.yellow(pc5.bold("grtl upgrade"))} ${pc5.white("for update steps")}`,
1340
- `${pc5.white("Or run")} ${pc5.yellow(info.upgradePlan.displayCommand)}`
1807
+ `${pc6.white(pc6.bold("Update available:"))} ${pc6.green(pc6.bold(`v${info.currentVersion}`))} ${pc6.dim("->")} ${pc6.green(pc6.bold(`v${info.latestVersion}`))}`,
1808
+ `${pc6.white("Run")} ${pc6.yellow(pc6.bold("grtl upgrade"))} ${pc6.white("for update steps")}`,
1809
+ `${pc6.white("Or run")} ${pc6.yellow(info.upgradePlan.displayCommand)}`
1341
1810
  ]);
1342
1811
  await markUpdateNotificationShown(info.latestVersion);
1343
1812
  log.blank();
1344
1813
  return;
1345
1814
  }
1346
1815
  log.box([
1347
- `${pc5.white(pc5.bold("Update available:"))} ${pc5.green(pc5.bold(`v${info.currentVersion}`))} ${pc5.dim("->")} ${pc5.green(pc5.bold(`v${info.latestVersion}`))}`,
1348
- `${pc5.white("Run")} ${pc5.yellow(pc5.bold("grtl upgrade"))} ${pc5.white("to update now")}`,
1349
- `${pc5.white("Or run")} ${pc5.yellow(info.upgradePlan.displayCommand)}`
1816
+ `${pc6.white(pc6.bold("Update available:"))} ${pc6.green(pc6.bold(`v${info.currentVersion}`))} ${pc6.dim("->")} ${pc6.green(pc6.bold(`v${info.latestVersion}`))}`,
1817
+ `${pc6.white("Run")} ${pc6.yellow(pc6.bold("grtl upgrade"))} ${pc6.white("to update now")}`,
1818
+ `${pc6.white("Or run")} ${pc6.yellow(info.upgradePlan.displayCommand)}`
1350
1819
  ]);
1351
1820
  await markUpdateNotificationShown(info.latestVersion);
1352
1821
  log.blank();
@@ -1357,28 +1826,28 @@ async function upgradeCommand(options) {
1357
1826
  const plan = info?.upgradePlan ?? getUpgradePlan();
1358
1827
  if (!info) {
1359
1828
  log.warn("Couldn't check for updates right now.");
1360
- log.info(`Try again later or run ${pc5.cyan(plan.displayCommand)} manually.`);
1829
+ log.info(`Try again later or run ${pc6.cyan(plan.displayCommand)} manually.`);
1361
1830
  return;
1362
1831
  }
1363
1832
  if (!info.updateAvailable) {
1364
- log.success(`grtl is up to date (${pc5.bold(`v${VERSION}`)})`);
1833
+ log.success(`grtl is up to date (${pc6.bold(`v${VERSION}`)})`);
1365
1834
  return;
1366
1835
  }
1367
1836
  log.blank();
1368
1837
  log.info(
1369
- `Update available: ${pc5.bold(`v${info.currentVersion}`)} ${pc5.dim("->")} ${pc5.bold(`v${info.latestVersion}`)}`
1838
+ `Update available: ${pc6.bold(`v${info.currentVersion}`)} ${pc6.dim("->")} ${pc6.bold(`v${info.latestVersion}`)}`
1370
1839
  );
1371
1840
  if (plan.needsExplicitVersion) {
1372
1841
  log.info(`You're using an ephemeral runner (${plan.installMethod}).`);
1373
- log.info(`Use ${pc5.cyan(plan.displayCommand)} to run the latest version immediately.`);
1374
- log.info(`Or install globally with ${pc5.cyan("npm install -g @genrtl/grtl@latest")}.`);
1842
+ log.info(`Use ${pc6.cyan(plan.displayCommand)} to run the latest version immediately.`);
1843
+ log.info(`Or install globally with ${pc6.cyan("npm install -g @genrtl/grtl@latest")}.`);
1375
1844
  return;
1376
1845
  }
1377
1846
  if (!plan.canRun) {
1378
- log.info(`Run ${pc5.cyan(plan.displayCommand)} to update your installed version.`);
1847
+ log.info(`Run ${pc6.cyan(plan.displayCommand)} to update your installed version.`);
1379
1848
  return;
1380
1849
  }
1381
- log.info(`Upgrade command: ${pc5.cyan(plan.displayCommand)}`);
1850
+ log.info(`Upgrade command: ${pc6.cyan(plan.displayCommand)}`);
1382
1851
  if (options.check) {
1383
1852
  return;
1384
1853
  }
@@ -1398,7 +1867,7 @@ async function upgradeCommand(options) {
1398
1867
  if (exitCode === 0) {
1399
1868
  log.blank();
1400
1869
  log.success("Upgrade complete.");
1401
- log.info(`Run ${pc5.cyan("grtl --version")} to verify the installed version.`);
1870
+ log.info(`Run ${pc6.cyan("grtl --version")} to verify the installed version.`);
1402
1871
  return;
1403
1872
  }
1404
1873
  log.blank();
@@ -1409,8 +1878,8 @@ async function upgradeCommand(options) {
1409
1878
 
1410
1879
  // src/index.ts
1411
1880
  var brand = {
1412
- primary: pc6.green,
1413
- dim: pc6.dim
1881
+ primary: pc7.green,
1882
+ dim: pc7.dim
1414
1883
  };
1415
1884
  var program = new Command2();
1416
1885
  program.name("grtl").description("GenRTL CLI - Search RTL engineering knowledge and configure GenRTL").version(VERSION).option("--base-url <url>").hook("preAction", (thisCommand) => {
@@ -1438,10 +1907,14 @@ Examples:
1438
1907
  ${brand.primary('npx @genrtl/grtl spec2plan-search "Plan an APB register block implementation"')}
1439
1908
  ${brand.primary('npx @genrtl/grtl verification-search "Verify an async FIFO"')}
1440
1909
  ${brand.primary('npx @genrtl/grtl debug-search "Explain this Vivado CDC warning"')}
1910
+
1911
+ ${brand.dim("# Acquire and install a reusable RTL CBB")}
1912
+ ${brand.primary("npx @genrtl/grtl cbb install cbb_uart@1.2.0")}
1441
1913
  `
1442
1914
  );
1443
1915
  registerSetupCommand(program);
1444
1916
  registerKnowledgeCommands(program);
1917
+ registerCbbCommands(program);
1445
1918
  registerUpgradeCommand(program);
1446
1919
  program.action(() => {
1447
1920
  console.log("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genrtl/grtl",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "CLI, MCP, and coding-agent Skills for GenRTL RTL engineering knowledge",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,14 +25,18 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@inquirer/prompts": "^8.2.0",
28
+ "boxen": "^8.0.1",
28
29
  "commander": "^13.1.0",
29
30
  "figlet": "^1.9.4",
31
+ "open": "^10.2.0",
30
32
  "ora": "^9.0.0",
31
- "picocolors": "^1.1.1"
33
+ "picocolors": "^1.1.1",
34
+ "yauzl": "^3.2.0"
32
35
  },
33
36
  "devDependencies": {
34
37
  "@types/figlet": "^1.7.0",
35
38
  "@types/node": "^22.19.1",
39
+ "@types/yauzl": "^2.10.3",
36
40
  "@typescript-eslint/eslint-plugin": "^8.28.0",
37
41
  "@typescript-eslint/parser": "^8.28.0",
38
42
  "eslint": "^9.34.0",