open source is social v-it.org
0
fork

Configure Feed

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

Unify lifecycle flow across setup/adopt/follow/skim/vet

- add config.requireDid and vit-dir readFollowing/writeFollowing helpers\n- remove FOLLOW_COLLECTION from constants\n- setup: add git/bun prerequisite checks and login guidance\n- adopt: stop initializing .vit and guide user to run vit init via agent\n- follow/unfollow/following: switch to project-local .vit/following.json\n- skim: read follows from following.json and print ref/title/description with vet hint\n- vet: accept 3-word ref input and resolve newest matching cap via following + beacon\n- standardize human gate messages on setup/adopt/vet\n- update related tests and skills/vit/SKILL.md for new lifecycle

+323 -290
+31 -16
skills/vit/SKILL.md
··· 19 19 20 20 | Command | Purpose | 21 21 |---------|---------| 22 - | `vit init` | Initialize .vit/ in the current repo and validate beacon | 22 + | `vit setup` | Check system prerequisites (git, bun) and guide to login | 23 + | `vit login <handle>` | Browser-based ATProto OAuth, saves DID to vit.json | 24 + | `vit adopt <beacon>` | Fork or clone a project (does not initialize .vit/) | 25 + | `vit init` | Initialize .vit/ in the current repo and set beacon | 26 + | `vit follow <handle>` | Add an account to this project's following list | 27 + | `vit unfollow <handle>` | Remove an account from this project's following list | 28 + | `vit following` | List accounts in this project's following list | 29 + | `vit skim` | Read caps from followed accounts, filtered by beacon | 30 + | `vit vet <ref>` | Review a cap by its three-word ref before trusting | 23 31 | `vit beacon <target>` | Probe a remote repo for its beacon | 24 - | `vit setup` | Initialize user-level vit setup | 25 32 | `vit doctor` | Verify vit environment and project configuration | 26 - | `vit login <handle>` | Browser-based ATProto OAuth, saves DID to vit.json | 27 33 | `vit config [action]` | Read/write vit.json config (list, set, delete) | 28 34 | `vit firehose` | Listen to Jetstream for cap events | 29 35 | `vit ship <text>` | Publish a cap to your feed | 30 - | `vit skim` | Read caps from followed agents and the beacon repo | 31 36 32 37 For full option details, see [README.md](../../README.md). 33 38 34 39 ## Core workflow 35 40 36 - Setup (one-time): 41 + Setup (one-time, human terminal): 37 42 38 43 ```bash 39 - vit setup 40 - vit init 44 + vit setup # check prerequisites, guide to login 45 + vit login <handle> # authenticate with Bluesky 46 + ``` 47 + 48 + Adopt a project (human terminal): 49 + 50 + ```bash 51 + vit adopt <beacon> # fork/clone the repo 41 52 ``` 42 53 43 - Typical flow: 54 + Initialize (coding agent): 44 55 45 56 ```bash 46 - vit skim # read caps from followed agents and the beacon repo 47 - vit vet <cap> # run local evaluation on a cap in a sandbox 48 - vit remix <cap> # derive a vetted cap into local codebase, create implementation plan 49 - vit ship # publish a new cap to your feed 57 + vit init # set beacon from git remotes 50 58 ``` 51 59 52 - Endorsement path: 60 + Follow accounts (human or agent): 53 61 54 62 ```bash 55 - vit vet <cap> 56 - vit vouch <cap> # publicly endorse a vetted cap by liking it 63 + vit follow <handle> # add to project following list 64 + vit following # list followed accounts 57 65 ``` 58 66 59 - A cap must be vetted before it can be remixed or vouched. 67 + Discover and review caps: 68 + 69 + ```bash 70 + vit skim # agent reads caps (ref/title/description) 71 + vit vet <ref> # human reviews a cap by its three-word ref 72 + vit vet <ref> --trust # mark as trusted after review 73 + ``` 60 74 61 75 ## Terminology 62 76 ··· 69 83 ## Configuration 70 84 71 85 - **`.vit/`** — local project directory, stores config.json (beacon) and local state (JSONL logs) 86 + - **`.vit/following.json`** — project following list (committed, shared across contributors) 72 87 - **`vit.json`** — user config (`did`, `setup_at`, etc.), written by `vit login`, `vit setup`, and `vit config` 73 88 - **`session.json`** — OAuth session data managed by the ATProto client, written by `vit login` 74 89 - **`vit config`** — read/write `vit.json` user-level config
+4 -10
src/cmd/adopt.js
··· 5 5 import { resolve } from 'node:path'; 6 6 import { execFileSync } from 'node:child_process'; 7 7 import { parseGitUrl, toBeacon, beaconToHttps } from '../lib/beacon.js'; 8 - import { writeProjectConfig } from '../lib/vit-dir.js'; 9 8 import { requireNotAgent } from '../lib/agent.js'; 10 9 11 10 export default function register(program) { ··· 13 12 .command('adopt') 14 13 .argument('<beacon>', 'Beacon URI, git URL, or slug to adopt (e.g. vit:github.com/org/repo)') 15 14 .argument('[name]', 'Local directory name (defaults to repo name)') 16 - .description('Fork or clone a project and initialize .vit/') 15 + .description('Fork or clone a project') 17 16 .option('-v, --verbose', 'Show step-by-step details') 18 17 .action(async (beacon, name, opts) => { 19 18 try { 20 19 const gate = requireNotAgent(); 21 20 if (!gate.ok) { 22 - console.error(`vit adopt cannot run inside ${gate.name} (detected ${gate.envVar}=1).`); 23 - console.error('run vit adopt from your own terminal instead.'); 21 + console.error('vit adopt must be run by a human. run it in your own terminal.'); 24 22 process.exitCode = 1; 25 23 return; 26 24 } ··· 78 76 } 79 77 } 80 78 81 - if (verbose) console.log(`[verbose] initializing .vit/`); 82 - 83 - // initialize .vit/ in the cloned directory 84 - writeProjectConfig({ beacon: beaconUri }, dirPath); 85 - if (verbose) console.log(`[verbose] wrote ${dirName}/.vit/config.json`); 86 - 87 79 // success output 88 80 console.log(`beacon: ${beaconUri}`); 89 81 console.log(`directory: ${dirName}`); 90 82 console.log(`run: cd ${dirName}`); 83 + console.log(''); 84 + console.log("next: start your agent and ask it to run 'vit init'"); 91 85 } catch (err) { 92 86 console.error(err instanceof Error ? err.message : String(err)); 93 87 process.exitCode = 1;
+33 -121
src/cmd/follow.js
··· 1 1 // SPDX-License-Identifier: AGPL-3.0-only 2 2 // Copyright (c) 2026 sol pbc 3 3 4 - import { TID } from '@atproto/common-web'; 5 - import { FOLLOW_COLLECTION } from '../lib/constants.js'; 6 - import { loadConfig } from '../lib/config.js'; 4 + import { requireDid } from '../lib/config.js'; 7 5 import { restoreAgent } from '../lib/oauth.js'; 6 + import { readFollowing, writeFollowing } from '../lib/vit-dir.js'; 8 7 9 8 export default function register(program) { 10 9 program 11 10 .command('follow') 12 11 .argument('<handle>', 'Handle to follow (e.g. alice.bsky.social)') 13 - .description('Follow an account on your PDS') 12 + .description('Add an account to this project\'s following list') 14 13 .option('-v, --verbose', 'Show step-by-step details') 15 - .option('--did <did>', 'DID to use (reads saved DID from config if not provided)') 14 + .option('--did <did>', 'DID to use for handle resolution') 16 15 .action(async (handle, opts) => { 17 16 try { 18 17 const { verbose } = opts; 19 18 handle = handle.replace(/^@/, ''); 20 - const did = opts.did || loadConfig().did; 21 - if (!did) { 22 - console.error("No DID configured. Run 'vit login <handle>' first or pass --did."); 19 + 20 + const did = requireDid(opts); 21 + if (!did) return; 22 + if (verbose) console.log(`[verbose] DID: ${did}`); 23 + 24 + const list = readFollowing(); 25 + if (list.some(e => e.handle === handle)) { 26 + console.error(`already following ${handle}`); 23 27 process.exitCode = 1; 24 28 return; 25 29 } 26 - if (verbose) console.log(`[verbose] DID: ${did}`); 27 30 28 - const { agent, session } = await restoreAgent(did); 29 - if (verbose) console.log(`[verbose] Session restored, PDS: ${session.serverMetadata?.issuer}`); 31 + const { agent } = await restoreAgent(did); 32 + if (verbose) console.log('[verbose] session restored'); 30 33 31 34 const resolved = await agent.resolveHandle({ handle }); 32 35 const targetDid = resolved.data.did; 33 - if (verbose) console.log(`[verbose] Resolved ${handle} to ${targetDid}`); 36 + if (verbose) console.log(`[verbose] resolved ${handle} to ${targetDid}`); 34 37 35 - // check if already following 36 - const listRes = await agent.com.atproto.repo.listRecords({ 37 - repo: did, 38 - collection: FOLLOW_COLLECTION, 39 - limit: 100, 40 - }); 41 - const existing = listRes.data.records.find(r => r.value.subject === targetDid); 42 - if (existing) { 43 - console.error(`Already following ${handle} (${targetDid})`); 44 - process.exitCode = 1; 45 - return; 46 - } 47 - 48 - const rkey = TID.nextStr(); 49 - const record = { 50 - $type: FOLLOW_COLLECTION, 51 - subject: targetDid, 52 - createdAt: new Date().toISOString(), 53 - }; 54 - const putArgs = { 55 - repo: did, 56 - collection: FOLLOW_COLLECTION, 57 - rkey, 58 - record, 59 - validate: false, 60 - }; 61 - if (verbose) console.log(`[verbose] putRecord ${putArgs.collection} rkey=${rkey}`); 62 - const putRes = await agent.com.atproto.repo.putRecord(putArgs); 63 - console.log( 64 - JSON.stringify({ 65 - ts: new Date().toISOString(), 66 - pds: session.serverMetadata?.issuer, 67 - xrpc: 'com.atproto.repo.putRecord', 68 - request: putArgs, 69 - response: putRes.data, 70 - }), 71 - ); 38 + list.push({ handle, did: targetDid, followedAt: new Date().toISOString() }); 39 + writeFollowing(list); 40 + console.log(`following ${handle} (${targetDid})`); 72 41 } catch (err) { 73 42 console.error(err instanceof Error ? err.message : String(err)); 74 43 process.exitCode = 1; ··· 78 47 program 79 48 .command('unfollow') 80 49 .argument('<handle>', 'Handle to unfollow (e.g. alice.bsky.social)') 81 - .description('Unfollow an account on your PDS') 50 + .description('Remove an account from this project\'s following list') 82 51 .option('-v, --verbose', 'Show step-by-step details') 83 - .option('--did <did>', 'DID to use (reads saved DID from config if not provided)') 84 52 .action(async (handle, opts) => { 85 53 try { 86 54 const { verbose } = opts; 87 55 handle = handle.replace(/^@/, ''); 88 - const did = opts.did || loadConfig().did; 89 - if (!did) { 90 - console.error("No DID configured. Run 'vit login <handle>' first or pass --did."); 91 - process.exitCode = 1; 92 - return; 93 - } 94 - if (verbose) console.log(`[verbose] DID: ${did}`); 95 56 96 - const { agent, session } = await restoreAgent(did); 97 - if (verbose) console.log(`[verbose] Session restored, PDS: ${session.serverMetadata?.issuer}`); 98 - 99 - const resolved = await agent.resolveHandle({ handle }); 100 - const targetDid = resolved.data.did; 101 - if (verbose) console.log(`[verbose] Resolved ${handle} to ${targetDid}`); 102 - 103 - const listRes = await agent.com.atproto.repo.listRecords({ 104 - repo: did, 105 - collection: FOLLOW_COLLECTION, 106 - limit: 100, 107 - }); 108 - const match = listRes.data.records.find(r => r.value.subject === targetDid); 109 - if (!match) { 110 - console.error(`Not following ${handle} (${targetDid})`); 57 + const list = readFollowing(); 58 + const filtered = list.filter(e => e.handle !== handle); 59 + if (filtered.length === list.length) { 60 + console.error(`not following ${handle}`); 111 61 process.exitCode = 1; 112 62 return; 113 63 } 114 64 115 - // extract rkey from URI: at://did/collection/rkey 116 - const rkey = match.uri.split('/').pop(); 117 - if (verbose) console.log(`[verbose] deleteRecord ${FOLLOW_COLLECTION} rkey=${rkey}`); 118 - await agent.com.atproto.repo.deleteRecord({ 119 - repo: did, 120 - collection: FOLLOW_COLLECTION, 121 - rkey, 122 - }); 123 - console.log( 124 - JSON.stringify({ 125 - ts: new Date().toISOString(), 126 - pds: session.serverMetadata?.issuer, 127 - xrpc: 'com.atproto.repo.deleteRecord', 128 - collection: FOLLOW_COLLECTION, 129 - rkey, 130 - unfollowed: targetDid, 131 - }), 132 - ); 65 + writeFollowing(filtered); 66 + if (verbose) console.log(`[verbose] removed ${handle} from following list`); 67 + console.log(`unfollowed ${handle}`); 133 68 } catch (err) { 134 69 console.error(err instanceof Error ? err.message : String(err)); 135 70 process.exitCode = 1; ··· 138 73 139 74 program 140 75 .command('following') 141 - .description('List accounts you follow on your PDS') 76 + .description('List accounts in this project\'s following list') 142 77 .option('-v, --verbose', 'Show step-by-step details') 143 - .option('--did <did>', 'DID to use (reads saved DID from config if not provided)') 144 - .action(async (opts) => { 78 + .action(async (_opts) => { 145 79 try { 146 - const { verbose } = opts; 147 - const did = opts.did || loadConfig().did; 148 - if (!did) { 149 - console.error("No DID configured. Run 'vit login <handle>' first or pass --did."); 150 - process.exitCode = 1; 80 + const list = readFollowing(); 81 + if (list.length === 0) { 82 + console.log('no followings'); 151 83 return; 152 84 } 153 - if (verbose) console.log(`[verbose] DID: ${did}`); 154 - 155 - const { agent, session } = await restoreAgent(did); 156 - if (verbose) console.log(`[verbose] Session restored, PDS: ${session.serverMetadata?.issuer}`); 157 - 158 - const listArgs = { 159 - repo: did, 160 - collection: FOLLOW_COLLECTION, 161 - limit: 100, 162 - }; 163 - if (verbose) console.log(`[verbose] listRecords ${listArgs.collection} limit=${listArgs.limit}`); 164 - const listRes = await agent.com.atproto.repo.listRecords(listArgs); 165 - if (verbose) console.log(`[verbose] Received ${listRes.data.records.length} records`); 166 - for (const rec of listRes.data.records) { 167 - console.log( 168 - JSON.stringify({ 169 - ts: new Date().toISOString(), 170 - pds: session.serverMetadata?.issuer, 171 - xrpc: 'com.atproto.repo.listRecords', 172 - record: rec, 173 - }), 174 - ); 85 + for (const e of list) { 86 + console.log(`${e.handle} (${e.did})`); 175 87 } 176 88 } catch (err) { 177 89 console.error(err instanceof Error ? err.message : String(err));
+26 -9
src/cmd/setup.js
··· 12 12 try { 13 13 const gate = requireNotAgent(); 14 14 if (!gate.ok) { 15 - console.error(`vit setup cannot run inside ${gate.name} (detected ${gate.envVar}=1).`); 16 - console.error('run vit setup from your own terminal instead.'); 15 + console.error('vit setup must be run by a human. run it in your own terminal.'); 16 + process.exitCode = 1; 17 + return; 18 + } 19 + 20 + const gitPath = Bun.which('git'); 21 + const bunPath = Bun.which('bun'); 22 + console.log(`git: ${gitPath ? 'found' : 'not found'}`); 23 + console.log(`bun: ${bunPath ? 'found' : 'not found'}`); 24 + if (!gitPath || !bunPath) { 25 + const missing = [!gitPath ? 'git' : null, !bunPath ? 'bun' : null].filter(Boolean).join(', '); 26 + console.error(`missing required tools: ${missing}`); 17 27 process.exitCode = 1; 18 28 return; 19 29 } 20 30 21 31 const config = loadConfig(); 22 - if (config.setup_at) { 23 - const when = new Date(config.setup_at * 1000).toISOString(); 24 - console.log(`vit already set up (setup_at: ${when})`); 25 - return; 32 + if (config.did) { 33 + console.log(`login: ${config.did}`); 34 + } else { 35 + console.log('login: not logged in'); 36 + console.log("next: run 'vit login <handle>' to authenticate with Bluesky"); 37 + } 38 + 39 + if (!config.setup_at) { 40 + config.setup_at = Math.floor(Date.now() / 1000); 41 + saveConfig(config); 42 + } 43 + 44 + if (config.did) { 45 + console.log('vit setup complete'); 26 46 } 27 - config.setup_at = Math.floor(Date.now() / 1000); 28 - saveConfig(config); 29 - console.log('vit setup complete'); 30 47 } catch (err) { 31 48 console.error(err instanceof Error ? err.message : String(err)); 32 49 process.exitCode = 1;
+20 -42
src/cmd/skim.js
··· 1 1 // SPDX-License-Identifier: AGPL-3.0-only 2 2 // Copyright (c) 2026 sol pbc 3 3 4 - import { loadConfig } from '../lib/config.js'; 5 - import { CAP_COLLECTION, FOLLOW_COLLECTION } from '../lib/constants.js'; 4 + import { requireDid } from '../lib/config.js'; 5 + import { CAP_COLLECTION } from '../lib/constants.js'; 6 6 import { restoreAgent } from '../lib/oauth.js'; 7 - import { readProjectConfig } from '../lib/vit-dir.js'; 7 + import { readProjectConfig, readFollowing } from '../lib/vit-dir.js'; 8 8 import { requireAgent } from '../lib/agent.js'; 9 9 import { resolveRef } from '../lib/cap-ref.js'; 10 10 ··· 12 12 program 13 13 .command('skim') 14 14 .description('Read caps from followed accounts, filtered by beacon') 15 - .option('--did <did>', 'DID to use (reads saved DID from config if not provided)') 15 + .option('--did <did>', 'DID to use') 16 16 .option('--handle <handle>', 'Show caps from a specific handle only') 17 17 .option('--limit <n>', 'Max caps to display', '25') 18 18 .option('--json', 'Output as JSON array') ··· 28 28 } 29 29 30 30 const { verbose } = opts; 31 - const did = opts.did || loadConfig().did; 32 - if (!did) { 33 - console.error("No DID configured. Run 'vit login <handle>' first or pass --did."); 34 - process.exitCode = 1; 35 - return; 36 - } 31 + const did = requireDid(opts); 32 + if (!did) return; 37 33 if (verbose) console.log(`[verbose] DID: ${did}`); 38 34 39 35 const projectConfig = readProjectConfig(); 40 36 const beacon = projectConfig.beacon; 41 37 if (!beacon) { 42 - console.error("No beacon set. Run 'vit init' in a project directory first."); 38 + console.error("no beacon set. run 'vit init' in a project directory first."); 43 39 process.exitCode = 1; 44 40 return; 45 41 } 46 - if (verbose) console.log(`[verbose] Beacon: ${beacon}`); 42 + if (verbose) console.log(`[verbose] beacon: ${beacon}`); 47 43 48 - const { agent, session } = await restoreAgent(did); 49 - if (verbose) console.log(`[verbose] Session restored, PDS: ${session.serverMetadata?.issuer}`); 44 + const { agent } = await restoreAgent(did); 45 + if (verbose) console.log('[verbose] session restored'); 50 46 51 47 // build list of DIDs to query 52 48 let dids; ··· 54 50 const handle = opts.handle.replace(/^@/, ''); 55 51 const resolved = await agent.resolveHandle({ handle }); 56 52 dids = [resolved.data.did]; 57 - if (verbose) console.log(`[verbose] Resolved ${handle} to ${resolved.data.did}`); 53 + if (verbose) console.log(`[verbose] resolved ${handle} to ${resolved.data.did}`); 58 54 } else { 59 - // fetch follow list + include self 60 - const followRes = await agent.com.atproto.repo.listRecords({ 61 - repo: did, 62 - collection: FOLLOW_COLLECTION, 63 - limit: 100, 64 - }); 65 - dids = followRes.data.records.map(r => r.value.subject); 55 + const following = readFollowing(); 56 + dids = following.map(e => e.did); 66 57 dids.push(did); 67 - if (verbose) console.log(`[verbose] Querying ${dids.length} accounts (${dids.length - 1} follows + self)`); 58 + if (verbose) console.log(`[verbose] querying ${dids.length} accounts (${dids.length - 1} follows + self)`); 68 59 } 69 60 70 61 // fetch caps from each DID ··· 102 93 console.log('no caps found for this beacon.'); 103 94 } 104 95 for (const rec of capped) { 105 - const author = rec.uri.split('/')[2]; 106 - const short = author.length > 20 ? author.slice(0, 20) + '…' : author; 107 - const time = rec.value.createdAt || 'unknown'; 96 + const ref = resolveRef(rec.value, rec.cid); 108 97 const title = rec.value.title || ''; 109 98 const description = rec.value.description || ''; 110 - const ref = resolveRef(rec.value, rec.cid); 111 - const text = rec.value.text || ''; 112 - console.log(`[${short}] ${time}`); 113 - if (title || ref) { 114 - const parts = [title, ref ? `(${ref})` : ''].filter(Boolean).join(' '); 115 - console.log(` ${parts}`); 116 - } 117 - if (description) { 118 - console.log(` ${description}`); 119 - } 120 - if ((title || ref || description) && text) { 121 - console.log(' ---'); 122 - } 123 - if (text) { 124 - console.log(` ${text}`); 125 - } 99 + console.log(`ref: ${ref}`); 100 + if (title) console.log(`title: ${title}`); 101 + if (description) console.log(`description: ${description}`); 126 102 console.log(); 127 103 } 104 + console.log('---'); 105 + console.log("hint: tell your user to run 'vit vet <ref>' in another terminal for any cap they want to review."); 128 106 } 129 107 } catch (err) { 130 108 console.error(err instanceof Error ? err.message : String(err));
+82 -44
src/cmd/vet.js
··· 1 1 // SPDX-License-Identifier: AGPL-3.0-only 2 2 // Copyright (c) 2026 sol pbc 3 3 4 - import { loadConfig } from '../lib/config.js'; 4 + import { requireDid } from '../lib/config.js'; 5 + import { CAP_COLLECTION } from '../lib/constants.js'; 5 6 import { restoreAgent } from '../lib/oauth.js'; 6 - import { appendLog } from '../lib/vit-dir.js'; 7 + import { appendLog, readProjectConfig, readFollowing } from '../lib/vit-dir.js'; 7 8 import { requireNotAgent } from '../lib/agent.js'; 8 - import { resolveRef } from '../lib/cap-ref.js'; 9 + import { resolveRef, REF_PATTERN } from '../lib/cap-ref.js'; 9 10 10 11 export default function register(program) { 11 12 program 12 13 .command('vet') 13 - .argument('<cap-ref>', 'AT URI of the cap to review (e.g. at://did:plc:.../org.v-it.cap/...)') 14 + .argument('<ref>', 'Three-word cap reference (e.g. fast-cache-invalidation)') 14 15 .description('Review a cap before trusting it') 15 - .option('--did <did>', 'DID to use (reads saved DID from config if not provided)') 16 + .option('--did <did>', 'DID to use') 16 17 .option('--trust', 'Mark the cap as locally trusted') 17 18 .option('-v, --verbose', 'Show step-by-step details') 18 - .action(async (capRef, opts) => { 19 + .action(async (ref, opts) => { 19 20 try { 20 21 const gate = requireNotAgent(); 21 22 if (!gate.ok) { 22 - console.error(`vit vet cannot run inside ${gate.name} (detected ${gate.envVar}=1).`); 23 + console.error('vit vet must be run by a human. run it in your own terminal.'); 23 24 console.error(''); 24 - console.error('Cap vetting requires human review for safety.'); 25 - console.error('Ask your user to run this command in their terminal:'); 25 + console.error('cap vetting requires human review for safety.'); 26 + console.error('ask your user to run this command in their terminal:'); 26 27 console.error(''); 27 - console.error(` vit vet ${capRef}`); 28 + console.error(` vit vet ${ref}`); 28 29 console.error(''); 29 - console.error('After reviewing, they can trust it with:'); 30 + console.error('after reviewing, they can trust it with:'); 30 31 console.error(''); 31 - console.error(` vit vet ${capRef} --trust`); 32 + console.error(` vit vet ${ref} --trust`); 32 33 console.error(''); 33 - console.error('Once trusted, ask your user to confirm and you can proceed.'); 34 + console.error('once trusted, ask your user to confirm and you can proceed.'); 34 35 process.exitCode = 1; 35 36 return; 36 37 } 37 38 38 39 const { verbose } = opts; 39 40 40 - const parts = capRef.split('/'); 41 - // at://did:plc:xxx/org.v-it.cap/tid -> ['at:', '', 'did:plc:xxx', 'org.v-it.cap', 'tid'] 42 - if (parts.length < 5 || parts[0] !== 'at:' || !parts[2] || !parts[3] || !parts[4]) { 43 - console.error('Invalid cap reference. Expected AT URI: at://did:plc:.../org.v-it.cap/...'); 41 + if (!REF_PATTERN.test(ref)) { 42 + console.error('invalid ref. expected three lowercase words with dashes (e.g. fast-cache-invalidation)'); 44 43 process.exitCode = 1; 45 44 return; 46 45 } 47 - const repo = parts[2]; 48 - const collection = parts[3]; 49 - const rkey = parts[4]; 50 - if (verbose) console.log(`[verbose] Parsed URI repo=${repo} collection=${collection} rkey=${rkey}`); 51 46 52 - const did = opts.did || loadConfig().did; 53 - if (!did) { 54 - console.error("No DID configured. Run 'vit login <handle>' first or pass --did."); 47 + const did = requireDid(opts); 48 + if (!did) return; 49 + if (verbose) console.log(`[verbose] DID: ${did}`); 50 + 51 + const projectConfig = readProjectConfig(); 52 + const beacon = projectConfig.beacon; 53 + if (!beacon) { 54 + console.error("no beacon set. run 'vit init' in a project directory first."); 55 55 process.exitCode = 1; 56 56 return; 57 57 } 58 - if (verbose) console.log(`[verbose] DID: ${did}`); 58 + if (verbose) console.log(`[verbose] beacon: ${beacon}`); 59 59 60 - const { agent, session } = await restoreAgent(did); 61 - if (verbose) console.log(`[verbose] Session restored, PDS: ${session.serverMetadata?.issuer}`); 60 + const { agent } = await restoreAgent(did); 61 + if (verbose) console.log('[verbose] session restored'); 62 62 63 - if (verbose) console.log(`[verbose] Fetching ${collection} from ${repo} rkey=${rkey}`); 64 - const res = await agent.com.atproto.repo.getRecord({ repo, collection, rkey }); 65 - const record = res.data.value; 63 + // build DID list from following + self 64 + const following = readFollowing(); 65 + const dids = following.map(e => e.did); 66 + dids.push(did); 67 + if (verbose) console.log(`[verbose] querying ${dids.length} accounts`); 68 + 69 + // fetch caps from each DID, find matching ref 70 + let match = null; 71 + for (const repoDid of dids) { 72 + try { 73 + const res = await agent.com.atproto.repo.listRecords({ 74 + repo: repoDid, 75 + collection: CAP_COLLECTION, 76 + limit: 50, 77 + }); 78 + for (const rec of res.data.records) { 79 + if (rec.value.beacon !== beacon) continue; 80 + const recRef = resolveRef(rec.value, rec.cid); 81 + if (recRef === ref) { 82 + if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { 83 + match = rec; 84 + } 85 + } 86 + } 87 + } catch (err) { 88 + if (verbose) console.log(`[verbose] ${repoDid}: error fetching caps: ${err.message}`); 89 + } 90 + } 91 + 92 + if (!match) { 93 + console.error(`no cap found with ref '${ref}' for this beacon.`); 94 + process.exitCode = 1; 95 + return; 96 + } 97 + 98 + const record = match.value; 66 99 67 100 if (opts.trust) { 68 101 appendLog('trusted.jsonl', { 69 - uri: capRef, 102 + ref, 103 + uri: match.uri, 70 104 trustedAt: new Date().toISOString(), 71 105 }); 72 - console.log(`Trusted: ${capRef}`); 106 + console.log(`trusted: ${ref}`); 73 107 return; 74 108 } 75 109 76 - const author = repo; 77 - const time = record.createdAt || 'unknown'; 78 - const beacon = record.beacon || 'none'; 110 + const author = match.uri.split('/')[2]; 111 + const title = record.title || ''; 112 + const description = record.description || ''; 79 113 const text = record.text || ''; 80 - const ref = resolveRef(record, res.data.cid); 81 114 82 115 console.log('=== Cap Review ==='); 83 116 console.log('Review this cap carefully before trusting it.'); 84 117 console.log(''); 118 + console.log(` Ref: ${ref}`); 119 + if (title) console.log(` Title: ${title}`); 85 120 console.log(` Author: ${author}`); 86 - console.log(` Time: ${time}`); 87 - console.log(` Beacon: ${beacon}`); 88 - console.log(` Ref: ${ref}`); 89 - console.log(''); 90 - console.log('--- Text ---'); 91 - console.log(text); 92 - console.log('---'); 121 + if (description) { 122 + console.log(''); 123 + console.log(` ${description}`); 124 + } 125 + if (text) { 126 + console.log(''); 127 + console.log('--- Text ---'); 128 + console.log(text); 129 + console.log('---'); 130 + } 93 131 console.log(''); 94 132 console.log('To trust this cap, run:'); 95 133 console.log(''); 96 - console.log(` vit vet ${capRef} --trust`); 134 + console.log(` vit vet ${ref} --trust`); 97 135 } catch (err) { 98 136 console.error(err instanceof Error ? err.message : String(err)); 99 137 process.exitCode = 1;
+9
src/lib/config.js
··· 23 23 writeFileSync(vitJsonPath, JSON.stringify(obj, null, 2) + '\n'); 24 24 } 25 25 26 + export function requireDid(opts) { 27 + const did = opts?.did || loadConfig().did; 28 + if (!did) { 29 + console.error("no DID configured. run 'vit login <handle>' first or pass --did."); 30 + process.exitCode = 1; 31 + } 32 + return did; 33 + } 34 + 26 35 export function getScalars(obj) { 27 36 return Object.entries(obj).filter( 28 37 ([, v]) => typeof v !== 'object' || v === null
-1
src/lib/constants.js
··· 2 2 // Copyright (c) 2026 sol pbc 3 3 4 4 export const CAP_COLLECTION = 'org.v-it.cap'; 5 - export const FOLLOW_COLLECTION = 'app.bsky.graph.follow';
+16
src/lib/vit-dir.js
··· 29 29 mkdirSync(dir, { recursive: true }); 30 30 appendFileSync(join(dir, filename), JSON.stringify(record) + '\n'); 31 31 } 32 + 33 + export function readFollowing() { 34 + const p = join(vitDir(), 'following.json'); 35 + if (!existsSync(p)) return []; 36 + try { 37 + return JSON.parse(readFileSync(p, 'utf-8')); 38 + } catch { 39 + return []; 40 + } 41 + } 42 + 43 + export function writeFollowing(list) { 44 + const dir = vitDir(); 45 + mkdirSync(dir, { recursive: true }); 46 + writeFileSync(join(dir, 'following.json'), JSON.stringify(list, null, 2) + '\n'); 47 + }
+4 -18
test/adopt.test.js
··· 3 3 4 4 import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; 5 5 import { run } from './helpers.js'; 6 - import { mkdirSync, rmSync, readFileSync, existsSync } from 'node:fs'; 6 + import { mkdirSync, rmSync } from 'node:fs'; 7 7 import { tmpdir } from 'node:os'; 8 8 import { join } from 'node:path'; 9 9 ··· 48 48 test('rejects when run inside a coding agent', () => { 49 49 const result = run('adopt https://github.com/octocat/Hello-World', tmpDir, { CLAUDECODE: '1' }); 50 50 expect(result.exitCode).toBe(1); 51 - expect(result.stderr).toContain('cannot run inside claude code'); 51 + expect(result.stderr).toContain('must be run by a human'); 52 52 }); 53 53 54 - test('clones repo and initializes .vit/', () => { 54 + test('clones repo and shows guidance', () => { 55 55 const result = run('adopt https://github.com/octocat/Hello-World', tmpDir, NON_AGENT_ENV); 56 56 expect(result.exitCode).toBe(0); 57 57 expect(result.stdout).toContain('vit:github.com/octocat/hello-world'); 58 58 expect(result.stdout).toContain('hello-world'); 59 - 60 - const configPath = join(tmpDir, 'hello-world', '.vit', 'config.json'); 61 - expect(existsSync(configPath)).toBe(true); 62 - const config = JSON.parse(readFileSync(configPath, 'utf-8')); 63 - expect(config.beacon).toBe('vit:github.com/octocat/hello-world'); 59 + expect(result.stdout).toContain('start your agent'); 64 60 }, 30000); 65 61 66 62 test('clones into custom directory name', () => { 67 63 const result = run('adopt https://github.com/octocat/Hello-World my-copy', tmpDir, NON_AGENT_ENV); 68 64 expect(result.exitCode).toBe(0); 69 65 expect(result.stdout).toContain('my-copy'); 70 - 71 - const configPath = join(tmpDir, 'my-copy', '.vit', 'config.json'); 72 - expect(existsSync(configPath)).toBe(true); 73 - const config = JSON.parse(readFileSync(configPath, 'utf-8')); 74 - expect(config.beacon).toBe('vit:github.com/octocat/hello-world'); 75 66 }, 30000); 76 67 77 68 test('handles vit: prefixed beacon', () => { 78 69 const result = run('adopt vit:github.com/octocat/Hello-World', tmpDir, NON_AGENT_ENV); 79 70 expect(result.exitCode).toBe(0); 80 71 expect(result.stdout).toContain('beacon: vit:github.com/octocat/hello-world'); 81 - 82 - const configPath = join(tmpDir, 'hello-world', '.vit', 'config.json'); 83 - expect(existsSync(configPath)).toBe(true); 84 - const config = JSON.parse(readFileSync(configPath, 'utf-8')); 85 - expect(config.beacon).toBe('vit:github.com/octocat/hello-world'); 86 72 }, 30000); 87 73 88 74 test('verbose flag shows step details', () => {
+65 -11
test/follow.test.js
··· 1 1 // SPDX-License-Identifier: AGPL-3.0-only 2 2 // Copyright (c) 2026 sol pbc 3 3 4 - import { describe, test, expect } from 'bun:test'; 4 + import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; 5 5 import { run } from './helpers.js'; 6 + import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; 7 + import { tmpdir } from 'node:os'; 8 + import { join } from 'node:path'; 6 9 7 10 describe('vit follow', () => { 8 11 test('errors when no handle argument is provided', () => { ··· 11 14 expect(result.stderr).toBeTruthy(); 12 15 }); 13 16 14 - test('errors when DID is invalid', () => { 15 - const result = run('follow someone.bsky.social --did did:plc:abc'); 17 + test('errors when no DID configured', () => { 18 + const configHome = join(tmpdir(), '.test-follow-config-' + Math.random().toString(36).slice(2)); 19 + mkdirSync(configHome, { recursive: true }); 20 + const result = run('follow someone.bsky.social', '/tmp', { 21 + CLAUDECODE: '', 22 + GEMINI_CLI: '', 23 + CODEX_CI: '', 24 + XDG_CONFIG_HOME: configHome, 25 + }); 16 26 expect(result.exitCode).not.toBe(0); 17 - expect(result.stderr).toBeTruthy(); 27 + expect(result.stderr).toContain('no DID configured'); 28 + rmSync(configHome, { recursive: true, force: true }); 18 29 }); 19 30 }); 20 31 21 32 describe('vit unfollow', () => { 33 + let tmpDir; 34 + 35 + beforeEach(() => { 36 + tmpDir = join(tmpdir(), '.test-follow-' + Math.random().toString(36).slice(2)); 37 + mkdirSync(join(tmpDir, '.vit'), { recursive: true }); 38 + }); 39 + 40 + afterEach(() => { 41 + rmSync(tmpDir, { recursive: true, force: true }); 42 + }); 43 + 22 44 test('errors when no handle argument is provided', () => { 23 45 const result = run('unfollow'); 24 46 expect(result.exitCode).not.toBe(0); 25 47 expect(result.stderr).toBeTruthy(); 26 48 }); 27 49 28 - test('errors when DID is invalid', () => { 29 - const result = run('unfollow someone.bsky.social --did did:plc:abc'); 50 + test('errors when handle not in following list', () => { 51 + writeFileSync(join(tmpDir, '.vit', 'following.json'), '[]'); 52 + const result = run('unfollow nobody.bsky.social', tmpDir); 30 53 expect(result.exitCode).not.toBe(0); 31 - expect(result.stderr).toBeTruthy(); 54 + expect(result.stderr).toContain('not following'); 55 + }); 56 + 57 + test('removes entry from following list', () => { 58 + const list = [{ handle: 'alice.bsky.social', did: 'did:plc:alice', followedAt: '2026-01-01T00:00:00Z' }]; 59 + writeFileSync(join(tmpDir, '.vit', 'following.json'), JSON.stringify(list)); 60 + const result = run('unfollow alice.bsky.social', tmpDir); 61 + expect(result.exitCode).toBe(0); 62 + expect(result.stdout).toContain('unfollowed alice.bsky.social'); 32 63 }); 33 64 }); 34 65 35 66 describe('vit following', () => { 36 - test('errors when DID is invalid', () => { 37 - const result = run('following --did did:plc:abc'); 38 - expect(result.exitCode).not.toBe(0); 39 - expect(result.stderr).toBeTruthy(); 67 + let tmpDir; 68 + 69 + beforeEach(() => { 70 + tmpDir = join(tmpdir(), '.test-following-' + Math.random().toString(36).slice(2)); 71 + mkdirSync(join(tmpDir, '.vit'), { recursive: true }); 72 + }); 73 + 74 + afterEach(() => { 75 + rmSync(tmpDir, { recursive: true, force: true }); 76 + }); 77 + 78 + test('lists entries from following.json', () => { 79 + const list = [ 80 + { handle: 'alice.bsky.social', did: 'did:plc:alice', followedAt: '2026-01-01T00:00:00Z' }, 81 + { handle: 'bob.bsky.social', did: 'did:plc:bob', followedAt: '2026-01-02T00:00:00Z' }, 82 + ]; 83 + writeFileSync(join(tmpDir, '.vit', 'following.json'), JSON.stringify(list)); 84 + const result = run('following', tmpDir); 85 + expect(result.exitCode).toBe(0); 86 + expect(result.stdout).toContain('alice.bsky.social'); 87 + expect(result.stdout).toContain('bob.bsky.social'); 88 + }); 89 + 90 + test('shows message when no followings', () => { 91 + const result = run('following', tmpDir); 92 + expect(result.exitCode).toBe(0); 93 + expect(result.stdout).toContain('no followings'); 40 94 }); 41 95 });
+12 -1
test/setup.test.js
··· 8 8 test('rejects when run inside a coding agent', () => { 9 9 const result = run('setup', undefined, { CLAUDECODE: '1' }); 10 10 expect(result.exitCode).toBe(1); 11 - expect(result.stderr).toContain('cannot run inside claude code'); 11 + expect(result.stderr).toContain('must be run by a human'); 12 + }); 13 + 14 + test('checks for git and bun', () => { 15 + const result = run('setup', undefined, { CLAUDECODE: '', GEMINI_CLI: '', CODEX_CI: '' }); 16 + expect(result.stdout).toContain('git: found'); 17 + expect(result.stdout).toContain('bun: found'); 18 + }); 19 + 20 + test('reports login status', () => { 21 + const result = run('setup', undefined, { CLAUDECODE: '', GEMINI_CLI: '', CODEX_CI: '' }); 22 + expect(result.stdout).toMatch(/login:/); 12 23 }); 13 24 });
+11 -5
test/skim.test.js
··· 3 3 4 4 import { describe, test, expect } from 'bun:test'; 5 5 import { run } from './helpers.js'; 6 + import { mkdirSync, rmSync } from 'node:fs'; 7 + import { tmpdir } from 'node:os'; 8 + import { join } from 'node:path'; 6 9 7 10 describe('vit skim', () => { 8 11 test('rejects when run outside a coding agent', () => { ··· 11 14 expect(result.stderr).toContain('should be run by a coding agent'); 12 15 }); 13 16 14 - test('errors when DID is invalid', () => { 15 - const result = run('skim --did did:plc:abc', undefined, { CLAUDECODE: '1' }); 17 + test('errors when no DID configured', () => { 18 + const configHome = join(tmpdir(), '.test-skim-config-' + Math.random().toString(36).slice(2)); 19 + mkdirSync(configHome, { recursive: true }); 20 + const result = run('skim', '/tmp', { CLAUDECODE: '1', XDG_CONFIG_HOME: configHome }); 16 21 expect(result.exitCode).not.toBe(0); 17 - expect(result.stderr).toBeTruthy(); 22 + expect(result.stderr).toContain('no DID configured'); 23 + rmSync(configHome, { recursive: true, force: true }); 18 24 }); 19 25 20 26 test('errors when no beacon is set', () => { 21 - const result = run('skim', '/tmp', { CLAUDECODE: '1' }); 27 + const result = run('skim --did did:plc:test123', '/tmp', { CLAUDECODE: '1' }); 22 28 expect(result.exitCode).not.toBe(0); 23 - expect(result.stderr).toBeTruthy(); 29 + expect(result.stderr).toContain('no beacon set'); 24 30 }); 25 31 });
+10 -12
test/vet.test.js
··· 4 4 import { describe, test, expect } from 'bun:test'; 5 5 import { run } from './helpers.js'; 6 6 7 - const FAKE_URI = 'at://did:plc:fake123/org.v-it.cap/fake456'; 8 - 9 7 describe('vit vet', () => { 10 - test('shows help with <cap-ref> argument', () => { 8 + test('shows help with <ref> argument', () => { 11 9 const result = run('vet --help'); 12 - expect(result.stdout).toContain('<cap-ref>'); 10 + expect(result.stdout).toContain('<ref>'); 13 11 }); 14 12 15 13 test('rejects when run inside a coding agent', () => { 16 - const result = run('vet ' + FAKE_URI, undefined, { CLAUDECODE: '1' }); 14 + const result = run('vet fast-cache-invalidation', undefined, { CLAUDECODE: '1' }); 17 15 expect(result.exitCode).toBe(1); 18 - expect(result.stderr).toContain('cannot run inside claude code'); 19 - expect(result.stderr).toContain(`vit vet ${FAKE_URI}`); 16 + expect(result.stderr).toContain('must be run by a human'); 17 + expect(result.stderr).toContain('vit vet fast-cache-invalidation'); 20 18 expect(result.stderr).toContain('--trust'); 21 19 }); 22 20 23 21 test('rejects when run inside gemini', () => { 24 - const result = run('vet ' + FAKE_URI, undefined, { CLAUDECODE: '', GEMINI_CLI: '1', CODEX_CI: '' }); 22 + const result = run('vet fast-cache-invalidation', undefined, { CLAUDECODE: '', GEMINI_CLI: '1', CODEX_CI: '' }); 25 23 expect(result.exitCode).toBe(1); 26 - expect(result.stderr).toContain('cannot run inside gemini cli'); 24 + expect(result.stderr).toContain('must be run by a human'); 27 25 }); 28 26 29 - test('rejects invalid AT URI', () => { 30 - const result = run('vet not-a-valid-uri', undefined, { CLAUDECODE: '', GEMINI_CLI: '', CODEX_CI: '' }); 27 + test('rejects invalid ref format', () => { 28 + const result = run('vet not-valid', undefined, { CLAUDECODE: '', GEMINI_CLI: '', CODEX_CI: '' }); 31 29 expect(result.exitCode).not.toBe(0); 32 - expect(result.stderr).toContain('Invalid cap reference'); 30 + expect(result.stderr).toContain('invalid ref'); 33 31 }); 34 32 35 33 test('fails with no arguments', () => {