argsbarg 3.4.1 → 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.
- package/CHANGELOG.md +26 -1
- package/biome.json +29 -6
- package/bun.lock +22 -0
- package/docs/install.md +1 -1
- package/docs/mcp.md +63 -3
- package/index.d.ts +51 -51
- package/justfile +27 -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 +3 -3
- 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/docs/api-guide.test.ts +2 -2
- package/src/docs/api-guide.ts +2 -2
- package/src/docs/builtin.ts +27 -8
- package/src/docs/docs.test.ts +23 -12
- package/src/docs/mcp-guide.ts +112 -11
- package/src/docs/resolve.ts +10 -3
- package/src/docs/save.ts +11 -3
- package/src/headless.test.ts +8 -16
- package/src/help.ts +73 -43
- package/src/hidden-mcpb.test.ts +8 -10
- package/src/hidden.ts +2 -2
- package/src/index.test.ts +113 -89
- package/src/index.ts +24 -24
- package/src/install/binary.ts +12 -5
- package/src/install/completions.ts +7 -3
- package/src/install/detect-installed.ts +35 -4
- package/src/install/gh-release-update.ts +31 -23
- package/src/install/index.ts +69 -19
- package/src/install/install.test.ts +57 -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 +51 -3
- package/src/install/plan.ts +96 -7
- package/src/install/shell.ts +1 -4
- package/src/install/status.ts +15 -7
- package/src/install/uninstall.ts +49 -5
- 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 +38 -14
- package/src/mcp/env.ts +7 -13
- package/src/mcp/server.ts +12 -6
- package/src/mcp/tools.ts +20 -4
- package/src/mcp.ts +3 -3
- package/src/parse.ts +96 -24
- 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/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 {
|
|
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,7 +1,9 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { homedir } from "node:os";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { mcpServerId, sanitizeToolSegment } from "../mcp/tools.ts";
|
|
5
|
+
import type { CliProgram } from "../types.ts";
|
|
6
|
+
import { resolveOpenCodeConfigPathForInstall } from "./mcp-opencode.ts";
|
|
5
7
|
|
|
6
8
|
export interface InstallPaths {
|
|
7
9
|
bindir: string;
|
|
@@ -13,6 +15,10 @@ export interface InstallPaths {
|
|
|
13
15
|
claudeSkillDir: string;
|
|
14
16
|
cursorMcpPath: string;
|
|
15
17
|
claudeMcpPath: string;
|
|
18
|
+
claudeDesktopMcpPath: string;
|
|
19
|
+
opencodeMcpPath: string;
|
|
20
|
+
chatGptMcpPath: string;
|
|
21
|
+
codexConfigPath: string;
|
|
16
22
|
bashRc: string;
|
|
17
23
|
zshRc: string;
|
|
18
24
|
mcpName: string;
|
|
@@ -43,6 +49,42 @@ export function resolveBindir(root: CliProgram, prefix?: string): string {
|
|
|
43
49
|
return join(userHome(), ".local", "bin");
|
|
44
50
|
}
|
|
45
51
|
|
|
52
|
+
/** Resolves Claude Desktop `claude_desktop_config.json` for the current OS. */
|
|
53
|
+
export function resolveClaudeDesktopMcpPath(home: string): string {
|
|
54
|
+
if (process.platform === "darwin") {
|
|
55
|
+
return join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
56
|
+
}
|
|
57
|
+
if (process.platform === "win32") {
|
|
58
|
+
const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
|
|
59
|
+
return join(appData, "Claude", "claude_desktop_config.json");
|
|
60
|
+
}
|
|
61
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
|
|
62
|
+
return join(xdgConfig, "Claude", "claude_desktop_config.json");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** True when Claude Desktop app data exists (config file or app support directory). */
|
|
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 {
|
|
85
|
+
return existsSync(configPath) || existsSync(dirname(configPath));
|
|
86
|
+
}
|
|
87
|
+
|
|
46
88
|
/** Resolves all install artifact paths for a program root. */
|
|
47
89
|
export function resolveInstallPaths(root: CliProgram, opts: { prefix?: string }): InstallPaths {
|
|
48
90
|
const home = userHome();
|
|
@@ -50,6 +92,8 @@ export function resolveInstallPaths(root: CliProgram, opts: { prefix?: string })
|
|
|
50
92
|
const key = root.key;
|
|
51
93
|
const skillDirName = sanitizeToolSegment(root.key);
|
|
52
94
|
const xdgConfig = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
|
|
95
|
+
const claudeDesktopMcpPath = resolveClaudeDesktopMcpPath(home);
|
|
96
|
+
const chatGptMcpPath = resolveChatGptMcpPath(home);
|
|
53
97
|
|
|
54
98
|
return {
|
|
55
99
|
bindir,
|
|
@@ -61,6 +105,10 @@ export function resolveInstallPaths(root: CliProgram, opts: { prefix?: string })
|
|
|
61
105
|
claudeSkillDir: join(home, ".claude", "skills", skillDirName),
|
|
62
106
|
cursorMcpPath: join(home, ".cursor", "mcp.json"),
|
|
63
107
|
claudeMcpPath: join(home, ".claude.json"),
|
|
108
|
+
claudeDesktopMcpPath,
|
|
109
|
+
opencodeMcpPath: resolveOpenCodeConfigPathForInstall(home),
|
|
110
|
+
chatGptMcpPath,
|
|
111
|
+
codexConfigPath: join(home, ".codex", "config.toml"),
|
|
64
112
|
bashRc: join(home, ".bashrc"),
|
|
65
113
|
zshRc: join(home, ".zshrc"),
|
|
66
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 {
|
|
@@ -27,7 +38,17 @@ export interface InstallOpts {
|
|
|
27
38
|
prefix?: string;
|
|
28
39
|
}
|
|
29
40
|
|
|
30
|
-
export type InstallActionKind =
|
|
41
|
+
export type InstallActionKind =
|
|
42
|
+
| "binary"
|
|
43
|
+
| "completions"
|
|
44
|
+
| "cursor-skill"
|
|
45
|
+
| "claude-skill"
|
|
46
|
+
| "cursor-mcp"
|
|
47
|
+
| "claude-mcp"
|
|
48
|
+
| "claude-desktop-mcp"
|
|
49
|
+
| "opencode-mcp"
|
|
50
|
+
| "codex-mcp"
|
|
51
|
+
| "chatgpt-desktop-mcp";
|
|
31
52
|
|
|
32
53
|
export interface InstallAction {
|
|
33
54
|
kind: InstallActionKind;
|
|
@@ -53,7 +74,11 @@ export function wantsInstallMcp(opts: InstallOpts, root: CliProgram): boolean {
|
|
|
53
74
|
}
|
|
54
75
|
|
|
55
76
|
/** Builds install actions for normal mode (--all / scoped targets). */
|
|
56
|
-
export function buildInstallPlan(
|
|
77
|
+
export function buildInstallPlan(
|
|
78
|
+
root: CliProgram,
|
|
79
|
+
paths: InstallPaths,
|
|
80
|
+
opts: InstallOpts,
|
|
81
|
+
): InstallAction[] {
|
|
57
82
|
const actions: InstallAction[] = [];
|
|
58
83
|
const dry = !!opts.dry;
|
|
59
84
|
|
|
@@ -139,26 +164,82 @@ export function buildInstallPlan(root: CliProgram, paths: InstallPaths, opts: In
|
|
|
139
164
|
}
|
|
140
165
|
actions.push({
|
|
141
166
|
kind: "claude-mcp",
|
|
142
|
-
summary: `claude mcp: ${paths.claudeMcpPath} (server "${paths.mcpName}")`,
|
|
167
|
+
summary: `claude code mcp: ${paths.claudeMcpPath} (server "${paths.mcpName}")`,
|
|
143
168
|
message: `Merging MCP server "${paths.mcpName}" into ${paths.claudeMcpPath}`,
|
|
144
169
|
run: () => {
|
|
145
170
|
mergeMcpConfig(paths.claudeMcpPath, paths.mcpName, entry, dry);
|
|
146
171
|
return [paths.claudeMcpPath];
|
|
147
172
|
},
|
|
148
173
|
});
|
|
174
|
+
if (claudeDesktopPresent(userHome(), paths.claudeDesktopMcpPath)) {
|
|
175
|
+
actions.push({
|
|
176
|
+
kind: "claude-desktop-mcp",
|
|
177
|
+
summary: `claude desktop mcp: ${paths.claudeDesktopMcpPath} (server "${paths.mcpName}")`,
|
|
178
|
+
message: `Merging MCP server "${paths.mcpName}" into ${paths.claudeDesktopMcpPath}`,
|
|
179
|
+
run: () => {
|
|
180
|
+
mergeMcpConfig(paths.claudeDesktopMcpPath, paths.mcpName, entry, dry);
|
|
181
|
+
return [paths.claudeDesktopMcpPath];
|
|
182
|
+
},
|
|
183
|
+
});
|
|
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
|
+
}
|
|
149
219
|
}
|
|
150
220
|
|
|
151
221
|
return actions;
|
|
152
222
|
}
|
|
153
223
|
|
|
154
224
|
/** Builds update actions for artifacts already installed. */
|
|
155
|
-
export function buildUpdatePlan(
|
|
225
|
+
export function buildUpdatePlan(
|
|
226
|
+
root: CliProgram,
|
|
227
|
+
paths: InstallPaths,
|
|
228
|
+
opts: InstallOpts,
|
|
229
|
+
): InstallAction[] {
|
|
156
230
|
const detected = detectInstalledArtifacts(paths);
|
|
157
231
|
const scoped: InstallOpts = {
|
|
158
232
|
bin: true,
|
|
159
233
|
completions: detected.bashCompletion || detected.zshCompletion || detected.fishCompletion,
|
|
160
234
|
skill: detected.cursorSkill || detected.claudeSkill,
|
|
161
|
-
mcp:
|
|
235
|
+
mcp:
|
|
236
|
+
(detected.cursorMcp ||
|
|
237
|
+
detected.claudeMcp ||
|
|
238
|
+
detected.claudeDesktopMcp ||
|
|
239
|
+
detected.opencodeMcp ||
|
|
240
|
+
detected.codexMcp ||
|
|
241
|
+
detected.chatGptMcp) &&
|
|
242
|
+
resolveCapabilities(root).mcp,
|
|
162
243
|
dry: opts.dry,
|
|
163
244
|
};
|
|
164
245
|
const plan = buildInstallPlan(root, paths, scoped);
|
|
@@ -180,6 +261,14 @@ export function buildUpdatePlan(root: CliProgram, paths: InstallPaths, opts: Ins
|
|
|
180
261
|
return detected.cursorMcp;
|
|
181
262
|
case "claude-mcp":
|
|
182
263
|
return detected.claudeMcp;
|
|
264
|
+
case "claude-desktop-mcp":
|
|
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;
|
|
183
272
|
default:
|
|
184
273
|
return false;
|
|
185
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
|
|