open source is social v-it.org
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 sol pbc
3
4import { DEFAULT_EXPLORE_URL } from '../lib/constants.js';
5import { readProjectConfig } from '../lib/vit-dir.js';
6import { brand } from '../lib/brand.js';
7import { jsonOk, jsonError } from '../lib/json-output.js';
8
9function 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
24function resolveUrl(opts) {
25 return opts.exploreUrl || process.env.VIT_EXPLORE_URL || DEFAULT_EXPLORE_URL;
26}
27
28function unavailableMessage(baseUrl) {
29 try {
30 return `${new URL(baseUrl).host} is unavailable. try 'vit scan' for network-wide discovery or 'vit skim' for your followed accounts.`;
31 } catch {
32 return `${baseUrl} is unavailable. try 'vit scan' for network-wide discovery or 'vit skim' for your followed accounts.`;
33 }
34}
35
36function mergeExploreOpts(opts, command) {
37 return {
38 ...(command?.parent?.opts?.() || {}),
39 ...(command?.opts?.() || opts || {}),
40 };
41}
42
43async function fetchAndShowStats(opts) {
44 const baseUrl = resolveUrl(opts);
45 try {
46 const url = new URL('/api/stats', baseUrl);
47 const res = await fetch(url);
48 if (!res.ok) throw new Error(`explore API returned ${res.status}`);
49 const data = await res.json();
50
51 if (opts.json) {
52 jsonOk(data);
53 return;
54 }
55
56 console.log(`${brand} explore stats`);
57 console.log(` caps: ${data.total_caps} skills: ${data.total_skills}`);
58 console.log(` vouches: ${data.total_vouches} beacons: ${data.total_beacons}`);
59 console.log(` active dids: ${data.active_dids} skill publishers: ${data.skill_publishers}`);
60 } catch (err) {
61 const msg = err instanceof Error ? err.message : String(err);
62 const finalMsg = msg.startsWith('explore API returned ')
63 ? msg
64 : unavailableMessage(baseUrl);
65 if (opts.json) {
66 jsonError(finalMsg);
67 return;
68 }
69 console.error(finalMsg);
70 process.exitCode = 1;
71 }
72}
73
74export default function register(program) {
75 const explore = program
76 .command('explore')
77 .description('Query the explore index for caps, skills, beacons, vouches, and stats')
78 .option('--json', 'Output as JSON')
79 .option('--explore-url <url>', 'Explore API base URL')
80 .action(async (opts) => {
81 await fetchAndShowStats(opts);
82 });
83
84 explore
85 .command('cap')
86 .argument('<ref>', 'Cap ref to look up')
87 .description('Show details for a single cap')
88 .option('--beacon <beacon>', 'Scope lookup to a beacon')
89 .option('--json', 'Output as JSON')
90 .option('--explore-url <url>', 'Explore API base URL')
91 .action(async (ref, opts, command) => {
92 opts = mergeExploreOpts(opts, command);
93 const baseUrl = resolveUrl(opts);
94
95 try {
96 const url = new URL('/api/cap', baseUrl);
97 url.searchParams.set('ref', ref);
98 if (opts.beacon) url.searchParams.set('beacon', opts.beacon);
99
100 const res = await fetch(url);
101 if (!res.ok) throw new Error(`explore API returned ${res.status}`);
102 const data = await res.json();
103
104 if (!data.cap) {
105 const msg = `no cap found with ref '${ref}'`;
106 if (opts.json) {
107 jsonError(msg);
108 return;
109 }
110 console.error(msg);
111 process.exitCode = 1;
112 return;
113 }
114
115 if (opts.json) {
116 jsonOk(data);
117 return;
118 }
119
120 const record = JSON.parse(data.cap.record_json);
121 console.log(`${brand} explore cap`);
122 console.log(` ${data.cap.title} [${record.kind}]`);
123 console.log(` ${data.cap.description}`);
124 console.log();
125 console.log(` beacon: ${data.cap.beacon}`);
126 console.log(` author: @${data.cap.handle}`);
127 console.log(` ref: ${data.cap.ref}`);
128 console.log(` posted: ${timeAgo(data.cap.created_at)}`);
129 if (record.text) {
130 console.log();
131 console.log(` ${record.text}`);
132 }
133 console.log();
134 console.log(` vouches: ${data.cap.vouch_count}`);
135 console.log();
136 console.log(` vit vet ${data.cap.ref} - inspect before adopting`);
137 console.log(` vit remix ${data.cap.ref} - remix this cap`);
138 } catch (err) {
139 const msg = err instanceof Error ? err.message : String(err);
140 const finalMsg = msg.startsWith('explore API returned ')
141 ? msg
142 : unavailableMessage(baseUrl);
143 if (opts.json) {
144 jsonError(finalMsg);
145 return;
146 }
147 console.error(finalMsg);
148 process.exitCode = 1;
149 }
150 });
151
152 explore
153 .command('skill')
154 .argument('<name>', 'Skill name to look up')
155 .description('Show details for a single skill')
156 .option('--json', 'Output as JSON')
157 .option('--explore-url <url>', 'Explore API base URL')
158 .action(async (name, opts, command) => {
159 opts = mergeExploreOpts(opts, command);
160 const baseUrl = resolveUrl(opts);
161
162 try {
163 const url = new URL('/api/skill', baseUrl);
164 url.searchParams.set('name', name);
165
166 const res = await fetch(url);
167 if (!res.ok) throw new Error(`explore API returned ${res.status}`);
168 const data = await res.json();
169
170 if (!data.skill) {
171 const msg = `no skill found with name '${name}'`;
172 if (opts.json) {
173 jsonError(msg);
174 return;
175 }
176 console.error(msg);
177 process.exitCode = 1;
178 return;
179 }
180
181 if (opts.json) {
182 jsonOk(data);
183 return;
184 }
185
186 const record = JSON.parse(data.skill.record_json);
187 console.log(`${brand} explore skill`);
188 console.log(` /${data.skill.name} v${data.skill.version}`);
189 console.log(` ${data.skill.description}`);
190 console.log();
191 console.log(` author: @${data.skill.handle}`);
192 if (record.license) console.log(` license: ${record.license}`);
193 if (data.skill.tags) console.log(` tags: ${data.skill.tags}`);
194 console.log(` vouches: ${data.skill.vouch_count}`);
195 console.log();
196 console.log(` vit learn skill-${data.skill.name} - install this skill`);
197 } catch (err) {
198 const msg = err instanceof Error ? err.message : String(err);
199 const finalMsg = msg.startsWith('explore API returned ')
200 ? msg
201 : unavailableMessage(baseUrl);
202 if (opts.json) {
203 jsonError(finalMsg);
204 return;
205 }
206 console.error(finalMsg);
207 process.exitCode = 1;
208 }
209 });
210
211 explore
212 .command('caps')
213 .description('List recent caps from the explore index')
214 .option('--beacon <beacon>', 'Filter by beacon')
215 .option('--kind <kind>', 'Filter by cap kind (e.g. request, feat, fix)')
216 .option('--limit <n>', 'Limit number of caps')
217 .option('--cursor <id>', 'Pagination cursor')
218 .option('--json', 'Output as JSON')
219 .option('--explore-url <url>', 'Explore API base URL')
220 .action(async (opts, command) => {
221 opts = mergeExploreOpts(opts, command);
222 const baseUrl = resolveUrl(opts);
223
224 try {
225 let beacon = opts.beacon;
226 if (beacon === '.') {
227 const config = readProjectConfig();
228 const beacons = [config.beacon, config.secondaryBeacon].filter(Boolean);
229 if (beacons.length === 0) {
230 const msg = "no beacon set — run 'vit init' first";
231 if (opts.json) {
232 jsonError(msg);
233 return;
234 }
235 console.error(msg);
236 process.exitCode = 1;
237 return;
238 }
239 beacon = beacons.join(',');
240 }
241
242 const url = new URL('/api/caps', baseUrl);
243 if (beacon) url.searchParams.set('beacon', beacon);
244 if (opts.kind) url.searchParams.set('kind', opts.kind);
245 if (opts.limit) url.searchParams.set('limit', opts.limit);
246 if (opts.cursor) url.searchParams.set('cursor', opts.cursor);
247
248 const res = await fetch(url);
249 if (!res.ok) throw new Error(`explore API returned ${res.status}`);
250 const data = await res.json();
251
252 if (opts.json) {
253 jsonOk({ caps: data.caps, cursor: data.cursor });
254 return;
255 }
256
257 console.log(`${brand} explore caps`);
258 if (!data.caps?.length) {
259 console.log('no caps found.');
260 console.log('the network is just getting started. ship a cap or skill to be one of the first.');
261 console.log("try 'vit scan' for real-time discovery — the explore index may still be catching up.");
262 return;
263 }
264
265 for (const cap of data.caps) {
266 console.log(` ${cap.title} (${cap.ref})`);
267 console.log(` @${cap.handle} ${cap.beacon}`);
268 console.log(` ${cap.description}`);
269 }
270 if (data.cursor) {
271 console.log(`\nnext: --cursor ${data.cursor}`);
272 }
273 } catch (err) {
274 const msg = err instanceof Error ? err.message : String(err);
275 const finalMsg = msg.startsWith('explore API returned ')
276 ? msg
277 : unavailableMessage(baseUrl);
278 if (opts.json) {
279 jsonError(finalMsg);
280 return;
281 }
282 console.error(finalMsg);
283 process.exitCode = 1;
284 }
285 });
286
287 explore
288 .command('skills')
289 .description('List published skills from the explore index')
290 .option('--tag <tag>', 'Filter by tag')
291 .option('--limit <n>', 'Limit number of skills')
292 .option('--cursor <id>', 'Pagination cursor')
293 .option('--json', 'Output as JSON')
294 .option('--explore-url <url>', 'Explore API base URL')
295 .action(async (opts, command) => {
296 opts = mergeExploreOpts(opts, command);
297 const baseUrl = resolveUrl(opts);
298
299 try {
300 const url = new URL('/api/skills', baseUrl);
301 if (opts.tag) url.searchParams.set('tag', opts.tag);
302 if (opts.limit) url.searchParams.set('limit', opts.limit);
303 if (opts.cursor) url.searchParams.set('cursor', opts.cursor);
304
305 const res = await fetch(url);
306 if (!res.ok) throw new Error(`explore API returned ${res.status}`);
307 const data = await res.json();
308
309 if (opts.json) {
310 jsonOk({ skills: data.skills, cursor: data.cursor });
311 return;
312 }
313
314 console.log(`${brand} explore skills`);
315 if (!data.skills?.length) {
316 console.log('no skills found.');
317 console.log('the network is just getting started. ship a cap or skill to be one of the first.');
318 return;
319 }
320
321 for (const skill of data.skills) {
322 console.log(` ${skill.name} v${skill.version} (${skill.ref})`);
323 console.log(` @${skill.handle} ${skill.description}`);
324 }
325 if (data.cursor) {
326 console.log(`\nnext: --cursor ${data.cursor}`);
327 }
328 } catch (err) {
329 const msg = err instanceof Error ? err.message : String(err);
330 const finalMsg = msg.startsWith('explore API returned ')
331 ? msg
332 : unavailableMessage(baseUrl);
333 if (opts.json) {
334 jsonError(finalMsg);
335 return;
336 }
337 console.error(finalMsg);
338 process.exitCode = 1;
339 }
340 });
341
342 explore
343 .command('beacons')
344 .description('List active beacons from the explore index')
345 .option('--json', 'Output as JSON')
346 .option('--explore-url <url>', 'Explore API base URL')
347 .action(async (opts, command) => {
348 opts = mergeExploreOpts(opts, command);
349 const baseUrl = resolveUrl(opts);
350
351 try {
352 const url = new URL('/api/beacons', baseUrl);
353 const res = await fetch(url);
354 if (!res.ok) throw new Error(`explore API returned ${res.status}`);
355 const data = await res.json();
356
357 if (opts.json) {
358 jsonOk({ beacons: data.beacons });
359 return;
360 }
361
362 console.log(`${brand} explore beacons`);
363 if (!data.beacons?.length) {
364 console.log('no beacons found.');
365 console.log('the network is just getting started. ship a cap or skill to be one of the first.');
366 return;
367 }
368
369 for (const beacon of data.beacons) {
370 console.log(` ${beacon.name}`);
371 console.log(` caps: ${beacon.cap_count} vouches: ${beacon.vouch_count} last active: ${beacon.last_activity}`);
372 }
373 } catch (err) {
374 const msg = err instanceof Error ? err.message : String(err);
375 const finalMsg = msg.startsWith('explore API returned ')
376 ? msg
377 : unavailableMessage(baseUrl);
378 if (opts.json) {
379 jsonError(finalMsg);
380 return;
381 }
382 console.error(finalMsg);
383 process.exitCode = 1;
384 }
385 });
386
387 explore
388 .command('vouches')
389 .description('List vouches for a cap from the explore index')
390 .option('--cap <uri>', 'Cap URI')
391 .option('--ref <ref>', 'Cap ref')
392 .option('--beacon <beacon>', 'Filter ref lookup by beacon')
393 .option('--json', 'Output as JSON')
394 .option('--explore-url <url>', 'Explore API base URL')
395 .action(async (opts, command) => {
396 opts = mergeExploreOpts(opts, command);
397 const baseUrl = resolveUrl(opts);
398
399 try {
400 if ((!opts.cap && !opts.ref) || (opts.cap && opts.ref)) {
401 const msg = 'provide --cap <uri> or --ref <ref>';
402 if (opts.json) {
403 jsonError(msg);
404 return;
405 }
406 console.error(msg);
407 process.exitCode = 1;
408 return;
409 }
410
411 let capUri = opts.cap;
412 if (opts.ref) {
413 const capsUrl = new URL('/api/caps', baseUrl);
414 if (opts.beacon) capsUrl.searchParams.set('beacon', opts.beacon);
415
416 const capsRes = await fetch(capsUrl);
417 if (!capsRes.ok) throw new Error(`explore API returned ${capsRes.status}`);
418 const capsData = await capsRes.json();
419 const match = capsData.caps?.find((cap) => cap.ref === opts.ref);
420
421 if (!match) {
422 const msg = `no cap found with ref '${opts.ref}'`;
423 if (opts.json) {
424 jsonError(msg);
425 return;
426 }
427 console.error(msg);
428 process.exitCode = 1;
429 return;
430 }
431
432 capUri = match.uri;
433 }
434
435 const url = new URL('/api/vouches', baseUrl);
436 url.searchParams.set('cap_uri', capUri);
437
438 const res = await fetch(url);
439 if (!res.ok) throw new Error(`explore API returned ${res.status}`);
440 const data = await res.json();
441
442 if (opts.json) {
443 jsonOk({ vouches: data.vouches, cap_uri: capUri });
444 return;
445 }
446
447 console.log(`${brand} explore vouches`);
448 if (!data.vouches?.length) {
449 console.log('no vouches found for this cap.');
450 return;
451 }
452
453 for (const vouch of data.vouches) {
454 const who = vouch.handle ? `@${vouch.handle}` : (vouch.did || 'unknown');
455 const createdAt = vouch.created_at || vouch.createdAt || 'unknown';
456 const ref = vouch.ref || vouch.cap_ref || vouch.cap_uri || '';
457 console.log(` ${who} ${createdAt}`);
458 if (ref) console.log(` ${ref}`);
459 }
460 } catch (err) {
461 const msg = err instanceof Error ? err.message : String(err);
462 const finalMsg = msg.startsWith('explore API returned ')
463 ? msg
464 : unavailableMessage(baseUrl);
465 if (opts.json) {
466 jsonError(finalMsg);
467 return;
468 }
469 console.error(finalMsg);
470 process.exitCode = 1;
471 }
472 });
473
474 explore
475 .command('stats', { isDefault: true })
476 .description('Show network-wide stats from the explore index')
477 .option('--json', 'Output as JSON')
478 .option('--explore-url <url>', 'Explore API base URL')
479 .action(async (opts, command) => {
480 opts = mergeExploreOpts(opts, command);
481 await fetchAndShowStats(opts);
482 });
483}