@kairyou/agent-tools 0.1.0 → 0.3.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.
Files changed (38) hide show
  1. package/README.md +147 -69
  2. package/README.zh-CN.md +143 -67
  3. package/dist/vision/cli.mjs +1972 -0
  4. package/dist/vision/mcp-server.mjs +32858 -0
  5. package/{statusline/claude/statusline.mjs → integrations/statusline/claude-statusline.mjs} +4 -2
  6. package/integrations/usage/cli.mjs +27 -0
  7. package/{hooks/codex/usage-hook.mjs → integrations/usage/codex-hook.mjs} +1 -1
  8. package/{lib/usage.mjs → integrations/usage/core.mjs} +5 -1
  9. package/{plugins/opencode/usage-plugin.mjs → integrations/usage/opencode-plugin.mjs} +1 -1
  10. package/integrations/usage/skills/at-usage/SKILL.md +16 -0
  11. package/integrations/vision/lib/cli.mjs +137 -0
  12. package/integrations/vision/lib/config.mjs +159 -0
  13. package/integrations/vision/lib/errors.mjs +35 -0
  14. package/integrations/vision/lib/image-source.mjs +273 -0
  15. package/integrations/vision/lib/inspect.mjs +108 -0
  16. package/integrations/vision/lib/providers/anthropic-compatible.mjs +59 -0
  17. package/integrations/vision/lib/providers/openai-compatible.mjs +56 -0
  18. package/integrations/vision/lib/providers/shared.mjs +251 -0
  19. package/integrations/vision/lib/rate-limit.mjs +188 -0
  20. package/integrations/vision/lib/redact.mjs +30 -0
  21. package/integrations/vision/mcp-server.mjs +96 -0
  22. package/integrations/vision/skills/at-vision/SKILL.md +66 -0
  23. package/package.json +15 -10
  24. package/scripts/build-vision.mjs +35 -0
  25. package/scripts/capture-codex-tools.mjs +48 -0
  26. package/scripts/install.mjs +511 -29
  27. package/scripts/release.mjs +126 -0
  28. package/skills/integrations/at-zentao/SKILL.md +148 -0
  29. package/skills/workflow/at-commit/SKILL.md +3 -8
  30. package/skills/workflow/at-review/SKILL.md +9 -4
  31. package/skills/workflow/at-simplify/SKILL.md +1 -0
  32. package/hooks/claude/.gitkeep +0 -1
  33. package/hooks/codex/.gitkeep +0 -1
  34. package/hooks/common/.gitkeep +0 -1
  35. package/hooks/opencode/.gitkeep +0 -1
  36. package/statusline/.gitkeep +0 -1
  37. package/statusline/codex/.gitkeep +0 -1
  38. /package/{plugins/opencode/usage-tui.mjs → integrations/usage/opencode-tui.mjs} +0 -0
@@ -1,15 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  // agent-tools installer: wires statusline / usage into each agent's config.
3
- // Skills are NOT handled here — install those with `npx skills add`.
3
+ // Runtime-dependent skills are installed with their capability. Standalone
4
+ // workflow/integration skills are still handled by `npx skills add`.
4
5
  //
5
6
  // Capabilities (all global for now — they target the user-level config):
6
7
  // statusline Claude Code statusLine script (claude only).
7
- // usage Active API provider quota/balance (codex + opencode).
8
+ // usage Active API provider quota/balance (all agents).
9
+ // vision inspect_image MCP server + at-vision skill (all agents).
10
+ //
11
+ // Standalone commands (dispatched before capability parsing):
12
+ // inspect-image <path|url> --question "..." Human diagnostic for vision.
13
+ // mcp-vision Run the vision MCP stdio server.
8
14
  //
9
15
  // Targets:
10
16
  // claude -> ~/.claude/settings.json (statusLine key)
17
+ // + ~/.claude/skills/at-usage
18
+ // ~/.claude.json (vision MCP) + ~/.claude/skills (at-vision)
11
19
  // codex -> ~/.codex/hooks.json (standalone hooks file)
12
- // opencode -> ~/.config/opencode/ (server + TUI plugins)
20
+ // + ~/.agents/skills/at-usage
21
+ // ~/.codex/config.toml (vision MCP) + ~/.agents/skills (at-vision)
22
+ // opencode -> ~/.config/opencode/ (server + TUI plugins,
23
+ // vision MCP in opencode.json + skills/at-vision)
13
24
  // Runtime scripts are copied into ~/.agent-tools so this installer can be
14
25
  // run via npx from GitHub without requiring a persistent local clone.
15
26
  //
@@ -25,10 +36,15 @@
25
36
  // --settings <path> Override the Claude settings.json (for testing).
26
37
  // --codex-hooks <path> Override the Codex hooks.json (for testing).
27
38
  // --opencode-config-dir <p> Override the opencode config dir (for testing).
39
+ // --claude-json <path> Override ~/.claude.json for vision MCP (for testing).
40
+ // --claude-skills-dir <p> Override ~/.claude/skills (for testing).
41
+ // --codex-config <path> Override ~/.codex/config.toml (for testing).
42
+ // --codex-skills-dir <p> Override ~/.agents/skills (for testing).
28
43
  // --uninstall Remove what this installer added, restoring backups.
29
44
  // --dry-run Print planned changes without writing anything.
30
45
  // -h, --help Show this help.
31
46
 
47
+ import { spawnSync } from "node:child_process";
32
48
  import fs from "node:fs";
33
49
  import os from "node:os";
34
50
  import path from "node:path";
@@ -41,28 +57,40 @@ const INSTALL_ROOT =
41
57
  const META_KEY = "_agentTools";
42
58
  const META_VERSION = 1;
43
59
  const SOURCE = {
44
- codexUsageHook: path.join(REPO_ROOT, "hooks", "codex", "usage-hook.mjs"),
45
- usageScript: path.join(REPO_ROOT, "lib", "usage.mjs"),
60
+ codexUsageHook: path.join(REPO_ROOT, "integrations", "usage", "codex-hook.mjs"),
61
+ usageScript: path.join(REPO_ROOT, "integrations", "usage", "core.mjs"),
62
+ usageCli: path.join(REPO_ROOT, "integrations", "usage", "cli.mjs"),
46
63
  config: path.join(REPO_ROOT, "config.default.jsonc"),
47
- claudeStatusline: path.join(REPO_ROOT, "statusline", "claude", "statusline.mjs"),
48
- opencodeUsagePlugin: path.join(REPO_ROOT, "plugins", "opencode", "usage-plugin.mjs"),
49
- opencodeUsageTui: path.join(REPO_ROOT, "plugins", "opencode", "usage-tui.mjs"),
64
+ claudeStatusline: path.join(REPO_ROOT, "integrations", "statusline", "claude-statusline.mjs"),
65
+ opencodeUsagePlugin: path.join(REPO_ROOT, "integrations", "usage", "opencode-plugin.mjs"),
66
+ opencodeUsageTui: path.join(REPO_ROOT, "integrations", "usage", "opencode-tui.mjs"),
50
67
  };
51
68
  const RUNTIME = {
52
- codexUsageHook: path.join(INSTALL_ROOT, "hooks", "codex", "usage-hook.mjs"),
53
- usageScript: path.join(INSTALL_ROOT, "lib", "usage.mjs"),
69
+ codexUsageHook: path.join(INSTALL_ROOT, "integrations", "usage", "codex-hook.mjs"),
70
+ usageScript: path.join(INSTALL_ROOT, "integrations", "usage", "core.mjs"),
71
+ usageCli: path.join(INSTALL_ROOT, "integrations", "usage", "cli.mjs"),
54
72
  config: path.join(INSTALL_ROOT, "config.jsonc"),
55
- claudeStatusline: path.join(INSTALL_ROOT, "statusline", "claude", "statusline.mjs"),
56
- opencodeUsagePlugin: path.join(INSTALL_ROOT, "plugins", "opencode", "usage-plugin.mjs"),
57
- opencodeUsageTui: path.join(INSTALL_ROOT, "plugins", "opencode", "usage-tui.mjs"),
73
+ claudeStatusline: path.join(INSTALL_ROOT, "integrations", "statusline", "claude-statusline.mjs"),
74
+ opencodeUsagePlugin: path.join(INSTALL_ROOT, "integrations", "usage", "opencode-plugin.mjs"),
75
+ opencodeUsageTui: path.join(INSTALL_ROOT, "integrations", "usage", "opencode-tui.mjs"),
58
76
  };
59
- const ALL_CAPS = ["statusline", "usage"];
77
+ const ALL_CAPS = ["statusline", "usage", "vision"];
60
78
  const ALL_AGENTS = ["claude", "codex", "opencode"];
61
79
  const AGENT_CAPS = {
62
- claude: ["statusline"],
63
- codex: ["usage"],
64
- opencode: ["usage"],
80
+ claude: ["statusline", "usage", "vision"],
81
+ codex: ["usage", "vision"],
82
+ opencode: ["usage", "vision"],
65
83
  };
84
+ const VISION_MCP_NAME = "agent-tools-vision";
85
+ const VISION_SKILL_NAME = "at-vision";
86
+ // The at-vision skill ships inside the vision capability dir (integrations/vision),
87
+ // not skills/: it is unusable without the MCP server, so it must not surface
88
+ // as an independently installable skill.
89
+ const VISION_SKILL_SRC = path.join(REPO_ROOT, "integrations", "vision", "skills", VISION_SKILL_NAME);
90
+ const USAGE_SKILL_NAME = "at-usage";
91
+ const USAGE_SKILL_SRC = path.join(REPO_ROOT, "integrations", "usage", "skills", USAGE_SKILL_NAME);
92
+ const VISION_BUNDLED_MCP_SERVER = path.join(REPO_ROOT, "dist", "vision", "mcp-server.mjs");
93
+ const VISION_BUNDLED_CLI = path.join(REPO_ROOT, "dist", "vision", "cli.mjs");
66
94
 
67
95
  function fwd(p) {
68
96
  return p.replace(/\\/g, "/");
@@ -152,7 +180,7 @@ function isOpenCodeUsageTuiEntry(entry) {
152
180
  const spec = Array.isArray(entry) ? entry[0] : entry;
153
181
  return (
154
182
  typeof spec === "string" &&
155
- /\/plugins\/opencode\/usage-tui\.mjs$/i.test(spec.replace(/\\/g, "/"))
183
+ /\/integrations\/usage\/opencode-tui\.mjs$/i.test(spec.replace(/\\/g, "/"))
156
184
  );
157
185
  }
158
186
 
@@ -233,6 +261,7 @@ function installRuntimeAssets(opts) {
233
261
  addFile(SOURCE.opencodeUsageTui, RUNTIME.opencodeUsageTui);
234
262
  }
235
263
  addFile(SOURCE.usageScript, RUNTIME.usageScript);
264
+ addFile(SOURCE.usageCli, RUNTIME.usageCli);
236
265
  addFile(SOURCE.config, RUNTIME.config, { mergeJsonc: true });
237
266
  }
238
267
  if (files.length === 0) return;
@@ -243,10 +272,14 @@ function installRuntimeAssets(opts) {
243
272
  function parseArgs(argv) {
244
273
  const opts = {
245
274
  agents: [],
246
- capabilities: [],
275
+ integrations: [],
247
276
  settings: null,
248
277
  codexHooks: null,
249
278
  opencodeConfigDir: null,
279
+ claudeJson: null,
280
+ claudeSkillsDir: null,
281
+ codexConfig: null,
282
+ codexSkillsDir: null,
250
283
  uninstall: false,
251
284
  dryRun: false,
252
285
  help: false,
@@ -277,6 +310,10 @@ function parseArgs(argv) {
277
310
  case "--settings": opts.settings = argv[++i]; break;
278
311
  case "--codex-hooks": opts.codexHooks = argv[++i]; break;
279
312
  case "--opencode-config-dir": opts.opencodeConfigDir = argv[++i]; break;
313
+ case "--claude-json": opts.claudeJson = argv[++i]; break;
314
+ case "--claude-skills-dir": opts.claudeSkillsDir = argv[++i]; break;
315
+ case "--codex-config": opts.codexConfig = argv[++i]; break;
316
+ case "--codex-skills-dir": opts.codexSkillsDir = argv[++i]; break;
280
317
  case "--uninstall": opts.uninstall = true; break;
281
318
  case "--dry-run": opts.dryRun = true; break;
282
319
  case "-h":
@@ -286,15 +323,15 @@ function parseArgs(argv) {
286
323
  console.error(`Unknown option: ${a}`);
287
324
  process.exit(2);
288
325
  }
289
- opts.capabilities.push(a);
326
+ opts.integrations.push(a);
290
327
  }
291
328
  }
292
329
  if (opts.agents.length === 0) opts.agents = ["claude"];
293
- if (!opts.help && opts.capabilities.length === 0) {
330
+ if (!opts.help && opts.integrations.length === 0) {
294
331
  console.error(`Missing capability (available: ${ALL_CAPS.join(", ")})`);
295
332
  process.exit(2);
296
333
  }
297
- for (const name of opts.capabilities) {
334
+ for (const name of opts.integrations) {
298
335
  if (!ALL_CAPS.includes(name)) {
299
336
  console.error(`Unknown capability: ${name} (available: ${ALL_CAPS.join(", ")})`);
300
337
  process.exit(2);
@@ -304,14 +341,14 @@ function parseArgs(argv) {
304
341
  }
305
342
 
306
343
  function wants(opts, cap) {
307
- return opts.capabilities.length === 0 || opts.capabilities.includes(cap);
344
+ return opts.integrations.length === 0 || opts.integrations.includes(cap);
308
345
  }
309
346
 
310
347
  function validateAgentCapabilities(opts) {
311
348
  const invalid = [];
312
349
  for (const agent of opts.agents) {
313
350
  const supported = AGENT_CAPS[agent] || [];
314
- for (const cap of opts.capabilities) {
351
+ for (const cap of opts.integrations) {
315
352
  if (!supported.includes(cap)) invalid.push(`${cap} -a ${agent}`);
316
353
  }
317
354
  }
@@ -322,7 +359,6 @@ function validateAgentCapabilities(opts) {
322
359
  for (const agent of ALL_AGENTS) {
323
360
  console.error(` ${agent}: ${AGENT_CAPS[agent].join(", ")}`);
324
361
  }
325
- console.error("Claude API usage is refreshed by the statusline capability; use `statusline -a claude`.");
326
362
  process.exit(2);
327
363
  }
328
364
 
@@ -380,7 +416,7 @@ function isOurProviderUsageEntry(entry) {
380
416
  entry.hooks.some(
381
417
  (h) =>
382
418
  typeof h?.command === "string" &&
383
- /(?:^|[/\\])(?:usage-hook|usage)\.mjs(?:["\s]|$)/.test(h.command)
419
+ /(?:^|[/\\])codex-hook\.mjs(?:["\s]|$)/.test(h.command)
384
420
  )
385
421
  );
386
422
  }
@@ -403,9 +439,309 @@ function applyProviderUsage(cfg, { remove }) {
403
439
  if (Object.keys(cfg.hooks).length === 0) delete cfg.hooks;
404
440
  }
405
441
 
442
+ // ---- Vision capability helpers. ----
443
+
444
+ // The installed tree mirrors the package tree, so the bundled runtime lands in
445
+ // dist/vision just like in the package.
446
+ const VISION_RUNTIME_DIR = path.join(INSTALL_ROOT, "dist", "vision");
447
+ const VISION_RATE_LIMIT_STATE = path.join(INSTALL_ROOT, "cache", "vision-rate-limit.json");
448
+ const VISION_DIST_DIR = path.join(REPO_ROOT, "dist", "vision");
449
+ const VISION_RUNTIME_SERVER = path.join(VISION_RUNTIME_DIR, "mcp-server.mjs");
450
+ const VISION_RUNTIME_CLI = path.join(VISION_RUNTIME_DIR, "cli.mjs");
451
+ const SKILL_MARKER = ".agent-tools-managed.json";
452
+ const VISION_SKILL_MARKER_DATA = Object.freeze({
453
+ owner: "@kairyou/agent-tools",
454
+ capability: "vision",
455
+ artifact: "skill",
456
+ });
457
+ const USAGE_SKILL_MARKER_DATA = Object.freeze({
458
+ owner: "@kairyou/agent-tools",
459
+ capability: "usage",
460
+ artifact: "skill",
461
+ });
462
+ // Vision is bundled at release time. Install atomically swaps two self-contained
463
+ // entry files into ~/.agent-tools/dist/vision, so hosts never run npm and a
464
+ // failed update cannot destroy the previously working runtime.
465
+ function visionMcpCommand() {
466
+ return { command: "node", args: [fwd(VISION_RUNTIME_SERVER)] };
467
+ }
468
+
469
+ function sameFilePath(left, right) {
470
+ function canonical(value) {
471
+ const resolved = path.resolve(String(value));
472
+ try {
473
+ return fs.realpathSync.native(resolved);
474
+ } catch {
475
+ return resolved;
476
+ }
477
+ }
478
+ const a = canonical(left);
479
+ const b = canonical(right);
480
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
481
+ }
482
+
483
+ function isVisionServerPath(value) {
484
+ return sameFilePath(value, VISION_RUNTIME_SERVER);
485
+ }
486
+
487
+ function isClaudeVisionMcp(value, mcp = visionMcpCommand()) {
488
+ return Boolean(
489
+ value &&
490
+ value.command === mcp.command &&
491
+ Array.isArray(value.args) &&
492
+ value.args.length === 1 &&
493
+ isVisionServerPath(value.args[0])
494
+ );
495
+ }
496
+
497
+ function isOpenCodeVisionMcp(value, mcp = visionMcpCommand()) {
498
+ return Boolean(
499
+ value &&
500
+ value.type === "local" &&
501
+ Array.isArray(value.command) &&
502
+ value.command.length === 2 &&
503
+ value.command[0] === mcp.command &&
504
+ isVisionServerPath(value.command[1])
505
+ );
506
+ }
507
+
508
+ function installVisionRuntime(opts) {
509
+ if (opts.uninstall) return;
510
+ console.log(`vision runtime: ${VISION_RUNTIME_DIR}`);
511
+ if (opts.dryRun) {
512
+ console.log(` [dry-run] would atomically install ${VISION_DIST_DIR} -> ${VISION_RUNTIME_DIR}`);
513
+ return;
514
+ }
515
+ for (const name of ["mcp-server.mjs", "cli.mjs"]) {
516
+ if (!fs.existsSync(path.join(VISION_DIST_DIR, name))) {
517
+ throw new Error(`Missing bundled vision runtime ${path.join(VISION_DIST_DIR, name)}. Run npm run build:vision.`);
518
+ }
519
+ }
520
+
521
+ fs.mkdirSync(path.dirname(VISION_RUNTIME_DIR), { recursive: true });
522
+ const suffix = `${process.pid}-${Date.now()}`;
523
+ const stage = `${VISION_RUNTIME_DIR}.stage-${suffix}`;
524
+ const backup = `${VISION_RUNTIME_DIR}.backup-${suffix}`;
525
+ let movedCurrent = false;
526
+ try {
527
+ fs.cpSync(VISION_DIST_DIR, stage, { recursive: true });
528
+ writeText(
529
+ path.join(stage, "runtime.json"),
530
+ JSON.stringify({ owner: "@kairyou/agent-tools", capability: "vision", version: 1 }, null, 2) + "\n",
531
+ false
532
+ );
533
+ for (const name of ["mcp-server.mjs", "cli.mjs"]) {
534
+ const check = spawnSync(process.execPath, ["--check", path.join(stage, name)], {
535
+ encoding: "utf8",
536
+ });
537
+ if (check.status !== 0) {
538
+ throw new Error(`Bundled vision runtime failed syntax validation (${name}): ${check.stderr || check.stdout}`);
539
+ }
540
+ }
541
+
542
+ if (fs.existsSync(VISION_RUNTIME_DIR)) {
543
+ fs.renameSync(VISION_RUNTIME_DIR, backup);
544
+ movedCurrent = true;
545
+ }
546
+ if (process.env.AGENT_TOOLS_VISION_TEST_FAIL_SWAP === "1") {
547
+ throw new Error("Simulated vision runtime swap failure");
548
+ }
549
+ fs.renameSync(stage, VISION_RUNTIME_DIR);
550
+ fs.rmSync(backup, { recursive: true, force: true });
551
+ console.log(" installed bundled vision runtime");
552
+ } catch (error) {
553
+ fs.rmSync(stage, { recursive: true, force: true });
554
+ if (movedCurrent && !fs.existsSync(VISION_RUNTIME_DIR) && fs.existsSync(backup)) {
555
+ fs.renameSync(backup, VISION_RUNTIME_DIR);
556
+ }
557
+ throw error;
558
+ } finally {
559
+ fs.rmSync(stage, { recursive: true, force: true });
560
+ if (fs.existsSync(VISION_RUNTIME_DIR)) fs.rmSync(backup, { recursive: true, force: true });
561
+ }
562
+ }
563
+
564
+ function isManagedSkillDir(dest, markerData) {
565
+ const marker = path.join(dest, SKILL_MARKER);
566
+ if (!fs.existsSync(marker)) return false;
567
+ try {
568
+ const value = JSON.parse(fs.readFileSync(marker, "utf8"));
569
+ return Object.entries(markerData).every(([key, expected]) => value?.[key] === expected);
570
+ } catch {
571
+ return false;
572
+ }
573
+ }
574
+
575
+ function installManagedSkillDir(
576
+ skillsRoot,
577
+ { name, source, markerData, replacements, remove, dryRun }
578
+ ) {
579
+ const dest = path.join(skillsRoot, name);
580
+ const isManaged = () => isManagedSkillDir(dest, markerData);
581
+ if (remove) {
582
+ if (!fs.existsSync(dest)) return;
583
+ if (!isManaged()) {
584
+ console.log(` kept unmanaged ${dest}`);
585
+ return;
586
+ }
587
+ if (dryRun) {
588
+ console.log(` [dry-run] would remove ${dest}`);
589
+ return;
590
+ }
591
+ fs.rmSync(dest, { recursive: true, force: true });
592
+ console.log(` removed ${dest}`);
593
+ return;
594
+ }
595
+ if (fs.existsSync(dest) && !isManaged()) {
596
+ console.error(
597
+ ` Refusing to overwrite existing unowned skill directory ${dest}. ` +
598
+ `Move or remove it, then re-run the install.`
599
+ );
600
+ process.exit(1);
601
+ }
602
+ if (dryRun) {
603
+ console.log(` [dry-run] would copy ${source} -> ${dest}`);
604
+ return;
605
+ }
606
+ fs.rmSync(dest, { recursive: true, force: true });
607
+ fs.cpSync(source, dest, { recursive: true });
608
+ const skillFile = path.join(dest, "SKILL.md");
609
+ let skill = fs.readFileSync(skillFile, "utf8");
610
+ for (const [token, value] of Object.entries(replacements)) {
611
+ if (!skill.includes(token)) {
612
+ fs.rmSync(dest, { recursive: true, force: true });
613
+ throw new Error(`${name} skill template is missing ${token}`);
614
+ }
615
+ skill = skill.replaceAll(token, value);
616
+ }
617
+ fs.writeFileSync(skillFile, skill);
618
+ fs.writeFileSync(
619
+ path.join(dest, SKILL_MARKER),
620
+ JSON.stringify(markerData, null, 2) + "\n"
621
+ );
622
+ console.log(` wrote ${dest}`);
623
+ }
624
+
625
+ function installVisionSkillDir(skillsRoot, { remove, dryRun }) {
626
+ installManagedSkillDir(skillsRoot, {
627
+ name: VISION_SKILL_NAME,
628
+ source: VISION_SKILL_SRC,
629
+ markerData: VISION_SKILL_MARKER_DATA,
630
+ replacements: { "{{VISION_CLI_PATH}}": fwd(VISION_RUNTIME_CLI) },
631
+ remove,
632
+ dryRun,
633
+ });
634
+ }
635
+
636
+ function installUsageSkillDir(skillsRoot, agent, { remove, dryRun }) {
637
+ installManagedSkillDir(skillsRoot, {
638
+ name: USAGE_SKILL_NAME,
639
+ source: USAGE_SKILL_SRC,
640
+ markerData: USAGE_SKILL_MARKER_DATA,
641
+ replacements: {
642
+ "{{USAGE_CLI_PATH}}": fwd(RUNTIME.usageCli),
643
+ "{{USAGE_AGENT}}": agent,
644
+ },
645
+ remove,
646
+ dryRun,
647
+ });
648
+ }
649
+
650
+ const CODEX_VISION_BEGIN = "# >>> agent-tools vision >>>";
651
+ const CODEX_VISION_END = "# <<< agent-tools vision <<<";
652
+
653
+ // Codex config.toml is user-owned free-form TOML; we manage exactly one
654
+ // marker-delimited block so install/update/uninstall never touch other keys.
655
+ function updateCodexVisionBlock(file, { remove, dryRun, mcp }) {
656
+ const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
657
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
658
+ const blockRe = new RegExp(
659
+ `\\r?\\n?${escape(CODEX_VISION_BEGIN)}[\\s\\S]*?${escape(CODEX_VISION_END)}\\r?\\n?`
660
+ );
661
+ let next = current.replace(blockRe, "\n").replace(/^\n+/, "");
662
+ if (!remove) {
663
+ // A same-named table outside our marker block would make the appended
664
+ // TOML a duplicate declaration and break the ENTIRE config.toml parse.
665
+ const manualRe = new RegExp(`^\\s*\\[mcp_servers\\.["']?${escape(VISION_MCP_NAME)}["']?\\]`, "m");
666
+ if (manualRe.test(next)) {
667
+ console.error(
668
+ ` Found an existing [mcp_servers.${VISION_MCP_NAME}] entry in ${file} that was not ` +
669
+ "written by this installer. Remove or rename that entry, then re-run the install."
670
+ );
671
+ process.exit(1);
672
+ }
673
+ const args = mcp.args.map((a) => JSON.stringify(a)).join(", ");
674
+ const block = [
675
+ CODEX_VISION_BEGIN,
676
+ `[mcp_servers.${VISION_MCP_NAME}]`,
677
+ `command = ${JSON.stringify(mcp.command)}`,
678
+ `args = [${args}]`,
679
+ CODEX_VISION_END,
680
+ "",
681
+ ].join("\n");
682
+ next = next.trim() === "" ? block : next.replace(/\s*$/, "\n\n") + block;
683
+ }
684
+ if (next === current) {
685
+ console.log(` kept existing ${file}`);
686
+ return;
687
+ }
688
+ if (remove && next.trim() === "" && fs.existsSync(file) && current.trim() !== "") {
689
+ // The file only ever contained our block; leave an empty file rather than
690
+ // deleting config.toml, which Codex may expect to exist.
691
+ writeText(file, "", dryRun);
692
+ return;
693
+ }
694
+ writeText(file, next, dryRun);
695
+ }
696
+
406
697
  // ---- Claude: statusLine backed up in _agentTools. ----
407
698
 
699
+ function runClaudeVision(opts) {
700
+ const claudeJson = opts.claudeJson || path.join(os.homedir(), ".claude.json");
701
+ const skillsDir = opts.claudeSkillsDir || path.join(os.homedir(), ".claude", "skills");
702
+ console.log(`claude vision: ${claudeJson}`);
703
+ const cfg = readJson(claudeJson);
704
+ const mcp = visionMcpCommand();
705
+ const existing = cfg.mcpServers?.[VISION_MCP_NAME];
706
+ if (opts.uninstall) {
707
+ if (isClaudeVisionMcp(existing, mcp)) {
708
+ delete cfg.mcpServers[VISION_MCP_NAME];
709
+ if (Object.keys(cfg.mcpServers).length === 0) delete cfg.mcpServers;
710
+ writeJson(claudeJson, cfg, opts.dryRun);
711
+ } else if (existing) {
712
+ console.log(` kept unmanaged ${VISION_MCP_NAME} entry in ${claudeJson}`);
713
+ } else {
714
+ console.log(` no ${VISION_MCP_NAME} MCP entry found; nothing to remove.`);
715
+ }
716
+ installVisionSkillDir(skillsDir, { remove: true, dryRun: opts.dryRun });
717
+ console.log(" - vision");
718
+ return;
719
+ }
720
+ if (existing && !isClaudeVisionMcp(existing, mcp)) {
721
+ throw new Error(
722
+ `Found an existing unmanaged ${VISION_MCP_NAME} entry in ${claudeJson}. ` +
723
+ "Remove or rename it, then re-run the install."
724
+ );
725
+ }
726
+ cfg.mcpServers = cfg.mcpServers || {};
727
+ cfg.mcpServers[VISION_MCP_NAME] = { type: "stdio", command: mcp.command, args: mcp.args };
728
+ writeJson(claudeJson, cfg, opts.dryRun);
729
+ installVisionSkillDir(skillsDir, { remove: false, dryRun: opts.dryRun });
730
+ console.log(" + vision (inspect_image MCP + at-vision skill)");
731
+ }
732
+
408
733
  function runClaude(opts) {
734
+ if (wants(opts, "vision")) runClaudeVision(opts);
735
+ if (wants(opts, "usage")) {
736
+ const skillsDir = opts.claudeSkillsDir || path.join(os.homedir(), ".claude", "skills");
737
+ console.log(`claude usage: ${skillsDir}`);
738
+ installUsageSkillDir(skillsDir, "claude", {
739
+ remove: opts.uninstall,
740
+ dryRun: opts.dryRun,
741
+ });
742
+ console.log(opts.uninstall ? " - usage skill" : " + usage skill (at-usage)");
743
+ }
744
+ if (!wants(opts, "statusline")) return;
409
745
  const settings = opts.settings || path.join(os.homedir(), ".claude", "settings.json");
410
746
  console.log(`claude (global): ${settings}`);
411
747
  const cfg = readJson(settings);
@@ -444,11 +780,30 @@ function runClaude(opts) {
444
780
  // written (Codex validates hooks.json against a schema); our hook is found by
445
781
  // command signature instead. ----
446
782
 
783
+ function runCodexVision(opts) {
784
+ const file = opts.codexConfig || path.join(os.homedir(), ".codex", "config.toml");
785
+ const skillsDir = opts.codexSkillsDir || path.join(os.homedir(), ".agents", "skills");
786
+ console.log(`codex vision: ${file}`);
787
+ updateCodexVisionBlock(file, {
788
+ remove: opts.uninstall,
789
+ dryRun: opts.dryRun,
790
+ mcp: visionMcpCommand(),
791
+ });
792
+ installVisionSkillDir(skillsDir, { remove: opts.uninstall, dryRun: opts.dryRun });
793
+ console.log(opts.uninstall ? " - vision" : " + vision (inspect_image MCP + at-vision skill)");
794
+ }
795
+
447
796
  function runCodex(opts) {
797
+ if (wants(opts, "vision")) runCodexVision(opts);
448
798
  if (!wants(opts, "usage")) {
449
- console.log("codex: nothing to do (supports: usage).");
450
799
  return;
451
800
  }
801
+ const skillsDir = opts.codexSkillsDir || path.join(os.homedir(), ".agents", "skills");
802
+ console.log(`codex usage skill: ${skillsDir}`);
803
+ installUsageSkillDir(skillsDir, "codex", {
804
+ remove: opts.uninstall,
805
+ dryRun: opts.dryRun,
806
+ });
452
807
  const file = opts.codexHooks || path.join(os.homedir(), ".codex", "hooks.json");
453
808
  console.log(`codex (global): ${file}`);
454
809
  const cfg = readJson(file);
@@ -491,9 +846,58 @@ function opencodeConfigDir(opts) {
491
846
  );
492
847
  }
493
848
 
849
+ // opencode.json may be JSONC with user comments — edit only our key in place.
850
+ function updateOpencodeVisionMcp(file, { remove, dryRun, mcp }) {
851
+ const exists = fs.existsSync(file);
852
+ const currentText = exists ? fs.readFileSync(file, "utf8") : "{}\n";
853
+ const errors = [];
854
+ const current = parseJsonc(currentText, errors, { allowTrailingComma: true }) || {};
855
+ if (errors.length > 0 || typeof current !== "object" || Array.isArray(current)) {
856
+ throw new Error(`Cannot parse ${file} as JSONC`);
857
+ }
858
+ const existing = current.mcp && current.mcp[VISION_MCP_NAME];
859
+ if (existing && !isOpenCodeVisionMcp(existing, mcp)) {
860
+ if (remove) {
861
+ console.log(` kept unmanaged ${VISION_MCP_NAME} entry in ${file}`);
862
+ return;
863
+ }
864
+ throw new Error(
865
+ `Found an existing unmanaged ${VISION_MCP_NAME} entry in ${file}. ` +
866
+ "Remove or rename it, then re-run the install."
867
+ );
868
+ }
869
+ const desired = remove
870
+ ? undefined
871
+ : { type: "local", command: [mcp.command, ...mcp.args], enabled: true };
872
+ if (JSON.stringify(existing) === JSON.stringify(desired)) {
873
+ console.log(` kept existing ${file}`);
874
+ return;
875
+ }
876
+ const eol = currentText.includes("\r\n") ? "\r\n" : "\n";
877
+ const edits = modify(currentText, ["mcp", VISION_MCP_NAME], desired, {
878
+ formattingOptions: { insertSpaces: true, tabSize: 2, eol },
879
+ });
880
+ const updated = applyEdits(currentText, edits).replace(/\s*$/, "") + eol;
881
+ writeText(file, updated, dryRun);
882
+ }
883
+
884
+ function runOpencodeVision(opts) {
885
+ const configDir = opencodeConfigDir(opts);
886
+ const file = path.join(configDir, "opencode.json");
887
+ const skillsDir = path.join(configDir, "skills");
888
+ console.log(`opencode vision: ${file}`);
889
+ updateOpencodeVisionMcp(file, {
890
+ remove: opts.uninstall,
891
+ dryRun: opts.dryRun,
892
+ mcp: visionMcpCommand(),
893
+ });
894
+ installVisionSkillDir(skillsDir, { remove: opts.uninstall, dryRun: opts.dryRun });
895
+ console.log(opts.uninstall ? " - vision" : " + vision (inspect_image MCP + at-vision skill)");
896
+ }
897
+
494
898
  function runOpencode(opts) {
899
+ if (wants(opts, "vision")) runOpencodeVision(opts);
495
900
  if (!wants(opts, "usage")) {
496
- console.log("opencode: nothing to do (supports: usage).");
497
901
  return;
498
902
  }
499
903
 
@@ -520,6 +924,70 @@ function runOpencode(opts) {
520
924
  if (!opts.dryRun) console.log(" NOTE: restart opencode to load the agent-tools usage plugin.");
521
925
  }
522
926
 
927
+ function visionRuntimeHasRemainingReference(opts) {
928
+ const removed = new Set(opts.agents);
929
+ if (!removed.has("claude")) {
930
+ const file = opts.claudeJson || path.join(os.homedir(), ".claude.json");
931
+ try {
932
+ if (isClaudeVisionMcp(readJson(file).mcpServers?.[VISION_MCP_NAME])) return true;
933
+ } catch {
934
+ return true;
935
+ }
936
+ }
937
+ if (!removed.has("codex")) {
938
+ const file = opts.codexConfig || path.join(os.homedir(), ".codex", "config.toml");
939
+ try {
940
+ const text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
941
+ const start = text.indexOf(CODEX_VISION_BEGIN);
942
+ const end = text.indexOf(CODEX_VISION_END, start + CODEX_VISION_BEGIN.length);
943
+ if (start !== -1 && end !== -1) {
944
+ const block = text.slice(start, end);
945
+ if (block.includes(fwd(VISION_RUNTIME_SERVER))) {
946
+ return true;
947
+ }
948
+ }
949
+ } catch {
950
+ return true;
951
+ }
952
+ }
953
+ if (!removed.has("opencode")) {
954
+ const file = path.join(opencodeConfigDir(opts), "opencode.json");
955
+ try {
956
+ if (fs.existsSync(file)) {
957
+ const errors = [];
958
+ const config = parseJsonc(fs.readFileSync(file, "utf8"), errors, { allowTrailingComma: true });
959
+ if (errors.length > 0) return true;
960
+ if (isOpenCodeVisionMcp(config?.mcp?.[VISION_MCP_NAME])) return true;
961
+ }
962
+ } catch {
963
+ return true;
964
+ }
965
+ }
966
+ return false;
967
+ }
968
+
969
+ function cleanupVisionRuntimeIfUnused(opts) {
970
+ if (!opts.uninstall || !wants(opts, "vision") || !fs.existsSync(VISION_RUNTIME_DIR)) return;
971
+ if (visionRuntimeHasRemainingReference(opts)) {
972
+ console.log(` kept shared vision runtime ${VISION_RUNTIME_DIR} (another agent still references it)`);
973
+ return;
974
+ }
975
+ if (opts.dryRun) {
976
+ console.log(` [dry-run] would remove unused vision runtime ${VISION_RUNTIME_DIR}`);
977
+ return;
978
+ }
979
+ fs.rmSync(VISION_RUNTIME_DIR, { recursive: true, force: true });
980
+ if (!fs.existsSync(`${VISION_RATE_LIMIT_STATE}.lock`)) {
981
+ fs.rmSync(VISION_RATE_LIMIT_STATE, { force: true });
982
+ try {
983
+ fs.rmdirSync(path.dirname(VISION_RATE_LIMIT_STATE));
984
+ } catch {
985
+ // The shared cache directory may contain state for other integrations.
986
+ }
987
+ }
988
+ console.log(` removed unused vision runtime ${VISION_RUNTIME_DIR}`);
989
+ }
990
+
523
991
  const AGENTS = { claude: runClaude, codex: runCodex, opencode: runOpencode };
524
992
 
525
993
  function main() {
@@ -538,6 +1006,7 @@ function main() {
538
1006
  }
539
1007
  validateAgentCapabilities(opts);
540
1008
  installRuntimeAssets(opts);
1009
+ if (wants(opts, "vision")) installVisionRuntime(opts);
541
1010
  for (const name of opts.agents) {
542
1011
  const run = AGENTS[name];
543
1012
  if (!run) {
@@ -546,6 +1015,19 @@ function main() {
546
1015
  }
547
1016
  run(opts);
548
1017
  }
1018
+ cleanupVisionRuntimeIfUnused(opts);
549
1019
  }
550
1020
 
551
- main();
1021
+ // Standalone vision commands dispatch before capability parsing so image
1022
+ // paths and questions are never mistaken for integrations.
1023
+ const subcommand = process.argv[2];
1024
+ if (subcommand === "inspect-image") {
1025
+ const { runInspectImageCli } = await import(
1026
+ pathToFileURL(VISION_BUNDLED_CLI).href
1027
+ );
1028
+ process.exitCode = await runInspectImageCli(process.argv.slice(3));
1029
+ } else if (subcommand === "mcp-vision") {
1030
+ await import(pathToFileURL(VISION_BUNDLED_MCP_SERVER).href);
1031
+ } else {
1032
+ main();
1033
+ }