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