argsbarg 3.4.2 → 3.5.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 (66) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/biome.json +29 -6
  3. package/bun.lock +22 -0
  4. package/docs/install.md +1 -1
  5. package/docs/mcp.md +45 -1
  6. package/index.d.ts +50 -50
  7. package/justfile +27 -6
  8. package/package.json +4 -2
  9. package/scripts/release.ts +26 -9
  10. package/src/builtins/builtins.test.ts +9 -4
  11. package/src/builtins/completion-bash.ts +74 -50
  12. package/src/builtins/completion-fish.ts +3 -8
  13. package/src/builtins/completion-group.ts +1 -1
  14. package/src/builtins/completion-zsh.ts +80 -42
  15. package/src/builtins/dispatch.ts +20 -16
  16. package/src/builtins/export.ts +19 -10
  17. package/src/builtins/index.ts +9 -4
  18. package/src/builtins/install.ts +10 -10
  19. package/src/builtins/mcp.ts +1 -1
  20. package/src/builtins/presentation.ts +8 -8
  21. package/src/builtins/scopes.ts +1 -1
  22. package/src/builtins/version.ts +1 -1
  23. package/src/completion.ts +4 -4
  24. package/src/docs/api-guide.test.ts +2 -2
  25. package/src/docs/api-guide.ts +2 -2
  26. package/src/docs/builtin.ts +27 -8
  27. package/src/docs/docs.test.ts +18 -11
  28. package/src/docs/mcp-guide.ts +105 -24
  29. package/src/docs/resolve.ts +10 -3
  30. package/src/docs/save.ts +11 -3
  31. package/src/headless.test.ts +8 -16
  32. package/src/help.ts +73 -43
  33. package/src/hidden-mcpb.test.ts +7 -6
  34. package/src/hidden.ts +2 -2
  35. package/src/index.test.ts +113 -89
  36. package/src/index.ts +24 -24
  37. package/src/install/binary.ts +12 -5
  38. package/src/install/completions.ts +7 -3
  39. package/src/install/detect-installed.ts +29 -4
  40. package/src/install/gh-release-update.ts +31 -23
  41. package/src/install/index.ts +69 -19
  42. package/src/install/install.test.ts +31 -8
  43. package/src/install/mcp-codex.test.ts +57 -0
  44. package/src/install/mcp-codex.ts +125 -0
  45. package/src/install/mcp-config.ts +12 -5
  46. package/src/install/mcp-opencode.test.ts +98 -0
  47. package/src/install/mcp-opencode.ts +149 -0
  48. package/src/install/paths.ts +29 -3
  49. package/src/install/plan.ts +73 -6
  50. package/src/install/shell.ts +1 -4
  51. package/src/install/status.ts +12 -6
  52. package/src/install/uninstall.ts +38 -4
  53. package/src/install/update.test.ts +2 -2
  54. package/src/install/update.ts +3 -1
  55. package/src/invoke.ts +12 -9
  56. package/src/mcp/bundle.ts +36 -8
  57. package/src/mcp/env.ts +7 -13
  58. package/src/mcp/server.ts +12 -6
  59. package/src/mcp/tools.ts +20 -4
  60. package/src/mcp.ts +3 -3
  61. package/src/parse.ts +96 -24
  62. package/src/runtime.ts +22 -12
  63. package/src/schema.ts +11 -5
  64. package/src/skill/generate.ts +4 -4
  65. package/src/skill/install.ts +6 -2
  66. package/src/validate.ts +21 -16
@@ -0,0 +1,125 @@
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import type { McpServerEntry } from "./mcp-config.ts";
4
+
5
+ /** True when the `codex` executable is on PATH. */
6
+ export function codexOnPath(): boolean {
7
+ return Bun.which("codex") !== null;
8
+ }
9
+
10
+ /** Global Codex config path (`~/.codex/config.toml`). */
11
+ export function resolveCodexConfigPath(home: string): string {
12
+ return join(home, ".codex", "config.toml");
13
+ }
14
+
15
+ function escapeRegExp(s: string): string {
16
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17
+ }
18
+
19
+ function parseTomlStringArray(inner: string): string[] {
20
+ const re = /"([^"]*)"/g;
21
+ const out: string[] = [];
22
+ let m = re.exec(inner);
23
+ while (m !== null) {
24
+ const value = m[1];
25
+ if (value !== undefined) {
26
+ out.push(value);
27
+ }
28
+ m = re.exec(inner);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /** Reads stdio `command`/`args` from a Codex `[mcp_servers.name]` block, if present. */
34
+ export function readCodexMcpEntry(path: string, name: string): McpServerEntry | undefined {
35
+ if (!existsSync(path)) return undefined;
36
+ const text = readFileSync(path, "utf8");
37
+ const sectionRe = new RegExp(`^\\[mcp_servers\\.${escapeRegExp(name)}\\]\\s*$`, "m");
38
+ const match = sectionRe.exec(text);
39
+ if (!match) return undefined;
40
+
41
+ const after = text.slice(match.index + match[0].length);
42
+ const nextSection = after.search(/^\[/m);
43
+ const block = nextSection === -1 ? after : after.slice(0, nextSection);
44
+
45
+ const transport = block.match(/transport\s*=\s*\{[^}]*type\s*=\s*"stdio"[^}]*\}/s)?.[0];
46
+ const search = transport ?? block;
47
+
48
+ const cmd =
49
+ search.match(/command\s*=\s*"([^"]*)"/)?.[1] ?? search.match(/command\s*=\s*'([^']*)'/)?.[1];
50
+ if (!cmd) return undefined;
51
+
52
+ const argsMatch = search.match(/args\s*=\s*\[([^\]]*)\]/s);
53
+ const argsInner = argsMatch?.[1];
54
+ const args = argsInner !== undefined ? parseTomlStringArray(argsInner) : [];
55
+ return { command: cmd, args };
56
+ }
57
+
58
+ export function codexMcpHasServer(home: string, name: string): boolean {
59
+ const path = resolveCodexConfigPath(home);
60
+ if (!existsSync(path)) return false;
61
+ const text = readFileSync(path, "utf8");
62
+ return new RegExp(`^\\[mcp_servers\\.${escapeRegExp(name)}\\]`, "m").test(text);
63
+ }
64
+
65
+ function entriesEqual(a: McpServerEntry, b: McpServerEntry): boolean {
66
+ return a.command === b.command && JSON.stringify(a.args) === JSON.stringify(b.args);
67
+ }
68
+
69
+ /** Returns an error when an existing Codex entry conflicts, or null if safe to merge. */
70
+ export function checkCodexMcpConflict(
71
+ home: string,
72
+ name: string,
73
+ expected: McpServerEntry,
74
+ yes: boolean,
75
+ ): string | null {
76
+ const path = resolveCodexConfigPath(home);
77
+ const existing = readCodexMcpEntry(path, name);
78
+ if (existing && !entriesEqual(existing, expected) && !yes) {
79
+ return (
80
+ `MCP server "${name}" in ${path} differs from expected Codex entry.\n` +
81
+ ` existing: ${JSON.stringify(existing)}\n` +
82
+ ` expected: ${JSON.stringify(expected)}\n` +
83
+ `Use --yes to overwrite.`
84
+ );
85
+ }
86
+ return null;
87
+ }
88
+
89
+ function runCodex(args: string[]): void {
90
+ const proc = Bun.spawnSync({
91
+ cmd: ["codex", ...args],
92
+ stdout: "pipe",
93
+ stderr: "pipe",
94
+ });
95
+ if (proc.exitCode !== 0) {
96
+ const err = new TextDecoder().decode(proc.stderr).trim();
97
+ throw new Error(err || `codex ${args.join(" ")} failed (exit ${proc.exitCode})`);
98
+ }
99
+ }
100
+
101
+ /** Registers a stdio MCP server via `codex mcp add`. */
102
+ export function mergeCodexMcpConfig(
103
+ home: string,
104
+ name: string,
105
+ entry: McpServerEntry,
106
+ dry: boolean,
107
+ overwrite: boolean,
108
+ ): string {
109
+ const configPath = resolveCodexConfigPath(home);
110
+ if (dry) return configPath;
111
+
112
+ mkdirSync(dirname(configPath), { recursive: true });
113
+ if (codexMcpHasServer(home, name) && overwrite) {
114
+ runCodex(["mcp", "remove", name]);
115
+ }
116
+
117
+ runCodex(["mcp", "add", name, "--", entry.command, ...entry.args]);
118
+ return configPath;
119
+ }
120
+
121
+ /** Removes a Codex MCP server via `codex mcp remove`. */
122
+ export function removeCodexMcpConfig(home: string, name: string, dry: boolean): void {
123
+ if (dry || !codexMcpHasServer(home, name)) return;
124
+ runCodex(["mcp", "remove", name]);
125
+ }
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
- import { CliProgram } from "../types.ts";
3
+ import type { CliProgram } from "../types.ts";
4
4
 
5
5
  export interface McpServerEntry {
6
6
  command: string;
@@ -19,7 +19,9 @@ function entriesEqual(a: McpServerEntry, b: McpServerEntry): boolean {
19
19
  export function readMcpServerEntry(path: string, name: string): McpServerEntry | undefined {
20
20
  if (!existsSync(path)) return undefined;
21
21
  try {
22
- const data = JSON.parse(readFileSync(path, "utf8")) as { mcpServers?: Record<string, McpServerEntry> };
22
+ const data = JSON.parse(readFileSync(path, "utf8")) as {
23
+ mcpServers?: Record<string, McpServerEntry>;
24
+ };
23
25
  return data.mcpServers?.[name];
24
26
  } catch {
25
27
  return undefined;
@@ -46,7 +48,12 @@ export function checkMcpConflict(
46
48
  }
47
49
 
48
50
  /** Merges MCP server entry into config file. */
49
- export function mergeMcpConfig(path: string, name: string, entry: McpServerEntry, dry: boolean): void {
51
+ export function mergeMcpConfig(
52
+ path: string,
53
+ name: string,
54
+ entry: McpServerEntry,
55
+ dry: boolean,
56
+ ): void {
50
57
  if (dry) return;
51
58
  let data: Record<string, unknown> = {};
52
59
  if (existsSync(path)) {
@@ -56,7 +63,7 @@ export function mergeMcpConfig(path: string, name: string, entry: McpServerEntry
56
63
  servers[name] = entry;
57
64
  data.mcpServers = servers;
58
65
  mkdirSync(dirname(path), { recursive: true });
59
- writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
66
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
60
67
  }
61
68
 
62
69
  /** Removes MCP server entry from config file (keeps file if other keys remain). */
@@ -65,5 +72,5 @@ export function removeMcpConfig(path: string, name: string, dry: boolean): void
65
72
  const data = JSON.parse(readFileSync(path, "utf8")) as { mcpServers?: Record<string, unknown> };
66
73
  if (!data.mcpServers?.[name]) return;
67
74
  delete data.mcpServers[name];
68
- writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
75
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
69
76
  }
@@ -0,0 +1,98 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { CliProgram } from "../types.ts";
6
+ import {
7
+ detectOpenCodeMcpConfigPath,
8
+ expectedOpenCodeMcpEntry,
9
+ mergeOpenCodeMcpConfig,
10
+ OPENCODE_CONFIG_SCHEMA,
11
+ opencodeConfigDir,
12
+ removeOpenCodeMcpConfig,
13
+ resolveOpenCodeConfigPathForInstall,
14
+ } from "./mcp-opencode.ts";
15
+
16
+ const fixture: CliProgram = {
17
+ key: "testapp",
18
+ version: "0.0.0",
19
+ description: "Test",
20
+ mcpServer: { enabled: true },
21
+ handler: () => {},
22
+ };
23
+
24
+ let home: string;
25
+ let prevHome: string | undefined;
26
+ let prevXdg: string | undefined;
27
+
28
+ beforeEach(() => {
29
+ home = mkdtempSync(join(tmpdir(), "argsbarg-opencode-"));
30
+ prevHome = process.env.HOME;
31
+ prevXdg = process.env.XDG_CONFIG_HOME;
32
+ process.env.HOME = home;
33
+ delete process.env.XDG_CONFIG_HOME;
34
+ });
35
+
36
+ afterEach(() => {
37
+ if (prevHome === undefined) delete process.env.HOME;
38
+ else process.env.HOME = prevHome;
39
+ if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME;
40
+ else process.env.XDG_CONFIG_HOME = prevXdg;
41
+ rmSync(home, { recursive: true, force: true });
42
+ });
43
+
44
+ describe("opencode mcp config", () => {
45
+ test("resolveOpenCodeConfigPathForInstall prefers existing file", () => {
46
+ const dir = opencodeConfigDir(home);
47
+ mkdirSync(dir, { recursive: true });
48
+ const existing = join(dir, "opencode.json");
49
+ writeFileSync(existing, "{}", "utf8");
50
+ expect(resolveOpenCodeConfigPathForInstall(home)).toBe(existing);
51
+ });
52
+
53
+ test("resolveOpenCodeConfigPathForInstall defaults to config.json", () => {
54
+ mkdirSync(opencodeConfigDir(home), { recursive: true });
55
+ expect(resolveOpenCodeConfigPathForInstall(home)).toBe(
56
+ join(opencodeConfigDir(home), "config.json"),
57
+ );
58
+ });
59
+
60
+ test("mergeOpenCodeMcpConfig writes mcp block", () => {
61
+ const path = resolveOpenCodeConfigPathForInstall(home);
62
+ mkdirSync(opencodeConfigDir(home), { recursive: true });
63
+ const entry = expectedOpenCodeMcpEntry(fixture);
64
+ mergeOpenCodeMcpConfig(path, "testapp", entry, false);
65
+ const data = JSON.parse(readFileSync(path, "utf8")) as {
66
+ $schema: string;
67
+ mcp: Record<string, unknown>;
68
+ };
69
+ expect(data.$schema).toBe(OPENCODE_CONFIG_SCHEMA);
70
+ expect(data.mcp.testapp).toEqual(entry);
71
+ });
72
+
73
+ test("detectOpenCodeMcpConfigPath finds entry across filenames", () => {
74
+ const dir = opencodeConfigDir(home);
75
+ mkdirSync(dir, { recursive: true });
76
+ const path = join(dir, "config.json");
77
+ writeFileSync(
78
+ path,
79
+ JSON.stringify({
80
+ mcp: {
81
+ testapp: { type: "local", command: ["testapp", "mcp"] },
82
+ },
83
+ }),
84
+ "utf8",
85
+ );
86
+ expect(detectOpenCodeMcpConfigPath(home, "testapp")).toBe(path);
87
+ });
88
+
89
+ test("removeOpenCodeMcpConfig deletes server entry", () => {
90
+ const path = resolveOpenCodeConfigPathForInstall(home);
91
+ mkdirSync(opencodeConfigDir(home), { recursive: true });
92
+ mergeOpenCodeMcpConfig(path, "testapp", expectedOpenCodeMcpEntry(fixture), false);
93
+ removeOpenCodeMcpConfig(path, "testapp", false);
94
+ const data = JSON.parse(readFileSync(path, "utf8")) as { mcp?: Record<string, unknown> };
95
+ expect(data.mcp?.testapp).toBeUndefined();
96
+ expect(existsSync(path)).toBe(true);
97
+ });
98
+ });
@@ -0,0 +1,149 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import type { CliProgram } from "../types.ts";
4
+ import { expectedMcpEntry } from "./mcp-config.ts";
5
+
6
+ export const OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json";
7
+
8
+ /** Global OpenCode config filenames, in precedence order (matches OpenCode). */
9
+ export const OPENCODE_CONFIG_FILENAMES = [
10
+ "opencode.jsonc",
11
+ "opencode.json",
12
+ "config.json",
13
+ ] as const;
14
+
15
+ export interface OpenCodeLocalMcpEntry {
16
+ type: "local";
17
+ command: string[];
18
+ enabled?: boolean;
19
+ }
20
+
21
+ /** `~/.config/opencode` (or `$XDG_CONFIG_HOME/opencode`). */
22
+ export function opencodeConfigDir(home: string): string {
23
+ const xdg = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
24
+ return join(xdg, "opencode");
25
+ }
26
+
27
+ /** True when OpenCode config directory exists. */
28
+ export function opencodePresent(home: string): boolean {
29
+ return existsSync(opencodeConfigDir(home));
30
+ }
31
+
32
+ /** Path to write a new entry: first existing config file, else `config.json`. */
33
+ export function resolveOpenCodeConfigPathForInstall(home: string): string {
34
+ const dir = opencodeConfigDir(home);
35
+ for (const file of OPENCODE_CONFIG_FILENAMES) {
36
+ const path = join(dir, file);
37
+ if (existsSync(path)) return path;
38
+ }
39
+ return join(dir, "config.json");
40
+ }
41
+
42
+ /** First config file containing `mcp[name]`, if any. */
43
+ export function detectOpenCodeMcpConfigPath(home: string, name: string): string | undefined {
44
+ const dir = opencodeConfigDir(home);
45
+ for (const file of OPENCODE_CONFIG_FILENAMES) {
46
+ const path = join(dir, file);
47
+ if (openCodeMcpHasServer(path, name)) return path;
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ export function expectedOpenCodeMcpEntry(root: CliProgram): OpenCodeLocalMcpEntry {
53
+ const entry = expectedMcpEntry(root);
54
+ return { type: "local", command: [entry.command, ...entry.args], enabled: true };
55
+ }
56
+
57
+ function entriesEqual(a: OpenCodeLocalMcpEntry, b: OpenCodeLocalMcpEntry): boolean {
58
+ return a.type === b.type && JSON.stringify(a.command) === JSON.stringify(b.command);
59
+ }
60
+
61
+ function readConfig(path: string): Record<string, unknown> {
62
+ if (!existsSync(path)) return {};
63
+ try {
64
+ return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
65
+ } catch {
66
+ throw new Error(
67
+ `Could not parse OpenCode config at ${path} (JSON with comments is not supported by install --mcp). ` +
68
+ `Add the server manually or use a .json config file.`,
69
+ );
70
+ }
71
+ }
72
+
73
+ /** Reads `mcp[name]` when it is a local stdio server. */
74
+ export function readOpenCodeMcpEntry(
75
+ path: string,
76
+ name: string,
77
+ ): OpenCodeLocalMcpEntry | undefined {
78
+ if (!existsSync(path)) return undefined;
79
+ try {
80
+ const data = readConfig(path);
81
+ const raw = (data.mcp as Record<string, unknown> | undefined)?.[name];
82
+ if (!raw || typeof raw !== "object") return undefined;
83
+ const entry = raw as { type?: string; command?: unknown };
84
+ if (entry.type !== "local" || !Array.isArray(entry.command)) return undefined;
85
+ if (!entry.command.every((p) => typeof p === "string")) return undefined;
86
+ return {
87
+ type: "local",
88
+ command: entry.command as string[],
89
+ enabled: (raw as { enabled?: boolean }).enabled,
90
+ };
91
+ } catch (err) {
92
+ if (err instanceof Error && err.message.includes("Could not parse OpenCode")) throw err;
93
+ return undefined;
94
+ }
95
+ }
96
+
97
+ export function openCodeMcpHasServer(path: string, name: string): boolean {
98
+ try {
99
+ return readOpenCodeMcpEntry(path, name) !== undefined;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ /** Returns an error when an existing OpenCode entry conflicts, or null if safe to merge. */
106
+ export function checkOpenCodeMcpConflict(
107
+ path: string,
108
+ name: string,
109
+ expected: OpenCodeLocalMcpEntry,
110
+ yes: boolean,
111
+ ): string | null {
112
+ const existing = readOpenCodeMcpEntry(path, name);
113
+ if (existing && !entriesEqual(existing, expected) && !yes) {
114
+ return (
115
+ `MCP server "${name}" in ${path} differs from expected OpenCode entry.\n` +
116
+ ` existing: ${JSON.stringify(existing)}\n` +
117
+ ` expected: ${JSON.stringify(expected)}\n` +
118
+ `Use --yes to overwrite.`
119
+ );
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /** Merges a local MCP entry into OpenCode `mcp` config. */
125
+ export function mergeOpenCodeMcpConfig(
126
+ path: string,
127
+ name: string,
128
+ entry: OpenCodeLocalMcpEntry,
129
+ dry: boolean,
130
+ ): void {
131
+ if (dry) return;
132
+ const data = readConfig(path);
133
+ if (!data.$schema) data.$schema = OPENCODE_CONFIG_SCHEMA;
134
+ const servers = (data.mcp as Record<string, OpenCodeLocalMcpEntry> | undefined) ?? {};
135
+ servers[name] = entry;
136
+ data.mcp = servers;
137
+ mkdirSync(dirname(path), { recursive: true });
138
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
139
+ }
140
+
141
+ /** Removes `mcp[name]` from an OpenCode config file. */
142
+ export function removeOpenCodeMcpConfig(path: string, name: string, dry: boolean): void {
143
+ if (dry || !existsSync(path)) return;
144
+ const data = readConfig(path);
145
+ const servers = data.mcp as Record<string, unknown> | undefined;
146
+ if (!servers?.[name]) return;
147
+ delete servers[name];
148
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
149
+ }
@@ -1,8 +1,9 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
- import { CliProgram } from "../types.ts";
5
- import { sanitizeToolSegment, mcpServerId } from "../mcp/tools.ts";
4
+ import { mcpServerId, sanitizeToolSegment } from "../mcp/tools.ts";
5
+ import type { CliProgram } from "../types.ts";
6
+ import { resolveOpenCodeConfigPathForInstall } from "./mcp-opencode.ts";
6
7
 
7
8
  export interface InstallPaths {
8
9
  bindir: string;
@@ -15,6 +16,9 @@ export interface InstallPaths {
15
16
  cursorMcpPath: string;
16
17
  claudeMcpPath: string;
17
18
  claudeDesktopMcpPath: string;
19
+ opencodeMcpPath: string;
20
+ chatGptMcpPath: string;
21
+ codexConfigPath: string;
18
22
  bashRc: string;
19
23
  zshRc: string;
20
24
  mcpName: string;
@@ -59,7 +63,25 @@ export function resolveClaudeDesktopMcpPath(home: string): string {
59
63
  }
60
64
 
61
65
  /** True when Claude Desktop app data exists (config file or app support directory). */
62
- export function claudeDesktopPresent(home: string, configPath: string): boolean {
66
+ export function claudeDesktopPresent(_home: string, configPath: string): boolean {
67
+ return existsSync(configPath) || existsSync(dirname(configPath));
68
+ }
69
+
70
+ /** Resolves ChatGPT desktop `chatgpt_mcp_config.json` for the current OS. */
71
+ export function resolveChatGptMcpPath(home: string): string {
72
+ if (process.platform === "darwin") {
73
+ return join(home, "Library", "Application Support", "ChatGPT", "chatgpt_mcp_config.json");
74
+ }
75
+ if (process.platform === "win32") {
76
+ const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
77
+ return join(appData, "OpenAI", "ChatGPT", "chatgpt_mcp_config.json");
78
+ }
79
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
80
+ return join(xdgConfig, "ChatGPT", "chatgpt_mcp_config.json");
81
+ }
82
+
83
+ /** True when ChatGPT desktop app data exists (config file or app support directory). */
84
+ export function chatGptDesktopPresent(_home: string, configPath: string): boolean {
63
85
  return existsSync(configPath) || existsSync(dirname(configPath));
64
86
  }
65
87
 
@@ -71,6 +93,7 @@ export function resolveInstallPaths(root: CliProgram, opts: { prefix?: string })
71
93
  const skillDirName = sanitizeToolSegment(root.key);
72
94
  const xdgConfig = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
73
95
  const claudeDesktopMcpPath = resolveClaudeDesktopMcpPath(home);
96
+ const chatGptMcpPath = resolveChatGptMcpPath(home);
74
97
 
75
98
  return {
76
99
  bindir,
@@ -83,6 +106,9 @@ export function resolveInstallPaths(root: CliProgram, opts: { prefix?: string })
83
106
  cursorMcpPath: join(home, ".cursor", "mcp.json"),
84
107
  claudeMcpPath: join(home, ".claude.json"),
85
108
  claudeDesktopMcpPath,
109
+ opencodeMcpPath: resolveOpenCodeConfigPathForInstall(home),
110
+ chatGptMcpPath,
111
+ codexConfigPath: join(home, ".codex", "config.toml"),
86
112
  bashRc: join(home, ".bashrc"),
87
113
  zshRc: join(home, ".zshrc"),
88
114
  mcpName: mcpServerId(root),
@@ -1,12 +1,23 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { resolveCapabilities } from "../capabilities.ts";
4
- import { CliProgram } from "../types.ts";
4
+ import type { CliProgram } from "../types.ts";
5
5
  import { installBinary } from "./binary.ts";
6
6
  import { installCompletions } from "./completions.ts";
7
7
  import { detectInstalledArtifacts } from "./detect-installed.ts";
8
+ import { codexOnPath, mergeCodexMcpConfig } from "./mcp-codex.ts";
8
9
  import { expectedMcpEntry, mergeMcpConfig } from "./mcp-config.ts";
9
- import { InstallPaths, userHome, claudeDesktopPresent } from "./paths.ts";
10
+ import {
11
+ expectedOpenCodeMcpEntry,
12
+ mergeOpenCodeMcpConfig,
13
+ opencodePresent,
14
+ } from "./mcp-opencode.ts";
15
+ import {
16
+ chatGptDesktopPresent,
17
+ claudeDesktopPresent,
18
+ type InstallPaths,
19
+ userHome,
20
+ } from "./paths.ts";
10
21
  import { detectShells } from "./shell.ts";
11
22
 
12
23
  export interface InstallOpts {
@@ -34,7 +45,10 @@ export type InstallActionKind =
34
45
  | "claude-skill"
35
46
  | "cursor-mcp"
36
47
  | "claude-mcp"
37
- | "claude-desktop-mcp";
48
+ | "claude-desktop-mcp"
49
+ | "opencode-mcp"
50
+ | "codex-mcp"
51
+ | "chatgpt-desktop-mcp";
38
52
 
39
53
  export interface InstallAction {
40
54
  kind: InstallActionKind;
@@ -60,7 +74,11 @@ export function wantsInstallMcp(opts: InstallOpts, root: CliProgram): boolean {
60
74
  }
61
75
 
62
76
  /** Builds install actions for normal mode (--all / scoped targets). */
63
- export function buildInstallPlan(root: CliProgram, paths: InstallPaths, opts: InstallOpts): InstallAction[] {
77
+ export function buildInstallPlan(
78
+ root: CliProgram,
79
+ paths: InstallPaths,
80
+ opts: InstallOpts,
81
+ ): InstallAction[] {
64
82
  const actions: InstallAction[] = [];
65
83
  const dry = !!opts.dry;
66
84
 
@@ -164,20 +182,63 @@ export function buildInstallPlan(root: CliProgram, paths: InstallPaths, opts: In
164
182
  },
165
183
  });
166
184
  }
185
+ if (opencodePresent(userHome())) {
186
+ const openCodeEntry = expectedOpenCodeMcpEntry(root);
187
+ actions.push({
188
+ kind: "opencode-mcp",
189
+ summary: `opencode mcp: ${paths.opencodeMcpPath} (server "${paths.mcpName}")`,
190
+ message: `Merging MCP server "${paths.mcpName}" into ${paths.opencodeMcpPath}`,
191
+ run: () => {
192
+ mergeOpenCodeMcpConfig(paths.opencodeMcpPath, paths.mcpName, openCodeEntry, dry);
193
+ return [paths.opencodeMcpPath];
194
+ },
195
+ });
196
+ }
197
+ if (codexOnPath()) {
198
+ actions.push({
199
+ kind: "codex-mcp",
200
+ summary: `codex mcp: ${paths.codexConfigPath} (server "${paths.mcpName}")`,
201
+ message: `Registering MCP server "${paths.mcpName}" via codex mcp add`,
202
+ run: () => {
203
+ const configPath = mergeCodexMcpConfig(userHome(), paths.mcpName, entry, dry, !!opts.yes);
204
+ return [configPath];
205
+ },
206
+ });
207
+ }
208
+ if (chatGptDesktopPresent(userHome(), paths.chatGptMcpPath)) {
209
+ actions.push({
210
+ kind: "chatgpt-desktop-mcp",
211
+ summary: `chatgpt desktop mcp: ${paths.chatGptMcpPath} (server "${paths.mcpName}")`,
212
+ message: `Merging MCP server "${paths.mcpName}" into ${paths.chatGptMcpPath}`,
213
+ run: () => {
214
+ mergeMcpConfig(paths.chatGptMcpPath, paths.mcpName, entry, dry);
215
+ return [paths.chatGptMcpPath];
216
+ },
217
+ });
218
+ }
167
219
  }
168
220
 
169
221
  return actions;
170
222
  }
171
223
 
172
224
  /** Builds update actions for artifacts already installed. */
173
- export function buildUpdatePlan(root: CliProgram, paths: InstallPaths, opts: InstallOpts): InstallAction[] {
225
+ export function buildUpdatePlan(
226
+ root: CliProgram,
227
+ paths: InstallPaths,
228
+ opts: InstallOpts,
229
+ ): InstallAction[] {
174
230
  const detected = detectInstalledArtifacts(paths);
175
231
  const scoped: InstallOpts = {
176
232
  bin: true,
177
233
  completions: detected.bashCompletion || detected.zshCompletion || detected.fishCompletion,
178
234
  skill: detected.cursorSkill || detected.claudeSkill,
179
235
  mcp:
180
- (detected.cursorMcp || detected.claudeMcp || detected.claudeDesktopMcp) &&
236
+ (detected.cursorMcp ||
237
+ detected.claudeMcp ||
238
+ detected.claudeDesktopMcp ||
239
+ detected.opencodeMcp ||
240
+ detected.codexMcp ||
241
+ detected.chatGptMcp) &&
181
242
  resolveCapabilities(root).mcp,
182
243
  dry: opts.dry,
183
244
  };
@@ -202,6 +263,12 @@ export function buildUpdatePlan(root: CliProgram, paths: InstallPaths, opts: Ins
202
263
  return detected.claudeMcp;
203
264
  case "claude-desktop-mcp":
204
265
  return detected.claudeDesktopMcp;
266
+ case "opencode-mcp":
267
+ return detected.opencodeMcp;
268
+ case "codex-mcp":
269
+ return detected.codexMcp;
270
+ case "chatgpt-desktop-mcp":
271
+ return detected.chatGptMcp;
205
272
  default:
206
273
  return false;
207
274
  }
@@ -39,10 +39,7 @@ export function buildZshFpathRcBlock(appKey: string, completionsDir: string): st
39
39
  export function removeRcBlock(content: string, appKey: string, tag: string): string {
40
40
  const start = rcMarkerStart(appKey, tag);
41
41
  const end = rcMarkerEnd(appKey, tag);
42
- const re = new RegExp(
43
- `${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}\\n?`,
44
- "g",
45
- );
42
+ const re = new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}\\n?`, "g");
46
43
  return content.replace(re, "");
47
44
  }
48
45
 
@@ -1,22 +1,22 @@
1
- import { CliProgram } from "../types.ts";
1
+ import type { CliProgram } from "../types.ts";
2
2
  import { buildInstallStatus, detectInstalledArtifacts } from "./detect-installed.ts";
3
- import type { InstallOpts } from "./plan.ts";
4
3
  import { resolveInstallPaths } from "./paths.ts";
4
+ import type { InstallOpts } from "./plan.ts";
5
5
 
6
6
  export function installOut(msg: string, opts: InstallOpts): void {
7
7
  if (opts.quiet || opts.json) return;
8
- process.stdout.write(msg + "\n");
8
+ process.stdout.write(`${msg}\n`);
9
9
  }
10
10
 
11
11
  export function installInfo(msg: string, opts: InstallOpts): void {
12
12
  if (opts.quiet) return;
13
13
  if (opts.json && !opts.dry) return;
14
14
  const prefix = opts.dry ? "[dry run] " : "";
15
- process.stderr.write(prefix + msg + "\n");
15
+ process.stderr.write(`${prefix + msg}\n`);
16
16
  }
17
17
 
18
18
  export function installErr(msg: string): void {
19
- process.stderr.write(msg + "\n");
19
+ process.stderr.write(`${msg}\n`);
20
20
  }
21
21
 
22
22
  /** Prints install status to stdout (human or JSON). */
@@ -36,7 +36,10 @@ export function printInstallStatus(root: CliProgram, opts: InstallOpts): void {
36
36
  if (status.cursorMcp) json.cursorMcp = status.cursorMcp;
37
37
  if (status.claudeMcp) json.claudeMcp = status.claudeMcp;
38
38
  if (status.claudeDesktopMcp) json.claudeDesktopMcp = status.claudeDesktopMcp;
39
- process.stdout.write(JSON.stringify(json, null, 2) + "\n");
39
+ if (status.opencodeMcp) json.opencodeMcp = status.opencodeMcp;
40
+ if (status.codexMcp) json.codexMcp = status.codexMcp;
41
+ if (status.chatGptMcp) json.chatGptMcp = status.chatGptMcp;
42
+ process.stdout.write(`${JSON.stringify(json, null, 2)}\n`);
40
43
  return;
41
44
  }
42
45
 
@@ -51,6 +54,9 @@ export function printInstallStatus(root: CliProgram, opts: InstallOpts): void {
51
54
  ["cursor mcp", status.cursorMcp],
52
55
  ["claude code mcp", status.claudeMcp],
53
56
  ["claude desktop mcp", status.claudeDesktopMcp],
57
+ ["opencode mcp", status.opencodeMcp],
58
+ ["codex mcp", status.codexMcp],
59
+ ["chatgpt desktop mcp", status.chatGptMcp],
54
60
  ];
55
61
  let any = false;
56
62
  for (const [label, value] of lines) {