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';
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}