open source is social v-it.org
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 sol pbc
3
4import { spawnSync } from 'node:child_process';
5import { existsSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
6import { join, dirname } from 'node:path';
7import { homedir, tmpdir } from 'node:os';
8import { requireDid } from '../lib/config.js';
9import { SKILL_COLLECTION } from '../lib/constants.js';
10import { restoreAgent } from '../lib/oauth.js';
11import { readFollowing, readLog, appendLog, vitDir } from '../lib/vit-dir.js';
12import { requireAgent, detectCodingAgent } from '../lib/agent.js';
13import { shouldBypassVet } from '../lib/trust-gate.js';
14import { isSkillRef, nameFromSkillRef, isValidSkillRef, isValidSkillName } from '../lib/skill-ref.js';
15import { mark, name } from '../lib/brand.js';
16import { resolvePds, resolveHandle, listRecordsFromPds, batchQuery } from '../lib/pds.js';
17import { loadConfig } from '../lib/config.js';
18import { jsonOk, jsonError } from '../lib/json-output.js';
19import { errorMessage, formatError } from '../lib/error-format.js';
20
21async function installSkill({ match, skillName, isGlobal, opts, ref }) {
22 const { verbose } = opts;
23 const vlog = opts.json ? (...a) => console.error(...a) : console.log;
24 const record = match.value;
25
26 const tempDir = mkdtempSync(join(tmpdir(), 'vit-learn-'));
27 try {
28 writeFileSync(join(tempDir, 'SKILL.md'), record.text);
29 if (verbose) vlog('[verbose] wrote SKILL.md to temp dir');
30
31 if (record.resources && record.resources.length > 0) {
32 const authorDid = match.uri.split('/')[2];
33 const pds = await resolvePds(authorDid);
34
35 for (const resource of record.resources) {
36 const resourcePath = join(tempDir, resource.path);
37 mkdirSync(dirname(resourcePath), { recursive: true });
38
39 try {
40 const blobCid = resource.blob?.ref?.$link || resource.blob?.cid;
41 if (blobCid) {
42 const blobUrl = new URL('/xrpc/com.atproto.sync.getBlob', pds);
43 blobUrl.searchParams.set('did', authorDid);
44 blobUrl.searchParams.set('cid', blobCid);
45 const blobRes = await fetch(blobUrl);
46 if (!blobRes.ok) throw new Error(`blob fetch failed: ${blobRes.status}`);
47 const blobData = Buffer.from(await blobRes.arrayBuffer());
48 writeFileSync(resourcePath, blobData);
49 if (verbose) vlog(`[verbose] wrote resource: ${resource.path}`);
50 }
51 } catch (err) {
52 console.error(`warning: failed to download resource ${resource.path}: ${err.message}`);
53 }
54 }
55 }
56
57 const addArgs = ['--yes', 'skills', 'add', tempDir, '-a', 'claude-code', '-y'];
58 if (isGlobal) addArgs.push('-g');
59 const addResult = spawnSync('npx', addArgs, {
60 encoding: 'utf-8',
61 stdio: ['pipe', 'pipe', 'pipe'],
62 env: { ...process.env, CI: 'true' },
63 });
64 if (addResult.status !== 0) {
65 const errText = (addResult.stderr || addResult.stdout || '').trim();
66 throw new Error(`skill install failed: ${errText || 'unknown error'}`);
67 }
68 if (verbose) vlog('[verbose] installed via npx skills add');
69 } finally {
70 try {
71 rmSync(tempDir, { recursive: true, force: true });
72 } catch (err) {
73 console.warn(`warning: failed to remove temporary directory ${tempDir}: ${errorMessage(err)}`);
74 }
75 }
76
77 const installDir = isGlobal
78 ? join(homedir(), '.claude', 'skills', skillName)
79 : join(process.cwd(), '.claude', 'skills', skillName);
80
81 if (existsSync(vitDir())) {
82 try {
83 appendLog('learned.jsonl', {
84 ref,
85 name: skillName,
86 uri: match.uri,
87 cid: match.cid,
88 installedTo: installDir,
89 scope: isGlobal ? 'user' : 'project',
90 learnedAt: new Date().toISOString(),
91 version: record.version || null,
92 });
93 } catch (logErr) {
94 console.error('warning: failed to write learned.jsonl:', logErr.message);
95 }
96 }
97
98 const scope = isGlobal ? 'user' : 'project';
99 if (opts.json) {
100 jsonOk({ ref, name: skillName, installedTo: installDir, scope, version: record.version || null });
101 return;
102 }
103 console.log(`${mark} learned: ${ref} (${scope})`);
104 console.log(`installed to: ${installDir}`);
105 if (record.version) console.log(`version: ${record.version}`);
106}
107
108async function learnFromHandle(ref, opts) {
109 const { verbose } = opts;
110 const vlog = opts.json ? (...a) => console.error(...a) : console.log;
111
112 let raw = ref.slice(1);
113 let projectLocal = false;
114 if (raw.endsWith('.')) {
115 projectLocal = true;
116 raw = raw.slice(0, -1);
117 }
118
119 const slashIdx = raw.indexOf('/');
120 if (slashIdx === -1 || slashIdx === 0 || slashIdx === raw.length - 1) {
121 if (opts.json) {
122 jsonError('invalid ref', 'expected format: @handle/skill-name');
123 return;
124 }
125 console.error('invalid ref. expected format: @handle/skill-name');
126 process.exitCode = 1;
127 return;
128 }
129
130 const handle = raw.slice(0, slashIdx);
131 const skillName = raw.slice(slashIdx + 1);
132
133 if (!handle.includes('.')) {
134 if (opts.json) {
135 jsonError('invalid handle', 'handle must be a domain name (e.g. alice.bsky.social)');
136 return;
137 }
138 console.error('invalid handle. must be a domain name (e.g. alice.bsky.social)');
139 process.exitCode = 1;
140 return;
141 }
142
143 if (!isValidSkillName(skillName)) {
144 if (opts.json) {
145 jsonError('invalid skill name', 'lowercase letters, numbers, hyphens only');
146 return;
147 }
148 console.error('invalid skill name. lowercase letters, numbers, hyphens only.');
149 console.error('no leading hyphen, no consecutive hyphens, max 64 chars.');
150 process.exitCode = 1;
151 return;
152 }
153
154 if (verbose) vlog(`[verbose] handle: ${handle}, skill: ${skillName}`);
155
156 const did = await resolveHandle(handle);
157 if (verbose) vlog(`[verbose] resolved DID: ${did}`);
158
159 const pds = await resolvePds(did);
160 if (verbose) vlog(`[verbose] resolved PDS: ${pds}`);
161
162 const { records } = await listRecordsFromPds(pds, did, SKILL_COLLECTION, 50);
163
164 let match = null;
165 for (const rec of records) {
166 if (rec.value.name === skillName) {
167 if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) {
168 match = rec;
169 }
170 }
171 }
172
173 if (!match) {
174 const msg = `no skill '${skillName}' found from @${handle}`;
175 if (opts.json) {
176 jsonError(msg);
177 return;
178 }
179 console.error(msg);
180 process.exitCode = 1;
181 return;
182 }
183
184 if (verbose) vlog(`[verbose] found skill: ${match.value.name} from ${match.uri}`);
185
186 if (opts.dryRun) {
187 const record = match.value;
188 if (opts.json) {
189 jsonOk({
190 name: record.name,
191 author: handle,
192 did,
193 description: record.description || null,
194 version: record.version || null,
195 tags: record.tags || [],
196 resources: (record.resources || []).map(r => r.path),
197 text: record.text,
198 });
199 return;
200 }
201 console.log(`name: ${record.name}`);
202 console.log(`author: @${handle} (${did})`);
203 if (record.description) console.log(`description: ${record.description}`);
204 if (record.version) console.log(`version: ${record.version}`);
205 if (record.tags?.length) console.log(`tags: ${record.tags.join(', ')}`);
206 if (record.resources?.length) console.log(`resources: ${record.resources.map(r => r.path).join(', ')}`);
207 console.log('');
208 console.log('--- SKILL.md ---');
209 console.log(record.text);
210 return;
211 }
212
213 const isGlobal = !(projectLocal || opts.project);
214 await installSkill({ match, skillName, isGlobal, opts, ref });
215}
216
217export default function register(program) {
218 program
219 .command('learn')
220 .argument('<ref>', 'Skill reference: @handle/name or skill-{name}')
221 .description('Install a skill from the network')
222 .option('--did <did>', 'DID to use (skill-{name} path only)')
223 .option('--user', 'Install to user-wide ~/.claude/skills/ (skill-{name} path, requires vet)')
224 .option('--project', 'Install to project .claude/skills/ (@handle/ path)')
225 .option('--dry-run', 'Show skill contents without installing')
226 .option('--json', 'Output as JSON')
227 .option('-v, --verbose', 'Show step-by-step details')
228 .addHelpText('after', `
229Examples:
230 vit learn @solpbc.org/using-vit Install from publisher (user-wide)
231 vit learn @solpbc.org/using-vit. Install from publisher (project-local)
232 vit learn @solpbc.org/using-vit --project Same as trailing dot
233 vit learn @solpbc.org/using-vit --dry-run Inspect without installing
234 vit learn skill-agent-test-patterns Install from followed accounts (project-local)
235 vit learn skill-agent-test-patterns --user Install from followed (user-wide, requires vet)
236`)
237 .action(async (ref, opts) => {
238 try {
239 if (ref.startsWith('@')) {
240 await learnFromHandle(ref, opts);
241 return;
242 }
243
244 const gate = requireAgent();
245 if (!gate.ok) {
246 if (opts.json) {
247 jsonError('agent required', 'run vit learn from a coding agent');
248 return;
249 }
250 console.error(`${name} learn should be run by a coding agent (e.g. claude code, gemini cli).`);
251 console.error(`open your agent and ask it to run '${name} learn' for you.`);
252 process.exitCode = 1;
253 return;
254 }
255
256 const { verbose } = opts;
257 const vlog = opts.json ? (...a) => console.error(...a) : console.log;
258
259 if (!isSkillRef(ref)) {
260 if (opts.json) {
261 jsonError('invalid skill ref', 'expected format: skill-{name}');
262 return;
263 }
264 console.error(`invalid skill ref. expected format: skill-{name} (e.g. skill-agent-test-patterns)`);
265 process.exitCode = 1;
266 return;
267 }
268
269 if (!isValidSkillRef(ref)) {
270 if (opts.json) {
271 jsonError('invalid skill ref', 'lowercase letters, numbers, hyphens only');
272 return;
273 }
274 console.error('invalid skill ref. name must be lowercase letters, numbers, hyphens only.');
275 console.error('no leading hyphen, no consecutive hyphens, max 64 chars.');
276 process.exitCode = 1;
277 return;
278 }
279
280 const skillName = nameFromSkillRef(ref);
281 if (verbose) vlog(`[verbose] skill name: ${skillName}`);
282
283 // Trust gate
284 const isUserInstall = !!opts.user;
285 const trusted = readLog('trusted.jsonl');
286 const trustedEntry = trusted.find(e => e.ref === ref);
287
288 if (isUserInstall && !trustedEntry) {
289 // --user ALWAYS requires vet
290 if (opts.json) {
291 jsonError(`skill '${ref}' is not yet vetted`, 'user-wide install requires vetting');
292 return;
293 }
294 console.error(`skill '${ref}' is not yet vetted. user-wide install requires vetting.`);
295 console.error(`tell your operator to vet it first:`);
296 console.error('');
297 console.error(` vit vet ${ref}`);
298 console.error('');
299 console.error('after reviewing, they can trust it with:');
300 console.error('');
301 console.error(` vit vet ${ref} --trust`);
302 process.exitCode = 1;
303 return;
304 }
305
306 if (!isUserInstall && !trustedEntry) {
307 // Project-level: requires vet UNLESS dangerous-accept
308 const trustGate = shouldBypassVet();
309 if (!trustGate.bypass) {
310 if (opts.json) {
311 jsonError(`skill '${ref}' is not yet vetted`, `run 'vit vet ${ref}' first`);
312 return;
313 }
314 console.error(`skill '${ref}' is not yet vetted.`);
315 console.error(`tell your operator to vet it first:`);
316 console.error('');
317 console.error(` vit vet ${ref}`);
318 console.error('');
319 console.error('after reviewing, they can trust it with:');
320 console.error('');
321 console.error(` vit vet ${ref} --trust`);
322 if (detectCodingAgent()) {
323 console.error('');
324 console.error('or, to trust all items without review:');
325 console.error('');
326 console.error(' vit vet --dangerous-accept --confirm');
327 }
328 process.exitCode = 1;
329 return;
330 }
331 if (verbose) vlog(`[verbose] vet gate bypassed: ${trustGate.reason}`);
332 }
333
334 if (opts.json && !(opts.did || loadConfig().did)) {
335 jsonError('no DID configured', "run 'vit login <handle>' first");
336 return;
337 }
338 const did = requireDid(opts);
339 if (!did) return;
340 if (verbose) vlog(`[verbose] DID: ${did}`);
341
342 const { agent } = await restoreAgent(did);
343 if (verbose) vlog('[verbose] session restored');
344
345 // Build DID list from following + self
346 const following = readFollowing();
347 const dids = following.map(e => e.did);
348 dids.push(did);
349
350 // Fetch skills from each DID, find matching ref
351 const allRecords = await batchQuery(dids, async (repoDid) => {
352 const pds = await resolvePds(repoDid);
353 if (verbose) vlog(`[verbose] ${repoDid}: resolved PDS ${pds}`);
354 return (await listRecordsFromPds(pds, repoDid, SKILL_COLLECTION, 50)).records;
355 }, { verbose });
356
357 let match = null;
358 for (const records of allRecords) {
359 for (const rec of records) {
360 const recName = rec.value.name;
361 if (recName === skillName) {
362 if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) {
363 match = rec;
364 }
365 }
366 }
367 }
368
369 if (!match) {
370 if (opts.json) {
371 jsonError(`no skill found with ref '${ref}'`);
372 return;
373 }
374 console.error(`no skill found with ref '${ref}' from followed accounts.`);
375 console.error('');
376 console.error('hint: skills appear from accounts you follow and your own.');
377 console.error(` vit following check who you're following`);
378 console.error(` vit explore skills browse skills network-wide`);
379 process.exitCode = 1;
380 return;
381 }
382
383 const record = match.value;
384 if (verbose) vlog(`[verbose] found skill: ${record.name} from ${match.uri}`);
385
386 if (opts.dryRun) {
387 if (opts.json) {
388 jsonOk({
389 name: record.name,
390 author: match.uri.split('/')[2],
391 description: record.description || null,
392 version: record.version || null,
393 tags: record.tags || [],
394 resources: (record.resources || []).map(r => r.path),
395 text: record.text,
396 });
397 return;
398 }
399 console.log(`name: ${record.name}`);
400 console.log(`author: ${match.uri.split('/')[2]}`);
401 if (record.description) console.log(`description: ${record.description}`);
402 if (record.version) console.log(`version: ${record.version}`);
403 if (record.tags?.length) console.log(`tags: ${record.tags.join(', ')}`);
404 if (record.resources?.length) console.log(`resources: ${record.resources.map(r => r.path).join(', ')}`);
405 console.log('');
406 console.log('--- SKILL.md ---');
407 console.log(record.text);
408 return;
409 }
410
411 await installSkill({ match, skillName, isGlobal: !!opts.user, opts, ref });
412 } catch (err) {
413 if (opts.json) {
414 jsonError(err);
415 return;
416 }
417 console.error(formatError(err, { verbose: opts.verbose }));
418 process.exitCode = 1;
419 }
420 });
421}