open source is social v-it.org
0
fork

Configure Feed

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

feat: add @handle/skill-name qualified ref to vit learn

+328 -85
+238 -84
src/cmd/learn.js
··· 2 2 // Copyright (c) 2026 sol pbc 3 3 4 4 import { spawnSync } from 'node:child_process'; 5 - import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; 5 + import { existsSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; 6 6 import { join, dirname } from 'node:path'; 7 7 import { homedir, tmpdir } from 'node:os'; 8 8 import { requireDid } from '../lib/config.js'; 9 9 import { SKILL_COLLECTION } from '../lib/constants.js'; 10 10 import { restoreAgent } from '../lib/oauth.js'; 11 - import { readFollowing, readLog, appendLog } from '../lib/vit-dir.js'; 11 + import { readFollowing, readLog, appendLog, vitDir } from '../lib/vit-dir.js'; 12 12 import { requireAgent, detectCodingAgent } from '../lib/agent.js'; 13 13 import { shouldBypassVet } from '../lib/trust-gate.js'; 14 - import { isSkillRef, nameFromSkillRef, isValidSkillRef } from '../lib/skill-ref.js'; 14 + import { isSkillRef, nameFromSkillRef, isValidSkillRef, isValidSkillName } from '../lib/skill-ref.js'; 15 15 import { mark, name } from '../lib/brand.js'; 16 - import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 16 + import { resolvePds, resolveHandle, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 17 17 import { loadConfig } from '../lib/config.js'; 18 18 import { jsonOk, jsonError } from '../lib/json-output.js'; 19 19 20 + async 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 + 102 + async 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 + 20 211 export default function register(program) { 21 212 program 22 213 .command('learn') 23 - .argument('<ref>', 'Skill reference (e.g. skill-agent-test-patterns)') 24 - .description('Install a skill from the network into your skill directory') 25 - .option('--did <did>', 'DID to use') 26 - .option('--user', 'Install to user-wide ~/.claude/skills/ (requires vet)') 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') 27 220 .option('--json', 'Output as JSON') 28 221 .option('-v, --verbose', 'Show step-by-step details') 222 + .addHelpText('after', ` 223 + Examples: 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 + `) 29 231 .action(async (ref, opts) => { 30 232 try { 233 + if (ref.startsWith('@')) { 234 + await learnFromHandle(ref, opts); 235 + return; 236 + } 237 + 31 238 const gate = requireAgent(); 32 239 if (!gate.ok) { 33 240 if (opts.json) { ··· 170 377 const record = match.value; 171 378 if (verbose) vlog(`[verbose] found skill: ${record.name} from ${match.uri}`); 172 379 173 - // Install via skills CLI 174 - const tempDir = mkdtempSync(join(tmpdir(), 'vit-learn-')); 175 - try { 176 - writeFileSync(join(tempDir, 'SKILL.md'), record.text); 177 - if (verbose) vlog('[verbose] wrote SKILL.md to temp dir'); 178 - 179 - // Download resource blobs to temp dir 180 - if (record.resources && record.resources.length > 0) { 181 - const authorDid = match.uri.split('/')[2]; 182 - const pds = await resolvePds(authorDid); 183 - 184 - for (const resource of record.resources) { 185 - const resourcePath = join(tempDir, resource.path); 186 - mkdirSync(dirname(resourcePath), { recursive: true }); 187 - 188 - try { 189 - // Download blob from PDS 190 - const blobCid = resource.blob?.ref?.$link || resource.blob?.cid; 191 - if (blobCid) { 192 - const blobUrl = new URL('/xrpc/com.atproto.sync.getBlob', pds); 193 - blobUrl.searchParams.set('did', authorDid); 194 - blobUrl.searchParams.set('cid', blobCid); 195 - const blobRes = await fetch(blobUrl); 196 - if (!blobRes.ok) throw new Error(`blob fetch failed: ${blobRes.status}`); 197 - const blobData = Buffer.from(await blobRes.arrayBuffer()); 198 - writeFileSync(resourcePath, blobData); 199 - if (verbose) vlog(`[verbose] wrote resource: ${resource.path}`); 200 - } 201 - } catch (err) { 202 - console.error(`warning: failed to download resource ${resource.path}: ${err.message}`); 203 - } 204 - } 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; 205 392 } 206 - 207 - // Delegate to skills CLI 208 - const addArgs = ['skills', 'add', tempDir, '-a', 'claude-code', '-y']; 209 - if (isUserInstall) addArgs.push('-g'); 210 - const addResult = spawnSync('npx', addArgs, { 211 - encoding: 'utf-8', 212 - stdio: ['pipe', 'pipe', 'pipe'], 213 - }); 214 - if (addResult.status !== 0) { 215 - const errText = (addResult.stderr || addResult.stdout || '').trim(); 216 - throw new Error(`skill install failed: ${errText || 'unknown error'}`); 217 - } 218 - if (verbose) vlog('[verbose] installed via npx skills add'); 219 - } finally { 220 - try { rmSync(tempDir, { recursive: true, force: true }); } catch {} 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; 221 403 } 222 404 223 - // Determine install path for logging 224 - const installDir = isUserInstall 225 - ? join(homedir(), '.claude', 'skills', skillName) 226 - : join(process.cwd(), '.claude', 'skills', skillName); 227 - 228 - // Log to learned.jsonl 229 - try { 230 - appendLog('learned.jsonl', { 231 - ref, 232 - name: skillName, 233 - uri: match.uri, 234 - cid: match.cid, 235 - installedTo: installDir, 236 - scope: isUserInstall ? 'user' : 'project', 237 - learnedAt: new Date().toISOString(), 238 - version: record.version || null, 239 - }); 240 - } catch (logErr) { 241 - console.error('warning: failed to write learned.jsonl:', logErr.message); 242 - } 243 - 244 - const scope = isUserInstall ? 'user' : 'project'; 245 - if (opts.json) { 246 - jsonOk({ ref, name: skillName, installedTo: installDir, scope, version: record.version || null }); 247 - return; 248 - } 249 - console.log(`${mark} learned: ${ref} (${scope})`); 250 - console.log(`installed to: ${installDir}`); 251 - if (record.version) console.log(`version: ${record.version}`); 405 + await installSkill({ match, skillName, isGlobal: !!opts.user, opts, ref }); 252 406 } catch (err) { 253 407 const msg = err instanceof Error ? err.message : String(err); 254 408 if (opts.json) {
+8
src/lib/pds.js
··· 61 61 } 62 62 } 63 63 64 + export async function resolveHandle(handle) { 65 + const url = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 66 + const res = await fetch(url); 67 + if (!res.ok) throw new Error(`could not resolve handle: ${handle}`); 68 + const data = await res.json(); 69 + return data.did; 70 + } 71 + 64 72 export async function batchQuery(items, fn, { batchSize = 10, verbose = false } = {}) { 65 73 if (verbose) console.log(`[verbose] querying ${items.length} accounts in batches of ${batchSize}`); 66 74 const results = [];
+62
test/learn.test.js
··· 112 112 rmSync(tmp, { recursive: true, force: true }); 113 113 }); 114 114 }); 115 + 116 + describe('vit learn @handle/', () => { 117 + test('parses @handle/name format', () => { 118 + const r = run('learn @test.example/my-skill', '/tmp'); 119 + expect(r.exitCode).not.toBe(0); 120 + expect(r.stderr).not.toContain('invalid skill ref'); 121 + }); 122 + 123 + test('rejects @handle/ with no skill name', () => { 124 + const r = run('learn @test.example/', '/tmp'); 125 + expect(r.exitCode).not.toBe(0); 126 + expect(r.stderr).toContain('invalid ref'); 127 + }); 128 + 129 + test('rejects @/name with no handle', () => { 130 + const r = run('learn @/my-skill', '/tmp'); 131 + expect(r.exitCode).not.toBe(0); 132 + expect(r.stderr).toContain('invalid ref'); 133 + }); 134 + 135 + test('rejects handle without dot', () => { 136 + const r = run('learn @localhost/my-skill', '/tmp'); 137 + expect(r.exitCode).not.toBe(0); 138 + expect(r.stderr).toContain('invalid handle'); 139 + }); 140 + 141 + test('rejects invalid skill name', () => { 142 + const r = run('learn @test.example/Bad-Name', '/tmp'); 143 + expect(r.exitCode).not.toBe(0); 144 + expect(r.stderr).toContain('invalid skill name'); 145 + }); 146 + 147 + test('trailing dot sets project-local', () => { 148 + const r = run('learn @test.example/my-skill.', '/tmp'); 149 + expect(r.exitCode).not.toBe(0); 150 + expect(r.stderr).not.toContain('invalid skill name'); 151 + }); 152 + 153 + test('@handle/ path does NOT require agent env', () => { 154 + const r = run('learn @test.example/my-skill', '/tmp', { CLAUDECODE: '', GEMINI_CLI: '', CODEX_CI: '' }); 155 + expect(r.exitCode).not.toBe(0); 156 + expect(r.stderr).not.toContain('should be run by a coding agent'); 157 + }); 158 + 159 + test('@handle/ path does NOT require .vit/ dir', () => { 160 + const r = run('learn @test.example/my-skill', '/tmp'); 161 + expect(r.exitCode).not.toBe(0); 162 + expect(r.stderr).not.toContain('not yet vetted'); 163 + }); 164 + 165 + test('--project flag accepted', () => { 166 + const r = run('learn @test.example/my-skill --project', '/tmp'); 167 + expect(r.exitCode).not.toBe(0); 168 + expect(r.stderr).not.toContain('unknown option'); 169 + }); 170 + 171 + test('--dry-run flag accepted', () => { 172 + const r = run('learn @test.example/my-skill --dry-run', '/tmp'); 173 + expect(r.exitCode).not.toBe(0); 174 + expect(r.stderr).not.toContain('unknown option'); 175 + }); 176 + }); 115 177 });
+20 -1
test/pds.test.js
··· 2 2 // Copyright (c) 2026 sol pbc 3 3 4 4 import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; 5 - import { resolvePds, resolveHandleFromDid, listRecordsFromPds, batchQuery } from '../src/lib/pds.js'; 5 + import { resolvePds, resolveHandleFromDid, resolveHandle, listRecordsFromPds, batchQuery } from '../src/lib/pds.js'; 6 6 7 7 function jsonResponse(data, { ok = true, status = 200, statusText = 'OK' } = {}) { 8 8 return { ··· 125 125 }; 126 126 127 127 await expect(resolveHandleFromDid('did:web:example.com:fallback')).resolves.toBe('did:web:example.com:fallback'); 128 + }); 129 + }); 130 + 131 + describe('resolveHandle', () => { 132 + test('resolves handle to DID', async () => { 133 + let fetchedUrl; 134 + global.fetch = async (input) => { 135 + fetchedUrl = String(input); 136 + return jsonResponse({ did: 'did:plc:test123' }); 137 + }; 138 + 139 + await expect(resolveHandle('test.example')).resolves.toBe('did:plc:test123'); 140 + expect(fetchedUrl).toBe('https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=test.example'); 141 + }); 142 + 143 + test('throws on non-ok response', async () => { 144 + global.fetch = async () => jsonResponse({}, { ok: false, status: 404, statusText: 'Not Found' }); 145 + 146 + await expect(resolveHandle('nonexistent.test')).rejects.toThrow('could not resolve handle: nonexistent.test'); 128 147 }); 129 148 }); 130 149