@tankpkg/mcp-server 0.6.3 → 0.8.1

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 (87) hide show
  1. package/README.md +11 -4
  2. package/dist/index.d.ts +1 -3
  3. package/dist/index.js +2215 -29
  4. package/dist/index.js.map +1 -1
  5. package/package.json +20 -14
  6. package/LICENSE +0 -21
  7. package/dist/index.d.ts.map +0 -1
  8. package/dist/lib/api-client.d.ts +0 -45
  9. package/dist/lib/api-client.d.ts.map +0 -1
  10. package/dist/lib/api-client.js +0 -78
  11. package/dist/lib/api-client.js.map +0 -1
  12. package/dist/lib/config.d.ts +0 -25
  13. package/dist/lib/config.d.ts.map +0 -1
  14. package/dist/lib/config.js +0 -59
  15. package/dist/lib/config.js.map +0 -1
  16. package/dist/lib/packer.d.ts +0 -34
  17. package/dist/lib/packer.d.ts.map +0 -1
  18. package/dist/lib/packer.js +0 -276
  19. package/dist/lib/packer.js.map +0 -1
  20. package/dist/tools/audit-skill.d.ts +0 -3
  21. package/dist/tools/audit-skill.d.ts.map +0 -1
  22. package/dist/tools/audit-skill.js +0 -213
  23. package/dist/tools/audit-skill.js.map +0 -1
  24. package/dist/tools/doctor.d.ts +0 -3
  25. package/dist/tools/doctor.d.ts.map +0 -1
  26. package/dist/tools/doctor.js +0 -158
  27. package/dist/tools/doctor.js.map +0 -1
  28. package/dist/tools/init-skill.d.ts +0 -3
  29. package/dist/tools/init-skill.d.ts.map +0 -1
  30. package/dist/tools/init-skill.js +0 -72
  31. package/dist/tools/init-skill.js.map +0 -1
  32. package/dist/tools/install-skill.d.ts +0 -3
  33. package/dist/tools/install-skill.d.ts.map +0 -1
  34. package/dist/tools/install-skill.js +0 -206
  35. package/dist/tools/install-skill.js.map +0 -1
  36. package/dist/tools/link-skill.d.ts +0 -3
  37. package/dist/tools/link-skill.d.ts.map +0 -1
  38. package/dist/tools/link-skill.js +0 -81
  39. package/dist/tools/link-skill.js.map +0 -1
  40. package/dist/tools/login.d.ts +0 -3
  41. package/dist/tools/login.d.ts.map +0 -1
  42. package/dist/tools/login.js +0 -104
  43. package/dist/tools/login.js.map +0 -1
  44. package/dist/tools/logout.d.ts +0 -3
  45. package/dist/tools/logout.d.ts.map +0 -1
  46. package/dist/tools/logout.js +0 -19
  47. package/dist/tools/logout.js.map +0 -1
  48. package/dist/tools/publish-skill.d.ts +0 -3
  49. package/dist/tools/publish-skill.d.ts.map +0 -1
  50. package/dist/tools/publish-skill.js +0 -166
  51. package/dist/tools/publish-skill.js.map +0 -1
  52. package/dist/tools/remove-skill.d.ts +0 -3
  53. package/dist/tools/remove-skill.d.ts.map +0 -1
  54. package/dist/tools/remove-skill.js +0 -110
  55. package/dist/tools/remove-skill.js.map +0 -1
  56. package/dist/tools/scan-skill.d.ts +0 -3
  57. package/dist/tools/scan-skill.d.ts.map +0 -1
  58. package/dist/tools/scan-skill.js +0 -200
  59. package/dist/tools/scan-skill.js.map +0 -1
  60. package/dist/tools/search-skills.d.ts +0 -3
  61. package/dist/tools/search-skills.d.ts.map +0 -1
  62. package/dist/tools/search-skills.js +0 -54
  63. package/dist/tools/search-skills.js.map +0 -1
  64. package/dist/tools/skill-info.d.ts +0 -3
  65. package/dist/tools/skill-info.d.ts.map +0 -1
  66. package/dist/tools/skill-info.js +0 -88
  67. package/dist/tools/skill-info.js.map +0 -1
  68. package/dist/tools/skill-permissions.d.ts +0 -3
  69. package/dist/tools/skill-permissions.d.ts.map +0 -1
  70. package/dist/tools/skill-permissions.js +0 -311
  71. package/dist/tools/skill-permissions.js.map +0 -1
  72. package/dist/tools/unlink-skill.d.ts +0 -3
  73. package/dist/tools/unlink-skill.d.ts.map +0 -1
  74. package/dist/tools/unlink-skill.js +0 -72
  75. package/dist/tools/unlink-skill.js.map +0 -1
  76. package/dist/tools/update-skill.d.ts +0 -3
  77. package/dist/tools/update-skill.d.ts.map +0 -1
  78. package/dist/tools/update-skill.js +0 -317
  79. package/dist/tools/update-skill.js.map +0 -1
  80. package/dist/tools/verify-skills.d.ts +0 -3
  81. package/dist/tools/verify-skills.d.ts.map +0 -1
  82. package/dist/tools/verify-skills.js +0 -121
  83. package/dist/tools/verify-skills.js.map +0 -1
  84. package/dist/tools/whoami.d.ts +0 -3
  85. package/dist/tools/whoami.d.ts.map +0 -1
  86. package/dist/tools/whoami.js +0 -29
  87. package/dist/tools/whoami.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,30 +1,2214 @@
1
1
  #!/usr/bin/env node
2
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
- // Import tools
5
- import { registerLoginTool } from './tools/login.js';
6
- import { registerSearchSkillsTool } from './tools/search-skills.js';
7
- import { registerSkillInfoTool } from './tools/skill-info.js';
8
- import { registerScanSkillTool } from './tools/scan-skill.js';
9
- import { registerPublishSkillTool } from './tools/publish-skill.js';
10
- import { registerLogoutTool } from './tools/logout.js';
11
- import { registerWhoamiTool } from './tools/whoami.js';
12
- import { registerInitSkillTool } from './tools/init-skill.js';
13
- import { registerRemoveSkillTool } from './tools/remove-skill.js';
14
- import { registerVerifySkillsTool } from './tools/verify-skills.js';
15
- import { registerLinkSkillTool } from './tools/link-skill.js';
16
- import { registerUnlinkSkillTool } from './tools/unlink-skill.js';
17
- import { registerDoctorTool } from './tools/doctor.js';
18
- import { registerSkillPermissionsTool } from './tools/skill-permissions.js';
19
- import { registerInstallSkillTool } from './tools/install-skill.js';
20
- import { registerUpdateSkillTool } from './tools/update-skill.js';
21
- import { registerAuditSkillTool } from './tools/audit-skill.js';
22
- // Create MCP server instance
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import semver from "semver";
7
+ import { z } from "zod";
8
+ import os from "node:os";
9
+ import crypto$1 from "node:crypto";
10
+ import { create, extract } from "tar";
11
+ import ignore from "ignore";
12
+ const MANIFEST_FILENAME = "tank.json";
13
+ const LEGACY_MANIFEST_FILENAME = "skills.json";
14
+ const LOCKFILE_FILENAME = "tank.lock";
15
+ const LEGACY_LOCKFILE_FILENAME = "skills.lock";
16
+ /**
17
+ * Resolves a semver range against a list of available versions.
18
+ * Returns the highest version that satisfies the range, or null if none match.
19
+ *
20
+ * Pre-release versions are excluded from range matching unless the range
21
+ * explicitly includes a pre-release tag (e.g., ">=1.0.0-beta.1").
22
+ * Exact version matches always work, including for pre-release versions.
23
+ *
24
+ * @param range - A semver range string (e.g., "^2.1.0", "~1.0.0", ">=2.0.0 <3.0.0", "*")
25
+ * @param versions - An array of semver version strings to match against
26
+ * @returns The highest matching version string, or null if no match
27
+ */
28
+ function resolve(range, versions) {
29
+ try {
30
+ if (!range || !semver.validRange(range)) return null;
31
+ const validVersions = versions.filter((v) => semver.valid(v) !== null);
32
+ if (validVersions.length === 0) return null;
33
+ return semver.maxSatisfying(validVersions, range) ?? null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+ const networkPermissionsSchema = z.object({ outbound: z.array(z.string()).optional() }).strict();
39
+ const filesystemPermissionsSchema = z.object({
40
+ read: z.array(z.string()).optional(),
41
+ write: z.array(z.string()).optional()
42
+ }).strict();
43
+ const permissionsSchema = z.object({
44
+ network: networkPermissionsSchema.optional(),
45
+ filesystem: filesystemPermissionsSchema.optional(),
46
+ subprocess: z.boolean().optional()
47
+ }).strict();
48
+ z.enum(["user", "admin"]);
49
+ z.enum([
50
+ "active",
51
+ "suspended",
52
+ "banned"
53
+ ]);
54
+ z.enum([
55
+ "active",
56
+ "deprecated",
57
+ "quarantined",
58
+ "removed"
59
+ ]);
60
+ z.enum([
61
+ "user.ban",
62
+ "user.suspend",
63
+ "user.unban",
64
+ "user.promote",
65
+ "user.demote",
66
+ "skill.quarantine",
67
+ "skill.remove",
68
+ "skill.deprecate",
69
+ "skill.restore",
70
+ "skill.feature",
71
+ "skill.unfeature",
72
+ "org.suspend",
73
+ "org.member.remove",
74
+ "org.delete"
75
+ ]);
76
+ const skillsJsonSchema = z.object({
77
+ 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"),
78
+ version: z.string().regex(/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/, "Version must be valid semver"),
79
+ description: z.string().max(500, "Description must be 500 characters or fewer").optional(),
80
+ skills: z.record(z.string(), z.string()).optional(),
81
+ permissions: permissionsSchema.optional(),
82
+ repository: z.string().url("Repository must be a valid URL").optional(),
83
+ visibility: z.enum(["public", "private"]).optional(),
84
+ audit: z.object({ min_score: z.number().min(0).max(10) }).strict().optional()
85
+ }).strict();
86
+ const lockedSkillV1Schema = z.object({
87
+ resolved: z.string().url(),
88
+ integrity: z.string().regex(/^sha512-/, "Integrity must start with sha512-"),
89
+ permissions: permissionsSchema,
90
+ audit_score: z.number().min(0).max(10).nullable()
91
+ });
92
+ z.object({
93
+ lockfileVersion: z.literal(1),
94
+ skills: z.record(z.string(), lockedSkillV1Schema)
95
+ });
96
+ const lockedSkillSchema = z.object({
97
+ resolved: z.string().url(),
98
+ integrity: z.string().regex(/^sha512-/, "Integrity must start with sha512-"),
99
+ permissions: permissionsSchema,
100
+ audit_score: z.number().min(0).max(10).nullable(),
101
+ dependencies: z.record(z.string(), z.string()).optional()
102
+ });
103
+ z.object({
104
+ lockfileVersion: z.union([z.literal(1), z.literal(2)]),
105
+ skills: z.record(z.string(), lockedSkillSchema)
106
+ });
107
+ //#endregion
108
+ //#region src/lib/config.ts
109
+ const DEFAULT_CONFIG = { registry: "https://tankpkg.dev" };
110
+ /**
111
+ * Get the path to the tank config directory.
112
+ */
113
+ function getConfigDir(configDir) {
114
+ return configDir ?? path.join(os.homedir(), ".tank");
115
+ }
116
+ /**
117
+ * Get the path to the tank config file.
118
+ */
119
+ function getConfigPath(configDir) {
120
+ return path.join(getConfigDir(configDir), "config.json");
121
+ }
122
+ /**
123
+ * Read the tank config file. Returns defaults if file doesn't exist.
124
+ */
125
+ function getConfig(configDir) {
126
+ const configPath = getConfigPath(configDir);
127
+ try {
128
+ const raw = fs.readFileSync(configPath, "utf-8");
129
+ const parsed = JSON.parse(raw);
130
+ const merged = {
131
+ ...DEFAULT_CONFIG,
132
+ ...parsed
133
+ };
134
+ const envToken = process.env.TANK_TOKEN?.trim();
135
+ if (envToken) merged.token = envToken;
136
+ return merged;
137
+ } catch {
138
+ const envToken = process.env.TANK_TOKEN?.trim();
139
+ return {
140
+ ...DEFAULT_CONFIG,
141
+ ...envToken ? { token: envToken } : {}
142
+ };
143
+ }
144
+ }
145
+ /**
146
+ * Write config to disk. Merges with existing config.
147
+ */
148
+ function setConfig(partial, configDir) {
149
+ const dir = getConfigDir(configDir);
150
+ const configPath = getConfigPath(configDir);
151
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, {
152
+ recursive: true,
153
+ mode: 448
154
+ });
155
+ const merged = {
156
+ ...getConfig(configDir),
157
+ ...partial
158
+ };
159
+ fs.writeFileSync(configPath, `${JSON.stringify(merged, null, 2)}\n`, {
160
+ encoding: "utf-8",
161
+ mode: 384
162
+ });
163
+ }
164
+ //#endregion
165
+ //#region src/lib/api-client.ts
166
+ /**
167
+ * Tank API client for MCP server.
168
+ */
169
+ var TankApiClient = class {
170
+ config;
171
+ constructor(options = {}) {
172
+ this.config = getConfig(options.configDir);
173
+ }
174
+ /**
175
+ * Get the base URL for the Tank API.
176
+ */
177
+ get baseUrl() {
178
+ return this.config.registry;
179
+ }
180
+ /**
181
+ * Get the auth token (if available).
182
+ */
183
+ get token() {
184
+ return this.config.token;
185
+ }
186
+ /**
187
+ * Check if authenticated.
188
+ */
189
+ get isAuthenticated() {
190
+ return !!this.config.token;
191
+ }
192
+ /**
193
+ * Make an authenticated API request.
194
+ */
195
+ async fetch(path, options = {}) {
196
+ const url = `${this.baseUrl}${path}`;
197
+ const headers = {
198
+ "Content-Type": "application/json",
199
+ ...options.headers
200
+ };
201
+ if (this.config.token) headers.Authorization = `Bearer ${this.config.token}`;
202
+ try {
203
+ const response = await fetch(url, {
204
+ ...options,
205
+ headers
206
+ });
207
+ if (!response.ok) return {
208
+ error: (await response.json().catch(() => ({}))).error ?? response.statusText,
209
+ status: response.status,
210
+ ok: false
211
+ };
212
+ return {
213
+ data: await response.json(),
214
+ ok: true
215
+ };
216
+ } catch (err) {
217
+ return {
218
+ error: err instanceof Error ? err.message : "Network error",
219
+ status: 0,
220
+ ok: false
221
+ };
222
+ }
223
+ }
224
+ async verifyAuth() {
225
+ if (!this.config.token) return {
226
+ valid: false,
227
+ reason: "no-token"
228
+ };
229
+ const result = await this.fetch("/api/v1/auth/whoami");
230
+ if (result.ok) return {
231
+ valid: true,
232
+ user: {
233
+ name: result.data.name,
234
+ email: result.data.email
235
+ }
236
+ };
237
+ if (result.status === 0) return {
238
+ valid: false,
239
+ reason: "network-error",
240
+ error: result.error
241
+ };
242
+ return {
243
+ valid: false,
244
+ reason: "unauthorized"
245
+ };
246
+ }
247
+ };
248
+ //#endregion
249
+ //#region src/tools/audit-skill.ts
250
+ const SCOPED_NAME_PATTERN$6 = /^@[a-z0-9-]+\/[a-z0-9][a-z0-9-]*$/;
251
+ function parseLockKey$2(key) {
252
+ const lastAt = key.lastIndexOf("@");
253
+ if (lastAt <= 0) return null;
254
+ return {
255
+ name: key.slice(0, lastAt),
256
+ version: key.slice(lastAt + 1)
257
+ };
258
+ }
259
+ function deriveVerdict(score, status) {
260
+ if (status !== "completed" || score === null) return "PENDING";
261
+ if (score >= 7) return "PASS";
262
+ if (score >= 4) return "FLAGGED";
263
+ return "FAIL";
264
+ }
265
+ function formatFindings(findings) {
266
+ if (findings.length === 0) return "";
267
+ const bySeverity = {
268
+ critical: [],
269
+ high: [],
270
+ medium: [],
271
+ low: []
272
+ };
273
+ for (const f of findings) if (bySeverity[f.severity]) bySeverity[f.severity].push(f);
274
+ const lines = ["", `### Findings (${findings.length})`];
275
+ for (const severity of [
276
+ "critical",
277
+ "high",
278
+ "medium",
279
+ "low"
280
+ ]) {
281
+ const group = bySeverity[severity];
282
+ if (group.length === 0) continue;
283
+ lines.push(`\n**${severity.toUpperCase()} (${group.length}):**`);
284
+ for (const f of group) lines.push(`- ${f.type}: ${f.description}${f.location ? ` (${f.location})` : ""}`);
285
+ }
286
+ return lines.join("\n");
287
+ }
288
+ function registerAuditSkillTool(server) {
289
+ server.tool("audit-skill", "Show security audit results for a skill from the Tank registry.", {
290
+ name: z.string().describe("Skill name in @org/name format"),
291
+ version: z.string().optional().describe("Specific version to audit (defaults to installed or latest)")
292
+ }, async ({ name, version }) => {
293
+ if (!SCOPED_NAME_PATTERN$6.test(name)) return {
294
+ content: [{
295
+ type: "text",
296
+ text: `Validation error: Skill name "${name}" must use the @org/name format (e.g. @acme/my-skill).`
297
+ }],
298
+ isError: true
299
+ };
300
+ const client = new TankApiClient();
301
+ if (!client.isAuthenticated) return {
302
+ content: [{
303
+ type: "text",
304
+ text: "Authentication required. Please run the \"login\" tool first to authenticate with Tank."
305
+ }],
306
+ isError: true
307
+ };
308
+ const encodedName = encodeURIComponent(name);
309
+ let targetVersion = version;
310
+ if (!targetVersion) {
311
+ let lockPath = path.join(process.cwd(), LOCKFILE_FILENAME);
312
+ if (!fs.existsSync(lockPath)) lockPath = path.join(process.cwd(), LEGACY_LOCKFILE_FILENAME);
313
+ if (fs.existsSync(lockPath)) try {
314
+ const raw = fs.readFileSync(lockPath, "utf-8");
315
+ const lock = JSON.parse(raw);
316
+ for (const key of Object.keys(lock.skills)) {
317
+ const parsed = parseLockKey$2(key);
318
+ if (parsed && parsed.name === name) {
319
+ targetVersion = parsed.version;
320
+ break;
321
+ }
322
+ }
323
+ } catch {}
324
+ }
325
+ if (!targetVersion) {
326
+ const metaResult = await client.fetch(`/api/v1/skills/${encodedName}`);
327
+ if (!metaResult.ok) {
328
+ if (metaResult.status === 0) return {
329
+ content: [{
330
+ type: "text",
331
+ text: "Unable to connect to the Tank registry. Check your network connection and try again."
332
+ }],
333
+ isError: true
334
+ };
335
+ if (metaResult.status === 404) return {
336
+ content: [{
337
+ type: "text",
338
+ text: `Skill "${name}" not found in the Tank registry.`
339
+ }],
340
+ isError: true
341
+ };
342
+ return {
343
+ content: [{
344
+ type: "text",
345
+ text: `Failed to fetch skill metadata: ${metaResult.error}`
346
+ }],
347
+ isError: true
348
+ };
349
+ }
350
+ targetVersion = metaResult.data.latestVersion;
351
+ }
352
+ const versionResult = await client.fetch(`/api/v1/skills/${encodedName}/${targetVersion}`);
353
+ if (!versionResult.ok) {
354
+ if (versionResult.status === 0) return {
355
+ content: [{
356
+ type: "text",
357
+ text: "Unable to connect to the Tank registry. Check your network connection and try again."
358
+ }],
359
+ isError: true
360
+ };
361
+ if (versionResult.status === 404) return {
362
+ content: [{
363
+ type: "text",
364
+ text: `Skill "${name}" version "${targetVersion}" not found in the Tank registry.`
365
+ }],
366
+ isError: true
367
+ };
368
+ return {
369
+ content: [{
370
+ type: "text",
371
+ text: `Failed to fetch audit data: ${versionResult.error}`
372
+ }],
373
+ isError: true
374
+ };
375
+ }
376
+ const details = versionResult.data;
377
+ const verdict = deriveVerdict(details.auditScore, details.auditStatus);
378
+ if (details.auditStatus !== "completed") return { content: [{
379
+ type: "text",
380
+ text: [
381
+ `## Audit: ${name}@${targetVersion}`,
382
+ "",
383
+ `**Status:** Pending security review`,
384
+ `**Scan Status:** ${details.auditStatus}`,
385
+ "",
386
+ "This skill has not yet been through security scanning. Results will be available once the scan completes."
387
+ ].join("\n")
388
+ }] };
389
+ let findingsText = "";
390
+ const scanResult = await client.fetch(`/api/v1/skills/${encodedName}/${targetVersion}/scan`);
391
+ if (scanResult.ok && scanResult.data.findings) findingsText = formatFindings(scanResult.data.findings);
392
+ const score = details.auditScore !== null ? details.auditScore.toFixed(1) : "N/A";
393
+ const lines = [
394
+ `## Audit: ${name}@${targetVersion}`,
395
+ "",
396
+ `**Verdict:** ${verdict}`,
397
+ `**Score:** ${score}/10`,
398
+ `**Scanned:** ${details.publishedAt}`,
399
+ `**Version:** ${targetVersion}`
400
+ ];
401
+ if (details.permissions) {
402
+ lines.push("", "**Permissions:**");
403
+ const p = details.permissions;
404
+ if (p.network?.outbound?.length) lines.push(` - Network: ${p.network.outbound.join(", ")}`);
405
+ if (p.filesystem?.read?.length || p.filesystem?.write?.length) {
406
+ const parts = [];
407
+ if (p.filesystem.read?.length) parts.push(`read: ${p.filesystem.read.join(", ")}`);
408
+ if (p.filesystem.write?.length) parts.push(`write: ${p.filesystem.write.join(", ")}`);
409
+ lines.push(` - Filesystem: ${parts.join("; ")}`);
410
+ }
411
+ lines.push(` - Subprocess: ${p.subprocess ? "yes" : "no"}`);
412
+ }
413
+ if (findingsText) lines.push(findingsText);
414
+ return { content: [{
415
+ type: "text",
416
+ text: lines.join("\n")
417
+ }] };
418
+ });
419
+ }
420
+ //#endregion
421
+ //#region src/tools/doctor.ts
422
+ const MIN_NODE_MAJOR = 24;
423
+ function checkConfigFile() {
424
+ const configPath = getConfigPath();
425
+ if (!fs.existsSync(configPath)) return {
426
+ name: "Configuration File",
427
+ status: "FAIL",
428
+ message: `Configuration file not found at ${configPath}. Run the login tool to create it.`
429
+ };
430
+ try {
431
+ const raw = fs.readFileSync(configPath, "utf-8");
432
+ JSON.parse(raw);
433
+ return {
434
+ name: "Configuration File",
435
+ status: "PASS",
436
+ message: `Configuration file exists and is valid JSON (${configPath}).`
437
+ };
438
+ } catch (err) {
439
+ return {
440
+ name: "Configuration File",
441
+ status: "FAIL",
442
+ message: `Configuration file at ${configPath} is malformed: ${err instanceof Error ? err.message : String(err)}`
443
+ };
444
+ }
445
+ }
446
+ async function checkAuthentication() {
447
+ const client = new TankApiClient();
448
+ if (!client.isAuthenticated) return {
449
+ name: "Authentication",
450
+ status: "FAIL",
451
+ message: "Not authenticated. Use the login tool to authenticate with Tank."
452
+ };
453
+ const authCheck = await client.verifyAuth();
454
+ if (authCheck.valid) return {
455
+ name: "Authentication",
456
+ status: "PASS",
457
+ message: `Authenticated as ${authCheck.user.name ?? "unknown"}.`
458
+ };
459
+ if (authCheck.reason === "network-error") return {
460
+ name: "Authentication",
461
+ status: "FAIL",
462
+ message: `Could not verify credentials (network error). ${authCheck.error ?? ""}`.trim()
463
+ };
464
+ return {
465
+ name: "Authentication",
466
+ status: "FAIL",
467
+ message: "Credentials are expired or invalid. Use the login tool to re-authenticate."
468
+ };
469
+ }
470
+ async function checkRegistryConnectivity() {
471
+ const registryUrl = getConfig().registry;
472
+ try {
473
+ const healthUrl = `${registryUrl}/api/health`;
474
+ const response = await fetch(healthUrl, { signal: AbortSignal.timeout(1e4) });
475
+ if (response.ok) return {
476
+ name: "Registry Connectivity",
477
+ status: "PASS",
478
+ message: `Registry at ${registryUrl} is reachable.`
479
+ };
480
+ return {
481
+ name: "Registry Connectivity",
482
+ status: "FAIL",
483
+ message: `Registry at ${registryUrl} returned HTTP ${response.status}.`
484
+ };
485
+ } catch {
486
+ return {
487
+ name: "Registry Connectivity",
488
+ status: "FAIL",
489
+ message: `Cannot reach registry at ${registryUrl}. Check your network connection.`
490
+ };
491
+ }
492
+ }
493
+ function checkNodeVersion() {
494
+ const raw = process.version;
495
+ const match = raw.match(/^v(\d+)/);
496
+ if ((match ? Number.parseInt(match[1], 10) : 0) >= MIN_NODE_MAJOR) return {
497
+ name: "Node.js Version",
498
+ status: "PASS",
499
+ message: `Node.js ${raw} meets the minimum requirement (v${MIN_NODE_MAJOR}.0.0).`
500
+ };
501
+ return {
502
+ name: "Node.js Version",
503
+ status: "FAIL",
504
+ message: `Node.js ${raw} is below the minimum required version v${MIN_NODE_MAJOR}.0.0. Please upgrade Node.js.`
505
+ };
506
+ }
507
+ function formatChecks(checks) {
508
+ const lines = [];
509
+ lines.push("Tank Doctor Report");
510
+ lines.push("==================");
511
+ lines.push("");
512
+ for (const check of checks) {
513
+ const icon = check.status === "PASS" ? "PASS" : "FAIL";
514
+ lines.push(`[${icon}] ${check.name}`);
515
+ lines.push(` ${check.message}`);
516
+ }
517
+ lines.push("");
518
+ const failedChecks = checks.filter((c) => c.status === "FAIL");
519
+ if (failedChecks.length === 0) lines.push("All checks passed. Your Tank environment is ready to use.");
520
+ else {
521
+ lines.push("Suggestions:");
522
+ for (const check of failedChecks) if (check.name === "Authentication") lines.push(" - Use the login tool to authenticate with Tank.");
523
+ else if (check.name === "Registry Connectivity") lines.push(" - Check your network connection and verify the registry URL.");
524
+ else if (check.name === "Node.js Version") lines.push(` - Upgrade Node.js to v${MIN_NODE_MAJOR}.0.0 or later.`);
525
+ else if (check.name === "Configuration File") lines.push(" - Use the login tool to create a valid configuration file.");
526
+ lines.push("");
527
+ lines.push("The environment is not healthy. Please address the issues above.");
528
+ }
529
+ return lines.join("\n");
530
+ }
531
+ function registerDoctorTool(server) {
532
+ server.tool("doctor", "Diagnose Tank setup and environment.", {}, async () => {
533
+ const checks = [];
534
+ checks.push(checkConfigFile());
535
+ checks.push(await checkAuthentication());
536
+ checks.push(await checkRegistryConnectivity());
537
+ checks.push(checkNodeVersion());
538
+ return { content: [{
539
+ type: "text",
540
+ text: formatChecks(checks)
541
+ }] };
542
+ });
543
+ }
544
+ //#endregion
545
+ //#region src/tools/init-skill.ts
546
+ const SCOPED_NAME_PATTERN$5 = /^@[a-z0-9-]+\/[a-z0-9][a-z0-9-]*$/;
547
+ const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
548
+ function registerInitSkillTool(server) {
549
+ server.tool("init-skill", `Create a new ${MANIFEST_FILENAME} and SKILL.md template for a Tank skill.`, {
550
+ name: z.string().regex(SCOPED_NAME_PATTERN$5, "Name must be in @org/name format"),
551
+ version: z.string().regex(SEMVER_PATTERN, "Version must be valid semver").optional().default("0.1.0"),
552
+ description: z.string().optional().default(""),
553
+ directory: z.string().optional().default(".")
554
+ }, async ({ name, version = "0.1.0", description = "", directory = "." }) => {
555
+ const targetDir = path.resolve(directory);
556
+ if (!fs.existsSync(targetDir)) return { content: [{
557
+ type: "text",
558
+ text: `Directory does not exist: ${targetDir}`
559
+ }] };
560
+ if (!fs.statSync(targetDir).isDirectory()) return { content: [{
561
+ type: "text",
562
+ text: `Path is not a directory: ${targetDir}`
563
+ }] };
564
+ const newManifestPath = path.join(targetDir, MANIFEST_FILENAME);
565
+ const legacyManifestPath = path.join(targetDir, LEGACY_MANIFEST_FILENAME);
566
+ const skillsJsonPath = newManifestPath;
567
+ if (fs.existsSync(newManifestPath) || fs.existsSync(legacyManifestPath)) return { content: [{
568
+ type: "text",
569
+ text: `${fs.existsSync(newManifestPath) ? MANIFEST_FILENAME : LEGACY_MANIFEST_FILENAME} already exists at ${targetDir}. Aborting to avoid overwrite.`
570
+ }] };
571
+ const manifest = {
572
+ name,
573
+ version,
574
+ description,
575
+ skills: {},
576
+ permissions: {
577
+ network: { outbound: [] },
578
+ filesystem: {
579
+ read: [],
580
+ write: []
581
+ },
582
+ subprocess: false
583
+ }
584
+ };
585
+ const parseResult = skillsJsonSchema.safeParse(manifest);
586
+ if (!parseResult.success) return { content: [{
587
+ type: "text",
588
+ text: `Failed to create ${MANIFEST_FILENAME}: ${parseResult.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`).join("; ")}`
589
+ }] };
590
+ fs.writeFileSync(skillsJsonPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
591
+ const skillMdPath = path.join(targetDir, "SKILL.md");
592
+ let createdSkillMd = false;
593
+ if (!fs.existsSync(skillMdPath)) {
594
+ const skillMd = `# ${name}\n\n${description || "Description here."}\n`;
595
+ fs.writeFileSync(skillMdPath, skillMd, "utf-8");
596
+ createdSkillMd = true;
597
+ }
598
+ return { content: [{
599
+ type: "text",
600
+ text: `Initialized skill in ${targetDir}\nCreated: ${skillsJsonPath}\n${createdSkillMd ? `Created: ${skillMdPath}` : `Skipped existing: ${skillMdPath}`}`
601
+ }] };
602
+ });
603
+ }
604
+ //#endregion
605
+ //#region src/tools/install-skill.ts
606
+ const SCOPED_NAME_PATTERN$4 = /^@[a-z0-9-]+\/[a-z0-9][a-z0-9-]*$/;
607
+ function textResult(text, isError) {
608
+ return {
609
+ content: [{
610
+ type: "text",
611
+ text
612
+ }],
613
+ ...isError ? { isError: true } : {}
614
+ };
615
+ }
616
+ function getSkillDir$4(projectDir, skillName) {
617
+ if (skillName.startsWith("@")) {
618
+ const [scope, name] = skillName.split("/");
619
+ return path.join(projectDir, ".tank", "skills", scope, name);
620
+ }
621
+ return path.join(projectDir, ".tank", "skills", skillName);
622
+ }
623
+ function registerInstallSkillTool(server) {
624
+ server.tool("install-skill", `Install a skill from the Tank registry. Resolves version, downloads tarball, verifies SHA-512 integrity, extracts files, and updates ${MANIFEST_FILENAME} + ${LOCKFILE_FILENAME}.`, {
625
+ name: z.string().describe("Skill name in @org/name format"),
626
+ version: z.string().optional().describe("Specific version or semver range (default: latest)"),
627
+ directory: z.string().optional().describe("Project directory (defaults to current working directory)")
628
+ }, async ({ name, version: versionRange, directory }) => {
629
+ if (!SCOPED_NAME_PATTERN$4.test(name)) return textResult(`Validation error: Skill name "${name}" must use the @org/name format (e.g. @acme/my-skill).`, true);
630
+ const client = new TankApiClient();
631
+ if (!client.isAuthenticated) return textResult("Not authenticated. Use the \"login\" tool first to authenticate with Tank.", true);
632
+ const dir = directory ? path.resolve(directory) : process.cwd();
633
+ const range = versionRange ?? "*";
634
+ let skillsJsonPath = path.join(dir, MANIFEST_FILENAME);
635
+ if (!fs.existsSync(skillsJsonPath) && fs.existsSync(path.join(dir, "skills.json"))) skillsJsonPath = path.join(dir, LEGACY_MANIFEST_FILENAME);
636
+ let skillsJson = { skills: {} };
637
+ if (fs.existsSync(skillsJsonPath)) try {
638
+ const raw = fs.readFileSync(skillsJsonPath, "utf-8");
639
+ skillsJson = JSON.parse(raw);
640
+ } catch {
641
+ return textResult(`Failed to read or parse ${path.basename(skillsJsonPath)}.`, true);
642
+ }
643
+ else {
644
+ skillsJsonPath = path.join(dir, MANIFEST_FILENAME);
645
+ skillsJson = { skills: {} };
646
+ fs.mkdirSync(dir, { recursive: true });
647
+ fs.writeFileSync(skillsJsonPath, `${JSON.stringify(skillsJson, null, 2)}\n`);
648
+ }
649
+ let lockPath = path.join(dir, LOCKFILE_FILENAME);
650
+ if (!fs.existsSync(lockPath) && fs.existsSync(path.join(dir, "skills.lock"))) lockPath = path.join(dir, LEGACY_LOCKFILE_FILENAME);
651
+ let lock = {
652
+ lockfileVersion: 2,
653
+ skills: {}
654
+ };
655
+ if (fs.existsSync(lockPath)) try {
656
+ const raw = fs.readFileSync(lockPath, "utf-8");
657
+ lock = JSON.parse(raw);
658
+ } catch {
659
+ lock = {
660
+ lockfileVersion: 2,
661
+ skills: {}
662
+ };
663
+ }
664
+ const encodedName = encodeURIComponent(name);
665
+ const versionsResult = await client.fetch(`/api/v1/skills/${encodedName}/versions`);
666
+ if (!versionsResult.ok) {
667
+ if (versionsResult.status === 401 || versionsResult.status === 403) return textResult("Authentication failed. Use the \"login\" tool to authenticate with Tank.", true);
668
+ if (versionsResult.status === 404) return textResult(`Skill not found: "${name}" does not exist in the Tank registry.`, true);
669
+ if (versionsResult.status === 0) return textResult(`Cannot reach the Tank registry. Check your network connection and try again.\nError: ${versionsResult.error}`, true);
670
+ return textResult(`Failed to fetch versions for ${name}: ${versionsResult.error}`, true);
671
+ }
672
+ const availableVersions = versionsResult.data.versions.map((v) => v.version);
673
+ const resolved = resolve(range, availableVersions);
674
+ if (!resolved) return textResult(`No version of ${name} satisfies range "${range}". Available versions: ${availableVersions.join(", ")}`, true);
675
+ const lockKey = `${name}@${resolved}`;
676
+ if (lock.skills[lockKey]) return textResult(`${name}@${resolved} is already installed. No changes needed.`);
677
+ const metaResult = await client.fetch(`/api/v1/skills/${encodedName}/${resolved}`);
678
+ if (!metaResult.ok) {
679
+ if (metaResult.status === 404) return textResult(`Version ${resolved} of ${name} not found in the registry.`, true);
680
+ return textResult(`Failed to fetch metadata for ${name}@${resolved}: ${metaResult.error}`, true);
681
+ }
682
+ const metadata = metaResult.data;
683
+ let tarballBuffer;
684
+ try {
685
+ const downloadRes = await fetch(metadata.downloadUrl);
686
+ if (!downloadRes.ok) return textResult(`Failed to download tarball for ${name}@${resolved}: ${downloadRes.status} ${downloadRes.statusText}`, true);
687
+ tarballBuffer = Buffer.from(await downloadRes.arrayBuffer());
688
+ } catch (err) {
689
+ return textResult(`Network error downloading tarball for ${name}@${resolved}: ${err instanceof Error ? err.message : String(err)}`, true);
690
+ }
691
+ const computedIntegrity = `sha512-${crypto$1.createHash("sha512").update(tarballBuffer).digest("base64")}`;
692
+ if (computedIntegrity !== metadata.integrity) return textResult(`Integrity verification failed for ${name}@${resolved}.\nExpected: ${metadata.integrity}\nGot: ${computedIntegrity}\n\nThe tarball may have been tampered with. No files were extracted.`, true);
693
+ const extractDir = getSkillDir$4(dir, name);
694
+ fs.mkdirSync(extractDir, { recursive: true });
695
+ try {
696
+ await extractSafely(tarballBuffer, extractDir);
697
+ } catch (err) {
698
+ fs.rmSync(extractDir, {
699
+ recursive: true,
700
+ force: true
701
+ });
702
+ return textResult(`Failed to extract tarball for ${name}@${resolved}: ${err instanceof Error ? err.message : String(err)}`, true);
703
+ }
704
+ const skills = skillsJson.skills ?? {};
705
+ skills[name] = range === "*" ? `^${resolved}` : range;
706
+ skillsJson.skills = skills;
707
+ fs.writeFileSync(skillsJsonPath, `${JSON.stringify(skillsJson, null, 2)}\n`);
708
+ lock.skills[lockKey] = {
709
+ resolved: metadata.downloadUrl,
710
+ integrity: computedIntegrity,
711
+ permissions: metadata.permissions ?? {},
712
+ audit_score: metadata.auditScore ?? null
713
+ };
714
+ const sortedSkills = {};
715
+ for (const key of Object.keys(lock.skills).sort()) sortedSkills[key] = lock.skills[key];
716
+ lock.skills = sortedSkills;
717
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
718
+ fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`);
719
+ const score = metadata.auditScore !== null && metadata.auditScore !== void 0 ? `${metadata.auditScore.toFixed(1)}/10` : "pending";
720
+ return textResult([
721
+ `## Installed ${name}@${resolved}`,
722
+ "",
723
+ `**Integrity:** SHA-512 verified`,
724
+ `**Audit Score:** ${score}`,
725
+ `**Extracted to:** ${extractDir}`,
726
+ "",
727
+ "### Updated files",
728
+ `- ${path.basename(skillsJsonPath)}: added "${name}": "${skills[name]}"`,
729
+ `- ${path.basename(lockPath)}: added ${lockKey}`
730
+ ].join("\n"));
731
+ });
732
+ }
733
+ /**
734
+ * Extract a tarball safely with security checks.
735
+ * Rejects: absolute paths, path traversal (..), symlinks/hardlinks.
736
+ */
737
+ async function extractSafely(tarball, destDir) {
738
+ const tmpTarball = path.join(destDir, ".tmp-tarball.tgz");
739
+ fs.writeFileSync(tmpTarball, tarball);
740
+ try {
741
+ await extract({
742
+ file: tmpTarball,
743
+ cwd: destDir,
744
+ filter: (entryPath) => {
745
+ if (path.isAbsolute(entryPath)) throw new Error(`Absolute path in tarball: ${entryPath}`);
746
+ if (entryPath.split("/").includes("..") || entryPath.split(path.sep).includes("..")) throw new Error(`Path traversal in tarball: ${entryPath}`);
747
+ return true;
748
+ },
749
+ onReadEntry: (entry) => {
750
+ if (entry.type === "SymbolicLink" || entry.type === "Link") throw new Error(`Symlink/hardlink in tarball: ${entry.path}`);
751
+ }
752
+ });
753
+ } finally {
754
+ if (fs.existsSync(tmpTarball)) fs.unlinkSync(tmpTarball);
755
+ }
756
+ }
757
+ //#endregion
758
+ //#region src/tools/link-skill.ts
759
+ const SCOPED_NAME_PATTERN$3 = /^@[a-z0-9-]+\/[a-z0-9][a-z0-9-]*$/;
760
+ function registerLinkSkillTool(server) {
761
+ server.tool("link-skill", "Link an installed skill into an agent workspace. Creates a symlink from the workspace .skills directory to the installed skill.", {
762
+ name: z.string().describe("Skill name in @org/name format"),
763
+ workspace: z.string().describe("Agent workspace directory path"),
764
+ directory: z.string().optional().describe("Project directory where skills are installed (defaults to current working directory)")
765
+ }, async ({ name, workspace, directory }) => {
766
+ if (!SCOPED_NAME_PATTERN$3.test(name)) return {
767
+ content: [{
768
+ type: "text",
769
+ text: `Validation error: Skill name "${name}" must use the @org/name format (e.g. @acme/my-skill).`
770
+ }],
771
+ isError: true
772
+ };
773
+ const projectDir = directory ? path.resolve(directory) : process.cwd();
774
+ const workspaceDir = path.resolve(workspace);
775
+ if (!fs.existsSync(workspaceDir)) return {
776
+ content: [{
777
+ type: "text",
778
+ text: `Error: Workspace directory does not exist: ${workspaceDir}`
779
+ }],
780
+ isError: true
781
+ };
782
+ const skillDir = getSkillDir$3(projectDir, name);
783
+ if (!fs.existsSync(skillDir)) return {
784
+ content: [{
785
+ type: "text",
786
+ text: `Skill "${name}" is not installed. Install it first with "install-skill" before linking.`
787
+ }],
788
+ isError: true
789
+ };
790
+ const [scope, skillName] = name.split("/");
791
+ const skillsLinkDir = path.join(workspaceDir, ".skills", scope);
792
+ const symlinkPath = path.join(skillsLinkDir, skillName);
793
+ try {
794
+ if (fs.lstatSync(symlinkPath).isSymbolicLink()) {
795
+ const currentTarget = fs.readlinkSync(symlinkPath);
796
+ const resolvedTarget = path.isAbsolute(currentTarget) ? currentTarget : path.resolve(path.dirname(symlinkPath), currentTarget);
797
+ if (path.resolve(resolvedTarget) === path.resolve(skillDir)) return { content: [{
798
+ type: "text",
799
+ text: `Skill "${name}" is already linked in ${workspaceDir}.`
800
+ }] };
801
+ fs.unlinkSync(symlinkPath);
802
+ }
803
+ } catch {}
804
+ fs.mkdirSync(skillsLinkDir, { recursive: true });
805
+ fs.symlinkSync(skillDir, symlinkPath, "dir");
806
+ return { content: [{
807
+ type: "text",
808
+ text: `Successfully linked "${name}" into ${workspaceDir}.\nSymlink: ${symlinkPath} → ${skillDir}`
809
+ }] };
810
+ });
811
+ }
812
+ function getSkillDir$3(projectDir, skillName) {
813
+ if (skillName.startsWith("@")) {
814
+ const [scope, name] = skillName.split("/");
815
+ return path.join(projectDir, ".tank", "skills", scope, name);
816
+ }
817
+ return path.join(projectDir, ".tank", "skills", skillName);
818
+ }
819
+ //#endregion
820
+ //#region src/tools/login.ts
821
+ const DEFAULT_POLL_INTERVAL_MS = 2e3;
822
+ const DEFAULT_TIMEOUT_MS = 300 * 1e3;
823
+ function registerLoginTool(server) {
824
+ server.tool("login", "Authenticate with Tank using GitHub OAuth device flow. Opens browser for authorization.", { timeout: z.number().optional().describe("Timeout in milliseconds (default: 300000 = 5 minutes)") }, async ({ timeout = DEFAULT_TIMEOUT_MS }) => {
825
+ const client = new TankApiClient();
826
+ const config = getConfig();
827
+ if (config.token) {
828
+ const authCheck = await client.verifyAuth();
829
+ if (authCheck.valid) return { content: [{
830
+ type: "text",
831
+ text: `Already logged in as ${authCheck.user?.name ?? authCheck.user?.email ?? "unknown user"}.\n\nTo log out, delete ~/.tank/config.json or use the CLI: tank logout`
832
+ }] };
833
+ }
834
+ const state = crypto.randomUUID();
835
+ const startRes = await fetch(`${config.registry}/api/v1/cli-auth/start`, {
836
+ method: "POST",
837
+ headers: { "Content-Type": "application/json" },
838
+ body: JSON.stringify({ state })
839
+ });
840
+ if (!startRes.ok) return { content: [{
841
+ type: "text",
842
+ text: `Failed to start login flow: ${(await startRes.json().catch(() => ({}))).error ?? startRes.statusText}`
843
+ }] };
844
+ const { sessionCode } = await startRes.json();
845
+ const deadline = Date.now() + timeout;
846
+ let lastStatus = "";
847
+ while (Date.now() < deadline) {
848
+ try {
849
+ const exchangeRes = await fetch(`${config.registry}/api/v1/cli-auth/exchange`, {
850
+ method: "POST",
851
+ headers: { "Content-Type": "application/json" },
852
+ body: JSON.stringify({
853
+ sessionCode,
854
+ state
855
+ })
856
+ });
857
+ if (exchangeRes.ok) {
858
+ const { token, user } = await exchangeRes.json();
859
+ setConfig({
860
+ token,
861
+ user
862
+ });
863
+ return { content: [{
864
+ type: "text",
865
+ text: `Successfully logged in as ${user.name ?? user.email ?? "unknown user"}!\n\nYou can now use all Tank MCP tools: scan-skill, publish-skill, etc.`
866
+ }] };
867
+ }
868
+ if (exchangeRes.status !== 400) return { content: [{
869
+ type: "text",
870
+ text: `Login failed: ${(await exchangeRes.json().catch(() => ({}))).error ?? exchangeRes.statusText}`
871
+ }] };
872
+ const newStatus = "Waiting for authorization...";
873
+ if (newStatus !== lastStatus) lastStatus = newStatus;
874
+ } catch {}
875
+ await new Promise((resolve) => setTimeout(resolve, DEFAULT_POLL_INTERVAL_MS));
876
+ }
877
+ return { content: [{
878
+ type: "text",
879
+ text: `Login timed out. The authorization link may have expired.\n\nTry again: tank login`
880
+ }] };
881
+ });
882
+ }
883
+ //#endregion
884
+ //#region src/tools/logout.ts
885
+ function registerLogoutTool(server) {
886
+ server.tool("logout", "Log out of Tank by clearing local credentials.", {}, async () => {
887
+ if (!getConfig().token) return { content: [{
888
+ type: "text",
889
+ text: "Not logged in. No credentials to clear."
890
+ }] };
891
+ setConfig({
892
+ token: void 0,
893
+ user: void 0
894
+ });
895
+ delete process.env.TANK_TOKEN;
896
+ return { content: [{
897
+ type: "text",
898
+ text: "Successfully logged out."
899
+ }] };
900
+ });
901
+ }
902
+ //#endregion
903
+ //#region src/lib/packer.ts
904
+ const MAX_PACKAGE_SIZE = 50 * 1024 * 1024;
905
+ const MAX_FILE_COUNT = 1e3;
906
+ const DEFAULT_IGNORES = [
907
+ "node_modules",
908
+ ".git",
909
+ ".env*",
910
+ "*.log",
911
+ ".tank",
912
+ ".DS_Store"
913
+ ];
914
+ const ALWAYS_IGNORED = ["node_modules", ".git"];
915
+ const IGNORE_FILES = [".tankignore", ".gitignore"];
916
+ /**
917
+ * Pack a skill directory into a .tgz tarball with integrity hashing.
918
+ */
919
+ async function pack(directory) {
920
+ const absDir = path.resolve(directory);
921
+ if (!fs.existsSync(absDir)) throw new Error(`Directory does not exist: ${absDir}`);
922
+ if (!fs.statSync(absDir).isDirectory()) throw new Error(`Not a directory: ${absDir}`);
923
+ let manifestPath = path.join(absDir, MANIFEST_FILENAME);
924
+ let manifestFilename = MANIFEST_FILENAME;
925
+ if (!fs.existsSync(manifestPath)) {
926
+ manifestPath = path.join(absDir, LEGACY_MANIFEST_FILENAME);
927
+ manifestFilename = LEGACY_MANIFEST_FILENAME;
928
+ }
929
+ if (!fs.existsSync(manifestPath)) throw new Error(`Missing required file: ${MANIFEST_FILENAME}`);
930
+ let skillsJsonContent;
931
+ try {
932
+ skillsJsonContent = fs.readFileSync(manifestPath, "utf-8");
933
+ } catch {
934
+ throw new Error(`Failed to read ${manifestFilename}`);
935
+ }
936
+ let parsed;
937
+ try {
938
+ parsed = JSON.parse(skillsJsonContent);
939
+ } catch {
940
+ throw new Error(`Invalid ${manifestFilename}: not valid JSON`);
941
+ }
942
+ const validation = skillsJsonSchema.safeParse(parsed);
943
+ if (!validation.success) {
944
+ const issues = validation.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
945
+ throw new Error(`Invalid ${manifestFilename}:\n${issues}`);
946
+ }
947
+ const skillMdPath = path.join(absDir, "SKILL.md");
948
+ if (!fs.existsSync(skillMdPath)) throw new Error("Missing required file: SKILL.md");
949
+ let readmeContent;
950
+ try {
951
+ readmeContent = fs.readFileSync(skillMdPath, "utf-8");
952
+ } catch {
953
+ throw new Error("Failed to read SKILL.md");
954
+ }
955
+ const files = collectFiles(absDir, absDir, buildIgnoreFilter(absDir));
956
+ if (files.length > MAX_FILE_COUNT) throw new Error(`Too many files: ${files.length} exceeds maximum of ${MAX_FILE_COUNT}`);
957
+ let totalSize = 0;
958
+ for (const file of files) {
959
+ const filePath = path.join(absDir, file);
960
+ const fileStat = fs.statSync(filePath);
961
+ totalSize += fileStat.size;
962
+ }
963
+ const tarball = await createTarball(absDir, files);
964
+ if (tarball.length > MAX_PACKAGE_SIZE) throw new Error(`Tarball too large: ${tarball.length} bytes exceeds maximum of ${MAX_PACKAGE_SIZE} bytes (50MB)`);
965
+ return {
966
+ tarball,
967
+ integrity: `sha512-${crypto$1.createHash("sha512").update(tarball).digest("base64")}`,
968
+ fileCount: files.length,
969
+ totalSize,
970
+ readme: readmeContent,
971
+ files,
972
+ manifest: validation.data
973
+ };
974
+ }
975
+ /**
976
+ * Pack a directory into a .tgz tarball for security scanning.
977
+ *
978
+ * Unlike pack(), this function does NOT require skills.json or SKILL.md.
979
+ * It applies the same security checks (no symlinks, no path traversal, etc.)
980
+ * and returns the same PackResult interface with a synthesised manifest.
981
+ *
982
+ * Validates:
983
+ * - Directory exists
984
+ * - No symlinks or hardlinks
985
+ * - No path traversal (.. components)
986
+ * - No absolute paths
987
+ * - File count <= 1000
988
+ * - Tarball size <= 50MB
989
+ *
990
+ * Does NOT validate:
991
+ * - skills.json existence or validity
992
+ * - SKILL.md existence (but reads it if present)
993
+ */
994
+ async function packForScan(directory) {
995
+ const absDir = path.resolve(directory);
996
+ if (!fs.existsSync(absDir)) throw new Error(`Directory does not exist: ${absDir}`);
997
+ if (!fs.statSync(absDir).isDirectory()) throw new Error(`Not a directory: ${absDir}`);
998
+ let readmeContent = "";
999
+ const skillMdPath = path.join(absDir, "SKILL.md");
1000
+ if (fs.existsSync(skillMdPath)) try {
1001
+ readmeContent = fs.readFileSync(skillMdPath, "utf-8");
1002
+ } catch {
1003
+ readmeContent = "";
1004
+ }
1005
+ const files = collectFiles(absDir, absDir, buildIgnoreFilter(absDir));
1006
+ if (files.length > MAX_FILE_COUNT) throw new Error(`Too many files: ${files.length} exceeds maximum of ${MAX_FILE_COUNT}`);
1007
+ if (files.length === 0) throw new Error("No files to scan: directory is empty or all files are ignored");
1008
+ let totalSize = 0;
1009
+ for (const file of files) {
1010
+ const filePath = path.join(absDir, file);
1011
+ const fileStat = fs.statSync(filePath);
1012
+ totalSize += fileStat.size;
1013
+ }
1014
+ const tarball = await createTarball(absDir, files);
1015
+ if (tarball.length > MAX_PACKAGE_SIZE) throw new Error(`Tarball too large: ${tarball.length} bytes exceeds maximum of ${MAX_PACKAGE_SIZE} bytes (50MB)`);
1016
+ const integrity = `sha512-${crypto$1.createHash("sha512").update(tarball).digest("base64")}`;
1017
+ const manifest = {
1018
+ name: path.basename(absDir),
1019
+ version: "0.0.0",
1020
+ description: "Local scan"
1021
+ };
1022
+ return {
1023
+ tarball,
1024
+ integrity,
1025
+ fileCount: files.length,
1026
+ totalSize,
1027
+ readme: readmeContent,
1028
+ files,
1029
+ manifest
1030
+ };
1031
+ }
1032
+ /**
1033
+ * Build an ignore filter from .tankignore, .gitignore, or defaults.
1034
+ */
1035
+ function buildIgnoreFilter(dir) {
1036
+ const ig = ignore();
1037
+ ig.add(ALWAYS_IGNORED);
1038
+ const tankIgnorePath = path.join(dir, ".tankignore");
1039
+ const gitIgnorePath = path.join(dir, ".gitignore");
1040
+ if (fs.existsSync(tankIgnorePath)) {
1041
+ const content = fs.readFileSync(tankIgnorePath, "utf-8");
1042
+ ig.add(content);
1043
+ ig.add(IGNORE_FILES);
1044
+ } else if (fs.existsSync(gitIgnorePath)) {
1045
+ const content = fs.readFileSync(gitIgnorePath, "utf-8");
1046
+ ig.add(content);
1047
+ ig.add(IGNORE_FILES);
1048
+ } else ig.add(DEFAULT_IGNORES);
1049
+ return ig;
1050
+ }
1051
+ /**
1052
+ * Recursively collect files from a directory.
1053
+ */
1054
+ function collectFiles(baseDir, currentDir, ig) {
1055
+ const files = [];
1056
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
1057
+ for (const entry of entries) {
1058
+ const fullPath = path.join(currentDir, entry.name);
1059
+ const relativePath = path.relative(baseDir, fullPath);
1060
+ if (relativePath.split(path.sep).includes("..")) throw new Error(`Path traversal detected: "${relativePath}" contains ".." component`);
1061
+ if (path.isAbsolute(relativePath)) throw new Error(`Absolute path detected: "${relativePath}"`);
1062
+ const lstatResult = fs.lstatSync(fullPath);
1063
+ if (lstatResult.isSymbolicLink()) throw new Error(`Symlink detected: "${relativePath}" — symlinks are not allowed`);
1064
+ const pathForIgnore = lstatResult.isDirectory() ? `${relativePath}/` : relativePath;
1065
+ if (ig.ignores(pathForIgnore)) continue;
1066
+ if (lstatResult.isDirectory()) {
1067
+ const subFiles = collectFiles(baseDir, fullPath, ig);
1068
+ files.push(...subFiles);
1069
+ } else if (lstatResult.isFile()) files.push(relativePath);
1070
+ }
1071
+ return files;
1072
+ }
1073
+ /**
1074
+ * Create a gzipped tarball from the given files.
1075
+ */
1076
+ async function createTarball(cwd, files) {
1077
+ return new Promise((resolve, reject) => {
1078
+ const chunks = [];
1079
+ const stream = create({
1080
+ gzip: true,
1081
+ cwd,
1082
+ portable: true
1083
+ }, files);
1084
+ stream.on("data", (chunk) => {
1085
+ chunks.push(chunk);
1086
+ });
1087
+ stream.on("end", () => {
1088
+ resolve(Buffer.concat(chunks));
1089
+ });
1090
+ stream.on("error", (err) => {
1091
+ reject(err);
1092
+ });
1093
+ });
1094
+ }
1095
+ //#endregion
1096
+ //#region src/tools/publish-skill.ts
1097
+ function registerPublishSkillTool(server) {
1098
+ server.tool("publish-skill", "Publish a skill to the Tank registry. Requires authentication.", {
1099
+ directory: z.string().optional().describe("Directory to publish (default: current directory)"),
1100
+ visibility: z.enum(["public", "private"]).optional().default("public").describe("Package visibility"),
1101
+ dryRun: z.boolean().optional().default(false).describe("Validate without publishing")
1102
+ }, async ({ directory = ".", visibility = "public", dryRun = false }) => {
1103
+ const absDir = path.resolve(directory);
1104
+ const client = new TankApiClient();
1105
+ if (!dryRun && !client.isAuthenticated) return { content: [{
1106
+ type: "text",
1107
+ text: "You need to log in first. Use the login tool to authenticate with Tank.\n\nExample: \"Log in to Tank\""
1108
+ }] };
1109
+ if (!dryRun) {
1110
+ if (!(await client.verifyAuth()).valid) return { content: [{
1111
+ type: "text",
1112
+ text: "Your session has expired. Use the login tool to authenticate again."
1113
+ }] };
1114
+ }
1115
+ let packResult;
1116
+ try {
1117
+ packResult = await pack(absDir);
1118
+ } catch (err) {
1119
+ return { content: [{
1120
+ type: "text",
1121
+ text: `Failed to pack skill: ${err instanceof Error ? err.message : String(err)}`
1122
+ }] };
1123
+ }
1124
+ const manifest = packResult.manifest;
1125
+ const skillName = manifest.name ?? "unknown";
1126
+ const skillVersion = manifest.version ?? "0.0.0";
1127
+ if (dryRun) return { content: [{
1128
+ type: "text",
1129
+ text: [
1130
+ `## Dry Run for ${skillName}@${skillVersion}`,
1131
+ "",
1132
+ "**Validation:** ✅ PASSED",
1133
+ "",
1134
+ "### Package Summary",
1135
+ `- **Name:** ${skillName}`,
1136
+ `- **Version:** ${skillVersion}`,
1137
+ `- **Visibility:** ${visibility}`,
1138
+ `- **Files:** ${packResult.fileCount}`,
1139
+ `- **Size:** ${(packResult.totalSize / 1024).toFixed(1)}KB compressed`,
1140
+ `- **Integrity:** ${packResult.integrity.slice(0, 20)}...`,
1141
+ "",
1142
+ "### Manifest",
1143
+ `- **Description:** ${manifest.description ?? "No description"}`,
1144
+ `- **Permissions:** ${JSON.stringify(manifest.permissions ?? {})}`,
1145
+ "",
1146
+ "### Files",
1147
+ ...packResult.files.slice(0, 10).map((f) => ` - ${f}`),
1148
+ packResult.files.length > 10 ? ` ... and ${packResult.files.length - 10} more` : "",
1149
+ "",
1150
+ "Ready to publish. Say \"publish my skill\" when you're ready."
1151
+ ].join("\n")
1152
+ }] };
1153
+ const startResult = await client.fetch("/api/v1/skills", {
1154
+ method: "POST",
1155
+ body: JSON.stringify({
1156
+ manifest: {
1157
+ ...manifest,
1158
+ visibility
1159
+ },
1160
+ readme: packResult.readme,
1161
+ files: packResult.files
1162
+ })
1163
+ });
1164
+ if (!startResult.ok) return { content: [{
1165
+ type: "text",
1166
+ text: `Failed to start publish: ${startResult.error}`
1167
+ }] };
1168
+ const { uploadUrl, versionId } = startResult.data;
1169
+ const uploadRes = await fetch(uploadUrl, {
1170
+ method: "PUT",
1171
+ headers: { "Content-Type": "application/gzip" },
1172
+ body: new Uint8Array(packResult.tarball)
1173
+ });
1174
+ if (!uploadRes.ok) return { content: [{
1175
+ type: "text",
1176
+ text: `Failed to upload tarball: ${uploadRes.statusText}`
1177
+ }] };
1178
+ const confirmResult = await client.fetch("/api/v1/skills/confirm", {
1179
+ method: "POST",
1180
+ body: JSON.stringify({
1181
+ versionId,
1182
+ integrity: packResult.integrity,
1183
+ fileCount: packResult.fileCount,
1184
+ tarballSize: packResult.tarball.length,
1185
+ readme: packResult.readme
1186
+ })
1187
+ });
1188
+ if (!confirmResult.ok) return { content: [{
1189
+ type: "text",
1190
+ text: `Failed to confirm publish: ${confirmResult.error}`
1191
+ }] };
1192
+ const confirm = confirmResult.data;
1193
+ const score = confirm.auditScore !== null ? `${confirm.auditScore.toFixed(1)}/10` : "pending";
1194
+ return { content: [{
1195
+ type: "text",
1196
+ text: [
1197
+ `## Published ${confirm.name}@${confirm.version}`,
1198
+ "",
1199
+ `**Status:** ✅ Successfully published`,
1200
+ `**Visibility:** ${visibility}`,
1201
+ `**Audit Score:** ${score}`,
1202
+ `**Scan Verdict:** ${confirm.scanVerdict ?? "pending"}`,
1203
+ "",
1204
+ "### Package Details",
1205
+ `- **Files:** ${packResult.fileCount}`,
1206
+ `- **Size:** ${(packResult.totalSize / 1024).toFixed(1)}KB`,
1207
+ "",
1208
+ `View your skill: https://tankpkg.dev/skills/${confirm.name}`
1209
+ ].join("\n")
1210
+ }] };
1211
+ });
1212
+ }
1213
+ //#endregion
1214
+ //#region src/tools/remove-skill.ts
1215
+ const SCOPED_NAME_PATTERN$2 = /^@[a-z0-9-]+\/[a-z0-9][a-z0-9-]*$/;
1216
+ function registerRemoveSkillTool(server) {
1217
+ server.tool("remove-skill", `Remove an installed skill from the project. Removes from ${MANIFEST_FILENAME}, ${LOCKFILE_FILENAME}, and deletes skill files.`, {
1218
+ name: z.string().describe("Skill name in @org/name format"),
1219
+ directory: z.string().optional().describe("Project directory (defaults to current working directory)")
1220
+ }, async ({ name, directory }) => {
1221
+ if (!SCOPED_NAME_PATTERN$2.test(name)) return {
1222
+ content: [{
1223
+ type: "text",
1224
+ text: `Validation error: Skill name "${name}" must use the @org/name format (e.g. @acme/my-skill).`
1225
+ }],
1226
+ isError: true
1227
+ };
1228
+ const dir = directory ? path.resolve(directory) : process.cwd();
1229
+ const results = [];
1230
+ let skillFoundAnywhere = false;
1231
+ let skillsJsonPath = path.join(dir, MANIFEST_FILENAME);
1232
+ if (!fs.existsSync(skillsJsonPath)) skillsJsonPath = path.join(dir, LEGACY_MANIFEST_FILENAME);
1233
+ if (fs.existsSync(skillsJsonPath)) try {
1234
+ const raw = fs.readFileSync(skillsJsonPath, "utf-8");
1235
+ const skillsJson = JSON.parse(raw);
1236
+ const skills = skillsJson.skills ?? {};
1237
+ if (name in skills) {
1238
+ skillFoundAnywhere = true;
1239
+ delete skills[name];
1240
+ skillsJson.skills = skills;
1241
+ fs.writeFileSync(skillsJsonPath, `${JSON.stringify(skillsJson, null, 2)}\n`);
1242
+ results.push(`Removed "${name}" from ${path.basename(skillsJsonPath)}`);
1243
+ }
1244
+ } catch {
1245
+ results.push(`Warning: Failed to read or parse ${path.basename(skillsJsonPath)}`);
1246
+ }
1247
+ let lockPath = path.join(dir, LOCKFILE_FILENAME);
1248
+ if (!fs.existsSync(lockPath)) lockPath = path.join(dir, LEGACY_LOCKFILE_FILENAME);
1249
+ if (fs.existsSync(lockPath)) try {
1250
+ const raw = fs.readFileSync(lockPath, "utf-8");
1251
+ const lock = JSON.parse(raw);
1252
+ let removedFromLock = false;
1253
+ for (const key of Object.keys(lock.skills)) {
1254
+ const lastAt = key.lastIndexOf("@");
1255
+ if (lastAt <= 0) continue;
1256
+ if (key.slice(0, lastAt) === name) {
1257
+ delete lock.skills[key];
1258
+ removedFromLock = true;
1259
+ skillFoundAnywhere = true;
1260
+ }
1261
+ }
1262
+ if (removedFromLock) {
1263
+ const sortedSkills = {};
1264
+ for (const key of Object.keys(lock.skills).sort()) sortedSkills[key] = lock.skills[key];
1265
+ lock.skills = sortedSkills;
1266
+ fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`);
1267
+ results.push(`Removed "${name}" from ${path.basename(lockPath)}`);
1268
+ }
1269
+ } catch {
1270
+ results.push(`Warning: Failed to read or parse ${path.basename(lockPath)}`);
1271
+ }
1272
+ const skillDir = getSkillDir$2(dir, name);
1273
+ if (fs.existsSync(skillDir)) {
1274
+ skillFoundAnywhere = true;
1275
+ fs.rmSync(skillDir, {
1276
+ recursive: true,
1277
+ force: true
1278
+ });
1279
+ results.push(`Deleted skill files from ${skillDir}`);
1280
+ } else results.push(`Skill files were already absent from ${skillDir}`);
1281
+ const symlinkName = name.replace(/\//g, "__");
1282
+ const agentSkillDir = path.join(dir, ".tank", "agent-skills", symlinkName);
1283
+ if (fs.existsSync(agentSkillDir)) {
1284
+ fs.rmSync(agentSkillDir, {
1285
+ recursive: true,
1286
+ force: true
1287
+ });
1288
+ results.push("Removed symlink from agent workspace");
1289
+ }
1290
+ if (!skillFoundAnywhere) return {
1291
+ content: [{
1292
+ type: "text",
1293
+ text: `Skill "${name}" is not installed. It was not found in ${MANIFEST_FILENAME}, ${LOCKFILE_FILENAME}, or .tank/skills/.`
1294
+ }],
1295
+ isError: true
1296
+ };
1297
+ return { content: [{
1298
+ type: "text",
1299
+ text: `Successfully removed ${name}.\n${results.join("\n")}`
1300
+ }] };
1301
+ });
1302
+ }
1303
+ function getSkillDir$2(projectDir, skillName) {
1304
+ if (skillName.startsWith("@")) {
1305
+ const [scope, name] = skillName.split("/");
1306
+ return path.join(projectDir, ".tank", "skills", scope, name);
1307
+ }
1308
+ return path.join(projectDir, ".tank", "skills", skillName);
1309
+ }
1310
+ //#endregion
1311
+ //#region src/tools/scan-skill.ts
1312
+ function registerScanSkillTool(server) {
1313
+ server.tool("scan-skill", "Scan a skill directory for security issues. Requires authentication.", { directory: z.string().optional().describe("Directory to scan (default: current directory)") }, async ({ directory = "." }) => {
1314
+ const absDir = path.resolve(directory);
1315
+ const client = new TankApiClient();
1316
+ if (!client.isAuthenticated) return { content: [{
1317
+ type: "text",
1318
+ text: "You need to log in first. Use the login tool to authenticate with Tank.\n\nExample: \"Log in to Tank\""
1319
+ }] };
1320
+ if (!(await client.verifyAuth()).valid) return { content: [{
1321
+ type: "text",
1322
+ text: "Your session has expired. Use the login tool to authenticate again.\n\nExample: \"Log in to Tank\""
1323
+ }] };
1324
+ let packResult;
1325
+ let usedSynthesisedManifest = false;
1326
+ if (fs.existsSync(path.join(absDir, "tank.json")) || fs.existsSync(path.join(absDir, "skills.json"))) try {
1327
+ packResult = await pack(absDir);
1328
+ } catch (err) {
1329
+ return { content: [{
1330
+ type: "text",
1331
+ text: `Failed to pack skill: ${err instanceof Error ? err.message : String(err)}`
1332
+ }] };
1333
+ }
1334
+ else try {
1335
+ packResult = await packForScan(absDir);
1336
+ usedSynthesisedManifest = true;
1337
+ } catch (err) {
1338
+ return { content: [{
1339
+ type: "text",
1340
+ text: `Failed to pack directory for scan: ${err instanceof Error ? err.message : String(err)}`
1341
+ }] };
1342
+ }
1343
+ const manifest = packResult.manifest;
1344
+ const skillName = manifest.name ?? "unknown";
1345
+ const skillVersion = manifest.version ?? "0.0.0";
1346
+ const formData = new FormData();
1347
+ const blob = new Blob([new Uint8Array(packResult.tarball)], { type: "application/gzip" });
1348
+ formData.append("tarball", blob, `${skillName}-${skillVersion}.tgz`);
1349
+ formData.append("manifest", JSON.stringify(manifest));
1350
+ const config = getConfig();
1351
+ const scanRes = await fetch(`${config.registry}/api/v1/scan`, {
1352
+ method: "POST",
1353
+ headers: { Authorization: `Bearer ${client.token}` },
1354
+ body: formData
1355
+ });
1356
+ if (!scanRes.ok) return { content: [{
1357
+ type: "text",
1358
+ text: `Scan failed: ${(await scanRes.json().catch(() => ({}))).error ?? scanRes.statusText}`
1359
+ }] };
1360
+ const scanResult = await scanRes.json();
1361
+ const verdictEmoji = {
1362
+ pass: "✅",
1363
+ pass_with_notes: "⚠️",
1364
+ flagged: "🚩",
1365
+ fail: "❌"
1366
+ };
1367
+ const severityEmoji = {
1368
+ critical: "🔴",
1369
+ high: "🟠",
1370
+ medium: "🟡",
1371
+ low: "🟢"
1372
+ };
1373
+ const lines = [`## Scan Results for ${skillName}@${skillVersion}`, ""];
1374
+ if (usedSynthesisedManifest) {
1375
+ lines.push(`> **Note:** No \`${MANIFEST_FILENAME}\` found. A synthesised manifest was used for scanning.`);
1376
+ lines.push("");
1377
+ }
1378
+ const auditScore = scanResult.audit_score ?? 0;
1379
+ const durationMs = scanResult.duration_ms ?? 0;
1380
+ lines.push(`**Verdict:** ${verdictEmoji[scanResult.verdict] ?? ""} ${scanResult.verdict.toUpperCase()}`, `**Score:** ${auditScore.toFixed(1)}/10`, `**Duration:** ${(durationMs / 1e3).toFixed(1)}s`, `**Files:** ${packResult.fileCount} (${(packResult.totalSize / 1024).toFixed(1)}KB)`, "");
1381
+ if (scanResult.findings.length > 0) {
1382
+ lines.push(`### Findings (${scanResult.findings.length})`);
1383
+ lines.push("");
1384
+ const bySeverity = {
1385
+ critical: [],
1386
+ high: [],
1387
+ medium: [],
1388
+ low: []
1389
+ };
1390
+ for (const f of scanResult.findings) bySeverity[f.severity].push(f);
1391
+ for (const severity of [
1392
+ "critical",
1393
+ "high",
1394
+ "medium",
1395
+ "low"
1396
+ ]) {
1397
+ const findings = bySeverity[severity];
1398
+ if (findings.length === 0) continue;
1399
+ lines.push(`#### ${severityEmoji[severity]} ${severity.toUpperCase()} (${findings.length})`);
1400
+ for (const f of findings) {
1401
+ lines.push(`- **${f.type}**: ${f.description}`);
1402
+ if (f.location) lines.push(` - Location: ${f.location}`);
1403
+ }
1404
+ lines.push("");
1405
+ }
1406
+ } else {
1407
+ lines.push("No findings. Your skill looks secure!");
1408
+ lines.push("");
1409
+ }
1410
+ if (scanResult.stage_results?.length > 0) {
1411
+ lines.push("### Scan Stages");
1412
+ lines.push("");
1413
+ for (const stage of scanResult.stage_results) {
1414
+ const status = stage.status === "passed" ? "✓" : "✗";
1415
+ lines.push(`- ${status} ${stage.stage} (${stage.duration_ms}ms)`);
1416
+ }
1417
+ lines.push("");
1418
+ }
1419
+ if (scanResult.llm_analysis?.enabled) {
1420
+ const llm = scanResult.llm_analysis;
1421
+ lines.push("### LLM Analysis");
1422
+ lines.push("");
1423
+ lines.push(`**Mode:** ${llm.mode}`);
1424
+ if (llm.provider_used) lines.push(`**Provider:** ${llm.provider_used}`);
1425
+ if (llm.findings_reviewed !== void 0 && llm.findings_reviewed > 0) lines.push(`**Findings Reviewed:** ${llm.findings_reviewed}`);
1426
+ if (llm.findings_dismissed !== void 0 && llm.findings_dismissed > 0) lines.push(`**False Positives Dismissed:** ${llm.findings_dismissed}`);
1427
+ if (llm.findings_confirmed !== void 0 && llm.findings_confirmed > 0) lines.push(`**Threats Confirmed:** ${llm.findings_confirmed}`);
1428
+ if (llm.findings_uncertain !== void 0 && llm.findings_uncertain > 0) lines.push(`**Uncertain:** ${llm.findings_uncertain}`);
1429
+ if (llm.latency_ms) lines.push(`**Latency:** ${llm.latency_ms}ms`);
1430
+ if (llm.error) lines.push(`**Error:** ${llm.error}`);
1431
+ lines.push("");
1432
+ }
1433
+ if (scanResult.scan_id) lines.push(`View full report: https://tankpkg.dev/scans/${scanResult.scan_id}`);
1434
+ return { content: [{
1435
+ type: "text",
1436
+ text: lines.join("\n")
1437
+ }] };
1438
+ });
1439
+ }
1440
+ //#endregion
1441
+ //#region src/tools/search-skills.ts
1442
+ function registerSearchSkillsTool(server) {
1443
+ const client = new TankApiClient();
1444
+ server.tool("search-skills", "Search the Tank registry for AI agent skills", {
1445
+ query: z.string().min(1).describe("Search query (skill name or keywords)"),
1446
+ limit: z.number().min(1).max(50).optional().default(10).describe("Maximum results to return")
1447
+ }, async ({ query, limit }) => {
1448
+ const result = await client.fetch(`/api/v1/search?q=${encodeURIComponent(query)}&limit=${limit}`);
1449
+ if (!result.ok) return { content: [{
1450
+ type: "text",
1451
+ text: `Search failed: ${result.error}`
1452
+ }] };
1453
+ const { results, total } = result.data;
1454
+ if (results.length === 0) return { content: [{
1455
+ type: "text",
1456
+ text: `No skills found matching "${query}". Try different keywords or browse the registry at https://tankpkg.dev`
1457
+ }] };
1458
+ const header = "| Skill | Score | Downloads | Description |\n|-------|-------|-----------|-------------|";
1459
+ const rows = results.map((skill) => {
1460
+ const score = skill.auditScore !== null ? skill.auditScore.toFixed(1) : "-";
1461
+ const downloads = skill.downloads > 1e3 ? `${(skill.downloads / 1e3).toFixed(1)}k` : skill.downloads.toString();
1462
+ const desc = skill.description?.slice(0, 50) ?? "No description";
1463
+ return `| ${skill.name} | ${score} | ${downloads} | ${desc} |`;
1464
+ });
1465
+ return { content: [{
1466
+ type: "text",
1467
+ text: [
1468
+ `Found ${total} skill${total !== 1 ? "s" : ""} matching "${query}":`,
1469
+ "",
1470
+ header,
1471
+ ...rows,
1472
+ "",
1473
+ `View full results: https://tankpkg.dev/search?q=${encodeURIComponent(query)}`
1474
+ ].join("\n")
1475
+ }] };
1476
+ });
1477
+ }
1478
+ //#endregion
1479
+ //#region src/tools/skill-info.ts
1480
+ function registerSkillInfoTool(server) {
1481
+ const client = new TankApiClient();
1482
+ server.tool("skill-info", "Get detailed information about a specific skill from the Tank registry", { name: z.string().describe("Skill name (e.g., @org/skill-name or skill-name)") }, async ({ name }) => {
1483
+ const result = await client.fetch(`/api/v1/skills/${encodeURIComponent(name)}`);
1484
+ if (!result.ok) {
1485
+ if (result.status === 404) return { content: [{
1486
+ type: "text",
1487
+ text: `Skill "${name}" not found. Search for skills: https://tankpkg.dev/search`
1488
+ }] };
1489
+ return { content: [{
1490
+ type: "text",
1491
+ text: `Failed to get skill info: ${result.error}`
1492
+ }] };
1493
+ }
1494
+ const skill = result.data;
1495
+ const score = skill.auditScore !== null ? `${skill.auditScore.toFixed(1)}/10` : "Not scored";
1496
+ const size = skill.versions[0] ? `${(skill.versions[0].tarballSize / 1024).toFixed(1)}KB` : "Unknown";
1497
+ let permsText = "None declared";
1498
+ if (skill.permissions) {
1499
+ const perms = [];
1500
+ const p = skill.permissions;
1501
+ if (p.network?.outbound?.length) perms.push(`network: ${p.network.outbound.join(", ")}`);
1502
+ if (p.filesystem?.read?.length || p.filesystem?.write?.length) {
1503
+ const fsPerms = [];
1504
+ if (p.filesystem.read?.length) fsPerms.push(`read: ${p.filesystem.read.length} paths`);
1505
+ if (p.filesystem.write?.length) fsPerms.push(`write: ${p.filesystem.write.length} paths`);
1506
+ perms.push(`filesystem (${fsPerms.join(", ")})`);
1507
+ }
1508
+ if (p.subprocess) perms.push("subprocess: allowed");
1509
+ if (perms.length > 0) permsText = perms.join("\n - ");
1510
+ }
1511
+ const versionsList = skill.versions.slice(0, 5).map((v) => {
1512
+ const vScore = v.auditScore !== null ? v.auditScore.toFixed(1) : "-";
1513
+ return `${v.version} (score: ${vScore})`;
1514
+ }).join("\n - ");
1515
+ return { content: [{
1516
+ type: "text",
1517
+ text: [
1518
+ `# ${skill.name}`,
1519
+ "",
1520
+ `**Publisher:** ${skill.publisher}`,
1521
+ `**Latest:** ${skill.latestVersion}`,
1522
+ `**Score:** ${score}`,
1523
+ `**Size:** ${size}`,
1524
+ `**Downloads:** ${skill.downloads}`,
1525
+ "",
1526
+ "**Description:**",
1527
+ skill.description ?? "No description available",
1528
+ "",
1529
+ "**Permissions:**",
1530
+ ` - ${permsText}`,
1531
+ "",
1532
+ "**Versions:**",
1533
+ ` - ${versionsList}`,
1534
+ skill.versions.length > 5 ? `\n ... and ${skill.versions.length - 5} more` : "",
1535
+ "",
1536
+ `View on Tank: https://tankpkg.dev/skills/${skill.name}`
1537
+ ].join("\n")
1538
+ }] };
1539
+ });
1540
+ }
1541
+ //#endregion
1542
+ //#region src/tools/skill-permissions.ts
1543
+ function parseSkillName(key) {
1544
+ const lastAt = key.lastIndexOf("@");
1545
+ if (lastAt > 0) return key.slice(0, lastAt);
1546
+ return key;
1547
+ }
1548
+ function collectPermissions(lockfile) {
1549
+ const networkMap = /* @__PURE__ */ new Map();
1550
+ const fsReadMap = /* @__PURE__ */ new Map();
1551
+ const fsWriteMap = /* @__PURE__ */ new Map();
1552
+ const subprocessSkills = [];
1553
+ const envMap = /* @__PURE__ */ new Map();
1554
+ const execMap = /* @__PURE__ */ new Map();
1555
+ for (const [key, entry] of Object.entries(lockfile.skills)) {
1556
+ const skillName = parseSkillName(key);
1557
+ const perms = entry.permissions;
1558
+ if (perms.network?.outbound) for (const domain of perms.network.outbound) {
1559
+ const existing = networkMap.get(domain) ?? [];
1560
+ existing.push(skillName);
1561
+ networkMap.set(domain, existing);
1562
+ }
1563
+ if (perms.filesystem?.read) for (const p of perms.filesystem.read) {
1564
+ const existing = fsReadMap.get(p) ?? [];
1565
+ existing.push(skillName);
1566
+ fsReadMap.set(p, existing);
1567
+ }
1568
+ if (perms.filesystem?.write) for (const p of perms.filesystem.write) {
1569
+ const existing = fsWriteMap.get(p) ?? [];
1570
+ existing.push(skillName);
1571
+ fsWriteMap.set(p, existing);
1572
+ }
1573
+ if (perms.subprocess === true) subprocessSkills.push(skillName);
1574
+ const rawPerms = perms;
1575
+ if (Array.isArray(rawPerms.env)) for (const envVar of rawPerms.env) {
1576
+ const existing = envMap.get(envVar) ?? [];
1577
+ existing.push(skillName);
1578
+ envMap.set(envVar, existing);
1579
+ }
1580
+ if (Array.isArray(rawPerms.exec)) for (const cmd of rawPerms.exec) {
1581
+ const existing = execMap.get(cmd) ?? [];
1582
+ existing.push(skillName);
1583
+ execMap.set(cmd, existing);
1584
+ }
1585
+ }
1586
+ const toEntries = (map) => Array.from(map.entries()).map(([value, skills]) => ({
1587
+ value,
1588
+ skills
1589
+ }));
1590
+ return {
1591
+ networkOutbound: toEntries(networkMap),
1592
+ filesystemRead: toEntries(fsReadMap),
1593
+ filesystemWrite: toEntries(fsWriteMap),
1594
+ subprocess: subprocessSkills,
1595
+ env: toEntries(envMap),
1596
+ exec: toEntries(execMap)
1597
+ };
1598
+ }
1599
+ function formatAttribution(skills) {
1600
+ return `<- ${skills.join(", ")}`;
1601
+ }
1602
+ function formatSection(title, entries) {
1603
+ const lines = [];
1604
+ lines.push(`${title}:`);
1605
+ if (entries.length === 0) lines.push(" none");
1606
+ else for (const entry of entries) lines.push(` ${entry.value} ${formatAttribution(entry.skills)}`);
1607
+ return lines.join("\n");
1608
+ }
1609
+ function isDomainAllowed(domain, allowedDomains) {
1610
+ for (const allowed of allowedDomains) {
1611
+ if (allowed === "*") return true;
1612
+ if (allowed === domain) return true;
1613
+ if (allowed.startsWith("*.")) {
1614
+ const suffix = allowed.slice(1);
1615
+ if (domain.endsWith(suffix) || domain === allowed.slice(2)) return true;
1616
+ if (domain === allowed) return true;
1617
+ }
1618
+ }
1619
+ return false;
1620
+ }
1621
+ function isPathAllowed(requestedPath, allowedPaths) {
1622
+ for (const allowed of allowedPaths) {
1623
+ if (allowed === requestedPath) return true;
1624
+ if (allowed.endsWith("/**")) {
1625
+ const prefix = allowed.slice(0, -3);
1626
+ if (requestedPath.startsWith(prefix)) return true;
1627
+ }
1628
+ }
1629
+ return false;
1630
+ }
1631
+ function checkBudget(resolved, budget) {
1632
+ const violations = [];
1633
+ const budgetDomains = budget.network?.outbound ?? [];
1634
+ for (const entry of resolved.networkOutbound) if (!isDomainAllowed(entry.value, budgetDomains)) violations.push({
1635
+ category: "network outbound",
1636
+ value: entry.value,
1637
+ skills: entry.skills
1638
+ });
1639
+ const budgetReadPaths = budget.filesystem?.read ?? [];
1640
+ for (const entry of resolved.filesystemRead) if (!isPathAllowed(entry.value, budgetReadPaths)) violations.push({
1641
+ category: "filesystem read",
1642
+ value: entry.value,
1643
+ skills: entry.skills
1644
+ });
1645
+ const budgetWritePaths = budget.filesystem?.write ?? [];
1646
+ for (const entry of resolved.filesystemWrite) if (!isPathAllowed(entry.value, budgetWritePaths)) violations.push({
1647
+ category: "filesystem write",
1648
+ value: entry.value,
1649
+ skills: entry.skills
1650
+ });
1651
+ if (resolved.subprocess.length > 0 && budget.subprocess !== true) violations.push({
1652
+ category: "subprocess",
1653
+ value: "subprocess access",
1654
+ skills: resolved.subprocess
1655
+ });
1656
+ return violations;
1657
+ }
1658
+ function registerSkillPermissionsTool(server) {
1659
+ server.tool("skill-permissions", "Display resolved permission summary for installed skills. Shows what capabilities each skill requires and checks against the project permission budget.", { directory: z.string().optional().describe("Project directory path (defaults to current working directory)") }, async ({ directory }) => {
1660
+ const dir = directory ? path.resolve(directory) : process.cwd();
1661
+ if (!fs.existsSync(dir)) return {
1662
+ content: [{
1663
+ type: "text",
1664
+ text: `Directory does not exist: ${dir}`
1665
+ }],
1666
+ isError: true
1667
+ };
1668
+ let skillsJsonPath = path.join(dir, MANIFEST_FILENAME);
1669
+ if (!fs.existsSync(skillsJsonPath)) skillsJsonPath = path.join(dir, LEGACY_MANIFEST_FILENAME);
1670
+ if (!fs.existsSync(skillsJsonPath)) return {
1671
+ content: [{
1672
+ type: "text",
1673
+ text: `No ${MANIFEST_FILENAME} found. Run "init-skill" to create one.`
1674
+ }],
1675
+ isError: true
1676
+ };
1677
+ let skillsJson;
1678
+ try {
1679
+ const raw = fs.readFileSync(skillsJsonPath, "utf-8");
1680
+ skillsJson = JSON.parse(raw);
1681
+ } catch {
1682
+ return {
1683
+ content: [{
1684
+ type: "text",
1685
+ text: `Failed to parse ${path.basename(skillsJsonPath)}. The file may be corrupted.`
1686
+ }],
1687
+ isError: true
1688
+ };
1689
+ }
1690
+ const skillDeps = skillsJson.skills ?? {};
1691
+ if (Object.keys(skillDeps).length === 0) return { content: [{
1692
+ type: "text",
1693
+ text: "No skills with permissions to display. The project has no skill dependencies."
1694
+ }] };
1695
+ let lockfilePath = path.join(dir, LOCKFILE_FILENAME);
1696
+ if (!fs.existsSync(lockfilePath)) lockfilePath = path.join(dir, LEGACY_LOCKFILE_FILENAME);
1697
+ if (!fs.existsSync(lockfilePath)) return { content: [{
1698
+ type: "text",
1699
+ text: `No ${LOCKFILE_FILENAME} found. Skills are declared but not installed. Run install to generate a lockfile.`
1700
+ }] };
1701
+ let lockfile;
1702
+ try {
1703
+ const raw = fs.readFileSync(lockfilePath, "utf-8");
1704
+ lockfile = JSON.parse(raw);
1705
+ } catch {
1706
+ return {
1707
+ content: [{
1708
+ type: "text",
1709
+ text: `Failed to parse ${path.basename(lockfilePath)}. The file may be corrupted.`
1710
+ }],
1711
+ isError: true
1712
+ };
1713
+ }
1714
+ if (!lockfile.skills || Object.keys(lockfile.skills).length === 0) return { content: [{
1715
+ type: "text",
1716
+ text: "No skills installed. The lockfile is empty."
1717
+ }] };
1718
+ const resolved = collectPermissions(lockfile);
1719
+ const lines = [];
1720
+ lines.push("Resolved permissions for this project:");
1721
+ lines.push("");
1722
+ lines.push(formatSection("Network (outbound)", resolved.networkOutbound));
1723
+ lines.push(formatSection("Filesystem (read)", resolved.filesystemRead));
1724
+ lines.push(formatSection("Filesystem (write)", resolved.filesystemWrite));
1725
+ lines.push("Subprocess:");
1726
+ if (resolved.subprocess.length === 0) lines.push(" none");
1727
+ else lines.push(` allowed ${formatAttribution(resolved.subprocess)}`);
1728
+ if (resolved.env.length > 0) lines.push(formatSection("Environment variables", resolved.env));
1729
+ if (resolved.exec.length > 0) lines.push(formatSection("Exec", resolved.exec));
1730
+ lines.push("");
1731
+ lines.push("Per-skill breakdown:");
1732
+ for (const [key, entry] of Object.entries(lockfile.skills)) {
1733
+ const skillName = parseSkillName(key);
1734
+ const perms = entry.permissions;
1735
+ const permParts = [];
1736
+ if (perms.network?.outbound && perms.network.outbound.length > 0) permParts.push(`network: ${perms.network.outbound.join(", ")}`);
1737
+ if (perms.filesystem?.read && perms.filesystem.read.length > 0) permParts.push(`filesystem:read: ${perms.filesystem.read.join(", ")}`);
1738
+ if (perms.filesystem?.write && perms.filesystem.write.length > 0) permParts.push(`filesystem:write: ${perms.filesystem.write.join(", ")}`);
1739
+ if (perms.subprocess === true) permParts.push("subprocess: allowed");
1740
+ const rawPerms = perms;
1741
+ if (Array.isArray(rawPerms.env) && rawPerms.env.length > 0) permParts.push(`env: ${rawPerms.env.join(", ")}`);
1742
+ if (Array.isArray(rawPerms.exec) && rawPerms.exec.length > 0) permParts.push(`exec: ${rawPerms.exec.join(", ")}`);
1743
+ if (permParts.length === 0) lines.push(` ${skillName}: no special permissions`);
1744
+ else lines.push(` ${skillName}: ${permParts.join("; ")}`);
1745
+ }
1746
+ const budget = skillsJson.permissions;
1747
+ lines.push("");
1748
+ if (!budget) lines.push("Budget status: No budget defined");
1749
+ else {
1750
+ const violations = checkBudget(resolved, budget);
1751
+ if (violations.length === 0) lines.push("Budget status: PASS (all within budget)");
1752
+ else {
1753
+ lines.push("Budget status: FAIL");
1754
+ for (const v of violations) lines.push(` - ${v.category}: "${v.value}" not in budget (requested by ${v.skills.join(", ")})`);
1755
+ }
1756
+ }
1757
+ return { content: [{
1758
+ type: "text",
1759
+ text: lines.join("\n")
1760
+ }] };
1761
+ });
1762
+ }
1763
+ //#endregion
1764
+ //#region src/tools/unlink-skill.ts
1765
+ const SCOPED_NAME_PATTERN$1 = /^@[a-z0-9-]+\/[a-z0-9][a-z0-9-]*$/;
1766
+ function registerUnlinkSkillTool(server) {
1767
+ server.tool("unlink-skill", "Unlink a skill from an agent workspace. Removes the symlink without deleting the installed skill files.", {
1768
+ name: z.string().describe("Skill name in @org/name format"),
1769
+ workspace: z.string().describe("Agent workspace directory path"),
1770
+ directory: z.string().optional().describe("Project directory where skills are installed (defaults to current working directory)")
1771
+ }, async ({ name, workspace, directory }) => {
1772
+ if (!SCOPED_NAME_PATTERN$1.test(name)) return {
1773
+ content: [{
1774
+ type: "text",
1775
+ text: `Validation error: Skill name "${name}" must use the @org/name format (e.g. @acme/my-skill).`
1776
+ }],
1777
+ isError: true
1778
+ };
1779
+ const projectDir = directory ? path.resolve(directory) : process.cwd();
1780
+ const workspaceDir = path.resolve(workspace);
1781
+ if (!fs.existsSync(workspaceDir)) return {
1782
+ content: [{
1783
+ type: "text",
1784
+ text: `Error: Workspace directory does not exist: ${workspaceDir}`
1785
+ }],
1786
+ isError: true
1787
+ };
1788
+ const skillDir = getSkillDir$1(projectDir, name);
1789
+ if (!fs.existsSync(skillDir)) return {
1790
+ content: [{
1791
+ type: "text",
1792
+ text: `Skill "${name}" is not installed. It was not found in ${skillDir}.`
1793
+ }],
1794
+ isError: true
1795
+ };
1796
+ const [scope, skillName] = name.split("/");
1797
+ const symlinkPath = path.join(workspaceDir, ".skills", scope, skillName);
1798
+ try {
1799
+ if (fs.lstatSync(symlinkPath).isSymbolicLink()) {
1800
+ fs.unlinkSync(symlinkPath);
1801
+ return { content: [{
1802
+ type: "text",
1803
+ text: `Successfully unlinked "${name}" from ${workspaceDir}.\nRemoved symlink: ${symlinkPath}`
1804
+ }] };
1805
+ }
1806
+ } catch {}
1807
+ return { content: [{
1808
+ type: "text",
1809
+ text: `No link exists for "${name}" in ${workspaceDir}.`
1810
+ }] };
1811
+ });
1812
+ }
1813
+ function getSkillDir$1(projectDir, skillName) {
1814
+ if (skillName.startsWith("@")) {
1815
+ const [scope, name] = skillName.split("/");
1816
+ return path.join(projectDir, ".tank", "skills", scope, name);
1817
+ }
1818
+ return path.join(projectDir, ".tank", "skills", skillName);
1819
+ }
1820
+ //#endregion
1821
+ //#region src/tools/update-skill.ts
1822
+ const SCOPED_NAME_PATTERN = /^@[a-z0-9-]+\/[a-z0-9][a-z0-9-]*$/;
1823
+ function parseLockKey$1(key) {
1824
+ const lastAt = key.lastIndexOf("@");
1825
+ if (lastAt <= 0) return null;
1826
+ return {
1827
+ name: key.slice(0, lastAt),
1828
+ version: key.slice(lastAt + 1)
1829
+ };
1830
+ }
1831
+ function registerUpdateSkillTool(server) {
1832
+ server.tool("update-skill", "Update an installed skill to the latest compatible version within its declared semver range.", {
1833
+ name: z.string().describe("Skill name in @org/name format"),
1834
+ directory: z.string().optional().describe("Project directory (defaults to current working directory)")
1835
+ }, async ({ name, directory }) => {
1836
+ if (!SCOPED_NAME_PATTERN.test(name)) return {
1837
+ content: [{
1838
+ type: "text",
1839
+ text: `Validation error: Skill name "${name}" must use the @org/name format (e.g. @acme/my-skill).`
1840
+ }],
1841
+ isError: true
1842
+ };
1843
+ const dir = directory ? path.resolve(directory) : process.cwd();
1844
+ let skillsJsonPath = path.join(dir, MANIFEST_FILENAME);
1845
+ if (!fs.existsSync(skillsJsonPath)) skillsJsonPath = path.join(dir, LEGACY_MANIFEST_FILENAME);
1846
+ if (!fs.existsSync(skillsJsonPath)) return {
1847
+ content: [{
1848
+ type: "text",
1849
+ text: `No ${MANIFEST_FILENAME} found in ${dir}. Run the "init-skill" tool first.`
1850
+ }],
1851
+ isError: true
1852
+ };
1853
+ let skillsJson;
1854
+ try {
1855
+ const raw = fs.readFileSync(skillsJsonPath, "utf-8");
1856
+ skillsJson = JSON.parse(raw);
1857
+ } catch {
1858
+ return {
1859
+ content: [{
1860
+ type: "text",
1861
+ text: `Failed to read or parse ${path.basename(skillsJsonPath)}.`
1862
+ }],
1863
+ isError: true
1864
+ };
1865
+ }
1866
+ const versionRange = (skillsJson.skills ?? {})[name];
1867
+ if (!versionRange) return {
1868
+ content: [{
1869
+ type: "text",
1870
+ text: `Skill "${name}" is not installed (not found in ${path.basename(skillsJsonPath)}). Install it first with the install-skill tool.`
1871
+ }],
1872
+ isError: true
1873
+ };
1874
+ let lockPath = path.join(dir, LOCKFILE_FILENAME);
1875
+ if (!fs.existsSync(lockPath)) lockPath = path.join(dir, LEGACY_LOCKFILE_FILENAME);
1876
+ let currentVersion = null;
1877
+ if (fs.existsSync(lockPath)) try {
1878
+ const raw = fs.readFileSync(lockPath, "utf-8");
1879
+ const lock = JSON.parse(raw);
1880
+ for (const key of Object.keys(lock.skills)) {
1881
+ const parsed = parseLockKey$1(key);
1882
+ if (!parsed) continue;
1883
+ if (parsed.name === name) {
1884
+ currentVersion = parsed.version;
1885
+ break;
1886
+ }
1887
+ }
1888
+ } catch {}
1889
+ if (!currentVersion) return {
1890
+ content: [{
1891
+ type: "text",
1892
+ text: `Skill "${name}" is not installed (not found in ${LOCKFILE_FILENAME}). Install it first with the install-skill tool.`
1893
+ }],
1894
+ isError: true
1895
+ };
1896
+ const client = new TankApiClient();
1897
+ if (!client.isAuthenticated) return {
1898
+ content: [{
1899
+ type: "text",
1900
+ text: "Authentication required. Please run the \"login\" tool first to authenticate with Tank."
1901
+ }],
1902
+ isError: true
1903
+ };
1904
+ const encodedName = encodeURIComponent(name);
1905
+ const versionsResult = await client.fetch(`/api/v1/skills/${encodedName}/versions`);
1906
+ if (!versionsResult.ok) {
1907
+ if (versionsResult.status === 0) return {
1908
+ content: [{
1909
+ type: "text",
1910
+ text: `Unable to connect to the Tank registry. Check your network connection and try again.`
1911
+ }],
1912
+ isError: true
1913
+ };
1914
+ if (versionsResult.status === 404) return {
1915
+ content: [{
1916
+ type: "text",
1917
+ text: `Skill "${name}" not found in the registry.`
1918
+ }],
1919
+ isError: true
1920
+ };
1921
+ return {
1922
+ content: [{
1923
+ type: "text",
1924
+ text: `Failed to fetch versions for ${name}: ${versionsResult.error}`
1925
+ }],
1926
+ isError: true
1927
+ };
1928
+ }
1929
+ const availableVersions = versionsResult.data.versions.map((v) => v.version);
1930
+ const resolved = resolve(versionRange, availableVersions);
1931
+ if (!resolved) return {
1932
+ content: [{
1933
+ type: "text",
1934
+ text: `No version of ${name} satisfies range "${versionRange}". Available: ${availableVersions.join(", ")}`
1935
+ }],
1936
+ isError: true
1937
+ };
1938
+ const allMajors = availableVersions.map((v) => {
1939
+ const major = v.split(".")[0];
1940
+ return {
1941
+ version: v,
1942
+ major: Number.parseInt(major, 10)
1943
+ };
1944
+ }).filter((v) => !Number.isNaN(v.major));
1945
+ const currentMajor = Number.parseInt(currentVersion.split(".")[0], 10);
1946
+ const newerMajors = allMajors.filter((v) => v.major > currentMajor).map((v) => v.version);
1947
+ const highestOutOfRange = newerMajors.length > 0 ? newerMajors.sort((a, b) => {
1948
+ const [aMaj, aMin, aPat] = a.split(".").map(Number);
1949
+ const [bMaj, bMin, bPat] = b.split(".").map(Number);
1950
+ return bMaj - aMaj || bMin - aMin || bPat - aPat;
1951
+ })[0] : null;
1952
+ if (resolved === currentVersion) {
1953
+ const lines = [`Already at latest compatible version: ${name}@${resolved}`];
1954
+ if (highestOutOfRange) lines.push(`\nNote: Version ${highestOutOfRange} is available but outside the declared range "${versionRange}". Update ${MANIFEST_FILENAME} to use it.`);
1955
+ return { content: [{
1956
+ type: "text",
1957
+ text: lines.join("")
1958
+ }] };
1959
+ }
1960
+ const versionResult = await client.fetch(`/api/v1/skills/${encodedName}/${resolved}`);
1961
+ if (!versionResult.ok) return {
1962
+ content: [{
1963
+ type: "text",
1964
+ text: `Failed to fetch version details for ${name}@${resolved}: ${versionResult.error}`
1965
+ }],
1966
+ isError: true
1967
+ };
1968
+ const versionData = versionResult.data;
1969
+ let tarballBuffer;
1970
+ try {
1971
+ const tarballRes = await fetch(versionData.downloadUrl, { headers: client.token ? { Authorization: `Bearer ${client.token}` } : {} });
1972
+ if (!tarballRes.ok) return {
1973
+ content: [{
1974
+ type: "text",
1975
+ text: `Failed to download tarball for ${name}@${resolved}: ${tarballRes.statusText}`
1976
+ }],
1977
+ isError: true
1978
+ };
1979
+ tarballBuffer = await tarballRes.arrayBuffer();
1980
+ } catch (err) {
1981
+ return {
1982
+ content: [{
1983
+ type: "text",
1984
+ text: `Network error downloading ${name}@${resolved}: ${err instanceof Error ? err.message : String(err)}`
1985
+ }],
1986
+ isError: true
1987
+ };
1988
+ }
1989
+ const { createHash } = await import("node:crypto");
1990
+ const computedIntegrity = `sha512-${createHash("sha512").update(Buffer.from(tarballBuffer)).digest("base64")}`;
1991
+ if (computedIntegrity !== versionData.integrity) return {
1992
+ content: [{
1993
+ type: "text",
1994
+ text: `Integrity check failed for ${name}@${resolved}. The tarball has been tampered with or is corrupted.\nExpected: ${versionData.integrity}\nGot: ${computedIntegrity}`
1995
+ }],
1996
+ isError: true
1997
+ };
1998
+ const { execSync } = await import("node:child_process");
1999
+ const skillDir = getSkillDir(dir, name);
2000
+ if (fs.existsSync(skillDir)) fs.rmSync(skillDir, {
2001
+ recursive: true,
2002
+ force: true
2003
+ });
2004
+ fs.mkdirSync(skillDir, { recursive: true });
2005
+ const tarballPath = path.join(skillDir, "__temp_tarball.tgz");
2006
+ fs.writeFileSync(tarballPath, Buffer.from(tarballBuffer));
2007
+ try {
2008
+ execSync(`tar xzf "${tarballPath}" -C "${skillDir}" --strip-components=1`, { stdio: "pipe" });
2009
+ } catch (err) {
2010
+ return {
2011
+ content: [{
2012
+ type: "text",
2013
+ text: `Failed to extract tarball for ${name}@${resolved}: ${err instanceof Error ? err.message : String(err)}`
2014
+ }],
2015
+ isError: true
2016
+ };
2017
+ } finally {
2018
+ try {
2019
+ fs.unlinkSync(tarballPath);
2020
+ } catch {}
2021
+ }
2022
+ let lock;
2023
+ if (fs.existsSync(lockPath)) try {
2024
+ const raw = fs.readFileSync(lockPath, "utf-8");
2025
+ lock = JSON.parse(raw);
2026
+ } catch {
2027
+ lock = {
2028
+ lockfileVersion: 1,
2029
+ skills: {}
2030
+ };
2031
+ }
2032
+ else lock = {
2033
+ lockfileVersion: 1,
2034
+ skills: {}
2035
+ };
2036
+ for (const key of Object.keys(lock.skills)) {
2037
+ const parsed = parseLockKey$1(key);
2038
+ if (parsed && parsed.name === name) delete lock.skills[key];
2039
+ }
2040
+ const newLockKey = `${name}@${resolved}`;
2041
+ lock.skills[newLockKey] = {
2042
+ resolved: versionData.downloadUrl,
2043
+ integrity: versionData.integrity,
2044
+ permissions: versionData.permissions,
2045
+ audit_score: versionData.auditScore
2046
+ };
2047
+ const sortedSkills = {};
2048
+ for (const key of Object.keys(lock.skills).sort()) sortedSkills[key] = lock.skills[key];
2049
+ lock.skills = sortedSkills;
2050
+ fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`);
2051
+ const lines = [
2052
+ `Updated ${name} from ${currentVersion} to ${resolved}.`,
2053
+ `Integrity verified (SHA-512).`,
2054
+ `Lockfile updated.`
2055
+ ];
2056
+ if (highestOutOfRange) lines.push(`\nNote: Version ${highestOutOfRange} is available but outside the declared range "${versionRange}". Update ${MANIFEST_FILENAME} to use it.`);
2057
+ return { content: [{
2058
+ type: "text",
2059
+ text: lines.join("\n")
2060
+ }] };
2061
+ });
2062
+ }
2063
+ function getSkillDir(projectDir, skillName) {
2064
+ if (skillName.startsWith("@")) {
2065
+ const [scope, name] = skillName.split("/");
2066
+ return path.join(projectDir, ".tank", "skills", scope, name);
2067
+ }
2068
+ return path.join(projectDir, ".tank", "skills", skillName);
2069
+ }
2070
+ //#endregion
2071
+ //#region src/tools/verify-skills.ts
2072
+ function registerVerifySkillsTool(server) {
2073
+ server.tool("verify-skills", "Verify that installed skills match their lockfile entries. Checks that skill directories exist and are not empty.", {
2074
+ name: z.string().optional().describe("Specific skill name to verify (verifies all if omitted)"),
2075
+ directory: z.string().optional().describe("Project directory (defaults to current working directory)")
2076
+ }, async ({ name, directory }) => {
2077
+ const dir = directory ? path.resolve(directory) : process.cwd();
2078
+ let lockPath = path.join(dir, LOCKFILE_FILENAME);
2079
+ if (!fs.existsSync(lockPath)) lockPath = path.join(dir, LEGACY_LOCKFILE_FILENAME);
2080
+ if (!fs.existsSync(lockPath)) return {
2081
+ content: [{
2082
+ type: "text",
2083
+ text: `No ${LOCKFILE_FILENAME} found. Run "install-skill" to install skills and generate a lockfile.`
2084
+ }],
2085
+ isError: true
2086
+ };
2087
+ let lock;
2088
+ try {
2089
+ const raw = fs.readFileSync(lockPath, "utf-8");
2090
+ lock = JSON.parse(raw);
2091
+ } catch {
2092
+ return {
2093
+ content: [{
2094
+ type: "text",
2095
+ text: `Failed to parse ${path.basename(lockPath)}. The file may be corrupted.`
2096
+ }],
2097
+ isError: true
2098
+ };
2099
+ }
2100
+ let entries = Object.entries(lock.skills);
2101
+ if (entries.length === 0) return { content: [{
2102
+ type: "text",
2103
+ text: "No skills to verify. The lockfile is empty."
2104
+ }] };
2105
+ if (name) {
2106
+ entries = entries.filter(([key]) => {
2107
+ const lastAt = key.lastIndexOf("@");
2108
+ if (lastAt <= 0) return false;
2109
+ return key.slice(0, lastAt) === name;
2110
+ });
2111
+ if (entries.length === 0) return {
2112
+ content: [{
2113
+ type: "text",
2114
+ text: `Skill "${name}" not found in lockfile.`
2115
+ }],
2116
+ isError: true
2117
+ };
2118
+ }
2119
+ const results = [];
2120
+ for (const [key, entry] of entries) {
2121
+ const skillDir = getExtractDir(dir, parseLockKey(key));
2122
+ if (!fs.existsSync(skillDir)) {
2123
+ results.push({
2124
+ key,
2125
+ status: "MISSING",
2126
+ detail: `Directory missing at ${skillDir}. Reinstall with "install-skill".`
2127
+ });
2128
+ continue;
2129
+ }
2130
+ if (fs.readdirSync(skillDir).length === 0) {
2131
+ results.push({
2132
+ key,
2133
+ status: "FAIL",
2134
+ detail: `Directory exists but is empty. Expected integrity: ${entry.integrity}. SHA-512 mismatch detected.`
2135
+ });
2136
+ continue;
2137
+ }
2138
+ results.push({
2139
+ key,
2140
+ status: "PASS",
2141
+ detail: `Verified (integrity: ${entry.integrity})`
2142
+ });
2143
+ }
2144
+ const passing = results.filter((r) => r.status === "PASS");
2145
+ const failing = results.filter((r) => r.status !== "PASS");
2146
+ const lines = [];
2147
+ for (const r of results) lines.push(`${r.status} ${r.key}: ${r.detail}`);
2148
+ if (failing.length > 0) {
2149
+ lines.push("");
2150
+ lines.push(`Verification failed: ${failing.length} issue(s) found, ${passing.length} passed.`);
2151
+ return {
2152
+ content: [{
2153
+ type: "text",
2154
+ text: lines.join("\n")
2155
+ }],
2156
+ isError: true
2157
+ };
2158
+ }
2159
+ lines.push("");
2160
+ lines.push(`All ${passing.length} skill(s) passed verification.`);
2161
+ return { content: [{
2162
+ type: "text",
2163
+ text: lines.join("\n")
2164
+ }] };
2165
+ });
2166
+ }
2167
+ function parseLockKey(key) {
2168
+ const lastAt = key.lastIndexOf("@");
2169
+ if (lastAt <= 0) return key;
2170
+ return key.slice(0, lastAt);
2171
+ }
2172
+ function getExtractDir(projectDir, skillName) {
2173
+ if (skillName.startsWith("@")) {
2174
+ const [scope, name] = skillName.split("/");
2175
+ return path.join(projectDir, ".tank", "skills", scope, name);
2176
+ }
2177
+ return path.join(projectDir, ".tank", "skills", skillName);
2178
+ }
2179
+ //#endregion
2180
+ //#region src/tools/whoami.ts
2181
+ function registerWhoamiTool(server) {
2182
+ server.tool("whoami", "Show the authenticated Tank user for the current local session.", {}, async () => {
2183
+ const client = new TankApiClient();
2184
+ if (!client.isAuthenticated) return { content: [{
2185
+ type: "text",
2186
+ text: "Not logged in. Use the login tool to authenticate."
2187
+ }] };
2188
+ const authCheck = await client.verifyAuth();
2189
+ if (authCheck.valid) return { content: [{
2190
+ type: "text",
2191
+ text: `Logged in as ${authCheck.user.name ?? "unknown"}\nEmail: ${authCheck.user.email ?? "unknown"}`
2192
+ }] };
2193
+ if (authCheck.reason === "network-error") return {
2194
+ content: [{
2195
+ type: "text",
2196
+ text: `Failed to connect to the registry. Check your network connection.\nError: ${authCheck.error ?? "unknown"}`
2197
+ }],
2198
+ isError: true
2199
+ };
2200
+ return { content: [{
2201
+ type: "text",
2202
+ text: "Session expired or invalid. Use the login tool to re-authenticate."
2203
+ }] };
2204
+ });
2205
+ }
2206
+ //#endregion
2207
+ //#region src/index.ts
23
2208
  const server = new McpServer({
24
- name: 'tank',
25
- version: '0.1.0',
2209
+ name: "tank",
2210
+ version: "0.1.0"
26
2211
  });
27
- // Register all tools
28
2212
  registerLoginTool(server);
29
2213
  registerSearchSkillsTool(server);
30
2214
  registerSkillInfoTool(server);
@@ -42,13 +2226,15 @@ registerSkillPermissionsTool(server);
42
2226
  registerInstallSkillTool(server);
43
2227
  registerUpdateSkillTool(server);
44
2228
  registerAuditSkillTool(server);
45
- // Start stdio transport
46
2229
  async function main() {
47
- const transport = new StdioServerTransport();
48
- await server.connect(transport);
2230
+ const transport = new StdioServerTransport();
2231
+ await server.connect(transport);
49
2232
  }
50
2233
  main().catch((error) => {
51
- console.error('MCP server error:', error);
52
- process.exit(1);
2234
+ console.error("MCP server error:", error);
2235
+ process.exit(1);
53
2236
  });
2237
+ //#endregion
2238
+ export {};
2239
+
54
2240
  //# sourceMappingURL=index.js.map