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