@kairyou/agent-tools 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,11 +5,19 @@
5
5
  // Capabilities (all global for now — they target the user-level config):
6
6
  // statusline Claude Code statusLine script (claude only).
7
7
  // usage Active API provider quota/balance (codex + opencode).
8
+ // vision inspect_image MCP server + at-vision skill (all agents).
9
+ //
10
+ // Standalone commands (dispatched before capability parsing):
11
+ // inspect-image <path|url> --question "..." Human diagnostic for vision.
12
+ // mcp-vision Run the vision MCP stdio server.
8
13
  //
9
14
  // Targets:
10
15
  // claude -> ~/.claude/settings.json (statusLine key)
16
+ // ~/.claude.json (vision MCP) + ~/.claude/skills (at-vision)
11
17
  // codex -> ~/.codex/hooks.json (standalone hooks file)
12
- // opencode -> ~/.config/opencode/ (server + TUI plugins)
18
+ // ~/.codex/config.toml (vision MCP) + ~/.agents/skills (at-vision)
19
+ // opencode -> ~/.config/opencode/ (server + TUI plugins,
20
+ // vision MCP in opencode.json + skills/at-vision)
13
21
  // Runtime scripts are copied into ~/.agent-tools so this installer can be
14
22
  // run via npx from GitHub without requiring a persistent local clone.
15
23
  //
@@ -25,10 +33,15 @@
25
33
  // --settings <path> Override the Claude settings.json (for testing).
26
34
  // --codex-hooks <path> Override the Codex hooks.json (for testing).
27
35
  // --opencode-config-dir <p> Override the opencode config dir (for testing).
36
+ // --claude-json <path> Override ~/.claude.json for vision MCP (for testing).
37
+ // --claude-skills-dir <p> Override ~/.claude/skills (for testing).
38
+ // --codex-config <path> Override ~/.codex/config.toml (for testing).
39
+ // --codex-skills-dir <p> Override ~/.agents/skills (for testing).
28
40
  // --uninstall Remove what this installer added, restoring backups.
29
41
  // --dry-run Print planned changes without writing anything.
30
42
  // -h, --help Show this help.
31
43
 
44
+ import { spawnSync } from "node:child_process";
32
45
  import fs from "node:fs";
33
46
  import os from "node:os";
34
47
  import path from "node:path";
@@ -56,13 +69,21 @@ const RUNTIME = {
56
69
  opencodeUsagePlugin: path.join(INSTALL_ROOT, "plugins", "opencode", "usage-plugin.mjs"),
57
70
  opencodeUsageTui: path.join(INSTALL_ROOT, "plugins", "opencode", "usage-tui.mjs"),
58
71
  };
59
- const ALL_CAPS = ["statusline", "usage"];
72
+ const ALL_CAPS = ["statusline", "usage", "vision"];
60
73
  const ALL_AGENTS = ["claude", "codex", "opencode"];
61
74
  const AGENT_CAPS = {
62
- claude: ["statusline"],
63
- codex: ["usage"],
64
- opencode: ["usage"],
75
+ claude: ["statusline", "vision"],
76
+ codex: ["usage", "vision"],
77
+ opencode: ["usage", "vision"],
65
78
  };
79
+ const VISION_MCP_NAME = "agent-tools-vision";
80
+ const VISION_SKILL_NAME = "at-vision";
81
+ // The at-vision skill ships inside the vision feature dir (plugins/vision),
82
+ // not skills/: it is unusable without the MCP server, so it must not surface
83
+ // as an independently installable skill.
84
+ const VISION_SKILL_SRC = path.join(REPO_ROOT, "plugins", "vision", "skills", VISION_SKILL_NAME);
85
+ const VISION_BUNDLED_MCP_SERVER = path.join(REPO_ROOT, "dist", "vision", "mcp-server.mjs");
86
+ const VISION_BUNDLED_CLI = path.join(REPO_ROOT, "dist", "vision", "cli.mjs");
66
87
 
67
88
  function fwd(p) {
68
89
  return p.replace(/\\/g, "/");
@@ -247,6 +268,10 @@ function parseArgs(argv) {
247
268
  settings: null,
248
269
  codexHooks: null,
249
270
  opencodeConfigDir: null,
271
+ claudeJson: null,
272
+ claudeSkillsDir: null,
273
+ codexConfig: null,
274
+ codexSkillsDir: null,
250
275
  uninstall: false,
251
276
  dryRun: false,
252
277
  help: false,
@@ -277,6 +302,10 @@ function parseArgs(argv) {
277
302
  case "--settings": opts.settings = argv[++i]; break;
278
303
  case "--codex-hooks": opts.codexHooks = argv[++i]; break;
279
304
  case "--opencode-config-dir": opts.opencodeConfigDir = argv[++i]; break;
305
+ case "--claude-json": opts.claudeJson = argv[++i]; break;
306
+ case "--claude-skills-dir": opts.claudeSkillsDir = argv[++i]; break;
307
+ case "--codex-config": opts.codexConfig = argv[++i]; break;
308
+ case "--codex-skills-dir": opts.codexSkillsDir = argv[++i]; break;
280
309
  case "--uninstall": opts.uninstall = true; break;
281
310
  case "--dry-run": opts.dryRun = true; break;
282
311
  case "-h":
@@ -403,9 +432,285 @@ function applyProviderUsage(cfg, { remove }) {
403
432
  if (Object.keys(cfg.hooks).length === 0) delete cfg.hooks;
404
433
  }
405
434
 
435
+ // ---- Vision capability helpers. ----
436
+
437
+ const VISION_RUNTIME_DIR = path.join(INSTALL_ROOT, "vision-runtime");
438
+ const VISION_RATE_LIMIT_STATE = path.join(INSTALL_ROOT, "cache", "vision-rate-limit.json");
439
+ const VISION_DIST_DIR = path.join(REPO_ROOT, "dist", "vision");
440
+ const VISION_RUNTIME_SERVER = path.join(VISION_RUNTIME_DIR, "mcp-server.mjs");
441
+ const VISION_RUNTIME_CLI = path.join(VISION_RUNTIME_DIR, "cli.mjs");
442
+ const VISION_LEGACY_RUNTIME_SERVER = path.join(
443
+ VISION_RUNTIME_DIR,
444
+ "plugins",
445
+ "vision",
446
+ "mcp-server.mjs"
447
+ );
448
+ const VISION_SKILL_MARKER = ".agent-tools-managed.json";
449
+ const VISION_SKILL_MARKER_DATA = Object.freeze({
450
+ owner: "@kairyou/agent-tools",
451
+ capability: "vision",
452
+ artifact: "skill",
453
+ });
454
+ // Vision is bundled at release time. Install atomically swaps two self-contained
455
+ // entry files into ~/.agent-tools/vision-runtime, so hosts never run npm and a
456
+ // failed update cannot destroy the previously working runtime.
457
+ function visionMcpCommand() {
458
+ return { command: "node", args: [fwd(VISION_RUNTIME_SERVER)] };
459
+ }
460
+
461
+ function sameFilePath(left, right) {
462
+ function canonical(value) {
463
+ const resolved = path.resolve(String(value));
464
+ try {
465
+ return fs.realpathSync.native(resolved);
466
+ } catch {
467
+ return resolved;
468
+ }
469
+ }
470
+ const a = canonical(left);
471
+ const b = canonical(right);
472
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
473
+ }
474
+
475
+ function isClaudeVisionMcp(value, mcp = visionMcpCommand()) {
476
+ return Boolean(
477
+ value &&
478
+ value.command === mcp.command &&
479
+ Array.isArray(value.args) &&
480
+ value.args.length === 1 &&
481
+ (sameFilePath(value.args[0], mcp.args[0]) ||
482
+ sameFilePath(value.args[0], VISION_LEGACY_RUNTIME_SERVER))
483
+ );
484
+ }
485
+
486
+ function isOpenCodeVisionMcp(value, mcp = visionMcpCommand()) {
487
+ return Boolean(
488
+ value &&
489
+ value.type === "local" &&
490
+ Array.isArray(value.command) &&
491
+ value.command.length === 2 &&
492
+ value.command[0] === mcp.command &&
493
+ (sameFilePath(value.command[1], mcp.args[0]) ||
494
+ sameFilePath(value.command[1], VISION_LEGACY_RUNTIME_SERVER))
495
+ );
496
+ }
497
+
498
+ function installVisionRuntime(opts) {
499
+ if (opts.uninstall) return;
500
+ console.log(`vision runtime: ${VISION_RUNTIME_DIR}`);
501
+ if (opts.dryRun) {
502
+ console.log(` [dry-run] would atomically install ${VISION_DIST_DIR} -> ${VISION_RUNTIME_DIR}`);
503
+ return;
504
+ }
505
+ for (const name of ["mcp-server.mjs", "cli.mjs"]) {
506
+ if (!fs.existsSync(path.join(VISION_DIST_DIR, name))) {
507
+ throw new Error(`Missing bundled vision runtime ${path.join(VISION_DIST_DIR, name)}. Run npm run build:vision.`);
508
+ }
509
+ }
510
+
511
+ fs.mkdirSync(path.dirname(VISION_RUNTIME_DIR), { recursive: true });
512
+ const suffix = `${process.pid}-${Date.now()}`;
513
+ const stage = `${VISION_RUNTIME_DIR}.stage-${suffix}`;
514
+ const backup = `${VISION_RUNTIME_DIR}.backup-${suffix}`;
515
+ let movedCurrent = false;
516
+ try {
517
+ fs.cpSync(VISION_DIST_DIR, stage, { recursive: true });
518
+ writeText(
519
+ path.join(stage, "runtime.json"),
520
+ JSON.stringify({ owner: "@kairyou/agent-tools", capability: "vision", version: 1 }, null, 2) + "\n",
521
+ false
522
+ );
523
+ for (const name of ["mcp-server.mjs", "cli.mjs"]) {
524
+ const check = spawnSync(process.execPath, ["--check", path.join(stage, name)], {
525
+ encoding: "utf8",
526
+ });
527
+ if (check.status !== 0) {
528
+ throw new Error(`Bundled vision runtime failed syntax validation (${name}): ${check.stderr || check.stdout}`);
529
+ }
530
+ }
531
+
532
+ if (fs.existsSync(VISION_RUNTIME_DIR)) {
533
+ fs.renameSync(VISION_RUNTIME_DIR, backup);
534
+ movedCurrent = true;
535
+ }
536
+ if (process.env.AGENT_TOOLS_VISION_TEST_FAIL_SWAP === "1") {
537
+ throw new Error("Simulated vision runtime swap failure");
538
+ }
539
+ fs.renameSync(stage, VISION_RUNTIME_DIR);
540
+ fs.rmSync(backup, { recursive: true, force: true });
541
+ console.log(" installed bundled vision runtime");
542
+ } catch (error) {
543
+ fs.rmSync(stage, { recursive: true, force: true });
544
+ if (movedCurrent && !fs.existsSync(VISION_RUNTIME_DIR) && fs.existsSync(backup)) {
545
+ fs.renameSync(backup, VISION_RUNTIME_DIR);
546
+ }
547
+ throw error;
548
+ } finally {
549
+ fs.rmSync(stage, { recursive: true, force: true });
550
+ if (fs.existsSync(VISION_RUNTIME_DIR)) fs.rmSync(backup, { recursive: true, force: true });
551
+ }
552
+ }
553
+
554
+ function isManagedVisionSkillDir(dest) {
555
+ const marker = path.join(dest, VISION_SKILL_MARKER);
556
+ if (!fs.existsSync(marker)) return false;
557
+ try {
558
+ const value = JSON.parse(fs.readFileSync(marker, "utf8"));
559
+ return Object.entries(VISION_SKILL_MARKER_DATA).every(([key, expected]) => value?.[key] === expected);
560
+ } catch {
561
+ return false;
562
+ }
563
+ }
564
+
565
+ function isLegacyManagedVisionSkillDir(dest) {
566
+ try {
567
+ const entries = fs.readdirSync(dest);
568
+ if (entries.length !== 1 || entries[0] !== "SKILL.md") return false;
569
+ const skill = fs.readFileSync(path.join(dest, "SKILL.md"), "utf8");
570
+ return (
571
+ /^---\s*[\s\S]*?^name:\s*at-vision\s*$/m.test(skill) &&
572
+ skill.includes("# Visual Reasoning Policy") &&
573
+ skill.includes("`inspect_image` MCP tool (server `agent-tools-vision`)")
574
+ );
575
+ } catch {
576
+ return false;
577
+ }
578
+ }
579
+
580
+ function installSkillDir(skillsRoot, { remove, dryRun }) {
581
+ const dest = path.join(skillsRoot, VISION_SKILL_NAME);
582
+ if (remove) {
583
+ if (!fs.existsSync(dest)) return;
584
+ if (!isManagedVisionSkillDir(dest)) {
585
+ console.log(` kept unmanaged ${dest}`);
586
+ return;
587
+ }
588
+ if (dryRun) {
589
+ console.log(` [dry-run] would remove ${dest}`);
590
+ return;
591
+ }
592
+ fs.rmSync(dest, { recursive: true, force: true });
593
+ console.log(` removed ${dest}`);
594
+ return;
595
+ }
596
+ if (fs.existsSync(dest) && !isManagedVisionSkillDir(dest)) {
597
+ if (isLegacyManagedVisionSkillDir(dest)) {
598
+ console.log(` adopting legacy managed skill ${dest}`);
599
+ } else {
600
+ console.error(
601
+ ` Refusing to overwrite existing unowned skill directory ${dest}. ` +
602
+ `Move or remove it, then re-run the install.`
603
+ );
604
+ process.exit(1);
605
+ }
606
+ }
607
+ if (dryRun) {
608
+ console.log(` [dry-run] would copy ${VISION_SKILL_SRC} -> ${dest}`);
609
+ return;
610
+ }
611
+ fs.rmSync(dest, { recursive: true, force: true });
612
+ fs.cpSync(VISION_SKILL_SRC, dest, { recursive: true });
613
+ const skillFile = path.join(dest, "SKILL.md");
614
+ const skill = fs.readFileSync(skillFile, "utf8");
615
+ const token = "{{VISION_CLI_PATH}}";
616
+ if (!skill.includes(token)) {
617
+ fs.rmSync(dest, { recursive: true, force: true });
618
+ throw new Error(`Vision skill template is missing ${token}`);
619
+ }
620
+ fs.writeFileSync(skillFile, skill.replaceAll(token, fwd(VISION_RUNTIME_CLI)));
621
+ fs.writeFileSync(
622
+ path.join(dest, VISION_SKILL_MARKER),
623
+ JSON.stringify(VISION_SKILL_MARKER_DATA, null, 2) + "\n"
624
+ );
625
+ console.log(` wrote ${dest}`);
626
+ }
627
+
628
+ const CODEX_VISION_BEGIN = "# >>> agent-tools vision >>>";
629
+ const CODEX_VISION_END = "# <<< agent-tools vision <<<";
630
+
631
+ // Codex config.toml is user-owned free-form TOML; we manage exactly one
632
+ // marker-delimited block so install/update/uninstall never touch other keys.
633
+ function updateCodexVisionBlock(file, { remove, dryRun, mcp }) {
634
+ const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
635
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
636
+ const blockRe = new RegExp(
637
+ `\\r?\\n?${escape(CODEX_VISION_BEGIN)}[\\s\\S]*?${escape(CODEX_VISION_END)}\\r?\\n?`
638
+ );
639
+ let next = current.replace(blockRe, "\n").replace(/^\n+/, "");
640
+ if (!remove) {
641
+ // A same-named table outside our marker block would make the appended
642
+ // TOML a duplicate declaration and break the ENTIRE config.toml parse.
643
+ const manualRe = new RegExp(`^\\s*\\[mcp_servers\\.["']?${escape(VISION_MCP_NAME)}["']?\\]`, "m");
644
+ if (manualRe.test(next)) {
645
+ console.error(
646
+ ` Found an existing [mcp_servers.${VISION_MCP_NAME}] entry in ${file} that was not ` +
647
+ "written by this installer. Remove or rename that entry, then re-run the install."
648
+ );
649
+ process.exit(1);
650
+ }
651
+ const args = mcp.args.map((a) => JSON.stringify(a)).join(", ");
652
+ const block = [
653
+ CODEX_VISION_BEGIN,
654
+ `[mcp_servers.${VISION_MCP_NAME}]`,
655
+ `command = ${JSON.stringify(mcp.command)}`,
656
+ `args = [${args}]`,
657
+ CODEX_VISION_END,
658
+ "",
659
+ ].join("\n");
660
+ next = next.trim() === "" ? block : next.replace(/\s*$/, "\n\n") + block;
661
+ }
662
+ if (next === current) {
663
+ console.log(` kept existing ${file}`);
664
+ return;
665
+ }
666
+ if (remove && next.trim() === "" && fs.existsSync(file) && current.trim() !== "") {
667
+ // The file only ever contained our block; leave an empty file rather than
668
+ // deleting config.toml, which Codex may expect to exist.
669
+ writeText(file, "", dryRun);
670
+ return;
671
+ }
672
+ writeText(file, next, dryRun);
673
+ }
674
+
406
675
  // ---- Claude: statusLine backed up in _agentTools. ----
407
676
 
677
+ function runClaudeVision(opts) {
678
+ const claudeJson = opts.claudeJson || path.join(os.homedir(), ".claude.json");
679
+ const skillsDir = opts.claudeSkillsDir || path.join(os.homedir(), ".claude", "skills");
680
+ console.log(`claude vision: ${claudeJson}`);
681
+ const cfg = readJson(claudeJson);
682
+ const mcp = visionMcpCommand();
683
+ const existing = cfg.mcpServers?.[VISION_MCP_NAME];
684
+ if (opts.uninstall) {
685
+ if (isClaudeVisionMcp(existing, mcp)) {
686
+ delete cfg.mcpServers[VISION_MCP_NAME];
687
+ if (Object.keys(cfg.mcpServers).length === 0) delete cfg.mcpServers;
688
+ writeJson(claudeJson, cfg, opts.dryRun);
689
+ } else if (existing) {
690
+ console.log(` kept unmanaged ${VISION_MCP_NAME} entry in ${claudeJson}`);
691
+ } else {
692
+ console.log(` no ${VISION_MCP_NAME} MCP entry found; nothing to remove.`);
693
+ }
694
+ installSkillDir(skillsDir, { remove: true, dryRun: opts.dryRun });
695
+ console.log(" - vision");
696
+ return;
697
+ }
698
+ if (existing && !isClaudeVisionMcp(existing, mcp)) {
699
+ throw new Error(
700
+ `Found an existing unmanaged ${VISION_MCP_NAME} entry in ${claudeJson}. ` +
701
+ "Remove or rename it, then re-run the install."
702
+ );
703
+ }
704
+ cfg.mcpServers = cfg.mcpServers || {};
705
+ cfg.mcpServers[VISION_MCP_NAME] = { type: "stdio", command: mcp.command, args: mcp.args };
706
+ writeJson(claudeJson, cfg, opts.dryRun);
707
+ installSkillDir(skillsDir, { remove: false, dryRun: opts.dryRun });
708
+ console.log(" + vision (inspect_image MCP + at-vision skill)");
709
+ }
710
+
408
711
  function runClaude(opts) {
712
+ if (wants(opts, "vision")) runClaudeVision(opts);
713
+ if (!wants(opts, "statusline")) return;
409
714
  const settings = opts.settings || path.join(os.homedir(), ".claude", "settings.json");
410
715
  console.log(`claude (global): ${settings}`);
411
716
  const cfg = readJson(settings);
@@ -444,9 +749,22 @@ function runClaude(opts) {
444
749
  // written (Codex validates hooks.json against a schema); our hook is found by
445
750
  // command signature instead. ----
446
751
 
752
+ function runCodexVision(opts) {
753
+ const file = opts.codexConfig || path.join(os.homedir(), ".codex", "config.toml");
754
+ const skillsDir = opts.codexSkillsDir || path.join(os.homedir(), ".agents", "skills");
755
+ console.log(`codex vision: ${file}`);
756
+ updateCodexVisionBlock(file, {
757
+ remove: opts.uninstall,
758
+ dryRun: opts.dryRun,
759
+ mcp: visionMcpCommand(),
760
+ });
761
+ installSkillDir(skillsDir, { remove: opts.uninstall, dryRun: opts.dryRun });
762
+ console.log(opts.uninstall ? " - vision" : " + vision (inspect_image MCP + at-vision skill)");
763
+ }
764
+
447
765
  function runCodex(opts) {
766
+ if (wants(opts, "vision")) runCodexVision(opts);
448
767
  if (!wants(opts, "usage")) {
449
- console.log("codex: nothing to do (supports: usage).");
450
768
  return;
451
769
  }
452
770
  const file = opts.codexHooks || path.join(os.homedir(), ".codex", "hooks.json");
@@ -491,9 +809,58 @@ function opencodeConfigDir(opts) {
491
809
  );
492
810
  }
493
811
 
812
+ // opencode.json may be JSONC with user comments — edit only our key in place.
813
+ function updateOpencodeVisionMcp(file, { remove, dryRun, mcp }) {
814
+ const exists = fs.existsSync(file);
815
+ const currentText = exists ? fs.readFileSync(file, "utf8") : "{}\n";
816
+ const errors = [];
817
+ const current = parseJsonc(currentText, errors, { allowTrailingComma: true }) || {};
818
+ if (errors.length > 0 || typeof current !== "object" || Array.isArray(current)) {
819
+ throw new Error(`Cannot parse ${file} as JSONC`);
820
+ }
821
+ const existing = current.mcp && current.mcp[VISION_MCP_NAME];
822
+ if (existing && !isOpenCodeVisionMcp(existing, mcp)) {
823
+ if (remove) {
824
+ console.log(` kept unmanaged ${VISION_MCP_NAME} entry in ${file}`);
825
+ return;
826
+ }
827
+ throw new Error(
828
+ `Found an existing unmanaged ${VISION_MCP_NAME} entry in ${file}. ` +
829
+ "Remove or rename it, then re-run the install."
830
+ );
831
+ }
832
+ const desired = remove
833
+ ? undefined
834
+ : { type: "local", command: [mcp.command, ...mcp.args], enabled: true };
835
+ if (JSON.stringify(existing) === JSON.stringify(desired)) {
836
+ console.log(` kept existing ${file}`);
837
+ return;
838
+ }
839
+ const eol = currentText.includes("\r\n") ? "\r\n" : "\n";
840
+ const edits = modify(currentText, ["mcp", VISION_MCP_NAME], desired, {
841
+ formattingOptions: { insertSpaces: true, tabSize: 2, eol },
842
+ });
843
+ const updated = applyEdits(currentText, edits).replace(/\s*$/, "") + eol;
844
+ writeText(file, updated, dryRun);
845
+ }
846
+
847
+ function runOpencodeVision(opts) {
848
+ const configDir = opencodeConfigDir(opts);
849
+ const file = path.join(configDir, "opencode.json");
850
+ const skillsDir = path.join(configDir, "skills");
851
+ console.log(`opencode vision: ${file}`);
852
+ updateOpencodeVisionMcp(file, {
853
+ remove: opts.uninstall,
854
+ dryRun: opts.dryRun,
855
+ mcp: visionMcpCommand(),
856
+ });
857
+ installSkillDir(skillsDir, { remove: opts.uninstall, dryRun: opts.dryRun });
858
+ console.log(opts.uninstall ? " - vision" : " + vision (inspect_image MCP + at-vision skill)");
859
+ }
860
+
494
861
  function runOpencode(opts) {
862
+ if (wants(opts, "vision")) runOpencodeVision(opts);
495
863
  if (!wants(opts, "usage")) {
496
- console.log("opencode: nothing to do (supports: usage).");
497
864
  return;
498
865
  }
499
866
 
@@ -520,6 +887,73 @@ function runOpencode(opts) {
520
887
  if (!opts.dryRun) console.log(" NOTE: restart opencode to load the agent-tools usage plugin.");
521
888
  }
522
889
 
890
+ function visionRuntimeHasRemainingReference(opts) {
891
+ const removed = new Set(opts.agents);
892
+ if (!removed.has("claude")) {
893
+ const file = opts.claudeJson || path.join(os.homedir(), ".claude.json");
894
+ try {
895
+ if (isClaudeVisionMcp(readJson(file).mcpServers?.[VISION_MCP_NAME])) return true;
896
+ } catch {
897
+ return true;
898
+ }
899
+ }
900
+ if (!removed.has("codex")) {
901
+ const file = opts.codexConfig || path.join(os.homedir(), ".codex", "config.toml");
902
+ try {
903
+ const text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
904
+ const start = text.indexOf(CODEX_VISION_BEGIN);
905
+ const end = text.indexOf(CODEX_VISION_END, start + CODEX_VISION_BEGIN.length);
906
+ if (start !== -1 && end !== -1) {
907
+ const block = text.slice(start, end);
908
+ if (
909
+ block.includes(fwd(VISION_RUNTIME_SERVER)) ||
910
+ block.includes(fwd(VISION_LEGACY_RUNTIME_SERVER))
911
+ ) {
912
+ return true;
913
+ }
914
+ }
915
+ } catch {
916
+ return true;
917
+ }
918
+ }
919
+ if (!removed.has("opencode")) {
920
+ const file = path.join(opencodeConfigDir(opts), "opencode.json");
921
+ try {
922
+ if (fs.existsSync(file)) {
923
+ const errors = [];
924
+ const config = parseJsonc(fs.readFileSync(file, "utf8"), errors, { allowTrailingComma: true });
925
+ if (errors.length > 0) return true;
926
+ if (isOpenCodeVisionMcp(config?.mcp?.[VISION_MCP_NAME])) return true;
927
+ }
928
+ } catch {
929
+ return true;
930
+ }
931
+ }
932
+ return false;
933
+ }
934
+
935
+ function cleanupVisionRuntimeIfUnused(opts) {
936
+ if (!opts.uninstall || !wants(opts, "vision") || !fs.existsSync(VISION_RUNTIME_DIR)) return;
937
+ if (visionRuntimeHasRemainingReference(opts)) {
938
+ console.log(` kept shared vision runtime ${VISION_RUNTIME_DIR} (another agent still references it)`);
939
+ return;
940
+ }
941
+ if (opts.dryRun) {
942
+ console.log(` [dry-run] would remove unused vision runtime ${VISION_RUNTIME_DIR}`);
943
+ return;
944
+ }
945
+ fs.rmSync(VISION_RUNTIME_DIR, { recursive: true, force: true });
946
+ if (!fs.existsSync(`${VISION_RATE_LIMIT_STATE}.lock`)) {
947
+ fs.rmSync(VISION_RATE_LIMIT_STATE, { force: true });
948
+ try {
949
+ fs.rmdirSync(path.dirname(VISION_RATE_LIMIT_STATE));
950
+ } catch {
951
+ // The shared cache directory may contain state for other capabilities.
952
+ }
953
+ }
954
+ console.log(` removed unused vision runtime ${VISION_RUNTIME_DIR}`);
955
+ }
956
+
523
957
  const AGENTS = { claude: runClaude, codex: runCodex, opencode: runOpencode };
524
958
 
525
959
  function main() {
@@ -538,6 +972,7 @@ function main() {
538
972
  }
539
973
  validateAgentCapabilities(opts);
540
974
  installRuntimeAssets(opts);
975
+ if (wants(opts, "vision")) installVisionRuntime(opts);
541
976
  for (const name of opts.agents) {
542
977
  const run = AGENTS[name];
543
978
  if (!run) {
@@ -546,6 +981,19 @@ function main() {
546
981
  }
547
982
  run(opts);
548
983
  }
984
+ cleanupVisionRuntimeIfUnused(opts);
549
985
  }
550
986
 
551
- main();
987
+ // Standalone vision commands dispatch before capability parsing so image
988
+ // paths and questions are never mistaken for capabilities.
989
+ const subcommand = process.argv[2];
990
+ if (subcommand === "inspect-image") {
991
+ const { runInspectImageCli } = await import(
992
+ pathToFileURL(VISION_BUNDLED_CLI).href
993
+ );
994
+ process.exitCode = await runInspectImageCli(process.argv.slice(3));
995
+ } else if (subcommand === "mcp-vision") {
996
+ await import(pathToFileURL(VISION_BUNDLED_MCP_SERVER).href);
997
+ } else {
998
+ main();
999
+ }
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ // Release helper for @kairyou/agent-tools.
3
+ //
4
+ // Usage:
5
+ // npm run release [-- minor|major] # default: patch
6
+ // npm run release -- --dry-run # npm test + preview tarball, no bump/publish
7
+ //
8
+ // Steps: clean-tree check -> npm test -> npm version -> npm publish -> git push --follow-tags
9
+ //
10
+ // Release when scripts/ lib/ plugins/ hooks/ statusline/ config.default.jsonc or
11
+ // package.json deps change — npx users only get these from the published package.
12
+ // (skills/ ships from GitHub via `npx skills add`; no release needed.)
13
+
14
+ import { spawnSync } from "node:child_process";
15
+
16
+ const BUMPS = ["patch", "minor", "major"];
17
+ let bump = "patch";
18
+ let dryRun = false;
19
+ for (const arg of process.argv.slice(2)) {
20
+ if (arg === "--dry-run") dryRun = true;
21
+ else if (BUMPS.includes(arg)) bump = arg;
22
+ else {
23
+ console.error(`Unknown argument: ${arg} (expected ${BUMPS.join("|")} or --dry-run)`);
24
+ process.exit(2);
25
+ }
26
+ }
27
+
28
+ function run(command, opts = {}) {
29
+ console.log(`\n> ${command}`);
30
+ const result = spawnSync(command, { stdio: "inherit", shell: true, ...opts });
31
+ if (result.status !== 0) {
32
+ console.error(`\nAborted: \`${command}\` exited with ${result.status}.`);
33
+ process.exit(result.status ?? 1);
34
+ }
35
+ }
36
+
37
+ const status = spawnSync("git status --porcelain", { shell: true, encoding: "utf8" });
38
+ if (!dryRun && status.stdout.trim() !== "") {
39
+ console.error("Working tree is not clean — commit or stash first:\n" + status.stdout);
40
+ process.exit(1);
41
+ }
42
+
43
+ run("npm test");
44
+
45
+ if (dryRun) {
46
+ // Shows exactly which files would ship; verify nothing is missing/extra.
47
+ run("npm pack --dry-run");
48
+ console.log("\nDry run complete. No version bump, nothing published.");
49
+ process.exit(0);
50
+ }
51
+
52
+ run(`npm version ${bump}`);
53
+ run("npm publish");
54
+ run("git push --follow-tags");
55
+
56
+ console.log("\nRelease complete.");