open source is social v-it.org
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 421 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'; 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}