open source is social v-it.org
0
fork

Configure Feed

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

feat(explore): add cap/skill detail commands and bare explore overview

Add singular explore detail commands for caps and skills, including JSON output and not-found handling.

Make bare 'vit explore' reuse the stats overview path and add regression coverage for the new detail and overview flows.

+235 -32
+182 -32
src/cmd/explore.js
··· 6 6 import { brand } from '../lib/brand.js'; 7 7 import { jsonOk, jsonError } from '../lib/json-output.js'; 8 8 9 + function timeAgo(isoString) { 10 + const seconds = Math.floor((Date.now() - new Date(isoString).getTime()) / 1000); 11 + if (seconds < 60) return `${seconds}s ago`; 12 + const minutes = Math.floor(seconds / 60); 13 + if (minutes < 60) return `${minutes}m ago`; 14 + const hours = Math.floor(minutes / 60); 15 + if (hours < 24) return `${hours}h ago`; 16 + const days = Math.floor(hours / 24); 17 + if (days < 30) return `${days}d ago`; 18 + const months = Math.floor(days / 30); 19 + if (months < 12) return `${months}mo ago`; 20 + const years = Math.floor(days / 365); 21 + return `${years}y ago`; 22 + } 23 + 9 24 function resolveUrl(opts) { 10 25 return opts.exploreUrl || process.env.VIT_EXPLORE_URL || DEFAULT_EXPLORE_URL; 11 26 } ··· 15 30 return `${new URL(baseUrl).host} is unavailable. try 'vit scan' for network-wide discovery or 'vit skim' for your followed accounts.`; 16 31 } catch { 17 32 return `${baseUrl} is unavailable. try 'vit scan' for network-wide discovery or 'vit skim' for your followed accounts.`; 33 + } 34 + } 35 + 36 + async function runStats(opts) { 37 + const baseUrl = resolveUrl(opts); 38 + try { 39 + const url = new URL('/api/stats', baseUrl); 40 + const res = await fetch(url); 41 + if (!res.ok) throw new Error(`explore API returned ${res.status}`); 42 + const data = await res.json(); 43 + 44 + if (opts.json) { 45 + jsonOk(data); 46 + return; 47 + } 48 + 49 + console.log(`${brand} explore stats`); 50 + console.log(` caps: ${data.total_caps} skills: ${data.total_skills}`); 51 + console.log(` vouches: ${data.total_vouches} beacons: ${data.total_beacons}`); 52 + console.log(` active dids: ${data.active_dids} skill publishers: ${data.skill_publishers}`); 53 + } catch (err) { 54 + const msg = err instanceof Error ? err.message : String(err); 55 + const finalMsg = msg.startsWith('explore API returned ') 56 + ? msg 57 + : unavailableMessage(baseUrl); 58 + if (opts.json) { 59 + jsonError(finalMsg); 60 + return; 61 + } 62 + console.error(finalMsg); 63 + process.exitCode = 1; 18 64 } 19 65 } 20 66 ··· 95 141 }); 96 142 97 143 explore 144 + .command('cap') 145 + .description('Show details for a single cap') 146 + .argument('<ref>', 'Cap ref slug') 147 + .option('--beacon <beacon>', 'Scope lookup to a specific beacon') 148 + .option('--json', 'Output as JSON') 149 + .option('--explore-url <url>', 'Explore API base URL') 150 + .action(async (ref, opts) => { 151 + const baseUrl = resolveUrl(opts); 152 + 153 + try { 154 + const url = new URL('/api/cap', baseUrl); 155 + url.searchParams.set('ref', ref); 156 + if (opts.beacon) url.searchParams.set('beacon', opts.beacon); 157 + 158 + const res = await fetch(url); 159 + if (!res.ok) throw new Error(`explore API returned ${res.status}`); 160 + const data = await res.json(); 161 + 162 + if (!data.cap) { 163 + const msg = `no cap found with ref '${ref}'`; 164 + if (opts.json) { 165 + jsonError(msg); 166 + return; 167 + } 168 + console.error(msg); 169 + process.exitCode = 1; 170 + return; 171 + } 172 + 173 + if (opts.json) { 174 + jsonOk(data); 175 + return; 176 + } 177 + 178 + const cap = data.cap; 179 + let record = {}; 180 + try { record = JSON.parse(cap.record_json); } catch {} 181 + 182 + console.log(`${brand} explore cap`); 183 + const kindTag = record.kind ? ` [${record.kind}]` : ''; 184 + console.log(` ${cap.title}${kindTag}`); 185 + console.log(` ${cap.description}`); 186 + if (cap.beacon) console.log(` beacon: ${cap.beacon}`); 187 + console.log(` author: @${cap.handle}`); 188 + console.log(` ref: ${cap.ref}`); 189 + console.log(` shipped: ${timeAgo(cap.created_at)}`); 190 + if (record.text) { 191 + console.log(''); 192 + console.log(` ${record.text}`); 193 + } 194 + console.log(''); 195 + console.log(` vouches: ${cap.vouch_count}`); 196 + console.log(''); 197 + console.log(' commands:'); 198 + console.log(` vit vouch ${cap.ref}`); 199 + console.log(` vit vet ${cap.ref}`); 200 + } catch (err) { 201 + const msg = err instanceof Error ? err.message : String(err); 202 + const finalMsg = msg.startsWith('explore API returned ') 203 + ? msg 204 + : unavailableMessage(baseUrl); 205 + if (opts.json) { 206 + jsonError(finalMsg); 207 + return; 208 + } 209 + console.error(finalMsg); 210 + process.exitCode = 1; 211 + } 212 + }); 213 + 214 + explore 98 215 .command('skills') 99 216 .description('List published skills from the explore index') 100 217 .option('--tag <tag>', 'Filter by tag') ··· 133 250 if (data.cursor) { 134 251 console.log(`\nnext: --cursor ${data.cursor}`); 135 252 } 253 + } catch (err) { 254 + const msg = err instanceof Error ? err.message : String(err); 255 + const finalMsg = msg.startsWith('explore API returned ') 256 + ? msg 257 + : unavailableMessage(baseUrl); 258 + if (opts.json) { 259 + jsonError(finalMsg); 260 + return; 261 + } 262 + console.error(finalMsg); 263 + process.exitCode = 1; 264 + } 265 + }); 266 + 267 + explore 268 + .command('skill') 269 + .description('Show details for a single skill') 270 + .argument('<name>', 'Skill name') 271 + .option('--json', 'Output as JSON') 272 + .option('--explore-url <url>', 'Explore API base URL') 273 + .action(async (name, opts) => { 274 + const baseUrl = resolveUrl(opts); 275 + 276 + try { 277 + const url = new URL('/api/skill', baseUrl); 278 + url.searchParams.set('name', name); 279 + 280 + const res = await fetch(url); 281 + if (!res.ok) throw new Error(`explore API returned ${res.status}`); 282 + const data = await res.json(); 283 + 284 + if (!data.skill) { 285 + const msg = `no skill found with name '${name}'`; 286 + if (opts.json) { 287 + jsonError(msg); 288 + return; 289 + } 290 + console.error(msg); 291 + process.exitCode = 1; 292 + return; 293 + } 294 + 295 + if (opts.json) { 296 + jsonOk(data); 297 + return; 298 + } 299 + 300 + const skill = data.skill; 301 + let record = {}; 302 + try { record = JSON.parse(skill.record_json); } catch {} 303 + 304 + console.log(`${brand} explore skill`); 305 + console.log(` ${skill.name} v${skill.version}`); 306 + console.log(` ${skill.description}`); 307 + console.log(` author: @${skill.handle}`); 308 + if (record.license) console.log(` license: ${record.license}`); 309 + if (skill.tags) console.log(` tags: ${skill.tags}`); 310 + console.log(''); 311 + console.log(` vouches: ${skill.vouch_count}`); 312 + console.log(''); 313 + console.log(' commands:'); 314 + console.log(` vit learn skill-${skill.name}`); 315 + console.log(` vit adopt <beacon> skill-${skill.name}`); 136 316 } catch (err) { 137 317 const msg = err instanceof Error ? err.message : String(err); 138 318 const finalMsg = msg.startsWith('explore API returned ') ··· 277 457 }); 278 458 279 459 explore 280 - .command('stats') 460 + .command('stats', { isDefault: true }) 281 461 .description('Show network-wide stats from the explore index') 282 462 .option('--json', 'Output as JSON') 283 463 .option('--explore-url <url>', 'Explore API base URL') 284 - .action(async (opts) => { 285 - const baseUrl = resolveUrl(opts); 286 - 287 - try { 288 - const url = new URL('/api/stats', baseUrl); 289 - const res = await fetch(url); 290 - if (!res.ok) throw new Error(`explore API returned ${res.status}`); 291 - const data = await res.json(); 292 - 293 - if (opts.json) { 294 - jsonOk(data); 295 - return; 296 - } 297 - 298 - console.log(`${brand} explore stats`); 299 - console.log(` caps: ${data.total_caps} skills: ${data.total_skills}`); 300 - console.log(` vouches: ${data.total_vouches} beacons: ${data.total_beacons}`); 301 - console.log(` active dids: ${data.active_dids} skill publishers: ${data.skill_publishers}`); 302 - } catch (err) { 303 - const msg = err instanceof Error ? err.message : String(err); 304 - const finalMsg = msg.startsWith('explore API returned ') 305 - ? msg 306 - : unavailableMessage(baseUrl); 307 - if (opts.json) { 308 - jsonError(finalMsg); 309 - return; 310 - } 311 - console.error(finalMsg); 312 - process.exitCode = 1; 313 - } 314 - }); 464 + .action(async (opts) => runStats(opts)); 315 465 }
+53
test/explore.test.js
··· 84 84 const data = JSON.parse(result.stdout); 85 85 expect(data.ok).toBe(true); 86 86 }); 87 + 88 + test('cap detail returns JSON', () => { 89 + const result = run('explore cap network-content-seeding --json', '/tmp'); 90 + expect(result.exitCode).toBe(0); 91 + const data = JSON.parse(result.stdout); 92 + expect(data.ok).toBe(true); 93 + expect(data.cap).toBeDefined(); 94 + expect(data.cap.ref).toBe('network-content-seeding'); 95 + expect(data.cap.title).toBeDefined(); 96 + }); 97 + 98 + test('cap detail with beacon', () => { 99 + const result = run('explore cap network-content-seeding --beacon vit:github.com/solpbc/vit --json', '/tmp'); 100 + expect(result.exitCode).toBe(0); 101 + const data = JSON.parse(result.stdout); 102 + expect(data.ok).toBe(true); 103 + expect(data.cap).toBeDefined(); 104 + expect(data.cap.ref).toBe('network-content-seeding'); 105 + }); 106 + 107 + test('cap not found', () => { 108 + const result = run('explore cap nonexistent-ref-xyz --json', '/tmp'); 109 + expect(result.exitCode).not.toBe(0); 110 + const data = JSON.parse(result.stdout); 111 + expect(data.ok).toBe(false); 112 + expect(data.error).toContain("no cap found with ref 'nonexistent-ref-xyz'"); 113 + }); 114 + 115 + test('skill detail returns JSON', () => { 116 + const result = run('explore skill atproto-records --json', '/tmp'); 117 + expect(result.exitCode).toBe(0); 118 + const data = JSON.parse(result.stdout); 119 + expect(data.ok).toBe(true); 120 + expect(data.skill).toBeDefined(); 121 + expect(data.skill.name).toBe('atproto-records'); 122 + expect(data.skill.version).toBeDefined(); 123 + }); 124 + 125 + test('skill not found', () => { 126 + const result = run('explore skill nonexistent-skill-xyz --json', '/tmp'); 127 + expect(result.exitCode).not.toBe(0); 128 + const data = JSON.parse(result.stdout); 129 + expect(data.ok).toBe(false); 130 + expect(data.error).toContain("no skill found with name 'nonexistent-skill-xyz'"); 131 + }); 132 + 133 + test('bare explore returns stats JSON', () => { 134 + const result = run('explore --json', '/tmp'); 135 + expect(result.exitCode).toBe(0); 136 + const data = JSON.parse(result.stdout); 137 + expect(data.ok).toBe(true); 138 + expect(typeof data.total_caps).toBe('number'); 139 + }); 87 140 });