open source is social v-it.org
0

Configure Feed

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

vit / src / cmd / ship.js
19 kB 610 lines
1// SPDX-License-Identifier: MIT 2// Copyright (c) 2026 sol pbc 3 4import { TID } from '@atproto/common-web'; 5import { readFileSync, readdirSync, statSync } from 'node:fs'; 6import { join, relative } from 'node:path'; 7import { CAP_COLLECTION, SKILL_COLLECTION } from '../lib/constants.js'; 8import { requireAgent } from '../lib/agent.js'; 9import { requireDid, loadConfig } from '../lib/config.js'; 10import { restoreAgent } from '../lib/oauth.js'; 11import { appendLog, readProjectConfig, readLog, readFollowing } from '../lib/vit-dir.js'; 12import { REF_PATTERN, resolveRef } from '../lib/cap-ref.js'; 13import { isValidSkillName, skillRefFromName } from '../lib/skill-ref.js'; 14import { name } from '../lib/brand.js'; 15import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 16import { jsonOk, jsonError } from '../lib/json-output.js'; 17 18function parseFrontmatter(text) { 19 const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); 20 if (!match) return { frontmatter: {}, body: text }; 21 const raw = match[1]; 22 const frontmatter = {}; 23 let currentKey = null; 24 let currentValue = ''; 25 let isMultiline = false; 26 27 for (const line of raw.split('\n')) { 28 if (isMultiline) { 29 if (line.match(/^\S/) && line.includes(':')) { 30 // New key — save accumulated value 31 frontmatter[currentKey] = currentValue.trim(); 32 isMultiline = false; 33 } else { 34 currentValue += ' ' + line.trim(); 35 continue; 36 } 37 } 38 39 const kvMatch = line.match(/^(\w[\w-]*):\s*(>-?|[|][-+]?)?(.*)$/); 40 if (kvMatch) { 41 currentKey = kvMatch[1]; 42 const indicator = kvMatch[2]; 43 const rest = kvMatch[3].trim(); 44 if (indicator && (indicator.startsWith('>') || indicator.startsWith('|'))) { 45 // Multiline YAML 46 currentValue = rest; 47 isMultiline = true; 48 } else { 49 frontmatter[currentKey] = rest; 50 } 51 } 52 } 53 if (isMultiline && currentKey) { 54 frontmatter[currentKey] = currentValue.trim(); 55 } 56 57 return { frontmatter, body: text.slice(match[0].length) }; 58} 59 60function gatherFiles(dir, base) { 61 const results = []; 62 const entries = readdirSync(dir, { withFileTypes: true }); 63 for (const entry of entries) { 64 const fullPath = join(dir, entry.name); 65 if (entry.isDirectory()) { 66 results.push(...gatherFiles(fullPath, base)); 67 } else if (entry.name !== 'SKILL.md') { 68 const relPath = relative(base, fullPath); 69 results.push({ path: relPath, fullPath }); 70 } 71 } 72 return results; 73} 74 75function guessMimeType(filename) { 76 const ext = filename.split('.').pop()?.toLowerCase(); 77 const map = { 78 md: 'text/markdown', 79 txt: 'text/plain', 80 json: 'application/json', 81 yaml: 'application/yaml', 82 yml: 'application/yaml', 83 js: 'text/javascript', 84 ts: 'text/typescript', 85 py: 'text/x-python', 86 sh: 'application/x-shellscript', 87 bash: 'application/x-shellscript', 88 html: 'text/html', 89 css: 'text/css', 90 xml: 'application/xml', 91 png: 'image/png', 92 jpg: 'image/jpeg', 93 jpeg: 'image/jpeg', 94 gif: 'image/gif', 95 svg: 'image/svg+xml', 96 pdf: 'application/pdf', 97 }; 98 return map[ext] || 'application/octet-stream'; 99} 100 101async function shipSkill(opts) { 102 const gate = requireAgent(); 103 if (!gate.ok) { 104 if (opts.json) { 105 jsonError('agent required', 'run vit ship --skill from a coding agent'); 106 return; 107 } 108 console.error(`${name} ship --skill should be run by a coding agent (e.g. claude code, gemini cli).`); 109 console.error(`open your agent and ask it to run '${name} ship --skill' for you.`); 110 process.exitCode = 1; 111 return; 112 } 113 114 const { verbose } = opts; 115 const vlog = opts.json ? (...a) => console.error(...a) : console.log; 116 const skillDir = opts.skill; 117 118 // Validate skill directory 119 let skillMdPath; 120 try { 121 skillMdPath = join(skillDir, 'SKILL.md'); 122 statSync(skillMdPath); 123 } catch { 124 if (opts.json) { 125 jsonError(`no SKILL.md found in ${skillDir}`); 126 return; 127 } 128 console.error(`error: no SKILL.md found in ${skillDir}`); 129 process.exitCode = 1; 130 return; 131 } 132 133 // Read SKILL.md verbatim 134 const skillMdText = readFileSync(skillMdPath, 'utf-8'); 135 if (!skillMdText.trim()) { 136 if (opts.json) { 137 jsonError('SKILL.md is empty'); 138 return; 139 } 140 console.error('error: SKILL.md is empty'); 141 process.exitCode = 1; 142 return; 143 } 144 145 // Parse frontmatter to extract fields 146 const { frontmatter } = parseFrontmatter(skillMdText); 147 148 const skillName = frontmatter.name; 149 if (!skillName) { 150 if (opts.json) { 151 jsonError("SKILL.md frontmatter must include a 'name' field"); 152 return; 153 } 154 console.error('error: SKILL.md frontmatter must include a "name" field'); 155 process.exitCode = 1; 156 return; 157 } 158 159 if (!isValidSkillName(skillName)) { 160 if (opts.json) { 161 jsonError('invalid skill name', 'lowercase letters, numbers, hyphens only'); 162 return; 163 } 164 console.error('error: skill name must be lowercase letters, numbers, hyphens only.'); 165 console.error(' no leading hyphen, no consecutive hyphens, max 64 chars.'); 166 console.error(` got: "${skillName}"`); 167 process.exitCode = 1; 168 return; 169 } 170 171 const skillDescription = frontmatter.description; 172 if (!skillDescription) { 173 if (opts.json) { 174 jsonError("SKILL.md frontmatter must include a 'description' field"); 175 return; 176 } 177 console.error('error: SKILL.md frontmatter must include a "description" field'); 178 process.exitCode = 1; 179 return; 180 } 181 182 if (verbose) vlog(`[verbose] skill name: ${skillName}`); 183 if (verbose) vlog(`[verbose] skill description: ${skillDescription.slice(0, 80)}...`); 184 185 // DID 186 if (opts.json && !(opts.did || loadConfig().did)) { 187 jsonError('no DID configured', "run 'vit login <handle>' first"); 188 return; 189 } 190 const did = requireDid(opts); 191 if (!did) return; 192 if (verbose) vlog(`[verbose] DID: ${did}`); 193 194 // Session 195 let agent, session; 196 try { 197 ({ agent, session } = await restoreAgent(did)); 198 } catch { 199 if (opts.json) { 200 jsonError('session expired or invalid', "run 'vit login <handle>'"); 201 return; 202 } 203 console.error(`session expired or invalid. tell your user to run '${name} login <handle>'.`); 204 process.exitCode = 1; 205 return; 206 } 207 if (verbose) vlog(`[verbose] Session restored, PDS: ${session.serverMetadata?.issuer}`); 208 209 // Gather and upload resource files as blobs 210 const resourceFiles = gatherFiles(skillDir, skillDir); 211 const resources = []; 212 for (const rf of resourceFiles) { 213 if (verbose) vlog(`[verbose] uploading resource: ${rf.path}`); 214 const data = readFileSync(rf.fullPath); 215 const mimeType = guessMimeType(rf.path); 216 try { 217 const uploadRes = await agent.com.atproto.repo.uploadBlob(data, { encoding: mimeType }); 218 resources.push({ 219 path: rf.path, 220 blob: uploadRes.data.blob, 221 mimeType, 222 }); 223 } catch (err) { 224 if (opts.json) { 225 jsonError(`failed to upload resource ${rf.path}: ${err.message}`); 226 return; 227 } 228 console.error(`error: failed to upload resource ${rf.path}: ${err.message}`); 229 process.exitCode = 1; 230 return; 231 } 232 } 233 234 // Build record 235 const now = new Date().toISOString(); 236 const ref = skillRefFromName(skillName); 237 const record = { 238 $type: SKILL_COLLECTION, 239 name: skillName, 240 description: skillDescription, 241 text: skillMdText, 242 createdAt: now, 243 }; 244 245 // Optional fields from frontmatter or CLI flags 246 const version = opts.version || frontmatter.version; 247 if (version) record.version = version; 248 249 const license = opts.license || frontmatter.license; 250 if (license) record.license = license; 251 252 if (frontmatter.compatibility) record.compatibility = frontmatter.compatibility; 253 254 if (resources.length > 0) record.resources = resources; 255 256 if (opts.tags) { 257 record.tags = opts.tags.split(',').map(t => t.trim()).filter(Boolean); 258 } 259 260 const rkey = TID.nextStr(); 261 if (verbose) vlog(`[verbose] Record built, ref: ${ref}, rkey: ${rkey}`); 262 263 const putArgs = { 264 repo: did, 265 collection: SKILL_COLLECTION, 266 rkey, 267 record, 268 validate: true, 269 }; 270 271 if (verbose) vlog(`[verbose] putRecord ${putArgs.collection} rkey=${rkey}`); 272 const putRes = await agent.com.atproto.repo.putRecord(putArgs); 273 274 try { 275 appendLog('skills.jsonl', { 276 ts: now, 277 did, 278 rkey, 279 ref, 280 name: skillName, 281 collection: SKILL_COLLECTION, 282 pds: session.serverMetadata?.issuer, 283 uri: putRes.data.uri, 284 cid: putRes.data.cid, 285 }); 286 } catch (logErr) { 287 console.error('warning: failed to write skills.jsonl:', logErr.message); 288 } 289 if (verbose) vlog(`[verbose] Log written to skills.jsonl`); 290 291 if (opts.json) { 292 jsonOk({ ref, uri: putRes.data.uri }); 293 return; 294 } 295 console.log(`shipped: ${ref}`); 296 console.log(`uri: ${putRes.data.uri}`); 297 if (verbose) { 298 vlog( 299 JSON.stringify({ 300 ts: now, 301 pds: session.serverMetadata?.issuer, 302 xrpc: 'com.atproto.repo.putRecord', 303 request: putArgs, 304 response: putRes.data, 305 }), 306 ); 307 } 308} 309 310async function shipCap(opts) { 311 const gate = requireAgent(); 312 if (!gate.ok) { 313 if (opts.json) { 314 jsonError('agent required', 'run vit ship from a coding agent'); 315 return; 316 } 317 console.error(`${name} ship should be run by a coding agent (e.g. claude code, gemini cli).`); 318 console.error(`open your agent and ask it to run '${name} ship' for you.`); 319 console.error(`refer to the using-vit skill (skills/vit/SKILL.md) for a shipping guide.`); 320 process.exitCode = 1; 321 return; 322 } 323 324 const { verbose } = opts; 325 const vlog = opts.json ? (...a) => console.error(...a) : console.log; 326 327 // preflight: DID 328 if (opts.json && !(opts.did || loadConfig().did)) { 329 jsonError('no DID configured', "run 'vit login <handle>' first"); 330 return; 331 } 332 const did = requireDid(opts); 333 if (!did) return; 334 if (verbose) vlog(`[verbose] DID: ${did}`); 335 336 // preflight: beacon 337 const projectConfig = readProjectConfig(); 338 if (!projectConfig.beacon) { 339 if (opts.json) { 340 jsonError('no beacon set', "run 'vit init' first"); 341 return; 342 } 343 console.error(`no beacon set. run '${name} init' in a project directory first.`); 344 process.exitCode = 1; 345 return; 346 } 347 if (verbose) vlog(`[verbose] beacon: ${projectConfig.beacon}`); 348 349 let text; 350 try { 351 text = readFileSync('/dev/stdin', 'utf-8').trim(); 352 } catch { 353 text = ''; 354 } 355 if (!text) { 356 if (opts.json) { 357 jsonError('cap body is required via stdin'); 358 return; 359 } 360 console.error('error: cap body is required via stdin (pipe or heredoc)'); 361 process.exitCode = 1; 362 return; 363 } 364 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 } 370 console.error('error: --ref must be exactly three lowercase words separated by dashes (e.g. fast-cache-invalidation)'); 371 process.exitCode = 1; 372 return; 373 } 374 375 let recapUri = null; 376 if (opts.recap) { 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 } 382 console.error('error: --recap must be exactly three lowercase words separated by dashes (e.g. fast-cache-invalidation)'); 383 process.exitCode = 1; 384 return; 385 } 386 387 const caps = readLog('caps.jsonl'); 388 const localMatch = caps.find(e => e.ref === opts.recap); 389 if (localMatch) { 390 recapUri = localMatch.uri; 391 if (verbose) vlog(`[verbose] recap resolved locally: ${recapUri}`); 392 } 393 } 394 395 if (opts.kind) { 396 const validKinds = ['feat', 'fix', 'test', 'docs', 'refactor', 'chore', 'perf', 'style']; 397 if (!validKinds.includes(opts.kind)) { 398 if (opts.json) { 399 jsonError(`--kind must be one of: ${validKinds.join(', ')}`); 400 return; 401 } 402 console.error(`error: --kind must be one of: ${validKinds.join(', ')}`); 403 process.exitCode = 1; 404 return; 405 } 406 } 407 408 const now = new Date().toISOString(); 409 410 // preflight: session 411 let agent, session; 412 try { 413 ({ agent, session } = await restoreAgent(did)); 414 } catch { 415 if (opts.json) { 416 jsonError('session expired or invalid', "run 'vit login <handle>'"); 417 return; 418 } 419 console.error(`session expired or invalid. tell your user to run '${name} login <handle>'.`); 420 process.exitCode = 1; 421 return; 422 } 423 if (verbose) vlog(`[verbose] Session restored, PDS: ${session.serverMetadata?.issuer}`); 424 425 if (opts.recap && !recapUri) { 426 const following = readFollowing(); 427 const dids = following.map(e => e.did); 428 dids.push(did); 429 430 const allRecords = await batchQuery(dids, async (repoDid) => { 431 const pds = await resolvePds(repoDid); 432 if (verbose) vlog(`[verbose] ${repoDid}: resolved PDS ${pds}`); 433 return (await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50)).records; 434 }, { verbose }); 435 436 let match = null; 437 for (const records of allRecords) { 438 for (const rec of records) { 439 const recRef = resolveRef(rec.value, rec.cid); 440 if (recRef === opts.recap) { 441 if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { 442 match = rec; 443 } 444 } 445 } 446 } 447 448 if (match) { 449 recapUri = match.uri; 450 if (verbose) vlog(`[verbose] recap resolved remotely: ${recapUri}`); 451 } else { 452 if (opts.json) { 453 jsonError(`could not find cap with ref '${opts.recap}' to recap`); 454 return; 455 } 456 console.error(`error: could not find cap with ref '${opts.recap}' to recap`); 457 process.exitCode = 1; 458 return; 459 } 460 } 461 462 const record = { 463 $type: CAP_COLLECTION, 464 text, 465 title: opts.title, 466 description: opts.description, 467 ref: opts.ref, 468 createdAt: now, 469 }; 470 if (projectConfig.beacon) record.beacon = projectConfig.beacon; 471 if (opts.kind) record.kind = opts.kind; 472 if (opts.recap) record.recap = { uri: recapUri, ref: opts.recap }; 473 const rkey = TID.nextStr(); 474 if (verbose) vlog(`[verbose] Record built, rkey: ${rkey}`); 475 const putArgs = { 476 repo: did, 477 collection: CAP_COLLECTION, 478 rkey, 479 record, 480 validate: true, 481 }; 482 if (verbose) vlog(`[verbose] putRecord ${putArgs.collection} rkey=${rkey}`); 483 const putRes = await agent.com.atproto.repo.putRecord(putArgs); 484 try { 485 appendLog('caps.jsonl', { 486 ts: now, 487 did, 488 rkey, 489 ref: opts.ref, 490 collection: CAP_COLLECTION, 491 pds: session.serverMetadata?.issuer, 492 uri: putRes.data.uri, 493 cid: putRes.data.cid, 494 }); 495 } catch (logErr) { 496 console.error('warning: failed to write caps.jsonl:', logErr.message); 497 } 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 } 503 console.log(`shipped: ${opts.ref}`); 504 console.log(`uri: ${putRes.data.uri}`); 505 if (verbose) { 506 vlog( 507 JSON.stringify({ 508 ts: now, 509 pds: session.serverMetadata?.issuer, 510 xrpc: 'com.atproto.repo.putRecord', 511 request: putArgs, 512 response: putRes.data, 513 }), 514 ); 515 } 516} 517 518export default function register(program) { 519 program 520 .command('ship') 521 .description('Publish a cap or skill to your feed') 522 .option('-v, --verbose', 'Show step-by-step details') 523 .option('--json', 'Output as JSON') 524 .option('--did <did>', 'DID to use (reads saved DID from config if not provided)') 525 .option('--title <title>', 'Short title for the cap') 526 .option('--description <description>', 'Description of the cap') 527 .option('--ref <ref>', 'Three lowercase words with dashes (e.g. fast-cache-invalidation)') 528 .option('--recap <ref>', 'Ref of the cap this derives from (quote-post semantics)') 529 .option('--kind <kind>', 'Category: feat, fix, test, docs, refactor, chore, perf, style') 530 .option('--skill <path>', 'Publish a skill directory (reads SKILL.md + resources)') 531 .option('--tags <tags>', 'Comma-separated discovery tags (for skills)') 532 .option('--version <version>', 'Version string (for skills, overrides frontmatter)') 533 .option('--license <license>', 'SPDX license identifier (for skills, overrides frontmatter)') 534 .action(async (opts) => { 535 try { 536 if (opts.skill) { 537 await shipSkill(opts); 538 } else { 539 // Validate required cap fields 540 if (!opts.title) { 541 if (opts.json) { 542 jsonError("required option '--title <title>' not specified"); 543 return; 544 } 545 console.error("error: required option '--title <title>' not specified"); 546 process.exitCode = 1; 547 return; 548 } 549 if (!opts.description) { 550 if (opts.json) { 551 jsonError("required option '--description <description>' not specified"); 552 return; 553 } 554 console.error("error: required option '--description <description>' not specified"); 555 process.exitCode = 1; 556 return; 557 } 558 if (!opts.ref) { 559 if (opts.json) { 560 jsonError("required option '--ref <ref>' not specified"); 561 return; 562 } 563 console.error("error: required option '--ref <ref>' not specified"); 564 process.exitCode = 1; 565 return; 566 } 567 await shipCap(opts); 568 } 569 } catch (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); 576 process.exitCode = 1; 577 } 578 }) 579 .addHelpText('after', ` 580Authoring guidance (for coding agents): 581 582 Refer to the using-vit skill (skills/vit/SKILL.md) for a complete shipping guide. 583 584 Cap fields: 585 --title Short name for the cap (2-5 words) 586 --description One sentence explaining what this cap does 587 --ref Three lowercase words with dashes (your-ref-name) 588 --recap <ref> Optional. Ref of the cap this derives from (links back to original) 589 --kind <kind> Category: feat, fix, test, docs, refactor, chore, perf, style 590 body (stdin) Full cap content, piped or via heredoc 591 592 Skill fields: 593 --skill <path> Path to skill directory containing SKILL.md 594 --tags <tags> Comma-separated discovery tags 595 --version <ver> Version override (defaults to SKILL.md frontmatter) 596 --license <id> License override (defaults to SKILL.md frontmatter) 597 598 Examples: 599 # Ship a cap 600 vit ship --title "Fast LRU Cache" \\ 601 --description "Thread-safe LRU cache with O(1) eviction" \\ 602 --ref "fast-lru-cache" \\ 603 <<'EOF' 604 ... full cap body text ... 605 EOF 606 607 # Ship a skill 608 vit ship --skill ./skills/agent-test-patterns/ \\ 609 --tags "testing,agents,claude"`); 610}