@tankpkg/cli 0.0.0-nightly.20260401.49863f7

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.
@@ -0,0 +1,3205 @@
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-CvbB8v_c.js";
3
+ import { Command } from "commander";
4
+ import chalk from "chalk";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { z } from "zod";
9
+ import { confirm, input } from "@inquirer/prompts";
10
+ import crypto$1 from "node:crypto";
11
+ import semver from "semver";
12
+ import ora from "ora";
13
+ import { buildSkillKey, checkPermissionBudget, downloadAllParallel, extractSafely, getExtractDir as getExtractDir$1, getGlobalExtractDir, getResolvedNodesInOrder, parseLockKey as parseLockKey$2, parseVersionFromLockKey, readExtractedDependencies, resolveDependencyTree, verifyExtractedDependencies, writeLockfileWithResolvedGraph } from "@tankpkg/sdk";
14
+ import open from "open";
15
+ import ignore from "ignore";
16
+ import { create } from "tar";
17
+ import { spawn } from "node:child_process";
18
+ import { fileURLToPath } from "node:url";
19
+ process.env.TANK_REGISTRY_URL;
20
+ const MANIFEST_FILENAME = "tank.json";
21
+ const LEGACY_MANIFEST_FILENAME = "skills.json";
22
+ const LOCKFILE_FILENAME = "tank.lock";
23
+ const LEGACY_LOCKFILE_FILENAME = "skills.lock";
24
+ const networkPermissionsSchema = z.object({ outbound: z.array(z.string()).optional() }).strict();
25
+ const filesystemPermissionsSchema = z.object({
26
+ read: z.array(z.string()).optional(),
27
+ write: z.array(z.string()).optional()
28
+ }).strict();
29
+ const permissionsSchema = z.object({
30
+ network: networkPermissionsSchema.optional(),
31
+ filesystem: filesystemPermissionsSchema.optional(),
32
+ subprocess: z.boolean().optional()
33
+ }).strict();
34
+ z.enum(["user", "admin"]);
35
+ z.enum([
36
+ "active",
37
+ "suspended",
38
+ "banned"
39
+ ]);
40
+ z.enum([
41
+ "active",
42
+ "deprecated",
43
+ "quarantined",
44
+ "removed"
45
+ ]);
46
+ z.enum([
47
+ "user.ban",
48
+ "user.suspend",
49
+ "user.unban",
50
+ "user.promote",
51
+ "user.demote",
52
+ "skill.quarantine",
53
+ "skill.remove",
54
+ "skill.deprecate",
55
+ "skill.restore",
56
+ "skill.feature",
57
+ "skill.unfeature",
58
+ "org.suspend",
59
+ "org.member.remove",
60
+ "org.delete"
61
+ ]);
62
+ const skillsJsonSchema = z.object({
63
+ 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"),
64
+ version: z.string().regex(/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/, "Version must be valid semver"),
65
+ description: z.string().max(500, `Description must be 500 characters or fewer`).optional(),
66
+ skills: z.record(z.string(), z.string()).optional(),
67
+ permissions: permissionsSchema.optional(),
68
+ repository: z.string().url("Repository must be a valid URL").optional(),
69
+ visibility: z.enum(["public", "private"]).optional(),
70
+ audit: z.object({ min_score: z.number().min(0).max(10) }).strict().optional()
71
+ }).strict();
72
+ const lockedSkillV1Schema = z.object({
73
+ resolved: z.string().url(),
74
+ integrity: z.string().regex(/^sha512-/, "Integrity must start with sha512-"),
75
+ permissions: permissionsSchema,
76
+ audit_score: z.number().min(0).max(10).nullable()
77
+ });
78
+ z.object({
79
+ lockfileVersion: z.literal(1),
80
+ skills: z.record(z.string(), lockedSkillV1Schema)
81
+ });
82
+ const lockedSkillSchema = z.object({
83
+ resolved: z.string().url(),
84
+ integrity: z.string().regex(/^sha512-/, "Integrity must start with sha512-"),
85
+ permissions: permissionsSchema,
86
+ audit_score: z.number().min(0).max(10).nullable(),
87
+ dependencies: z.record(z.string(), z.string()).optional()
88
+ });
89
+ z.object({
90
+ lockfileVersion: z.union([z.literal(1), z.literal(2)]),
91
+ skills: z.record(z.string(), lockedSkillSchema)
92
+ });
93
+ //#endregion
94
+ //#region src/lib/manifest.ts
95
+ const warnedManifest = /* @__PURE__ */ new Set();
96
+ const warnedLockfile = /* @__PURE__ */ new Set();
97
+ /**
98
+ * Resolve the manifest file path with fallback priority:
99
+ * 1. tank.json (preferred)
100
+ * 2. skills.json (deprecated fallback)
101
+ *
102
+ * If both exist, prefers tank.json and warns about duplicate.
103
+ * Returns the path even if neither exists (for write operations).
104
+ */
105
+ function resolveManifestPath(directory) {
106
+ const dir = directory ?? process.cwd();
107
+ const newPath = path.join(dir, MANIFEST_FILENAME);
108
+ const legacyPath = path.join(dir, LEGACY_MANIFEST_FILENAME);
109
+ const newExists = fs.existsSync(newPath);
110
+ const legacyExists = fs.existsSync(legacyPath);
111
+ if (newExists && legacyExists && !warnedManifest.has(dir)) {
112
+ warnedManifest.add(dir);
113
+ logger.warn(`Both ${MANIFEST_FILENAME} and ${LEGACY_MANIFEST_FILENAME} exist. Using ${MANIFEST_FILENAME}.`);
114
+ }
115
+ if (newExists) return {
116
+ path: newPath,
117
+ isLegacy: false,
118
+ exists: true
119
+ };
120
+ if (legacyExists) {
121
+ if (!warnedManifest.has(dir)) {
122
+ warnedManifest.add(dir);
123
+ logger.warn(`${LEGACY_MANIFEST_FILENAME} is deprecated — run \`tank migrate\` to switch to ${MANIFEST_FILENAME}`);
124
+ }
125
+ return {
126
+ path: legacyPath,
127
+ isLegacy: true,
128
+ exists: true
129
+ };
130
+ }
131
+ return {
132
+ path: newPath,
133
+ isLegacy: false,
134
+ exists: false
135
+ };
136
+ }
137
+ /**
138
+ * Resolve the lockfile path with fallback priority:
139
+ * 1. tank.lock (preferred)
140
+ * 2. skills.lock (deprecated fallback)
141
+ */
142
+ function resolveLockfilePath(directory) {
143
+ const dir = directory ?? process.cwd();
144
+ const newPath = path.join(dir, LOCKFILE_FILENAME);
145
+ const legacyPath = path.join(dir, LEGACY_LOCKFILE_FILENAME);
146
+ const newExists = fs.existsSync(newPath);
147
+ const legacyExists = fs.existsSync(legacyPath);
148
+ if (newExists && legacyExists && !warnedLockfile.has(dir)) {
149
+ warnedLockfile.add(dir);
150
+ logger.warn(`Both ${LOCKFILE_FILENAME} and ${LEGACY_LOCKFILE_FILENAME} exist. Using ${LOCKFILE_FILENAME}.`);
151
+ }
152
+ if (newExists) return {
153
+ path: newPath,
154
+ isLegacy: false,
155
+ exists: true
156
+ };
157
+ if (legacyExists) {
158
+ if (!warnedLockfile.has(dir)) {
159
+ warnedLockfile.add(dir);
160
+ logger.warn(`${LEGACY_LOCKFILE_FILENAME} is deprecated — run \`tank migrate\` to switch to ${LOCKFILE_FILENAME}`);
161
+ }
162
+ return {
163
+ path: legacyPath,
164
+ isLegacy: true,
165
+ exists: true
166
+ };
167
+ }
168
+ return {
169
+ path: newPath,
170
+ isLegacy: false,
171
+ exists: false
172
+ };
173
+ }
174
+ //#endregion
175
+ //#region src/lib/lockfile.ts
176
+ /**
177
+ * Read and parse the lockfile from the given directory.
178
+ * Returns null if the file doesn't exist or is corrupt.
179
+ */
180
+ function readLockfile$1(directory) {
181
+ const resolved = resolveLockfilePath(directory);
182
+ if (!resolved.exists) return null;
183
+ try {
184
+ const raw = fs.readFileSync(resolved.path, "utf-8");
185
+ return JSON.parse(raw);
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+ //#endregion
191
+ //#region src/commands/audit.ts
192
+ function scoreColor$2(score) {
193
+ if (score >= 7) return chalk.green;
194
+ if (score >= 4) return chalk.yellow;
195
+ return chalk.red;
196
+ }
197
+ function formatScore(result) {
198
+ if (result.error) return chalk.dim("error");
199
+ if (result.score == null || result.status !== "completed") return chalk.dim("pending");
200
+ return scoreColor$2(result.score)(result.score.toFixed(1));
201
+ }
202
+ function formatStatus(result) {
203
+ if (result.error) return chalk.dim("error");
204
+ if (result.score == null || result.status !== "completed") return chalk.dim("Analysis pending");
205
+ if (result.score >= 4) return chalk.green("pass");
206
+ return chalk.red("issues");
207
+ }
208
+ function padRight$1(text, width) {
209
+ if (text.length >= width) return text;
210
+ return text + " ".repeat(width - text.length);
211
+ }
212
+ /**
213
+ * Parse a lockfile key like "@org/skill@1.0.0" into { name, version }.
214
+ * Scoped packages start with @, so find the LAST @ to split.
215
+ */
216
+ function parseLockKey$4(key) {
217
+ const lastAt = key.lastIndexOf("@");
218
+ if (lastAt <= 0) throw new Error(`Invalid lockfile key: ${key}`);
219
+ return {
220
+ name: key.slice(0, lastAt),
221
+ version: key.slice(lastAt + 1)
222
+ };
223
+ }
224
+ async function fetchVersionDetails(registryUrl, name, version) {
225
+ const url = `${registryUrl}/api/v1/skills/${encodeURIComponent(name)}/${version}`;
226
+ let res;
227
+ try {
228
+ res = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
229
+ } catch (err) {
230
+ throw new Error(`Network error fetching audit data: ${err instanceof Error ? err.message : String(err)}`);
231
+ }
232
+ if (!res.ok) throw new Error(`API error for ${name}@${version}: ${res.status} ${res.statusText}`);
233
+ return await res.json();
234
+ }
235
+ function displayDetailedAudit(result) {
236
+ console.log("");
237
+ console.log(chalk.bold(result.name));
238
+ console.log("");
239
+ console.log(`${chalk.dim("Version:".padEnd(14))}${result.version}`);
240
+ console.log(`${chalk.dim("Audit Score:".padEnd(14))}${formatScore(result)}`);
241
+ console.log(`${chalk.dim("Status:".padEnd(14))}${result.status}`);
242
+ const perms = result.permissions;
243
+ if (perms) {
244
+ console.log("");
245
+ console.log(chalk.bold("Permissions:"));
246
+ const networkDomains = perms.network?.outbound;
247
+ if (networkDomains && networkDomains.length > 0) console.log(` ${chalk.dim("Network:".padEnd(14))}${networkDomains.join(", ")}`);
248
+ const fsRead = perms.filesystem?.read;
249
+ const fsWrite = perms.filesystem?.write;
250
+ if (fsRead || fsWrite) {
251
+ const parts = [];
252
+ if (fsRead && fsRead.length > 0) parts.push(`${fsRead.join(", ")} (read)`);
253
+ if (fsWrite && fsWrite.length > 0) parts.push(`${fsWrite.join(", ")} (write)`);
254
+ console.log(` ${chalk.dim("Filesystem:".padEnd(14))}${parts.join(", ")}`);
255
+ }
256
+ console.log(` ${chalk.dim("Subprocess:".padEnd(14))}${perms.subprocess ? "yes" : "no"}`);
257
+ }
258
+ }
259
+ function displayTable(results) {
260
+ console.log(`${padRight$1("NAME", 30) + padRight$1("VERSION", 12) + padRight$1("SCORE", 10)}STATUS`);
261
+ for (const result of results) {
262
+ const name = chalk.bold(padRight$1(result.name, 30));
263
+ const version = padRight$1(result.version, 12);
264
+ const score = padRight$1(formatScore(result), 10);
265
+ const status = formatStatus(result);
266
+ console.log(`${name}${version}${score}${status}`);
267
+ }
268
+ const total = results.length;
269
+ const pass = results.filter((r) => !r.error && r.score != null && r.status === "completed" && r.score >= 4).length;
270
+ const issues = total - pass;
271
+ console.log("");
272
+ console.log(`${total} skill${total === 1 ? "" : "s"} audited. ${pass} pass, ${issues} ${issues === 1 ? "has" : "have"} issues.`);
273
+ }
274
+ async function auditCommand(options) {
275
+ const { name, configDir } = options;
276
+ const config = getConfig(configDir);
277
+ const lock = readLockfile$1();
278
+ if (!lock) {
279
+ console.log("No lockfile found. Run: tank install");
280
+ return;
281
+ }
282
+ const entries = Object.entries(lock.skills);
283
+ if (entries.length === 0) {
284
+ console.log("No skills installed.");
285
+ return;
286
+ }
287
+ if (name) {
288
+ const matchingEntry = entries.find(([key]) => {
289
+ return parseLockKey$4(key).name === name;
290
+ });
291
+ if (!matchingEntry) {
292
+ console.log(`Skill not installed: ${name}`);
293
+ return;
294
+ }
295
+ const [key] = matchingEntry;
296
+ const parsed = parseLockKey$4(key);
297
+ const details = await fetchVersionDetails(config.registry, parsed.name, parsed.version);
298
+ displayDetailedAudit({
299
+ name: parsed.name,
300
+ version: parsed.version,
301
+ score: details.auditScore,
302
+ status: details.auditStatus,
303
+ permissions: details.permissions
304
+ });
305
+ return;
306
+ }
307
+ const results = [];
308
+ for (const [key] of entries) {
309
+ const parsed = parseLockKey$4(key);
310
+ try {
311
+ const details = await fetchVersionDetails(config.registry, parsed.name, parsed.version);
312
+ results.push({
313
+ name: parsed.name,
314
+ version: parsed.version,
315
+ score: details.auditScore,
316
+ status: details.auditStatus
317
+ });
318
+ } catch (err) {
319
+ if (err instanceof Error && err.message.startsWith("Network error")) throw err;
320
+ results.push({
321
+ name: parsed.name,
322
+ version: parsed.version,
323
+ score: null,
324
+ status: "error",
325
+ error: true
326
+ });
327
+ }
328
+ }
329
+ displayTable(results);
330
+ }
331
+ //#endregion
332
+ //#region src/lib/agents.ts
333
+ const resolveHomedir = (homedir) => homedir ?? os.homedir();
334
+ const isWindows = process.platform === "win32";
335
+ const SUPPORTED_AGENTS = [
336
+ {
337
+ id: "claude",
338
+ name: "Claude Code",
339
+ configDirs: (homedir) => [path.join(homedir, ".claude")]
340
+ },
341
+ {
342
+ id: "opencode",
343
+ name: "OpenCode",
344
+ configDirs: (homedir) => {
345
+ const dirs = [path.join(homedir, ".config", "opencode")];
346
+ if (isWindows) {
347
+ const appData = process.env.APPDATA;
348
+ if (appData) dirs.push(path.join(appData, "opencode"));
349
+ }
350
+ return dirs;
351
+ }
352
+ },
353
+ {
354
+ id: "cursor",
355
+ name: "Cursor",
356
+ configDirs: (homedir) => {
357
+ const dirs = [path.join(homedir, ".cursor")];
358
+ if (isWindows) {
359
+ const appData = process.env.APPDATA;
360
+ if (appData) dirs.push(path.join(appData, "Cursor"));
361
+ }
362
+ return dirs;
363
+ }
364
+ },
365
+ {
366
+ id: "codex",
367
+ name: "Codex",
368
+ configDirs: (homedir) => [path.join(homedir, ".codex")]
369
+ },
370
+ {
371
+ id: "openclaw",
372
+ name: "OpenClaw",
373
+ configDirs: (homedir) => [path.join(homedir, ".openclaw")]
374
+ },
375
+ {
376
+ id: "universal",
377
+ name: "Universal",
378
+ configDirs: (homedir) => [path.join(homedir, ".agents")]
379
+ }
380
+ ];
381
+ /**
382
+ * Returns the first existing config directory for an agent,
383
+ * or the first (default) directory if none exist.
384
+ */
385
+ function resolveConfigDir(agent, homedir) {
386
+ const dirs = agent.configDirs(homedir);
387
+ return dirs.find((d) => fs.existsSync(d)) ?? dirs[0];
388
+ }
389
+ function isAgentInstalled(agent, homedir) {
390
+ return agent.configDirs(homedir).some((d) => fs.existsSync(d));
391
+ }
392
+ function getSupportedAgents(homedir) {
393
+ const resolved = resolveHomedir(homedir);
394
+ return SUPPORTED_AGENTS.map((agent) => ({
395
+ id: agent.id,
396
+ name: agent.name,
397
+ skillsDir: path.join(resolveConfigDir(agent, resolved), "skills")
398
+ }));
399
+ }
400
+ function detectInstalledAgents(homedir) {
401
+ const resolved = resolveHomedir(homedir);
402
+ return SUPPORTED_AGENTS.filter((agent) => isAgentInstalled(agent, resolved)).map((agent) => ({
403
+ id: agent.id,
404
+ name: agent.name,
405
+ skillsDir: path.join(resolveConfigDir(agent, resolved), "skills")
406
+ }));
407
+ }
408
+ function getSymlinkName(skillName) {
409
+ const match = skillName.match(/^@([^/]+)\/(.+)$/);
410
+ if (!match) return skillName;
411
+ const [, scope, name] = match;
412
+ if (scope.length === 0 || name.length === 0) return skillName;
413
+ return `${scope}--${name}`;
414
+ }
415
+ function getGlobalSkillsDir(homedir) {
416
+ return path.join(resolveHomedir(homedir), ".tank", "skills");
417
+ }
418
+ function getGlobalAgentSkillsDir(homedir) {
419
+ return path.join(resolveHomedir(homedir), ".tank", "agent-skills");
420
+ }
421
+ //#endregion
422
+ //#region src/lib/links.ts
423
+ function createEmptyManifest() {
424
+ return {
425
+ version: 1,
426
+ links: {}
427
+ };
428
+ }
429
+ function readLinks(linksDir) {
430
+ if (!fs.existsSync(linksDir)) return createEmptyManifest();
431
+ const linksPath = path.join(linksDir, "links.json");
432
+ if (!fs.existsSync(linksPath)) return createEmptyManifest();
433
+ try {
434
+ const raw = fs.readFileSync(linksPath, "utf-8");
435
+ return JSON.parse(raw);
436
+ } catch {
437
+ return createEmptyManifest();
438
+ }
439
+ }
440
+ function writeLinks(linksDir, manifest) {
441
+ if (!fs.existsSync(linksDir)) fs.mkdirSync(linksDir, { recursive: true });
442
+ const sortedLinks = {};
443
+ for (const skillName of Object.keys(manifest.links).sort()) {
444
+ const entry = manifest.links[skillName];
445
+ const sortedAgentLinks = {};
446
+ for (const agentId of Object.keys(entry.agentLinks).sort()) sortedAgentLinks[agentId] = entry.agentLinks[agentId];
447
+ sortedLinks[skillName] = {
448
+ source: entry.source,
449
+ sourceDir: entry.sourceDir,
450
+ installedAt: entry.installedAt,
451
+ agentLinks: sortedAgentLinks
452
+ };
453
+ }
454
+ const output = {
455
+ version: manifest.version,
456
+ links: sortedLinks
457
+ };
458
+ fs.writeFileSync(path.join(linksDir, "links.json"), `${JSON.stringify(output, null, 2)}\n`);
459
+ }
460
+ function readGlobalLinks(homedir) {
461
+ const home = homedir ?? os.homedir();
462
+ return readLinks(path.join(home, ".tank"));
463
+ }
464
+ //#endregion
465
+ //#region src/lib/linker.ts
466
+ const resolveSymlinkTarget = (symlinkPath, target) => {
467
+ if (path.isAbsolute(target)) return target;
468
+ return path.resolve(path.dirname(symlinkPath), target);
469
+ };
470
+ const checkSymlink = (symlinkPath) => {
471
+ try {
472
+ if (!fs.lstatSync(symlinkPath).isSymbolicLink()) return {
473
+ exists: true,
474
+ isSymlink: false,
475
+ targetPath: null,
476
+ targetValid: false
477
+ };
478
+ const targetPath = resolveSymlinkTarget(symlinkPath, fs.readlinkSync(symlinkPath));
479
+ return {
480
+ exists: true,
481
+ isSymlink: true,
482
+ targetPath,
483
+ targetValid: fs.existsSync(targetPath)
484
+ };
485
+ } catch {
486
+ return {
487
+ exists: false,
488
+ isSymlink: false,
489
+ targetPath: null,
490
+ targetValid: false
491
+ };
492
+ }
493
+ };
494
+ const createEntry = (manifest, skillName, entry) => ({
495
+ version: manifest.version,
496
+ links: {
497
+ ...manifest.links,
498
+ [skillName]: entry
499
+ }
500
+ });
501
+ function linkSkillToAgents(options) {
502
+ const result = {
503
+ linked: [],
504
+ skipped: [],
505
+ failed: []
506
+ };
507
+ const agents = detectInstalledAgents(options.homedir);
508
+ const symlinkName = getSymlinkName(options.skillName);
509
+ const resolvedSource = path.resolve(options.sourceDir);
510
+ const agentLinks = {};
511
+ for (const agent of agents) {
512
+ const symlinkPath = path.join(agent.skillsDir, symlinkName);
513
+ try {
514
+ fs.mkdirSync(agent.skillsDir, { recursive: true });
515
+ const check = checkSymlink(symlinkPath);
516
+ if (check.exists && !check.isSymlink) {
517
+ result.failed.push({
518
+ agentId: agent.id,
519
+ error: `Path exists and is not a symlink: ${symlinkPath}`
520
+ });
521
+ continue;
522
+ }
523
+ if (check.exists && check.isSymlink && check.targetPath) {
524
+ if (path.resolve(check.targetPath) === resolvedSource && check.targetValid) {
525
+ result.skipped.push(agent.id);
526
+ agentLinks[agent.id] = symlinkPath;
527
+ continue;
528
+ }
529
+ fs.unlinkSync(symlinkPath);
530
+ }
531
+ fs.symlinkSync(options.sourceDir, symlinkPath, "dir");
532
+ result.linked.push(agent.id);
533
+ agentLinks[agent.id] = symlinkPath;
534
+ } catch (error) {
535
+ const message = error instanceof Error ? error.message : String(error);
536
+ result.failed.push({
537
+ agentId: agent.id,
538
+ error: message
539
+ });
540
+ }
541
+ }
542
+ const manifest = readLinks(options.linksDir);
543
+ const entry = {
544
+ source: options.source,
545
+ sourceDir: options.sourceDir,
546
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
547
+ agentLinks
548
+ };
549
+ const updated = createEntry(manifest, options.skillName, entry);
550
+ writeLinks(options.linksDir, updated);
551
+ return result;
552
+ }
553
+ function unlinkSkillFromAgents(options) {
554
+ const manifest = readLinks(options.linksDir);
555
+ const entry = manifest.links[options.skillName];
556
+ if (!entry) return {
557
+ unlinked: [],
558
+ notFound: []
559
+ };
560
+ const result = {
561
+ unlinked: [],
562
+ notFound: []
563
+ };
564
+ for (const [agentId, symlinkPath] of Object.entries(entry.agentLinks)) try {
565
+ if (!fs.lstatSync(symlinkPath).isSymbolicLink()) {
566
+ result.notFound.push(agentId);
567
+ continue;
568
+ }
569
+ fs.unlinkSync(symlinkPath);
570
+ result.unlinked.push(agentId);
571
+ } catch {
572
+ result.notFound.push(agentId);
573
+ }
574
+ const updated = {
575
+ version: manifest.version,
576
+ links: { ...manifest.links }
577
+ };
578
+ if (options.skillName in updated.links) delete updated.links[options.skillName];
579
+ writeLinks(options.linksDir, updated);
580
+ return result;
581
+ }
582
+ const getStatusForAgent = (agent, skillName) => {
583
+ const symlinkName = getSymlinkName(skillName);
584
+ const symlinkPath = path.join(agent.skillsDir, symlinkName);
585
+ const check = checkSymlink(symlinkPath);
586
+ return {
587
+ agentId: agent.id,
588
+ agentName: agent.name,
589
+ linked: check.exists && check.isSymlink,
590
+ symlinkPath,
591
+ targetValid: check.exists && check.isSymlink && check.targetValid
592
+ };
593
+ };
594
+ function getSkillLinkStatus(options) {
595
+ const agents = detectInstalledAgents(options.homedir);
596
+ readLinks(options.linksDir);
597
+ return agents.map((agent) => getStatusForAgent(agent, options.skillName));
598
+ }
599
+ //#endregion
600
+ //#region src/commands/doctor.ts
601
+ const parseLockKey$3 = (key) => {
602
+ const lastAt = key.lastIndexOf("@");
603
+ if (lastAt > 0) return key.slice(0, lastAt);
604
+ return key;
605
+ };
606
+ const getExtractDir$2 = (baseDir, skillName) => {
607
+ if (skillName.startsWith("@")) {
608
+ const [scope, name] = skillName.split("/");
609
+ return path.join(baseDir, scope, name);
610
+ }
611
+ return path.join(baseDir, skillName);
612
+ };
613
+ const formatAgents = (agents, label) => {
614
+ if (agents.length === 0) return `${label}`;
615
+ return `${label} (${agents.map((agent) => agent.agentName).join(", ")})`;
616
+ };
617
+ const summarizeStatus = (skillName, statuses, extractExists, scope) => {
618
+ if (statuses.length === 0) return {
619
+ statusText: chalk.yellow("⚠️ no agents detected"),
620
+ issues: []
621
+ };
622
+ const brokenAgents = statuses.filter((status) => status.linked && !status.targetValid);
623
+ const linkedAgents = statuses.filter((status) => status.linked && status.targetValid);
624
+ if (brokenAgents.length > 0) return {
625
+ statusText: formatAgents(brokenAgents, chalk.yellow("⚠️ broken link")),
626
+ issues: [scope === "dev" ? `Run \`tank link\` in the skill directory to fix ${skillName}` : `Run \`tank install ${skillName}\` to fix broken link`]
627
+ };
628
+ if (linkedAgents.length > 0) {
629
+ if (!extractExists && scope !== "dev") return {
630
+ statusText: chalk.yellow("⚠️ missing extract"),
631
+ issues: [`Run \`tank install ${skillName}\` to install missing extract`]
632
+ };
633
+ return {
634
+ statusText: formatAgents(linkedAgents, chalk.green("✅ linked")),
635
+ issues: []
636
+ };
637
+ }
638
+ if (!extractExists && scope !== "dev") return {
639
+ statusText: chalk.yellow("⚠️ missing extract"),
640
+ issues: [`Run \`tank install ${skillName}\` to install missing extract`]
641
+ };
642
+ return {
643
+ statusText: chalk.red("❌ not linked"),
644
+ issues: []
645
+ };
646
+ };
647
+ const printSectionHeader = (title) => {
648
+ console.log(`\n${chalk.bold(title)}:`);
649
+ };
650
+ async function doctorCommand(options) {
651
+ try {
652
+ const directory = options?.directory ?? process.cwd();
653
+ const homedir = options?.homedir ?? os.homedir();
654
+ const supportedAgents = getSupportedAgents(homedir);
655
+ const installedAgents = detectInstalledAgents(homedir);
656
+ const installedIds = new Set(installedAgents.map((agent) => agent.id));
657
+ const resolvedManifest = resolveManifestPath(directory);
658
+ const localSkills = resolvedManifest.exists ? Object.keys(JSON.parse(fs.readFileSync(resolvedManifest.path, "utf-8")).skills ?? {}) : [];
659
+ localSkills.sort();
660
+ const resolvedGlobalLock = resolveLockfilePath(path.join(homedir, ".tank"));
661
+ const globalSkills = resolvedGlobalLock.exists ? Object.keys(JSON.parse(fs.readFileSync(resolvedGlobalLock.path, "utf-8")).skills ?? {}).map(parseLockKey$3) : [];
662
+ const uniqueGlobal = Array.from(new Set(globalSkills)).sort();
663
+ const globalLinks = readGlobalLinks(homedir);
664
+ const devLinks = Object.entries(globalLinks.links).filter(([, entry]) => entry.source === "dev").map(([skillName]) => skillName).sort();
665
+ const suggestions = /* @__PURE__ */ new Set();
666
+ console.log(chalk.bold("Tank Doctor Report"));
667
+ console.log(chalk.bold("=================="));
668
+ printSectionHeader("Detected Agents");
669
+ for (const agent of supportedAgents) {
670
+ const installed = installedIds.has(agent.id);
671
+ const icon = installed ? chalk.green("✅") : chalk.red("❌");
672
+ const details = installed ? agent.skillsDir : chalk.gray("(not found)");
673
+ console.log(` ${icon} ${agent.name} ${details}`);
674
+ }
675
+ if (installedAgents.length === 0) suggestions.add("No agents detected. Install an AI agent to enable skill linking.");
676
+ const localLinksDir = path.join(directory, ".tank");
677
+ printSectionHeader(`Local Skills (${localSkills.length}): [project: ${directory}]`);
678
+ if (localSkills.length === 0) console.log(" none");
679
+ for (const skillName of localSkills) {
680
+ const extractDir = getExtractDir$2(path.join(directory, ".tank", "skills"), skillName);
681
+ const extractExists = fs.existsSync(extractDir);
682
+ const summary = summarizeStatus(skillName, getSkillLinkStatus({
683
+ skillName,
684
+ linksDir: localLinksDir,
685
+ homedir
686
+ }), extractExists, "local");
687
+ for (const issue of summary.issues) suggestions.add(issue);
688
+ console.log(` ${skillName} ${summary.statusText}`);
689
+ }
690
+ const globalLinksDir = path.join(homedir, ".tank");
691
+ const globalSkillsDir = getGlobalSkillsDir(homedir);
692
+ printSectionHeader(`Global Skills (${uniqueGlobal.length}): [${globalSkillsDir}]`);
693
+ if (uniqueGlobal.length === 0) console.log(" none");
694
+ for (const skillName of uniqueGlobal) {
695
+ const extractDir = getExtractDir$2(globalSkillsDir, skillName);
696
+ const extractExists = fs.existsSync(extractDir);
697
+ const summary = summarizeStatus(skillName, getSkillLinkStatus({
698
+ skillName,
699
+ linksDir: globalLinksDir,
700
+ homedir
701
+ }), extractExists, "global");
702
+ for (const issue of summary.issues) suggestions.add(issue);
703
+ console.log(` ${skillName} ${summary.statusText}`);
704
+ }
705
+ printSectionHeader(`Dev Links (${devLinks.length}): [tank link]`);
706
+ if (devLinks.length === 0) console.log(" none");
707
+ for (const skillName of devLinks) {
708
+ const summary = summarizeStatus(skillName, getSkillLinkStatus({
709
+ skillName,
710
+ linksDir: globalLinksDir,
711
+ homedir
712
+ }), true, "dev");
713
+ for (const issue of summary.issues) suggestions.add(issue);
714
+ console.log(` ${skillName} ${summary.statusText}`);
715
+ }
716
+ if (localSkills.length === 0 && uniqueGlobal.length === 0 && devLinks.length === 0) suggestions.add("Run `tank install @tank/typescript` to add your first skill");
717
+ printSectionHeader("Suggestions");
718
+ if (suggestions.size === 0) console.log(" none");
719
+ else for (const suggestion of suggestions) console.log(` • ${suggestion}`);
720
+ } catch (error) {
721
+ const message = error instanceof Error ? error.message : String(error);
722
+ console.log(chalk.red(`Doctor report failed: ${message}`));
723
+ console.log("Suggestions:");
724
+ console.log(" • Run `tank install @tank/typescript` to add your first skill");
725
+ }
726
+ }
727
+ //#endregion
728
+ //#region src/commands/info.ts
729
+ function formatDate(iso) {
730
+ try {
731
+ return iso.split("T")[0];
732
+ } catch {
733
+ return iso;
734
+ }
735
+ }
736
+ function labelValue(label, value) {
737
+ return `${chalk.dim(label.padEnd(14))}${value}`;
738
+ }
739
+ async function infoCommand(options) {
740
+ const { name, configDir } = options;
741
+ const config = getConfig(configDir);
742
+ const encodedName = encodeURIComponent(name);
743
+ const metaUrl = `${config.registry}/api/v1/skills/${encodedName}`;
744
+ const headers = { "User-Agent": USER_AGENT };
745
+ if (config.token) headers.Authorization = `Bearer ${config.token}`;
746
+ let metaRes;
747
+ try {
748
+ metaRes = await fetch(metaUrl, { headers });
749
+ } catch (err) {
750
+ throw new Error(`Network error fetching skill info: ${err instanceof Error ? err.message : String(err)}`);
751
+ }
752
+ if (metaRes.status === 404) {
753
+ console.log(`Skill not found: ${name}`);
754
+ return;
755
+ }
756
+ if (!metaRes.ok) {
757
+ const body = await metaRes.json().catch(() => null);
758
+ throw new Error(body?.error ?? `Failed to fetch skill info: ${metaRes.statusText}`);
759
+ }
760
+ const meta = await metaRes.json();
761
+ const versionUrl = `${config.registry}/api/v1/skills/${encodedName}/${meta.latestVersion}`;
762
+ let versionRes;
763
+ try {
764
+ versionRes = await fetch(versionUrl, { headers });
765
+ } catch (err) {
766
+ throw new Error(`Network error fetching version details: ${err instanceof Error ? err.message : String(err)}`);
767
+ }
768
+ let versionData;
769
+ if (versionRes.ok) versionData = await versionRes.json();
770
+ console.log("");
771
+ console.log(chalk.bold(meta.name));
772
+ console.log("");
773
+ if (meta.description) console.log(labelValue("Description:", meta.description));
774
+ console.log(labelValue("Version:", meta.latestVersion));
775
+ if (meta.visibility) console.log(labelValue("Visibility:", meta.visibility));
776
+ console.log(labelValue("Publisher:", meta.publisher?.displayName ?? "unknown"));
777
+ if (versionData?.auditScore != null) console.log(labelValue("Audit Score:", `${versionData.auditScore}/10`));
778
+ console.log(labelValue("Created:", formatDate(meta.createdAt)));
779
+ const perms = versionData?.permissions;
780
+ if (perms) {
781
+ console.log("");
782
+ console.log(chalk.bold("Permissions:"));
783
+ const networkDomains = perms.network?.outbound;
784
+ if (networkDomains && networkDomains.length > 0) console.log(` ${chalk.dim("Network:".padEnd(14))}${networkDomains.join(", ")}`);
785
+ const fsRead = perms.filesystem?.read;
786
+ const fsWrite = perms.filesystem?.write;
787
+ if (fsRead || fsWrite) {
788
+ const parts = [];
789
+ if (fsRead && fsRead.length > 0) parts.push(`${fsRead.join(", ")} (read)`);
790
+ if (fsWrite && fsWrite.length > 0) parts.push(`${fsWrite.join(", ")} (write)`);
791
+ console.log(` ${chalk.dim("Filesystem:".padEnd(14))}${parts.join(", ")}`);
792
+ }
793
+ const subprocess = perms.subprocess;
794
+ console.log(` ${chalk.dim("Subprocess:".padEnd(14))}${subprocess ? "yes" : "no"}`);
795
+ }
796
+ console.log("");
797
+ console.log(`Install: ${chalk.cyan(`tank install ${meta.name}`)}`);
798
+ }
799
+ //#endregion
800
+ //#region src/commands/init.ts
801
+ const NAME_PATTERN = /^(@[a-z0-9-]+\/)?[a-z0-9][a-z0-9-]*$/;
802
+ const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
803
+ const MAX_NAME_LENGTH = 214;
804
+ function validateName(value) {
805
+ if (!value) return "Name must not be empty";
806
+ if (value.length > MAX_NAME_LENGTH) return `Name must be ${MAX_NAME_LENGTH} characters or fewer`;
807
+ if (!NAME_PATTERN.test(value)) return "Name must be lowercase, alphanumeric + hyphens, optionally scoped (@org/name)";
808
+ return true;
809
+ }
810
+ function validateVersion(value) {
811
+ if (!SEMVER_PATTERN.test(value)) return "Version must be valid semver (e.g. 1.0.0)";
812
+ return true;
813
+ }
814
+ async function initCommand(options = {}) {
815
+ const cwd = process.cwd();
816
+ const resolved = resolveManifestPath(cwd);
817
+ const filePath = resolved.exists ? resolved.path : path.join(cwd, MANIFEST_FILENAME);
818
+ if (options.yes) {
819
+ const dirName = path.basename(cwd);
820
+ const name = options.name ?? dirName;
821
+ const version = options.version ?? "0.1.0";
822
+ const description = options.description ?? "";
823
+ const privateChoice = options.private ?? false;
824
+ const nameResult = validateName(name);
825
+ if (nameResult !== true) {
826
+ logger.error(nameResult);
827
+ return;
828
+ }
829
+ const versionResult = validateVersion(version);
830
+ if (versionResult !== true) {
831
+ logger.error(versionResult);
832
+ return;
833
+ }
834
+ if (resolved.exists) {
835
+ if (!options.force) {
836
+ logger.error(`${path.basename(resolved.path)} already exists. Use --force to overwrite.`);
837
+ return;
838
+ }
839
+ }
840
+ const manifest = {
841
+ name,
842
+ version,
843
+ ...description ? { description } : {},
844
+ visibility: privateChoice ? "private" : "public",
845
+ skills: {},
846
+ permissions: {
847
+ network: { outbound: [] },
848
+ filesystem: {
849
+ read: [],
850
+ write: []
851
+ },
852
+ subprocess: false
853
+ }
854
+ };
855
+ const result = skillsJsonSchema.safeParse(manifest);
856
+ if (!result.success) {
857
+ logger.error(`Generated ${MANIFEST_FILENAME} is invalid:`);
858
+ for (const issue of result.error.issues) logger.error(` ${issue.path.join(".")}: ${issue.message}`);
859
+ return;
860
+ }
861
+ fs.writeFileSync(filePath, `${JSON.stringify(manifest, null, 2)}\n`);
862
+ logger.success(`Created ${MANIFEST_FILENAME}`);
863
+ return;
864
+ }
865
+ if (resolved.exists) {
866
+ logger.warn(`${path.basename(resolved.path)} already exists in this directory.`);
867
+ if (!await confirm({
868
+ message: `Overwrite existing ${path.basename(resolved.path)}?`,
869
+ default: false
870
+ })) {
871
+ logger.info("Aborted.");
872
+ return;
873
+ }
874
+ }
875
+ const defaultAuthor = getConfig().user?.name ?? "";
876
+ const name = await input({
877
+ message: "Skill name:",
878
+ default: path.basename(cwd),
879
+ validate: validateName
880
+ });
881
+ const version = await input({
882
+ message: "Version:",
883
+ default: "0.1.0",
884
+ validate: validateVersion
885
+ });
886
+ const description = await input({
887
+ message: "Description:",
888
+ default: ""
889
+ });
890
+ const privateChoice = await confirm({
891
+ message: "Make this skill private?",
892
+ default: name.startsWith("@")
893
+ });
894
+ await input({
895
+ message: "Author:",
896
+ default: defaultAuthor
897
+ });
898
+ const manifest = {
899
+ name,
900
+ version,
901
+ ...description ? { description } : {},
902
+ visibility: privateChoice ? "private" : "public",
903
+ skills: {},
904
+ permissions: {
905
+ network: { outbound: [] },
906
+ filesystem: {
907
+ read: [],
908
+ write: []
909
+ },
910
+ subprocess: false
911
+ }
912
+ };
913
+ const result = skillsJsonSchema.safeParse(manifest);
914
+ if (!result.success) {
915
+ logger.error(`Generated ${MANIFEST_FILENAME} is invalid:`);
916
+ for (const issue of result.error.issues) logger.error(` ${issue.path.join(".")}: ${issue.message}`);
917
+ return;
918
+ }
919
+ fs.writeFileSync(filePath, `${JSON.stringify(manifest, null, 2)}\n`);
920
+ logger.success(`Created ${MANIFEST_FILENAME}`);
921
+ }
922
+ //#endregion
923
+ //#region ../internals-helpers/dist/index.js
924
+ function resolve(range, versions) {
925
+ try {
926
+ if (!range || !semver.validRange(range)) return null;
927
+ const validVersions = versions.filter((v) => semver.valid(v) !== null);
928
+ if (validVersions.length === 0) return null;
929
+ return semver.maxSatisfying(validVersions, range) ?? null;
930
+ } catch {
931
+ return null;
932
+ }
933
+ }
934
+ //#endregion
935
+ //#region src/lib/frontmatter.ts
936
+ function hasFrontmatter(content) {
937
+ return /^---\s*\n/.test(content);
938
+ }
939
+ function stripScope(skillName) {
940
+ const match = skillName.match(/^@[^/]+\/(.+)$/);
941
+ if (!match) return skillName;
942
+ return match[1] ?? skillName;
943
+ }
944
+ function extractDescriptionFromMarkdown(content) {
945
+ const lines = content.split(/\r?\n/);
946
+ const firstLine = lines.find((line) => line.trim().length > 0);
947
+ if (firstLine && /^#\s+/.test(firstLine)) return firstLine.replace(/^#\s+/, "").trim();
948
+ let seenHeading = false;
949
+ let paragraphLines = [];
950
+ for (const line of lines) {
951
+ const trimmed = line.trim();
952
+ if (/^#{1,6}\s+/.test(trimmed)) {
953
+ seenHeading = true;
954
+ paragraphLines = [];
955
+ continue;
956
+ }
957
+ if (!seenHeading) continue;
958
+ if (trimmed.length === 0) {
959
+ if (paragraphLines.length > 0) break;
960
+ continue;
961
+ }
962
+ paragraphLines.push(trimmed);
963
+ }
964
+ if (paragraphLines.length > 0) {
965
+ const paragraph = paragraphLines.join(" ").trim();
966
+ const match = paragraph.match(/^(.+?[.!?])(\s|$)/);
967
+ return (match ? match[1] : paragraph).trim();
968
+ }
969
+ return "An AI agent skill";
970
+ }
971
+ function generateFrontmatter(name, description) {
972
+ return `---\nname: ${name}\ndescription: |\n${description.split(/\r?\n/).map((line) => ` ${line}`).join("\n")}\n---\n\n`;
973
+ }
974
+ function prepareAgentSkillDir(options) {
975
+ const { skillName, extractDir, agentSkillsBaseDir, description } = options;
976
+ const symlinkName = getSymlinkName(skillName);
977
+ const targetDir = path.resolve(agentSkillsBaseDir, symlinkName);
978
+ fs.mkdirSync(targetDir, { recursive: true });
979
+ const sourceSkillPath = path.join(extractDir, "SKILL.md");
980
+ const targetSkillPath = path.join(targetDir, "SKILL.md");
981
+ const baseName = stripScope(skillName);
982
+ if (!fs.existsSync(sourceSkillPath)) {
983
+ const minimal = generateFrontmatter(baseName, description ?? "An AI agent skill");
984
+ fs.writeFileSync(targetSkillPath, minimal, "utf-8");
985
+ } else {
986
+ const content = fs.readFileSync(sourceSkillPath, "utf-8");
987
+ if (hasFrontmatter(content)) fs.writeFileSync(targetSkillPath, content, "utf-8");
988
+ else {
989
+ const frontmatter = generateFrontmatter(baseName, description ?? extractDescriptionFromMarkdown(content));
990
+ fs.writeFileSync(targetSkillPath, `${frontmatter}${content}`, "utf-8");
991
+ }
992
+ }
993
+ const entries = fs.readdirSync(extractDir, { withFileTypes: true });
994
+ for (const entry of entries) {
995
+ if (entry.name === "SKILL.md") continue;
996
+ const sourcePath = path.join(extractDir, entry.name);
997
+ const targetPath = path.join(targetDir, entry.name);
998
+ fs.cpSync(sourcePath, targetPath, { recursive: true });
999
+ }
1000
+ return targetDir;
1001
+ }
1002
+ //#endregion
1003
+ //#region src/commands/install.ts
1004
+ function createRegistryFetcher(registry, headers) {
1005
+ const versionsCache = /* @__PURE__ */ new Map();
1006
+ const metadataCache = /* @__PURE__ */ new Map();
1007
+ return {
1008
+ async fetchVersions(name) {
1009
+ const cached = versionsCache.get(name);
1010
+ if (cached) return cached;
1011
+ const encoded = encodeURIComponent(name);
1012
+ let res;
1013
+ try {
1014
+ res = await fetch(`${registry}/api/v1/skills/${encoded}/versions`, { headers });
1015
+ } catch (err) {
1016
+ throw new Error(`Network error fetching versions: ${err instanceof Error ? err.message : String(err)}`);
1017
+ }
1018
+ if (!res.ok) {
1019
+ if (res.status === 403) throw new Error("Token lacks required scope: skills:read");
1020
+ if (res.status === 404) throw new Error(`Skill not found or no access: ${name}`);
1021
+ const body = await res.json().catch(() => null);
1022
+ throw new Error(body?.error ?? res.statusText);
1023
+ }
1024
+ const data = await res.json();
1025
+ versionsCache.set(name, data.versions);
1026
+ return data.versions;
1027
+ },
1028
+ async fetchMetadata(name, version) {
1029
+ const cacheKey = buildSkillKey(name, version);
1030
+ const cached = metadataCache.get(cacheKey);
1031
+ if (cached) return cached;
1032
+ const encoded = encodeURIComponent(name);
1033
+ let res;
1034
+ try {
1035
+ res = await fetch(`${registry}/api/v1/skills/${encoded}/${version}`, { headers });
1036
+ } catch (err) {
1037
+ throw new Error(`Network error fetching metadata: ${err instanceof Error ? err.message : String(err)}`);
1038
+ }
1039
+ if (!res.ok) {
1040
+ if (res.status === 403) throw new Error("Token lacks required scope: skills:read");
1041
+ if (res.status === 404) throw new Error(`Skill not found or no access: ${name}@${version}`);
1042
+ const body = await res.json().catch(() => null);
1043
+ throw new Error(body?.error ?? res.statusText);
1044
+ }
1045
+ const data = await res.json();
1046
+ const normalized = {
1047
+ ...data,
1048
+ dependencies: data.dependencies ?? {}
1049
+ };
1050
+ metadataCache.set(cacheKey, normalized);
1051
+ return normalized;
1052
+ }
1053
+ };
1054
+ }
1055
+ function readSkillsJson(skillsJsonPath) {
1056
+ try {
1057
+ const raw = fs.readFileSync(skillsJsonPath, "utf-8");
1058
+ return JSON.parse(raw);
1059
+ } catch {
1060
+ throw new Error(`Failed to read or parse ${path.basename(skillsJsonPath)}`);
1061
+ }
1062
+ }
1063
+ function readOrCreateSkillsJson(skillsJsonPath) {
1064
+ if (!fs.existsSync(skillsJsonPath)) {
1065
+ const skillsJson = { skills: {} };
1066
+ fs.writeFileSync(skillsJsonPath, `${JSON.stringify(skillsJson, null, 2)}\n`);
1067
+ logger.info(`Created ${MANIFEST_FILENAME}`);
1068
+ return skillsJson;
1069
+ }
1070
+ return readSkillsJson(skillsJsonPath);
1071
+ }
1072
+ function readLockOrFresh(lockPath) {
1073
+ if (!fs.existsSync(lockPath)) return {
1074
+ lockfileVersion: 2,
1075
+ skills: {}
1076
+ };
1077
+ try {
1078
+ const raw = fs.readFileSync(lockPath, "utf-8");
1079
+ return JSON.parse(raw);
1080
+ } catch {
1081
+ return {
1082
+ lockfileVersion: 2,
1083
+ skills: {}
1084
+ };
1085
+ }
1086
+ }
1087
+ function buildLockedVersionByName(lock) {
1088
+ const lockedVersionByName = /* @__PURE__ */ new Map();
1089
+ for (const key of Object.keys(lock.skills)) lockedVersionByName.set(parseLockKey$2(key), parseVersionFromLockKey(key));
1090
+ return lockedVersionByName;
1091
+ }
1092
+ function createExtractDirResolver(directory, global, resolvedHome) {
1093
+ return (skillName) => global ? getGlobalExtractDir(resolvedHome, skillName) : getExtractDir$1(directory, skillName);
1094
+ }
1095
+ function validateResolvedNodes(resolvedNodes, projectPermissions, auditMinScore) {
1096
+ if (!projectPermissions) logger.warn(`No permission budget defined in ${MANIFEST_FILENAME}. Install proceeding without permission checks.`);
1097
+ for (const node of resolvedNodes) {
1098
+ if (projectPermissions) checkPermissionBudget(projectPermissions, node.meta.permissions, node.name);
1099
+ if (auditMinScore !== void 0) {
1100
+ 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.`);
1101
+ 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}`);
1102
+ }
1103
+ }
1104
+ }
1105
+ async function runLegacyFallback(options) {
1106
+ const { rootSkillNames, resolvedNodeByName, extractDirForSkill, directory, configDir, global, homedir } = options;
1107
+ for (const skillName of rootSkillNames) {
1108
+ const node = resolvedNodeByName.get(skillName);
1109
+ if (!node || Object.keys(node.meta.dependencies).length > 0) continue;
1110
+ const extractedDeps = readExtractedDependencies(extractDirForSkill(skillName));
1111
+ for (const [depName, depRange] of Object.entries(extractedDeps)) {
1112
+ if (depName === skillName) continue;
1113
+ await installCommand({
1114
+ name: depName,
1115
+ versionRange: depRange,
1116
+ directory,
1117
+ configDir,
1118
+ global,
1119
+ homedir,
1120
+ isTransitive: true
1121
+ });
1122
+ }
1123
+ }
1124
+ }
1125
+ function linkInstalledRoots(options) {
1126
+ const { rootSkillNames, resolvedNodeByName, extractDirForSkill, directory, global, resolvedHome, homedir } = options;
1127
+ const agentSkillsBaseDir = global ? getGlobalAgentSkillsDir(resolvedHome) : path.join(directory, ".tank", "agent-skills");
1128
+ const linksDir = global ? path.join(resolvedHome, ".tank") : path.join(directory, ".tank");
1129
+ for (const skillName of rootSkillNames) try {
1130
+ const node = resolvedNodeByName.get(skillName);
1131
+ if (!node) continue;
1132
+ const linkResult = linkSkillToAgents({
1133
+ skillName,
1134
+ sourceDir: prepareAgentSkillDir({
1135
+ skillName,
1136
+ extractDir: extractDirForSkill(skillName),
1137
+ agentSkillsBaseDir,
1138
+ description: node.meta.description
1139
+ }),
1140
+ linksDir,
1141
+ source: global ? "global" : "local",
1142
+ homedir
1143
+ });
1144
+ if (linkResult.linked.length > 0) logger.info(`Linked to ${linkResult.linked.length} agent(s)`);
1145
+ if (linkResult.failed.length > 0) for (const failedLink of linkResult.failed) logger.warn(`Failed to link to ${failedLink.agentId}: ${failedLink.error}`);
1146
+ } catch {
1147
+ if (rootSkillNames.length === 1) logger.warn("Agent linking skipped (non-fatal)");
1148
+ else logger.warn(`Agent linking skipped for ${skillName} (non-fatal)`);
1149
+ }
1150
+ if (detectInstalledAgents(homedir).length === 0) logger.warn("No agents detected for linking");
1151
+ }
1152
+ async function executeInstallPipeline(options) {
1153
+ const { directory, configDir, global, homedir, resolvedHome, lock, lockPath, resolvedNodes, nodesToInstall, rootSkillNames, projectPermissions, auditMinScore, spinner } = options;
1154
+ if (!global) validateResolvedNodes(resolvedNodes, projectPermissions, auditMinScore);
1155
+ const extractDirForSkill = createExtractDirResolver(directory, global, resolvedHome);
1156
+ const resolvedNodeByName = new Map(resolvedNodes.map((node) => [node.name, node]));
1157
+ const downloaded = await downloadAllParallel(nodesToInstall, (msg) => {
1158
+ spinner.text = msg;
1159
+ });
1160
+ for (const node of nodesToInstall) {
1161
+ const payload = downloaded.get(node.name);
1162
+ if (!payload) throw new Error(`Missing downloaded tarball for ${node.name}@${node.version}`);
1163
+ spinner.text = `Extracting ${node.name}@${node.version}...`;
1164
+ const extractDir = extractDirForSkill(node.name);
1165
+ fs.mkdirSync(extractDir, { recursive: true });
1166
+ await extractSafely(payload.buffer, extractDir);
1167
+ verifyExtractedDependencies(extractDir, node);
1168
+ }
1169
+ lock.lockfileVersion = 2;
1170
+ const updatedLock = writeLockfileWithResolvedGraph(lock, resolvedNodes, downloaded);
1171
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
1172
+ fs.writeFileSync(lockPath, `${JSON.stringify(updatedLock, null, 2)}\n`);
1173
+ await runLegacyFallback({
1174
+ rootSkillNames,
1175
+ resolvedNodeByName,
1176
+ extractDirForSkill,
1177
+ directory,
1178
+ configDir,
1179
+ global,
1180
+ homedir
1181
+ });
1182
+ linkInstalledRoots({
1183
+ rootSkillNames,
1184
+ resolvedNodeByName,
1185
+ extractDirForSkill,
1186
+ directory,
1187
+ global,
1188
+ resolvedHome,
1189
+ homedir
1190
+ });
1191
+ return updatedLock;
1192
+ }
1193
+ async function installCommand(options) {
1194
+ const { name, versionRange = "*", directory = process.cwd(), configDir, global = false, homedir, isTransitive = false } = options;
1195
+ const config = getConfig(configDir);
1196
+ const resolvedHome = homedir ?? os.homedir();
1197
+ const requestHeaders = { "User-Agent": USER_AGENT };
1198
+ if (config.token) requestHeaders.Authorization = `Bearer ${config.token}`;
1199
+ const resolvedManifest = resolveManifestPath(directory);
1200
+ const skillsJsonPath = resolvedManifest.exists ? resolvedManifest.path : path.join(directory, MANIFEST_FILENAME);
1201
+ const skillsJson = global ? { skills: {} } : readOrCreateSkillsJson(skillsJsonPath);
1202
+ const resolvedLock = global ? resolveLockfilePath(path.join(resolvedHome, ".tank")) : resolveLockfilePath(directory);
1203
+ const lockPath = resolvedLock.exists ? resolvedLock.path : global ? path.join(resolvedHome, ".tank", LOCKFILE_FILENAME) : path.join(directory, LOCKFILE_FILENAME);
1204
+ const lock = readLockOrFresh(lockPath);
1205
+ const spinner = ora("Resolving dependency graph...").start();
1206
+ try {
1207
+ const fetcher = createRegistryFetcher(config.registry, requestHeaders);
1208
+ const requestedAvailableVersions = (await fetcher.fetchVersions(name)).map((versionInfo) => versionInfo.version);
1209
+ const requestedResolvedVersion = resolve(versionRange, requestedAvailableVersions);
1210
+ if (!requestedResolvedVersion) throw new Error(`No version of ${name} satisfies range "${versionRange}". Available: ${requestedAvailableVersions.join(", ")}`);
1211
+ const requestedLockKey = buildSkillKey(name, requestedResolvedVersion);
1212
+ if (lock.skills[requestedLockKey]) {
1213
+ logger.info(`${name}@${requestedResolvedVersion} is already installed`);
1214
+ spinner.succeed(`${name}@${requestedResolvedVersion} is already installed`);
1215
+ return;
1216
+ }
1217
+ const rootDependencies = {};
1218
+ if (!global && !isTransitive) {
1219
+ const existingSkills = skillsJson.skills ?? {};
1220
+ const lockedVersionByName = buildLockedVersionByName(lock);
1221
+ for (const [skillName, range] of Object.entries(existingSkills)) {
1222
+ if (typeof range !== "string") continue;
1223
+ rootDependencies[skillName] = lockedVersionByName.get(skillName) ?? range;
1224
+ }
1225
+ }
1226
+ rootDependencies[name] = versionRange;
1227
+ const resolvedGraph = await resolveDependencyTree(rootDependencies, fetcher);
1228
+ const resolvedNodes = getResolvedNodesInOrder(resolvedGraph.nodes, resolvedGraph.installOrder);
1229
+ const rootNode = resolvedGraph.nodes.get(name);
1230
+ if (!rootNode) throw new Error(`Failed to resolve requested skill: ${name}`);
1231
+ const nodesToInstall = resolvedNodes.filter((node) => {
1232
+ const lockKey = buildSkillKey(node.name, node.version);
1233
+ return !lock.skills[lockKey];
1234
+ });
1235
+ const projectPermissions = global ? void 0 : skillsJson.permissions;
1236
+ const auditMinScore = global ? void 0 : skillsJson.audit?.min_score;
1237
+ await executeInstallPipeline({
1238
+ directory,
1239
+ configDir,
1240
+ global,
1241
+ homedir,
1242
+ resolvedHome,
1243
+ lock,
1244
+ lockPath,
1245
+ resolvedNodes,
1246
+ nodesToInstall,
1247
+ rootSkillNames: [name],
1248
+ projectPermissions,
1249
+ auditMinScore,
1250
+ spinner
1251
+ });
1252
+ if (!global && !isTransitive) {
1253
+ const skills = skillsJson.skills ?? {};
1254
+ skills[name] = versionRange === "*" ? `^${rootNode.version}` : versionRange;
1255
+ skillsJson.skills = skills;
1256
+ fs.writeFileSync(skillsJsonPath, `${JSON.stringify(skillsJson, null, 2)}\n`);
1257
+ }
1258
+ spinner.succeed(`Installed ${name}@${rootNode.version}`);
1259
+ } catch (err) {
1260
+ spinner.fail("Install failed");
1261
+ throw err;
1262
+ }
1263
+ }
1264
+ async function installFromLockfile(options) {
1265
+ const { directory = process.cwd(), configDir, global = false, homedir } = options;
1266
+ const resolvedHome = homedir ?? os.homedir();
1267
+ const config = getConfig(configDir);
1268
+ const requestHeaders = { "User-Agent": USER_AGENT };
1269
+ if (config.token) requestHeaders.Authorization = `Bearer ${config.token}`;
1270
+ const resolvedLock = global ? resolveLockfilePath(path.join(resolvedHome, ".tank")) : resolveLockfilePath(directory);
1271
+ const lockPath = resolvedLock.path;
1272
+ if (!resolvedLock.exists) throw new Error(`No ${LOCKFILE_FILENAME} found in ${directory}`);
1273
+ let lock;
1274
+ try {
1275
+ const raw = fs.readFileSync(lockPath, "utf-8");
1276
+ lock = JSON.parse(raw);
1277
+ } catch {
1278
+ throw new Error(`Failed to read or parse ${path.basename(lockPath)}`);
1279
+ }
1280
+ const entries = Object.entries(lock.skills);
1281
+ if (entries.length === 0) {
1282
+ logger.info("No skills in lockfile");
1283
+ return;
1284
+ }
1285
+ const spinner = ora("Installing from lockfile...").start();
1286
+ const skillsDir = global ? getGlobalSkillsDir(resolvedHome) : path.join(directory, ".tank", "skills");
1287
+ try {
1288
+ for (const [key, entry] of entries) {
1289
+ const skillName = parseLockKey$2(key);
1290
+ const version = parseVersionFromLockKey(key);
1291
+ spinner.text = `Installing ${key}...`;
1292
+ const encodedName = encodeURIComponent(skillName);
1293
+ const metaUrl = `${config.registry}/api/v1/skills/${encodedName}/${version}`;
1294
+ let metaRes;
1295
+ try {
1296
+ metaRes = await fetch(metaUrl, { headers: requestHeaders });
1297
+ } catch (err) {
1298
+ throw new Error(`Network error fetching ${key}: ${err instanceof Error ? err.message : String(err)}`);
1299
+ }
1300
+ if (!metaRes.ok) {
1301
+ if (metaRes.status === 404) throw new Error(`Skill or version not found: ${key}`);
1302
+ const body = await metaRes.json().catch(() => null);
1303
+ throw new Error(`Failed to fetch ${key}: ${body?.error ?? metaRes.statusText}`);
1304
+ }
1305
+ const downloadUrl = (await metaRes.json()).downloadUrl;
1306
+ const downloadRes = await fetch(downloadUrl);
1307
+ if (!downloadRes.ok) throw new Error(`Failed to download ${key}: ${downloadRes.status} ${downloadRes.statusText}`);
1308
+ const tarballBuffer = Buffer.from(await downloadRes.arrayBuffer());
1309
+ const computedIntegrity = buildIntegrity(tarballBuffer);
1310
+ if (computedIntegrity !== entry.integrity) throw new Error(`Integrity mismatch for ${key}. Expected: ${entry.integrity}, Got: ${computedIntegrity}`);
1311
+ const extractDir = global ? getGlobalExtractDir(resolvedHome, skillName) : getExtractDir$1(directory, skillName);
1312
+ if (fs.existsSync(extractDir)) fs.rmSync(extractDir, {
1313
+ recursive: true,
1314
+ force: true
1315
+ });
1316
+ fs.mkdirSync(extractDir, { recursive: true });
1317
+ await extractSafely(tarballBuffer, extractDir);
1318
+ if (global) try {
1319
+ const linkResult = linkSkillToAgents({
1320
+ skillName,
1321
+ sourceDir: prepareAgentSkillDir({
1322
+ skillName,
1323
+ extractDir,
1324
+ agentSkillsBaseDir: getGlobalAgentSkillsDir(resolvedHome)
1325
+ }),
1326
+ linksDir: path.join(resolvedHome, ".tank"),
1327
+ source: "global",
1328
+ homedir
1329
+ });
1330
+ if (detectInstalledAgents(homedir).length === 0) logger.warn("No agents detected for linking");
1331
+ if (linkResult.linked.length > 0) logger.info(`Linked to ${linkResult.linked.length} agent(s)`);
1332
+ if (linkResult.failed.length > 0) for (const failedLink of linkResult.failed) logger.warn(`Failed to link to ${failedLink.agentId}: ${failedLink.error}`);
1333
+ } catch {
1334
+ logger.warn("Agent linking skipped (non-fatal)");
1335
+ }
1336
+ }
1337
+ spinner.succeed(`Installed ${entries.length} skill${entries.length === 1 ? "" : "s"} from lockfile`);
1338
+ } catch (err) {
1339
+ spinner.fail("Install from lockfile failed");
1340
+ if (fs.existsSync(skillsDir)) fs.rmSync(skillsDir, {
1341
+ recursive: true,
1342
+ force: true
1343
+ });
1344
+ throw err;
1345
+ }
1346
+ }
1347
+ async function installAll(options) {
1348
+ const { directory = process.cwd(), configDir, global = false, homedir } = options;
1349
+ const resolvedHome = homedir ?? os.homedir();
1350
+ const config = getConfig(configDir);
1351
+ const requestHeaders = { "User-Agent": USER_AGENT };
1352
+ if (config.token) requestHeaders.Authorization = `Bearer ${config.token}`;
1353
+ const resolvedLock = global ? resolveLockfilePath(path.join(resolvedHome, ".tank")) : resolveLockfilePath(directory);
1354
+ const lockPath = resolvedLock.exists ? resolvedLock.path : global ? path.join(resolvedHome, ".tank", LOCKFILE_FILENAME) : path.join(directory, LOCKFILE_FILENAME);
1355
+ const resolvedManifest = resolveManifestPath(directory);
1356
+ const skillsJsonPath = resolvedManifest.path;
1357
+ if (resolvedLock.exists) return installFromLockfile({
1358
+ directory,
1359
+ configDir,
1360
+ global,
1361
+ homedir
1362
+ });
1363
+ if (global) {
1364
+ logger.info(`No ${LOCKFILE_FILENAME} found — nothing to install`);
1365
+ return;
1366
+ }
1367
+ if (!resolvedManifest.exists) {
1368
+ logger.info(`No ${MANIFEST_FILENAME} found — nothing to install`);
1369
+ return;
1370
+ }
1371
+ const skillsJson = readSkillsJson(skillsJsonPath);
1372
+ const skills = skillsJson.skills ?? {};
1373
+ const skillEntries = Object.entries(skills);
1374
+ if (skillEntries.length === 0) {
1375
+ logger.info(`No skills defined in ${MANIFEST_FILENAME}`);
1376
+ return;
1377
+ }
1378
+ const spinner = ora("Resolving dependency graph...").start();
1379
+ try {
1380
+ const rootDependencies = {};
1381
+ for (const [skillName, range] of skillEntries) if (typeof range === "string") rootDependencies[skillName] = range;
1382
+ const resolvedGraph = await resolveDependencyTree(rootDependencies, createRegistryFetcher(config.registry, requestHeaders));
1383
+ const resolvedNodes = getResolvedNodesInOrder(resolvedGraph.nodes, resolvedGraph.installOrder);
1384
+ const lock = {
1385
+ lockfileVersion: 2,
1386
+ skills: {}
1387
+ };
1388
+ const projectPermissions = skillsJson.permissions;
1389
+ const auditMinScore = skillsJson.audit?.min_score;
1390
+ await executeInstallPipeline({
1391
+ directory,
1392
+ configDir,
1393
+ global,
1394
+ homedir,
1395
+ resolvedHome,
1396
+ lock,
1397
+ lockPath,
1398
+ resolvedNodes,
1399
+ nodesToInstall: resolvedNodes,
1400
+ rootSkillNames: skillEntries.map(([skillName]) => skillName),
1401
+ projectPermissions,
1402
+ auditMinScore,
1403
+ spinner
1404
+ });
1405
+ spinner.succeed(`Installed ${skillEntries.length} root skill${skillEntries.length === 1 ? "" : "s"}`);
1406
+ } catch (err) {
1407
+ spinner.fail("Install failed");
1408
+ throw err;
1409
+ }
1410
+ }
1411
+ function buildIntegrity(buffer) {
1412
+ return `sha512-${crypto$1.createHash("sha512").update(buffer).digest("base64")}`;
1413
+ }
1414
+ //#endregion
1415
+ //#region src/commands/link.ts
1416
+ async function linkCommand(options = {}) {
1417
+ const workDir = options.directory ?? process.cwd();
1418
+ const homedir = options.homedir ?? os.homedir();
1419
+ const resolvedManifest = resolveManifestPath(workDir);
1420
+ if (!resolvedManifest.exists) throw new Error(`No ${MANIFEST_FILENAME} found. Run this command from a skill directory.`);
1421
+ let skillsJson;
1422
+ try {
1423
+ const raw = fs.readFileSync(resolvedManifest.path, "utf-8");
1424
+ skillsJson = JSON.parse(raw);
1425
+ } catch {
1426
+ throw new Error(`Failed to read or parse ${path.basename(resolvedManifest.path)}`);
1427
+ }
1428
+ const skillName = skillsJson.name;
1429
+ if (typeof skillName !== "string" || skillName.trim().length === 0) throw new Error(`Missing 'name' in ${path.basename(resolvedManifest.path)}`);
1430
+ const description = typeof skillsJson.description === "string" ? skillsJson.description : void 0;
1431
+ const agents = detectInstalledAgents(options.homedir);
1432
+ if (agents.length === 0) {
1433
+ logger.info("No AI agents detected. Skills linked to agents will be available once agents are installed.");
1434
+ return;
1435
+ }
1436
+ const skillMdPath = path.join(workDir, "SKILL.md");
1437
+ let sourceDir = workDir;
1438
+ if (fs.existsSync(skillMdPath)) {
1439
+ if (!hasFrontmatter(fs.readFileSync(skillMdPath, "utf-8"))) sourceDir = prepareAgentSkillDir({
1440
+ skillName,
1441
+ extractDir: workDir,
1442
+ agentSkillsBaseDir: getGlobalAgentSkillsDir(homedir),
1443
+ description
1444
+ });
1445
+ } else sourceDir = prepareAgentSkillDir({
1446
+ skillName,
1447
+ extractDir: workDir,
1448
+ agentSkillsBaseDir: getGlobalAgentSkillsDir(homedir),
1449
+ description
1450
+ });
1451
+ readGlobalLinks(homedir);
1452
+ const result = linkSkillToAgents({
1453
+ skillName,
1454
+ sourceDir,
1455
+ linksDir: path.join(homedir, ".tank"),
1456
+ source: "dev",
1457
+ homedir: options.homedir
1458
+ });
1459
+ const agentNames = new Map(agents.map((agent) => [agent.id, agent.name]));
1460
+ for (const agentId of result.linked) logger.success(agentNames.get(agentId) ?? agentId);
1461
+ for (const agentId of result.skipped) {
1462
+ const name = agentNames.get(agentId) ?? agentId;
1463
+ logger.warn(`- ${name} (already linked)`);
1464
+ }
1465
+ for (const failure of result.failed) {
1466
+ const name = agentNames.get(failure.agentId) ?? failure.agentId;
1467
+ logger.error(`${name}: ${failure.error}`);
1468
+ }
1469
+ logger.success(`Linked ${skillName} to ${result.linked.length} agent(s)`);
1470
+ }
1471
+ //#endregion
1472
+ //#region src/commands/login.ts
1473
+ const DEFAULT_POLL_INTERVAL_MS = 2e3;
1474
+ const DEFAULT_TIMEOUT_MS = 300 * 1e3;
1475
+ /**
1476
+ * Start the CLI login flow:
1477
+ * 1. Generate random state
1478
+ * 2. POST /api/v1/cli-auth/start → get authUrl + sessionCode
1479
+ * 3. Open browser to authUrl
1480
+ * 4. Poll POST /api/v1/cli-auth/exchange until authorized or timeout
1481
+ * 5. Write token + user to config
1482
+ */
1483
+ async function loginCommand(options = {}) {
1484
+ const { configDir, timeout = DEFAULT_TIMEOUT_MS, pollInterval = DEFAULT_POLL_INTERVAL_MS } = options;
1485
+ const baseUrl = getConfig(configDir).registry;
1486
+ const state = crypto.randomUUID();
1487
+ authFlowLog.info({ state: `${state.slice(0, 8)}...` }, "Login flow started");
1488
+ logger.info("Starting login...");
1489
+ let authUrl;
1490
+ let sessionCode;
1491
+ try {
1492
+ const startRes = await fetch(`${baseUrl}/api/v1/cli-auth/start`, {
1493
+ method: "POST",
1494
+ headers: { "Content-Type": "application/json" },
1495
+ body: JSON.stringify({ state })
1496
+ });
1497
+ if (!startRes.ok) {
1498
+ const body = await startRes.json().catch(() => null);
1499
+ authFlowLog.error({
1500
+ status: startRes.status,
1501
+ error: body?.error
1502
+ }, "Start request failed");
1503
+ throw new Error(`Failed to start auth session: ${body?.error ?? startRes.statusText}`);
1504
+ }
1505
+ authFlowLog.info({
1506
+ ok: startRes.ok,
1507
+ status: startRes.status
1508
+ }, "Start response received");
1509
+ const startData = await startRes.json();
1510
+ authUrl = startData.authUrl;
1511
+ sessionCode = startData.sessionCode;
1512
+ authFlowLog.info({
1513
+ authUrl,
1514
+ sessionCode: `${sessionCode.slice(0, 8)}...`
1515
+ }, "Session created, opening browser");
1516
+ } catch (err) {
1517
+ if (err instanceof Error && err.message.startsWith("Failed to start auth session:")) throw err;
1518
+ authFlowLog.error({ error: err instanceof Error ? err.message : String(err) }, "Start request failed");
1519
+ 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)}`);
1520
+ }
1521
+ try {
1522
+ await open(authUrl);
1523
+ logger.info("Opened browser for authentication.");
1524
+ } catch {
1525
+ logger.warn("Could not open browser automatically.");
1526
+ logger.info(`Open this URL in your browser:\n ${authUrl}`);
1527
+ }
1528
+ logger.info("Waiting for authorization...");
1529
+ const deadline = Date.now() + timeout;
1530
+ while (Date.now() < deadline) {
1531
+ try {
1532
+ const exchangeRes = await fetch(`${baseUrl}/api/v1/cli-auth/exchange`, {
1533
+ method: "POST",
1534
+ headers: { "Content-Type": "application/json" },
1535
+ body: JSON.stringify({
1536
+ sessionCode,
1537
+ state
1538
+ })
1539
+ });
1540
+ authFlowLog.debug({
1541
+ status: exchangeRes.status,
1542
+ ok: exchangeRes.ok
1543
+ }, "Exchange poll response");
1544
+ if (exchangeRes.ok) {
1545
+ const { token, user } = await exchangeRes.json();
1546
+ authFlowLog.info({
1547
+ userName: user.name,
1548
+ userEmail: user.email
1549
+ }, "Login successful, saving config");
1550
+ setConfig({
1551
+ token,
1552
+ user
1553
+ }, configDir);
1554
+ const displayName = user.name ?? user.email ?? "unknown";
1555
+ logger.success(`Logged in as ${displayName}`);
1556
+ return;
1557
+ }
1558
+ if (exchangeRes.status !== 400) {
1559
+ const body = await exchangeRes.json().catch(() => null);
1560
+ throw new Error(`Exchange failed: ${body?.error ?? exchangeRes.statusText}`);
1561
+ }
1562
+ } catch (err) {
1563
+ authFlowLog.warn({ error: err instanceof Error ? err.message : String(err) }, "Exchange poll error");
1564
+ if (err instanceof Error && err.message.startsWith("Exchange failed:")) throw err;
1565
+ }
1566
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
1567
+ }
1568
+ authFlowLog.error({}, "Login timed out");
1569
+ throw new Error("Login timed out. Please try again.");
1570
+ }
1571
+ //#endregion
1572
+ //#region src/commands/logout.ts
1573
+ /**
1574
+ * Logout command: Remove token and user from config.
1575
+ * If not logged in, prints "Not logged in" and returns.
1576
+ * If logged in, removes token and user, prints success message.
1577
+ */
1578
+ async function logoutCommand(options = {}) {
1579
+ const { configDir } = options;
1580
+ if (!getConfig(configDir).token) {
1581
+ logger.warn("Not logged in. Run: tank login");
1582
+ return;
1583
+ }
1584
+ setConfig({
1585
+ token: void 0,
1586
+ user: void 0
1587
+ }, configDir);
1588
+ logger.success("Logged out");
1589
+ }
1590
+ //#endregion
1591
+ //#region src/commands/migrate.ts
1592
+ async function migrateCommand(options = {}) {
1593
+ const dir = options.directory ?? process.cwd();
1594
+ let migrated = false;
1595
+ const legacyManifest = path.join(dir, LEGACY_MANIFEST_FILENAME);
1596
+ const newManifest = path.join(dir, MANIFEST_FILENAME);
1597
+ if (fs.existsSync(newManifest)) logger.info(`${MANIFEST_FILENAME} already exists — skipping manifest migration`);
1598
+ else if (fs.existsSync(legacyManifest)) {
1599
+ fs.copyFileSync(legacyManifest, newManifest);
1600
+ logger.success(`${LEGACY_MANIFEST_FILENAME} → ${MANIFEST_FILENAME}`);
1601
+ migrated = true;
1602
+ } else logger.info(`No ${LEGACY_MANIFEST_FILENAME} found — nothing to migrate`);
1603
+ const legacyLock = path.join(dir, LEGACY_LOCKFILE_FILENAME);
1604
+ const newLock = path.join(dir, LOCKFILE_FILENAME);
1605
+ if (fs.existsSync(newLock)) logger.info(`${LOCKFILE_FILENAME} already exists — skipping lockfile migration`);
1606
+ else if (fs.existsSync(legacyLock)) {
1607
+ fs.copyFileSync(legacyLock, newLock);
1608
+ logger.success(`${LEGACY_LOCKFILE_FILENAME} → ${LOCKFILE_FILENAME}`);
1609
+ migrated = true;
1610
+ } else logger.info(`No ${LEGACY_LOCKFILE_FILENAME} found — nothing to migrate`);
1611
+ if (migrated) {
1612
+ logger.info("Old files were kept. Remove them when ready:");
1613
+ if (fs.existsSync(legacyManifest) && fs.existsSync(newManifest)) logger.info(` rm ${LEGACY_MANIFEST_FILENAME}`);
1614
+ if (fs.existsSync(legacyLock) && fs.existsSync(newLock)) logger.info(` rm ${LEGACY_LOCKFILE_FILENAME}`);
1615
+ logger.info("If your .gitignore or CI configs reference the old filenames, update them too.");
1616
+ } else logger.info("Already migrated — nothing to do");
1617
+ }
1618
+ //#endregion
1619
+ //#region src/commands/permissions.ts
1620
+ /**
1621
+ * Parse a lockfile key like "@org/skill@1.0.0" into the skill name "@org/skill".
1622
+ */
1623
+ function parseSkillName(key) {
1624
+ const lastAt = key.lastIndexOf("@");
1625
+ if (lastAt > 0) return key.slice(0, lastAt);
1626
+ return key;
1627
+ }
1628
+ function collectPermissions(lockfile) {
1629
+ const networkMap = /* @__PURE__ */ new Map();
1630
+ const fsReadMap = /* @__PURE__ */ new Map();
1631
+ const fsWriteMap = /* @__PURE__ */ new Map();
1632
+ const subprocessSkills = [];
1633
+ for (const [key, entry] of Object.entries(lockfile.skills)) {
1634
+ const skillName = parseSkillName(key);
1635
+ const perms = entry.permissions;
1636
+ if (perms.network?.outbound) for (const domain of perms.network.outbound) {
1637
+ const existing = networkMap.get(domain) ?? [];
1638
+ existing.push(skillName);
1639
+ networkMap.set(domain, existing);
1640
+ }
1641
+ if (perms.filesystem?.read) for (const p of perms.filesystem.read) {
1642
+ const existing = fsReadMap.get(p) ?? [];
1643
+ existing.push(skillName);
1644
+ fsReadMap.set(p, existing);
1645
+ }
1646
+ if (perms.filesystem?.write) for (const p of perms.filesystem.write) {
1647
+ const existing = fsWriteMap.get(p) ?? [];
1648
+ existing.push(skillName);
1649
+ fsWriteMap.set(p, existing);
1650
+ }
1651
+ if (perms.subprocess === true) subprocessSkills.push(skillName);
1652
+ }
1653
+ const toEntries = (map) => Array.from(map.entries()).map(([value, skills]) => ({
1654
+ value,
1655
+ skills
1656
+ }));
1657
+ return {
1658
+ networkOutbound: toEntries(networkMap),
1659
+ filesystemRead: toEntries(fsReadMap),
1660
+ filesystemWrite: toEntries(fsWriteMap),
1661
+ subprocess: subprocessSkills
1662
+ };
1663
+ }
1664
+ /**
1665
+ * Check if a domain is allowed by the budget's domain list.
1666
+ * Supports wildcard matching: *.example.com matches sub.example.com
1667
+ */
1668
+ function isDomainAllowed(domain, allowedDomains) {
1669
+ for (const allowed of allowedDomains) {
1670
+ if (allowed === domain) return true;
1671
+ if (allowed.startsWith("*.")) {
1672
+ const suffix = allowed.slice(1);
1673
+ if (domain.endsWith(suffix) || domain === allowed.slice(2)) return true;
1674
+ if (domain === allowed) return true;
1675
+ }
1676
+ }
1677
+ return false;
1678
+ }
1679
+ /**
1680
+ * Check if a path is allowed by the budget's path list.
1681
+ */
1682
+ function isPathAllowed(requestedPath, allowedPaths) {
1683
+ for (const allowed of allowedPaths) {
1684
+ if (allowed === requestedPath) return true;
1685
+ if (allowed.endsWith("/**")) {
1686
+ const prefix = allowed.slice(0, -3);
1687
+ if (requestedPath.startsWith(prefix)) return true;
1688
+ }
1689
+ }
1690
+ return false;
1691
+ }
1692
+ function checkBudget(resolved, budget) {
1693
+ const violations = [];
1694
+ const budgetDomains = budget.network?.outbound ?? [];
1695
+ for (const entry of resolved.networkOutbound) if (!isDomainAllowed(entry.value, budgetDomains)) violations.push({
1696
+ category: "network outbound",
1697
+ value: entry.value,
1698
+ skills: entry.skills
1699
+ });
1700
+ const budgetReadPaths = budget.filesystem?.read ?? [];
1701
+ for (const entry of resolved.filesystemRead) if (!isPathAllowed(entry.value, budgetReadPaths)) violations.push({
1702
+ category: "filesystem read",
1703
+ value: entry.value,
1704
+ skills: entry.skills
1705
+ });
1706
+ const budgetWritePaths = budget.filesystem?.write ?? [];
1707
+ for (const entry of resolved.filesystemWrite) if (!isPathAllowed(entry.value, budgetWritePaths)) violations.push({
1708
+ category: "filesystem write",
1709
+ value: entry.value,
1710
+ skills: entry.skills
1711
+ });
1712
+ if (resolved.subprocess.length > 0 && budget.subprocess !== true) violations.push({
1713
+ category: "subprocess",
1714
+ value: "subprocess access",
1715
+ skills: resolved.subprocess
1716
+ });
1717
+ return violations;
1718
+ }
1719
+ function formatAttribution(skills) {
1720
+ return chalk.gray(`← ${skills.join(", ")}`);
1721
+ }
1722
+ function printPermissionSection(title, entries) {
1723
+ console.log(`\n${chalk.bold(title)}:`);
1724
+ if (entries.length === 0) console.log(" none");
1725
+ else for (const entry of entries) console.log(` ${entry.value} ${formatAttribution(entry.skills)}`);
1726
+ }
1727
+ async function permissionsCommand(options) {
1728
+ const dir = options?.directory ?? process.cwd();
1729
+ const resolvedLock = resolveLockfilePath(dir);
1730
+ if (!resolvedLock.exists) {
1731
+ console.log("No skills installed.");
1732
+ return;
1733
+ }
1734
+ const lockfileContent = fs.readFileSync(resolvedLock.path, "utf-8");
1735
+ const lockfile = JSON.parse(lockfileContent);
1736
+ if (!lockfile.skills || Object.keys(lockfile.skills).length === 0) {
1737
+ console.log("No skills installed.");
1738
+ return;
1739
+ }
1740
+ const resolved = collectPermissions(lockfile);
1741
+ console.log("\nResolved permissions for this project:\n");
1742
+ printPermissionSection("Network (outbound)", resolved.networkOutbound);
1743
+ printPermissionSection("Filesystem (read)", resolved.filesystemRead);
1744
+ printPermissionSection("Filesystem (write)", resolved.filesystemWrite);
1745
+ console.log(`\n${chalk.bold("Subprocess")}:`);
1746
+ if (resolved.subprocess.length === 0) console.log(" none");
1747
+ else console.log(` allowed ${formatAttribution(resolved.subprocess)}`);
1748
+ const resolvedManifest = resolveManifestPath(dir);
1749
+ let budget;
1750
+ if (resolvedManifest.exists) {
1751
+ const skillsJsonContent = fs.readFileSync(resolvedManifest.path, "utf-8");
1752
+ budget = JSON.parse(skillsJsonContent).permissions;
1753
+ }
1754
+ console.log("");
1755
+ if (!budget) {
1756
+ console.log(`Budget status: ${chalk.yellow("⚠ No budget defined")}`);
1757
+ return;
1758
+ }
1759
+ const violations = checkBudget(resolved, budget);
1760
+ if (violations.length === 0) console.log(`Budget status: ${chalk.green("✓ PASS")} (all within budget)`);
1761
+ else {
1762
+ console.log(`Budget status: ${chalk.red("✗ FAIL")}`);
1763
+ for (const v of violations) console.log(chalk.red(` - ${v.category}: "${v.value}" not in budget (requested by ${v.skills.join(", ")})`));
1764
+ }
1765
+ }
1766
+ //#endregion
1767
+ //#region src/lib/packer.ts
1768
+ const MAX_PACKAGE_SIZE = 50 * 1024 * 1024;
1769
+ const MAX_FILE_COUNT = 1e3;
1770
+ const DEFAULT_IGNORES = [
1771
+ "node_modules",
1772
+ ".git",
1773
+ ".env*",
1774
+ "*.log",
1775
+ ".tank",
1776
+ ".DS_Store"
1777
+ ];
1778
+ const ALWAYS_IGNORED = ["node_modules", ".git"];
1779
+ const IGNORE_FILES = [".tankignore", ".gitignore"];
1780
+ /**
1781
+ * Pack a skill directory into a .tgz tarball with integrity hashing.
1782
+ *
1783
+ * Validates:
1784
+ * - skills.json exists and is valid
1785
+ * - SKILL.md exists
1786
+ * - No symlinks or hardlinks
1787
+ * - No path traversal (.. components)
1788
+ * - No absolute paths
1789
+ * - File count <= 1000
1790
+ * - Tarball size <= 50MB
1791
+ */
1792
+ async function pack(directory) {
1793
+ const absDir = path.resolve(directory);
1794
+ if (!fs.existsSync(absDir)) throw new Error(`Directory does not exist: ${absDir}`);
1795
+ if (!fs.statSync(absDir).isDirectory()) throw new Error(`Not a directory: ${absDir}`);
1796
+ let manifestPath = path.join(absDir, MANIFEST_FILENAME);
1797
+ let manifestFilename = MANIFEST_FILENAME;
1798
+ if (!fs.existsSync(manifestPath)) {
1799
+ manifestPath = path.join(absDir, LEGACY_MANIFEST_FILENAME);
1800
+ manifestFilename = LEGACY_MANIFEST_FILENAME;
1801
+ }
1802
+ if (!fs.existsSync(manifestPath)) throw new Error(`Missing required file: ${MANIFEST_FILENAME}`);
1803
+ let skillsJsonContent;
1804
+ try {
1805
+ skillsJsonContent = fs.readFileSync(manifestPath, "utf-8");
1806
+ } catch {
1807
+ throw new Error(`Failed to read ${manifestFilename}`);
1808
+ }
1809
+ let parsed;
1810
+ try {
1811
+ parsed = JSON.parse(skillsJsonContent);
1812
+ } catch {
1813
+ throw new Error(`Invalid ${manifestFilename}: not valid JSON`);
1814
+ }
1815
+ const validation = skillsJsonSchema.safeParse(parsed);
1816
+ if (!validation.success) {
1817
+ const issues = validation.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
1818
+ throw new Error(`Invalid ${manifestFilename}:\n${issues}`);
1819
+ }
1820
+ const skillMdPath = path.join(absDir, "SKILL.md");
1821
+ if (!fs.existsSync(skillMdPath)) throw new Error("Missing required file: SKILL.md");
1822
+ let readmeContent;
1823
+ try {
1824
+ readmeContent = fs.readFileSync(skillMdPath, "utf-8");
1825
+ } catch {
1826
+ throw new Error("Failed to read SKILL.md");
1827
+ }
1828
+ const files = collectFiles(absDir, absDir, buildIgnoreFilter(absDir));
1829
+ if (files.length > MAX_FILE_COUNT) throw new Error(`Too many files: ${files.length} exceeds maximum of ${MAX_FILE_COUNT}`);
1830
+ let totalSize = 0;
1831
+ for (const file of files) {
1832
+ const filePath = path.join(absDir, file);
1833
+ const fileStat = fs.statSync(filePath);
1834
+ totalSize += fileStat.size;
1835
+ }
1836
+ const tarball = await createTarball(absDir, files);
1837
+ if (tarball.length > MAX_PACKAGE_SIZE) throw new Error(`Tarball too large: ${tarball.length} bytes exceeds maximum of ${MAX_PACKAGE_SIZE} bytes (50MB)`);
1838
+ return {
1839
+ tarball,
1840
+ integrity: `sha512-${crypto$1.createHash("sha512").update(tarball).digest("base64")}`,
1841
+ fileCount: files.length,
1842
+ totalSize,
1843
+ readme: readmeContent,
1844
+ files
1845
+ };
1846
+ }
1847
+ /**
1848
+ * Pack a directory into a .tgz tarball for security scanning.
1849
+ *
1850
+ * Unlike pack(), this function does NOT require skills.json or SKILL.md.
1851
+ * It applies the same security checks (no symlinks, no path traversal, etc.)
1852
+ * and returns the same PackResult interface.
1853
+ *
1854
+ * Validates:
1855
+ * - Directory exists
1856
+ * - No symlinks or hardlinks
1857
+ * - No path traversal (.. components)
1858
+ * - No absolute paths
1859
+ * - File count <= 1000
1860
+ * - Tarball size <= 50MB
1861
+ *
1862
+ * Does NOT validate:
1863
+ * - skills.json existence or validity
1864
+ * - SKILL.md existence (but reads it if present)
1865
+ */
1866
+ async function packForScan(directory) {
1867
+ const absDir = path.resolve(directory);
1868
+ if (!fs.existsSync(absDir)) throw new Error(`Directory does not exist: ${absDir}`);
1869
+ if (!fs.statSync(absDir).isDirectory()) throw new Error(`Not a directory: ${absDir}`);
1870
+ let readmeContent = "";
1871
+ const skillMdPath = path.join(absDir, "SKILL.md");
1872
+ if (fs.existsSync(skillMdPath)) try {
1873
+ readmeContent = fs.readFileSync(skillMdPath, "utf-8");
1874
+ } catch {
1875
+ readmeContent = "";
1876
+ }
1877
+ const files = collectFiles(absDir, absDir, buildIgnoreFilter(absDir));
1878
+ if (files.length === 0) throw new Error(`No manifest found and no files to scan in ${absDir}`);
1879
+ if (files.length > MAX_FILE_COUNT) throw new Error(`Too many files: ${files.length} exceeds maximum of ${MAX_FILE_COUNT}`);
1880
+ let totalSize = 0;
1881
+ for (const file of files) {
1882
+ const filePath = path.join(absDir, file);
1883
+ const fileStat = fs.statSync(filePath);
1884
+ totalSize += fileStat.size;
1885
+ }
1886
+ const tarball = await createTarball(absDir, files);
1887
+ if (tarball.length > MAX_PACKAGE_SIZE) throw new Error(`Tarball too large: ${tarball.length} bytes exceeds maximum of ${MAX_PACKAGE_SIZE} bytes (50MB)`);
1888
+ return {
1889
+ tarball,
1890
+ integrity: `sha512-${crypto$1.createHash("sha512").update(tarball).digest("base64")}`,
1891
+ fileCount: files.length,
1892
+ totalSize,
1893
+ readme: readmeContent,
1894
+ files
1895
+ };
1896
+ }
1897
+ /**
1898
+ * Build an ignore filter from .tankignore, .gitignore, or defaults.
1899
+ */
1900
+ function buildIgnoreFilter(dir) {
1901
+ const ig = ignore();
1902
+ ig.add(ALWAYS_IGNORED);
1903
+ const tankIgnorePath = path.join(dir, ".tankignore");
1904
+ const gitIgnorePath = path.join(dir, ".gitignore");
1905
+ if (fs.existsSync(tankIgnorePath)) {
1906
+ const content = fs.readFileSync(tankIgnorePath, "utf-8");
1907
+ ig.add(content);
1908
+ ig.add(IGNORE_FILES);
1909
+ } else if (fs.existsSync(gitIgnorePath)) {
1910
+ const content = fs.readFileSync(gitIgnorePath, "utf-8");
1911
+ ig.add(content);
1912
+ ig.add(IGNORE_FILES);
1913
+ } else ig.add(DEFAULT_IGNORES);
1914
+ return ig;
1915
+ }
1916
+ /**
1917
+ * Recursively collect files from a directory, applying ignore rules and security checks.
1918
+ */
1919
+ function collectFiles(baseDir, currentDir, ig) {
1920
+ const files = [];
1921
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
1922
+ for (const entry of entries) {
1923
+ const fullPath = path.join(currentDir, entry.name);
1924
+ const relativePath = path.relative(baseDir, fullPath);
1925
+ if (relativePath.split(path.sep).includes("..")) throw new Error(`Path traversal detected: "${relativePath}" contains ".." component`);
1926
+ if (path.isAbsolute(relativePath)) throw new Error(`Absolute path detected: "${relativePath}"`);
1927
+ const lstatResult = fs.lstatSync(fullPath);
1928
+ if (lstatResult.isSymbolicLink()) throw new Error(`Symlink detected: "${relativePath}" — symlinks are not allowed in skill packages`);
1929
+ const pathForIgnore = lstatResult.isDirectory() ? `${relativePath}/` : relativePath;
1930
+ if (ig.ignores(pathForIgnore)) continue;
1931
+ if (lstatResult.isDirectory()) {
1932
+ const subFiles = collectFiles(baseDir, fullPath, ig);
1933
+ files.push(...subFiles);
1934
+ } else if (lstatResult.isFile()) files.push(relativePath);
1935
+ }
1936
+ return files;
1937
+ }
1938
+ /**
1939
+ * Create a gzipped tarball from the given files in the directory.
1940
+ */
1941
+ async function createTarball(cwd, files) {
1942
+ return new Promise((resolve, reject) => {
1943
+ const chunks = [];
1944
+ const stream = create({
1945
+ gzip: true,
1946
+ cwd,
1947
+ portable: true
1948
+ }, files);
1949
+ stream.on("data", (chunk) => {
1950
+ chunks.push(chunk);
1951
+ });
1952
+ stream.on("end", () => {
1953
+ resolve(Buffer.concat(chunks));
1954
+ });
1955
+ stream.on("error", (err) => {
1956
+ reject(err);
1957
+ });
1958
+ });
1959
+ }
1960
+ //#endregion
1961
+ //#region src/commands/publish.ts
1962
+ /**
1963
+ * Format bytes into a human-readable size string.
1964
+ */
1965
+ function formatSize$1(bytes) {
1966
+ if (bytes < 1024) return `${bytes} B`;
1967
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1968
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1969
+ }
1970
+ /**
1971
+ * Publish a skill package to the Tank registry.
1972
+ *
1973
+ * Flow:
1974
+ * 1. Check auth (token exists)
1975
+ * 2. Read skills.json from directory
1976
+ * 3. Pack directory into tarball
1977
+ * 4. If --dry-run: print summary and exit
1978
+ * 5. POST /api/v1/skills with manifest → get uploadUrl, skillId, versionId
1979
+ * 6. PUT tarball to uploadUrl
1980
+ * 7. POST /api/v1/skills/confirm with integrity data
1981
+ * 8. Print success
1982
+ */
1983
+ async function publishCommand(options = {}) {
1984
+ const { directory = process.cwd(), configDir, dryRun = false, private: privateFlag, visibility } = options;
1985
+ const config = getConfig(configDir);
1986
+ if (!config.token) throw new Error("Not logged in. Run: tank login");
1987
+ const resolvedManifest = resolveManifestPath(directory);
1988
+ if (!resolvedManifest.exists) throw new Error(`No ${MANIFEST_FILENAME} found in ${directory}. Run: tank init`);
1989
+ let manifest;
1990
+ try {
1991
+ const raw = fs.readFileSync(resolvedManifest.path, "utf-8");
1992
+ manifest = JSON.parse(raw);
1993
+ } catch {
1994
+ throw new Error(`Failed to read or parse ${path.basename(resolvedManifest.path)}`);
1995
+ }
1996
+ if (visibility && visibility !== "public" && visibility !== "private") throw new Error("Invalid visibility. Use 'public' or 'private'");
1997
+ const effectiveVisibility = visibility ?? (privateFlag ? "private" : void 0);
1998
+ if (effectiveVisibility) manifest.visibility = effectiveVisibility;
1999
+ const name = manifest.name;
2000
+ const version = manifest.version;
2001
+ const spinner = ora("Packing...").start();
2002
+ let packResult;
2003
+ try {
2004
+ packResult = await pack(directory);
2005
+ } catch (err) {
2006
+ spinner.fail("Packing failed");
2007
+ throw err;
2008
+ }
2009
+ const { tarball, integrity, fileCount, totalSize, readme, files } = packResult;
2010
+ if (dryRun) {
2011
+ spinner.stop();
2012
+ logger.info(`name: ${name}`);
2013
+ logger.info(`version: ${version}`);
2014
+ logger.info(`visibility: ${String(manifest.visibility ?? "default")}`);
2015
+ logger.info(`size: ${formatSize$1(totalSize)} (${fileCount} files)`);
2016
+ logger.info(`tarball: ${formatSize$1(tarball.length)} (compressed)`);
2017
+ try {
2018
+ const verifyRes = await fetch(`${config.registry}/api/v1/auth/whoami`, {
2019
+ method: "GET",
2020
+ headers: {
2021
+ Authorization: `Bearer ${config.token}`,
2022
+ "User-Agent": USER_AGENT
2023
+ }
2024
+ });
2025
+ if (verifyRes.status === 401) logger.warn("Token is invalid or expired. Run: tank login");
2026
+ else if (!verifyRes.ok) logger.warn("Could not verify token with server. Publish may fail.");
2027
+ else logger.success("Auth verified with server.");
2028
+ } catch {
2029
+ logger.warn("Could not reach server to verify token. Publish may fail.");
2030
+ }
2031
+ logger.success("Dry run complete — no files were uploaded.");
2032
+ return;
2033
+ }
2034
+ spinner.text = "Publishing...";
2035
+ const headers = {
2036
+ Authorization: `Bearer ${config.token}`,
2037
+ "Content-Type": "application/json",
2038
+ "User-Agent": USER_AGENT
2039
+ };
2040
+ const step1Res = await fetch(`${config.registry}/api/v1/skills`, {
2041
+ method: "POST",
2042
+ headers,
2043
+ body: JSON.stringify({
2044
+ manifest,
2045
+ readme,
2046
+ files
2047
+ })
2048
+ });
2049
+ if (!step1Res.ok) {
2050
+ spinner.fail("Publish failed");
2051
+ const errorMsg = (await step1Res.json().catch(() => null))?.error ?? step1Res.statusText;
2052
+ if (step1Res.status === 401) throw new Error("Authentication failed. Your token may be expired or invalid. Run: tank login");
2053
+ if (step1Res.status === 403) throw new Error(`Publish failed: ${errorMsg}`);
2054
+ if (step1Res.status === 404) throw new Error(`Publish failed: ${errorMsg}`);
2055
+ if (step1Res.status === 409) throw new Error(`Version already exists. Bump the version in ${MANIFEST_FILENAME}`);
2056
+ throw new Error(errorMsg);
2057
+ }
2058
+ const { uploadUrl, versionId } = await step1Res.json();
2059
+ spinner.text = "Uploading...";
2060
+ const uploadRes = await fetch(uploadUrl, {
2061
+ method: "PUT",
2062
+ headers: { "Content-Type": "application/octet-stream" },
2063
+ body: new Uint8Array(tarball)
2064
+ });
2065
+ if (!uploadRes.ok) {
2066
+ spinner.fail("Upload failed");
2067
+ throw new Error(`Failed to upload tarball: ${uploadRes.status} ${uploadRes.statusText}`);
2068
+ }
2069
+ spinner.text = "Confirming...";
2070
+ const confirmRes = await fetch(`${config.registry}/api/v1/skills/confirm`, {
2071
+ method: "POST",
2072
+ headers,
2073
+ body: JSON.stringify({
2074
+ versionId,
2075
+ integrity,
2076
+ fileCount,
2077
+ tarballSize: totalSize,
2078
+ readme
2079
+ })
2080
+ });
2081
+ if (!confirmRes.ok) {
2082
+ spinner.fail("Publish confirmation failed");
2083
+ const body = await confirmRes.json().catch(() => null);
2084
+ throw new Error(`Failed to confirm publish: ${body?.error ?? confirmRes.statusText}`);
2085
+ }
2086
+ spinner.succeed(`Published ${name}@${version} (${formatSize$1(totalSize)}, ${fileCount} files)`);
2087
+ }
2088
+ //#endregion
2089
+ //#region src/commands/remove.ts
2090
+ async function removeCommand(options) {
2091
+ const { name, directory = process.cwd(), global, homedir } = options;
2092
+ if (global) {
2093
+ const resolvedHome = homedir ?? os.homedir();
2094
+ try {
2095
+ const unlinkResult = unlinkSkillFromAgents({
2096
+ skillName: name,
2097
+ linksDir: path.join(resolvedHome, ".tank"),
2098
+ homedir
2099
+ });
2100
+ if (unlinkResult.unlinked.length > 0) logger.info(`Unlinked from ${unlinkResult.unlinked.length} agent(s)`);
2101
+ } catch {
2102
+ logger.warn("Agent unlinking skipped (non-fatal)");
2103
+ }
2104
+ const symlinkName = getSymlinkName(name);
2105
+ const agentSkillDir = path.join(getGlobalAgentSkillsDir(resolvedHome), symlinkName);
2106
+ if (fs.existsSync(agentSkillDir)) fs.rmSync(agentSkillDir, {
2107
+ recursive: true,
2108
+ force: true
2109
+ });
2110
+ const skillDir = getGlobalSkillDir(resolvedHome, name);
2111
+ if (fs.existsSync(skillDir)) fs.rmSync(skillDir, {
2112
+ recursive: true,
2113
+ force: true
2114
+ });
2115
+ const resolvedLock = resolveLockfilePath(path.join(resolvedHome, ".tank"));
2116
+ const lockPath = resolvedLock.path;
2117
+ if (resolvedLock.exists) {
2118
+ let lock;
2119
+ try {
2120
+ const raw = fs.readFileSync(lockPath, "utf-8");
2121
+ lock = JSON.parse(raw);
2122
+ } catch {
2123
+ lock = {
2124
+ lockfileVersion: 2,
2125
+ skills: {}
2126
+ };
2127
+ }
2128
+ for (const key of Object.keys(lock.skills)) {
2129
+ const lastAt = key.lastIndexOf("@");
2130
+ if (lastAt <= 0) continue;
2131
+ if (key.slice(0, lastAt) === name) delete lock.skills[key];
2132
+ }
2133
+ const sortedSkills = {};
2134
+ for (const key of Object.keys(lock.skills).sort()) sortedSkills[key] = lock.skills[key];
2135
+ lock.skills = sortedSkills;
2136
+ fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`);
2137
+ }
2138
+ logger.success(`Removed ${name} (global)`);
2139
+ return;
2140
+ }
2141
+ const resolvedManifest = resolveManifestPath(directory);
2142
+ if (!resolvedManifest.exists) throw new Error(`No ${MANIFEST_FILENAME} found in ${directory}. Run: tank init`);
2143
+ let skillsJson;
2144
+ try {
2145
+ const raw = fs.readFileSync(resolvedManifest.path, "utf-8");
2146
+ skillsJson = JSON.parse(raw);
2147
+ } catch {
2148
+ throw new Error(`Failed to read or parse ${path.basename(resolvedManifest.path)}`);
2149
+ }
2150
+ const skills = skillsJson.skills ?? {};
2151
+ if (!(name in skills)) throw new Error(`Skill "${name}" is not installed (not found in ${path.basename(resolvedManifest.path)})`);
2152
+ delete skills[name];
2153
+ skillsJson.skills = skills;
2154
+ fs.writeFileSync(resolvedManifest.path, `${JSON.stringify(skillsJson, null, 2)}\n`);
2155
+ const resolvedLocalLock = resolveLockfilePath(directory);
2156
+ const lockPath = resolvedLocalLock.path;
2157
+ if (resolvedLocalLock.exists) {
2158
+ let lock;
2159
+ try {
2160
+ const raw = fs.readFileSync(lockPath, "utf-8");
2161
+ lock = JSON.parse(raw);
2162
+ } catch {
2163
+ lock = {
2164
+ lockfileVersion: 2,
2165
+ skills: {}
2166
+ };
2167
+ }
2168
+ for (const key of Object.keys(lock.skills)) {
2169
+ const lastAt = key.lastIndexOf("@");
2170
+ if (lastAt <= 0) continue;
2171
+ if (key.slice(0, lastAt) === name) delete lock.skills[key];
2172
+ }
2173
+ const sortedSkills = {};
2174
+ for (const key of Object.keys(lock.skills).sort()) sortedSkills[key] = lock.skills[key];
2175
+ lock.skills = sortedSkills;
2176
+ fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`);
2177
+ }
2178
+ try {
2179
+ const unlinkResult = unlinkSkillFromAgents({
2180
+ skillName: name,
2181
+ linksDir: path.join(directory, ".tank"),
2182
+ homedir
2183
+ });
2184
+ if (unlinkResult.unlinked.length > 0) logger.info(`Unlinked from ${unlinkResult.unlinked.length} agent(s)`);
2185
+ } catch {
2186
+ logger.warn("Agent unlinking skipped (non-fatal)");
2187
+ }
2188
+ const symlinkName = getSymlinkName(name);
2189
+ const agentSkillDir = path.join(directory, ".tank", "agent-skills", symlinkName);
2190
+ if (fs.existsSync(agentSkillDir)) fs.rmSync(agentSkillDir, {
2191
+ recursive: true,
2192
+ force: true
2193
+ });
2194
+ const skillDir = getSkillDir(directory, name);
2195
+ if (fs.existsSync(skillDir)) fs.rmSync(skillDir, {
2196
+ recursive: true,
2197
+ force: true
2198
+ });
2199
+ logger.success(`Removed ${name}`);
2200
+ }
2201
+ function getSkillDir(projectDir, skillName) {
2202
+ if (skillName.startsWith("@")) {
2203
+ const [scope, name] = skillName.split("/");
2204
+ return path.join(projectDir, ".tank", "skills", scope, name);
2205
+ }
2206
+ return path.join(projectDir, ".tank", "skills", skillName);
2207
+ }
2208
+ function getGlobalSkillDir(homedir, skillName) {
2209
+ const globalDir = getGlobalSkillsDir(homedir);
2210
+ if (skillName.startsWith("@")) {
2211
+ const [scope, name] = skillName.split("/");
2212
+ return path.join(globalDir, scope, name);
2213
+ }
2214
+ return path.join(globalDir, skillName);
2215
+ }
2216
+ //#endregion
2217
+ //#region src/commands/run.ts
2218
+ const AGENTS_MODULE = "@tankpkg/vault/src/runner/agents";
2219
+ const RUNNER_MODULE = "@tankpkg/vault/src/runner/run";
2220
+ const SERVER_MODULE = "@tankpkg/vault/src/proxy/server";
2221
+ const VAULT_MODULE = "@tankpkg/vault/src/tokenizer/vault";
2222
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
2223
+ const BOOTSTRAP_PATH = path.resolve(__dirname, "..", "..", "..", "vault", "src", "proxy", "bootstrap.cjs");
2224
+ const PLACEHOLDER_REQUIRE = "--require tank-vault-proxy-bootstrap.js";
2225
+ async function runCommand(options) {
2226
+ const [agentsModule, runnerModule, serverModule, vaultModule] = await Promise.all([
2227
+ import(AGENTS_MODULE),
2228
+ import(RUNNER_MODULE),
2229
+ import(SERVER_MODULE),
2230
+ import(VAULT_MODULE)
2231
+ ]);
2232
+ const { getAgentConfig, getSupportedAgentIds } = agentsModule;
2233
+ const { buildAgentEnv } = runnerModule;
2234
+ const { startProxy } = serverModule;
2235
+ const { VaultStore } = vaultModule;
2236
+ const config = getAgentConfig(options.agent);
2237
+ if (!config) {
2238
+ const supported = getSupportedAgentIds().join(", ");
2239
+ console.error(chalk.red(`Unknown agent: ${options.agent}`));
2240
+ console.error(`Supported agents: ${supported}`);
2241
+ process.exit(1);
2242
+ }
2243
+ const vault = new VaultStore();
2244
+ const proxy = await startProxy(vault);
2245
+ console.log(`Vault proxy started on port ${proxy.port}`);
2246
+ console.log(`Agent: ${config.name} (${config.runtime})`);
2247
+ console.log("Credentials will be detected from traffic");
2248
+ const env = buildAgentEnv(config.strategy, proxy.url, process.env);
2249
+ const bootstrapRequire = `--require ${BOOTSTRAP_PATH}`;
2250
+ if (env.NODE_OPTIONS?.includes(PLACEHOLDER_REQUIRE)) env.NODE_OPTIONS = env.NODE_OPTIONS.replace(PLACEHOLDER_REQUIRE, bootstrapRequire);
2251
+ if (options.verbose) {
2252
+ console.log(`Bootstrap: ${BOOTSTRAP_PATH}`);
2253
+ console.log(`Proxy URL: ${proxy.url}`);
2254
+ }
2255
+ const child = spawn(config.command, options.agentArgs ?? [], {
2256
+ env,
2257
+ stdio: "inherit"
2258
+ });
2259
+ let cleaningUp = false;
2260
+ const cleanupAndExit = async (code) => {
2261
+ if (cleaningUp) return;
2262
+ cleaningUp = true;
2263
+ process.off("SIGINT", onSigint);
2264
+ process.off("SIGTERM", onSigterm);
2265
+ vault.clear();
2266
+ await proxy.close();
2267
+ process.exit(code);
2268
+ };
2269
+ const onSigint = () => {
2270
+ child.kill("SIGINT");
2271
+ };
2272
+ const onSigterm = () => {
2273
+ child.kill("SIGTERM");
2274
+ };
2275
+ process.on("SIGINT", onSigint);
2276
+ process.on("SIGTERM", onSigterm);
2277
+ child.once("error", async (error) => {
2278
+ console.error(chalk.red(`Failed to launch agent: ${error.message}`));
2279
+ await cleanupAndExit(1);
2280
+ });
2281
+ child.once("exit", async (code, signal) => {
2282
+ await cleanupAndExit(typeof code === "number" ? code : signal ? 1 : 0);
2283
+ });
2284
+ }
2285
+ //#endregion
2286
+ //#region src/commands/scan.ts
2287
+ function verdictColor(verdict) {
2288
+ switch (verdict) {
2289
+ case "pass": return chalk.green;
2290
+ case "pass_with_notes": return chalk.yellow;
2291
+ case "flagged": return chalk.hex("#FF8C00");
2292
+ case "fail": return chalk.red;
2293
+ default: return chalk.white;
2294
+ }
2295
+ }
2296
+ function severityColor(severity) {
2297
+ switch (severity) {
2298
+ case "critical": return chalk.red;
2299
+ case "high": return chalk.hex("#FF8C00");
2300
+ case "medium": return chalk.yellow;
2301
+ case "low": return chalk.green;
2302
+ default: return chalk.white;
2303
+ }
2304
+ }
2305
+ function scoreColor$1(score) {
2306
+ if (score >= 7) return chalk.green;
2307
+ if (score >= 4) return chalk.yellow;
2308
+ return chalk.red;
2309
+ }
2310
+ function formatSize(bytes) {
2311
+ if (bytes < 1024) return `${bytes} B`;
2312
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
2313
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
2314
+ }
2315
+ async function scanCommand(options = {}) {
2316
+ const { directory = process.cwd(), configDir } = options;
2317
+ const absDir = path.resolve(directory);
2318
+ const config = getConfig(configDir);
2319
+ if (!config.token) throw new Error("Not logged in. Run: tank login");
2320
+ const spinner = ora("Packing skill...").start();
2321
+ const resolvedManifest = resolveManifestPath(absDir);
2322
+ let manifest;
2323
+ let packResult;
2324
+ if (resolvedManifest.exists) {
2325
+ try {
2326
+ packResult = await pack(absDir);
2327
+ } catch (err) {
2328
+ spinner.fail("Packing failed");
2329
+ throw err;
2330
+ }
2331
+ try {
2332
+ manifest = JSON.parse(fs.readFileSync(resolvedManifest.path, "utf-8"));
2333
+ } catch (err) {
2334
+ spinner.fail(`Failed to read ${path.basename(resolvedManifest.path)}`);
2335
+ throw err;
2336
+ }
2337
+ } else {
2338
+ try {
2339
+ packResult = await packForScan(absDir);
2340
+ } catch (err) {
2341
+ spinner.fail("Packing failed");
2342
+ throw err;
2343
+ }
2344
+ manifest = {
2345
+ name: path.basename(absDir),
2346
+ version: "0.0.0",
2347
+ description: "Local scan"
2348
+ };
2349
+ }
2350
+ const name = manifest.name ?? "unknown";
2351
+ const version = manifest.version ?? "0.0.0";
2352
+ spinner.text = `Scanning ${name}@${version}...`;
2353
+ const formData = new FormData();
2354
+ const blob = new Blob([new Uint8Array(packResult.tarball)], { type: "application/gzip" });
2355
+ formData.append("tarball", blob, `${name}-${version}.tgz`);
2356
+ formData.append("manifest", JSON.stringify(manifest));
2357
+ let scanRes;
2358
+ try {
2359
+ scanRes = await fetch(`${config.registry}/api/v1/scan`, {
2360
+ method: "POST",
2361
+ headers: {
2362
+ Authorization: `Bearer ${config.token}`,
2363
+ "User-Agent": USER_AGENT
2364
+ },
2365
+ body: formData
2366
+ });
2367
+ } catch (err) {
2368
+ spinner.fail("Scan failed");
2369
+ throw new Error(`Network error: ${err instanceof Error ? err.message : String(err)}`);
2370
+ }
2371
+ if (!scanRes.ok) {
2372
+ spinner.fail("Scan failed");
2373
+ const body = await scanRes.json().catch(() => null);
2374
+ if (scanRes.status === 401) throw new Error("Authentication failed. Your token may be expired or invalid. Run: tank login");
2375
+ throw new Error(body?.error ?? scanRes.statusText);
2376
+ }
2377
+ const result = await scanRes.json();
2378
+ spinner.stop();
2379
+ const verdictLabel = verdictColor(result.verdict)(result.verdict.toUpperCase());
2380
+ const auditScore = result.audit_score ?? 0;
2381
+ const scoreLabel = scoreColor$1(auditScore)(auditScore.toFixed(1));
2382
+ console.log("");
2383
+ console.log(chalk.bold(`Security Scan: ${name}@${version}`));
2384
+ console.log("");
2385
+ console.log(`${chalk.dim("Verdict:".padEnd(14))}${verdictLabel}`);
2386
+ console.log(`${chalk.dim("Score:".padEnd(14))}${scoreLabel}/10`);
2387
+ console.log(`${chalk.dim("Duration:".padEnd(14))}${(result.duration_ms / 1e3).toFixed(1)}s`);
2388
+ console.log(`${chalk.dim("Files:".padEnd(14))}${packResult.fileCount} (${formatSize(packResult.totalSize)})`);
2389
+ if (result.findings.length > 0) {
2390
+ console.log("");
2391
+ console.log(chalk.bold(`Findings (${result.findings.length})`));
2392
+ const bySeverity = {
2393
+ critical: [],
2394
+ high: [],
2395
+ medium: [],
2396
+ low: []
2397
+ };
2398
+ for (const f of result.findings) bySeverity[f.severity].push(f);
2399
+ for (const severity of [
2400
+ "critical",
2401
+ "high",
2402
+ "medium",
2403
+ "low"
2404
+ ]) {
2405
+ const findings = bySeverity[severity];
2406
+ if (findings.length === 0) continue;
2407
+ console.log("");
2408
+ const label = severityColor(severity)(`${severity.toUpperCase()} (${findings.length})`);
2409
+ console.log(` ${label}`);
2410
+ for (const f of findings) {
2411
+ console.log(` - ${chalk.bold(f.type)}: ${f.description}`);
2412
+ if (f.location) console.log(` ${chalk.dim("Location:")} ${f.location}`);
2413
+ }
2414
+ }
2415
+ } else {
2416
+ console.log("");
2417
+ console.log(chalk.green("No findings. Your skill looks secure!"));
2418
+ }
2419
+ if (result.stage_results?.length > 0) {
2420
+ console.log("");
2421
+ console.log(chalk.bold("Scan Stages"));
2422
+ for (const stage of result.stage_results) {
2423
+ const icon = stage.status === "passed" ? chalk.green("✓") : chalk.red("✗");
2424
+ console.log(` ${icon} ${stage.stage} (${stage.duration_ms}ms)`);
2425
+ }
2426
+ }
2427
+ if (result.scan_id) {
2428
+ console.log("");
2429
+ console.log(chalk.dim(`Full report: ${config.registry}/scans/${result.scan_id}`));
2430
+ }
2431
+ console.log("");
2432
+ }
2433
+ //#endregion
2434
+ //#region src/commands/search.ts
2435
+ const MAX_DESC_LENGTH = 60;
2436
+ function scoreColor(score) {
2437
+ if (score >= 7) return chalk.green;
2438
+ if (score >= 4) return chalk.yellow;
2439
+ return chalk.red;
2440
+ }
2441
+ function truncate(text, maxLen) {
2442
+ if (text.length <= maxLen) return text;
2443
+ return `${text.slice(0, maxLen - 3)}...`;
2444
+ }
2445
+ function padRight(text, width) {
2446
+ if (text.length >= width) return text;
2447
+ return text + " ".repeat(width - text.length);
2448
+ }
2449
+ async function searchCommand(options) {
2450
+ const { query, configDir } = options;
2451
+ const config = getConfig(configDir);
2452
+ const url = `${config.registry}/api/v1/search?q=${encodeURIComponent(query)}&limit=20`;
2453
+ let res;
2454
+ try {
2455
+ const headers = { "User-Agent": USER_AGENT };
2456
+ if (config.token) headers.Authorization = `Bearer ${config.token}`;
2457
+ res = await fetch(url, { headers });
2458
+ } catch (err) {
2459
+ throw new Error(`Network error searching: ${err instanceof Error ? err.message : String(err)}`);
2460
+ }
2461
+ if (!res.ok) {
2462
+ const body = await res.json().catch(() => null);
2463
+ throw new Error(body?.error ?? `Search failed: ${res.statusText}`);
2464
+ }
2465
+ const data = await res.json();
2466
+ if (data.results.length === 0) {
2467
+ console.log(`No skills found for "${query}"`);
2468
+ return;
2469
+ }
2470
+ console.log(`${padRight("NAME", 30) + padRight("VERSION", 10) + padRight("SCORE", 8)}DESCRIPTION`);
2471
+ for (const result of data.results) {
2472
+ const name = chalk.bold(padRight(result.name, 30));
2473
+ const version = padRight(result.latestVersion, 10);
2474
+ const scoreStr = Number.isInteger(result.auditScore) ? result.auditScore.toFixed(1) : String(result.auditScore);
2475
+ const score = scoreColor(result.auditScore)(padRight(scoreStr, 8));
2476
+ const desc = truncate(result.description ?? "", MAX_DESC_LENGTH);
2477
+ console.log(`${name}${version}${score}${desc}`);
2478
+ }
2479
+ console.log("");
2480
+ console.log(`${data.results.length} skill${data.results.length === 1 ? "" : "s"} found`);
2481
+ }
2482
+ //#endregion
2483
+ //#region src/commands/unlink.ts
2484
+ async function unlinkCommand(options = {}) {
2485
+ const resolvedManifest = resolveManifestPath(options.directory ?? process.cwd());
2486
+ if (!resolvedManifest.exists) throw new Error(`No ${MANIFEST_FILENAME} found. Run this command from a skill directory.`);
2487
+ let skillsJson;
2488
+ try {
2489
+ const raw = fs.readFileSync(resolvedManifest.path, "utf-8");
2490
+ skillsJson = JSON.parse(raw);
2491
+ } catch {
2492
+ throw new Error(`Failed to read or parse ${path.basename(resolvedManifest.path)}`);
2493
+ }
2494
+ const skillName = skillsJson.name;
2495
+ if (typeof skillName !== "string" || skillName.trim().length === 0) throw new Error(`Missing 'name' in ${path.basename(resolvedManifest.path)}`);
2496
+ const homedir = options.homedir ?? os.homedir();
2497
+ const result = unlinkSkillFromAgents({
2498
+ skillName,
2499
+ linksDir: path.join(homedir, ".tank"),
2500
+ homedir: options.homedir
2501
+ });
2502
+ const symlinkName = getSymlinkName(skillName);
2503
+ const wrapperDir = path.join(getGlobalAgentSkillsDir(options.homedir), symlinkName);
2504
+ if (fs.existsSync(wrapperDir)) fs.rmSync(wrapperDir, {
2505
+ recursive: true,
2506
+ force: true
2507
+ });
2508
+ if (result.unlinked.length === 0 && result.notFound.length === 0) {
2509
+ logger.info(`No links found for ${skillName}`);
2510
+ return;
2511
+ }
2512
+ logger.success(`Unlinked ${skillName} from ${result.unlinked.length} agent(s)`);
2513
+ }
2514
+ //#endregion
2515
+ //#region src/commands/update.ts
2516
+ const VERSION_CHECK_CONCURRENCY = 8;
2517
+ async function updateCommand(options) {
2518
+ const { name, directory = process.cwd(), configDir, global = false, homedir } = options;
2519
+ if (global) {
2520
+ if (name) await updateSingleGlobal(name, configDir, homedir);
2521
+ else await updateAllGlobal(configDir, homedir);
2522
+ return;
2523
+ }
2524
+ const resolvedManifest = resolveManifestPath(directory);
2525
+ if (!resolvedManifest.exists) throw new Error(`No ${MANIFEST_FILENAME} found in ${directory}. Run: tank init`);
2526
+ let skillsJson;
2527
+ try {
2528
+ const raw = fs.readFileSync(resolvedManifest.path, "utf-8");
2529
+ skillsJson = JSON.parse(raw);
2530
+ } catch {
2531
+ throw new Error(`Failed to read or parse ${path.basename(resolvedManifest.path)}`);
2532
+ }
2533
+ const skills = skillsJson.skills ?? {};
2534
+ if (name) await updateSingle(name, skills, directory, configDir, global, homedir);
2535
+ else await updateAll(skills, directory, configDir, global, homedir);
2536
+ }
2537
+ function parseLockKey$1(key) {
2538
+ const lastAt = key.lastIndexOf("@");
2539
+ if (lastAt <= 0) return null;
2540
+ return {
2541
+ name: key.slice(0, lastAt),
2542
+ version: key.slice(lastAt + 1)
2543
+ };
2544
+ }
2545
+ function readLockfile(lockPath) {
2546
+ if (!fs.existsSync(lockPath)) return null;
2547
+ try {
2548
+ const raw = fs.readFileSync(lockPath, "utf-8");
2549
+ return JSON.parse(raw);
2550
+ } catch {
2551
+ return null;
2552
+ }
2553
+ }
2554
+ function readLockfileStrict(lockPath) {
2555
+ if (!fs.existsSync(lockPath)) throw new Error(`Global ${LOCKFILE_FILENAME} not found at ${lockPath}`);
2556
+ try {
2557
+ const raw = fs.readFileSync(lockPath, "utf-8");
2558
+ return JSON.parse(raw);
2559
+ } catch {
2560
+ throw new Error(`Failed to read or parse global ${LOCKFILE_FILENAME}`);
2561
+ }
2562
+ }
2563
+ function getGlobalLockPath(homedir) {
2564
+ const resolvedHome = homedir ?? os.homedir();
2565
+ return resolveLockfilePath(path.join(resolvedHome, ".tank")).path;
2566
+ }
2567
+ async function fetchAvailableVersions(name, registry, headers) {
2568
+ const versionsUrl = `${registry}/api/v1/skills/${encodeURIComponent(name)}/versions`;
2569
+ let versionsRes;
2570
+ try {
2571
+ versionsRes = await fetch(versionsUrl, { headers });
2572
+ } catch (err) {
2573
+ throw new Error(`Network error fetching versions for ${name}: ${err instanceof Error ? err.message : String(err)}`);
2574
+ }
2575
+ if (!versionsRes.ok) {
2576
+ if (versionsRes.status === 404) throw new Error(`Skill not found in registry: ${name}`);
2577
+ const body = await versionsRes.json().catch(() => null);
2578
+ throw new Error(body?.error ?? versionsRes.statusText);
2579
+ }
2580
+ return (await versionsRes.json()).versions.map((v) => v.version);
2581
+ }
2582
+ /**
2583
+ * Deduplicate lockfile entries by skill name.
2584
+ * When multiple versions of the same skill exist in the lockfile (e.g. from
2585
+ * transitive dependencies), keeps only the highest version per skill name.
2586
+ */
2587
+ function deduplicateByName(entries) {
2588
+ const versionsByName = /* @__PURE__ */ new Map();
2589
+ for (const key of entries) {
2590
+ const parsed = parseLockKey$1(key);
2591
+ if (!parsed) continue;
2592
+ const versions = versionsByName.get(parsed.name) ?? [];
2593
+ versions.push(parsed.version);
2594
+ versionsByName.set(parsed.name, versions);
2595
+ }
2596
+ const latestByName = /* @__PURE__ */ new Map();
2597
+ for (const [name, versions] of versionsByName) {
2598
+ const latest = resolve("*", versions);
2599
+ if (latest) latestByName.set(name, latest);
2600
+ }
2601
+ return latestByName;
2602
+ }
2603
+ async function fetchVersionsBatch(skillNames, registry, headers) {
2604
+ const results = /* @__PURE__ */ new Map();
2605
+ for (let i = 0; i < skillNames.length; i += VERSION_CHECK_CONCURRENCY) {
2606
+ const batch = skillNames.slice(i, i + VERSION_CHECK_CONCURRENCY);
2607
+ const settled = await Promise.allSettled(batch.map(async (name) => {
2608
+ return {
2609
+ name,
2610
+ versions: await fetchAvailableVersions(name, registry, headers)
2611
+ };
2612
+ }));
2613
+ for (const result of settled) if (result.status === "fulfilled") results.set(result.value.name, result.value.versions);
2614
+ else throw result.reason;
2615
+ }
2616
+ return results;
2617
+ }
2618
+ async function updateSingle(name, skills, directory, configDir, global = false, homedir) {
2619
+ const versionRange = skills[name];
2620
+ if (!versionRange) throw new Error(`Skill "${name}" is not installed (not found in ${MANIFEST_FILENAME})`);
2621
+ const config = getConfig(configDir);
2622
+ const requestHeaders = { "User-Agent": USER_AGENT };
2623
+ if (config.token) requestHeaders.Authorization = `Bearer ${config.token}`;
2624
+ const availableVersions = await fetchAvailableVersions(name, config.registry, requestHeaders);
2625
+ const resolved = resolve(versionRange, availableVersions);
2626
+ if (!resolved) throw new Error(`No version of ${name} satisfies range "${versionRange}". Available: ${availableVersions.join(", ")}`);
2627
+ const lockPath = global ? getGlobalLockPath(homedir) : resolveLockfilePath(directory).path;
2628
+ let currentVersion = null;
2629
+ const lock = readLockfile(lockPath);
2630
+ if (lock) for (const key of Object.keys(lock.skills)) {
2631
+ const parsed = parseLockKey$1(key);
2632
+ if (!parsed) continue;
2633
+ if (parsed.name === name) {
2634
+ currentVersion = parsed.version;
2635
+ break;
2636
+ }
2637
+ }
2638
+ if (resolved === currentVersion) {
2639
+ logger.info(`Already at latest: ${name}@${resolved}`);
2640
+ return;
2641
+ }
2642
+ await installCommand({
2643
+ name,
2644
+ versionRange,
2645
+ directory,
2646
+ configDir,
2647
+ global,
2648
+ homedir
2649
+ });
2650
+ logger.success(`Updated ${name} to ${resolved}`);
2651
+ }
2652
+ async function updateAll(skills, directory, configDir, global = false, homedir) {
2653
+ const skillEntries = Object.entries(skills);
2654
+ if (skillEntries.length === 0) {
2655
+ logger.info(`No skills defined in ${MANIFEST_FILENAME}`);
2656
+ return;
2657
+ }
2658
+ const config = getConfig(configDir);
2659
+ const requestHeaders = { "User-Agent": USER_AGENT };
2660
+ if (config.token) requestHeaders.Authorization = `Bearer ${config.token}`;
2661
+ const lock = readLockfile(global ? getGlobalLockPath(homedir) : resolveLockfilePath(directory).path);
2662
+ const currentVersionByName = /* @__PURE__ */ new Map();
2663
+ if (lock) for (const key of Object.keys(lock.skills)) {
2664
+ const parsed = parseLockKey$1(key);
2665
+ if (!parsed) continue;
2666
+ const existing = currentVersionByName.get(parsed.name);
2667
+ if (!existing) currentVersionByName.set(parsed.name, parsed.version);
2668
+ else {
2669
+ const higher = resolve("*", [existing, parsed.version]);
2670
+ if (higher) currentVersionByName.set(parsed.name, higher);
2671
+ }
2672
+ }
2673
+ const allVersions = await fetchVersionsBatch(skillEntries.map(([name]) => name), config.registry, requestHeaders);
2674
+ const toUpdate = [];
2675
+ for (const [name, versionRange] of skillEntries) {
2676
+ const availableVersions = allVersions.get(name);
2677
+ if (!availableVersions) continue;
2678
+ const resolved = resolve(versionRange, availableVersions);
2679
+ if (!resolved) continue;
2680
+ if (resolved === (currentVersionByName.get(name) ?? null)) continue;
2681
+ toUpdate.push({
2682
+ name,
2683
+ versionRange
2684
+ });
2685
+ }
2686
+ if (toUpdate.length === 0) {
2687
+ logger.info("All skills up to date");
2688
+ return;
2689
+ }
2690
+ for (const { name, versionRange } of toUpdate) await installCommand({
2691
+ name,
2692
+ versionRange,
2693
+ directory,
2694
+ configDir,
2695
+ global,
2696
+ homedir
2697
+ });
2698
+ logger.success(`Updated ${toUpdate.length} skill${toUpdate.length === 1 ? "" : "s"}`);
2699
+ }
2700
+ async function updateSingleGlobal(name, configDir, homedir) {
2701
+ const lock = readLockfileStrict(getGlobalLockPath(homedir));
2702
+ let currentVersion = null;
2703
+ for (const key of Object.keys(lock.skills)) {
2704
+ const parsed = parseLockKey$1(key);
2705
+ if (!parsed) continue;
2706
+ if (parsed.name === name) {
2707
+ currentVersion = parsed.version;
2708
+ break;
2709
+ }
2710
+ }
2711
+ if (!currentVersion) throw new Error(`Skill "${name}" is not installed globally (not found in ${LOCKFILE_FILENAME})`);
2712
+ const config = getConfig(configDir);
2713
+ const requestHeaders = { "User-Agent": USER_AGENT };
2714
+ if (config.token) requestHeaders.Authorization = `Bearer ${config.token}`;
2715
+ const availableVersions = await fetchAvailableVersions(name, config.registry, requestHeaders);
2716
+ const versionRange = `>=${currentVersion}`;
2717
+ const resolved = resolve(versionRange, availableVersions);
2718
+ if (!resolved) throw new Error(`No version of ${name} satisfies range "${versionRange}". Available: ${availableVersions.join(", ")}`);
2719
+ if (resolved === currentVersion) {
2720
+ logger.info(`Already at latest: ${name}@${resolved}`);
2721
+ return;
2722
+ }
2723
+ await installCommand({
2724
+ name,
2725
+ versionRange,
2726
+ global: true,
2727
+ homedir,
2728
+ configDir
2729
+ });
2730
+ logger.success(`Updated ${name} to ${resolved}`);
2731
+ }
2732
+ async function updateAllGlobal(configDir, homedir) {
2733
+ const lock = readLockfileStrict(getGlobalLockPath(homedir));
2734
+ const entries = Object.keys(lock.skills);
2735
+ if (entries.length === 0) {
2736
+ logger.info(`No skills defined in global ${LOCKFILE_FILENAME}`);
2737
+ return;
2738
+ }
2739
+ const config = getConfig(configDir);
2740
+ const requestHeaders = { "User-Agent": USER_AGENT };
2741
+ if (config.token) requestHeaders.Authorization = `Bearer ${config.token}`;
2742
+ const latestByName = deduplicateByName(entries);
2743
+ const allVersions = await fetchVersionsBatch(Array.from(latestByName.keys()), config.registry, requestHeaders);
2744
+ const toUpdate = [];
2745
+ for (const [name, currentVersion] of latestByName) {
2746
+ const availableVersions = allVersions.get(name);
2747
+ if (!availableVersions) continue;
2748
+ const resolved = resolve("*", availableVersions);
2749
+ if (!resolved) continue;
2750
+ if (resolved === currentVersion) continue;
2751
+ toUpdate.push(name);
2752
+ }
2753
+ if (toUpdate.length === 0) {
2754
+ logger.info("All skills up to date");
2755
+ return;
2756
+ }
2757
+ for (const name of toUpdate) await installCommand({
2758
+ name,
2759
+ versionRange: "*",
2760
+ global: true,
2761
+ homedir,
2762
+ configDir
2763
+ });
2764
+ logger.success(`Updated ${toUpdate.length} skill${toUpdate.length === 1 ? "" : "s"}`);
2765
+ }
2766
+ //#endregion
2767
+ //#region src/commands/upgrade.ts
2768
+ function isNewerVersion$1(candidateVersion, currentVersion) {
2769
+ if (candidateVersion === currentVersion) return false;
2770
+ return resolve("*", [candidateVersion, currentVersion]) === candidateVersion;
2771
+ }
2772
+ function resolveCurrentBinary() {
2773
+ try {
2774
+ return fs.realpathSync(process.argv[1]);
2775
+ } catch {
2776
+ return process.execPath;
2777
+ }
2778
+ }
2779
+ async function upgradeCommand(opts) {
2780
+ const currentBinaryPath = resolveCurrentBinary();
2781
+ if (process.platform !== "win32" && (currentBinaryPath.includes("/Cellar/") || currentBinaryPath.includes("/homebrew/"))) {
2782
+ console.log(chalk.yellow("Tank was installed via Homebrew. Run `brew upgrade tank` instead."));
2783
+ return;
2784
+ }
2785
+ if (currentBinaryPath.includes("node_modules") || currentBinaryPath.endsWith(".js") || currentBinaryPath.endsWith(".mjs")) {
2786
+ console.log(chalk.yellow("Tank was installed via npm/npx. Run `npm update -g @tankpkg/cli` to upgrade instead."));
2787
+ return;
2788
+ }
2789
+ let targetVersion;
2790
+ if (opts?.version) targetVersion = opts.version;
2791
+ else {
2792
+ const res = await fetch("https://api.github.com/repos/tankpkg/tank/releases/latest", { headers: { "User-Agent": USER_AGENT } });
2793
+ if (!res.ok) throw new Error(`Failed to fetch latest release: ${res.status} ${res.statusText}`);
2794
+ targetVersion = (await res.json()).tag_name.replace(/^v/, "");
2795
+ }
2796
+ if (!isNewerVersion$1(targetVersion, VERSION) && !opts?.force) {
2797
+ console.log(chalk.green(`✓ Already on latest version: ${VERSION}`));
2798
+ return;
2799
+ }
2800
+ const binaryName = `tank-${process.platform === "win32" ? "windows" : process.platform === "darwin" ? "darwin" : "linux"}-${process.arch === "arm64" ? "arm64" : "x64"}${process.platform === "win32" ? ".exe" : ""}`;
2801
+ if (opts?.dryRun) {
2802
+ console.log(`Would upgrade tank ${VERSION} → ${targetVersion}`);
2803
+ return;
2804
+ }
2805
+ console.log(chalk.cyan(`Upgrading tank ${VERSION} → ${targetVersion}...`));
2806
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tank-upgrade-"));
2807
+ try {
2808
+ const binaryUrl = `https://github.com/tankpkg/tank/releases/download/v${targetVersion}/${binaryName}`;
2809
+ const tmpBin = path.join(tmpDir, binaryName);
2810
+ const binRes = await fetch(binaryUrl, { headers: { "User-Agent": USER_AGENT } });
2811
+ if (!binRes.ok) throw new Error(`Failed to download binary: ${binRes.status} ${binRes.statusText}`);
2812
+ if (!binRes.body) throw new Error("Empty response body when downloading binary");
2813
+ const binBuffer = Buffer.from(await binRes.arrayBuffer());
2814
+ fs.writeFileSync(tmpBin, binBuffer);
2815
+ const sumsUrl = `https://github.com/tankpkg/tank/releases/download/v${targetVersion}/SHA256SUMS`;
2816
+ const sumsRes = await fetch(sumsUrl, { headers: { "User-Agent": USER_AGENT } });
2817
+ if (!sumsRes.ok) throw new Error(`Failed to download SHA256SUMS: ${sumsRes.status} ${sumsRes.statusText}`);
2818
+ const sumsText = await sumsRes.text();
2819
+ let expectedHash;
2820
+ for (const line of sumsText.split("\n")) {
2821
+ const trimmed = line.trim();
2822
+ if (!trimmed) continue;
2823
+ const parts = trimmed.split(/\s+/);
2824
+ if (parts.length >= 2 && parts[1] === binaryName) {
2825
+ expectedHash = parts[0];
2826
+ break;
2827
+ }
2828
+ }
2829
+ if (!expectedHash) throw new Error(`No checksum found for ${binaryName} in SHA256SUMS`);
2830
+ const fileBuffer = fs.readFileSync(tmpBin);
2831
+ if (crypto$1.createHash("sha256").update(fileBuffer).digest("hex") !== expectedHash) {
2832
+ console.log(chalk.red("Checksum mismatch. Aborting for security."));
2833
+ return;
2834
+ }
2835
+ if (process.platform !== "win32") fs.chmodSync(tmpBin, 493);
2836
+ fs.copyFileSync(tmpBin, currentBinaryPath);
2837
+ if (process.platform !== "win32") fs.chmodSync(currentBinaryPath, 493);
2838
+ console.log(chalk.green(`✓ Upgraded tank ${VERSION} → ${targetVersion}`));
2839
+ console.log(chalk.gray(`Release notes: https://github.com/tankpkg/tank/releases/tag/v${targetVersion}`));
2840
+ } finally {
2841
+ fs.rmSync(tmpDir, {
2842
+ recursive: true,
2843
+ force: true
2844
+ });
2845
+ }
2846
+ }
2847
+ //#endregion
2848
+ //#region src/commands/verify.ts
2849
+ /**
2850
+ * Verify that installed skills match the lockfile.
2851
+ *
2852
+ * For each entry in skills.lock:
2853
+ * 1. Parse the skill name from the lock key
2854
+ * 2. Check that the skill directory exists in .tank/skills/
2855
+ * 3. Check that the directory is not empty
2856
+ *
2857
+ * Throws on failure (exit code 1). Prints success message on pass.
2858
+ */
2859
+ async function verifyCommand(options) {
2860
+ const directory = options?.directory ?? process.cwd();
2861
+ const lock = readLockfile$1(directory);
2862
+ if (!lock) throw new Error(`No ${LOCKFILE_FILENAME} found in ${directory}. Run: tank install`);
2863
+ const entries = Object.entries(lock.skills);
2864
+ if (entries.length === 0) {
2865
+ logger.success("No skills to verify (lockfile is empty)");
2866
+ return;
2867
+ }
2868
+ const issues = [];
2869
+ for (const [key] of entries) {
2870
+ const skillDir = getExtractDir(directory, parseLockKey(key));
2871
+ if (!fs.existsSync(skillDir)) {
2872
+ issues.push(`${key}: directory missing at ${skillDir}`);
2873
+ continue;
2874
+ }
2875
+ if (fs.readdirSync(skillDir).length === 0) issues.push(`${key}: directory exists but is empty`);
2876
+ }
2877
+ if (issues.length > 0) {
2878
+ for (const issue of issues) logger.error(issue);
2879
+ throw new Error(`Verification failed: ${issues.length} issue${issues.length === 1 ? "" : "s"} found`);
2880
+ }
2881
+ const count = entries.length;
2882
+ logger.success(`All ${count} skill${count === 1 ? "" : "s"} verified`);
2883
+ }
2884
+ function parseLockKey(key) {
2885
+ const lastAt = key.lastIndexOf("@");
2886
+ if (lastAt <= 0) throw new Error(`Invalid lockfile key: ${key}`);
2887
+ return key.slice(0, lastAt);
2888
+ }
2889
+ function getExtractDir(projectDir, skillName) {
2890
+ if (skillName.startsWith("@")) {
2891
+ const [scope, name] = skillName.split("/");
2892
+ return path.join(projectDir, ".tank", "skills", scope, name);
2893
+ }
2894
+ return path.join(projectDir, ".tank", "skills", skillName);
2895
+ }
2896
+ //#endregion
2897
+ //#region src/commands/whoami.ts
2898
+ async function whoamiCommand(options = {}) {
2899
+ const { configDir } = options;
2900
+ const config = getConfig(configDir);
2901
+ if (!config.token) {
2902
+ logger.warn("Not logged in. Run: tank login");
2903
+ return;
2904
+ }
2905
+ try {
2906
+ const res = await fetch(`${config.registry}/api/v1/auth/whoami`, {
2907
+ method: "GET",
2908
+ headers: {
2909
+ Authorization: `Bearer ${config.token}`,
2910
+ "User-Agent": USER_AGENT
2911
+ }
2912
+ });
2913
+ if (res.status === 401) {
2914
+ logger.error("Token is invalid or expired. Run: tank login");
2915
+ return;
2916
+ }
2917
+ if (!res.ok) {
2918
+ if (config.user) {
2919
+ printUserInfo(config.user);
2920
+ logger.warn("Could not verify token with server. Run: tank login");
2921
+ } else logger.error("Could not verify token. Server returned an error. Run: tank login");
2922
+ process.exitCode = 1;
2923
+ return;
2924
+ }
2925
+ if (config.user) printUserInfo(config.user);
2926
+ else logger.info("Logged in (token verified).");
2927
+ } catch {
2928
+ if (config.user) {
2929
+ logger.info(`Logged in as: ${config.user.name ?? "unknown"} (offline)`);
2930
+ logger.info(`Email: ${config.user.email ?? "unknown"}`);
2931
+ logger.warn("Could not reach server to verify token. Run: tank login");
2932
+ } else logger.error("Could not verify token. Check your network connection.");
2933
+ process.exitCode = 1;
2934
+ }
2935
+ }
2936
+ function printUserInfo(user) {
2937
+ logger.info(`Logged in as: ${user.name ?? "unknown"}`);
2938
+ logger.info(`Email: ${user.email ?? "unknown"}`);
2939
+ }
2940
+ //#endregion
2941
+ //#region src/lib/upgrade-check.ts
2942
+ function isNewerVersion(candidateVersion, currentVersion) {
2943
+ if (candidateVersion === currentVersion) return false;
2944
+ return resolve("*", [candidateVersion, currentVersion]) === candidateVersion;
2945
+ }
2946
+ async function checkForUpgrade(configDir) {
2947
+ try {
2948
+ if (process.env.TANK_NO_UPDATE_CHECK || process.env.CI) return;
2949
+ const cacheDir = getConfigDir(configDir);
2950
+ const cachePath = path.join(cacheDir, "upgrade_check.json");
2951
+ let cache = null;
2952
+ try {
2953
+ const raw = fs.readFileSync(cachePath, "utf-8");
2954
+ cache = JSON.parse(raw);
2955
+ } catch {}
2956
+ if (cache !== null && Date.now() - cache.lastCheck < 1440 * 60 * 1e3 && cache !== null) {
2957
+ if (isNewerVersion(cache.latestVersion, VERSION)) {
2958
+ console.error(`\n ${chalk.cyan("ℹ")} New version available: ${chalk.gray(VERSION)} → ${chalk.green(cache.latestVersion)}`);
2959
+ console.error(` Run ${chalk.cyan("`tank upgrade`")} to update.\n`);
2960
+ }
2961
+ return;
2962
+ }
2963
+ const res = await fetch("https://api.github.com/repos/tankpkg/tank/releases/latest", {
2964
+ headers: { "User-Agent": `tank-cli/${VERSION}` },
2965
+ signal: AbortSignal.timeout(3e3)
2966
+ });
2967
+ if (!res.ok) return;
2968
+ const latestVersion = (await res.json()).tag_name.replace(/^v/, "");
2969
+ if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
2970
+ const newCache = {
2971
+ lastCheck: Date.now(),
2972
+ latestVersion
2973
+ };
2974
+ fs.writeFileSync(cachePath, `${JSON.stringify(newCache, null, 2)}\n`);
2975
+ if (isNewerVersion(latestVersion, VERSION)) {
2976
+ console.error(`\n ${chalk.cyan("ℹ")} New version available: ${chalk.gray(VERSION)} → ${chalk.green(latestVersion)}`);
2977
+ console.error(` Run ${chalk.cyan("`tank upgrade`")} to update.\n`);
2978
+ }
2979
+ } catch {}
2980
+ }
2981
+ //#endregion
2982
+ //#region src/bin/tank.ts
2983
+ const program = new Command();
2984
+ program.name("tank").description("Security-first package manager for AI agent skills").version(VERSION);
2985
+ program.command("init").description("Create a new tank.json in the current directory").option("-y, --yes", "Skip prompts, use defaults").option("--name <name>", "Skill name").option("--skill-version <version>", "Skill version (default: 0.1.0)").option("--description <desc>", "Skill description").option("--private", "Make skill private").option("--force", "Overwrite existing tank.json").action(async (opts) => {
2986
+ try {
2987
+ await initCommand({
2988
+ yes: opts.yes,
2989
+ name: opts.name,
2990
+ version: opts.skillVersion,
2991
+ description: opts.description,
2992
+ private: opts.private,
2993
+ force: opts.force
2994
+ });
2995
+ } catch (err) {
2996
+ const msg = err instanceof Error ? err.message : String(err);
2997
+ console.error(`Init failed: ${msg}`);
2998
+ process.exit(1);
2999
+ }
3000
+ });
3001
+ program.command("login").description("Authenticate with the Tank registry via browser").action(async () => {
3002
+ try {
3003
+ await loginCommand();
3004
+ } catch (err) {
3005
+ const msg = err instanceof Error ? err.message : String(err);
3006
+ console.error(`Login failed: ${msg}`);
3007
+ await flushLogs();
3008
+ process.exit(1);
3009
+ }
3010
+ await flushLogs();
3011
+ });
3012
+ program.command("whoami").description("Show the currently logged-in user").action(async () => {
3013
+ try {
3014
+ await whoamiCommand();
3015
+ } catch (err) {
3016
+ const msg = err instanceof Error ? err.message : String(err);
3017
+ console.error(`Error: ${msg}`);
3018
+ process.exit(1);
3019
+ }
3020
+ });
3021
+ program.command("logout").description("Remove authentication token from config").action(async () => {
3022
+ try {
3023
+ await logoutCommand();
3024
+ } catch (err) {
3025
+ const msg = err instanceof Error ? err.message : String(err);
3026
+ console.error(`Logout failed: ${msg}`);
3027
+ process.exit(1);
3028
+ }
3029
+ });
3030
+ program.command("publish").alias("pub").description("Pack and publish a skill to the Tank registry").option("--dry-run", "Validate and pack without uploading").option("--private", "Publish skill as private").option("--visibility <mode>", "Skill visibility (public|private)").action(async (opts) => {
3031
+ try {
3032
+ await publishCommand({
3033
+ dryRun: opts.dryRun,
3034
+ private: opts.private,
3035
+ visibility: opts.visibility
3036
+ });
3037
+ } catch (err) {
3038
+ const msg = err instanceof Error ? err.message : String(err);
3039
+ console.error(`Publish failed: ${msg}`);
3040
+ process.exit(1);
3041
+ }
3042
+ });
3043
+ 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) => {
3044
+ try {
3045
+ if (name) await installCommand({
3046
+ name,
3047
+ versionRange,
3048
+ global: opts.global
3049
+ });
3050
+ else await installAll({ global: opts.global });
3051
+ } catch (err) {
3052
+ const msg = err instanceof Error ? err.message : String(err);
3053
+ console.error(`Install failed: ${msg}`);
3054
+ process.exit(1);
3055
+ }
3056
+ });
3057
+ program.command("remove").aliases(["rm", "r"]).description("Remove an installed skill").argument("<name>", "Skill name (e.g., @org/skill-name)").option("-g, --global", "Remove a globally installed skill").action(async (name, opts) => {
3058
+ try {
3059
+ await removeCommand({
3060
+ name,
3061
+ global: opts.global
3062
+ });
3063
+ } catch (err) {
3064
+ const msg = err instanceof Error ? err.message : String(err);
3065
+ console.error(`Remove failed: ${msg}`);
3066
+ process.exit(1);
3067
+ }
3068
+ });
3069
+ program.command("update").alias("up").description("Update skills to latest versions within their ranges").argument("[name]", "Skill name to update (omit to update all)").option("-g, --global", "Update globally installed skills").action(async (name, opts) => {
3070
+ try {
3071
+ await updateCommand({
3072
+ name,
3073
+ global: opts.global
3074
+ });
3075
+ } catch (err) {
3076
+ const msg = err instanceof Error ? err.message : String(err);
3077
+ console.error(`Update failed: ${msg}`);
3078
+ process.exit(1);
3079
+ }
3080
+ });
3081
+ program.command("verify").description("Verify installed skills match the lockfile").action(async () => {
3082
+ try {
3083
+ await verifyCommand();
3084
+ } catch (err) {
3085
+ const msg = err instanceof Error ? err.message : String(err);
3086
+ console.error(`Verify failed: ${msg}`);
3087
+ process.exit(1);
3088
+ }
3089
+ });
3090
+ program.command("permissions").alias("perms").description("Display resolved permission summary for installed skills").action(async () => {
3091
+ try {
3092
+ await permissionsCommand();
3093
+ } catch (err) {
3094
+ const msg = err instanceof Error ? err.message : String(err);
3095
+ console.error(`Error: ${msg}`);
3096
+ process.exit(1);
3097
+ }
3098
+ });
3099
+ program.command("search").alias("s").description("Search for skills in the Tank registry").argument("<query>", "Search query").action(async (query) => {
3100
+ try {
3101
+ await searchCommand({ query });
3102
+ } catch (err) {
3103
+ const msg = err instanceof Error ? err.message : String(err);
3104
+ console.error(`Search failed: ${msg}`);
3105
+ process.exit(1);
3106
+ }
3107
+ });
3108
+ program.command("info").alias("show").description("Show detailed information about a skill").argument("<name>", "Skill name (e.g., @org/skill-name)").action(async (name) => {
3109
+ try {
3110
+ await infoCommand({ name });
3111
+ } catch (err) {
3112
+ const msg = err instanceof Error ? err.message : String(err);
3113
+ console.error(`Info failed: ${msg}`);
3114
+ process.exit(1);
3115
+ }
3116
+ });
3117
+ program.command("audit").description("Display security audit results for installed skills").argument("[name]", "Skill name to audit (omit to audit all)").action(async (name) => {
3118
+ try {
3119
+ await auditCommand({ name });
3120
+ } catch (err) {
3121
+ const msg = err instanceof Error ? err.message : String(err);
3122
+ console.error(`Audit failed: ${msg}`);
3123
+ process.exit(1);
3124
+ }
3125
+ });
3126
+ program.command("run").description("Launch an agent with credential protection (vault proxy)").argument("<agent>", "Agent ID to launch").allowUnknownOption(true).allowExcessArguments(true).option("--verbose", "Print verbose vault proxy details").action(async (agent, opts, cmd) => {
3127
+ try {
3128
+ const agentArgs = cmd.args.slice(1);
3129
+ await runCommand({
3130
+ agent,
3131
+ verbose: opts.verbose,
3132
+ agentArgs
3133
+ });
3134
+ } catch (err) {
3135
+ const msg = err instanceof Error ? err.message : String(err);
3136
+ console.error(`Run failed: ${msg}`);
3137
+ process.exit(1);
3138
+ }
3139
+ });
3140
+ program.command("scan").description("Scan a local skill for security issues without publishing").option("-d, --directory <path>", "Directory to scan (default: current directory)").action(async (opts) => {
3141
+ try {
3142
+ await scanCommand({ directory: opts.directory });
3143
+ } catch (err) {
3144
+ const msg = err instanceof Error ? err.message : String(err);
3145
+ console.error(`Scan failed: ${msg}`);
3146
+ process.exit(1);
3147
+ }
3148
+ });
3149
+ program.command("link").alias("ln").description("Link current skill directory to AI agent directories (for development)").action(async () => {
3150
+ try {
3151
+ await linkCommand();
3152
+ } catch (err) {
3153
+ const msg = err instanceof Error ? err.message : String(err);
3154
+ console.error(`Link failed: ${msg}`);
3155
+ process.exit(1);
3156
+ }
3157
+ });
3158
+ program.command("unlink").description("Remove skill symlinks from AI agent directories").action(async () => {
3159
+ try {
3160
+ await unlinkCommand();
3161
+ } catch (err) {
3162
+ const msg = err instanceof Error ? err.message : String(err);
3163
+ console.error(`Unlink failed: ${msg}`);
3164
+ process.exit(1);
3165
+ }
3166
+ });
3167
+ program.command("doctor").description("Diagnose agent integration health").action(async () => {
3168
+ try {
3169
+ await doctorCommand();
3170
+ } catch (err) {
3171
+ const msg = err instanceof Error ? err.message : String(err);
3172
+ console.error(`Doctor failed: ${msg}`);
3173
+ process.exit(1);
3174
+ }
3175
+ });
3176
+ program.command("migrate").description("Migrate skills.json → tank.json and skills.lock → tank.lock").action(async () => {
3177
+ try {
3178
+ await migrateCommand();
3179
+ } catch (err) {
3180
+ const msg = err instanceof Error ? err.message : String(err);
3181
+ console.error(`Migration failed: ${msg}`);
3182
+ process.exit(1);
3183
+ }
3184
+ });
3185
+ program.command("upgrade").description("Update tank to the latest version").argument("[version]", "Target version (default: latest)").option("--dry-run", "Check for updates without installing").option("--force", "Reinstall even if already on the target version").action(async (version, opts) => {
3186
+ try {
3187
+ await upgradeCommand({
3188
+ version,
3189
+ dryRun: opts.dryRun,
3190
+ force: opts.force
3191
+ });
3192
+ } catch (err) {
3193
+ const msg = err instanceof Error ? err.message : String(err);
3194
+ console.error(`Upgrade failed: ${msg}`);
3195
+ await flushLogs();
3196
+ process.exit(1);
3197
+ }
3198
+ await flushLogs();
3199
+ });
3200
+ checkForUpgrade().catch(() => {});
3201
+ program.parse();
3202
+ //#endregion
3203
+ export {};
3204
+
3205
+ //# sourceMappingURL=tank.js.map