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