@tankpkg/cli 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,8 @@
1
+ import crypto from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
- import crypto from 'node:crypto';
4
4
  import os from 'node:os';
5
5
  import ora from 'ora';
6
- import { extract } from 'tar';
7
6
  import { resolve, LOCKFILE_VERSION } from '@tank/shared';
8
7
  import { getConfig } from '../lib/config.js';
9
8
  import { logger } from '../lib/logger.js';
@@ -11,241 +10,323 @@ import { prepareAgentSkillDir } from '../lib/frontmatter.js';
11
10
  import { linkSkillToAgents } from '../lib/linker.js';
12
11
  import { detectInstalledAgents, getGlobalSkillsDir, getGlobalAgentSkillsDir } from '../lib/agents.js';
13
12
  import { USER_AGENT } from '../version.js';
14
- const MAX_UNCOMPRESSED_SIZE = 100 * 1024 * 1024; // 100MB
15
- /**
16
- * Install a skill from the Tank registry.
17
- *
18
- * Flow:
19
- * 1. Read skills.json from directory (must exist)
20
- * 2. Fetch available versions
21
- * 3. Resolve best version using semver
22
- * 4. Check if already installed (skip if same version in lockfile)
23
- * 5. Fetch version metadata + download URL
24
- * 6. Check permission budget
25
- * 7. Download tarball
26
- * 8. Verify integrity (sha512)
27
- * 9. Extract tarball safely
28
- * 10. Update skills.json
29
- * 11. Update skills.lock
30
- */
31
- export async function installCommand(options) {
32
- const { name, versionRange = '*', directory = process.cwd(), configDir, global = false, homedir, } = options;
33
- const config = getConfig(configDir);
34
- const resolvedHome = homedir ?? os.homedir();
35
- const requestHeaders = { 'User-Agent': USER_AGENT };
36
- if (config.token) {
37
- requestHeaders.Authorization = `Bearer ${config.token}`;
38
- }
39
- // 1. Read or create skills.json
40
- const skillsJsonPath = path.join(directory, 'skills.json');
41
- let skillsJson = { skills: {} };
42
- if (!global) {
43
- if (!fs.existsSync(skillsJsonPath)) {
44
- skillsJson = { skills: {} };
45
- fs.writeFileSync(skillsJsonPath, JSON.stringify(skillsJson, null, 2) + '\n');
46
- logger.info('Created skills.json');
47
- }
48
- else {
13
+ import { resolveDependencyTree, buildSkillKey, } from '../lib/dependency-resolver.js';
14
+ import { checkPermissionBudget } from '../lib/permission-checker.js';
15
+ import { downloadAllParallel, extractSafely, getExtractDir, getGlobalExtractDir, getResolvedNodesInOrder, parseLockKey, parseVersionFromLockKey, readExtractedDependencies, verifyExtractedDependencies, writeLockfileWithResolvedGraph, } from '../lib/install-pipeline.js';
16
+ function createRegistryFetcher(registry, headers) {
17
+ const versionsCache = new Map();
18
+ const metadataCache = new Map();
19
+ return {
20
+ async fetchVersions(name) {
21
+ const cached = versionsCache.get(name);
22
+ if (cached) {
23
+ return cached;
24
+ }
25
+ const encoded = encodeURIComponent(name);
26
+ let res;
49
27
  try {
50
- const raw = fs.readFileSync(skillsJsonPath, 'utf-8');
51
- skillsJson = JSON.parse(raw);
28
+ res = await fetch(`${registry}/api/v1/skills/${encoded}/versions`, { headers });
52
29
  }
53
- catch {
54
- throw new Error('Failed to read or parse skills.json');
30
+ catch (err) {
31
+ throw new Error(`Network error fetching versions: ${err instanceof Error ? err.message : String(err)}`);
55
32
  }
56
- }
57
- }
58
- // Read existing lockfile if present
59
- const lockPath = global
60
- ? path.join(resolvedHome, '.tank', 'skills.lock')
61
- : path.join(directory, 'skills.lock');
62
- let lock = { lockfileVersion: LOCKFILE_VERSION, skills: {} };
63
- if (fs.existsSync(lockPath)) {
64
- try {
65
- const raw = fs.readFileSync(lockPath, 'utf-8');
66
- lock = JSON.parse(raw);
67
- }
68
- catch {
69
- // If lockfile is corrupt, start fresh
70
- lock = { lockfileVersion: LOCKFILE_VERSION, skills: {} };
71
- }
72
- }
73
- const spinner = ora('Resolving versions...').start();
74
- // 2. Fetch available versions
75
- const encodedName = encodeURIComponent(name);
76
- const versionsUrl = `${config.registry}/api/v1/skills/${encodedName}/versions`;
77
- let versionsRes;
33
+ if (!res.ok) {
34
+ if (res.status === 403)
35
+ throw new Error('Token lacks required scope: skills:read');
36
+ if (res.status === 404)
37
+ throw new Error(`Skill not found or no access: ${name}`);
38
+ const body = await res.json().catch(() => ({}));
39
+ throw new Error(body.error ?? res.statusText);
40
+ }
41
+ const data = await res.json();
42
+ versionsCache.set(name, data.versions);
43
+ return data.versions;
44
+ },
45
+ async fetchMetadata(name, version) {
46
+ const cacheKey = buildSkillKey(name, version);
47
+ const cached = metadataCache.get(cacheKey);
48
+ if (cached) {
49
+ return cached;
50
+ }
51
+ const encoded = encodeURIComponent(name);
52
+ let res;
53
+ try {
54
+ res = await fetch(`${registry}/api/v1/skills/${encoded}/${version}`, { headers });
55
+ }
56
+ catch (err) {
57
+ throw new Error(`Network error fetching metadata: ${err instanceof Error ? err.message : String(err)}`);
58
+ }
59
+ if (!res.ok) {
60
+ if (res.status === 403)
61
+ throw new Error('Token lacks required scope: skills:read');
62
+ if (res.status === 404)
63
+ throw new Error(`Skill not found or no access: ${name}@${version}`);
64
+ const body = await res.json().catch(() => ({}));
65
+ throw new Error(body.error ?? res.statusText);
66
+ }
67
+ const data = await res.json();
68
+ const normalized = {
69
+ ...data,
70
+ dependencies: data.dependencies ?? {},
71
+ };
72
+ metadataCache.set(cacheKey, normalized);
73
+ return normalized;
74
+ },
75
+ };
76
+ }
77
+ function readSkillsJson(skillsJsonPath) {
78
78
  try {
79
- versionsRes = await fetch(versionsUrl, {
80
- headers: requestHeaders,
81
- });
82
- }
83
- catch (err) {
84
- spinner.fail('Failed to fetch versions');
85
- throw new Error(`Network error fetching versions: ${err instanceof Error ? err.message : String(err)}`);
79
+ const raw = fs.readFileSync(skillsJsonPath, 'utf-8');
80
+ return JSON.parse(raw);
86
81
  }
87
- if (!versionsRes.ok) {
88
- spinner.fail('Failed to fetch versions');
89
- if (versionsRes.status === 403) {
90
- throw new Error('Token lacks required scope: skills:read');
91
- }
92
- if (versionsRes.status === 404) {
93
- throw new Error(`Skill not found or no access: ${name}`);
94
- }
95
- const body = await versionsRes.json().catch(() => ({}));
96
- throw new Error(body.error ?? versionsRes.statusText);
82
+ catch {
83
+ throw new Error('Failed to read or parse skills.json');
97
84
  }
98
- const versionsData = await versionsRes.json();
99
- const availableVersions = versionsData.versions.map((v) => v.version);
100
- // 3. Resolve best version
101
- const resolved = resolve(versionRange, availableVersions);
102
- if (!resolved) {
103
- spinner.fail('Version resolution failed');
104
- throw new Error(`No version of ${name} satisfies range "${versionRange}". Available: ${availableVersions.join(', ')}`);
85
+ }
86
+ function readOrCreateSkillsJson(skillsJsonPath) {
87
+ if (!fs.existsSync(skillsJsonPath)) {
88
+ const skillsJson = { skills: {} };
89
+ fs.writeFileSync(skillsJsonPath, JSON.stringify(skillsJson, null, 2) + '\n');
90
+ logger.info('Created skills.json');
91
+ return skillsJson;
105
92
  }
106
- // 4. Check if already installed
107
- const lockKey = `${name}@${resolved}`;
108
- if (lock.skills[lockKey]) {
109
- spinner.stop();
110
- logger.info(`${name}@${resolved} is already installed`);
111
- return;
93
+ return readSkillsJson(skillsJsonPath);
94
+ }
95
+ function readLockOrFresh(lockPath) {
96
+ if (!fs.existsSync(lockPath)) {
97
+ return { lockfileVersion: LOCKFILE_VERSION, skills: {} };
112
98
  }
113
- // 5. Fetch version metadata
114
- spinner.text = `Fetching ${name}@${resolved}...`;
115
- const metaUrl = `${config.registry}/api/v1/skills/${encodedName}/${resolved}`;
116
- let metaRes;
117
99
  try {
118
- metaRes = await fetch(metaUrl, {
119
- headers: requestHeaders,
120
- });
100
+ const raw = fs.readFileSync(lockPath, 'utf-8');
101
+ return JSON.parse(raw);
121
102
  }
122
- catch (err) {
123
- spinner.fail('Failed to fetch version metadata');
124
- throw new Error(`Network error fetching metadata: ${err instanceof Error ? err.message : String(err)}`);
103
+ catch {
104
+ return { lockfileVersion: LOCKFILE_VERSION, skills: {} };
105
+ }
106
+ }
107
+ function buildLockedVersionByName(lock) {
108
+ const lockedVersionByName = new Map();
109
+ for (const key of Object.keys(lock.skills)) {
110
+ lockedVersionByName.set(parseLockKey(key), parseVersionFromLockKey(key));
111
+ }
112
+ return lockedVersionByName;
113
+ }
114
+ function createExtractDirResolver(directory, global, resolvedHome) {
115
+ return (skillName) => (global ? getGlobalExtractDir(resolvedHome, skillName) : getExtractDir(directory, skillName));
116
+ }
117
+ function validateResolvedNodes(resolvedNodes, projectPermissions, auditMinScore) {
118
+ if (!projectPermissions) {
119
+ logger.warn('No permission budget defined in skills.json. Install proceeding without permission checks.');
125
120
  }
126
- if (!metaRes.ok) {
127
- spinner.fail('Failed to fetch version metadata');
128
- if (metaRes.status === 403) {
129
- throw new Error('Token lacks required scope: skills:read');
121
+ for (const node of resolvedNodes) {
122
+ if (projectPermissions) {
123
+ checkPermissionBudget(projectPermissions, node.meta.permissions, node.name);
130
124
  }
131
- if (metaRes.status === 404) {
132
- throw new Error(`Skill not found or no access: ${name}@${resolved}`);
125
+ if (auditMinScore !== undefined) {
126
+ if (node.meta.auditScore === null || node.meta.auditScore === undefined) {
127
+ logger.warn(`Audit score not yet available for ${node.name}. Install proceeding without audit score check.`);
128
+ }
129
+ else if (node.meta.auditScore < auditMinScore) {
130
+ throw new Error(`Audit score ${node.meta.auditScore} for ${node.name} is below minimum threshold ${auditMinScore} defined in skills.json`);
131
+ }
133
132
  }
134
- const body = await metaRes.json().catch(() => ({}));
135
- throw new Error(body.error ?? metaRes.statusText);
136
133
  }
137
- const metadata = await metaRes.json();
138
- // 6. Check permission budget
139
- const projectPermissions = global ? undefined : skillsJson.permissions;
140
- const skillPermissions = metadata.permissions;
141
- if (!global) {
142
- if (!projectPermissions) {
143
- logger.warn('No permission budget defined in skills.json. Install proceeding without permission checks.');
134
+ }
135
+ async function runLegacyFallback(options) {
136
+ const { rootSkillNames, resolvedNodeByName, extractDirForSkill, directory, configDir, global, homedir } = options;
137
+ for (const skillName of rootSkillNames) {
138
+ const node = resolvedNodeByName.get(skillName);
139
+ if (!node || Object.keys(node.meta.dependencies).length > 0) {
140
+ continue;
144
141
  }
145
- else {
146
- checkPermissionBudget(projectPermissions, skillPermissions, name);
142
+ const extractedDeps = readExtractedDependencies(extractDirForSkill(skillName));
143
+ for (const [depName, depRange] of Object.entries(extractedDeps)) {
144
+ if (depName === skillName) {
145
+ continue;
146
+ }
147
+ await installCommand({
148
+ name: depName,
149
+ versionRange: depRange,
150
+ directory,
151
+ configDir,
152
+ global,
153
+ homedir,
154
+ isTransitive: true,
155
+ });
147
156
  }
148
157
  }
149
- // 6.5. Check audit score threshold
150
- const auditMinScore = global ? undefined : skillsJson.audit?.min_score;
151
- if (!global && auditMinScore !== undefined) {
152
- if (metadata.auditScore === null || metadata.auditScore === undefined) {
153
- logger.warn(`Audit score not yet available for ${name}. Install proceeding without audit score check.`);
158
+ }
159
+ function linkInstalledRoots(options) {
160
+ const { rootSkillNames, resolvedNodeByName, extractDirForSkill, directory, global, resolvedHome, homedir } = options;
161
+ const agentSkillsBaseDir = global
162
+ ? getGlobalAgentSkillsDir(resolvedHome)
163
+ : path.join(directory, '.tank', 'agent-skills');
164
+ const linksDir = global ? path.join(resolvedHome, '.tank') : path.join(directory, '.tank');
165
+ for (const skillName of rootSkillNames) {
166
+ try {
167
+ const node = resolvedNodeByName.get(skillName);
168
+ if (!node) {
169
+ continue;
170
+ }
171
+ const agentSkillDir = prepareAgentSkillDir({
172
+ skillName,
173
+ extractDir: extractDirForSkill(skillName),
174
+ agentSkillsBaseDir,
175
+ description: node.meta.description,
176
+ });
177
+ const linkResult = linkSkillToAgents({
178
+ skillName,
179
+ sourceDir: agentSkillDir,
180
+ linksDir,
181
+ source: global ? 'global' : 'local',
182
+ homedir,
183
+ });
184
+ if (linkResult.linked.length > 0) {
185
+ logger.info(`Linked to ${linkResult.linked.length} agent(s)`);
186
+ }
187
+ if (linkResult.failed.length > 0) {
188
+ for (const failedLink of linkResult.failed) {
189
+ logger.warn(`Failed to link to ${failedLink.agentId}: ${failedLink.error}`);
190
+ }
191
+ }
154
192
  }
155
- else if (metadata.auditScore < auditMinScore) {
156
- throw new Error(`Audit score ${metadata.auditScore} for ${name} is below minimum threshold ${auditMinScore} defined in skills.json`);
193
+ catch {
194
+ if (rootSkillNames.length === 1) {
195
+ logger.warn('Agent linking skipped (non-fatal)');
196
+ }
197
+ else {
198
+ logger.warn(`Agent linking skipped for ${skillName} (non-fatal)`);
199
+ }
157
200
  }
158
201
  }
159
- // 7. Download tarball
160
- spinner.text = `Downloading ${name}@${resolved}...`;
161
- let downloadRes;
162
- try {
163
- downloadRes = await fetch(metadata.downloadUrl);
164
- }
165
- catch (err) {
166
- spinner.fail('Download failed');
167
- throw new Error(`Network error downloading tarball: ${err instanceof Error ? err.message : String(err)}`);
168
- }
169
- if (!downloadRes.ok) {
170
- spinner.fail('Download failed');
171
- throw new Error(`Failed to download tarball: ${downloadRes.status} ${downloadRes.statusText}`);
202
+ const detectedAgents = detectInstalledAgents(homedir);
203
+ if (detectedAgents.length === 0) {
204
+ logger.warn('No agents detected for linking');
172
205
  }
173
- const tarballBuffer = Buffer.from(await downloadRes.arrayBuffer());
174
- // 8. Verify integrity
175
- spinner.text = 'Verifying integrity...';
176
- const hash = crypto.createHash('sha512').update(tarballBuffer).digest('base64');
177
- const computedIntegrity = `sha512-${hash}`;
178
- if (computedIntegrity !== metadata.integrity) {
179
- spinner.fail('Integrity check failed');
180
- throw new Error(`Integrity mismatch for ${name}@${resolved}. Expected: ${metadata.integrity}, Got: ${computedIntegrity}`);
181
- }
182
- // 9. Extract tarball safely
183
- spinner.text = `Extracting ${name}@${resolved}...`;
184
- const extractDir = global
185
- ? getGlobalExtractDir(resolvedHome, name)
186
- : getExtractDir(directory, name);
187
- // Create extraction directory
188
- fs.mkdirSync(extractDir, { recursive: true });
189
- // Extract with safety checks
190
- await extractSafely(tarballBuffer, extractDir);
191
- // 10. Update skills.json
206
+ }
207
+ async function executeInstallPipeline(options) {
208
+ const { directory, configDir, global, homedir, resolvedHome, lock, lockPath, resolvedNodes, nodesToInstall, rootSkillNames, projectPermissions, auditMinScore, spinner, } = options;
192
209
  if (!global) {
193
- const skills = (skillsJson.skills ?? {});
194
- // Use the provided range if explicit, otherwise use ^{resolved}
195
- skills[name] = versionRange === '*' ? `^${resolved}` : versionRange;
196
- skillsJson.skills = skills;
197
- fs.writeFileSync(skillsJsonPath, JSON.stringify(skillsJson, null, 2) + '\n');
198
- }
199
- // 11. Update skills.lock
200
- lock.skills[lockKey] = {
201
- resolved: metadata.downloadUrl,
202
- integrity: computedIntegrity,
203
- permissions: skillPermissions ?? {},
204
- audit_score: metadata.auditScore ?? null,
205
- };
206
- // Sort keys alphabetically
207
- const sortedSkills = {};
208
- for (const key of Object.keys(lock.skills).sort()) {
209
- sortedSkills[key] = lock.skills[key];
210
- }
211
- lock.skills = sortedSkills;
210
+ validateResolvedNodes(resolvedNodes, projectPermissions, auditMinScore);
211
+ }
212
+ const extractDirForSkill = createExtractDirResolver(directory, global, resolvedHome);
213
+ const resolvedNodeByName = new Map(resolvedNodes.map((node) => [node.name, node]));
214
+ const downloaded = await downloadAllParallel(nodesToInstall, spinner);
215
+ for (const node of nodesToInstall) {
216
+ const payload = downloaded.get(node.name);
217
+ if (!payload) {
218
+ throw new Error(`Missing downloaded tarball for ${node.name}@${node.version}`);
219
+ }
220
+ spinner.text = `Extracting ${node.name}@${node.version}...`;
221
+ const extractDir = extractDirForSkill(node.name);
222
+ fs.mkdirSync(extractDir, { recursive: true });
223
+ await extractSafely(payload.buffer, extractDir);
224
+ verifyExtractedDependencies(extractDir, node);
225
+ }
226
+ lock.lockfileVersion = LOCKFILE_VERSION;
227
+ const updatedLock = writeLockfileWithResolvedGraph(lock, resolvedNodes, downloaded);
212
228
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
213
- fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
214
- // 12. Agent linking (always-on, failures are warnings)
229
+ fs.writeFileSync(lockPath, JSON.stringify(updatedLock, null, 2) + '\n');
230
+ await runLegacyFallback({
231
+ rootSkillNames,
232
+ resolvedNodeByName,
233
+ extractDirForSkill,
234
+ directory,
235
+ configDir,
236
+ global,
237
+ homedir,
238
+ });
239
+ linkInstalledRoots({
240
+ rootSkillNames,
241
+ resolvedNodeByName,
242
+ extractDirForSkill,
243
+ directory,
244
+ global,
245
+ resolvedHome,
246
+ homedir,
247
+ });
248
+ return updatedLock;
249
+ }
250
+ export async function installCommand(options) {
251
+ const { name, versionRange = '*', directory = process.cwd(), configDir, global = false, homedir, isTransitive = false, } = options;
252
+ const config = getConfig(configDir);
253
+ const resolvedHome = homedir ?? os.homedir();
254
+ const requestHeaders = { 'User-Agent': USER_AGENT };
255
+ if (config.token) {
256
+ requestHeaders.Authorization = `Bearer ${config.token}`;
257
+ }
258
+ const skillsJsonPath = path.join(directory, 'skills.json');
259
+ const skillsJson = global ? { skills: {} } : readOrCreateSkillsJson(skillsJsonPath);
260
+ const lockPath = global
261
+ ? path.join(resolvedHome, '.tank', 'skills.lock')
262
+ : path.join(directory, 'skills.lock');
263
+ const lock = readLockOrFresh(lockPath);
264
+ const spinner = ora('Resolving dependency graph...').start();
215
265
  try {
216
- const agentSkillsBaseDir = global
217
- ? getGlobalAgentSkillsDir(resolvedHome)
218
- : path.join(directory, '.tank', 'agent-skills');
219
- const agentSkillDir = prepareAgentSkillDir({
220
- skillName: name,
221
- extractDir,
222
- agentSkillsBaseDir,
223
- description: metadata.description,
224
- });
225
- const linkResult = linkSkillToAgents({
226
- skillName: name,
227
- sourceDir: agentSkillDir,
228
- linksDir: global ? path.join(resolvedHome, '.tank') : path.join(directory, '.tank'),
229
- source: global ? 'global' : 'local',
230
- homedir: options.homedir,
231
- });
232
- const detectedAgents = detectInstalledAgents(options.homedir);
233
- if (detectedAgents.length === 0) {
234
- logger.warn('No agents detected for linking');
266
+ const fetcher = createRegistryFetcher(config.registry, requestHeaders);
267
+ const requestedVersions = await fetcher.fetchVersions(name);
268
+ const requestedAvailableVersions = requestedVersions.map((versionInfo) => versionInfo.version);
269
+ const requestedResolvedVersion = resolve(versionRange, requestedAvailableVersions);
270
+ if (!requestedResolvedVersion) {
271
+ throw new Error(`No version of ${name} satisfies range "${versionRange}". Available: ${requestedAvailableVersions.join(', ')}`);
235
272
  }
236
- if (linkResult.linked.length > 0) {
237
- logger.info(`Linked to ${linkResult.linked.length} agent(s)`);
273
+ const requestedLockKey = buildSkillKey(name, requestedResolvedVersion);
274
+ if (lock.skills[requestedLockKey]) {
275
+ logger.info(`${name}@${requestedResolvedVersion} is already installed`);
276
+ spinner.succeed(`${name}@${requestedResolvedVersion} is already installed`);
277
+ return;
238
278
  }
239
- if (linkResult.failed.length > 0) {
240
- for (const f of linkResult.failed) {
241
- logger.warn(`Failed to link to ${f.agentId}: ${f.error}`);
279
+ const rootDependencies = {};
280
+ if (!global && !isTransitive) {
281
+ const existingSkills = (skillsJson.skills ?? {});
282
+ const lockedVersionByName = buildLockedVersionByName(lock);
283
+ for (const [skillName, range] of Object.entries(existingSkills)) {
284
+ if (typeof range !== 'string') {
285
+ continue;
286
+ }
287
+ rootDependencies[skillName] = lockedVersionByName.get(skillName) ?? range;
242
288
  }
243
289
  }
290
+ rootDependencies[name] = versionRange;
291
+ const resolvedGraph = await resolveDependencyTree(rootDependencies, fetcher);
292
+ const resolvedNodes = getResolvedNodesInOrder(resolvedGraph.nodes, resolvedGraph.installOrder);
293
+ const rootNode = resolvedGraph.nodes.get(name);
294
+ if (!rootNode) {
295
+ throw new Error(`Failed to resolve requested skill: ${name}`);
296
+ }
297
+ const nodesToInstall = resolvedNodes.filter((node) => {
298
+ const lockKey = buildSkillKey(node.name, node.version);
299
+ return !lock.skills[lockKey];
300
+ });
301
+ const projectPermissions = global ? undefined : skillsJson.permissions;
302
+ const auditMinScore = global ? undefined : skillsJson.audit?.min_score;
303
+ await executeInstallPipeline({
304
+ directory,
305
+ configDir,
306
+ global,
307
+ homedir,
308
+ resolvedHome,
309
+ lock,
310
+ lockPath,
311
+ resolvedNodes,
312
+ nodesToInstall,
313
+ rootSkillNames: [name],
314
+ projectPermissions,
315
+ auditMinScore,
316
+ spinner,
317
+ });
318
+ if (!global && !isTransitive) {
319
+ const skills = (skillsJson.skills ?? {});
320
+ skills[name] = versionRange === '*' ? `^${rootNode.version}` : versionRange;
321
+ skillsJson.skills = skills;
322
+ fs.writeFileSync(skillsJsonPath, JSON.stringify(skillsJson, null, 2) + '\n');
323
+ }
324
+ spinner.succeed(`Installed ${name}@${rootNode.version}`);
244
325
  }
245
- catch {
246
- logger.warn('Agent linking skipped (non-fatal)');
326
+ catch (err) {
327
+ spinner.fail('Install failed');
328
+ throw err;
247
329
  }
248
- spinner.succeed(`Installed ${name}@${resolved}`);
249
330
  }
250
331
  export async function installFromLockfile(options) {
251
332
  const { directory = process.cwd(), configDir, global = false, homedir } = options;
@@ -283,7 +364,6 @@ export async function installFromLockfile(options) {
283
364
  const skillName = parseLockKey(key);
284
365
  const version = parseVersionFromLockKey(key);
285
366
  spinner.text = `Installing ${key}...`;
286
- // Fetch metadata from API to record download and get fresh signed URL
287
367
  const encodedName = encodeURIComponent(skillName);
288
368
  const metaUrl = `${config.registry}/api/v1/skills/${encodedName}/${version}`;
289
369
  let metaRes;
@@ -309,8 +389,7 @@ export async function installFromLockfile(options) {
309
389
  throw new Error(`Failed to download ${key}: ${downloadRes.status} ${downloadRes.statusText}`);
310
390
  }
311
391
  const tarballBuffer = Buffer.from(await downloadRes.arrayBuffer());
312
- const hash = crypto.createHash('sha512').update(tarballBuffer).digest('base64');
313
- const computedIntegrity = `sha512-${hash}`;
392
+ const computedIntegrity = buildIntegrity(tarballBuffer);
314
393
  if (computedIntegrity !== entry.integrity) {
315
394
  throw new Error(`Integrity mismatch for ${key}. Expected: ${entry.integrity}, Got: ${computedIntegrity}`);
316
395
  }
@@ -345,8 +424,8 @@ export async function installFromLockfile(options) {
345
424
  logger.info(`Linked to ${linkResult.linked.length} agent(s)`);
346
425
  }
347
426
  if (linkResult.failed.length > 0) {
348
- for (const f of linkResult.failed) {
349
- logger.warn(`Failed to link to ${f.agentId}: ${f.error}`);
427
+ for (const failedLink of linkResult.failed) {
428
+ logger.warn(`Failed to link to ${failedLink.agentId}: ${failedLink.error}`);
350
429
  }
351
430
  }
352
431
  }
@@ -368,6 +447,11 @@ export async function installFromLockfile(options) {
368
447
  export async function installAll(options) {
369
448
  const { directory = process.cwd(), configDir, global = false, homedir } = options;
370
449
  const resolvedHome = homedir ?? os.homedir();
450
+ const config = getConfig(configDir);
451
+ const requestHeaders = { 'User-Agent': USER_AGENT };
452
+ if (config.token) {
453
+ requestHeaders.Authorization = `Bearer ${config.token}`;
454
+ }
371
455
  const lockPath = global
372
456
  ? path.join(resolvedHome, '.tank', 'skills.lock')
373
457
  : path.join(directory, 'skills.lock');
@@ -383,168 +467,51 @@ export async function installAll(options) {
383
467
  logger.info('No skills.json found — nothing to install');
384
468
  return;
385
469
  }
386
- let skillsJson;
387
- try {
388
- const raw = fs.readFileSync(skillsJsonPath, 'utf-8');
389
- skillsJson = JSON.parse(raw);
390
- }
391
- catch {
392
- throw new Error('Failed to read or parse skills.json');
393
- }
470
+ const skillsJson = readSkillsJson(skillsJsonPath);
394
471
  const skills = (skillsJson.skills ?? {});
395
472
  const skillEntries = Object.entries(skills);
396
473
  if (skillEntries.length === 0) {
397
474
  logger.info('No skills defined in skills.json');
398
475
  return;
399
476
  }
400
- for (const [name, versionRange] of skillEntries) {
401
- await installCommand({ name, versionRange, directory, configDir, global, homedir });
402
- }
403
- }
404
- function parseLockKey(key) {
405
- const lastAt = key.lastIndexOf('@');
406
- if (lastAt <= 0) {
407
- throw new Error(`Invalid lockfile key: ${key}`);
408
- }
409
- return key.slice(0, lastAt);
410
- }
411
- function parseVersionFromLockKey(key) {
412
- const lastAt = key.lastIndexOf('@');
413
- if (lastAt <= 0 || lastAt === key.length - 1) {
414
- throw new Error(`Invalid lockfile key: ${key}`);
415
- }
416
- return key.slice(lastAt + 1);
417
- }
418
- function getExtractDir(projectDir, skillName) {
419
- if (skillName.startsWith('@')) {
420
- const [scope, name] = skillName.split('/');
421
- return path.join(projectDir, '.tank', 'skills', scope, name);
422
- }
423
- return path.join(projectDir, '.tank', 'skills', skillName);
424
- }
425
- function getGlobalExtractDir(homedir, skillName) {
426
- const globalDir = path.join(homedir, '.tank', 'skills');
427
- if (skillName.startsWith('@')) {
428
- const [scope, name] = skillName.split('/');
429
- return path.join(globalDir, scope, name);
430
- }
431
- return path.join(globalDir, skillName);
432
- }
433
- /**
434
- * Extract a tarball safely with security checks.
435
- * Rejects: absolute paths, path traversal (..), symlinks/hardlinks.
436
- * Enforces max uncompressed size.
437
- */
438
- async function extractSafely(tarball, destDir) {
439
- // Write tarball to a temp file for extraction
440
- const tmpTarball = path.join(destDir, '.tmp-tarball.tgz');
441
- fs.writeFileSync(tmpTarball, tarball);
477
+ const spinner = ora('Resolving dependency graph...').start();
442
478
  try {
443
- await extract({
444
- file: tmpTarball,
445
- cwd: destDir,
446
- // Safety: reject entries that try to escape the extraction directory
447
- filter: (entryPath) => {
448
- // Reject absolute paths
449
- if (path.isAbsolute(entryPath)) {
450
- throw new Error(`Absolute path in tarball: ${entryPath}`);
451
- }
452
- // Reject path traversal
453
- if (entryPath.split('/').includes('..') || entryPath.split(path.sep).includes('..')) {
454
- throw new Error(`Path traversal in tarball: ${entryPath}`);
455
- }
456
- return true;
457
- },
458
- onReadEntry: (entry) => {
459
- // Reject symlinks and hardlinks
460
- if (entry.type === 'SymbolicLink' || entry.type === 'Link') {
461
- throw new Error(`Symlink/hardlink in tarball: ${entry.path}`);
462
- }
463
- },
464
- });
465
- }
466
- finally {
467
- // Clean up temp tarball
468
- if (fs.existsSync(tmpTarball)) {
469
- fs.unlinkSync(tmpTarball);
470
- }
471
- }
472
- }
473
- /**
474
- * Check if a skill's permissions fit within the project's permission budget.
475
- * Throws if any permission exceeds the budget.
476
- */
477
- function checkPermissionBudget(budget, skillPerms, skillName) {
478
- if (!skillPerms)
479
- return;
480
- // Check subprocess
481
- if (skillPerms.subprocess === true && budget.subprocess !== true) {
482
- throw new Error(`Permission denied: ${skillName} requires subprocess access, but project budget does not allow it`);
483
- }
484
- // Check network outbound
485
- if (skillPerms.network?.outbound && skillPerms.network.outbound.length > 0) {
486
- const budgetDomains = budget.network?.outbound ?? [];
487
- for (const domain of skillPerms.network.outbound) {
488
- if (!isDomainAllowed(domain, budgetDomains)) {
489
- throw new Error(`Permission denied: ${skillName} requests network access to "${domain}", which is not in the project's permission budget`);
490
- }
491
- }
492
- }
493
- // Check filesystem read
494
- if (skillPerms.filesystem?.read && skillPerms.filesystem.read.length > 0) {
495
- const budgetPaths = budget.filesystem?.read ?? [];
496
- for (const p of skillPerms.filesystem.read) {
497
- if (!isPathAllowed(p, budgetPaths)) {
498
- throw new Error(`Permission denied: ${skillName} requests filesystem read access to "${p}", which is not in the project's permission budget`);
499
- }
500
- }
501
- }
502
- // Check filesystem write
503
- if (skillPerms.filesystem?.write && skillPerms.filesystem.write.length > 0) {
504
- const budgetPaths = budget.filesystem?.write ?? [];
505
- for (const p of skillPerms.filesystem.write) {
506
- if (!isPathAllowed(p, budgetPaths)) {
507
- throw new Error(`Permission denied: ${skillName} requests filesystem write access to "${p}", which is not in the project's permission budget`);
479
+ const rootDependencies = {};
480
+ for (const [skillName, range] of skillEntries) {
481
+ if (typeof range === 'string') {
482
+ rootDependencies[skillName] = range;
508
483
  }
509
484
  }
485
+ const fetcher = createRegistryFetcher(config.registry, requestHeaders);
486
+ const resolvedGraph = await resolveDependencyTree(rootDependencies, fetcher);
487
+ const resolvedNodes = getResolvedNodesInOrder(resolvedGraph.nodes, resolvedGraph.installOrder);
488
+ const lock = { lockfileVersion: LOCKFILE_VERSION, skills: {} };
489
+ const projectPermissions = skillsJson.permissions;
490
+ const auditMinScore = skillsJson.audit?.min_score;
491
+ await executeInstallPipeline({
492
+ directory,
493
+ configDir,
494
+ global,
495
+ homedir,
496
+ resolvedHome,
497
+ lock,
498
+ lockPath,
499
+ resolvedNodes,
500
+ nodesToInstall: resolvedNodes,
501
+ rootSkillNames: skillEntries.map(([skillName]) => skillName),
502
+ projectPermissions,
503
+ auditMinScore,
504
+ spinner,
505
+ });
506
+ spinner.succeed(`Installed ${skillEntries.length} root skill${skillEntries.length === 1 ? '' : 's'}`);
510
507
  }
511
- }
512
- /**
513
- * Check if a domain is allowed by the budget's domain list.
514
- * Supports wildcard matching: *.example.com matches sub.example.com
515
- */
516
- function isDomainAllowed(domain, allowedDomains) {
517
- for (const allowed of allowedDomains) {
518
- if (allowed === domain)
519
- return true;
520
- // Wildcard matching: *.example.com
521
- if (allowed.startsWith('*.')) {
522
- const suffix = allowed.slice(1); // .example.com
523
- if (domain.endsWith(suffix) || domain === allowed.slice(2)) {
524
- return true;
525
- }
526
- // Also match if the skill requests the same wildcard pattern
527
- if (domain === allowed)
528
- return true;
529
- }
508
+ catch (err) {
509
+ spinner.fail('Install failed');
510
+ throw err;
530
511
  }
531
- return false;
532
512
  }
533
- /**
534
- * Check if a path is allowed by the budget's path list.
535
- * Simple subset check: skill path must match one of the budget paths.
536
- */
537
- function isPathAllowed(requestedPath, allowedPaths) {
538
- for (const allowed of allowedPaths) {
539
- if (allowed === requestedPath)
540
- return true;
541
- // If budget allows ./src/** and skill requests ./src/foo, it's allowed
542
- if (allowed.endsWith('/**')) {
543
- const prefix = allowed.slice(0, -3); // ./src
544
- if (requestedPath.startsWith(prefix))
545
- return true;
546
- }
547
- }
548
- return false;
513
+ function buildIntegrity(buffer) {
514
+ const hash = crypto.createHash('sha512').update(buffer).digest('base64');
515
+ return `sha512-${hash}`;
549
516
  }
550
517
  //# sourceMappingURL=install.js.map