@tankpkg/cli 0.7.0 → 0.9.0

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