open source is social v-it.org
0
fork

Configure Feed

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

implement vit skills CLI: ship --skill, learn, skim/vet/vouch/scan/doctor updates

- ship --skill: reads SKILL.md verbatim as text field, parses frontmatter for
name/description/version/license/compatibility, uploads resources as blobs,
publishes org.v-it.skill record with ref = skill-{name}
- learn: fetches skill from ATProto, writes text directly as SKILL.md (no
reconstruction), downloads resource blobs, installs to .claude/skills/{name}/
or ~/.claude/skills/{name}/ (--user). Trust gate: requires vet unless
skip-perms + project-level. --user always requires vet.
- skim: queries both org.v-it.cap and org.v-it.skill from followed accounts.
Caps filtered by beacon, skills unfiltered. Type labels in output. --caps
and --skills filter flags.
- vet: detects skill- prefix, queries org.v-it.skill collection, shows full
skill content + resource listing for review
- vouch: detects skill- prefix, creates vouch with no beacon field for skills
- scan: --skills flag queries Jetstream for org.v-it.skill events, shows
skill publishers/counts/tags, supports --tag filter
- doctor: reports installed skills (project + user) with version info

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+1189 -353
+2
src/cli.js
··· 8 8 import registerConfig from './cmd/config.js'; 9 9 import registerDoctor from './cmd/doctor.js'; 10 10 import registerInit from './cmd/init.js'; 11 + import registerLearn from './cmd/learn.js'; 11 12 import registerLogin from './cmd/login.js'; 12 13 import registerFirehose from './cmd/firehose.js'; 13 14 import registerScan from './cmd/scan.js'; ··· 32 33 registerConfig(program); 33 34 registerDoctor(program); 34 35 registerInit(program); 36 + registerLearn(program); 35 37 registerLogin(program); 36 38 registerFirehose(program); 37 39 registerScan(program);
+48 -1
src/cmd/doctor.js
··· 4 4 import { loadConfig } from '../lib/config.js'; 5 5 import { restoreAgent } from '../lib/oauth.js'; 6 6 import { readProjectConfig } from '../lib/vit-dir.js'; 7 - import { existsSync, lstatSync } from 'node:fs'; 7 + import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs'; 8 8 import { join } from 'node:path'; 9 + import { homedir } from 'node:os'; 9 10 import { mark, name } from '../lib/brand.js'; 10 11 import { which } from '../lib/compat.js'; 11 12 13 + function scanSkillDir(dir) { 14 + const skills = []; 15 + if (!existsSync(dir)) return skills; 16 + try { 17 + const entries = readdirSync(dir, { withFileTypes: true }); 18 + for (const entry of entries) { 19 + if (!entry.isDirectory()) continue; 20 + const skillMd = join(dir, entry.name, 'SKILL.md'); 21 + if (existsSync(skillMd)) { 22 + let version = null; 23 + try { 24 + const content = readFileSync(skillMd, 'utf-8'); 25 + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); 26 + if (match) { 27 + const versionMatch = match[1].match(/^version:\s*(.+)$/m); 28 + if (versionMatch) version = versionMatch[1].trim(); 29 + } 30 + } catch { /* ignore read errors */ } 31 + skills.push({ name: entry.name, version }); 32 + } 33 + } 34 + } catch { /* ignore dir read errors */ } 35 + return skills; 36 + } 37 + 12 38 export default function register(program) { 13 39 async function checkHealth() { 14 40 try { ··· 49 75 console.log(`${mark} skill: ok (using-vit)`); 50 76 } else { 51 77 console.log(`${mark} skill: not installed (run ${name} setup)`); 78 + } 79 + 80 + // Report installed skills 81 + const projectSkillDir = join(process.cwd(), '.claude', 'skills'); 82 + const projectSkills = scanSkillDir(projectSkillDir); 83 + const userSkillDir = join(homedir(), '.claude', 'skills'); 84 + const userSkills = scanSkillDir(userSkillDir); 85 + 86 + if (projectSkills.length > 0) { 87 + console.log(`${mark} project skills: ${projectSkills.length} installed`); 88 + for (const s of projectSkills) { 89 + const ver = s.version ? ` v${s.version}` : ''; 90 + console.log(` ${s.name}${ver}`); 91 + } 92 + } 93 + if (userSkills.length > 0) { 94 + console.log(`${mark} user skills: ${userSkills.length} installed`); 95 + for (const s of userSkills) { 96 + const ver = s.version ? ` v${s.version}` : ''; 97 + console.log(` ${s.name}${ver}`); 98 + } 52 99 } 53 100 54 101 if (!config.did) {
+200
src/cmd/learn.js
··· 1 + // SPDX-License-Identifier: AGPL-3.0-only 2 + // Copyright (c) 2026 sol pbc 3 + 4 + import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; 5 + import { join, dirname } from 'node:path'; 6 + import { homedir } from 'node:os'; 7 + import { requireDid } from '../lib/config.js'; 8 + import { SKILL_COLLECTION } from '../lib/constants.js'; 9 + import { restoreAgent } from '../lib/oauth.js'; 10 + import { readFollowing, readLog, appendLog } from '../lib/vit-dir.js'; 11 + import { requireAgent } from '../lib/agent.js'; 12 + import { isSkillRef, nameFromSkillRef, isValidSkillRef } from '../lib/skill-ref.js'; 13 + import { mark, name } from '../lib/brand.js'; 14 + import { resolvePds, listRecordsFromPds } from '../lib/pds.js'; 15 + 16 + export default function register(program) { 17 + program 18 + .command('learn') 19 + .argument('<ref>', 'Skill reference (e.g. skill-agent-test-patterns)') 20 + .description('Install a skill from the network into your skill directory') 21 + .option('--did <did>', 'DID to use') 22 + .option('--user', 'Install to user-wide ~/.claude/skills/ (requires vet)') 23 + .option('-v, --verbose', 'Show step-by-step details') 24 + .action(async (ref, opts) => { 25 + try { 26 + const gate = requireAgent(); 27 + if (!gate.ok) { 28 + console.error(`${name} learn should be run by a coding agent (e.g. claude code, gemini cli).`); 29 + console.error(`open your agent and ask it to run '${name} learn' for you.`); 30 + process.exitCode = 1; 31 + return; 32 + } 33 + 34 + const { verbose } = opts; 35 + 36 + if (!isSkillRef(ref)) { 37 + console.error(`invalid skill ref. expected format: skill-{name} (e.g. skill-agent-test-patterns)`); 38 + process.exitCode = 1; 39 + return; 40 + } 41 + 42 + if (!isValidSkillRef(ref)) { 43 + console.error('invalid skill ref. name must be lowercase letters, numbers, hyphens only.'); 44 + console.error('no leading hyphen, no consecutive hyphens, max 64 chars.'); 45 + process.exitCode = 1; 46 + return; 47 + } 48 + 49 + const skillName = nameFromSkillRef(ref); 50 + if (verbose) console.log(`[verbose] skill name: ${skillName}`); 51 + 52 + // Trust gate 53 + const isUserInstall = !!opts.user; 54 + const trusted = readLog('trusted.jsonl'); 55 + const trustedEntry = trusted.find(e => e.ref === ref); 56 + 57 + if (isUserInstall && !trustedEntry) { 58 + // --user ALWAYS requires vet 59 + console.error(`skill '${ref}' is not yet vetted. user-wide install requires vetting.`); 60 + console.error(`ask the user to vet it first:`); 61 + console.error(''); 62 + console.error(` vit vet ${ref}`); 63 + console.error(''); 64 + console.error('after reviewing, they can trust it with:'); 65 + console.error(''); 66 + console.error(` vit vet ${ref} --trust`); 67 + process.exitCode = 1; 68 + return; 69 + } 70 + 71 + if (!isUserInstall && !trustedEntry) { 72 + // Project-level: requires vet UNLESS skip-perms 73 + // Check if running in skip-perms mode (dangerously-skip-permissions) 74 + const skipPerms = process.env.CLAUDE_SKIP_PERMISSIONS === '1' || 75 + process.argv.includes('--dangerously-skip-permissions'); 76 + if (!skipPerms) { 77 + console.error(`skill '${ref}' is not yet vetted.`); 78 + console.error(`ask the user to vet it first:`); 79 + console.error(''); 80 + console.error(` vit vet ${ref}`); 81 + console.error(''); 82 + console.error('after reviewing, they can trust it with:'); 83 + console.error(''); 84 + console.error(` vit vet ${ref} --trust`); 85 + process.exitCode = 1; 86 + return; 87 + } 88 + if (verbose) console.log('[verbose] skip-perms mode: bypassing vet for project-level install'); 89 + } 90 + 91 + const did = requireDid(opts); 92 + if (!did) return; 93 + if (verbose) console.log(`[verbose] DID: ${did}`); 94 + 95 + const { agent } = await restoreAgent(did); 96 + if (verbose) console.log('[verbose] session restored'); 97 + 98 + // Build DID list from following + self 99 + const following = readFollowing(); 100 + const dids = following.map(e => e.did); 101 + dids.push(did); 102 + if (verbose) console.log(`[verbose] querying ${dids.length} accounts`); 103 + 104 + // Fetch skills from each DID, find matching ref 105 + let match = null; 106 + for (const repoDid of dids) { 107 + try { 108 + const pds = await resolvePds(repoDid); 109 + if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 110 + const res = await listRecordsFromPds(pds, repoDid, SKILL_COLLECTION, 50); 111 + for (const rec of res.records) { 112 + const recName = rec.value.name; 113 + if (recName === skillName) { 114 + if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { 115 + match = rec; 116 + } 117 + } 118 + } 119 + } catch (err) { 120 + if (verbose) console.log(`[verbose] ${repoDid}: error fetching skills: ${err.message}`); 121 + } 122 + } 123 + 124 + if (!match) { 125 + console.error(`no skill found with ref '${ref}' from followed accounts.`); 126 + process.exitCode = 1; 127 + return; 128 + } 129 + 130 + const record = match.value; 131 + if (verbose) console.log(`[verbose] found skill: ${record.name} from ${match.uri}`); 132 + 133 + // Determine install path 134 + let installDir; 135 + if (isUserInstall) { 136 + installDir = join(homedir(), '.claude', 'skills', skillName); 137 + } else { 138 + installDir = join(process.cwd(), '.claude', 'skills', skillName); 139 + } 140 + 141 + mkdirSync(installDir, { recursive: true }); 142 + 143 + // Write SKILL.md from text field — verbatim, no reconstruction 144 + writeFileSync(join(installDir, 'SKILL.md'), record.text); 145 + if (verbose) console.log(`[verbose] wrote SKILL.md to ${installDir}`); 146 + 147 + // Download and write resource blobs 148 + if (record.resources && record.resources.length > 0) { 149 + const authorDid = match.uri.split('/')[2]; 150 + const pds = await resolvePds(authorDid); 151 + 152 + for (const resource of record.resources) { 153 + const resourcePath = join(installDir, resource.path); 154 + mkdirSync(dirname(resourcePath), { recursive: true }); 155 + 156 + try { 157 + // Download blob from PDS 158 + const blobCid = resource.blob?.ref?.$link || resource.blob?.cid; 159 + if (blobCid) { 160 + const blobUrl = new URL('/xrpc/com.atproto.sync.getBlob', pds); 161 + blobUrl.searchParams.set('did', authorDid); 162 + blobUrl.searchParams.set('cid', blobCid); 163 + const blobRes = await fetch(blobUrl); 164 + if (!blobRes.ok) throw new Error(`blob fetch failed: ${blobRes.status}`); 165 + const blobData = Buffer.from(await blobRes.arrayBuffer()); 166 + writeFileSync(resourcePath, blobData); 167 + if (verbose) console.log(`[verbose] wrote resource: ${resource.path}`); 168 + } 169 + } catch (err) { 170 + console.error(`warning: failed to download resource ${resource.path}: ${err.message}`); 171 + } 172 + } 173 + } 174 + 175 + // Log to learned.jsonl 176 + try { 177 + appendLog('learned.jsonl', { 178 + ref, 179 + name: skillName, 180 + uri: match.uri, 181 + cid: match.cid, 182 + installedTo: installDir, 183 + scope: isUserInstall ? 'user' : 'project', 184 + learnedAt: new Date().toISOString(), 185 + version: record.version || null, 186 + }); 187 + } catch (logErr) { 188 + console.error('warning: failed to write learned.jsonl:', logErr.message); 189 + } 190 + 191 + const scope = isUserInstall ? 'user' : 'project'; 192 + console.log(`${mark} learned: ${ref} (${scope})`); 193 + console.log(`installed to: ${installDir}`); 194 + if (record.version) console.log(`version: ${record.version}`); 195 + } catch (err) { 196 + console.error(err instanceof Error ? err.message : String(err)); 197 + process.exitCode = 1; 198 + } 199 + }); 200 + }
+68 -19
src/cmd/scan.js
··· 1 1 // SPDX-License-Identifier: AGPL-3.0-only 2 2 // Copyright (c) 2026 sol pbc 3 3 4 - import { CAP_COLLECTION } from '../lib/constants.js'; 4 + import { CAP_COLLECTION, SKILL_COLLECTION } from '../lib/constants.js'; 5 5 import { resolveRef } from '../lib/cap-ref.js'; 6 6 import { resolveHandleFromDid } from '../lib/pds.js'; 7 7 import { brand } from '../lib/brand.js'; ··· 11 11 export default function register(program) { 12 12 program 13 13 .command('scan') 14 - .description('Discover cap publishers across the network via Jetstream replay') 14 + .description('Discover cap and skill publishers across the network via Jetstream replay') 15 15 .option('--days <n>', 'Number of days to replay', '7') 16 - .option('--beacon <beacon>', 'Filter by beacon') 16 + .option('--beacon <beacon>', 'Filter by beacon (caps only)') 17 + .option('--skills', 'Show only skill publishers') 18 + .option('--caps', 'Show only cap publishers') 19 + .option('--tag <tag>', 'Filter skills by tag') 17 20 .option('-v, --verbose', 'Show each event as it arrives') 18 21 .action(async (opts) => { 19 22 try { ··· 24 27 return; 25 28 } 26 29 30 + const wantCaps = !opts.skills; 31 + const wantSkills = !opts.caps; 32 + 27 33 const cursor = (Date.now() - days * 86400000) * 1000; 28 34 const timeout = Math.max(120000, Math.min(600000, days * 60000)); 35 + 36 + // Build wanted collections 37 + const collections = []; 38 + if (wantCaps) collections.push(CAP_COLLECTION); 39 + if (wantSkills) collections.push(SKILL_COLLECTION); 29 40 30 41 const url = new URL(JETSTREAM_URL); 31 - url.searchParams.set('wantedCollections', CAP_COLLECTION); 42 + for (const col of collections) { 43 + url.searchParams.append('wantedCollections', col); 44 + } 32 45 url.searchParams.set('cursor', String(cursor)); 33 46 47 + const scanType = wantCaps && wantSkills ? 'cap + skill' : wantSkills ? 'skill' : 'cap'; 34 48 console.log(`${brand} scan`); 35 - console.log(` Replaying ${days} day${days === 1 ? '' : 's'} of cap events...`); 49 + console.log(` Replaying ${days} day${days === 1 ? '' : 's'} of ${scanType} events...`); 36 50 if (opts.beacon) console.log(` Beacon filter: ${opts.beacon}`); 51 + if (opts.tag) console.log(` Tag filter: ${opts.tag}`); 37 52 console.log(` Timeout: ${Math.round(timeout / 1000)}s`); 38 53 console.log(''); 39 54 ··· 55 70 const record = msg.commit?.record; 56 71 if (!record) return; 57 72 58 - if (opts.beacon && record.beacon !== opts.beacon) return; 73 + const collection = msg.commit?.collection; 74 + const isCapEvent = collection === CAP_COLLECTION; 75 + const isSkillEvent = collection === SKILL_COLLECTION; 76 + 77 + if (!isCapEvent && !isSkillEvent) return; 78 + 79 + // Apply filters 80 + if (isCapEvent && opts.beacon && record.beacon !== opts.beacon) return; 81 + if (isSkillEvent && opts.tag) { 82 + const tags = record.tags || []; 83 + if (!tags.some(t => t.toLowerCase() === opts.tag.toLowerCase())) return; 84 + } 59 85 60 86 const did = msg.did; 61 - const ref = msg.commit?.cid ? resolveRef(record, msg.commit.cid) : null; 87 + const ref = isCapEvent && msg.commit?.cid ? resolveRef(record, msg.commit.cid) : null; 62 88 63 89 if (opts.verbose) { 64 90 const didShort = did.slice(-12); 65 - const title = record.title || ''; 66 - const refPart = ref ? ` (${ref})` : ''; 67 - console.log(` ${didShort}: ${title}${refPart} [${record.beacon || 'no beacon'}]`); 91 + if (isCapEvent) { 92 + const title = record.title || ''; 93 + const refPart = ref ? ` (${ref})` : ''; 94 + console.log(` ${didShort}: [cap] ${title}${refPart} [${record.beacon || 'no beacon'}]`); 95 + } else { 96 + const skillName = record.name || ''; 97 + const tags = record.tags ? ` [${record.tags.join(', ')}]` : ''; 98 + console.log(` ${didShort}: [skill] ${skillName}${tags}`); 99 + } 68 100 } 69 101 70 102 if (!publishers.has(did)) { 71 - publishers.set(did, { count: 0, beacons: new Set(), lastActive: '' }); 103 + publishers.set(did, { capCount: 0, skillCount: 0, beacons: new Set(), tags: new Set(), lastActive: '' }); 72 104 } 73 105 const entry = publishers.get(did); 74 - entry.count++; 75 - if (record.beacon) entry.beacons.add(record.beacon); 106 + if (isCapEvent) { 107 + entry.capCount++; 108 + if (record.beacon) entry.beacons.add(record.beacon); 109 + } else { 110 + entry.skillCount++; 111 + if (record.tags) { 112 + for (const t of record.tags) entry.tags.add(t); 113 + } 114 + } 76 115 if (record.createdAt && record.createdAt > entry.lastActive) { 77 116 entry.lastActive = record.createdAt; 78 117 } ··· 90 129 }); 91 130 92 131 if (publishers.size === 0) { 93 - console.log('no cap publishers found in this time window.'); 132 + console.log(`no ${scanType} publishers found in this time window.`); 94 133 return; 95 134 } 96 135 97 136 const entries = []; 98 137 for (const [did, stats] of publishers) { 99 138 const handle = await resolveHandleFromDid(did); 100 - entries.push({ handle, did, ...stats, beacons: [...stats.beacons] }); 139 + entries.push({ handle, did, ...stats, beacons: [...stats.beacons], tags: [...stats.tags] }); 101 140 } 102 141 103 - entries.sort((a, b) => b.count - a.count); 142 + const totalCount = (e) => e.capCount + e.skillCount; 143 + entries.sort((a, b) => totalCount(b) - totalCount(a)); 104 144 105 145 console.log(`found ${entries.length} publisher${entries.length === 1 ? '' : 's'}:\n`); 106 146 for (const e of entries) { 107 - const beaconStr = e.beacons.length > 0 ? e.beacons.join(', ') : '(none)'; 147 + console.log(` @${e.handle}`); 148 + const parts = []; 149 + if (wantCaps && e.capCount > 0) { 150 + const beaconStr = e.beacons.length > 0 ? e.beacons.join(', ') : '(none)'; 151 + parts.push(`caps: ${e.capCount} beacons: ${beaconStr}`); 152 + } 153 + if (wantSkills && e.skillCount > 0) { 154 + const tagStr = e.tags.length > 0 ? e.tags.join(', ') : '(none)'; 155 + parts.push(`skills: ${e.skillCount} tags: ${tagStr}`); 156 + } 108 157 const lastActive = e.lastActive ? e.lastActive.split('T')[0] : 'unknown'; 109 - console.log(` @${e.handle}`); 110 - console.log(` caps: ${e.count} beacons: ${beaconStr} last active: ${lastActive}`); 158 + parts.push(`last active: ${lastActive}`); 159 + console.log(` ${parts.join(' ')}`); 111 160 } 112 161 } catch (err) { 113 162 console.error(err instanceof Error ? err.message : String(err));
+447 -155
src/cmd/ship.js
··· 2 2 // Copyright (c) 2026 sol pbc 3 3 4 4 import { TID } from '@atproto/common-web'; 5 - import { readFileSync } from 'node:fs'; 6 - import { CAP_COLLECTION } from '../lib/constants.js'; 5 + import { readFileSync, readdirSync, statSync } from 'node:fs'; 6 + import { join, relative } from 'node:path'; 7 + import { CAP_COLLECTION, SKILL_COLLECTION } from '../lib/constants.js'; 7 8 import { requireAgent } from '../lib/agent.js'; 8 9 import { requireDid } from '../lib/config.js'; 9 10 import { restoreAgent } from '../lib/oauth.js'; 10 11 import { appendLog, readProjectConfig, readLog, readFollowing } from '../lib/vit-dir.js'; 11 12 import { REF_PATTERN, resolveRef } from '../lib/cap-ref.js'; 13 + import { isValidSkillName, skillRefFromName } from '../lib/skill-ref.js'; 12 14 import { name } from '../lib/brand.js'; 13 15 import { resolvePds, listRecordsFromPds } from '../lib/pds.js'; 14 16 15 - export default function register(program) { 16 - program 17 - .command('ship') 18 - .description('Publish a cap to your feed') 19 - .option('-v, --verbose', 'Show step-by-step details') 20 - .option('--did <did>', 'DID to use (reads saved DID from config if not provided)') 21 - .requiredOption('--title <title>', 'Short title for the cap') 22 - .requiredOption('--description <description>', 'Description of the cap') 23 - .requiredOption('--ref <ref>', 'Three lowercase words with dashes (e.g. fast-cache-invalidation)') 24 - .option('--recap <ref>', 'Ref of the cap this derives from (quote-post semantics)') 25 - .action(async (opts) => { 26 - try { 27 - const gate = requireAgent(); 28 - if (!gate.ok) { 29 - console.error(`${name} ship should be run by a coding agent (e.g. claude code, gemini cli).`); 30 - console.error(`open your agent and ask it to run '${name} ship' for you.`); 31 - console.error(`refer to the using-vit skill (skills/vit/SKILL.md) for a shipping guide.`); 32 - process.exitCode = 1; 33 - return; 34 - } 17 + function parseFrontmatter(text) { 18 + const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); 19 + if (!match) return { frontmatter: {}, body: text }; 20 + const raw = match[1]; 21 + const frontmatter = {}; 22 + let currentKey = null; 23 + let currentValue = ''; 24 + let isMultiline = false; 25 + 26 + for (const line of raw.split('\n')) { 27 + if (isMultiline) { 28 + if (line.match(/^\S/) && line.includes(':')) { 29 + // New key — save accumulated value 30 + frontmatter[currentKey] = currentValue.trim(); 31 + isMultiline = false; 32 + } else { 33 + currentValue += ' ' + line.trim(); 34 + continue; 35 + } 36 + } 37 + 38 + const kvMatch = line.match(/^(\w[\w-]*):\s*(>-?|[|][-+]?)?(.*)$/); 39 + if (kvMatch) { 40 + currentKey = kvMatch[1]; 41 + const indicator = kvMatch[2]; 42 + const rest = kvMatch[3].trim(); 43 + if (indicator && (indicator.startsWith('>') || indicator.startsWith('|'))) { 44 + // Multiline YAML 45 + currentValue = rest; 46 + isMultiline = true; 47 + } else { 48 + frontmatter[currentKey] = rest; 49 + } 50 + } 51 + } 52 + if (isMultiline && currentKey) { 53 + frontmatter[currentKey] = currentValue.trim(); 54 + } 55 + 56 + return { frontmatter, body: text.slice(match[0].length) }; 57 + } 58 + 59 + function gatherFiles(dir, base) { 60 + const results = []; 61 + const entries = readdirSync(dir, { withFileTypes: true }); 62 + for (const entry of entries) { 63 + const fullPath = join(dir, entry.name); 64 + if (entry.isDirectory()) { 65 + results.push(...gatherFiles(fullPath, base)); 66 + } else if (entry.name !== 'SKILL.md') { 67 + const relPath = relative(base, fullPath); 68 + results.push({ path: relPath, fullPath }); 69 + } 70 + } 71 + return results; 72 + } 73 + 74 + function guessMimeType(filename) { 75 + const ext = filename.split('.').pop()?.toLowerCase(); 76 + const map = { 77 + md: 'text/markdown', 78 + txt: 'text/plain', 79 + json: 'application/json', 80 + yaml: 'application/yaml', 81 + yml: 'application/yaml', 82 + js: 'text/javascript', 83 + ts: 'text/typescript', 84 + py: 'text/x-python', 85 + sh: 'application/x-shellscript', 86 + bash: 'application/x-shellscript', 87 + html: 'text/html', 88 + css: 'text/css', 89 + xml: 'application/xml', 90 + png: 'image/png', 91 + jpg: 'image/jpeg', 92 + jpeg: 'image/jpeg', 93 + gif: 'image/gif', 94 + svg: 'image/svg+xml', 95 + pdf: 'application/pdf', 96 + }; 97 + return map[ext] || 'application/octet-stream'; 98 + } 99 + 100 + async function shipSkill(opts) { 101 + const gate = requireAgent(); 102 + if (!gate.ok) { 103 + console.error(`${name} ship --skill should be run by a coding agent (e.g. claude code, gemini cli).`); 104 + console.error(`open your agent and ask it to run '${name} ship --skill' for you.`); 105 + process.exitCode = 1; 106 + return; 107 + } 108 + 109 + const { verbose } = opts; 110 + const skillDir = opts.skill; 111 + 112 + // Validate skill directory 113 + let skillMdPath; 114 + try { 115 + skillMdPath = join(skillDir, 'SKILL.md'); 116 + statSync(skillMdPath); 117 + } catch { 118 + console.error(`error: no SKILL.md found in ${skillDir}`); 119 + process.exitCode = 1; 120 + return; 121 + } 122 + 123 + // Read SKILL.md verbatim 124 + const skillMdText = readFileSync(skillMdPath, 'utf-8'); 125 + if (!skillMdText.trim()) { 126 + console.error('error: SKILL.md is empty'); 127 + process.exitCode = 1; 128 + return; 129 + } 130 + 131 + // Parse frontmatter to extract fields 132 + const { frontmatter } = parseFrontmatter(skillMdText); 133 + 134 + const skillName = frontmatter.name; 135 + if (!skillName) { 136 + console.error('error: SKILL.md frontmatter must include a "name" field'); 137 + process.exitCode = 1; 138 + return; 139 + } 140 + 141 + if (!isValidSkillName(skillName)) { 142 + console.error('error: skill name must be lowercase letters, numbers, hyphens only.'); 143 + console.error(' no leading hyphen, no consecutive hyphens, max 64 chars.'); 144 + console.error(` got: "${skillName}"`); 145 + process.exitCode = 1; 146 + return; 147 + } 148 + 149 + const skillDescription = frontmatter.description; 150 + if (!skillDescription) { 151 + console.error('error: SKILL.md frontmatter must include a "description" field'); 152 + process.exitCode = 1; 153 + return; 154 + } 155 + 156 + if (verbose) console.log(`[verbose] skill name: ${skillName}`); 157 + if (verbose) console.log(`[verbose] skill description: ${skillDescription.slice(0, 80)}...`); 35 158 36 - const { verbose } = opts; 159 + // DID 160 + const did = requireDid(opts); 161 + if (!did) return; 162 + if (verbose) console.log(`[verbose] DID: ${did}`); 37 163 38 - // preflight: DID 39 - const did = requireDid(opts); 40 - if (!did) return; 41 - if (verbose) console.log(`[verbose] DID: ${did}`); 164 + // Session 165 + let agent, session; 166 + try { 167 + ({ agent, session } = await restoreAgent(did)); 168 + } catch { 169 + console.error(`session expired or invalid. tell your user to run '${name} login <handle>'.`); 170 + process.exitCode = 1; 171 + return; 172 + } 173 + if (verbose) console.log(`[verbose] Session restored, PDS: ${session.serverMetadata?.issuer}`); 42 174 43 - // preflight: beacon 44 - const projectConfig = readProjectConfig(); 45 - if (!projectConfig.beacon) { 46 - console.error(`no beacon set. run '${name} init' in a project directory first.`); 47 - process.exitCode = 1; 48 - return; 49 - } 50 - if (verbose) console.log(`[verbose] beacon: ${projectConfig.beacon}`); 175 + // Gather and upload resource files as blobs 176 + const resourceFiles = gatherFiles(skillDir, skillDir); 177 + const resources = []; 178 + for (const rf of resourceFiles) { 179 + if (verbose) console.log(`[verbose] uploading resource: ${rf.path}`); 180 + const data = readFileSync(rf.fullPath); 181 + const mimeType = guessMimeType(rf.path); 182 + try { 183 + const uploadRes = await agent.com.atproto.repo.uploadBlob(data, { encoding: mimeType }); 184 + resources.push({ 185 + path: rf.path, 186 + blob: uploadRes.data.blob, 187 + mimeType, 188 + }); 189 + } catch (err) { 190 + console.error(`error: failed to upload resource ${rf.path}: ${err.message}`); 191 + process.exitCode = 1; 192 + return; 193 + } 194 + } 51 195 52 - let text; 53 - try { 54 - text = readFileSync('/dev/stdin', 'utf-8').trim(); 55 - } catch { 56 - text = ''; 57 - } 58 - if (!text) { 59 - console.error('error: cap body is required via stdin (pipe or heredoc)'); 60 - process.exitCode = 1; 61 - return; 62 - } 196 + // Build record 197 + const now = new Date().toISOString(); 198 + const ref = skillRefFromName(skillName); 199 + const record = { 200 + $type: SKILL_COLLECTION, 201 + name: skillName, 202 + description: skillDescription, 203 + text: skillMdText, 204 + createdAt: now, 205 + }; 63 206 64 - if (!REF_PATTERN.test(opts.ref)) { 65 - console.error('error: --ref must be exactly three lowercase words separated by dashes (e.g. fast-cache-invalidation)'); 66 - process.exitCode = 1; 67 - return; 68 - } 207 + // Optional fields from frontmatter or CLI flags 208 + const version = opts.version || frontmatter.version; 209 + if (version) record.version = version; 69 210 70 - let recapUri = null; 71 - if (opts.recap) { 72 - if (!REF_PATTERN.test(opts.recap)) { 73 - console.error('error: --recap must be exactly three lowercase words separated by dashes (e.g. fast-cache-invalidation)'); 74 - process.exitCode = 1; 75 - return; 76 - } 211 + const license = opts.license || frontmatter.license; 212 + if (license) record.license = license; 77 213 78 - const caps = readLog('caps.jsonl'); 79 - const localMatch = caps.find(e => e.ref === opts.recap); 80 - if (localMatch) { 81 - recapUri = localMatch.uri; 82 - if (verbose) console.log(`[verbose] recap resolved locally: ${recapUri}`); 83 - } 84 - } 214 + if (frontmatter.compatibility) record.compatibility = frontmatter.compatibility; 85 215 86 - const now = new Date().toISOString(); 216 + if (resources.length > 0) record.resources = resources; 87 217 88 - // preflight: session 89 - let agent, session; 90 - try { 91 - ({ agent, session } = await restoreAgent(did)); 92 - } catch { 93 - console.error(`session expired or invalid. tell your user to run '${name} login <handle>'.`); 94 - process.exitCode = 1; 95 - return; 96 - } 97 - if (verbose) console.log(`[verbose] Session restored, PDS: ${session.serverMetadata?.issuer}`); 218 + if (opts.tags) { 219 + record.tags = opts.tags.split(',').map(t => t.trim()).filter(Boolean); 220 + } 98 221 99 - if (opts.recap && !recapUri) { 100 - const following = readFollowing(); 101 - const dids = following.map(e => e.did); 102 - dids.push(did); 103 - if (verbose) console.log(`[verbose] recap: querying ${dids.length} accounts`); 222 + const rkey = TID.nextStr(); 223 + if (verbose) console.log(`[verbose] Record built, ref: ${ref}, rkey: ${rkey}`); 104 224 105 - let match = null; 106 - for (const repoDid of dids) { 107 - try { 108 - const pds = await resolvePds(repoDid); 109 - if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 110 - const res = await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50); 111 - for (const rec of res.records) { 112 - const recRef = resolveRef(rec.value, rec.cid); 113 - if (recRef === opts.recap) { 114 - if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { 115 - match = rec; 116 - } 117 - } 118 - } 119 - } catch (err) { 120 - if (verbose) console.log(`[verbose] ${repoDid}: error fetching caps: ${err.message}`); 225 + const putArgs = { 226 + repo: did, 227 + collection: SKILL_COLLECTION, 228 + rkey, 229 + record, 230 + validate: false, 231 + }; 232 + 233 + if (verbose) console.log(`[verbose] putRecord ${putArgs.collection} rkey=${rkey}`); 234 + const putRes = await agent.com.atproto.repo.putRecord(putArgs); 235 + 236 + try { 237 + appendLog('skills.jsonl', { 238 + ts: now, 239 + did, 240 + rkey, 241 + ref, 242 + name: skillName, 243 + collection: SKILL_COLLECTION, 244 + pds: session.serverMetadata?.issuer, 245 + uri: putRes.data.uri, 246 + cid: putRes.data.cid, 247 + }); 248 + } catch (logErr) { 249 + console.error('warning: failed to write skills.jsonl:', logErr.message); 250 + } 251 + if (verbose) console.log(`[verbose] Log written to skills.jsonl`); 252 + 253 + console.log(`shipped: ${ref}`); 254 + console.log(`uri: ${putRes.data.uri}`); 255 + if (verbose) { 256 + console.log( 257 + JSON.stringify({ 258 + ts: now, 259 + pds: session.serverMetadata?.issuer, 260 + xrpc: 'com.atproto.repo.putRecord', 261 + request: putArgs, 262 + response: putRes.data, 263 + }), 264 + ); 265 + } 266 + } 267 + 268 + async function shipCap(opts) { 269 + const gate = requireAgent(); 270 + if (!gate.ok) { 271 + console.error(`${name} ship should be run by a coding agent (e.g. claude code, gemini cli).`); 272 + console.error(`open your agent and ask it to run '${name} ship' for you.`); 273 + console.error(`refer to the using-vit skill (skills/vit/SKILL.md) for a shipping guide.`); 274 + process.exitCode = 1; 275 + return; 276 + } 277 + 278 + const { verbose } = opts; 279 + 280 + // preflight: DID 281 + const did = requireDid(opts); 282 + if (!did) return; 283 + if (verbose) console.log(`[verbose] DID: ${did}`); 284 + 285 + // preflight: beacon 286 + const projectConfig = readProjectConfig(); 287 + if (!projectConfig.beacon) { 288 + console.error(`no beacon set. run '${name} init' in a project directory first.`); 289 + process.exitCode = 1; 290 + return; 291 + } 292 + if (verbose) console.log(`[verbose] beacon: ${projectConfig.beacon}`); 293 + 294 + let text; 295 + try { 296 + text = readFileSync('/dev/stdin', 'utf-8').trim(); 297 + } catch { 298 + text = ''; 299 + } 300 + if (!text) { 301 + console.error('error: cap body is required via stdin (pipe or heredoc)'); 302 + process.exitCode = 1; 303 + return; 304 + } 305 + 306 + if (!REF_PATTERN.test(opts.ref)) { 307 + console.error('error: --ref must be exactly three lowercase words separated by dashes (e.g. fast-cache-invalidation)'); 308 + process.exitCode = 1; 309 + return; 310 + } 311 + 312 + let recapUri = null; 313 + if (opts.recap) { 314 + if (!REF_PATTERN.test(opts.recap)) { 315 + console.error('error: --recap must be exactly three lowercase words separated by dashes (e.g. fast-cache-invalidation)'); 316 + process.exitCode = 1; 317 + return; 318 + } 319 + 320 + const caps = readLog('caps.jsonl'); 321 + const localMatch = caps.find(e => e.ref === opts.recap); 322 + if (localMatch) { 323 + recapUri = localMatch.uri; 324 + if (verbose) console.log(`[verbose] recap resolved locally: ${recapUri}`); 325 + } 326 + } 327 + 328 + const now = new Date().toISOString(); 329 + 330 + // preflight: session 331 + let agent, session; 332 + try { 333 + ({ agent, session } = await restoreAgent(did)); 334 + } catch { 335 + console.error(`session expired or invalid. tell your user to run '${name} login <handle>'.`); 336 + process.exitCode = 1; 337 + return; 338 + } 339 + if (verbose) console.log(`[verbose] Session restored, PDS: ${session.serverMetadata?.issuer}`); 340 + 341 + if (opts.recap && !recapUri) { 342 + const following = readFollowing(); 343 + const dids = following.map(e => e.did); 344 + dids.push(did); 345 + if (verbose) console.log(`[verbose] recap: querying ${dids.length} accounts`); 346 + 347 + let match = null; 348 + for (const repoDid of dids) { 349 + try { 350 + const pds = await resolvePds(repoDid); 351 + if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 352 + const res = await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50); 353 + for (const rec of res.records) { 354 + const recRef = resolveRef(rec.value, rec.cid); 355 + if (recRef === opts.recap) { 356 + if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { 357 + match = rec; 121 358 } 122 359 } 360 + } 361 + } catch (err) { 362 + if (verbose) console.log(`[verbose] ${repoDid}: error fetching caps: ${err.message}`); 363 + } 364 + } 123 365 124 - if (match) { 125 - recapUri = match.uri; 126 - if (verbose) console.log(`[verbose] recap resolved remotely: ${recapUri}`); 127 - } else { 128 - console.error(`error: could not find cap with ref '${opts.recap}' to recap`); 366 + if (match) { 367 + recapUri = match.uri; 368 + if (verbose) console.log(`[verbose] recap resolved remotely: ${recapUri}`); 369 + } else { 370 + console.error(`error: could not find cap with ref '${opts.recap}' to recap`); 371 + process.exitCode = 1; 372 + return; 373 + } 374 + } 375 + 376 + const record = { 377 + $type: CAP_COLLECTION, 378 + text, 379 + title: opts.title, 380 + description: opts.description, 381 + ref: opts.ref, 382 + createdAt: now, 383 + }; 384 + if (projectConfig.beacon) record.beacon = projectConfig.beacon; 385 + if (opts.recap) record.recap = { uri: recapUri, ref: opts.recap }; 386 + const rkey = TID.nextStr(); 387 + if (verbose) console.log(`[verbose] Record built, rkey: ${rkey}`); 388 + const putArgs = { 389 + repo: did, 390 + collection: CAP_COLLECTION, 391 + rkey, 392 + record, 393 + validate: false, 394 + }; 395 + if (verbose) console.log(`[verbose] putRecord ${putArgs.collection} rkey=${rkey}`); 396 + const putRes = await agent.com.atproto.repo.putRecord(putArgs); 397 + try { 398 + appendLog('caps.jsonl', { 399 + ts: now, 400 + did, 401 + rkey, 402 + ref: opts.ref, 403 + collection: CAP_COLLECTION, 404 + pds: session.serverMetadata?.issuer, 405 + uri: putRes.data.uri, 406 + cid: putRes.data.cid, 407 + }); 408 + } catch (logErr) { 409 + console.error('warning: failed to write caps.jsonl:', logErr.message); 410 + } 411 + if (verbose) console.log(`[verbose] Log written to caps.jsonl`); 412 + console.log(`shipped: ${opts.ref}`); 413 + console.log(`uri: ${putRes.data.uri}`); 414 + if (verbose) { 415 + console.log( 416 + JSON.stringify({ 417 + ts: now, 418 + pds: session.serverMetadata?.issuer, 419 + xrpc: 'com.atproto.repo.putRecord', 420 + request: putArgs, 421 + response: putRes.data, 422 + }), 423 + ); 424 + } 425 + } 426 + 427 + export default function register(program) { 428 + program 429 + .command('ship') 430 + .description('Publish a cap or skill to your feed') 431 + .option('-v, --verbose', 'Show step-by-step details') 432 + .option('--did <did>', 'DID to use (reads saved DID from config if not provided)') 433 + .option('--title <title>', 'Short title for the cap') 434 + .option('--description <description>', 'Description of the cap') 435 + .option('--ref <ref>', 'Three lowercase words with dashes (e.g. fast-cache-invalidation)') 436 + .option('--recap <ref>', 'Ref of the cap this derives from (quote-post semantics)') 437 + .option('--skill <path>', 'Publish a skill directory (reads SKILL.md + resources)') 438 + .option('--tags <tags>', 'Comma-separated discovery tags (for skills)') 439 + .option('--version <version>', 'Version string (for skills, overrides frontmatter)') 440 + .option('--license <license>', 'SPDX license identifier (for skills, overrides frontmatter)') 441 + .action(async (opts) => { 442 + try { 443 + if (opts.skill) { 444 + await shipSkill(opts); 445 + } else { 446 + // Validate required cap fields 447 + if (!opts.title) { 448 + console.error("error: required option '--title <title>' not specified"); 129 449 process.exitCode = 1; 130 450 return; 131 451 } 132 - } 133 - 134 - const record = { 135 - $type: CAP_COLLECTION, 136 - text, 137 - title: opts.title, 138 - description: opts.description, 139 - ref: opts.ref, 140 - createdAt: now, 141 - }; 142 - if (projectConfig.beacon) record.beacon = projectConfig.beacon; 143 - if (opts.recap) record.recap = { uri: recapUri, ref: opts.recap }; 144 - const rkey = TID.nextStr(); 145 - if (verbose) console.log(`[verbose] Record built, rkey: ${rkey}`); 146 - const putArgs = { 147 - repo: did, 148 - collection: CAP_COLLECTION, 149 - rkey, 150 - record, 151 - validate: false, 152 - }; 153 - if (verbose) console.log(`[verbose] putRecord ${putArgs.collection} rkey=${rkey}`); 154 - const putRes = await agent.com.atproto.repo.putRecord(putArgs); 155 - try { 156 - appendLog('caps.jsonl', { 157 - ts: now, 158 - did, 159 - rkey, 160 - ref: opts.ref, 161 - collection: CAP_COLLECTION, 162 - pds: session.serverMetadata?.issuer, 163 - uri: putRes.data.uri, 164 - cid: putRes.data.cid, 165 - }); 166 - } catch (logErr) { 167 - console.error('warning: failed to write caps.jsonl:', logErr.message); 168 - } 169 - if (verbose) console.log(`[verbose] Log written to caps.jsonl`); 170 - console.log(`shipped: ${opts.ref}`); 171 - console.log(`uri: ${putRes.data.uri}`); 172 - if (verbose) { 173 - console.log( 174 - JSON.stringify({ 175 - ts: now, 176 - pds: session.serverMetadata?.issuer, 177 - xrpc: 'com.atproto.repo.putRecord', 178 - request: putArgs, 179 - response: putRes.data, 180 - }), 181 - ); 452 + if (!opts.description) { 453 + console.error("error: required option '--description <description>' not specified"); 454 + process.exitCode = 1; 455 + return; 456 + } 457 + if (!opts.ref) { 458 + console.error("error: required option '--ref <ref>' not specified"); 459 + process.exitCode = 1; 460 + return; 461 + } 462 + await shipCap(opts); 182 463 } 183 464 } catch (err) { 184 465 console.error(err instanceof Error ? err.message : String(err)); ··· 190 471 191 472 Refer to the using-vit skill (skills/vit/SKILL.md) for a complete shipping guide. 192 473 193 - Fields: 474 + Cap fields: 194 475 --title Short name for the cap (2-5 words) 195 476 --description One sentence explaining what this cap does 196 477 --ref Three lowercase words with dashes (your-ref-name) 197 478 --recap <ref> Optional. Ref of the cap this derives from (links back to original) 198 479 body (stdin) Full cap content, piped or via heredoc 199 480 200 - Example: 481 + Skill fields: 482 + --skill <path> Path to skill directory containing SKILL.md 483 + --tags <tags> Comma-separated discovery tags 484 + --version <ver> Version override (defaults to SKILL.md frontmatter) 485 + --license <id> License override (defaults to SKILL.md frontmatter) 486 + 487 + Examples: 488 + # Ship a cap 201 489 vit ship --title "Fast LRU Cache" \\ 202 490 --description "Thread-safe LRU cache with O(1) eviction" \\ 203 491 --ref "fast-lru-cache" \\ 204 492 <<'EOF' 205 493 ... full cap body text ... 206 - EOF`); 494 + EOF 495 + 496 + # Ship a skill 497 + vit ship --skill ./skills/agent-test-patterns/ \\ 498 + --tags "testing,agents,claude"`); 207 499 }
+88 -29
src/cmd/skim.js
··· 2 2 // Copyright (c) 2026 sol pbc 3 3 4 4 import { requireDid } from '../lib/config.js'; 5 - import { CAP_COLLECTION } from '../lib/constants.js'; 5 + import { CAP_COLLECTION, SKILL_COLLECTION } from '../lib/constants.js'; 6 6 import { restoreAgent } from '../lib/oauth.js'; 7 7 import { readProjectConfig, readFollowing } from '../lib/vit-dir.js'; 8 8 import { requireAgent } from '../lib/agent.js'; 9 9 import { resolveRef } from '../lib/cap-ref.js'; 10 + import { skillRefFromName } from '../lib/skill-ref.js'; 10 11 import { name } from '../lib/brand.js'; 11 12 import { resolvePds, listRecordsFromPds } from '../lib/pds.js'; 12 13 13 14 export default function register(program) { 14 15 program 15 16 .command('skim') 16 - .description('Read caps from followed accounts, filtered by beacon') 17 + .description('Read caps and skills from followed accounts') 17 18 .option('--did <did>', 'DID to use') 18 - .option('--handle <handle>', 'Show caps from a specific handle only') 19 - .option('--limit <n>', 'Max caps to display', '25') 19 + .option('--handle <handle>', 'Show items from a specific handle only') 20 + .option('--limit <n>', 'Max items to display', '25') 20 21 .option('--json', 'Output as JSON array') 22 + .option('--caps', 'Show only caps') 23 + .option('--skills', 'Show only skills') 21 24 .option('-v, --verbose', 'Show step-by-step details') 22 25 .action(async (opts) => { 23 26 try { ··· 36 39 37 40 const projectConfig = readProjectConfig(); 38 41 const beacon = projectConfig.beacon; 39 - if (!beacon) { 40 - console.error(`no beacon set. run '${name} init' in a project directory first.`); 41 - process.exitCode = 1; 42 - return; 42 + 43 + // Caps require a beacon; skills-only mode does not 44 + const wantCaps = !opts.skills; 45 + const wantSkills = !opts.caps; 46 + 47 + if (wantCaps && !beacon) { 48 + if (!wantSkills) { 49 + // Caps-only mode and no beacon 50 + console.error(`no beacon set. run '${name} init' in a project directory first.`); 51 + process.exitCode = 1; 52 + return; 53 + } 54 + // Mixed mode with no beacon: just show skills 55 + if (verbose) console.log('[verbose] no beacon set, showing skills only'); 43 56 } 44 - if (verbose) console.log(`[verbose] beacon: ${beacon}`); 57 + 58 + if (verbose && beacon) console.log(`[verbose] beacon: ${beacon}`); 45 59 46 60 const { agent } = await restoreAgent(did); 47 61 if (verbose) console.log('[verbose] session restored'); ··· 73 87 } 74 88 } 75 89 76 - // fetch caps from each DID 77 - const allCaps = []; 90 + // fetch from each DID 91 + const allItems = []; 92 + 78 93 for (const repoDid of dids) { 79 94 try { 80 95 const pds = await resolvePds(repoDid); 81 96 if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 82 - const res = await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50); 83 - const caps = res.records.filter(r => r.value.beacon === beacon); 84 - if (verbose) console.log(`[verbose] ${repoDid}: ${res.records.length} caps, ${caps.length} matching beacon`); 85 - for (const cap of caps) cap._handle = handleMap.get(repoDid) || repoDid; 86 - allCaps.push(...caps); 97 + 98 + // Fetch caps (filtered by beacon) 99 + if (wantCaps && beacon) { 100 + const res = await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50); 101 + const caps = res.records.filter(r => r.value.beacon === beacon); 102 + if (verbose) console.log(`[verbose] ${repoDid}: ${res.records.length} caps, ${caps.length} matching beacon`); 103 + for (const cap of caps) { 104 + cap._handle = handleMap.get(repoDid) || repoDid; 105 + cap._type = 'cap'; 106 + } 107 + allItems.push(...caps); 108 + } 109 + 110 + // Fetch skills (unfiltered — skills are universal) 111 + if (wantSkills) { 112 + try { 113 + const res = await listRecordsFromPds(pds, repoDid, SKILL_COLLECTION, 50); 114 + if (verbose) console.log(`[verbose] ${repoDid}: ${res.records.length} skills`); 115 + for (const skill of res.records) { 116 + skill._handle = handleMap.get(repoDid) || repoDid; 117 + skill._type = 'skill'; 118 + } 119 + allItems.push(...res.records); 120 + } catch (err) { 121 + if (verbose) console.log(`[verbose] ${repoDid}: error fetching skills: ${err.message}`); 122 + } 123 + } 87 124 } catch (err) { 88 - if (verbose) console.log(`[verbose] ${repoDid}: error fetching caps: ${err.message}`); 125 + if (verbose) console.log(`[verbose] ${repoDid}: error: ${err.message}`); 89 126 } 90 127 } 91 128 92 129 // sort by createdAt descending 93 - allCaps.sort((a, b) => { 130 + allItems.sort((a, b) => { 94 131 const ta = a.value.createdAt || ''; 95 132 const tb = b.value.createdAt || ''; 96 133 return tb.localeCompare(ta); ··· 98 135 99 136 // apply limit 100 137 const limit = parseInt(opts.limit, 10); 101 - const capped = allCaps.slice(0, limit); 138 + const capped = allItems.slice(0, limit); 102 139 103 140 if (opts.json) { 104 141 console.log(JSON.stringify(capped, null, 2)); 105 142 } else { 106 143 if (capped.length === 0) { 107 - console.log('no caps found for this beacon.'); 144 + if (wantSkills && !wantCaps) { 145 + console.log('no skills found.'); 146 + } else if (wantCaps && !wantSkills) { 147 + console.log('no caps found for this beacon.'); 148 + } else { 149 + console.log('no caps or skills found.'); 150 + } 108 151 } 109 152 for (const rec of capped) { 110 - const ref = resolveRef(rec.value, rec.cid); 111 - const title = rec.value.title || ''; 112 - const description = rec.value.description || ''; 113 - console.log(`ref: ${ref}`); 114 - console.log(`by: @${rec._handle}`); 115 - if (title) console.log(`title: ${title}`); 116 - if (description) console.log(`description: ${description}`); 117 - console.log(); 153 + if (rec._type === 'skill') { 154 + const skillRef = skillRefFromName(rec.value.name); 155 + const skillName = rec.value.name || ''; 156 + const description = rec.value.description || ''; 157 + const version = rec.value.version; 158 + const tags = rec.value.tags; 159 + console.log(`ref: ${skillRef}`); 160 + console.log(`by: @${rec._handle}`); 161 + console.log(`type: skill${version ? ' v' + version : ''}`); 162 + if (skillName) console.log(`title: ${skillName}`); 163 + if (description) console.log(`description: ${description}`); 164 + if (tags && tags.length > 0) console.log(`tags: ${tags.join(', ')}`); 165 + console.log(); 166 + } else { 167 + const ref = resolveRef(rec.value, rec.cid); 168 + const title = rec.value.title || ''; 169 + const description = rec.value.description || ''; 170 + console.log(`ref: ${ref}`); 171 + console.log(`by: @${rec._handle}`); 172 + console.log(`type: cap`); 173 + if (title) console.log(`title: ${title}`); 174 + if (description) console.log(`description: ${description}`); 175 + console.log(); 176 + } 118 177 } 119 178 console.log('---'); 120 - console.log(`hint: tell your user to run '${name} vet <ref>' in another terminal for any cap they want to review.`); 179 + console.log(`hint: tell your user to run '${name} vet <ref>' in another terminal for any item they want to review.`); 121 180 } 122 181 } catch (err) { 123 182 console.error(err instanceof Error ? err.message : String(err));
+178 -76
src/cmd/vet.js
··· 2 2 // Copyright (c) 2026 sol pbc 3 3 4 4 import { requireDid } from '../lib/config.js'; 5 - import { CAP_COLLECTION } from '../lib/constants.js'; 5 + import { CAP_COLLECTION, SKILL_COLLECTION } from '../lib/constants.js'; 6 6 import { restoreAgent } from '../lib/oauth.js'; 7 7 import { appendLog, readProjectConfig, readFollowing } from '../lib/vit-dir.js'; 8 8 import { requireNotAgent } from '../lib/agent.js'; 9 9 import { resolveRef, REF_PATTERN } from '../lib/cap-ref.js'; 10 + import { isSkillRef, isValidSkillRef, nameFromSkillRef } from '../lib/skill-ref.js'; 10 11 import { mark, brand, name } from '../lib/brand.js'; 11 12 import { resolvePds, listRecordsFromPds } from '../lib/pds.js'; 12 13 13 14 export default function register(program) { 14 15 program 15 16 .command('vet') 16 - .argument('<ref>', 'Three-word cap reference (e.g. fast-cache-invalidation)') 17 - .description('Review a cap before trusting it') 17 + .argument('<ref>', 'Cap or skill reference (e.g. fast-cache-invalidation or skill-agent-test-patterns)') 18 + .description('Review a cap or skill before trusting it') 18 19 .option('--did <did>', 'DID to use') 19 - .option('--trust', 'Mark the cap as locally trusted') 20 + .option('--trust', 'Mark the item as locally trusted') 20 21 .option('-v, --verbose', 'Show step-by-step details') 21 22 .action(async (ref, opts) => { 22 23 try { ··· 24 25 if (!gate.ok) { 25 26 console.error(`${name} vet must be run by a human. run it in your own terminal.`); 26 27 console.error(''); 27 - console.error('cap vetting requires human review for safety.'); 28 + console.error('vetting requires human review for safety.'); 28 29 console.error('ask your user to run this command in their terminal:'); 29 30 console.error(''); 30 31 console.error(` vit vet ${ref}`); ··· 39 40 } 40 41 41 42 const { verbose } = opts; 43 + const isSkill = isSkillRef(ref); 42 44 43 - if (!REF_PATTERN.test(ref)) { 44 - console.error('invalid ref. expected three lowercase words with dashes (e.g. fast-cache-invalidation)'); 45 - process.exitCode = 1; 46 - return; 45 + // Validate ref format 46 + if (isSkill) { 47 + if (!isValidSkillRef(ref)) { 48 + console.error('invalid skill ref. expected format: skill-{name} (lowercase letters, numbers, hyphens)'); 49 + process.exitCode = 1; 50 + return; 51 + } 52 + } else { 53 + if (!REF_PATTERN.test(ref)) { 54 + console.error('invalid ref. expected three lowercase words with dashes (e.g. fast-cache-invalidation)'); 55 + process.exitCode = 1; 56 + return; 57 + } 47 58 } 48 59 49 60 const did = requireDid(opts); 50 61 if (!did) return; 51 62 if (verbose) console.log(`[verbose] DID: ${did}`); 52 63 53 - const projectConfig = readProjectConfig(); 54 - const beacon = projectConfig.beacon; 55 - if (!beacon) { 56 - console.error(`no beacon set. run '${name} init' in a project directory first.`); 57 - process.exitCode = 1; 58 - return; 59 - } 60 - if (verbose) console.log(`[verbose] beacon: ${beacon}`); 64 + if (!isSkill) { 65 + // Cap vet requires beacon 66 + const projectConfig = readProjectConfig(); 67 + const beacon = projectConfig.beacon; 68 + if (!beacon) { 69 + console.error(`no beacon set. run '${name} init' in a project directory first.`); 70 + process.exitCode = 1; 71 + return; 72 + } 73 + if (verbose) console.log(`[verbose] beacon: ${beacon}`); 61 74 62 - const { agent } = await restoreAgent(did); 63 - if (verbose) console.log('[verbose] session restored'); 75 + const { agent } = await restoreAgent(did); 76 + if (verbose) console.log('[verbose] session restored'); 64 77 65 - // build DID list from following + self 66 - const following = readFollowing(); 67 - const dids = following.map(e => e.did); 68 - dids.push(did); 69 - if (verbose) console.log(`[verbose] querying ${dids.length} accounts`); 78 + // build DID list from following + self 79 + const following = readFollowing(); 80 + const dids = following.map(e => e.did); 81 + dids.push(did); 82 + if (verbose) console.log(`[verbose] querying ${dids.length} accounts`); 70 83 71 - // fetch caps from each DID, find matching ref 72 - let match = null; 73 - for (const repoDid of dids) { 74 - try { 75 - const pds = await resolvePds(repoDid); 76 - if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 77 - const res = await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50); 78 - for (const rec of res.records) { 79 - if (rec.value.beacon !== beacon) continue; 80 - const recRef = resolveRef(rec.value, rec.cid); 81 - if (recRef === ref) { 82 - if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { 83 - match = rec; 84 + // fetch caps from each DID, find matching ref 85 + let match = null; 86 + for (const repoDid of dids) { 87 + try { 88 + const pds = await resolvePds(repoDid); 89 + if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 90 + const res = await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50); 91 + for (const rec of res.records) { 92 + if (rec.value.beacon !== beacon) continue; 93 + const recRef = resolveRef(rec.value, rec.cid); 94 + if (recRef === ref) { 95 + if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { 96 + match = rec; 97 + } 84 98 } 85 99 } 100 + } catch (err) { 101 + if (verbose) console.log(`[verbose] ${repoDid}: error fetching caps: ${err.message}`); 86 102 } 87 - } catch (err) { 88 - if (verbose) console.log(`[verbose] ${repoDid}: error fetching caps: ${err.message}`); 89 103 } 90 - } 91 104 92 - if (!match) { 93 - console.error(`no cap found with ref '${ref}' for this beacon.`); 94 - process.exitCode = 1; 95 - return; 96 - } 105 + if (!match) { 106 + console.error(`no cap found with ref '${ref}' for this beacon.`); 107 + process.exitCode = 1; 108 + return; 109 + } 97 110 98 - const record = match.value; 111 + const record = match.value; 99 112 100 - if (opts.trust) { 101 - appendLog('trusted.jsonl', { 102 - ref, 103 - uri: match.uri, 104 - trustedAt: new Date().toISOString(), 105 - }); 106 - console.log(`${mark} trusted: ${ref}`); 107 - return; 108 - } 113 + if (opts.trust) { 114 + appendLog('trusted.jsonl', { 115 + ref, 116 + uri: match.uri, 117 + trustedAt: new Date().toISOString(), 118 + }); 119 + console.log(`${mark} trusted: ${ref}`); 120 + return; 121 + } 109 122 110 - const author = match.uri.split('/')[2]; 111 - const title = record.title || ''; 112 - const description = record.description || ''; 113 - const text = record.text || ''; 123 + const author = match.uri.split('/')[2]; 124 + const title = record.title || ''; 125 + const description = record.description || ''; 126 + const text = record.text || ''; 114 127 115 - console.log(`=== ${brand} cap review ===`); 116 - console.log('Review this cap carefully before trusting it.'); 117 - console.log(''); 118 - console.log(` Ref: ${ref}`); 119 - if (title) console.log(` Title: ${title}`); 120 - console.log(` Author: ${author}`); 121 - if (description) { 128 + console.log(`=== ${brand} cap review ===`); 129 + console.log('Review this cap carefully before trusting it.'); 130 + console.log(''); 131 + console.log(` Ref: ${ref}`); 132 + if (title) console.log(` Title: ${title}`); 133 + console.log(` Author: ${author}`); 134 + if (description) { 135 + console.log(''); 136 + console.log(` ${description}`); 137 + } 138 + if (text) { 139 + console.log(''); 140 + console.log('--- Text ---'); 141 + console.log(text); 142 + console.log('---'); 143 + } 144 + console.log(''); 145 + console.log('To trust this cap, run:'); 146 + console.log(''); 147 + console.log(` vit vet ${ref} --trust`); 148 + } else { 149 + // Skill vet — no beacon required 150 + const skillName = nameFromSkillRef(ref); 151 + 152 + const { agent } = await restoreAgent(did); 153 + if (verbose) console.log('[verbose] session restored'); 154 + 155 + const following = readFollowing(); 156 + const dids = following.map(e => e.did); 157 + dids.push(did); 158 + if (verbose) console.log(`[verbose] querying ${dids.length} accounts`); 159 + 160 + let match = null; 161 + for (const repoDid of dids) { 162 + try { 163 + const pds = await resolvePds(repoDid); 164 + if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 165 + const res = await listRecordsFromPds(pds, repoDid, SKILL_COLLECTION, 50); 166 + for (const rec of res.records) { 167 + if (rec.value.name === skillName) { 168 + if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { 169 + match = rec; 170 + } 171 + } 172 + } 173 + } catch (err) { 174 + if (verbose) console.log(`[verbose] ${repoDid}: error fetching skills: ${err.message}`); 175 + } 176 + } 177 + 178 + if (!match) { 179 + console.error(`no skill found with ref '${ref}' from followed accounts.`); 180 + process.exitCode = 1; 181 + return; 182 + } 183 + 184 + const record = match.value; 185 + 186 + if (opts.trust) { 187 + appendLog('trusted.jsonl', { 188 + ref, 189 + uri: match.uri, 190 + trustedAt: new Date().toISOString(), 191 + }); 192 + console.log(`${mark} trusted: ${ref}`); 193 + return; 194 + } 195 + 196 + const author = match.uri.split('/')[2]; 197 + 198 + console.log(`=== ${brand} skill review ===`); 199 + console.log('Review this skill carefully before trusting it.'); 200 + console.log(''); 201 + console.log(` Ref: ${ref}`); 202 + console.log(` Name: ${record.name}`); 203 + console.log(` Author: ${author}`); 204 + if (record.version) console.log(` Version: ${record.version}`); 205 + if (record.license) console.log(` License: ${record.license}`); 206 + if (record.description) { 207 + console.log(''); 208 + console.log(` ${record.description}`); 209 + } 210 + if (record.compatibility) { 211 + console.log(''); 212 + console.log(` Compatibility: ${record.compatibility}`); 213 + } 214 + if (record.text) { 215 + console.log(''); 216 + console.log('--- SKILL.md ---'); 217 + console.log(record.text); 218 + console.log('---'); 219 + } 220 + if (record.resources && record.resources.length > 0) { 221 + console.log(''); 222 + console.log('Resources:'); 223 + for (const r of record.resources) { 224 + const desc = r.description ? ` — ${r.description}` : ''; 225 + console.log(` ${r.path}${desc}`); 226 + } 227 + } 228 + if (record.tags && record.tags.length > 0) { 229 + console.log(''); 230 + console.log(` Tags: ${record.tags.join(', ')}`); 231 + } 122 232 console.log(''); 123 - console.log(` ${description}`); 124 - } 125 - if (text) { 233 + console.log('To trust this skill, run:'); 126 234 console.log(''); 127 - console.log('--- Text ---'); 128 - console.log(text); 129 - console.log('---'); 235 + console.log(` vit vet ${ref} --trust`); 130 236 } 131 - console.log(''); 132 - console.log('To trust this cap, run:'); 133 - console.log(''); 134 - console.log(` vit vet ${ref} --trust`); 135 237 } catch (err) { 136 238 console.error(err instanceof Error ? err.message : String(err)); 137 239 process.exitCode = 1;
+158 -73
src/cmd/vouch.js
··· 2 2 // Copyright (c) 2026 sol pbc 3 3 4 4 import { requireDid } from '../lib/config.js'; 5 - import { CAP_COLLECTION, VOUCH_COLLECTION } from '../lib/constants.js'; 5 + import { CAP_COLLECTION, SKILL_COLLECTION, VOUCH_COLLECTION } from '../lib/constants.js'; 6 6 import { TID } from '@atproto/common-web'; 7 7 import { restoreAgent } from '../lib/oauth.js'; 8 8 import { appendLog, readProjectConfig, readFollowing, readLog } from '../lib/vit-dir.js'; 9 9 import { resolveRef, REF_PATTERN } from '../lib/cap-ref.js'; 10 + import { isSkillRef, isValidSkillRef, nameFromSkillRef } from '../lib/skill-ref.js'; 10 11 import { mark, name } from '../lib/brand.js'; 12 + import { resolvePds, listRecordsFromPds } from '../lib/pds.js'; 11 13 12 14 export default function register(program) { 13 15 program 14 16 .command('vouch') 15 - .argument('<ref>', 'Three-word cap reference (e.g. fast-cache-invalidation)') 16 - .description('Publicly endorse a vetted cap') 17 + .argument('<ref>', 'Cap or skill reference (e.g. fast-cache-invalidation or skill-agent-test-patterns)') 18 + .description('Publicly endorse a vetted cap or skill') 17 19 .option('--did <did>', 'DID to use') 18 20 .option('-v, --verbose', 'Show step-by-step details') 19 21 .action(async (ref, opts) => { 20 22 try { 21 23 const { verbose } = opts; 24 + const isSkill = isSkillRef(ref); 22 25 23 - if (!REF_PATTERN.test(ref)) { 24 - console.error('invalid ref. expected three lowercase words with dashes (e.g. fast-cache-invalidation)'); 25 - process.exitCode = 1; 26 - return; 26 + // Validate ref format 27 + if (isSkill) { 28 + if (!isValidSkillRef(ref)) { 29 + console.error('invalid skill ref. expected format: skill-{name} (lowercase letters, numbers, hyphens)'); 30 + process.exitCode = 1; 31 + return; 32 + } 33 + } else { 34 + if (!REF_PATTERN.test(ref)) { 35 + console.error('invalid ref. expected three lowercase words with dashes (e.g. fast-cache-invalidation)'); 36 + process.exitCode = 1; 37 + return; 38 + } 27 39 } 28 40 29 41 const did = requireDid(opts); 30 42 if (!did) return; 31 43 if (verbose) console.log(`[verbose] DID: ${did}`); 32 44 33 - const projectConfig = readProjectConfig(); 34 - const beacon = projectConfig.beacon; 35 - if (!beacon) { 36 - console.error(`no beacon set. run '${name} init' in a project directory first.`); 37 - process.exitCode = 1; 38 - return; 39 - } 40 - if (verbose) console.log(`[verbose] beacon: ${beacon}`); 41 - 45 + // Check trusted 42 46 const trusted = readLog('trusted.jsonl'); 43 47 const trustedEntry = trusted.find(e => e.ref === ref); 44 48 if (!trustedEntry) { 45 - console.error(`cap '${ref}' is not yet vetted. vet it first:`); 49 + const itemType = isSkill ? 'skill' : 'cap'; 50 + console.error(`${itemType} '${ref}' is not yet vetted. vet it first:`); 46 51 console.error(''); 47 52 console.error(` vit vet ${ref}`); 48 53 console.error(''); ··· 57 62 const { agent } = await restoreAgent(did); 58 63 if (verbose) console.log('[verbose] session restored'); 59 64 60 - const following = readFollowing(); 61 - const dids = following.map(e => e.did); 62 - dids.push(did); 63 - if (verbose) console.log(`[verbose] querying ${dids.length} accounts`); 65 + if (isSkill) { 66 + // Skill vouch — no beacon required 67 + const skillName = nameFromSkillRef(ref); 68 + 69 + const following = readFollowing(); 70 + const dids = following.map(e => e.did); 71 + dids.push(did); 72 + if (verbose) console.log(`[verbose] querying ${dids.length} accounts`); 64 73 65 - let match = null; 66 - for (const repoDid of dids) { 74 + let match = null; 75 + for (const repoDid of dids) { 76 + try { 77 + const pds = await resolvePds(repoDid); 78 + if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 79 + const res = await listRecordsFromPds(pds, repoDid, SKILL_COLLECTION, 50); 80 + for (const rec of res.records) { 81 + if (rec.value.name === skillName) { 82 + if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { 83 + match = rec; 84 + } 85 + } 86 + } 87 + } catch (err) { 88 + if (verbose) console.log(`[verbose] ${repoDid}: error fetching skills: ${err.message}`); 89 + } 90 + } 91 + 92 + if (!match) { 93 + console.error(`no skill found with ref '${ref}' from followed accounts.`); 94 + process.exitCode = 1; 95 + return; 96 + } 97 + 98 + const now = new Date().toISOString(); 99 + const vouchRecord = { 100 + $type: VOUCH_COLLECTION, 101 + subject: { 102 + uri: match.uri, 103 + cid: match.cid, 104 + }, 105 + createdAt: now, 106 + ref, 107 + // No beacon for skill vouches 108 + }; 109 + if (verbose) console.log(`[verbose] creating vouch for ${match.uri}`); 110 + const rkey = TID.nextStr(); 111 + const res = await agent.com.atproto.repo.putRecord({ 112 + repo: did, 113 + collection: VOUCH_COLLECTION, 114 + rkey, 115 + record: vouchRecord, 116 + validate: false, 117 + }); 118 + 67 119 try { 68 - const res = await agent.com.atproto.repo.listRecords({ 69 - repo: repoDid, 70 - collection: CAP_COLLECTION, 71 - limit: 50, 120 + appendLog('vouched.jsonl', { 121 + ref, 122 + uri: match.uri, 123 + cid: match.cid, 124 + vouchUri: res.data.uri, 125 + ts: now, 72 126 }); 73 - for (const rec of res.data.records) { 74 - if (rec.value.beacon !== beacon) continue; 75 - const recRef = resolveRef(rec.value, rec.cid); 76 - if (recRef === ref) { 77 - if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { 78 - match = rec; 127 + } catch (logErr) { 128 + console.error('warning: failed to write vouched.jsonl:', logErr.message); 129 + } 130 + if (verbose) console.log('[verbose] logged to vouched.jsonl'); 131 + 132 + console.log(`${mark} vouched: ${ref} (${match.uri})`); 133 + } else { 134 + // Cap vouch — requires beacon 135 + const projectConfig = readProjectConfig(); 136 + const beacon = projectConfig.beacon; 137 + if (!beacon) { 138 + console.error(`no beacon set. run '${name} init' in a project directory first.`); 139 + process.exitCode = 1; 140 + return; 141 + } 142 + if (verbose) console.log(`[verbose] beacon: ${beacon}`); 143 + 144 + const following = readFollowing(); 145 + const dids = following.map(e => e.did); 146 + dids.push(did); 147 + if (verbose) console.log(`[verbose] querying ${dids.length} accounts`); 148 + 149 + let match = null; 150 + for (const repoDid of dids) { 151 + try { 152 + const res = await agent.com.atproto.repo.listRecords({ 153 + repo: repoDid, 154 + collection: CAP_COLLECTION, 155 + limit: 50, 156 + }); 157 + for (const rec of res.data.records) { 158 + if (rec.value.beacon !== beacon) continue; 159 + const recRef = resolveRef(rec.value, rec.cid); 160 + if (recRef === ref) { 161 + if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { 162 + match = rec; 163 + } 79 164 } 80 165 } 166 + } catch (err) { 167 + if (verbose) console.log(`[verbose] ${repoDid}: error fetching caps: ${err.message}`); 81 168 } 82 - } catch (err) { 83 - if (verbose) console.log(`[verbose] ${repoDid}: error fetching caps: ${err.message}`); 84 169 } 85 - } 86 170 87 - if (!match) { 88 - console.error(`no cap found with ref '${ref}' for this beacon.`); 89 - process.exitCode = 1; 90 - return; 91 - } 171 + if (!match) { 172 + console.error(`no cap found with ref '${ref}' for this beacon.`); 173 + process.exitCode = 1; 174 + return; 175 + } 92 176 93 - const now = new Date().toISOString(); 94 - const vouchRecord = { 95 - $type: VOUCH_COLLECTION, 96 - subject: { 97 - uri: match.uri, 98 - cid: match.cid, 99 - }, 100 - createdAt: now, 101 - ref, 102 - beacon, 103 - }; 104 - if (verbose) console.log(`[verbose] creating vouch for ${match.uri}`); 105 - const rkey = TID.nextStr(); 106 - const res = await agent.com.atproto.repo.putRecord({ 107 - repo: did, 108 - collection: VOUCH_COLLECTION, 109 - rkey, 110 - record: vouchRecord, 111 - validate: false, 112 - }); 113 - 114 - try { 115 - appendLog('vouched.jsonl', { 177 + const now = new Date().toISOString(); 178 + const vouchRecord = { 179 + $type: VOUCH_COLLECTION, 180 + subject: { 181 + uri: match.uri, 182 + cid: match.cid, 183 + }, 184 + createdAt: now, 116 185 ref, 117 - uri: match.uri, 118 - cid: match.cid, 119 - vouchUri: res.data.uri, 120 186 beacon, 121 - ts: now, 187 + }; 188 + if (verbose) console.log(`[verbose] creating vouch for ${match.uri}`); 189 + const rkey = TID.nextStr(); 190 + const res = await agent.com.atproto.repo.putRecord({ 191 + repo: did, 192 + collection: VOUCH_COLLECTION, 193 + rkey, 194 + record: vouchRecord, 195 + validate: false, 122 196 }); 123 - } catch (logErr) { 124 - console.error('warning: failed to write vouched.jsonl:', logErr.message); 125 - } 126 - if (verbose) console.log('[verbose] logged to vouched.jsonl'); 127 197 128 - console.log(`${mark} vouched: ${ref} (${match.uri})`); 198 + try { 199 + appendLog('vouched.jsonl', { 200 + ref, 201 + uri: match.uri, 202 + cid: match.cid, 203 + vouchUri: res.data.uri, 204 + beacon, 205 + ts: now, 206 + }); 207 + } catch (logErr) { 208 + console.error('warning: failed to write vouched.jsonl:', logErr.message); 209 + } 210 + if (verbose) console.log('[verbose] logged to vouched.jsonl'); 211 + 212 + console.log(`${mark} vouched: ${ref} (${match.uri})`); 213 + } 129 214 } catch (err) { 130 215 console.error(err instanceof Error ? err.message : String(err)); 131 216 process.exitCode = 1;