This repository has no description
0

Configure Feed

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

vit / src / cmd / ship.js
23 kB 706 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'; 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}