···1010import registerInit from './cmd/init.js';
1111import registerLogin from './cmd/login.js';
1212import registerFirehose from './cmd/firehose.js';
1313+import registerScan from './cmd/scan.js';
1314import registerRemix from './cmd/remix.js';
1415import registerShip from './cmd/ship.js';
1516import registerSkim from './cmd/skim.js';
···3334registerInit(program);
3435registerLogin(program);
3536registerFirehose(program);
3737+registerScan(program);
3638registerShip(program);
3739registerSkim(program);
3840registerRemix(program);
+10-1
src/cmd/firehose.js
···113113 .description('Listen to Jetstream for cap events')
114114 .option('-v, --verbose', 'Show full JSON for each event')
115115 .option('--did <did>', 'Filter by DID (reads saved DID from config if not provided)')
116116+ .option('--global', 'Show cap events from all DIDs across the network')
116117 .option('--collection <nsid>', 'Collection NSID to filter', CAP_COLLECTION)
117118 .action(async (opts) => {
118119 try {
119119- if (!opts.did) {
120120+ if (opts.global && opts.did) {
121121+ console.error('error: --global and --did are mutually exclusive');
122122+ process.exitCode = 1;
123123+ return;
124124+ }
125125+126126+ if (opts.global) {
127127+ opts.did = undefined;
128128+ } else if (!opts.did) {
120129 const config = loadConfig();
121130 if (config.did) {
122131 opts.did = config.did;
+5-6
src/cmd/remix.js
···88import { requireAgent } from '../lib/agent.js';
99import { resolveRef, REF_PATTERN } from '../lib/cap-ref.js';
1010import { brand, name } from '../lib/brand.js';
1111+import { resolvePds, listRecordsFromPds } from '../lib/pds.js';
11121213export default function register(program) {
1314 program
···7374 let match = null;
7475 for (const repoDid of dids) {
7576 try {
7676- const res = await agent.com.atproto.repo.listRecords({
7777- repo: repoDid,
7878- collection: CAP_COLLECTION,
7979- limit: 50,
8080- });
8181- for (const rec of res.data.records) {
7777+ const pds = await resolvePds(repoDid);
7878+ if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`);
7979+ const res = await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50);
8080+ for (const rec of res.records) {
8281 if (rec.value.beacon !== beacon) continue;
8382 const recRef = resolveRef(rec.value, rec.cid);
8483 if (recRef === ref) {
+117
src/cmd/scan.js
···11+// SPDX-License-Identifier: AGPL-3.0-only
22+// Copyright (c) 2026 sol pbc
33+44+import { CAP_COLLECTION } from '../lib/constants.js';
55+import { resolveRef } from '../lib/cap-ref.js';
66+import { resolveHandleFromDid } from '../lib/pds.js';
77+import { brand } from '../lib/brand.js';
88+99+const JETSTREAM_URL = 'wss://jetstream2.us-east.bsky.network/subscribe';
1010+1111+export default function register(program) {
1212+ program
1313+ .command('scan')
1414+ .description('Discover cap publishers across the network via Jetstream replay')
1515+ .option('--days <n>', 'Number of days to replay', '7')
1616+ .option('--beacon <beacon>', 'Filter by beacon')
1717+ .option('-v, --verbose', 'Show each event as it arrives')
1818+ .action(async (opts) => {
1919+ try {
2020+ const days = parseInt(opts.days, 10);
2121+ if (isNaN(days) || days < 1) {
2222+ console.error('error: --days must be a positive integer');
2323+ process.exitCode = 1;
2424+ return;
2525+ }
2626+2727+ const cursor = (Date.now() - days * 86400000) * 1000;
2828+ const timeout = Math.max(120000, Math.min(600000, days * 60000));
2929+3030+ const url = new URL(JETSTREAM_URL);
3131+ url.searchParams.set('wantedCollections', CAP_COLLECTION);
3232+ url.searchParams.set('cursor', String(cursor));
3333+3434+ console.log(`${brand} scan`);
3535+ console.log(` Replaying ${days} day${days === 1 ? '' : 's'} of cap events...`);
3636+ if (opts.beacon) console.log(` Beacon filter: ${opts.beacon}`);
3737+ console.log(` Timeout: ${Math.round(timeout / 1000)}s`);
3838+ console.log('');
3939+4040+ const publishers = new Map();
4141+4242+ await new Promise((resolve, reject) => {
4343+ const ws = new WebSocket(url.toString());
4444+ const timer = setTimeout(() => {
4545+ ws.close();
4646+ resolve();
4747+ }, timeout);
4848+4949+ ws.onmessage = (event) => {
5050+ let msg;
5151+ try { msg = JSON.parse(event.data); } catch { return; }
5252+5353+ if (msg.kind !== 'commit' || msg.commit?.operation !== 'create') return;
5454+5555+ const record = msg.commit?.record;
5656+ if (!record) return;
5757+5858+ if (opts.beacon && record.beacon !== opts.beacon) return;
5959+6060+ const did = msg.did;
6161+ const ref = msg.commit?.cid ? resolveRef(record, msg.commit.cid) : null;
6262+6363+ if (opts.verbose) {
6464+ const didShort = did.slice(-12);
6565+ const title = record.title || '';
6666+ const refPart = ref ? ` (${ref})` : '';
6767+ console.log(` ${didShort}: ${title}${refPart} [${record.beacon || 'no beacon'}]`);
6868+ }
6969+7070+ if (!publishers.has(did)) {
7171+ publishers.set(did, { count: 0, beacons: new Set(), lastActive: '' });
7272+ }
7373+ const entry = publishers.get(did);
7474+ entry.count++;
7575+ if (record.beacon) entry.beacons.add(record.beacon);
7676+ if (record.createdAt && record.createdAt > entry.lastActive) {
7777+ entry.lastActive = record.createdAt;
7878+ }
7979+ };
8080+8181+ ws.onerror = (err) => {
8282+ clearTimeout(timer);
8383+ reject(new Error(`WebSocket error: ${err?.message ?? 'unknown'}`));
8484+ };
8585+8686+ ws.onclose = () => {
8787+ clearTimeout(timer);
8888+ resolve();
8989+ };
9090+ });
9191+9292+ if (publishers.size === 0) {
9393+ console.log('no cap publishers found in this time window.');
9494+ return;
9595+ }
9696+9797+ const entries = [];
9898+ for (const [did, stats] of publishers) {
9999+ const handle = await resolveHandleFromDid(did);
100100+ entries.push({ handle, did, ...stats, beacons: [...stats.beacons] });
101101+ }
102102+103103+ entries.sort((a, b) => b.count - a.count);
104104+105105+ console.log(`found ${entries.length} publisher${entries.length === 1 ? '' : 's'}:\n`);
106106+ for (const e of entries) {
107107+ const beaconStr = e.beacons.length > 0 ? e.beacons.join(', ') : '(none)';
108108+ const lastActive = e.lastActive ? e.lastActive.split('T')[0] : 'unknown';
109109+ console.log(` @${e.handle}`);
110110+ console.log(` caps: ${e.count} beacons: ${beaconStr} last active: ${lastActive}`);
111111+ }
112112+ } catch (err) {
113113+ console.error(err instanceof Error ? err.message : String(err));
114114+ process.exitCode = 1;
115115+ }
116116+ });
117117+}
+5-6
src/cmd/ship.js
···1010import { appendLog, readProjectConfig, readLog, readFollowing } from '../lib/vit-dir.js';
1111import { REF_PATTERN, resolveRef } from '../lib/cap-ref.js';
1212import { name } from '../lib/brand.js';
1313+import { resolvePds, listRecordsFromPds } from '../lib/pds.js';
13141415export default function register(program) {
1516 program
···104105 let match = null;
105106 for (const repoDid of dids) {
106107 try {
107107- const res = await agent.com.atproto.repo.listRecords({
108108- repo: repoDid,
109109- collection: CAP_COLLECTION,
110110- limit: 50,
111111- });
112112- for (const rec of res.data.records) {
108108+ const pds = await resolvePds(repoDid);
109109+ if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`);
110110+ const res = await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50);
111111+ for (const rec of res.records) {
113112 const recRef = resolveRef(rec.value, rec.cid);
114113 if (recRef === opts.recap) {
115114 if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) {
+6-7
src/cmd/skim.js
···88import { requireAgent } from '../lib/agent.js';
99import { resolveRef } from '../lib/cap-ref.js';
1010import { name } from '../lib/brand.js';
1111+import { resolvePds, listRecordsFromPds } from '../lib/pds.js';
11121213export default function register(program) {
1314 program
···7677 const allCaps = [];
7778 for (const repoDid of dids) {
7879 try {
7979- const res = await agent.com.atproto.repo.listRecords({
8080- repo: repoDid,
8181- collection: CAP_COLLECTION,
8282- limit: 50,
8383- });
8484- const caps = res.data.records.filter(r => r.value.beacon === beacon);
8585- if (verbose) console.log(`[verbose] ${repoDid}: ${res.data.records.length} caps, ${caps.length} matching beacon`);
8080+ const pds = await resolvePds(repoDid);
8181+ if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`);
8282+ const res = await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50);
8383+ const caps = res.records.filter(r => r.value.beacon === beacon);
8484+ if (verbose) console.log(`[verbose] ${repoDid}: ${res.records.length} caps, ${caps.length} matching beacon`);
8685 for (const cap of caps) cap._handle = handleMap.get(repoDid) || repoDid;
8786 allCaps.push(...caps);
8887 } catch (err) {
+5-6
src/cmd/vet.js
···88import { requireNotAgent } from '../lib/agent.js';
99import { resolveRef, REF_PATTERN } from '../lib/cap-ref.js';
1010import { mark, brand, name } from '../lib/brand.js';
1111+import { resolvePds, listRecordsFromPds } from '../lib/pds.js';
11121213export default function register(program) {
1314 program
···7172 let match = null;
7273 for (const repoDid of dids) {
7374 try {
7474- const res = await agent.com.atproto.repo.listRecords({
7575- repo: repoDid,
7676- collection: CAP_COLLECTION,
7777- limit: 50,
7878- });
7979- for (const rec of res.data.records) {
7575+ const pds = await resolvePds(repoDid);
7676+ if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`);
7777+ const res = await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50);
7878+ for (const rec of res.records) {
8079 if (rec.value.beacon !== beacon) continue;
8180 const recRef = resolveRef(rec.value, rec.cid);
8281 if (recRef === ref) {
+34
src/lib/pds.js
···11+// SPDX-License-Identifier: AGPL-3.0-only
22+// Copyright (c) 2026 sol pbc
33+44+const PLC_DIRECTORY = 'https://plc.directory';
55+const pdsCache = new Map();
66+77+export async function resolvePds(did) {
88+ if (pdsCache.has(did)) return pdsCache.get(did);
99+ const res = await fetch(`${PLC_DIRECTORY}/${did}`);
1010+ if (!res.ok) throw new Error(`failed to resolve PDS for ${did}: ${res.status} ${res.statusText}`);
1111+ const doc = await res.json();
1212+ const pds = doc.service?.find(s => s.type === 'AtprotoPersonalDataServer');
1313+ if (!pds?.serviceEndpoint) throw new Error(`no PDS found in DID document for ${did}`);
1414+ pdsCache.set(did, pds.serviceEndpoint);
1515+ return pds.serviceEndpoint;
1616+}
1717+1818+export async function listRecordsFromPds(pdsUrl, repo, collection, limit) {
1919+ const url = new URL('/xrpc/com.atproto.repo.listRecords', pdsUrl);
2020+ url.searchParams.set('repo', repo);
2121+ url.searchParams.set('collection', collection);
2222+ if (limit) url.searchParams.set('limit', String(limit));
2323+ const res = await fetch(url);
2424+ if (!res.ok) throw new Error(`listRecords failed for ${repo}: ${res.status} ${res.statusText}`);
2525+ return res.json();
2626+}
2727+2828+export async function resolveHandleFromDid(did) {
2929+ const res = await fetch(`${PLC_DIRECTORY}/${did}`);
3030+ if (!res.ok) return did;
3131+ const doc = await res.json();
3232+ const aka = doc.alsoKnownAs?.find(a => a.startsWith('at://'));
3333+ return aka ? aka.replace('at://', '') : did;
3434+}