open source is social v-it.org
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 sol pbc
3
4import { requireDid } from '../lib/config.js';
5import { CAP_COLLECTION, SKILL_COLLECTION } from '../lib/constants.js';
6import { restoreAgent } from '../lib/oauth.js';
7import { readBeaconSet, readFollowing } from '../lib/vit-dir.js';
8import { requireAgent } from '../lib/agent.js';
9import { resolveRef } from '../lib/cap-ref.js';
10import { skillRefFromName } from '../lib/skill-ref.js';
11import { name } from '../lib/brand.js';
12import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js';
13import { jsonError } from '../lib/json-output.js';
14import { formatError } from '../lib/error-format.js';
15
16export default function register(program) {
17 program
18 .command('skim')
19 .description('Read caps and skills from followed accounts')
20 .option('--did <did>', 'DID to use')
21 .option('--handle <handle>', 'Show items from a specific handle only')
22 .option('--limit <n>', 'Max items to display', '25')
23 .option('--json', 'Output as JSON array')
24 .option('--caps', 'Show only caps')
25 .option('--skills', 'Show only skills')
26 .option('--kind <kind>', 'Filter caps by kind (e.g. request, feat, fix)')
27 .option('-v, --verbose', 'Show step-by-step details')
28 .action(async (opts) => {
29 try {
30 const gate = requireAgent();
31 if (!gate.ok) {
32 console.error(`${name} skim should be run by a coding agent (e.g. claude code, gemini cli).`);
33 console.error(`open your agent and ask it to run '${name} skim' for you.`);
34 process.exitCode = 1;
35 return;
36 }
37
38 const { verbose } = opts;
39 const did = requireDid(opts);
40 if (!did) return;
41 if (verbose) console.log(`[verbose] DID: ${did}`);
42
43 const beaconSet = readBeaconSet();
44
45 const wantCaps = !opts.skills;
46 const wantSkills = !opts.caps;
47 const skillsOnly = opts.skills && !opts.caps;
48
49 // Beacon required unless --skills only mode
50 if (beaconSet.size === 0 && !skillsOnly) {
51 console.error(`no beacon set. run '${name} init' in a project directory first.`);
52 process.exitCode = 1;
53 return;
54 }
55
56 if (verbose && beaconSet.size > 0) console.log(`[verbose] beacons: ${[...beaconSet].join(', ')}`);
57
58 const { agent } = await restoreAgent(did);
59 if (verbose) console.log('[verbose] session restored');
60
61 // build list of DIDs to query and DID→handle map
62 const handleMap = new Map();
63 let dids;
64 if (opts.handle) {
65 const handle = opts.handle.replace(/^@/, '');
66 const resolved = await agent.resolveHandle({ handle });
67 dids = [resolved.data.did];
68 handleMap.set(resolved.data.did, handle);
69 if (verbose) console.log(`[verbose] resolved ${handle} to ${resolved.data.did}`);
70 } else {
71 const following = readFollowing();
72 for (const e of following) handleMap.set(e.did, e.handle);
73 dids = following.map(e => e.did);
74 dids.push(did);
75 }
76
77 // resolve own handle if not already known
78 if (!handleMap.has(did)) {
79 try {
80 const desc = await agent.com.atproto.repo.describeRepo({ repo: did });
81 handleMap.set(did, desc.data.handle);
82 } catch {
83 if (verbose) console.log(`[verbose] could not resolve handle for ${did}`);
84 }
85 }
86
87 // fetch from each DID
88 const allItems = [];
89
90 const batchResults = await batchQuery(dids, async (repoDid) => {
91 const pds = await resolvePds(repoDid);
92 if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`);
93 const items = [];
94
95 // Fetch caps (filtered by beacon)
96 if (wantCaps && beaconSet.size > 0) {
97 const res = await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50);
98 let caps = res.records.filter(r => beaconSet.has(r.value.beacon));
99 if (opts.kind) {
100 caps = caps.filter(r => r.value.kind === opts.kind);
101 }
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 items.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 items.push(...res.records);
120 } catch (err) {
121 if (verbose) console.log(`[verbose] ${repoDid}: error fetching skills: ${err.message}`);
122 }
123 }
124
125 return items;
126 }, { verbose });
127
128 for (const items of batchResults) {
129 allItems.push(...items);
130 }
131
132 // sort by createdAt descending
133 allItems.sort((a, b) => {
134 const ta = a.value.createdAt || '';
135 const tb = b.value.createdAt || '';
136 return tb.localeCompare(ta);
137 });
138
139 // apply limit
140 const limit = parseInt(opts.limit, 10);
141 const capped = allItems.slice(0, limit);
142
143 if (opts.json) {
144 if (capped.length === 0) {
145 const following = readFollowing();
146 let hint;
147 if (skillsOnly) {
148 hint = "no skills found — try 'vit explore skills' or ship your own with 'vit ship --skill'";
149 } else if (following.length === 0) {
150 hint = "not following anyone — run 'vit follow <handle>'";
151 } else {
152 hint = "no matching caps — try 'vit explore caps' or 'vit ship'";
153 }
154 console.log(JSON.stringify({ ok: true, items: [], hint }, null, 2));
155 } else {
156 console.log(JSON.stringify(capped, null, 2));
157 }
158 } else {
159 if (capped.length === 0) {
160 if (skillsOnly) {
161 console.log('no skills found from followed accounts.');
162 console.log('');
163 console.log("try 'vit explore skills' to discover skills network-wide, or ship your own with 'vit ship --skill'.");
164 } else {
165 const following = readFollowing();
166 if (following.length === 0) {
167 console.log("no caps or skills found. you're not following anyone yet and haven't shipped any caps for this beacon.");
168 console.log('');
169 console.log('next steps:');
170 console.log(' vit scan discover active publishers on the network');
171 console.log(' vit follow <handle> start following someone to see their caps');
172 console.log(' vit ship publish a cap to seed the network');
173 } else {
174 console.log('no caps found for this beacon from your followed accounts.');
175 console.log('');
176 console.log("the network grows when people ship. publish a cap with 'vit ship' to get things started for this project.");
177 console.log("try 'vit explore caps' for network-wide discovery.");
178 }
179 }
180 }
181 for (const rec of capped) {
182 if (rec._type === 'skill') {
183 const skillRef = skillRefFromName(rec.value.name);
184 const skillName = rec.value.name || '';
185 const description = rec.value.description || '';
186 const version = rec.value.version;
187 const tags = rec.value.tags;
188 console.log(`ref: ${skillRef}`);
189 console.log(`by: @${rec._handle}`);
190 console.log(`type: skill${version ? ' v' + version : ''}`);
191 if (skillName) console.log(`title: ${skillName}`);
192 if (description) console.log(`description: ${description}`);
193 if (tags && tags.length > 0) console.log(`tags: ${tags.join(', ')}`);
194 console.log();
195 } else {
196 const ref = resolveRef(rec.value, rec.cid);
197 const title = rec.value.title || '';
198 const description = rec.value.description || '';
199 console.log(`ref: ${ref}`);
200 console.log(`by: @${rec._handle}`);
201 console.log(`type: cap`);
202 if (title) console.log(`title: ${title}`);
203 if (description) console.log(`description: ${description}`);
204 console.log();
205 }
206 }
207 if (capped.length > 0) {
208 console.log('---');
209 console.log(`hint: tell your operator to run '${name} vet <ref>' in another terminal for any item they want to review.`);
210 }
211 }
212 } catch (err) {
213 if (opts.json) {
214 jsonError(err);
215 return;
216 }
217 console.error(formatError(err, { verbose: opts.verbose }));
218 process.exitCode = 1;
219 }
220 });
221}