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