open source is social v-it.org
0
fork

Configure Feed

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

feat: add --json flag to 11 agent-facing commands

Add structured JSON output to init, follow, unfollow, following, ship,
remix, vet, vouch, doctor, learn, and scan. When --json is passed:

- Success: stdout is { ok: true, ...data }
- Error: stdout is { ok: false, error, hint } with exitCode 1
- No human-readable text on stdout
- Verbose messages redirect to stderr

Shared helper in src/lib/json-output.js provides jsonOk() and
jsonError() for consistent envelope formatting. Existing behavior
without --json is unchanged. skim --json is untouched.

Includes 26 new tests covering JSON error paths for all commands.

+853 -124
+63 -22
src/cmd/doctor.js
··· 9 9 import { homedir } from 'node:os'; 10 10 import { mark, name } from '../lib/brand.js'; 11 11 import { which } from '../lib/compat.js'; 12 + import { jsonOk, jsonError } from '../lib/json-output.js'; 12 13 13 14 function scanSkillDir(dir) { 14 15 const skills = []; ··· 36 37 } 37 38 38 39 export default function register(program) { 39 - async function checkHealth() { 40 + async function checkHealth(opts) { 40 41 try { 41 42 const config = loadConfig(); 43 + const setup = { 44 + done: !!config.setup_at, 45 + at: config.setup_at ? new Date(config.setup_at * 1000).toISOString() : null, 46 + }; 47 + let installType = 'not on PATH'; 48 + let vitPath = which(name); 49 + let installPath = vitPath || null; 50 + let beacon = null; 51 + let skillInstalled = false; 52 + let projectSkills = []; 53 + let userSkills = []; 54 + let blueskyOk = false; 55 + let pds = null; 56 + 42 57 if (config.setup_at) { 43 58 const when = new Date(config.setup_at * 1000).toISOString(); 44 - console.log(`${mark} setup: ok (${when})`); 59 + if (!opts.json) console.log(`${mark} setup: ok (${when})`); 45 60 } else { 46 - console.log(`${mark} setup: not done (run ${name} setup)`); 61 + if (!opts.json) console.log(`${mark} setup: not done (run ${name} setup)`); 47 62 } 48 63 49 - const vitPath = which(name); 50 64 if (!vitPath) { 51 - console.log(`${mark} install: not on PATH`); 65 + installType = 'not on PATH'; 66 + if (!opts.json) console.log(`${mark} install: not on PATH`); 52 67 } else { 53 68 try { 54 69 if (lstatSync(vitPath).isSymbolicLink()) { 55 - console.log(`${mark} install: linked (${vitPath})`); 70 + installType = 'linked'; 71 + if (!opts.json) console.log(`${mark} install: linked (${vitPath})`); 56 72 } else if (vitPath.includes('node_modules')) { 57 - console.log(`${mark} install: global`); 73 + installType = 'global'; 74 + if (!opts.json) console.log(`${mark} install: global`); 58 75 } else { 59 - console.log(`${mark} install: source (${vitPath})`); 76 + installType = 'source'; 77 + if (!opts.json) console.log(`${mark} install: source (${vitPath})`); 60 78 } 61 79 } catch { 62 - console.log(`${mark} install: source (${vitPath})`); 80 + installType = 'source'; 81 + if (!opts.json) console.log(`${mark} install: source (${vitPath})`); 63 82 } 64 83 } 65 84 66 85 const projConfig = readProjectConfig(); 86 + beacon = projConfig.beacon || null; 67 87 if (projConfig.beacon) { 68 - console.log(`${mark} beacon: ${projConfig.beacon}`); 88 + if (!opts.json) console.log(`${mark} beacon: ${projConfig.beacon}`); 69 89 } else { 70 - console.log(`${mark} beacon: not set`); 90 + if (!opts.json) console.log(`${mark} beacon: not set`); 71 91 } 72 92 73 93 const skillPath = join(process.cwd(), '.claude', 'skills', 'using-vit', 'SKILL.md'); 94 + skillInstalled = existsSync(skillPath); 74 95 if (existsSync(skillPath)) { 75 - console.log(`${mark} skill: ok (using-vit)`); 96 + if (!opts.json) console.log(`${mark} skill: ok (using-vit)`); 76 97 } else { 77 - console.log(`${mark} skill: not installed (run ${name} setup)`); 98 + if (!opts.json) console.log(`${mark} skill: not installed (run ${name} setup)`); 78 99 } 79 100 80 101 // Report installed skills 81 102 const projectSkillDir = join(process.cwd(), '.claude', 'skills'); 82 - const projectSkills = scanSkillDir(projectSkillDir); 103 + projectSkills = scanSkillDir(projectSkillDir); 83 104 const userSkillDir = join(homedir(), '.claude', 'skills'); 84 - const userSkills = scanSkillDir(userSkillDir); 105 + userSkills = scanSkillDir(userSkillDir); 85 106 86 - if (projectSkills.length > 0) { 107 + if (!opts.json && projectSkills.length > 0) { 87 108 console.log(`${mark} project skills: ${projectSkills.length} installed`); 88 109 for (const s of projectSkills) { 89 110 const ver = s.version ? ` v${s.version}` : ''; 90 111 console.log(` ${s.name}${ver}`); 91 112 } 92 113 } 93 - if (userSkills.length > 0) { 114 + if (!opts.json && userSkills.length > 0) { 94 115 console.log(`${mark} user skills: ${userSkills.length} installed`); 95 116 for (const s of userSkills) { 96 117 const ver = s.version ? ` v${s.version}` : ''; ··· 99 120 } 100 121 101 122 if (!config.did) { 102 - console.log(`${mark} bluesky: not logged in (run ${name} login <handle>)`); 123 + if (!opts.json) console.log(`${mark} bluesky: not logged in (run ${name} login <handle>)`); 103 124 } else { 104 125 try { 105 126 const { session } = await restoreAgent(config.did); 106 - const pds = session.serverMetadata?.issuer; 107 - console.log(`${mark} bluesky: ok (${session.did}${pds ? ', ' + pds : ''})`); 127 + blueskyOk = true; 128 + pds = session.serverMetadata?.issuer || null; 129 + if (!opts.json) console.log(`${mark} bluesky: ok (${session.did}${pds ? ', ' + pds : ''})`); 108 130 } catch { 109 - console.log(`${mark} bluesky: token expired or invalid (run ${name} login <handle>)`); 131 + if (!opts.json) console.log(`${mark} bluesky: token expired or invalid (run ${name} login <handle>)`); 110 132 } 111 133 } 134 + 135 + if (opts.json) { 136 + jsonOk({ 137 + setup, 138 + install: { type: installType, path: installPath }, 139 + beacon, 140 + skill: skillInstalled, 141 + projectSkills, 142 + userSkills, 143 + bluesky: { ok: blueskyOk, did: config.did || null, pds }, 144 + }); 145 + } 112 146 } catch (err) { 113 - console.error(err instanceof Error ? err.message : String(err)); 147 + const msg = err instanceof Error ? err.message : String(err); 148 + if (opts.json) { 149 + jsonError(msg); 150 + return; 151 + } 152 + console.error(msg); 114 153 process.exitCode = 1; 115 154 } 116 155 } 117 156 118 157 program.command('doctor') 119 158 .description('Verify vit environment and project configuration') 159 + .option('--json', 'Output as JSON') 120 160 .action(checkHealth); 121 161 program.command('status') 122 162 .description('Alias for doctor') 163 + .option('--json', 'Output as JSON') 123 164 .action(checkHealth); 124 165 }
+58 -9
src/cmd/follow.js
··· 1 1 // SPDX-License-Identifier: AGPL-3.0-only 2 2 // Copyright (c) 2026 sol pbc 3 3 4 - import { requireDid } from '../lib/config.js'; 4 + import { requireDid, loadConfig } from '../lib/config.js'; 5 5 import { restoreAgent } from '../lib/oauth.js'; 6 6 import { readFollowing, writeFollowing } from '../lib/vit-dir.js'; 7 7 import { mark } from '../lib/brand.js'; 8 + import { jsonOk, jsonError } from '../lib/json-output.js'; 8 9 9 10 export default function register(program) { 10 11 program ··· 12 13 .argument('<handle>', 'Handle to follow (e.g. alice.bsky.social)') 13 14 .description('Add an account to this project\'s following list') 14 15 .option('-v, --verbose', 'Show step-by-step details') 16 + .option('--json', 'Output as JSON') 15 17 .option('--did <did>', 'DID to use for handle resolution') 16 18 .action(async (handle, opts) => { 17 19 try { 18 20 const { verbose } = opts; 21 + const vlog = opts.json ? (...a) => console.error(...a) : console.log; 19 22 handle = handle.replace(/^@/, ''); 20 23 24 + if (opts.json && !(opts.did || loadConfig().did)) { 25 + jsonError('no DID configured', "run 'vit login <handle>' first"); 26 + return; 27 + } 21 28 const did = requireDid(opts); 22 29 if (!did) return; 23 - if (verbose) console.log(`[verbose] DID: ${did}`); 30 + if (verbose) vlog(`[verbose] DID: ${did}`); 24 31 25 32 const list = readFollowing(); 26 33 if (list.some(e => e.handle === handle)) { 34 + if (opts.json) { 35 + jsonError(`already following ${handle}`); 36 + return; 37 + } 27 38 console.error(`already following ${handle}`); 28 39 process.exitCode = 1; 29 40 return; 30 41 } 31 42 32 43 const { agent } = await restoreAgent(did); 33 - if (verbose) console.log('[verbose] session restored'); 44 + if (verbose) vlog('[verbose] session restored'); 34 45 35 46 const resolved = await agent.resolveHandle({ handle }); 36 47 const targetDid = resolved.data.did; 37 - if (verbose) console.log(`[verbose] resolved ${handle} to ${targetDid}`); 48 + if (verbose) vlog(`[verbose] resolved ${handle} to ${targetDid}`); 38 49 39 50 list.push({ handle, did: targetDid, followedAt: new Date().toISOString() }); 40 51 writeFollowing(list); 52 + if (opts.json) { 53 + jsonOk({ handle, did: targetDid, followedAt: list[list.length - 1].followedAt }); 54 + return; 55 + } 41 56 console.log(`${mark} following ${handle} (${targetDid})`); 42 57 } catch (err) { 43 - console.error(err instanceof Error ? err.message : String(err)); 58 + const msg = err instanceof Error ? err.message : String(err); 59 + if (opts.json) { 60 + jsonError(msg); 61 + return; 62 + } 63 + console.error(msg); 44 64 process.exitCode = 1; 45 65 } 46 66 }); ··· 50 70 .argument('<handle>', 'Handle to unfollow (e.g. alice.bsky.social)') 51 71 .description('Remove an account from this project\'s following list') 52 72 .option('-v, --verbose', 'Show step-by-step details') 73 + .option('--json', 'Output as JSON') 53 74 .action(async (handle, opts) => { 54 75 try { 55 76 const { verbose } = opts; 77 + const vlog = opts.json ? (...a) => console.error(...a) : console.log; 56 78 handle = handle.replace(/^@/, ''); 57 79 58 80 const list = readFollowing(); 59 81 const filtered = list.filter(e => e.handle !== handle); 60 82 if (filtered.length === list.length) { 83 + if (opts.json) { 84 + jsonError(`not following ${handle}`); 85 + return; 86 + } 61 87 console.error(`not following ${handle}`); 62 88 process.exitCode = 1; 63 89 return; 64 90 } 65 91 66 92 writeFollowing(filtered); 67 - if (verbose) console.log(`[verbose] removed ${handle} from following list`); 93 + if (verbose) vlog(`[verbose] removed ${handle} from following list`); 94 + if (opts.json) { 95 + jsonOk({ handle }); 96 + return; 97 + } 68 98 console.log(`${mark} unfollowed ${handle}`); 69 99 } catch (err) { 70 - console.error(err instanceof Error ? err.message : String(err)); 100 + const msg = err instanceof Error ? err.message : String(err); 101 + if (opts.json) { 102 + jsonError(msg); 103 + return; 104 + } 105 + console.error(msg); 71 106 process.exitCode = 1; 72 107 } 73 108 }); ··· 76 111 .command('following') 77 112 .description('List accounts in this project\'s following list') 78 113 .option('-v, --verbose', 'Show step-by-step details') 79 - .action(async (_opts) => { 114 + .option('--json', 'Output as JSON') 115 + .action(async (opts) => { 80 116 try { 81 117 const list = readFollowing(); 82 118 if (list.length === 0) { 119 + if (opts.json) { 120 + jsonOk({ following: [] }); 121 + return; 122 + } 83 123 console.log('no followings'); 84 124 return; 85 125 } 126 + if (opts.json) { 127 + jsonOk({ following: list }); 128 + return; 129 + } 86 130 for (const e of list) { 87 131 console.log(`${e.handle} (${e.did})`); 88 132 } 89 133 } catch (err) { 90 - console.error(err instanceof Error ? err.message : String(err)); 134 + const msg = err instanceof Error ? err.message : String(err); 135 + if (opts.json) { 136 + jsonError(msg); 137 + return; 138 + } 139 + console.error(msg); 91 140 process.exitCode = 1; 92 141 } 93 142 });
+52 -10
src/cmd/init.js
··· 8 8 import { vitDir, readProjectConfig, writeProjectConfig } from '../lib/vit-dir.js'; 9 9 import { requireAgent } from '../lib/agent.js'; 10 10 import { mark, name, DOT_VIT_README } from '../lib/brand.js'; 11 + import { jsonOk, jsonError } from '../lib/json-output.js'; 11 12 12 13 export default function register(program) { 13 14 program 14 15 .command('init') 15 16 .description('Initialize .vit directory and set project beacon. Use the most official upstream or well-known git URL so all contributors converge on the same beacon.') 16 17 .option('--beacon <url>', 'Git URL (or "." to read from git remote upstream/origin) to derive the beacon URI') 18 + .option('--json', 'Output as JSON') 17 19 .option('-v, --verbose', 'Show step-by-step details') 18 20 .action(async (opts) => { 19 21 try { 20 22 const gate = requireAgent(); 21 23 if (!gate.ok) { 24 + if (opts.json) { 25 + jsonError('agent required', 'run vit init from a coding agent'); 26 + return; 27 + } 22 28 console.error(`${name} init should be run by a coding agent (e.g. claude code, gemini cli).`); 23 29 console.error(`open your agent and ask it to run '${name} init' for you.`); 24 30 process.exitCode = 1; ··· 26 32 } 27 33 28 34 const { verbose } = opts; 35 + const vlog = opts.json ? (...a) => console.error(...a) : console.log; 29 36 const dir = vitDir(); 30 - if (verbose) console.log(`[verbose] .vit dir: ${dir}`); 37 + if (verbose) vlog(`[verbose] .vit dir: ${dir}`); 31 38 32 39 if (!opts.beacon) { 33 40 const config = readProjectConfig(); 34 41 if (config.beacon) { 42 + if (opts.json) { 43 + jsonOk({ beacon: config.beacon }); 44 + return; 45 + } 35 46 console.log(`${mark} beacon: ${config.beacon}`); 36 47 console.log(`hint: to change the beacon, run: ${name} init --beacon <git-url>`); 37 48 return; ··· 45 56 }); 46 57 isGitRepo = true; 47 58 } catch {} 48 - if (verbose) console.log(`[verbose] in git repo: ${isGitRepo ? 'yes' : 'no'}`); 59 + if (verbose) vlog(`[verbose] in git repo: ${isGitRepo ? 'yes' : 'no'}`); 49 60 50 61 const hasVitDir = existsSync(dir); 51 62 if (!isGitRepo) { 63 + let remotes = []; 64 + if (opts.json) { 65 + jsonOk({ 66 + status: hasVitDir ? 'no beacon' : 'not initialized', 67 + git: false, 68 + remotes, 69 + }); 70 + return; 71 + } 52 72 console.log(hasVitDir ? 'status: no beacon' : 'status: not initialized'); 53 73 console.log('git: false'); 54 74 if (hasVitDir) { ··· 74 94 } catch { 75 95 remoteNames = []; 76 96 } 77 - if (verbose) console.log(`[verbose] remotes detected: ${remoteNames.length > 0 ? remoteNames.join(', ') : 'none'}`); 97 + if (verbose) vlog(`[verbose] remotes detected: ${remoteNames.length > 0 ? remoteNames.join(', ') : 'none'}`); 78 98 79 99 const remotes = []; 80 100 for (const name of remoteNames) { ··· 87 107 } catch {} 88 108 } 89 109 if (verbose && remotes.length > 0) { 90 - console.log(`[verbose] remote urls: ${remotes.map(r => `${r.name}=${r.url}`).join(' ')}`); 110 + vlog(`[verbose] remote urls: ${remotes.map(r => `${r.name}=${r.url}`).join(' ')}`); 111 + } 112 + 113 + if (opts.json) { 114 + jsonOk({ 115 + status: hasVitDir ? 'no beacon' : 'not initialized', 116 + git: true, 117 + remotes: remotes.map(r => ({ name: r.name, url: r.url })), 118 + }); 119 + return; 91 120 } 92 121 93 122 const remotesDisplay = remotes.length > 0 ··· 110 139 111 140 let gitUrl = opts.beacon; 112 141 if (gitUrl === '.') { 113 - if (verbose) console.log('[verbose] resolving --beacon . via remote.upstream.url then remote.origin.url'); 142 + if (verbose) vlog('[verbose] resolving --beacon . via remote.upstream.url then remote.origin.url'); 114 143 let usedRemote = ''; 115 144 try { 116 145 gitUrl = execSync('git config --get remote.upstream.url', { ··· 135 164 } 136 165 137 166 if (!gitUrl) { 167 + if (opts.json) { 168 + jsonError('no git remote found', 'set a remote or provide a git URL directly'); 169 + return; 170 + } 138 171 console.error('No git remote found. Set a remote or provide a git URL directly.'); 139 172 process.exitCode = 1; 140 173 return; 141 174 } 142 - if (verbose) console.log(`[verbose] Read git remote ${usedRemote}: ${gitUrl}`); 175 + if (verbose) vlog(`[verbose] Read git remote ${usedRemote}: ${gitUrl}`); 143 176 } 144 177 145 178 const beacon = 'vit:' + toBeacon(gitUrl); 146 - if (verbose) console.log(`[verbose] Computed beacon: ${beacon}`); 179 + if (verbose) vlog(`[verbose] Computed beacon: ${beacon}`); 147 180 writeProjectConfig({ beacon }); 148 - if (verbose) console.log(`[verbose] Wrote config.json`); 181 + if (verbose) vlog(`[verbose] Wrote config.json`); 149 182 const readmePath = join(vitDir(), 'README.md'); 150 183 if (!existsSync(readmePath)) { 151 184 writeFileSync(readmePath, DOT_VIT_README); 152 - if (verbose) console.log(`[verbose] Wrote .vit/README.md`); 185 + if (verbose) vlog(`[verbose] Wrote .vit/README.md`); 186 + } 187 + if (opts.json) { 188 + jsonOk({ beacon }); 189 + return; 153 190 } 154 191 console.log(`${mark} beacon: ${beacon}`); 155 192 } catch (err) { 156 - console.error(err instanceof Error ? err.message : String(err)); 193 + const msg = err instanceof Error ? err.message : String(err); 194 + if (opts.json) { 195 + jsonError(msg); 196 + return; 197 + } 198 + console.error(msg); 157 199 process.exitCode = 1; 158 200 } 159 201 });
+50 -9
src/cmd/learn.js
··· 13 13 import { isSkillRef, nameFromSkillRef, isValidSkillRef } from '../lib/skill-ref.js'; 14 14 import { mark, name } from '../lib/brand.js'; 15 15 import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 16 + import { loadConfig } from '../lib/config.js'; 17 + import { jsonOk, jsonError } from '../lib/json-output.js'; 16 18 17 19 export default function register(program) { 18 20 program ··· 21 23 .description('Install a skill from the network into your skill directory') 22 24 .option('--did <did>', 'DID to use') 23 25 .option('--user', 'Install to user-wide ~/.claude/skills/ (requires vet)') 26 + .option('--json', 'Output as JSON') 24 27 .option('-v, --verbose', 'Show step-by-step details') 25 28 .action(async (ref, opts) => { 26 29 try { 27 30 const gate = requireAgent(); 28 31 if (!gate.ok) { 32 + if (opts.json) { 33 + jsonError('agent required', 'run vit learn from a coding agent'); 34 + return; 35 + } 29 36 console.error(`${name} learn should be run by a coding agent (e.g. claude code, gemini cli).`); 30 37 console.error(`open your agent and ask it to run '${name} learn' for you.`); 31 38 process.exitCode = 1; ··· 33 40 } 34 41 35 42 const { verbose } = opts; 43 + const vlog = opts.json ? (...a) => console.error(...a) : console.log; 36 44 37 45 if (!isSkillRef(ref)) { 46 + if (opts.json) { 47 + jsonError('invalid skill ref', 'expected format: skill-{name}'); 48 + return; 49 + } 38 50 console.error(`invalid skill ref. expected format: skill-{name} (e.g. skill-agent-test-patterns)`); 39 51 process.exitCode = 1; 40 52 return; 41 53 } 42 54 43 55 if (!isValidSkillRef(ref)) { 56 + if (opts.json) { 57 + jsonError('invalid skill ref', 'lowercase letters, numbers, hyphens only'); 58 + return; 59 + } 44 60 console.error('invalid skill ref. name must be lowercase letters, numbers, hyphens only.'); 45 61 console.error('no leading hyphen, no consecutive hyphens, max 64 chars.'); 46 62 process.exitCode = 1; ··· 48 64 } 49 65 50 66 const skillName = nameFromSkillRef(ref); 51 - if (verbose) console.log(`[verbose] skill name: ${skillName}`); 67 + if (verbose) vlog(`[verbose] skill name: ${skillName}`); 52 68 53 69 // Trust gate 54 70 const isUserInstall = !!opts.user; ··· 57 73 58 74 if (isUserInstall && !trustedEntry) { 59 75 // --user ALWAYS requires vet 76 + if (opts.json) { 77 + jsonError(`skill '${ref}' is not yet vetted`, 'user-wide install requires vetting'); 78 + return; 79 + } 60 80 console.error(`skill '${ref}' is not yet vetted. user-wide install requires vetting.`); 61 81 console.error(`ask the user to vet it first:`); 62 82 console.error(''); ··· 73 93 // Project-level: requires vet UNLESS dangerous-accept 74 94 const trustGate = shouldBypassVet(); 75 95 if (!trustGate.bypass) { 96 + if (opts.json) { 97 + jsonError(`skill '${ref}' is not yet vetted`, `run 'vit vet ${ref}' first`); 98 + return; 99 + } 76 100 console.error(`skill '${ref}' is not yet vetted.`); 77 101 console.error(`ask the user to vet it first:`); 78 102 console.error(''); ··· 90 114 process.exitCode = 1; 91 115 return; 92 116 } 93 - if (verbose) console.log(`[verbose] vet gate bypassed: ${trustGate.reason}`); 117 + if (verbose) vlog(`[verbose] vet gate bypassed: ${trustGate.reason}`); 94 118 } 95 119 120 + if (opts.json && !(opts.did || loadConfig().did)) { 121 + jsonError('no DID configured', "run 'vit login <handle>' first"); 122 + return; 123 + } 96 124 const did = requireDid(opts); 97 125 if (!did) return; 98 - if (verbose) console.log(`[verbose] DID: ${did}`); 126 + if (verbose) vlog(`[verbose] DID: ${did}`); 99 127 100 128 const { agent } = await restoreAgent(did); 101 - if (verbose) console.log('[verbose] session restored'); 129 + if (verbose) vlog('[verbose] session restored'); 102 130 103 131 // Build DID list from following + self 104 132 const following = readFollowing(); ··· 108 136 // Fetch skills from each DID, find matching ref 109 137 const allRecords = await batchQuery(dids, async (repoDid) => { 110 138 const pds = await resolvePds(repoDid); 111 - if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 139 + if (verbose) vlog(`[verbose] ${repoDid}: resolved PDS ${pds}`); 112 140 return (await listRecordsFromPds(pds, repoDid, SKILL_COLLECTION, 50)).records; 113 141 }, { verbose }); 114 142 ··· 125 153 } 126 154 127 155 if (!match) { 156 + if (opts.json) { 157 + jsonError(`no skill found with ref '${ref}'`); 158 + return; 159 + } 128 160 console.error(`no skill found with ref '${ref}' from followed accounts.`); 129 161 process.exitCode = 1; 130 162 return; 131 163 } 132 164 133 165 const record = match.value; 134 - if (verbose) console.log(`[verbose] found skill: ${record.name} from ${match.uri}`); 166 + if (verbose) vlog(`[verbose] found skill: ${record.name} from ${match.uri}`); 135 167 136 168 // Determine install path 137 169 let installDir; ··· 145 177 146 178 // Write SKILL.md from text field — verbatim, no reconstruction 147 179 writeFileSync(join(installDir, 'SKILL.md'), record.text); 148 - if (verbose) console.log(`[verbose] wrote SKILL.md to ${installDir}`); 180 + if (verbose) vlog(`[verbose] wrote SKILL.md to ${installDir}`); 149 181 150 182 // Download and write resource blobs 151 183 if (record.resources && record.resources.length > 0) { ··· 167 199 if (!blobRes.ok) throw new Error(`blob fetch failed: ${blobRes.status}`); 168 200 const blobData = Buffer.from(await blobRes.arrayBuffer()); 169 201 writeFileSync(resourcePath, blobData); 170 - if (verbose) console.log(`[verbose] wrote resource: ${resource.path}`); 202 + if (verbose) vlog(`[verbose] wrote resource: ${resource.path}`); 171 203 } 172 204 } catch (err) { 173 205 console.error(`warning: failed to download resource ${resource.path}: ${err.message}`); ··· 192 224 } 193 225 194 226 const scope = isUserInstall ? 'user' : 'project'; 227 + if (opts.json) { 228 + jsonOk({ ref, name: skillName, installedTo: installDir, scope, version: record.version || null }); 229 + return; 230 + } 195 231 console.log(`${mark} learned: ${ref} (${scope})`); 196 232 console.log(`installed to: ${installDir}`); 197 233 if (record.version) console.log(`version: ${record.version}`); 198 234 } catch (err) { 199 - console.error(err instanceof Error ? err.message : String(err)); 235 + const msg = err instanceof Error ? err.message : String(err); 236 + if (opts.json) { 237 + jsonError(msg); 238 + return; 239 + } 240 + console.error(msg); 200 241 process.exitCode = 1; 201 242 } 202 243 });
+45 -7
src/cmd/remix.js
··· 10 10 import { resolveRef, REF_PATTERN } from '../lib/cap-ref.js'; 11 11 import { brand, name } from '../lib/brand.js'; 12 12 import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 13 + import { loadConfig } from '../lib/config.js'; 14 + import { jsonOk, jsonError } from '../lib/json-output.js'; 13 15 14 16 export default function register(program) { 15 17 program ··· 17 19 .argument('<ref>', 'Three-word cap reference (e.g. fast-cache-invalidation)') 18 20 .description('Derive a vetted cap into the local codebase') 19 21 .option('--did <did>', 'DID to use') 22 + .option('--json', 'Output as JSON') 20 23 .option('-v, --verbose', 'Show step-by-step details') 21 24 .action(async (ref, opts) => { 22 25 try { 23 26 const gate = requireAgent(); 24 27 if (!gate.ok) { 28 + if (opts.json) { 29 + jsonError('agent required', 'run vit remix from a coding agent'); 30 + return; 31 + } 25 32 console.error(`${name} remix should be run by a coding agent (e.g. claude code, gemini cli).`); 26 33 console.error(`open your agent and ask it to run '${name} remix' for you.`); 27 34 process.exitCode = 1; ··· 29 36 } 30 37 31 38 const { verbose } = opts; 39 + const vlog = opts.json ? (...a) => console.error(...a) : console.log; 32 40 33 41 if (!REF_PATTERN.test(ref)) { 42 + if (opts.json) { 43 + jsonError('invalid ref', 'expected three lowercase words with dashes'); 44 + return; 45 + } 34 46 console.error('invalid ref. expected three lowercase words with dashes (e.g. fast-cache-invalidation)'); 35 47 process.exitCode = 1; 36 48 return; 37 49 } 38 50 51 + if (opts.json && !(opts.did || loadConfig().did)) { 52 + jsonError('no DID configured', "run 'vit login <handle>' first"); 53 + return; 54 + } 39 55 const did = requireDid(opts); 40 56 if (!did) return; 41 - if (verbose) console.log(`[verbose] DID: ${did}`); 57 + if (verbose) vlog(`[verbose] DID: ${did}`); 42 58 43 59 const projectConfig = readProjectConfig(); 44 60 const beacon = projectConfig.beacon; 45 61 if (!beacon) { 62 + if (opts.json) { 63 + jsonError('no beacon set', "run 'vit init' first"); 64 + return; 65 + } 46 66 console.error(`no beacon set. run '${name} init' in a project directory first.`); 47 67 process.exitCode = 1; 48 68 return; 49 69 } 50 - if (verbose) console.log(`[verbose] beacon: ${beacon}`); 70 + if (verbose) vlog(`[verbose] beacon: ${beacon}`); 51 71 52 72 const trusted = readLog('trusted.jsonl'); 53 73 const trustedEntry = trusted.find(e => e.ref === ref); 54 74 if (!trustedEntry) { 55 75 const trustGate = shouldBypassVet(); 56 76 if (!trustGate.bypass) { 77 + if (opts.json) { 78 + jsonError(`cap '${ref}' is not trusted`, `ask user to run 'vit vet ${ref} --trust'`); 79 + return; 80 + } 57 81 console.error(`cap '${ref}' is not trusted. ask the user to vet it first:`); 58 82 console.error(''); 59 83 console.error(` vit vet ${ref}`); ··· 70 94 process.exitCode = 1; 71 95 return; 72 96 } 73 - if (verbose) console.log(`[verbose] vet gate bypassed: ${trustGate.reason}`); 97 + if (verbose) vlog(`[verbose] vet gate bypassed: ${trustGate.reason}`); 74 98 } 75 - if (verbose && trustedEntry) console.log(`[verbose] trusted entry found, uri: ${trustedEntry.uri}`); 99 + if (verbose && trustedEntry) vlog(`[verbose] trusted entry found, uri: ${trustedEntry.uri}`); 76 100 77 101 const { agent } = await restoreAgent(did); 78 - if (verbose) console.log('[verbose] session restored'); 102 + if (verbose) vlog('[verbose] session restored'); 79 103 80 104 const following = readFollowing(); 81 105 const dids = following.map(e => e.did); ··· 83 107 84 108 const allRecords = await batchQuery(dids, async (repoDid) => { 85 109 const pds = await resolvePds(repoDid); 86 - if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 110 + if (verbose) vlog(`[verbose] ${repoDid}: resolved PDS ${pds}`); 87 111 return (await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50)).records; 88 112 }, { verbose }); 89 113 ··· 101 125 } 102 126 103 127 if (!match) { 128 + if (opts.json) { 129 + jsonError(`no cap found with ref '${ref}' for this beacon`); 130 + return; 131 + } 104 132 console.error(`no cap found with ref '${ref}' for this beacon.`); 105 133 process.exitCode = 1; 106 134 return; ··· 111 139 const title = record.title || ref; 112 140 const description = record.description || ''; 113 141 const text = record.text || ''; 142 + 143 + if (opts.json) { 144 + jsonOk({ ref, author, title, description, text }); 145 + return; 146 + } 114 147 115 148 console.log(`# ${brand} remix: ${title}`); 116 149 console.log(''); ··· 137 170 console.log(''); 138 171 console.log(text); 139 172 } catch (err) { 140 - console.error(err.message); 173 + const msg = err instanceof Error ? err.message : String(err); 174 + if (opts.json) { 175 + jsonError(msg); 176 + return; 177 + } 178 + console.error(msg); 141 179 process.exitCode = 1; 142 180 } 143 181 });
+31 -9
src/cmd/scan.js
··· 5 5 import { resolveRef } from '../lib/cap-ref.js'; 6 6 import { resolveHandleFromDid } from '../lib/pds.js'; 7 7 import { brand } from '../lib/brand.js'; 8 + import { jsonOk, jsonError } from '../lib/json-output.js'; 8 9 9 10 export default function register(program) { 10 11 program ··· 16 17 .option('--caps', 'Show only cap publishers') 17 18 .option('--tag <tag>', 'Filter skills by tag') 18 19 .option('-v, --verbose', 'Show each event as it arrives') 20 + .option('--json', 'Output as JSON') 19 21 .option('--jetstream <url>', 'Jetstream WebSocket URL (default: VIT_JETSTREAM_URL env or built-in)') 20 22 .action(async (opts) => { 21 23 try { 24 + const vlog = opts.json ? (...a) => console.error(...a) : console.log; 22 25 const days = parseInt(opts.days, 10); 23 26 if (isNaN(days) || days < 1) { 27 + if (opts.json) { 28 + jsonError('--days must be a positive integer'); 29 + return; 30 + } 24 31 console.error('error: --days must be a positive integer'); 25 32 process.exitCode = 1; 26 33 return; ··· 46 53 url.searchParams.set('cursor', String(cursor)); 47 54 48 55 const scanType = wantCaps && wantSkills ? 'cap + skill' : wantSkills ? 'skill' : 'cap'; 49 - console.log(`${brand} scan`); 50 - console.log(` Replaying ${days} day${days === 1 ? '' : 's'} of ${scanType} events...`); 51 - if (opts.beacon) console.log(` Beacon filter: ${opts.beacon}`); 52 - if (opts.tag) console.log(` Tag filter: ${opts.tag}`); 53 - console.log(` Timeout: ${Math.round(timeout / 1000)}s`); 54 - console.log(''); 56 + if (!opts.json) { 57 + console.log(`${brand} scan`); 58 + console.log(` Replaying ${days} day${days === 1 ? '' : 's'} of ${scanType} events...`); 59 + if (opts.beacon) console.log(` Beacon filter: ${opts.beacon}`); 60 + if (opts.tag) console.log(` Tag filter: ${opts.tag}`); 61 + console.log(` Timeout: ${Math.round(timeout / 1000)}s`); 62 + console.log(''); 63 + } 55 64 56 65 const publishers = new Map(); 57 66 ··· 92 101 if (isCapEvent) { 93 102 const title = record.title || ''; 94 103 const refPart = ref ? ` (${ref})` : ''; 95 - console.log(` ${didShort}: [cap] ${title}${refPart} [${record.beacon || 'no beacon'}]`); 104 + vlog(` ${didShort}: [cap] ${title}${refPart} [${record.beacon || 'no beacon'}]`); 96 105 } else { 97 106 const skillName = record.name || ''; 98 107 const tags = record.tags ? ` [${record.tags.join(', ')}]` : ''; 99 - console.log(` ${didShort}: [skill] ${skillName}${tags}`); 108 + vlog(` ${didShort}: [skill] ${skillName}${tags}`); 100 109 } 101 110 } 102 111 ··· 130 139 }); 131 140 132 141 if (publishers.size === 0) { 142 + if (opts.json) { 143 + jsonOk({ publishers: [] }); 144 + return; 145 + } 133 146 console.log(`no ${scanType} publishers found in this time window.`); 134 147 return; 135 148 } ··· 143 156 const totalCount = (e) => e.capCount + e.skillCount; 144 157 entries.sort((a, b) => totalCount(b) - totalCount(a)); 145 158 159 + if (opts.json) { 160 + jsonOk({ publishers: entries }); 161 + return; 162 + } 146 163 console.log(`found ${entries.length} publisher${entries.length === 1 ? '' : 's'}:\n`); 147 164 for (const e of entries) { 148 165 console.log(` @${e.handle}`); ··· 160 177 console.log(` ${parts.join(' ')}`); 161 178 } 162 179 } catch (err) { 163 - console.error(err instanceof Error ? err.message : String(err)); 180 + const msg = err instanceof Error ? err.message : String(err); 181 + if (opts.json) { 182 + jsonError(msg); 183 + return; 184 + } 185 + console.error(msg); 164 186 process.exitCode = 1; 165 187 } 166 188 });
+122 -21
src/cmd/ship.js
··· 6 6 import { join, relative } from 'node:path'; 7 7 import { CAP_COLLECTION, SKILL_COLLECTION } from '../lib/constants.js'; 8 8 import { requireAgent } from '../lib/agent.js'; 9 - import { requireDid } from '../lib/config.js'; 9 + import { requireDid, loadConfig } from '../lib/config.js'; 10 10 import { restoreAgent } from '../lib/oauth.js'; 11 11 import { appendLog, readProjectConfig, readLog, readFollowing } from '../lib/vit-dir.js'; 12 12 import { REF_PATTERN, resolveRef } from '../lib/cap-ref.js'; 13 13 import { isValidSkillName, skillRefFromName } from '../lib/skill-ref.js'; 14 14 import { name } from '../lib/brand.js'; 15 15 import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 16 + import { jsonOk, jsonError } from '../lib/json-output.js'; 16 17 17 18 function parseFrontmatter(text) { 18 19 const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); ··· 100 101 async function shipSkill(opts) { 101 102 const gate = requireAgent(); 102 103 if (!gate.ok) { 104 + if (opts.json) { 105 + jsonError('agent required', 'run vit ship --skill from a coding agent'); 106 + return; 107 + } 103 108 console.error(`${name} ship --skill should be run by a coding agent (e.g. claude code, gemini cli).`); 104 109 console.error(`open your agent and ask it to run '${name} ship --skill' for you.`); 105 110 process.exitCode = 1; ··· 107 112 } 108 113 109 114 const { verbose } = opts; 115 + const vlog = opts.json ? (...a) => console.error(...a) : console.log; 110 116 const skillDir = opts.skill; 111 117 112 118 // Validate skill directory ··· 115 121 skillMdPath = join(skillDir, 'SKILL.md'); 116 122 statSync(skillMdPath); 117 123 } catch { 124 + if (opts.json) { 125 + jsonError(`no SKILL.md found in ${skillDir}`); 126 + return; 127 + } 118 128 console.error(`error: no SKILL.md found in ${skillDir}`); 119 129 process.exitCode = 1; 120 130 return; ··· 123 133 // Read SKILL.md verbatim 124 134 const skillMdText = readFileSync(skillMdPath, 'utf-8'); 125 135 if (!skillMdText.trim()) { 136 + if (opts.json) { 137 + jsonError('SKILL.md is empty'); 138 + return; 139 + } 126 140 console.error('error: SKILL.md is empty'); 127 141 process.exitCode = 1; 128 142 return; ··· 133 147 134 148 const skillName = frontmatter.name; 135 149 if (!skillName) { 150 + if (opts.json) { 151 + jsonError("SKILL.md frontmatter must include a 'name' field"); 152 + return; 153 + } 136 154 console.error('error: SKILL.md frontmatter must include a "name" field'); 137 155 process.exitCode = 1; 138 156 return; 139 157 } 140 158 141 159 if (!isValidSkillName(skillName)) { 160 + if (opts.json) { 161 + jsonError('invalid skill name', 'lowercase letters, numbers, hyphens only'); 162 + return; 163 + } 142 164 console.error('error: skill name must be lowercase letters, numbers, hyphens only.'); 143 165 console.error(' no leading hyphen, no consecutive hyphens, max 64 chars.'); 144 166 console.error(` got: "${skillName}"`); ··· 148 170 149 171 const skillDescription = frontmatter.description; 150 172 if (!skillDescription) { 173 + if (opts.json) { 174 + jsonError("SKILL.md frontmatter must include a 'description' field"); 175 + return; 176 + } 151 177 console.error('error: SKILL.md frontmatter must include a "description" field'); 152 178 process.exitCode = 1; 153 179 return; 154 180 } 155 181 156 - if (verbose) console.log(`[verbose] skill name: ${skillName}`); 157 - if (verbose) console.log(`[verbose] skill description: ${skillDescription.slice(0, 80)}...`); 182 + if (verbose) vlog(`[verbose] skill name: ${skillName}`); 183 + if (verbose) vlog(`[verbose] skill description: ${skillDescription.slice(0, 80)}...`); 158 184 159 185 // DID 186 + if (opts.json && !(opts.did || loadConfig().did)) { 187 + jsonError('no DID configured', "run 'vit login <handle>' first"); 188 + return; 189 + } 160 190 const did = requireDid(opts); 161 191 if (!did) return; 162 - if (verbose) console.log(`[verbose] DID: ${did}`); 192 + if (verbose) vlog(`[verbose] DID: ${did}`); 163 193 164 194 // Session 165 195 let agent, session; 166 196 try { 167 197 ({ agent, session } = await restoreAgent(did)); 168 198 } catch { 199 + if (opts.json) { 200 + jsonError('session expired or invalid', "run 'vit login <handle>'"); 201 + return; 202 + } 169 203 console.error(`session expired or invalid. tell your user to run '${name} login <handle>'.`); 170 204 process.exitCode = 1; 171 205 return; 172 206 } 173 - if (verbose) console.log(`[verbose] Session restored, PDS: ${session.serverMetadata?.issuer}`); 207 + if (verbose) vlog(`[verbose] Session restored, PDS: ${session.serverMetadata?.issuer}`); 174 208 175 209 // Gather and upload resource files as blobs 176 210 const resourceFiles = gatherFiles(skillDir, skillDir); 177 211 const resources = []; 178 212 for (const rf of resourceFiles) { 179 - if (verbose) console.log(`[verbose] uploading resource: ${rf.path}`); 213 + if (verbose) vlog(`[verbose] uploading resource: ${rf.path}`); 180 214 const data = readFileSync(rf.fullPath); 181 215 const mimeType = guessMimeType(rf.path); 182 216 try { ··· 187 221 mimeType, 188 222 }); 189 223 } catch (err) { 224 + if (opts.json) { 225 + jsonError(`failed to upload resource ${rf.path}: ${err.message}`); 226 + return; 227 + } 190 228 console.error(`error: failed to upload resource ${rf.path}: ${err.message}`); 191 229 process.exitCode = 1; 192 230 return; ··· 220 258 } 221 259 222 260 const rkey = TID.nextStr(); 223 - if (verbose) console.log(`[verbose] Record built, ref: ${ref}, rkey: ${rkey}`); 261 + if (verbose) vlog(`[verbose] Record built, ref: ${ref}, rkey: ${rkey}`); 224 262 225 263 const putArgs = { 226 264 repo: did, ··· 230 268 validate: true, 231 269 }; 232 270 233 - if (verbose) console.log(`[verbose] putRecord ${putArgs.collection} rkey=${rkey}`); 271 + if (verbose) vlog(`[verbose] putRecord ${putArgs.collection} rkey=${rkey}`); 234 272 const putRes = await agent.com.atproto.repo.putRecord(putArgs); 235 273 236 274 try { ··· 248 286 } catch (logErr) { 249 287 console.error('warning: failed to write skills.jsonl:', logErr.message); 250 288 } 251 - if (verbose) console.log(`[verbose] Log written to skills.jsonl`); 289 + if (verbose) vlog(`[verbose] Log written to skills.jsonl`); 252 290 291 + if (opts.json) { 292 + jsonOk({ ref, uri: putRes.data.uri }); 293 + return; 294 + } 253 295 console.log(`shipped: ${ref}`); 254 296 console.log(`uri: ${putRes.data.uri}`); 255 297 if (verbose) { 256 - console.log( 298 + vlog( 257 299 JSON.stringify({ 258 300 ts: now, 259 301 pds: session.serverMetadata?.issuer, ··· 268 310 async function shipCap(opts) { 269 311 const gate = requireAgent(); 270 312 if (!gate.ok) { 313 + if (opts.json) { 314 + jsonError('agent required', 'run vit ship from a coding agent'); 315 + return; 316 + } 271 317 console.error(`${name} ship should be run by a coding agent (e.g. claude code, gemini cli).`); 272 318 console.error(`open your agent and ask it to run '${name} ship' for you.`); 273 319 console.error(`refer to the using-vit skill (skills/vit/SKILL.md) for a shipping guide.`); ··· 276 322 } 277 323 278 324 const { verbose } = opts; 325 + const vlog = opts.json ? (...a) => console.error(...a) : console.log; 279 326 280 327 // preflight: DID 328 + if (opts.json && !(opts.did || loadConfig().did)) { 329 + jsonError('no DID configured', "run 'vit login <handle>' first"); 330 + return; 331 + } 281 332 const did = requireDid(opts); 282 333 if (!did) return; 283 - if (verbose) console.log(`[verbose] DID: ${did}`); 334 + if (verbose) vlog(`[verbose] DID: ${did}`); 284 335 285 336 // preflight: beacon 286 337 const projectConfig = readProjectConfig(); 287 338 if (!projectConfig.beacon) { 339 + if (opts.json) { 340 + jsonError('no beacon set', "run 'vit init' first"); 341 + return; 342 + } 288 343 console.error(`no beacon set. run '${name} init' in a project directory first.`); 289 344 process.exitCode = 1; 290 345 return; 291 346 } 292 - if (verbose) console.log(`[verbose] beacon: ${projectConfig.beacon}`); 347 + if (verbose) vlog(`[verbose] beacon: ${projectConfig.beacon}`); 293 348 294 349 let text; 295 350 try { ··· 298 353 text = ''; 299 354 } 300 355 if (!text) { 356 + if (opts.json) { 357 + jsonError('cap body is required via stdin'); 358 + return; 359 + } 301 360 console.error('error: cap body is required via stdin (pipe or heredoc)'); 302 361 process.exitCode = 1; 303 362 return; 304 363 } 305 364 306 365 if (!REF_PATTERN.test(opts.ref)) { 366 + if (opts.json) { 367 + jsonError('--ref must be exactly three lowercase words separated by dashes'); 368 + return; 369 + } 307 370 console.error('error: --ref must be exactly three lowercase words separated by dashes (e.g. fast-cache-invalidation)'); 308 371 process.exitCode = 1; 309 372 return; ··· 312 375 let recapUri = null; 313 376 if (opts.recap) { 314 377 if (!REF_PATTERN.test(opts.recap)) { 378 + if (opts.json) { 379 + jsonError('--recap must be exactly three lowercase words separated by dashes'); 380 + return; 381 + } 315 382 console.error('error: --recap must be exactly three lowercase words separated by dashes (e.g. fast-cache-invalidation)'); 316 383 process.exitCode = 1; 317 384 return; ··· 321 388 const localMatch = caps.find(e => e.ref === opts.recap); 322 389 if (localMatch) { 323 390 recapUri = localMatch.uri; 324 - if (verbose) console.log(`[verbose] recap resolved locally: ${recapUri}`); 391 + if (verbose) vlog(`[verbose] recap resolved locally: ${recapUri}`); 325 392 } 326 393 } 327 394 328 395 if (opts.kind) { 329 396 const validKinds = ['feat', 'fix', 'test', 'docs', 'refactor', 'chore', 'perf', 'style']; 330 397 if (!validKinds.includes(opts.kind)) { 398 + if (opts.json) { 399 + jsonError(`--kind must be one of: ${validKinds.join(', ')}`); 400 + return; 401 + } 331 402 console.error(`error: --kind must be one of: ${validKinds.join(', ')}`); 332 403 process.exitCode = 1; 333 404 return; ··· 341 412 try { 342 413 ({ agent, session } = await restoreAgent(did)); 343 414 } catch { 415 + if (opts.json) { 416 + jsonError('session expired or invalid', "run 'vit login <handle>'"); 417 + return; 418 + } 344 419 console.error(`session expired or invalid. tell your user to run '${name} login <handle>'.`); 345 420 process.exitCode = 1; 346 421 return; 347 422 } 348 - if (verbose) console.log(`[verbose] Session restored, PDS: ${session.serverMetadata?.issuer}`); 423 + if (verbose) vlog(`[verbose] Session restored, PDS: ${session.serverMetadata?.issuer}`); 349 424 350 425 if (opts.recap && !recapUri) { 351 426 const following = readFollowing(); ··· 354 429 355 430 const allRecords = await batchQuery(dids, async (repoDid) => { 356 431 const pds = await resolvePds(repoDid); 357 - if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 432 + if (verbose) vlog(`[verbose] ${repoDid}: resolved PDS ${pds}`); 358 433 return (await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50)).records; 359 434 }, { verbose }); 360 435 ··· 372 447 373 448 if (match) { 374 449 recapUri = match.uri; 375 - if (verbose) console.log(`[verbose] recap resolved remotely: ${recapUri}`); 450 + if (verbose) vlog(`[verbose] recap resolved remotely: ${recapUri}`); 376 451 } else { 452 + if (opts.json) { 453 + jsonError(`could not find cap with ref '${opts.recap}' to recap`); 454 + return; 455 + } 377 456 console.error(`error: could not find cap with ref '${opts.recap}' to recap`); 378 457 process.exitCode = 1; 379 458 return; ··· 392 471 if (opts.kind) record.kind = opts.kind; 393 472 if (opts.recap) record.recap = { uri: recapUri, ref: opts.recap }; 394 473 const rkey = TID.nextStr(); 395 - if (verbose) console.log(`[verbose] Record built, rkey: ${rkey}`); 474 + if (verbose) vlog(`[verbose] Record built, rkey: ${rkey}`); 396 475 const putArgs = { 397 476 repo: did, 398 477 collection: CAP_COLLECTION, ··· 400 479 record, 401 480 validate: true, 402 481 }; 403 - if (verbose) console.log(`[verbose] putRecord ${putArgs.collection} rkey=${rkey}`); 482 + if (verbose) vlog(`[verbose] putRecord ${putArgs.collection} rkey=${rkey}`); 404 483 const putRes = await agent.com.atproto.repo.putRecord(putArgs); 405 484 try { 406 485 appendLog('caps.jsonl', { ··· 416 495 } catch (logErr) { 417 496 console.error('warning: failed to write caps.jsonl:', logErr.message); 418 497 } 419 - if (verbose) console.log(`[verbose] Log written to caps.jsonl`); 498 + if (verbose) vlog(`[verbose] Log written to caps.jsonl`); 499 + if (opts.json) { 500 + jsonOk({ ref: opts.ref, uri: putRes.data.uri }); 501 + return; 502 + } 420 503 console.log(`shipped: ${opts.ref}`); 421 504 console.log(`uri: ${putRes.data.uri}`); 422 505 if (verbose) { 423 - console.log( 506 + vlog( 424 507 JSON.stringify({ 425 508 ts: now, 426 509 pds: session.serverMetadata?.issuer, ··· 437 520 .command('ship') 438 521 .description('Publish a cap or skill to your feed') 439 522 .option('-v, --verbose', 'Show step-by-step details') 523 + .option('--json', 'Output as JSON') 440 524 .option('--did <did>', 'DID to use (reads saved DID from config if not provided)') 441 525 .option('--title <title>', 'Short title for the cap') 442 526 .option('--description <description>', 'Description of the cap') ··· 454 538 } else { 455 539 // Validate required cap fields 456 540 if (!opts.title) { 541 + if (opts.json) { 542 + jsonError("required option '--title <title>' not specified"); 543 + return; 544 + } 457 545 console.error("error: required option '--title <title>' not specified"); 458 546 process.exitCode = 1; 459 547 return; 460 548 } 461 549 if (!opts.description) { 550 + if (opts.json) { 551 + jsonError("required option '--description <description>' not specified"); 552 + return; 553 + } 462 554 console.error("error: required option '--description <description>' not specified"); 463 555 process.exitCode = 1; 464 556 return; 465 557 } 466 558 if (!opts.ref) { 559 + if (opts.json) { 560 + jsonError("required option '--ref <ref>' not specified"); 561 + return; 562 + } 467 563 console.error("error: required option '--ref <ref>' not specified"); 468 564 process.exitCode = 1; 469 565 return; ··· 471 567 await shipCap(opts); 472 568 } 473 569 } catch (err) { 474 - console.error(err instanceof Error ? err.message : String(err)); 570 + const msg = err instanceof Error ? err.message : String(err); 571 + if (opts.json) { 572 + jsonError(msg); 573 + return; 574 + } 575 + console.error(msg); 475 576 process.exitCode = 1; 476 577 } 477 578 })
+104 -24
src/cmd/vet.js
··· 12 12 import { isSkillRef, isValidSkillRef, nameFromSkillRef } from '../lib/skill-ref.js'; 13 13 import { mark, brand, name } from '../lib/brand.js'; 14 14 import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 15 + import { loadConfig } from '../lib/config.js'; 16 + import { jsonOk, jsonError } from '../lib/json-output.js'; 15 17 16 18 function ensureGitignore() { 17 19 const gitignorePath = join(vitDir(), '.gitignore'); ··· 32 34 .option('--trust', 'Mark the item as locally trusted') 33 35 .option('--dangerous-accept', 'Permanently disable vet gate for this project (human only)') 34 36 .option('--confirm', 'Confirm dangerous-accept, or bypass agent gate with --trust') 37 + .option('--json', 'Output as JSON') 35 38 .option('-v, --verbose', 'Show step-by-step details') 36 39 .action(async (ref, opts) => { 37 40 try { 41 + const { verbose } = opts; 42 + const vlog = opts.json ? (...a) => console.error(...a) : console.log; 38 43 // --- dangerous-accept flow --- 39 44 if (opts.dangerousAccept) { 40 45 const gate = requireNotAgent(); 41 46 if (!gate.ok) { 47 + if (opts.json) { 48 + jsonError('dangerous-accept is human-only'); 49 + return; 50 + } 42 51 console.error(`${name} vet --dangerous-accept is human-only. agents cannot set this flag.`); 43 52 process.exitCode = 1; 44 53 return; ··· 50 59 const acceptPath = join(dir, 'dangerous-accept'); 51 60 writeFileSync(acceptPath, JSON.stringify({ acceptedAt: new Date().toISOString() }) + '\n'); 52 61 ensureGitignore(); 62 + if (opts.json) { 63 + jsonOk({ dangerousAccept: true }); 64 + return; 65 + } 53 66 console.log('dangerous-accept enabled for this project.'); 54 67 console.log(''); 55 68 console.log('agents can now remix and learn without vetting.'); 56 69 console.log('to revoke: delete .vit/dangerous-accept'); 57 70 } else { 71 + if (opts.json) { 72 + jsonOk({ dangerousAccept: false, message: 'confirm with --confirm' }); 73 + return; 74 + } 58 75 console.log(''); 59 76 console.log(' WARNING: this permanently disables the vetting safety gate for all'); 60 77 console.log(' caps and skills in this project.'); ··· 70 87 71 88 // --- Regular vet flow: ref is required --- 72 89 if (!ref) { 90 + if (opts.json) { 91 + jsonError('ref argument is required', 'usage: vit vet <ref>'); 92 + return; 93 + } 73 94 console.error('ref argument is required for vetting. usage: vit vet <ref>'); 74 95 process.exitCode = 1; 75 96 return; 76 97 } 77 98 99 + const isSkill = isSkillRef(ref); 100 + 101 + // Validate ref format 102 + if (isSkill) { 103 + if (!isValidSkillRef(ref)) { 104 + if (opts.json) { 105 + jsonError('invalid skill ref', 'expected format: skill-{name}'); 106 + return; 107 + } 108 + console.error('invalid skill ref. expected format: skill-{name} (lowercase letters, numbers, hyphens)'); 109 + process.exitCode = 1; 110 + return; 111 + } 112 + } else { 113 + if (!REF_PATTERN.test(ref)) { 114 + if (opts.json) { 115 + jsonError('invalid ref', 'expected three lowercase words with dashes'); 116 + return; 117 + } 118 + console.error('invalid ref. expected three lowercase words with dashes (e.g. fast-cache-invalidation)'); 119 + process.exitCode = 1; 120 + return; 121 + } 122 + } 123 + 78 124 // --- Agent gate --- 79 125 const agent = detectCodingAgent(); 80 126 if (agent) { 81 127 if (opts.trust && opts.confirm) { 82 128 // Sandboxed sub-agent pattern — allow it 83 129 } else { 130 + if (opts.json) { 131 + jsonError('vit vet is for human review', 'use --trust --confirm to bypass'); 132 + return; 133 + } 84 134 console.error('vit vet is for human review. agents should not vet directly.'); 85 135 console.error(''); 86 136 console.error('if you are a sandboxed sub-agent specifically tasked with vetting,'); ··· 95 145 } 96 146 } 97 147 98 - const { verbose } = opts; 99 - const isSkill = isSkillRef(ref); 100 - 101 - // Validate ref format 102 - if (isSkill) { 103 - if (!isValidSkillRef(ref)) { 104 - console.error('invalid skill ref. expected format: skill-{name} (lowercase letters, numbers, hyphens)'); 105 - process.exitCode = 1; 106 - return; 107 - } 108 - } else { 109 - if (!REF_PATTERN.test(ref)) { 110 - console.error('invalid ref. expected three lowercase words with dashes (e.g. fast-cache-invalidation)'); 111 - process.exitCode = 1; 112 - return; 113 - } 148 + if (opts.json && !(opts.did || loadConfig().did)) { 149 + jsonError('no DID configured', "run 'vit login <handle>' first"); 150 + return; 114 151 } 115 - 116 152 const did = requireDid(opts); 117 153 if (!did) return; 118 - if (verbose) console.log(`[verbose] DID: ${did}`); 154 + if (verbose) vlog(`[verbose] DID: ${did}`); 119 155 120 156 if (!isSkill) { 121 157 // Cap vet requires beacon 122 158 const projectConfig = readProjectConfig(); 123 159 const beacon = projectConfig.beacon; 124 160 if (!beacon) { 161 + if (opts.json) { 162 + jsonError('no beacon set', "run 'vit init' first"); 163 + return; 164 + } 125 165 console.error(`no beacon set. run '${name} init' in a project directory first.`); 126 166 process.exitCode = 1; 127 167 return; 128 168 } 129 - if (verbose) console.log(`[verbose] beacon: ${beacon}`); 169 + if (verbose) vlog(`[verbose] beacon: ${beacon}`); 130 170 131 171 const { agent: oauthAgent } = await restoreAgent(did); 132 - if (verbose) console.log('[verbose] session restored'); 172 + if (verbose) vlog('[verbose] session restored'); 133 173 134 174 // build DID list from following + self 135 175 const following = readFollowing(); ··· 139 179 // fetch caps from each DID, find matching ref 140 180 const allRecords = await batchQuery(dids, async (repoDid) => { 141 181 const pds = await resolvePds(repoDid); 142 - if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 182 + if (verbose) vlog(`[verbose] ${repoDid}: resolved PDS ${pds}`); 143 183 return (await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50)).records; 144 184 }, { verbose }); 145 185 ··· 157 197 } 158 198 159 199 if (!match) { 200 + if (opts.json) { 201 + jsonError(`no cap found with ref '${ref}' for this beacon`); 202 + return; 203 + } 160 204 console.error(`no cap found with ref '${ref}' for this beacon.`); 161 205 process.exitCode = 1; 162 206 return; ··· 170 214 uri: match.uri, 171 215 trustedAt: new Date().toISOString(), 172 216 }); 217 + if (opts.json) { 218 + jsonOk({ trusted: true, ref, uri: match.uri }); 219 + return; 220 + } 173 221 console.log(`${mark} trusted: ${ref}`); 174 222 return; 175 223 } ··· 178 226 const title = record.title || ''; 179 227 const description = record.description || ''; 180 228 const text = record.text || ''; 229 + 230 + if (opts.json) { 231 + jsonOk({ ref, type: 'cap', author, title, description, text }); 232 + return; 233 + } 181 234 182 235 console.log(`=== ${brand} cap review ===`); 183 236 console.log('Review this cap carefully before trusting it.'); ··· 204 257 const skillName = nameFromSkillRef(ref); 205 258 206 259 const { agent: oauthAgent } = await restoreAgent(did); 207 - if (verbose) console.log('[verbose] session restored'); 260 + if (verbose) vlog('[verbose] session restored'); 208 261 209 262 const following = readFollowing(); 210 263 const dids = following.map(e => e.did); ··· 212 265 213 266 const allRecords = await batchQuery(dids, async (repoDid) => { 214 267 const pds = await resolvePds(repoDid); 215 - if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 268 + if (verbose) vlog(`[verbose] ${repoDid}: resolved PDS ${pds}`); 216 269 return (await listRecordsFromPds(pds, repoDid, SKILL_COLLECTION, 50)).records; 217 270 }, { verbose }); 218 271 ··· 228 281 } 229 282 230 283 if (!match) { 284 + if (opts.json) { 285 + jsonError(`no skill found with ref '${ref}'`); 286 + return; 287 + } 231 288 console.error(`no skill found with ref '${ref}' from followed accounts.`); 232 289 process.exitCode = 1; 233 290 return; ··· 241 298 uri: match.uri, 242 299 trustedAt: new Date().toISOString(), 243 300 }); 301 + if (opts.json) { 302 + jsonOk({ trusted: true, ref, uri: match.uri }); 303 + return; 304 + } 244 305 console.log(`${mark} trusted: ${ref}`); 245 306 return; 246 307 } 247 308 248 309 const author = match.uri.split('/')[2]; 249 310 311 + if (opts.json) { 312 + jsonOk({ 313 + ref, 314 + type: 'skill', 315 + name: record.name, 316 + author, 317 + version: record.version || null, 318 + license: record.license || null, 319 + description: record.description || null, 320 + text: record.text || null, 321 + }); 322 + return; 323 + } 324 + 250 325 console.log(`=== ${brand} skill review ===`); 251 326 console.log('Review this skill carefully before trusting it.'); 252 327 console.log(''); ··· 287 362 console.log(` vit vet ${ref} --trust`); 288 363 } 289 364 } catch (err) { 290 - console.error(err instanceof Error ? err.message : String(err)); 365 + const msg = err instanceof Error ? err.message : String(err); 366 + if (opts.json) { 367 + jsonError(msg); 368 + return; 369 + } 370 + console.error(msg); 291 371 process.exitCode = 1; 292 372 } 293 373 });
+62 -13
src/cmd/vouch.js
··· 10 10 import { isSkillRef, isValidSkillRef, nameFromSkillRef } from '../lib/skill-ref.js'; 11 11 import { mark, name } from '../lib/brand.js'; 12 12 import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 13 + import { loadConfig } from '../lib/config.js'; 14 + import { jsonOk, jsonError } from '../lib/json-output.js'; 13 15 14 16 export default function register(program) { 15 17 program ··· 17 19 .argument('<ref>', 'Cap or skill reference (e.g. fast-cache-invalidation or skill-agent-test-patterns)') 18 20 .description('Publicly endorse a vetted cap or skill') 19 21 .option('--did <did>', 'DID to use') 22 + .option('--json', 'Output as JSON') 20 23 .option('-v, --verbose', 'Show step-by-step details') 21 24 .action(async (ref, opts) => { 22 25 try { 23 26 const { verbose } = opts; 27 + const vlog = opts.json ? (...a) => console.error(...a) : console.log; 24 28 const isSkill = isSkillRef(ref); 25 29 26 30 // Validate ref format 27 31 if (isSkill) { 28 32 if (!isValidSkillRef(ref)) { 33 + if (opts.json) { 34 + jsonError('invalid skill ref', 'expected format: skill-{name}'); 35 + return; 36 + } 29 37 console.error('invalid skill ref. expected format: skill-{name} (lowercase letters, numbers, hyphens)'); 30 38 process.exitCode = 1; 31 39 return; 32 40 } 33 41 } else { 34 42 if (!REF_PATTERN.test(ref)) { 43 + if (opts.json) { 44 + jsonError('invalid ref', 'expected three lowercase words with dashes'); 45 + return; 46 + } 35 47 console.error('invalid ref. expected three lowercase words with dashes (e.g. fast-cache-invalidation)'); 36 48 process.exitCode = 1; 37 49 return; 38 50 } 39 51 } 40 52 53 + if (opts.json && !(opts.did || loadConfig().did)) { 54 + jsonError('no DID configured', "run 'vit login <handle>' first"); 55 + return; 56 + } 41 57 const did = requireDid(opts); 42 58 if (!did) return; 43 - if (verbose) console.log(`[verbose] DID: ${did}`); 59 + if (verbose) vlog(`[verbose] DID: ${did}`); 44 60 45 61 if (isSkill) { 46 62 // Skill vouch — no beacon required, check trusted first 47 63 const trusted = readLog('trusted.jsonl'); 48 64 const trustedEntry = trusted.find(e => e.ref === ref); 49 65 if (!trustedEntry) { 66 + if (opts.json) { 67 + jsonError(`skill '${ref}' is not yet vetted`, `run 'vit vet ${ref}' first`); 68 + return; 69 + } 50 70 console.error(`skill '${ref}' is not yet vetted. vet it first:`); 51 71 console.error(''); 52 72 console.error(` vit vet ${ref}`); ··· 57 77 process.exitCode = 1; 58 78 return; 59 79 } 60 - if (verbose) console.log(`[verbose] trusted entry found, uri: ${trustedEntry.uri}`); 80 + if (verbose) vlog(`[verbose] trusted entry found, uri: ${trustedEntry.uri}`); 61 81 62 82 const skillName = nameFromSkillRef(ref); 63 83 64 84 const { agent } = await restoreAgent(did); 65 - if (verbose) console.log('[verbose] session restored'); 85 + if (verbose) vlog('[verbose] session restored'); 66 86 67 87 const following = readFollowing(); 68 88 const dids = following.map(e => e.did); ··· 70 90 71 91 const allRecords = await batchQuery(dids, async (repoDid) => { 72 92 const pds = await resolvePds(repoDid); 73 - if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 93 + if (verbose) vlog(`[verbose] ${repoDid}: resolved PDS ${pds}`); 74 94 return (await listRecordsFromPds(pds, repoDid, SKILL_COLLECTION, 50)).records; 75 95 }, { verbose }); 76 96 ··· 86 106 } 87 107 88 108 if (!match) { 109 + if (opts.json) { 110 + jsonError(`no skill found with ref '${ref}'`); 111 + return; 112 + } 89 113 console.error(`no skill found with ref '${ref}' from followed accounts.`); 90 114 process.exitCode = 1; 91 115 return; ··· 102 126 ref, 103 127 // No beacon for skill vouches 104 128 }; 105 - if (verbose) console.log(`[verbose] creating vouch for ${match.uri}`); 129 + if (verbose) vlog(`[verbose] creating vouch for ${match.uri}`); 106 130 const rkey = TID.nextStr(); 107 131 const res = await agent.com.atproto.repo.putRecord({ 108 132 repo: did, ··· 123 147 } catch (logErr) { 124 148 console.error('warning: failed to write vouched.jsonl:', logErr.message); 125 149 } 126 - if (verbose) console.log('[verbose] logged to vouched.jsonl'); 150 + if (verbose) vlog('[verbose] logged to vouched.jsonl'); 127 151 152 + if (opts.json) { 153 + jsonOk({ ref, uri: match.uri, vouchUri: res.data.uri }); 154 + return; 155 + } 128 156 console.log(`${mark} vouched: ${ref} (${match.uri})`); 129 157 } else { 130 158 // Cap vouch — requires beacon (check beacon before trusted, matching original behavior) 131 159 const projectConfig = readProjectConfig(); 132 160 const beacon = projectConfig.beacon; 133 161 if (!beacon) { 162 + if (opts.json) { 163 + jsonError('no beacon set', "run 'vit init' first"); 164 + return; 165 + } 134 166 console.error(`no beacon set. run '${name} init' in a project directory first.`); 135 167 process.exitCode = 1; 136 168 return; 137 169 } 138 - if (verbose) console.log(`[verbose] beacon: ${beacon}`); 170 + if (verbose) vlog(`[verbose] beacon: ${beacon}`); 139 171 140 172 const trusted = readLog('trusted.jsonl'); 141 173 const trustedEntry = trusted.find(e => e.ref === ref); 142 174 if (!trustedEntry) { 175 + if (opts.json) { 176 + jsonError(`cap '${ref}' is not yet vetted`, `run 'vit vet ${ref}' first`); 177 + return; 178 + } 143 179 console.error(`cap '${ref}' is not yet vetted. vet it first:`); 144 180 console.error(''); 145 181 console.error(` vit vet ${ref}`); ··· 150 186 process.exitCode = 1; 151 187 return; 152 188 } 153 - if (verbose) console.log(`[verbose] trusted entry found, uri: ${trustedEntry.uri}`); 189 + if (verbose) vlog(`[verbose] trusted entry found, uri: ${trustedEntry.uri}`); 154 190 155 191 const { agent } = await restoreAgent(did); 156 - if (verbose) console.log('[verbose] session restored'); 192 + if (verbose) vlog('[verbose] session restored'); 157 193 158 194 const following = readFollowing(); 159 195 const dids = following.map(e => e.did); ··· 161 197 162 198 const allRecords = await batchQuery(dids, async (repoDid) => { 163 199 const pds = await resolvePds(repoDid); 164 - if (verbose) console.log(`[verbose] ${repoDid}: resolved PDS ${pds}`); 200 + if (verbose) vlog(`[verbose] ${repoDid}: resolved PDS ${pds}`); 165 201 return (await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50)).records; 166 202 }, { verbose }); 167 203 ··· 179 215 } 180 216 181 217 if (!match) { 218 + if (opts.json) { 219 + jsonError(`no cap found with ref '${ref}' for this beacon`); 220 + return; 221 + } 182 222 console.error(`no cap found with ref '${ref}' for this beacon.`); 183 223 process.exitCode = 1; 184 224 return; ··· 195 235 ref, 196 236 beacon, 197 237 }; 198 - if (verbose) console.log(`[verbose] creating vouch for ${match.uri}`); 238 + if (verbose) vlog(`[verbose] creating vouch for ${match.uri}`); 199 239 const rkey = TID.nextStr(); 200 240 const res = await agent.com.atproto.repo.putRecord({ 201 241 repo: did, ··· 217 257 } catch (logErr) { 218 258 console.error('warning: failed to write vouched.jsonl:', logErr.message); 219 259 } 220 - if (verbose) console.log('[verbose] logged to vouched.jsonl'); 260 + if (verbose) vlog('[verbose] logged to vouched.jsonl'); 221 261 262 + if (opts.json) { 263 + jsonOk({ ref, uri: match.uri, vouchUri: res.data.uri }); 264 + return; 265 + } 222 266 console.log(`${mark} vouched: ${ref} (${match.uri})`); 223 267 } 224 268 } catch (err) { 225 - console.error(err instanceof Error ? err.message : String(err)); 269 + const msg = err instanceof Error ? err.message : String(err); 270 + if (opts.json) { 271 + jsonError(msg); 272 + return; 273 + } 274 + console.error(msg); 226 275 process.exitCode = 1; 227 276 } 228 277 });
+13
src/lib/json-output.js
··· 1 + // SPDX-License-Identifier: AGPL-3.0-only 2 + // Copyright (c) 2026 sol pbc 3 + 4 + export function jsonOk(data) { 5 + console.log(JSON.stringify({ ok: true, ...data }, null, 2)); 6 + } 7 + 8 + export function jsonError(error, hint) { 9 + const obj = { ok: false, error }; 10 + if (hint) obj.hint = hint; 11 + console.log(JSON.stringify(obj, null, 2)); 12 + process.exitCode = 1; 13 + }
+253
test/json-output.test.js
··· 1 + // SPDX-License-Identifier: AGPL-3.0-only 2 + // Copyright (c) 2026 sol pbc 3 + 4 + import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; 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'; 9 + 10 + const agentEnv = { CLAUDECODE: '1' }; 11 + 12 + function parseJson(stdout) { 13 + return JSON.parse(stdout); 14 + } 15 + 16 + describe('--json flag', () => { 17 + let tmpDir; 18 + 19 + beforeEach(() => { 20 + tmpDir = join(tmpdir(), '.test-json-' + Math.random().toString(36).slice(2)); 21 + mkdirSync(join(tmpDir, '.vit'), { recursive: true }); 22 + }); 23 + 24 + afterEach(() => { 25 + rmSync(tmpDir, { recursive: true, force: true }); 26 + }); 27 + 28 + describe('init --json', () => { 29 + test('reports status as JSON when not initialized', () => { 30 + const r = run('init --json', tmpDir, agentEnv); 31 + const j = parseJson(r.stdout); 32 + expect(j.ok).toBe(true); 33 + expect(j.status).toBe('no beacon'); 34 + }); 35 + 36 + test('reports beacon as JSON when set', () => { 37 + run('init --beacon https://github.com/solpbc/vit.git', tmpDir, agentEnv); 38 + const r = run('init --json', tmpDir, agentEnv); 39 + const j = parseJson(r.stdout); 40 + expect(j.ok).toBe(true); 41 + expect(j.beacon).toContain('github.com/solpbc/vit'); 42 + }); 43 + 44 + test('creates beacon and returns JSON', () => { 45 + const r = run('init --json --beacon https://github.com/solpbc/vit.git', tmpDir, agentEnv); 46 + const j = parseJson(r.stdout); 47 + expect(j.ok).toBe(true); 48 + expect(j.beacon).toContain('github.com/solpbc/vit'); 49 + }); 50 + 51 + test('rejects non-agent with JSON error', () => { 52 + const r = run('init --json --beacon https://github.com/solpbc/vit.git', tmpDir, { CLAUDECODE: '', GEMINI_CLI: '', CODEX_CI: '' }); 53 + expect(r.exitCode).toBe(1); 54 + const j = parseJson(r.stdout); 55 + expect(j.ok).toBe(false); 56 + expect(j.error).toContain('agent required'); 57 + }); 58 + }); 59 + 60 + describe('doctor --json', () => { 61 + test('returns health report as JSON', () => { 62 + const r = run('doctor --json'); 63 + const j = parseJson(r.stdout); 64 + expect(j.ok).toBe(true); 65 + expect(j).toHaveProperty('setup'); 66 + expect(j).toHaveProperty('beacon'); 67 + expect(j).toHaveProperty('bluesky'); 68 + }); 69 + 70 + test('status --json also works', () => { 71 + const r = run('status --json'); 72 + const j = parseJson(r.stdout); 73 + expect(j.ok).toBe(true); 74 + }); 75 + }); 76 + 77 + describe('following --json', () => { 78 + test('returns empty list as JSON', () => { 79 + const r = run('following --json', tmpDir); 80 + const j = parseJson(r.stdout); 81 + expect(j.ok).toBe(true); 82 + expect(j.following).toEqual([]); 83 + }); 84 + 85 + test('returns list as JSON', () => { 86 + const list = [ 87 + { handle: 'alice.bsky.social', did: 'did:plc:alice', followedAt: '2026-01-01T00:00:00Z' }, 88 + ]; 89 + writeFileSync(join(tmpDir, '.vit', 'following.json'), JSON.stringify(list)); 90 + const r = run('following --json', tmpDir); 91 + const j = parseJson(r.stdout); 92 + expect(j.ok).toBe(true); 93 + expect(j.following).toHaveLength(1); 94 + expect(j.following[0].handle).toBe('alice.bsky.social'); 95 + }); 96 + }); 97 + 98 + describe('unfollow --json', () => { 99 + test('returns error when not following', () => { 100 + writeFileSync(join(tmpDir, '.vit', 'following.json'), '[]'); 101 + const r = run('unfollow nobody.bsky.social --json', tmpDir); 102 + expect(r.exitCode).toBe(1); 103 + const j = parseJson(r.stdout); 104 + expect(j.ok).toBe(false); 105 + expect(j.error).toContain('not following'); 106 + }); 107 + 108 + test('returns success JSON on unfollow', () => { 109 + const list = [{ handle: 'alice.bsky.social', did: 'did:plc:alice', followedAt: '2026-01-01T00:00:00Z' }]; 110 + writeFileSync(join(tmpDir, '.vit', 'following.json'), JSON.stringify(list)); 111 + const r = run('unfollow alice.bsky.social --json', tmpDir); 112 + expect(r.exitCode).toBe(0); 113 + const j = parseJson(r.stdout); 114 + expect(j.ok).toBe(true); 115 + expect(j.handle).toBe('alice.bsky.social'); 116 + }); 117 + }); 118 + 119 + describe('follow --json', () => { 120 + test('returns error when no DID configured', () => { 121 + const configHome = join(tmpdir(), '.test-json-follow-' + Math.random().toString(36).slice(2)); 122 + mkdirSync(configHome, { recursive: true }); 123 + const r = run('follow someone.bsky.social --json', tmpDir, { 124 + CLAUDECODE: '', 125 + GEMINI_CLI: '', 126 + CODEX_CI: '', 127 + XDG_CONFIG_HOME: configHome, 128 + }); 129 + expect(r.exitCode).toBe(1); 130 + const j = parseJson(r.stdout); 131 + expect(j.ok).toBe(false); 132 + expect(j.error).toContain('no DID configured'); 133 + rmSync(configHome, { recursive: true, force: true }); 134 + }); 135 + }); 136 + 137 + describe('ship --json', () => { 138 + test('missing --title returns JSON error', () => { 139 + const r = run('ship --json --description "desc" --ref "one-two-three"'); 140 + const j = parseJson(r.stdout); 141 + expect(j.ok).toBe(false); 142 + expect(j.error).toContain('--title'); 143 + }); 144 + 145 + test('missing --description returns JSON error', () => { 146 + const r = run('ship --json --title "Hi" --ref "one-two-three"'); 147 + const j = parseJson(r.stdout); 148 + expect(j.ok).toBe(false); 149 + expect(j.error).toContain('--description'); 150 + }); 151 + 152 + test('missing --ref returns JSON error', () => { 153 + const r = run('ship --json --title "Hi" --description "desc"'); 154 + const j = parseJson(r.stdout); 155 + expect(j.ok).toBe(false); 156 + expect(j.error).toContain('--ref'); 157 + }); 158 + 159 + test('non-agent returns JSON error', () => { 160 + const r = run('ship --json --title "Hi" --description "desc" --ref "one-two-three"', '/tmp', { CLAUDECODE: '', GEMINI_CLI: '', CODEX_CI: '' }, 'body'); 161 + const j = parseJson(r.stdout); 162 + expect(j.ok).toBe(false); 163 + expect(j.error).toContain('agent required'); 164 + }); 165 + 166 + test('empty body returns JSON error', () => { 167 + const r = run('ship --json --title "Hi" --description "desc" --ref "one-two-three" --did "did:plc:abc"', undefined, agentEnv, ''); 168 + const j = parseJson(r.stdout); 169 + expect(j.ok).toBe(false); 170 + expect(j.error).toContain('body is required'); 171 + }); 172 + 173 + test('invalid ref returns JSON error', () => { 174 + const r = run('ship --json --title "Hi" --description "desc" --ref "Bad-Ref" --did "did:plc:abc"', undefined, agentEnv, 'body'); 175 + const j = parseJson(r.stdout); 176 + expect(j.ok).toBe(false); 177 + expect(j.error).toContain('three lowercase words'); 178 + }); 179 + 180 + test('invalid kind returns JSON error', () => { 181 + const r = run('ship --json --title "Hi" --description "desc" --ref "one-two-three" --kind "invalid" --did "did:plc:abc"', undefined, agentEnv, 'body'); 182 + const j = parseJson(r.stdout); 183 + expect(j.ok).toBe(false); 184 + expect(j.error).toContain('--kind'); 185 + }); 186 + }); 187 + 188 + describe('vet --json', () => { 189 + test('missing ref returns JSON error', () => { 190 + const r = run('vet --json', tmpDir); 191 + const j = parseJson(r.stdout); 192 + expect(j.ok).toBe(false); 193 + expect(j.error).toContain('ref argument is required'); 194 + }); 195 + 196 + test('invalid ref returns JSON error', () => { 197 + const r = run('vet BADREF --json', tmpDir); 198 + const j = parseJson(r.stdout); 199 + expect(j.ok).toBe(false); 200 + expect(j.error).toContain('invalid ref'); 201 + }); 202 + }); 203 + 204 + describe('vouch --json', () => { 205 + test('invalid ref returns JSON error', () => { 206 + const r = run('vouch BADREF --json', tmpDir); 207 + const j = parseJson(r.stdout); 208 + expect(j.ok).toBe(false); 209 + expect(j.error).toContain('invalid ref'); 210 + }); 211 + }); 212 + 213 + describe('remix --json', () => { 214 + test('invalid ref returns JSON error', () => { 215 + const r = run('remix BADREF --json', tmpDir, agentEnv); 216 + const j = parseJson(r.stdout); 217 + expect(j.ok).toBe(false); 218 + expect(j.error).toContain('invalid ref'); 219 + }); 220 + 221 + test('non-agent returns JSON error', () => { 222 + const r = run('remix one-two-three --json', tmpDir, { CLAUDECODE: '', GEMINI_CLI: '', CODEX_CI: '' }); 223 + const j = parseJson(r.stdout); 224 + expect(j.ok).toBe(false); 225 + expect(j.error).toContain('agent required'); 226 + }); 227 + }); 228 + 229 + describe('learn --json', () => { 230 + test('invalid ref returns JSON error', () => { 231 + const r = run('learn badref --json', tmpDir, agentEnv); 232 + const j = parseJson(r.stdout); 233 + expect(j.ok).toBe(false); 234 + expect(j.error).toContain('invalid skill ref'); 235 + }); 236 + 237 + test('non-agent returns JSON error', () => { 238 + const r = run('learn skill-test --json', tmpDir, { CLAUDECODE: '', GEMINI_CLI: '', CODEX_CI: '' }); 239 + const j = parseJson(r.stdout); 240 + expect(j.ok).toBe(false); 241 + expect(j.error).toContain('agent required'); 242 + }); 243 + }); 244 + 245 + describe('scan --json', () => { 246 + test('invalid --days returns JSON error', () => { 247 + const r = run('scan --json --days 0', tmpDir); 248 + const j = parseJson(r.stdout); 249 + expect(j.ok).toBe(false); 250 + expect(j.error).toContain('--days must be a positive integer'); 251 + }); 252 + }); 253 + });