open source is social v-it.org
at main 416 lines 16 kB view raw
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}