open source is social v-it.org
at main 711 lines 23 kB view raw
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}