@tankpkg/cli 0.9.1 → 0.10.2

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,44 +1,23 @@
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-nDgSC2MM.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-Djt3NpiR.js";
3
3
  import { Command } from "commander";
4
4
  import chalk from "chalk";
5
5
  import fs from "node:fs";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
- import semver from "semver";
9
8
  import { z } from "zod";
10
9
  import { confirm, input } from "@inquirer/prompts";
11
10
  import crypto$1 from "node:crypto";
11
+ import semver from "semver";
12
12
  import ora from "ora";
13
13
  import { create, extract } from "tar";
14
14
  import open from "open";
15
15
  import ignore from "ignore";
16
+ process.env.TANK_REGISTRY_URL;
16
17
  const MANIFEST_FILENAME = "tank.json";
17
18
  const LEGACY_MANIFEST_FILENAME = "skills.json";
18
19
  const LOCKFILE_FILENAME = "tank.lock";
19
20
  const LEGACY_LOCKFILE_FILENAME = "skills.lock";
20
- /**
21
- * Resolves a semver range against a list of available versions.
22
- * Returns the highest version that satisfies the range, or null if none match.
23
- *
24
- * Pre-release versions are excluded from range matching unless the range
25
- * explicitly includes a pre-release tag (e.g., ">=1.0.0-beta.1").
26
- * Exact version matches always work, including for pre-release versions.
27
- *
28
- * @param range - A semver range string (e.g., "^2.1.0", "~1.0.0", ">=2.0.0 <3.0.0", "*")
29
- * @param versions - An array of semver version strings to match against
30
- * @returns The highest matching version string, or null if no match
31
- */
32
- function resolve(range, versions) {
33
- try {
34
- if (!range || !semver.validRange(range)) return null;
35
- const validVersions = versions.filter((v) => semver.valid(v) !== null);
36
- if (validVersions.length === 0) return null;
37
- return semver.maxSatisfying(validVersions, range) ?? null;
38
- } catch {
39
- return null;
40
- }
41
- }
42
21
  const networkPermissionsSchema = z.object({ outbound: z.array(z.string()).optional() }).strict();
43
22
  const filesystemPermissionsSchema = z.object({
44
23
  read: z.array(z.string()).optional(),
@@ -78,9 +57,9 @@ z.enum([
78
57
  "org.delete"
79
58
  ]);
80
59
  const skillsJsonSchema = z.object({
81
- name: z.string().min(1, "Name must not be empty").max(214, "Name must be 214 characters or fewer").regex(/^@[a-z0-9-]+\/[a-z0-9][a-z0-9-]*$/, "Name must be scoped (@org/name), lowercase alphanumeric and hyphens"),
60
+ name: z.string().min(1, "Name must not be empty").max(214, `Name must be 214 characters or fewer`).regex(/^@[a-z0-9-]+\/[a-z0-9][a-z0-9-]*$/, "Name must be scoped (@org/name), lowercase alphanumeric and hyphens"),
82
61
  version: z.string().regex(/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/, "Version must be valid semver"),
83
- description: z.string().max(500, "Description must be 500 characters or fewer").optional(),
62
+ description: z.string().max(500, `Description must be 500 characters or fewer`).optional(),
84
63
  skills: z.record(z.string(), z.string()).optional(),
85
64
  permissions: permissionsSchema.optional(),
86
65
  repository: z.string().url("Repository must be a valid URL").optional(),
@@ -876,7 +855,7 @@ async function initCommand(options = {}) {
876
855
  for (const issue of result.error.issues) logger.error(` ${issue.path.join(".")}: ${issue.message}`);
877
856
  return;
878
857
  }
879
- fs.writeFileSync(filePath, JSON.stringify(manifest, null, 2) + "\n");
858
+ fs.writeFileSync(filePath, `${JSON.stringify(manifest, null, 2)}\n`);
880
859
  logger.success(`Created ${MANIFEST_FILENAME}`);
881
860
  return;
882
861
  }
@@ -934,10 +913,22 @@ async function initCommand(options = {}) {
934
913
  for (const issue of result.error.issues) logger.error(` ${issue.path.join(".")}: ${issue.message}`);
935
914
  return;
936
915
  }
937
- fs.writeFileSync(filePath, JSON.stringify(manifest, null, 2) + "\n");
916
+ fs.writeFileSync(filePath, `${JSON.stringify(manifest, null, 2)}\n`);
938
917
  logger.success(`Created ${MANIFEST_FILENAME}`);
939
918
  }
940
919
  //#endregion
920
+ //#region ../internals-helpers/dist/index.js
921
+ function resolve(range, versions) {
922
+ try {
923
+ if (!range || !semver.validRange(range)) return null;
924
+ const validVersions = versions.filter((v) => semver.valid(v) !== null);
925
+ if (validVersions.length === 0) return null;
926
+ return semver.maxSatisfying(validVersions, range) ?? null;
927
+ } catch {
928
+ return null;
929
+ }
930
+ }
931
+ //#endregion
941
932
  //#region src/lib/dependency-resolver.ts
942
933
  function buildSkillKey(name, version) {
943
934
  return `${name}@${version}`;
@@ -1291,6 +1282,26 @@ function getResolvedNodesInOrder(nodes, installOrder) {
1291
1282
  //#endregion
1292
1283
  //#region src/lib/permission-checker.ts
1293
1284
  /**
1285
+ * Check if a skill's permissions fit within the project's permission budget.
1286
+ * Throws if any permission exceeds the budget.
1287
+ */
1288
+ function checkPermissionBudget(budget, skillPerms, skillName) {
1289
+ if (!skillPerms) return;
1290
+ if (skillPerms.subprocess === true && budget.subprocess !== true) throw new Error(`Permission denied: ${skillName} requires subprocess access, but project budget does not allow it`);
1291
+ if (skillPerms.network?.outbound && skillPerms.network.outbound.length > 0) {
1292
+ const budgetDomains = budget.network?.outbound ?? [];
1293
+ 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`);
1294
+ }
1295
+ if (skillPerms.filesystem?.read && skillPerms.filesystem.read.length > 0) {
1296
+ const budgetPaths = budget.filesystem?.read ?? [];
1297
+ 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`);
1298
+ }
1299
+ if (skillPerms.filesystem?.write && skillPerms.filesystem.write.length > 0) {
1300
+ const budgetPaths = budget.filesystem?.write ?? [];
1301
+ 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`);
1302
+ }
1303
+ }
1304
+ /**
1294
1305
  * Check if a domain is allowed by the budget's domain list.
1295
1306
  * Supports wildcard matching: *.example.com matches sub.example.com
1296
1307
  */
@@ -1319,91 +1330,6 @@ function isPathAllowed$1(requestedPath, allowedPaths) {
1319
1330
  }
1320
1331
  return false;
1321
1332
  }
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
- }
1407
1333
  //#endregion
1408
1334
  //#region src/commands/install.ts
1409
1335
  function createRegistryFetcher(registry, headers) {
@@ -1497,30 +1423,15 @@ function buildLockedVersionByName(lock) {
1497
1423
  function createExtractDirResolver(directory, global, resolvedHome) {
1498
1424
  return (skillName) => global ? getGlobalExtractDir(resolvedHome, skillName) : getExtractDir$1(directory, skillName);
1499
1425
  }
1500
- async function validateResolvedNodes(resolvedNodes, projectPermissions, auditMinScore, options) {
1426
+ function validateResolvedNodes(resolvedNodes, projectPermissions, auditMinScore) {
1501
1427
  if (!projectPermissions) logger.warn(`No permission budget defined in ${MANIFEST_FILENAME}. Install proceeding without permission checks.`);
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
- }
1428
+ for (const node of resolvedNodes) {
1429
+ if (projectPermissions) checkPermissionBudget(projectPermissions, node.meta.permissions, node.name);
1430
+ if (auditMinScore !== void 0) {
1431
+ 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.`);
1432
+ 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}`);
1518
1433
  }
1519
1434
  }
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
- }
1524
1435
  }
1525
1436
  async function runLegacyFallback(options) {
1526
1437
  const { rootSkillNames, resolvedNodeByName, extractDirForSkill, directory, configDir, global, homedir } = options;
@@ -1570,12 +1481,8 @@ function linkInstalledRoots(options) {
1570
1481
  if (detectInstalledAgents(homedir).length === 0) logger.warn("No agents detected for linking");
1571
1482
  }
1572
1483
  async function executeInstallPipeline(options) {
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
- });
1484
+ const { directory, configDir, global, homedir, resolvedHome, lock, lockPath, resolvedNodes, nodesToInstall, rootSkillNames, projectPermissions, auditMinScore, spinner } = options;
1485
+ if (!global) validateResolvedNodes(resolvedNodes, projectPermissions, auditMinScore);
1579
1486
  const extractDirForSkill = createExtractDirResolver(directory, global, resolvedHome);
1580
1487
  const resolvedNodeByName = new Map(resolvedNodes.map((node) => [node.name, node]));
1581
1488
  const downloaded = await downloadAllParallel(nodesToInstall, spinner);
@@ -1613,7 +1520,7 @@ async function executeInstallPipeline(options) {
1613
1520
  return updatedLock;
1614
1521
  }
1615
1522
  async function installCommand(options) {
1616
- const { name, versionRange = "*", directory = process.cwd(), configDir, global = false, homedir, isTransitive = false, yes } = options;
1523
+ const { name, versionRange = "*", directory = process.cwd(), configDir, global = false, homedir, isTransitive = false } = options;
1617
1524
  const config = getConfig(configDir);
1618
1525
  const resolvedHome = homedir ?? os.homedir();
1619
1526
  const requestHeaders = { "User-Agent": USER_AGENT };
@@ -1669,10 +1576,7 @@ async function installCommand(options) {
1669
1576
  rootSkillNames: [name],
1670
1577
  projectPermissions,
1671
1578
  auditMinScore,
1672
- spinner,
1673
- yes,
1674
- skillsJsonPath,
1675
- skillsJson
1579
+ spinner
1676
1580
  });
1677
1581
  if (!global && !isTransitive) {
1678
1582
  const skills = skillsJson.skills ?? {};
@@ -1770,7 +1674,7 @@ async function installFromLockfile(options) {
1770
1674
  }
1771
1675
  }
1772
1676
  async function installAll(options) {
1773
- const { directory = process.cwd(), configDir, global = false, homedir, yes } = options;
1677
+ const { directory = process.cwd(), configDir, global = false, homedir } = options;
1774
1678
  const resolvedHome = homedir ?? os.homedir();
1775
1679
  const config = getConfig(configDir);
1776
1680
  const requestHeaders = { "User-Agent": USER_AGENT };
@@ -1825,10 +1729,7 @@ async function installAll(options) {
1825
1729
  rootSkillNames: skillEntries.map(([skillName]) => skillName),
1826
1730
  projectPermissions,
1827
1731
  auditMinScore,
1828
- spinner,
1829
- yes,
1830
- skillsJsonPath,
1831
- skillsJson
1732
+ spinner
1832
1733
  });
1833
1734
  spinner.succeed(`Installed ${skillEntries.length} root skill${skillEntries.length === 1 ? "" : "s"}`);
1834
1735
  } catch (err) {
@@ -1914,28 +1815,38 @@ async function loginCommand(options = {}) {
1914
1815
  const state = crypto.randomUUID();
1915
1816
  authFlowLog.info({ state: `${state.slice(0, 8)}...` }, "Login flow started");
1916
1817
  logger.info("Starting login...");
1917
- const startRes = await fetch(`${baseUrl}/api/v1/cli-auth/start`, {
1918
- method: "POST",
1919
- headers: { "Content-Type": "application/json" },
1920
- body: JSON.stringify({ state })
1921
- });
1922
- if (!startRes.ok) {
1923
- const body = await startRes.json().catch(() => null);
1924
- authFlowLog.error({
1925
- status: startRes.status,
1926
- error: body?.error
1927
- }, "Start request failed");
1928
- throw new Error(`Failed to start auth session: ${body?.error ?? startRes.statusText}`);
1929
- }
1930
- authFlowLog.info({
1931
- ok: startRes.ok,
1932
- status: startRes.status
1933
- }, "Start response received");
1934
- const { authUrl, sessionCode } = await startRes.json();
1935
- authFlowLog.info({
1936
- authUrl,
1937
- sessionCode: `${sessionCode.slice(0, 8)}...`
1938
- }, "Session created, opening browser");
1818
+ let authUrl;
1819
+ let sessionCode;
1820
+ try {
1821
+ const startRes = await fetch(`${baseUrl}/api/v1/cli-auth/start`, {
1822
+ method: "POST",
1823
+ headers: { "Content-Type": "application/json" },
1824
+ body: JSON.stringify({ state })
1825
+ });
1826
+ if (!startRes.ok) {
1827
+ const body = await startRes.json().catch(() => null);
1828
+ authFlowLog.error({
1829
+ status: startRes.status,
1830
+ error: body?.error
1831
+ }, "Start request failed");
1832
+ throw new Error(`Failed to start auth session: ${body?.error ?? startRes.statusText}`);
1833
+ }
1834
+ authFlowLog.info({
1835
+ ok: startRes.ok,
1836
+ status: startRes.status
1837
+ }, "Start response received");
1838
+ const startData = await startRes.json();
1839
+ authUrl = startData.authUrl;
1840
+ sessionCode = startData.sessionCode;
1841
+ authFlowLog.info({
1842
+ authUrl,
1843
+ sessionCode: `${sessionCode.slice(0, 8)}...`
1844
+ }, "Session created, opening browser");
1845
+ } catch (err) {
1846
+ if (err instanceof Error && err.message.startsWith("Failed to start auth session:")) throw err;
1847
+ authFlowLog.error({ error: err instanceof Error ? err.message : String(err) }, "Start request failed");
1848
+ throw new Error(`Could not reach registry at ${baseUrl}. Check your internet connection or registry URL.\n Error: ${err instanceof Error ? err.message : String(err)}`);
1849
+ }
1939
1850
  try {
1940
1851
  await open(authUrl);
1941
1852
  logger.info("Opened browser for authentication.");
@@ -2293,6 +2204,7 @@ async function packForScan(directory) {
2293
2204
  readmeContent = "";
2294
2205
  }
2295
2206
  const files = collectFiles(absDir, absDir, buildIgnoreFilter(absDir));
2207
+ if (files.length === 0) throw new Error(`No manifest found and no files to scan in ${absDir}`);
2296
2208
  if (files.length > MAX_FILE_COUNT) throw new Error(`Too many files: ${files.length} exceeds maximum of ${MAX_FILE_COUNT}`);
2297
2209
  let totalSize = 0;
2298
2210
  for (const file of files) {
@@ -2568,7 +2480,7 @@ async function removeCommand(options) {
2568
2480
  if (!(name in skills)) throw new Error(`Skill "${name}" is not installed (not found in ${path.basename(resolvedManifest.path)})`);
2569
2481
  delete skills[name];
2570
2482
  skillsJson.skills = skills;
2571
- fs.writeFileSync(resolvedManifest.path, JSON.stringify(skillsJson, null, 2) + "\n");
2483
+ fs.writeFileSync(resolvedManifest.path, `${JSON.stringify(skillsJson, null, 2)}\n`);
2572
2484
  const resolvedLocalLock = resolveLockfilePath(directory);
2573
2485
  const lockPath = resolvedLocalLock.path;
2574
2486
  if (resolvedLocalLock.exists) {
@@ -3130,6 +3042,10 @@ async function upgradeCommand(opts) {
3130
3042
  console.log(chalk.yellow("Tank was installed via Homebrew. Run `brew upgrade tank` instead."));
3131
3043
  return;
3132
3044
  }
3045
+ if (currentBinaryPath.includes("node_modules") || currentBinaryPath.endsWith(".js") || currentBinaryPath.endsWith(".mjs")) {
3046
+ console.log(chalk.yellow("Tank was installed via npm/npx. Run `npm update -g @tankpkg/cli` to upgrade instead."));
3047
+ return;
3048
+ }
3133
3049
  let targetVersion;
3134
3050
  if (opts?.version) targetVersion = opts.version;
3135
3051
  else {
@@ -3384,18 +3300,14 @@ program.command("publish").alias("pub").description("Pack and publish a skill to
3384
3300
  process.exit(1);
3385
3301
  }
3386
3302
  });
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) => {
3303
+ 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) => {
3388
3304
  try {
3389
3305
  if (name) await installCommand({
3390
3306
  name,
3391
3307
  versionRange,
3392
- global: opts.global,
3393
- yes: opts.yes
3394
- });
3395
- else await installAll({
3396
- global: opts.global,
3397
- yes: opts.yes
3308
+ global: opts.global
3398
3309
  });
3310
+ else await installAll({ global: opts.global });
3399
3311
  } catch (err) {
3400
3312
  const msg = err instanceof Error ? err.message : String(err);
3401
3313
  console.error(`Install failed: ${msg}`);
@@ -3480,26 +3392,7 @@ program.command("scan").description("Scan a local skill for security issues with
3480
3392
  process.exit(1);
3481
3393
  }
3482
3394
  });
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 () => {
3395
+ program.command("link").alias("ln").description("Link current skill directory to AI agent directories (for development)").action(async () => {
3503
3396
  try {
3504
3397
  await linkCommand();
3505
3398
  } catch (err) {
@@ -3508,14 +3401,7 @@ See also: tank unlink, tank doctor`).action(async () => {
3508
3401
  process.exit(1);
3509
3402
  }
3510
3403
  });
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 () => {
3404
+ program.command("unlink").description("Remove skill symlinks from AI agent directories").action(async () => {
3519
3405
  try {
3520
3406
  await unlinkCommand();
3521
3407
  } catch (err) {