@tankpkg/cli 0.0.0-nightly.20260416.9bc2c8a → 0.0.0-nightly.20260416.d672db7

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,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { a as VERSION, i as USER_AGENT, l as setConfig, n as flushLogs, o as getConfig, s as getConfigDir, t as authFlowLog } from "../debug-logger-DMCCDELn.js";
2
+ import { a as VERSION, i as USER_AGENT, l as setConfig, n as flushLogs, o as getConfig, s as getConfigDir, t as authFlowLog } from "../debug-logger-W7tIIaFy.js";
3
3
  import { t as logger } from "../logger-BhULz3Uz.js";
4
4
  import { Command } from "commander";
5
5
  import chalk from "chalk";
6
- import fs from "node:fs";
7
- import os from "node:os";
8
- import path from "node:path";
6
+ import fs, { createWriteStream } from "node:fs";
7
+ import os, { tmpdir } from "node:os";
8
+ import path, { join } from "node:path";
9
9
  import { z } from "zod";
10
10
  import { claudeCodeAdapter, clineAdapter, compilePackage, cursorAdapter, normalizeDirectory, opencodeAdapter, rooCodeAdapter, windsurfAdapter } from "@internals/adapters";
11
11
  import ora from "ora";
@@ -13,10 +13,14 @@ import { confirm, input } from "@inquirer/prompts";
13
13
  import crypto$1 from "node:crypto";
14
14
  import semver from "semver";
15
15
  import { buildSkillKey, checkPermissionBudget, downloadAllParallel, extractSafely, getExtractDir as getExtractDir$1, getGlobalExtractDir, getResolvedNodesInOrder, parseLockKey as parseLockKey$2, parseVersionFromLockKey, readExtractedDependencies, resolveDependencyTree, verifyExtractedDependencies, writeLockfileWithResolvedGraph } from "@tankpkg/sdk";
16
+ import { createInterface } from "node:readline";
17
+ import { execSync, spawn } from "node:child_process";
18
+ import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
19
+ import { Readable } from "node:stream";
20
+ import { pipeline } from "node:stream/promises";
16
21
  import open from "open";
17
22
  import ignore from "ignore";
18
23
  import { create } from "tar";
19
- import { spawn } from "node:child_process";
20
24
  import { fileURLToPath } from "node:url";
21
25
  //#region \0rolldown/runtime.js
22
26
  var __defProp = Object.defineProperty;
@@ -313,6 +317,22 @@ z.object({
313
317
  atoms: z.array(z.record(z.string(), z.unknown())).optional(),
314
318
  includes: z.array(z.string()).optional()
315
319
  }).strict();
320
+ const SKILL_SOURCES = [
321
+ "registry",
322
+ "github",
323
+ "clawhub",
324
+ "skills_sh",
325
+ "agentskills_il",
326
+ "npm",
327
+ "local"
328
+ ];
329
+ const SCAN_VERDICTS = [
330
+ "pass",
331
+ "pass_with_notes",
332
+ "flagged",
333
+ "fail",
334
+ "error"
335
+ ];
316
336
  const lockedSkillV1Schema = z.object({
317
337
  resolved: z.string().url(),
318
338
  integrity: z.string().regex(/^sha512-/, "Integrity must start with sha512-"),
@@ -328,7 +348,10 @@ const lockedSkillSchema = z.object({
328
348
  integrity: z.string().regex(/^sha512-/, "Integrity must start with sha512-"),
329
349
  permissions: permissionsSchema,
330
350
  audit_score: z.number().min(0).max(10).nullable(),
331
- dependencies: z.record(z.string(), z.string()).optional()
351
+ dependencies: z.record(z.string(), z.string()).optional(),
352
+ source: z.enum(SKILL_SOURCES).optional(),
353
+ scan_verdict: z.enum(SCAN_VERDICTS).optional(),
354
+ scanned_at: z.string().optional()
332
355
  });
333
356
  z.object({
334
357
  lockfileVersion: z.union([z.literal(1), z.literal(2)]),
@@ -433,7 +456,7 @@ function readLockfile$1(directory) {
433
456
  }
434
457
  //#endregion
435
458
  //#region src/commands/audit.ts
436
- function scoreColor$2(score) {
459
+ function scoreColor$3(score) {
437
460
  if (score >= 7) return chalk.green;
438
461
  if (score >= 4) return chalk.yellow;
439
462
  return chalk.red;
@@ -441,7 +464,7 @@ function scoreColor$2(score) {
441
464
  function formatScore(result) {
442
465
  if (result.error) return chalk.dim("error");
443
466
  if (result.score == null || result.status !== "completed") return chalk.dim("pending");
444
- return scoreColor$2(result.score)(result.score.toFixed(1));
467
+ return scoreColor$3(result.score)(result.score.toFixed(1));
445
468
  }
446
469
  function formatStatus(result) {
447
470
  if (result.error) return chalk.dim("error");
@@ -1327,6 +1350,527 @@ function prepareAgentSkillDir(options) {
1327
1350
  return targetDir;
1328
1351
  }
1329
1352
  //#endregion
1353
+ //#region src/lib/scan-gate.ts
1354
+ /**
1355
+ * Security scan gate for `tank install <url>`.
1356
+ * Calls the public scan API and enforces verdicts.
1357
+ */
1358
+ function verdictColor$1(verdict) {
1359
+ switch (verdict) {
1360
+ case "pass": return chalk.green;
1361
+ case "pass_with_notes": return chalk.yellow;
1362
+ case "flagged": return chalk.hex("#FF8C00");
1363
+ case "fail": return chalk.red;
1364
+ case "error": return chalk.red;
1365
+ default: return chalk.white;
1366
+ }
1367
+ }
1368
+ function severityColor$1(severity) {
1369
+ switch (severity) {
1370
+ case "critical": return chalk.red;
1371
+ case "high": return chalk.hex("#FF8C00");
1372
+ case "medium": return chalk.yellow;
1373
+ case "low": return chalk.green;
1374
+ case "info": return chalk.blue;
1375
+ default: return chalk.white;
1376
+ }
1377
+ }
1378
+ function scoreColor$2(score) {
1379
+ if (score >= 7) return chalk.green;
1380
+ if (score >= 4) return chalk.yellow;
1381
+ return chalk.red;
1382
+ }
1383
+ async function promptUser(question) {
1384
+ const rl = createInterface({
1385
+ input: process.stdin,
1386
+ output: process.stdout
1387
+ });
1388
+ return new Promise((resolve) => {
1389
+ rl.question(question, (answer) => {
1390
+ rl.close();
1391
+ resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
1392
+ });
1393
+ });
1394
+ }
1395
+ async function scanUrl(url, options) {
1396
+ const config = getConfig();
1397
+ const registryUrl = options?.registryUrl ?? config.registry;
1398
+ const token = options?.token ?? config.token;
1399
+ let res;
1400
+ try {
1401
+ const headers = {
1402
+ "Content-Type": "application/json",
1403
+ "User-Agent": USER_AGENT
1404
+ };
1405
+ if (token) headers.Authorization = `Bearer ${token}`;
1406
+ res = await fetch(`${registryUrl}/api/v1/scan`, {
1407
+ method: "POST",
1408
+ headers,
1409
+ body: JSON.stringify({ url }),
1410
+ signal: AbortSignal.timeout(65e3)
1411
+ });
1412
+ } catch (err) {
1413
+ return {
1414
+ success: false,
1415
+ verdict: "error",
1416
+ auditScore: null,
1417
+ findings: [],
1418
+ durationMs: null,
1419
+ error: `Network error: ${err instanceof Error ? err.message : String(err)}`
1420
+ };
1421
+ }
1422
+ if (!res.ok) {
1423
+ if (res.status === 429) return {
1424
+ success: false,
1425
+ verdict: "error",
1426
+ auditScore: null,
1427
+ findings: [],
1428
+ durationMs: null,
1429
+ error: `Rate limited (429): ${token ? "Authenticated rate limit reached (20/hr). Try again later." : "Anonymous rate limit reached (3/hr). Run `tank login` for higher limits."}`
1430
+ };
1431
+ if (res.status === 504) return {
1432
+ success: false,
1433
+ verdict: "error",
1434
+ auditScore: null,
1435
+ findings: [],
1436
+ durationMs: null,
1437
+ error: "Scan timed out (504). The skill may be too large or the scanner is overloaded."
1438
+ };
1439
+ return {
1440
+ success: false,
1441
+ verdict: "error",
1442
+ auditScore: null,
1443
+ findings: [],
1444
+ durationMs: null,
1445
+ error: (await res.json().catch(() => null))?.error ?? `HTTP ${res.status}: ${res.statusText}`
1446
+ };
1447
+ }
1448
+ const data = await res.json();
1449
+ const findings = data.findings.map((f) => ({
1450
+ severity: f.severity,
1451
+ type: f.type,
1452
+ description: f.description,
1453
+ ...f.location ? { location: f.location } : {}
1454
+ }));
1455
+ return {
1456
+ success: true,
1457
+ verdict: data.verdict,
1458
+ auditScore: data.audit_score ?? null,
1459
+ findings,
1460
+ durationMs: data.duration_ms ?? null
1461
+ };
1462
+ }
1463
+ function displayScanResults(result) {
1464
+ const verdictLabel = verdictColor$1(result.verdict)(result.verdict.toUpperCase());
1465
+ console.log("");
1466
+ console.log(chalk.bold("Security Scan Results"));
1467
+ console.log("");
1468
+ console.log(`${chalk.dim("Verdict:".padEnd(14))}${verdictLabel}`);
1469
+ if (result.auditScore !== null) {
1470
+ const scoreLabel = scoreColor$2(result.auditScore)(result.auditScore.toFixed(1));
1471
+ console.log(`${chalk.dim("Score:".padEnd(14))}${scoreLabel}/10`);
1472
+ }
1473
+ if (result.durationMs !== null) console.log(`${chalk.dim("Duration:".padEnd(14))}${(result.durationMs / 1e3).toFixed(1)}s`);
1474
+ if (result.error) console.log(`${chalk.dim("Error:".padEnd(14))}${chalk.red(result.error)}`);
1475
+ if (result.findings.length > 0) {
1476
+ console.log("");
1477
+ console.log(chalk.bold(`Findings (${result.findings.length})`));
1478
+ const bySeverity = {
1479
+ critical: [],
1480
+ high: [],
1481
+ medium: [],
1482
+ low: [],
1483
+ info: []
1484
+ };
1485
+ for (const f of result.findings) bySeverity[f.severity].push(f);
1486
+ for (const severity of [
1487
+ "critical",
1488
+ "high",
1489
+ "medium",
1490
+ "low",
1491
+ "info"
1492
+ ]) {
1493
+ const group = bySeverity[severity];
1494
+ if (group.length === 0) continue;
1495
+ console.log("");
1496
+ const label = severityColor$1(severity)(`${severity.toUpperCase()} (${group.length})`);
1497
+ console.log(` ${label}`);
1498
+ for (const f of group) {
1499
+ console.log(` - ${chalk.bold(f.type)}: ${f.description}`);
1500
+ if (f.location) console.log(` ${chalk.dim("Location:")} ${f.location}`);
1501
+ }
1502
+ }
1503
+ } else if (result.success) {
1504
+ console.log("");
1505
+ console.log(chalk.green("No findings. The skill looks secure."));
1506
+ }
1507
+ console.log("");
1508
+ }
1509
+ async function enforceVerdict(result, options) {
1510
+ switch (result.verdict) {
1511
+ case "pass":
1512
+ case "pass_with_notes": return { allowed: true };
1513
+ case "flagged": {
1514
+ if (options?.yes) return { allowed: true };
1515
+ const count = result.findings.length;
1516
+ if (await promptUser(chalk.yellow(`⚠ Security scan flagged ${count} issue${count === 1 ? "" : "s"}. Install anyway? (y/N) `))) return { allowed: true };
1517
+ return {
1518
+ allowed: false,
1519
+ reason: "User declined after security warnings"
1520
+ };
1521
+ }
1522
+ case "fail": return {
1523
+ allowed: false,
1524
+ reason: "Security scan failed with critical findings"
1525
+ };
1526
+ case "error": return {
1527
+ allowed: false,
1528
+ reason: `Security scan error: ${result.error ?? "unknown"}`
1529
+ };
1530
+ default: return {
1531
+ allowed: false,
1532
+ reason: `Unknown verdict: ${result.verdict}`
1533
+ };
1534
+ }
1535
+ }
1536
+ //#endregion
1537
+ //#region src/lib/url-fetcher.ts
1538
+ /**
1539
+ * Fetch skills from URLs for `tank install <url>`.
1540
+ * Routes GitHub (git clone), ClawHub (zip), skills.sh, and generic tarballs
1541
+ * to temp directories with cleanup-on-failure semantics.
1542
+ */
1543
+ const HOST_MAP = [
1544
+ [/github\.com/i, "github"],
1545
+ [/clawhub\.ai/i, "clawhub"],
1546
+ [/skills\.sh/i, "skills_sh"],
1547
+ [/agentskills\.co\.il/i, "agentskills_il"],
1548
+ [/registry\.npmjs\.org/i, "npm"]
1549
+ ];
1550
+ function detectSourceType(url) {
1551
+ for (const [pattern, sourceType] of HOST_MAP) if (pattern.test(url)) return sourceType;
1552
+ return "unknown";
1553
+ }
1554
+ /** Returns true if the input looks like a URL rather than a package name. */
1555
+ function isUrl(input) {
1556
+ if (input.startsWith("http://") || input.startsWith("https://")) return true;
1557
+ for (const [pattern] of HOST_MAP) if (pattern.test(input)) return true;
1558
+ return false;
1559
+ }
1560
+ /** Best-effort skill name extraction from a URL. */
1561
+ function inferSkillName(url) {
1562
+ try {
1563
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.replace(/\.git$/, "").split("/").filter(Boolean);
1564
+ switch (detectSourceType(url)) {
1565
+ case "github": {
1566
+ if (segments.length < 2) return null;
1567
+ const treeIdx = segments.indexOf("tree");
1568
+ if (treeIdx !== -1 && segments.length > treeIdx + 2) return segments[segments.length - 1] ?? null;
1569
+ return segments[1] ?? null;
1570
+ }
1571
+ case "clawhub": return segments[1] ?? null;
1572
+ case "skills_sh": return segments[2] ?? segments[1] ?? null;
1573
+ case "agentskills_il": return segments[1] ?? null;
1574
+ case "npm": return segments[segments.length - 1] ?? null;
1575
+ default: return segments[segments.length - 1] ?? null;
1576
+ }
1577
+ } catch {
1578
+ return null;
1579
+ }
1580
+ }
1581
+ async function createTempDir() {
1582
+ return mkdtemp(join(tmpdir(), "tank-fetch-"));
1583
+ }
1584
+ async function cleanupDir(dir) {
1585
+ try {
1586
+ await rm(dir, {
1587
+ recursive: true,
1588
+ force: true
1589
+ });
1590
+ } catch {}
1591
+ }
1592
+ function ensureGitInstalled() {
1593
+ try {
1594
+ execSync("git --version", { stdio: "ignore" });
1595
+ } catch {
1596
+ throw new Error("Git is not installed. Install git and try again.");
1597
+ }
1598
+ }
1599
+ function gitCloneShallow(repoUrl, dest) {
1600
+ try {
1601
+ execSync(`git clone --depth 1 ${repoUrl} ${dest}`, {
1602
+ stdio: "pipe",
1603
+ timeout: 6e4
1604
+ });
1605
+ } catch (err) {
1606
+ const msg = err instanceof Error ? err.message : String(err);
1607
+ if (msg.includes("Repository not found") || msg.includes("not found")) throw new Error(`Repository not found: ${repoUrl}`);
1608
+ if (msg.includes("timed out") || msg.includes("ETIMEDOUT")) throw new Error(`Network timeout cloning ${repoUrl}`);
1609
+ throw new Error(`Git clone failed: ${msg}`);
1610
+ }
1611
+ }
1612
+ function gitRevParseHead(dir) {
1613
+ try {
1614
+ return execSync("git rev-parse HEAD", {
1615
+ cwd: dir,
1616
+ stdio: "pipe"
1617
+ }).toString().trim();
1618
+ } catch {
1619
+ return null;
1620
+ }
1621
+ }
1622
+ async function downloadFile(url, dest) {
1623
+ const res = await fetch(url, { signal: AbortSignal.timeout(6e4) });
1624
+ if (!res.ok) {
1625
+ if (res.status === 404) throw new Error(`Not found: ${url}`);
1626
+ throw new Error(`HTTP ${res.status} downloading ${url}`);
1627
+ }
1628
+ if (!res.body) throw new Error(`Empty response body from ${url}`);
1629
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
1630
+ }
1631
+ async function extractZip(zipPath, dest) {
1632
+ try {
1633
+ execSync(`unzip -o -q "${zipPath}" -d "${dest}"`, {
1634
+ stdio: "pipe",
1635
+ timeout: 3e4
1636
+ });
1637
+ } catch (err) {
1638
+ const msg = err instanceof Error ? err.message : String(err);
1639
+ throw new Error(`Zip extraction failed: ${msg}`);
1640
+ }
1641
+ }
1642
+ async function extractTarball(tarPath, dest) {
1643
+ try {
1644
+ execSync(`tar xzf "${tarPath}" -C "${dest}"`, {
1645
+ stdio: "pipe",
1646
+ timeout: 3e4
1647
+ });
1648
+ } catch (err) {
1649
+ const msg = err instanceof Error ? err.message : String(err);
1650
+ throw new Error(`Tarball extraction failed: ${msg}`);
1651
+ }
1652
+ }
1653
+ function parseGitHubUrl(url) {
1654
+ try {
1655
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.replace(/\.git$/, "").split("/").filter(Boolean);
1656
+ if (segments.length < 2) return null;
1657
+ const owner = segments[0];
1658
+ const repo = segments[1];
1659
+ let branch = null;
1660
+ let subpath = null;
1661
+ if (segments[2] === "tree" && segments.length > 3) {
1662
+ branch = segments[3];
1663
+ if (segments.length > 4) subpath = segments.slice(4).join("/");
1664
+ }
1665
+ return {
1666
+ owner,
1667
+ repo,
1668
+ branch,
1669
+ subpath
1670
+ };
1671
+ } catch {
1672
+ return null;
1673
+ }
1674
+ }
1675
+ async function fetchFromGitHub(url, tempDir) {
1676
+ ensureGitInstalled();
1677
+ const parts = parseGitHubUrl(url);
1678
+ if (!parts) throw new Error(`Invalid GitHub URL: ${url}`);
1679
+ const cloneUrl = `https://github.com/${parts.owner}/${parts.repo}.git`;
1680
+ const cloneDest = join(tempDir, parts.repo);
1681
+ logger.info(`Cloning ${parts.owner}/${parts.repo}...`);
1682
+ gitCloneShallow(cloneUrl, cloneDest);
1683
+ if (parts.branch) try {
1684
+ execSync(`git checkout ${parts.branch}`, {
1685
+ cwd: cloneDest,
1686
+ stdio: "pipe",
1687
+ timeout: 1e4
1688
+ });
1689
+ } catch {}
1690
+ const commitSha = gitRevParseHead(cloneDest);
1691
+ let localPath = cloneDest;
1692
+ if (parts.subpath) {
1693
+ const subDir = join(cloneDest, parts.subpath);
1694
+ try {
1695
+ if ((await stat(subDir)).isDirectory()) localPath = subDir;
1696
+ } catch {
1697
+ throw new Error(`Subpath not found in repo: ${parts.subpath}`);
1698
+ }
1699
+ }
1700
+ return {
1701
+ localPath,
1702
+ sourceType: "github",
1703
+ sourceUrl: url,
1704
+ commitSha,
1705
+ inferredName: parts.subpath ? parts.subpath.split("/").pop() ?? parts.repo : parts.repo,
1706
+ cleanup: () => cleanupDir(tempDir)
1707
+ };
1708
+ }
1709
+ function parseClawHubUrl(url) {
1710
+ try {
1711
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.split("/").filter(Boolean);
1712
+ if (segments.length < 2) return null;
1713
+ return {
1714
+ owner: segments[0],
1715
+ skillName: segments[1]
1716
+ };
1717
+ } catch {
1718
+ return null;
1719
+ }
1720
+ }
1721
+ async function fetchFromClawHub(url, tempDir) {
1722
+ const parts = parseClawHubUrl(url);
1723
+ if (!parts) throw new Error(`Invalid ClawHub URL: ${url}`);
1724
+ logger.info(`Fetching ${parts.owner}/${parts.skillName} from ClawHub...`);
1725
+ const pageUrl = url.startsWith("http") ? url : `https://${url}`;
1726
+ const pageRes = await fetch(pageUrl, { signal: AbortSignal.timeout(3e4) });
1727
+ if (!pageRes.ok) {
1728
+ if (pageRes.status === 404) throw new Error(`Skill not found on ClawHub: ${parts.skillName}`);
1729
+ throw new Error(`HTTP ${pageRes.status} fetching ClawHub page`);
1730
+ }
1731
+ const html = await pageRes.text();
1732
+ const downloadUrlMatch = html.match(/https?:\/\/[^\s"']+\.convex\.cloud[^\s"']*download[^\s"']*/i) ?? html.match(/https?:\/\/[^\s"']+\.zip/i);
1733
+ if (!downloadUrlMatch) throw new Error("Could not find download URL on ClawHub page. The skill may not have a downloadable archive.");
1734
+ const zipPath = join(tempDir, `${parts.skillName}.zip`);
1735
+ await downloadFile(downloadUrlMatch[0], zipPath);
1736
+ const extractDir = join(tempDir, parts.skillName);
1737
+ await mkdir(extractDir, { recursive: true });
1738
+ await extractZip(zipPath, extractDir);
1739
+ return {
1740
+ localPath: extractDir,
1741
+ sourceType: "clawhub",
1742
+ sourceUrl: url,
1743
+ commitSha: null,
1744
+ inferredName: parts.skillName,
1745
+ cleanup: () => cleanupDir(tempDir)
1746
+ };
1747
+ }
1748
+ function parseSkillsShUrl(url) {
1749
+ try {
1750
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.split("/").filter(Boolean);
1751
+ if (segments.length < 2) return null;
1752
+ return {
1753
+ owner: segments[0],
1754
+ repo: segments[1],
1755
+ skillName: segments[2] ?? null
1756
+ };
1757
+ } catch {
1758
+ return null;
1759
+ }
1760
+ }
1761
+ async function fetchFromSkillsSh(url, tempDir) {
1762
+ ensureGitInstalled();
1763
+ const parts = parseSkillsShUrl(url);
1764
+ if (!parts) throw new Error(`Invalid skills.sh URL: ${url}`);
1765
+ const cloneUrl = `https://github.com/${parts.owner}/${parts.repo}.git`;
1766
+ const cloneDest = join(tempDir, parts.repo);
1767
+ logger.info(`Cloning ${parts.owner}/${parts.repo} (via skills.sh)...`);
1768
+ gitCloneShallow(cloneUrl, cloneDest);
1769
+ const commitSha = gitRevParseHead(cloneDest);
1770
+ let localPath = cloneDest;
1771
+ const inferredName = parts.skillName ?? parts.repo;
1772
+ if (parts.skillName) {
1773
+ const candidates = [
1774
+ join(cloneDest, "skills", parts.skillName),
1775
+ join(cloneDest, "src", "skills", parts.skillName),
1776
+ join(cloneDest, parts.skillName)
1777
+ ];
1778
+ let found = false;
1779
+ for (const candidate of candidates) try {
1780
+ if ((await stat(candidate)).isDirectory()) {
1781
+ localPath = candidate;
1782
+ found = true;
1783
+ break;
1784
+ }
1785
+ } catch {}
1786
+ if (!found) throw new Error(`Skill "${parts.skillName}" not found in ${parts.repo}. Searched: skills/${parts.skillName}, src/skills/${parts.skillName}, ${parts.skillName}`);
1787
+ }
1788
+ return {
1789
+ localPath,
1790
+ sourceType: "skills_sh",
1791
+ sourceUrl: url,
1792
+ commitSha,
1793
+ inferredName,
1794
+ cleanup: () => cleanupDir(tempDir)
1795
+ };
1796
+ }
1797
+ async function fetchFromGenericUrl(url, tempDir) {
1798
+ const fullUrl = url.startsWith("http") ? url : `https://${url}`;
1799
+ logger.info(`Downloading from ${fullUrl}...`);
1800
+ const isTarball = /\.(tar\.gz|tgz)(\?|$)/i.test(fullUrl);
1801
+ const isZip = /\.zip(\?|$)/i.test(fullUrl);
1802
+ if (!isTarball && !isZip) {
1803
+ const archivePath = join(tempDir, "skill.tar.gz");
1804
+ await downloadFile(fullUrl, archivePath);
1805
+ const extractDir = join(tempDir, "skill");
1806
+ await mkdir(extractDir, { recursive: true });
1807
+ try {
1808
+ await extractTarball(archivePath, extractDir);
1809
+ } catch {
1810
+ try {
1811
+ await extractZip(archivePath, extractDir);
1812
+ } catch {
1813
+ throw new Error(`Failed to extract archive from ${fullUrl}. Expected .tar.gz or .zip format.`);
1814
+ }
1815
+ }
1816
+ return {
1817
+ localPath: extractDir,
1818
+ sourceType: detectSourceType(url),
1819
+ sourceUrl: url,
1820
+ commitSha: null,
1821
+ inferredName: inferSkillName(url),
1822
+ cleanup: () => cleanupDir(tempDir)
1823
+ };
1824
+ }
1825
+ const archivePath = join(tempDir, `skill.${isTarball ? "tar.gz" : "zip"}`);
1826
+ await downloadFile(fullUrl, archivePath);
1827
+ const extractDir = join(tempDir, "skill");
1828
+ await mkdir(extractDir, { recursive: true });
1829
+ if (isTarball) await extractTarball(archivePath, extractDir);
1830
+ else await extractZip(archivePath, extractDir);
1831
+ return {
1832
+ localPath: extractDir,
1833
+ sourceType: detectSourceType(url),
1834
+ sourceUrl: url,
1835
+ commitSha: null,
1836
+ inferredName: inferSkillName(url),
1837
+ cleanup: () => cleanupDir(tempDir)
1838
+ };
1839
+ }
1840
+ /** Fetch a skill from a URL to a local temp directory. */
1841
+ async function fetchFromUrl(url) {
1842
+ const sourceType = detectSourceType(url);
1843
+ let tempDir = null;
1844
+ try {
1845
+ tempDir = await createTempDir();
1846
+ let result;
1847
+ switch (sourceType) {
1848
+ case "github":
1849
+ result = await fetchFromGitHub(url, tempDir);
1850
+ break;
1851
+ case "clawhub":
1852
+ result = await fetchFromClawHub(url, tempDir);
1853
+ break;
1854
+ case "skills_sh":
1855
+ result = await fetchFromSkillsSh(url, tempDir);
1856
+ break;
1857
+ default:
1858
+ result = await fetchFromGenericUrl(url, tempDir);
1859
+ break;
1860
+ }
1861
+ return {
1862
+ success: true,
1863
+ ...result
1864
+ };
1865
+ } catch (err) {
1866
+ if (tempDir) await cleanupDir(tempDir);
1867
+ return {
1868
+ success: false,
1869
+ error: err instanceof Error ? err.message : String(err)
1870
+ };
1871
+ }
1872
+ }
1873
+ //#endregion
1330
1874
  //#region src/commands/install.ts
1331
1875
  function createRegistryFetcher(registry, headers) {
1332
1876
  const versionsCache = /* @__PURE__ */ new Map();
@@ -1755,6 +2299,149 @@ async function installAll(options) {
1755
2299
  function buildIntegrity(buffer) {
1756
2300
  return `sha512-${crypto$1.createHash("sha512").update(buffer).digest("base64")}`;
1757
2301
  }
2302
+ /** Map url-fetcher source types to lockfile SkillSource values. */
2303
+ function mapSourceType(urlSourceType) {
2304
+ switch (urlSourceType) {
2305
+ case "github": return "github";
2306
+ case "clawhub": return "clawhub";
2307
+ case "skills_sh": return "skills_sh";
2308
+ case "agentskills_il": return "agentskills_il";
2309
+ case "npm": return "npm";
2310
+ default: return "local";
2311
+ }
2312
+ }
2313
+ /** Compute SHA-512 integrity hash over all files in a directory (sorted by path). */
2314
+ function computeDirectoryIntegrity(dir) {
2315
+ const files = [];
2316
+ function walkDir(current) {
2317
+ const entries = fs.readdirSync(current, { withFileTypes: true });
2318
+ for (const entry of entries) {
2319
+ if (entry.name === ".git") continue;
2320
+ const fullPath = path.join(current, entry.name);
2321
+ if (entry.isDirectory()) walkDir(fullPath);
2322
+ else if (entry.isFile()) files.push(fullPath);
2323
+ }
2324
+ }
2325
+ walkDir(dir);
2326
+ files.sort();
2327
+ const hash = crypto$1.createHash("sha512");
2328
+ for (const file of files) hash.update(fs.readFileSync(file));
2329
+ return `sha512-${hash.digest("base64")}`;
2330
+ }
2331
+ /** Read a manifest (tank.json or skills.json) from a directory, returning null if missing/invalid. */
2332
+ function readManifestFromDir(dir) {
2333
+ for (const filename of ["tank.json", "skills.json"]) {
2334
+ const manifestPath = path.join(dir, filename);
2335
+ if (fs.existsSync(manifestPath)) try {
2336
+ return JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
2337
+ } catch {
2338
+ return null;
2339
+ }
2340
+ }
2341
+ return null;
2342
+ }
2343
+ async function installFromUrl(url, options) {
2344
+ const { global = false, yes = false } = options;
2345
+ const resolvedHome = os.homedir();
2346
+ const directory = process.cwd();
2347
+ const spinner = ora(`Fetching from URL...`).start();
2348
+ let fetchResult;
2349
+ try {
2350
+ const output = await fetchFromUrl(url);
2351
+ if (!output.success) {
2352
+ spinner.fail("Fetch failed");
2353
+ logger.error(output.error);
2354
+ process.exit(1);
2355
+ }
2356
+ fetchResult = output;
2357
+ spinner.text = "Scanning for security issues...";
2358
+ } catch (err) {
2359
+ spinner.fail("Fetch failed");
2360
+ const msg = err instanceof Error ? err.message : String(err);
2361
+ logger.error(msg);
2362
+ process.exit(1);
2363
+ }
2364
+ try {
2365
+ const scanResult = await scanUrl(url);
2366
+ displayScanResults(scanResult);
2367
+ const enforcement = await enforceVerdict(scanResult, { yes });
2368
+ if (!enforcement.allowed) {
2369
+ spinner.fail(enforcement.reason ?? "Install blocked by security scan");
2370
+ await fetchResult.cleanup();
2371
+ process.exit(1);
2372
+ }
2373
+ const skillMdPath = path.join(fetchResult.localPath, "SKILL.md");
2374
+ if (!fs.existsSync(skillMdPath)) throw new Error("No SKILL.md found. This doesn't appear to be a valid skill.");
2375
+ const existingManifest = readManifestFromDir(fetchResult.localPath);
2376
+ const skillName = existingManifest?.name ?? fetchResult.inferredName ?? path.basename(fetchResult.localPath);
2377
+ const skillVersion = existingManifest?.version ?? "0.0.0";
2378
+ const skillDescription = existingManifest?.description ?? "";
2379
+ if (!existingManifest) {
2380
+ const generatedManifest = {
2381
+ name: skillName,
2382
+ version: skillVersion,
2383
+ description: skillDescription
2384
+ };
2385
+ fs.writeFileSync(path.join(fetchResult.localPath, "tank.json"), `${JSON.stringify(generatedManifest, null, 2)}\n`);
2386
+ logger.info("Generated tank.json (no manifest found in source)");
2387
+ }
2388
+ spinner.text = `Installing ${skillName}...`;
2389
+ const installDir = global ? path.join(resolvedHome, ".tank", "skills", skillName) : path.join(directory, ".tank", "skills", skillName);
2390
+ if (fs.existsSync(installDir)) fs.rmSync(installDir, {
2391
+ recursive: true,
2392
+ force: true
2393
+ });
2394
+ fs.mkdirSync(path.dirname(installDir), { recursive: true });
2395
+ fs.cpSync(fetchResult.localPath, installDir, { recursive: true });
2396
+ const integrity = computeDirectoryIntegrity(installDir);
2397
+ const resolvedLock = resolveLockfilePath(global ? path.join(resolvedHome, ".tank") : directory);
2398
+ const lockPath = resolvedLock.exists ? resolvedLock.path : global ? path.join(resolvedHome, ".tank", LOCKFILE_FILENAME) : path.join(directory, LOCKFILE_FILENAME);
2399
+ const lock = readLockOrFresh(lockPath);
2400
+ const lockKey = `${skillName}@${skillVersion}`;
2401
+ const skillPermissions = existingManifest?.permissions ?? {};
2402
+ lock.skills[lockKey] = {
2403
+ resolved: url.startsWith("http") ? url : `https://${url}`,
2404
+ integrity,
2405
+ permissions: skillPermissions,
2406
+ audit_score: scanResult.auditScore ?? null,
2407
+ source: mapSourceType(fetchResult.sourceType),
2408
+ scan_verdict: scanResult.verdict,
2409
+ scanned_at: (/* @__PURE__ */ new Date()).toISOString()
2410
+ };
2411
+ lock.lockfileVersion = 2;
2412
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
2413
+ fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`);
2414
+ const linkedAgents = [];
2415
+ try {
2416
+ const agentSkillsBaseDir = global ? getGlobalAgentSkillsDir(resolvedHome) : path.join(directory, ".tank", "agent-skills");
2417
+ const linksDir = global ? path.join(resolvedHome, ".tank") : path.join(directory, ".tank");
2418
+ const linkResult = linkSkillToAgents({
2419
+ skillName,
2420
+ sourceDir: prepareAgentSkillDir({
2421
+ skillName,
2422
+ extractDir: installDir,
2423
+ agentSkillsBaseDir,
2424
+ description: skillDescription
2425
+ }),
2426
+ linksDir,
2427
+ source: global ? "global" : "local"
2428
+ });
2429
+ linkedAgents.push(...linkResult.linked);
2430
+ if (linkResult.failed.length > 0) for (const failedLink of linkResult.failed) logger.warn(`Failed to link to ${failedLink.agentId}: ${failedLink.error}`);
2431
+ } catch {
2432
+ logger.warn("Agent linking skipped (non-fatal)");
2433
+ }
2434
+ if (detectInstalledAgents().length === 0) logger.warn("No agents detected for linking");
2435
+ await fetchResult.cleanup();
2436
+ spinner.succeed(`Installed ${skillName} from ${fetchResult.sourceType}`);
2437
+ if (linkedAgents.length > 0) logger.info(`Linked to ${linkedAgents.join(", ")}`);
2438
+ logger.info(`Locked (${integrity.slice(0, 20)}..., scanned ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]})`);
2439
+ } catch (err) {
2440
+ await fetchResult.cleanup();
2441
+ spinner.fail("Install failed");
2442
+ throw err;
2443
+ }
2444
+ }
1758
2445
  //#endregion
1759
2446
  //#region src/commands/link.ts
1760
2447
  async function linkCommand(options = {}) {
@@ -3399,9 +4086,13 @@ program.command("publish").alias("pub").description("Pack and publish a skill to
3399
4086
  process.exit(1);
3400
4087
  }
3401
4088
  });
3402
- 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) => {
4089
+ program.command("install").alias("i").description("Install a skill from the Tank registry, a URL, or all skills from lockfile").argument("[name]", "Skill name or URL (e.g., @org/skill-name or https://github.com/owner/repo). 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 flagged scan verdicts").action(async (name, versionRange, opts) => {
3403
4090
  try {
3404
- if (name) await installCommand({
4091
+ if (name && isUrl(name)) await installFromUrl(name, {
4092
+ global: opts.global,
4093
+ yes: opts.yes
4094
+ });
4095
+ else if (name) await installCommand({
3405
4096
  name,
3406
4097
  versionRange,
3407
4098
  global: opts.global