@tankpkg/cli 0.8.1 → 0.9.1

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/bin/tank.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as VERSION, c as getConfigDir, i as USER_AGENT, n as flushLogs, o as logger, s as getConfig, t as authFlowLog, u as setConfig } from "../debug-logger-BjGZcIhg.js";
2
+ import { a as VERSION, c as getConfigDir, i as USER_AGENT, n as flushLogs, o as logger, s as getConfig, t as authFlowLog, u as setConfig } from "../debug-logger-nDgSC2MM.js";
3
3
  import { Command } from "commander";
4
4
  import chalk from "chalk";
5
5
  import fs from "node:fs";
@@ -1291,26 +1291,6 @@ function getResolvedNodesInOrder(nodes, installOrder) {
1291
1291
  //#endregion
1292
1292
  //#region src/lib/permission-checker.ts
1293
1293
  /**
1294
- * Check if a skill's permissions fit within the project's permission budget.
1295
- * Throws if any permission exceeds the budget.
1296
- */
1297
- function checkPermissionBudget(budget, skillPerms, skillName) {
1298
- if (!skillPerms) return;
1299
- if (skillPerms.subprocess === true && budget.subprocess !== true) throw new Error(`Permission denied: ${skillName} requires subprocess access, but project budget does not allow it`);
1300
- if (skillPerms.network?.outbound && skillPerms.network.outbound.length > 0) {
1301
- const budgetDomains = budget.network?.outbound ?? [];
1302
- for (const domain of skillPerms.network.outbound) if (!isDomainAllowed$1(domain, budgetDomains)) throw new Error(`Permission denied: ${skillName} requests network access to "${domain}", which is not in the project's permission budget`);
1303
- }
1304
- if (skillPerms.filesystem?.read && skillPerms.filesystem.read.length > 0) {
1305
- const budgetPaths = budget.filesystem?.read ?? [];
1306
- for (const p of skillPerms.filesystem.read) if (!isPathAllowed$1(p, budgetPaths)) throw new Error(`Permission denied: ${skillName} requests filesystem read access to "${p}", which is not in the project's permission budget`);
1307
- }
1308
- if (skillPerms.filesystem?.write && skillPerms.filesystem.write.length > 0) {
1309
- const budgetPaths = budget.filesystem?.write ?? [];
1310
- for (const p of skillPerms.filesystem.write) if (!isPathAllowed$1(p, budgetPaths)) throw new Error(`Permission denied: ${skillName} requests filesystem write access to "${p}", which is not in the project's permission budget`);
1311
- }
1312
- }
1313
- /**
1314
1294
  * Check if a domain is allowed by the budget's domain list.
1315
1295
  * Supports wildcard matching: *.example.com matches sub.example.com
1316
1296
  */
@@ -1339,6 +1319,91 @@ function isPathAllowed$1(requestedPath, allowedPaths) {
1339
1319
  }
1340
1320
  return false;
1341
1321
  }
1322
+ /**
1323
+ * Collect all permission violations without throwing.
1324
+ * Mirrors checkPermissionBudget() logic but returns violations as an array.
1325
+ */
1326
+ function collectPermissionViolations(budget, skillPerms, skillName) {
1327
+ if (!skillPerms) return [];
1328
+ const violations = [];
1329
+ if (skillPerms.subprocess === true && budget.subprocess !== true) violations.push({
1330
+ skillName,
1331
+ type: "subprocess",
1332
+ requested: "true"
1333
+ });
1334
+ if (skillPerms.network?.outbound && skillPerms.network.outbound.length > 0) {
1335
+ const budgetDomains = budget.network?.outbound ?? [];
1336
+ for (const domain of skillPerms.network.outbound) if (!isDomainAllowed$1(domain, budgetDomains)) violations.push({
1337
+ skillName,
1338
+ type: "network.outbound",
1339
+ requested: domain
1340
+ });
1341
+ }
1342
+ if (skillPerms.filesystem?.read && skillPerms.filesystem.read.length > 0) {
1343
+ const budgetPaths = budget.filesystem?.read ?? [];
1344
+ for (const p of skillPerms.filesystem.read) if (!isPathAllowed$1(p, budgetPaths)) violations.push({
1345
+ skillName,
1346
+ type: "filesystem.read",
1347
+ requested: p
1348
+ });
1349
+ }
1350
+ if (skillPerms.filesystem?.write && skillPerms.filesystem.write.length > 0) {
1351
+ const budgetPaths = budget.filesystem?.write ?? [];
1352
+ for (const p of skillPerms.filesystem.write) if (!isPathAllowed$1(p, budgetPaths)) violations.push({
1353
+ skillName,
1354
+ type: "filesystem.write",
1355
+ requested: p
1356
+ });
1357
+ }
1358
+ return violations;
1359
+ }
1360
+ //#endregion
1361
+ //#region src/lib/permission-prompt.ts
1362
+ async function promptForPermissionExpansion(violations, options) {
1363
+ if (options.yes === true) return "accept";
1364
+ if (options.isInteractive === false) return "decline";
1365
+ logger.warn("The following permissions exceed your project budget:");
1366
+ for (const v of violations) logger.warn(` ${v.skillName}: ${v.type} → ${v.requested}`);
1367
+ return await confirm({
1368
+ message: "Would you like to add these permissions to tank.json?",
1369
+ default: true
1370
+ }) ? "accept" : "decline";
1371
+ }
1372
+ function mergePermissionsIntoBudget(currentBudget, violations) {
1373
+ const result = {
1374
+ ...currentBudget,
1375
+ network: currentBudget.network ? {
1376
+ ...currentBudget.network,
1377
+ outbound: [...currentBudget.network.outbound ?? []]
1378
+ } : void 0,
1379
+ filesystem: currentBudget.filesystem ? {
1380
+ ...currentBudget.filesystem,
1381
+ read: currentBudget.filesystem.read ? [...currentBudget.filesystem.read] : void 0,
1382
+ write: currentBudget.filesystem.write ? [...currentBudget.filesystem.write] : void 0
1383
+ } : void 0
1384
+ };
1385
+ for (const v of violations) switch (v.type) {
1386
+ case "filesystem.read":
1387
+ if (!result.filesystem) result.filesystem = {};
1388
+ if (!result.filesystem.read) result.filesystem.read = [];
1389
+ if (!result.filesystem.read.includes(v.requested)) result.filesystem.read.push(v.requested);
1390
+ break;
1391
+ case "filesystem.write":
1392
+ if (!result.filesystem) result.filesystem = {};
1393
+ if (!result.filesystem.write) result.filesystem.write = [];
1394
+ if (!result.filesystem.write.includes(v.requested)) result.filesystem.write.push(v.requested);
1395
+ break;
1396
+ case "network.outbound":
1397
+ if (!result.network) result.network = {};
1398
+ if (!result.network.outbound) result.network.outbound = [];
1399
+ if (!result.network.outbound.includes(v.requested)) result.network.outbound.push(v.requested);
1400
+ break;
1401
+ case "subprocess":
1402
+ result.subprocess = true;
1403
+ break;
1404
+ }
1405
+ return result;
1406
+ }
1342
1407
  //#endregion
1343
1408
  //#region src/commands/install.ts
1344
1409
  function createRegistryFetcher(registry, headers) {
@@ -1432,15 +1497,30 @@ function buildLockedVersionByName(lock) {
1432
1497
  function createExtractDirResolver(directory, global, resolvedHome) {
1433
1498
  return (skillName) => global ? getGlobalExtractDir(resolvedHome, skillName) : getExtractDir$1(directory, skillName);
1434
1499
  }
1435
- function validateResolvedNodes(resolvedNodes, projectPermissions, auditMinScore) {
1500
+ async function validateResolvedNodes(resolvedNodes, projectPermissions, auditMinScore, options) {
1436
1501
  if (!projectPermissions) logger.warn(`No permission budget defined in ${MANIFEST_FILENAME}. Install proceeding without permission checks.`);
1437
- for (const node of resolvedNodes) {
1438
- if (projectPermissions) checkPermissionBudget(projectPermissions, node.meta.permissions, node.name);
1439
- if (auditMinScore !== void 0) {
1440
- if (node.meta.auditScore === null || node.meta.auditScore === void 0) logger.warn(`Audit score not yet available for ${node.name}. Install proceeding without audit score check.`);
1441
- else if (node.meta.auditScore < auditMinScore) throw new Error(`Audit score ${node.meta.auditScore} for ${node.name} is below minimum threshold ${auditMinScore} defined in ${MANIFEST_FILENAME}`);
1502
+ if (projectPermissions) {
1503
+ const allViolations = resolvedNodes.flatMap((node) => collectPermissionViolations(projectPermissions, node.meta.permissions, node.name));
1504
+ if (allViolations.length > 0) {
1505
+ const isInteractive = !process.env.CI && process.stdout.isTTY === true;
1506
+ const decision = await promptForPermissionExpansion(allViolations, {
1507
+ yes: options?.yes,
1508
+ isInteractive
1509
+ });
1510
+ if (decision === "accept" && options?.skillsJsonPath && options?.skillsJson) {
1511
+ const merged = mergePermissionsIntoBudget(projectPermissions, allViolations);
1512
+ options.skillsJson.permissions = merged;
1513
+ fs.writeFileSync(options.skillsJsonPath, `${JSON.stringify(options.skillsJson, null, 2)}\n`);
1514
+ } else if (decision === "decline") {
1515
+ const first = allViolations[0];
1516
+ throw new Error(`Permission denied: ${first.skillName} requests ${first.type} access to "${first.requested}", which is not in the project's permission budget`);
1517
+ }
1442
1518
  }
1443
1519
  }
1520
+ for (const node of resolvedNodes) if (auditMinScore !== void 0) {
1521
+ if (node.meta.auditScore === null || node.meta.auditScore === void 0) logger.warn(`Audit score not yet available for ${node.name}. Install proceeding without audit score check.`);
1522
+ else if (node.meta.auditScore < auditMinScore) throw new Error(`Audit score ${node.meta.auditScore} for ${node.name} is below minimum threshold ${auditMinScore} defined in ${MANIFEST_FILENAME}`);
1523
+ }
1444
1524
  }
1445
1525
  async function runLegacyFallback(options) {
1446
1526
  const { rootSkillNames, resolvedNodeByName, extractDirForSkill, directory, configDir, global, homedir } = options;
@@ -1490,8 +1570,12 @@ function linkInstalledRoots(options) {
1490
1570
  if (detectInstalledAgents(homedir).length === 0) logger.warn("No agents detected for linking");
1491
1571
  }
1492
1572
  async function executeInstallPipeline(options) {
1493
- const { directory, configDir, global, homedir, resolvedHome, lock, lockPath, resolvedNodes, nodesToInstall, rootSkillNames, projectPermissions, auditMinScore, spinner } = options;
1494
- if (!global) validateResolvedNodes(resolvedNodes, projectPermissions, auditMinScore);
1573
+ const { directory, configDir, global, homedir, resolvedHome, lock, lockPath, resolvedNodes, nodesToInstall, rootSkillNames, projectPermissions, auditMinScore, spinner, yes, skillsJsonPath, skillsJson } = options;
1574
+ if (!global) await validateResolvedNodes(resolvedNodes, projectPermissions, auditMinScore, {
1575
+ yes,
1576
+ skillsJsonPath,
1577
+ skillsJson
1578
+ });
1495
1579
  const extractDirForSkill = createExtractDirResolver(directory, global, resolvedHome);
1496
1580
  const resolvedNodeByName = new Map(resolvedNodes.map((node) => [node.name, node]));
1497
1581
  const downloaded = await downloadAllParallel(nodesToInstall, spinner);
@@ -1529,7 +1613,7 @@ async function executeInstallPipeline(options) {
1529
1613
  return updatedLock;
1530
1614
  }
1531
1615
  async function installCommand(options) {
1532
- const { name, versionRange = "*", directory = process.cwd(), configDir, global = false, homedir, isTransitive = false } = options;
1616
+ const { name, versionRange = "*", directory = process.cwd(), configDir, global = false, homedir, isTransitive = false, yes } = options;
1533
1617
  const config = getConfig(configDir);
1534
1618
  const resolvedHome = homedir ?? os.homedir();
1535
1619
  const requestHeaders = { "User-Agent": USER_AGENT };
@@ -1585,7 +1669,10 @@ async function installCommand(options) {
1585
1669
  rootSkillNames: [name],
1586
1670
  projectPermissions,
1587
1671
  auditMinScore,
1588
- spinner
1672
+ spinner,
1673
+ yes,
1674
+ skillsJsonPath,
1675
+ skillsJson
1589
1676
  });
1590
1677
  if (!global && !isTransitive) {
1591
1678
  const skills = skillsJson.skills ?? {};
@@ -1683,7 +1770,7 @@ async function installFromLockfile(options) {
1683
1770
  }
1684
1771
  }
1685
1772
  async function installAll(options) {
1686
- const { directory = process.cwd(), configDir, global = false, homedir } = options;
1773
+ const { directory = process.cwd(), configDir, global = false, homedir, yes } = options;
1687
1774
  const resolvedHome = homedir ?? os.homedir();
1688
1775
  const config = getConfig(configDir);
1689
1776
  const requestHeaders = { "User-Agent": USER_AGENT };
@@ -1738,7 +1825,10 @@ async function installAll(options) {
1738
1825
  rootSkillNames: skillEntries.map(([skillName]) => skillName),
1739
1826
  projectPermissions,
1740
1827
  auditMinScore,
1741
- spinner
1828
+ spinner,
1829
+ yes,
1830
+ skillsJsonPath,
1831
+ skillsJson
1742
1832
  });
1743
1833
  spinner.succeed(`Installed ${skillEntries.length} root skill${skillEntries.length === 1 ? "" : "s"}`);
1744
1834
  } catch (err) {
@@ -3294,14 +3384,18 @@ program.command("publish").alias("pub").description("Pack and publish a skill to
3294
3384
  process.exit(1);
3295
3385
  }
3296
3386
  });
3297
- program.command("install").alias("i").description("Install a skill from the Tank registry, or all skills from lockfile").argument("[name]", "Skill name (e.g., @org/skill-name). Omit to install from lockfile.").argument("[version-range]", "Semver range (default: *)", "*").option("-g, --global", "Install skill globally (available to all projects)").action(async (name, versionRange, opts) => {
3387
+ program.command("install").alias("i").description("Install a skill from the Tank registry, or all skills from lockfile").argument("[name]", "Skill name (e.g., @org/skill-name). Omit to install from lockfile.").argument("[version-range]", "Semver range (default: *)", "*").option("-g, --global", "Install skill globally (available to all projects)").option("-y, --yes", "Auto-accept permission budget expansion").action(async (name, versionRange, opts) => {
3298
3388
  try {
3299
3389
  if (name) await installCommand({
3300
3390
  name,
3301
3391
  versionRange,
3302
- global: opts.global
3392
+ global: opts.global,
3393
+ yes: opts.yes
3394
+ });
3395
+ else await installAll({
3396
+ global: opts.global,
3397
+ yes: opts.yes
3303
3398
  });
3304
- else await installAll({ global: opts.global });
3305
3399
  } catch (err) {
3306
3400
  const msg = err instanceof Error ? err.message : String(err);
3307
3401
  console.error(`Install failed: ${msg}`);
@@ -3386,7 +3480,26 @@ program.command("scan").description("Scan a local skill for security issues with
3386
3480
  process.exit(1);
3387
3481
  }
3388
3482
  });
3389
- program.command("link").alias("ln").description("Link current skill directory to AI agent directories (for development)").action(async () => {
3483
+ program.command("link").alias("ln").description("Link current skill to all detected AI agents for local development").addHelpText("after", `
3484
+ Creates symlinks from each agent's skills directory to this skill source,
3485
+ so changes are reflected immediately without re-publishing.
3486
+
3487
+ Must be run from a directory containing a valid tank.json.
3488
+
3489
+ Supported agents:
3490
+ Claude Code ~/.claude/skills
3491
+ OpenCode ~/.config/opencode/skills
3492
+ Cursor ~/.cursor/skills
3493
+ Codex ~/.codex/skills
3494
+ OpenClaw ~/.openclaw/skills
3495
+ Universal ~/.agents/skills
3496
+
3497
+ Examples:
3498
+ $ cd my-skill && tank link Link to all detected agents
3499
+ $ tank doctor Verify link health
3500
+ $ tank unlink Remove all symlinks
3501
+
3502
+ See also: tank unlink, tank doctor`).action(async () => {
3390
3503
  try {
3391
3504
  await linkCommand();
3392
3505
  } catch (err) {
@@ -3395,7 +3508,14 @@ program.command("link").alias("ln").description("Link current skill directory to
3395
3508
  process.exit(1);
3396
3509
  }
3397
3510
  });
3398
- program.command("unlink").description("Remove skill symlinks from AI agent directories").action(async () => {
3511
+ program.command("unlink").description("Remove skill symlinks from all AI agent directories").addHelpText("after", `
3512
+ Removes the symlinks created by \`tank link\` from every detected agent.
3513
+ Must be run from the same skill directory that was originally linked.
3514
+
3515
+ Examples:
3516
+ $ cd my-skill && tank unlink Remove links for this skill
3517
+
3518
+ See also: tank link, tank doctor`).action(async () => {
3399
3519
  try {
3400
3520
  await unlinkCommand();
3401
3521
  } catch (err) {