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