@tankpkg/cli 0.0.0-nightly.20260415.9bc2c8a → 0.0.0-nightly.20260416.ff1c580
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 +701 -10
- package/dist/bin/tank.js.map +1 -1
- package/dist/{debug-logger-CbsZyznz.js → debug-logger-BIlzIOBz.js} +2 -2
- package/dist/{debug-logger-CbsZyznz.js.map → debug-logger-BIlzIOBz.js.map} +1 -1
- package/dist/index.js +1 -1
- package/dist/package.json +1 -1
- package/package.json +1 -1
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-
|
|
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-BIlzIOBz.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$
|
|
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$
|
|
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,
|
|
1459
|
+
findings,
|
|
1460
|
+
durationMs: data.duration_ms
|
|
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
|
|
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
|