@tankpkg/mcp-server 0.7.0 → 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.
- package/README.md +11 -4
- package/dist/index.d.ts +1 -3
- package/dist/index.js +2215 -29
- package/dist/index.js.map +1 -1
- package/package.json +20 -14
- package/LICENSE +0 -21
- package/dist/index.d.ts.map +0 -1
- package/dist/lib/api-client.d.ts +0 -45
- package/dist/lib/api-client.d.ts.map +0 -1
- package/dist/lib/api-client.js +0 -78
- package/dist/lib/api-client.js.map +0 -1
- package/dist/lib/config.d.ts +0 -25
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/config.js +0 -59
- package/dist/lib/config.js.map +0 -1
- package/dist/lib/packer.d.ts +0 -34
- package/dist/lib/packer.d.ts.map +0 -1
- package/dist/lib/packer.js +0 -276
- package/dist/lib/packer.js.map +0 -1
- package/dist/tools/audit-skill.d.ts +0 -3
- package/dist/tools/audit-skill.d.ts.map +0 -1
- package/dist/tools/audit-skill.js +0 -213
- package/dist/tools/audit-skill.js.map +0 -1
- package/dist/tools/doctor.d.ts +0 -3
- package/dist/tools/doctor.d.ts.map +0 -1
- package/dist/tools/doctor.js +0 -158
- package/dist/tools/doctor.js.map +0 -1
- package/dist/tools/init-skill.d.ts +0 -3
- package/dist/tools/init-skill.d.ts.map +0 -1
- package/dist/tools/init-skill.js +0 -72
- package/dist/tools/init-skill.js.map +0 -1
- package/dist/tools/install-skill.d.ts +0 -3
- package/dist/tools/install-skill.d.ts.map +0 -1
- package/dist/tools/install-skill.js +0 -206
- package/dist/tools/install-skill.js.map +0 -1
- package/dist/tools/link-skill.d.ts +0 -3
- package/dist/tools/link-skill.d.ts.map +0 -1
- package/dist/tools/link-skill.js +0 -81
- package/dist/tools/link-skill.js.map +0 -1
- package/dist/tools/login.d.ts +0 -3
- package/dist/tools/login.d.ts.map +0 -1
- package/dist/tools/login.js +0 -104
- package/dist/tools/login.js.map +0 -1
- package/dist/tools/logout.d.ts +0 -3
- package/dist/tools/logout.d.ts.map +0 -1
- package/dist/tools/logout.js +0 -19
- package/dist/tools/logout.js.map +0 -1
- package/dist/tools/publish-skill.d.ts +0 -3
- package/dist/tools/publish-skill.d.ts.map +0 -1
- package/dist/tools/publish-skill.js +0 -166
- package/dist/tools/publish-skill.js.map +0 -1
- package/dist/tools/remove-skill.d.ts +0 -3
- package/dist/tools/remove-skill.d.ts.map +0 -1
- package/dist/tools/remove-skill.js +0 -110
- package/dist/tools/remove-skill.js.map +0 -1
- package/dist/tools/scan-skill.d.ts +0 -3
- package/dist/tools/scan-skill.d.ts.map +0 -1
- package/dist/tools/scan-skill.js +0 -200
- package/dist/tools/scan-skill.js.map +0 -1
- package/dist/tools/search-skills.d.ts +0 -3
- package/dist/tools/search-skills.d.ts.map +0 -1
- package/dist/tools/search-skills.js +0 -54
- package/dist/tools/search-skills.js.map +0 -1
- package/dist/tools/skill-info.d.ts +0 -3
- package/dist/tools/skill-info.d.ts.map +0 -1
- package/dist/tools/skill-info.js +0 -88
- package/dist/tools/skill-info.js.map +0 -1
- package/dist/tools/skill-permissions.d.ts +0 -3
- package/dist/tools/skill-permissions.d.ts.map +0 -1
- package/dist/tools/skill-permissions.js +0 -311
- package/dist/tools/skill-permissions.js.map +0 -1
- package/dist/tools/unlink-skill.d.ts +0 -3
- package/dist/tools/unlink-skill.d.ts.map +0 -1
- package/dist/tools/unlink-skill.js +0 -72
- package/dist/tools/unlink-skill.js.map +0 -1
- package/dist/tools/update-skill.d.ts +0 -3
- package/dist/tools/update-skill.d.ts.map +0 -1
- package/dist/tools/update-skill.js +0 -317
- package/dist/tools/update-skill.js.map +0 -1
- package/dist/tools/verify-skills.d.ts +0 -3
- package/dist/tools/verify-skills.d.ts.map +0 -1
- package/dist/tools/verify-skills.js +0 -121
- package/dist/tools/verify-skills.js.map +0 -1
- package/dist/tools/whoami.d.ts +0 -3
- package/dist/tools/whoami.d.ts.map +0 -1
- package/dist/tools/whoami.js +0 -29
- 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
|
|
3
|
-
import { StdioServerTransport } from
|
|
4
|
-
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
48
|
-
|
|
2230
|
+
const transport = new StdioServerTransport();
|
|
2231
|
+
await server.connect(transport);
|
|
49
2232
|
}
|
|
50
2233
|
main().catch((error) => {
|
|
51
|
-
|
|
52
|
-
|
|
2234
|
+
console.error("MCP server error:", error);
|
|
2235
|
+
process.exit(1);
|
|
53
2236
|
});
|
|
2237
|
+
//#endregion
|
|
2238
|
+
export {};
|
|
2239
|
+
|
|
54
2240
|
//# sourceMappingURL=index.js.map
|