argsbarg 3.4.2 → 3.6.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.
- package/CHANGELOG.md +31 -1
- package/README.md +24 -8
- package/biome.json +29 -6
- package/bun.lock +22 -0
- package/docs/cli-program.md +75 -1
- package/docs/install.md +1 -1
- package/docs/mcp.md +45 -1
- package/docs/templates/cursor/rules/cli-program.mdc +15 -21
- package/index.d.ts +95 -50
- package/justfile +24 -6
- package/package.json +4 -2
- package/scripts/release.ts +26 -9
- package/src/builtins/builtins.test.ts +9 -4
- package/src/builtins/completion-bash.ts +74 -50
- package/src/builtins/completion-fish.ts +3 -8
- package/src/builtins/completion-group.ts +1 -1
- package/src/builtins/completion-zsh.ts +80 -42
- package/src/builtins/dispatch.ts +20 -16
- package/src/builtins/export.ts +19 -10
- package/src/builtins/index.ts +9 -4
- package/src/builtins/install.ts +10 -10
- package/src/builtins/mcp.ts +1 -1
- package/src/builtins/presentation.ts +8 -8
- package/src/builtins/scopes.ts +1 -1
- package/src/builtins/version.ts +1 -1
- package/src/completion.ts +4 -4
- package/src/context.ts +91 -15
- package/src/docs/api-guide.test.ts +2 -2
- package/src/docs/api-guide.ts +19 -5
- package/src/docs/builtin.ts +27 -8
- package/src/docs/docs.test.ts +18 -11
- package/src/docs/mcp-guide.ts +108 -25
- package/src/docs/resolve.ts +10 -3
- package/src/docs/save.ts +11 -3
- package/src/formats.test.ts +35 -0
- package/src/formats.ts +135 -0
- package/src/headless.test.ts +8 -16
- package/src/help.ts +73 -43
- package/src/hidden-mcpb.test.ts +7 -6
- package/src/hidden.ts +2 -2
- package/src/index.test.ts +120 -96
- package/src/index.ts +36 -24
- package/src/install/binary.ts +12 -5
- package/src/install/completions.ts +7 -3
- package/src/install/detect-installed.ts +29 -4
- package/src/install/gh-release-update.ts +31 -23
- package/src/install/index.ts +69 -19
- package/src/install/install.test.ts +31 -8
- package/src/install/mcp-codex.test.ts +57 -0
- package/src/install/mcp-codex.ts +125 -0
- package/src/install/mcp-config.ts +12 -5
- package/src/install/mcp-opencode.test.ts +98 -0
- package/src/install/mcp-opencode.ts +149 -0
- package/src/install/paths.ts +29 -3
- package/src/install/plan.ts +73 -6
- package/src/install/shell.ts +1 -4
- package/src/install/status.ts +12 -6
- package/src/install/uninstall.ts +38 -4
- package/src/install/update.test.ts +2 -2
- package/src/install/update.ts +3 -1
- package/src/invoke.ts +12 -9
- package/src/mcp/bundle.ts +36 -8
- package/src/mcp/env.ts +7 -13
- package/src/mcp/server.ts +12 -6
- package/src/mcp/tools.ts +83 -18
- package/src/mcp.ts +3 -3
- package/src/parse.ts +129 -27
- package/src/runtime.ts +22 -12
- package/src/schema.ts +11 -5
- package/src/skill/generate.ts +4 -4
- package/src/skill/install.ts +6 -2
- package/src/types.ts +24 -0
- package/src/validate.ts +75 -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 {
|
|
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(
|
|
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)
|
|
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)
|
|
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
|
+
}
|
package/src/install/paths.ts
CHANGED
|
@@ -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 {
|
|
5
|
-
import {
|
|
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(
|
|
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),
|
package/src/install/plan.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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(
|
|
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 ||
|
|
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
|
}
|
package/src/install/shell.ts
CHANGED
|
@@ -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
|
|
package/src/install/status.ts
CHANGED
|
@@ -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
|
|
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
|
|
15
|
+
process.stderr.write(`${prefix + msg}\n`);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export function installErr(msg: string): void {
|
|
19
|
-
process.stderr.write(msg
|
|
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
|
-
|
|
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) {
|