@mergesafe-io/connect 0.3.0 → 0.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/README.md CHANGED
@@ -2,14 +2,14 @@
2
2
 
3
3
  One command connects every AI client on your machine to the
4
4
  [MergeSafe](https://app.mergesafe.io) MCP server — globally, so it works in
5
- every project. No JSON editing.
5
+ every project. No config editing.
6
6
 
7
7
  ```bash
8
8
  npx @mergesafe-io/connect --key msk_your_api_key
9
9
  ```
10
10
 
11
11
  That's it. The CLI detects which clients are installed (Claude Code, Claude
12
- Desktop, Cursor, Windsurf, VS Code, …), writes each one's **global** config,
12
+ Desktop, Codex, Cursor, Windsurf, VS Code, …), writes each one's **global** config,
13
13
  and backs up every file it touches. Restart your clients and the `mergesafe`
14
14
  tools are available everywhere.
15
15
 
@@ -28,9 +28,9 @@ That removes only the mergesafe entry — all other configs are preserved.
28
28
  - Finds the right **global** MCP config file for each client and OS
29
29
  - Backs up the existing file next to it (`*.bak-<timestamp>`)
30
30
  - Merges in the `mergesafe` server entry — every other setting and server is preserved
31
- - Refuses to touch a file it can't parse (broken JSON aborts with a clear message)
31
+ - Refuses to touch a file it can't parse (broken JSON or TOML aborts with a clear message)
32
32
 
33
- No dependencies, no telemetry, Node 18+.
33
+ No telemetry, Node 18.17+. Codex TOML updates use `@decimalturn/toml-patch` to preserve comments and unrelated settings.
34
34
 
35
35
  ## Supported clients
36
36
 
@@ -38,6 +38,7 @@ No dependencies, no telemetry, Node 18+.
38
38
  |---|---|
39
39
  | `claude-code` | `~/.claude.json` (user scope — every project and session) |
40
40
  | `claude-desktop` | `claude_desktop_config.json` (per-OS path) — bridges through `mcp-remote` |
41
+ | `codex` | `~/.codex/config.toml` (or `$CODEX_HOME/config.toml`; `--project` for `./.codex/config.toml`) |
41
42
  | `cursor` | `~/.cursor/mcp.json` (`--project` for `./.cursor/mcp.json`) |
42
43
  | `windsurf` | `~/.codeium/windsurf/mcp_config.json` |
43
44
  | `vscode` | user-profile `mcp.json` (`--project` for `./.vscode/mcp.json`) |
@@ -50,13 +51,47 @@ Not covered: Zed (its `settings.json` is JSONC with comments — a rewrite would
50
51
  destroy them; configure manually). Gemini CLI: use its built-in command:
51
52
  `gemini mcp add --transport http mergesafe https://mcp.mergesafe.io/mcp -H "Authorization: Bearer msk_…"`
52
53
 
54
+ ## Codex
55
+
56
+ ```bash
57
+ npx @mergesafe-io/connect@latest codex --key msk_your_api_key
58
+ ```
59
+
60
+ The [official Codex MCP documentation](https://developers.openai.com/codex/mcp/)
61
+ specifies `~/.codex/config.toml` for the shared user configuration and
62
+ `.codex/config.toml` for trusted projects. If `CODEX_HOME` is set, the user
63
+ configuration is written to `$CODEX_HOME/config.toml`, following the
64
+ [Codex configuration documentation](https://learn.chatgpt.com/docs/config-file/config-advanced).
65
+ The CLI, desktop app, and IDE extension share this configuration.
66
+
67
+ The installer adds a Streamable HTTP connection with your API key:
68
+
69
+ ```toml
70
+ [mcp_servers.mergesafe]
71
+ url = "https://mcp.mergesafe.io/mcp"
72
+ http_headers = { Authorization = "Bearer msk_your_api_key" }
73
+ ```
74
+
75
+ Existing Codex settings, comments, and other MCP servers are preserved.
76
+ Restart Codex or reload the IDE extension, then check `/mcp`.
77
+ To install only in a trusted project:
78
+
79
+ ```bash
80
+ npx @mergesafe-io/connect@latest codex --project --key msk_your_api_key
81
+ ```
82
+
83
+ To remove only MergeSafe from the Codex user configuration:
84
+
85
+ ```bash
86
+ npx @mergesafe-io/connect@latest codex --uninstall
87
+ ```
88
+
53
89
  ## Options
54
90
 
55
91
  | Flag | Effect |
56
92
  |---|---|
57
93
  | `--key <msk_…>` | Your MergeSafe API key (from app.mergesafe.io → Settings). Prompted if omitted. |
58
- | `--project` | Write the project-local config instead of the global one (cursor, vscode, roo-code, continue — needs an explicit client) |
59
- | `--with-rules` | Also write agent rules files (`.cursor/rules/mergesafe.mdc`, `AGENTS.md` block) so the AI runs MergeSafe after coding |
94
+ | `--project` | Write the project-local config instead of the global one (codex, cursor, vscode, roo-code, continue — needs an explicit client) |
60
95
  | `--dry-run` | Print the target paths and resulting files without writing |
61
96
  | `--uninstall` | Remove the `mergesafe` entry (no client = every detected client) |
62
97
  | `--help` | Usage |
@@ -64,7 +99,8 @@ destroy them; configure manually). Gemini CLI: use its built-in command:
64
99
  ## Development
65
100
 
66
101
  ```bash
67
- npm test # node --test, zero deps
102
+ npm ci
103
+ npm test # node --test
68
104
  node bin/mergesafe-connect.js --key msk_x --dry-run # auto-detect
69
105
  node bin/mergesafe-connect.js cursor --key msk_x --dry-run # single client
70
106
  ```
package/lib/actions.js CHANGED
@@ -4,22 +4,20 @@ import path from "node:path";
4
4
 
5
5
  import { SERVER_NAME } from "./clients.js";
6
6
  import { applyEntry, removeEntry } from "./config-file.js";
7
- import { writeRulesFiles } from "./rules.js";
7
+ import { applyTomlEntry, removeTomlEntry } from "./toml-config.js";
8
8
 
9
- export async function handleInstall(client, key, configPath, dryRun, withRules, cwd) {
9
+ export async function handleInstall(client, key, configPath, dryRun) {
10
10
  const existingText = await readIfExists(configPath);
11
11
 
12
+ const mergeEntry = client.configFormat === "toml" ? applyTomlEntry : applyEntry;
12
13
  const newText = client.buildFileContent
13
14
  ? // Dedicated-file clients (Continue): whole-file write
14
15
  client.buildFileContent(key)
15
- : // Merge via applyEntry — only the mergesafe entry is touched
16
- applyEntry(existingText ?? "", client.rootKey, SERVER_NAME, client.buildEntry(key));
16
+ : // Merge only the mergesafe entry in the client’s config format
17
+ mergeEntry(existingText ?? "", client.rootKey, SERVER_NAME, client.buildEntry(key));
17
18
 
18
19
  if (dryRun) {
19
20
  console.log(`Would write ${configPath}:\n\n${newText}`);
20
- if (withRules) {
21
- console.log("Would also write agent rules files (.cursor/rules/mergesafe.mdc, AGENTS.md).");
22
- }
23
21
  return;
24
22
  }
25
23
 
@@ -29,10 +27,6 @@ export async function handleInstall(client, key, configPath, dryRun, withRules,
29
27
 
30
28
  console.log(`✔ ${client.label} is connected to MergeSafe (${configPath})`);
31
29
  console.log(`→ ${client.restartHint}`);
32
-
33
- if (withRules) {
34
- await reportRules(cwd);
35
- }
36
30
  }
37
31
 
38
32
  export async function handleUninstall(client, configPath, dryRun) {
@@ -57,7 +51,8 @@ export async function handleUninstall(client, configPath, dryRun) {
57
51
  throw new Error(`Client "${client.label}" does not support --uninstall`);
58
52
  }
59
53
 
60
- const newText = removeEntry(existingText, client.rootKey, SERVER_NAME);
54
+ const deleteEntry = client.configFormat === "toml" ? removeTomlEntry : removeEntry;
55
+ const newText = deleteEntry(existingText, client.rootKey, SERVER_NAME);
61
56
 
62
57
  if (newText === null) {
63
58
  console.log(`⚠ No mergesafe entry found in ${configPath} — nothing to remove.`);
@@ -75,14 +70,6 @@ export async function handleUninstall(client, configPath, dryRun) {
75
70
  console.log(`→ ${client.restartHint}`);
76
71
  }
77
72
 
78
- export async function reportRules(cwd) {
79
- const written = await writeRulesFiles({ cwd });
80
- for (const file of written) {
81
- console.log(`✔ Rules written: ${file}`);
82
- }
83
- console.log("→ Your AI agent will now run MergeSafe after each coding session.");
84
- }
85
-
86
73
  async function backupIfPresent(existingText, configPath) {
87
74
  if (existingText === null) return;
88
75
  const backupPath = `${configPath}.bak-${timestamp()}`;
package/lib/cli.js CHANGED
@@ -3,7 +3,7 @@ import os from "node:os";
3
3
  import readline from "node:readline/promises";
4
4
  import { parseArgs } from "node:util";
5
5
 
6
- import { handleInstall, handleUninstall, reportRules } from "./actions.js";
6
+ import { handleInstall, handleUninstall } from "./actions.js";
7
7
  import { SERVER_NAME, getClient, listClientIds } from "./clients.js";
8
8
  import { detectClients } from "./detect.js";
9
9
 
@@ -17,6 +17,7 @@ the "${SERVER_NAME}" entry is touched.
17
17
  Clients (all global unless noted):
18
18
  claude-code Claude Code — ~/.claude.json (user scope, every project)
19
19
  claude-desktop Claude Desktop — claude_desktop_config.json (via mcp-remote bridge)
20
+ codex Codex — ~/.codex/config.toml (or $CODEX_HOME/config.toml; --project for ./.codex/config.toml)
20
21
  cursor Cursor — ~/.cursor/mcp.json (--project for ./.cursor/mcp.json)
21
22
  windsurf Windsurf — ~/.codeium/windsurf/mcp_config.json
22
23
  vscode VS Code — user-profile mcp.json (--project for ./.vscode/mcp.json)
@@ -29,9 +30,7 @@ Options:
29
30
  --key <msk_...> MergeSafe API key (create one on app.mergesafe.io/dashboard/settings;
30
31
  prompted interactively if omitted)
31
32
  --project Write the project-local config instead of the global one
32
- (cursor, vscode, roo-code, continue — needs an explicit client)
33
- --with-rules Also write agent rules files (.cursor/rules/mergesafe.mdc,
34
- AGENTS.md block) so the AI runs MergeSafe after coding
33
+ (codex, cursor, vscode, roo-code, continue — needs an explicit client)
35
34
  --dry-run Print the target path and resulting file without writing
36
35
  --uninstall Remove the mergesafe entry (no client = every detected client)
37
36
  --help Show usage
@@ -44,7 +43,6 @@ export async function main(argv) {
44
43
  options: {
45
44
  key: { type: "string" },
46
45
  project: { type: "boolean", default: false },
47
- "with-rules": { type: "boolean", default: false },
48
46
  "dry-run": { type: "boolean", default: false },
49
47
  uninstall: { type: "boolean", default: false },
50
48
  help: { type: "boolean", default: false },
@@ -77,7 +75,7 @@ export async function main(argv) {
77
75
  await handleUninstall(client, configPath, values["dry-run"]);
78
76
  } else {
79
77
  const key = await resolveKey(values.key);
80
- await handleInstall(client, key, configPath, values["dry-run"], values["with-rules"], ctx.cwd);
78
+ await handleInstall(client, key, configPath, values["dry-run"]);
81
79
  }
82
80
  }
83
81
 
@@ -106,7 +104,7 @@ async function runAllDetected(values, ctx) {
106
104
  if (values.uninstall) {
107
105
  await handleUninstall(client, client.configPath(ctx), values["dry-run"]);
108
106
  } else {
109
- await handleInstall(client, key, client.configPath(ctx), values["dry-run"], false, ctx.cwd);
107
+ await handleInstall(client, key, client.configPath(ctx), values["dry-run"]);
110
108
  }
111
109
  } catch (err) {
112
110
  failures.push(`${client.label}: ${err.message}`);
@@ -114,9 +112,6 @@ async function runAllDetected(values, ctx) {
114
112
  console.log("");
115
113
  }
116
114
 
117
- if (values["with-rules"] && !values.uninstall && !values["dry-run"]) {
118
- await reportRules(ctx.cwd);
119
- }
120
115
  reportOutcome(detected.length, failures, values.uninstall);
121
116
  }
122
117
 
package/lib/clients.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** Per-client knowledge: where the MCP config lives and what to write in it. */
2
2
  import path from "node:path";
3
3
 
4
- import { claudeDesktopConfigPath, vscodeUserDir } from "./paths.js";
4
+ import { claudeDesktopConfigPath, codexUserDir, vscodeUserDir } from "./paths.js";
5
5
 
6
6
  export const MCP_HTTP_URL = "https://mcp.mergesafe.io/mcp";
7
7
  // Legacy endpoint, kept for clients without solid Streamable HTTP support
@@ -47,6 +47,20 @@ const CLIENTS = {
47
47
  }),
48
48
  restartHint: "Fully quit and reopen Claude Desktop (needs Node.js 18+ for npx).",
49
49
  },
50
+ codex: {
51
+ label: "Codex",
52
+ configFormat: "toml",
53
+ rootKey: "mcp_servers",
54
+ // Official configuration: https://developers.openai.com/codex/mcp/
55
+ configPath: (ctx) =>
56
+ ctx.project
57
+ ? path.join(ctx.cwd, ".codex", "config.toml")
58
+ : path.join(codexUserDir(ctx), "config.toml"),
59
+ detectPaths: (ctx) => [codexUserDir(ctx)],
60
+ deleteKey: true,
61
+ buildEntry: (key) => ({ url: MCP_HTTP_URL, http_headers: bearer(key) }),
62
+ restartHint: "Restart Codex or reload the IDE extension, then check /mcp. Project configs require a trusted project.",
63
+ },
50
64
  cursor: {
51
65
  label: "Cursor",
52
66
  rootKey: "mcpServers",
package/lib/paths.js CHANGED
@@ -27,3 +27,10 @@ export function claudeDesktopConfigPath(ctx) {
27
27
  const configHome = ctx.env.XDG_CONFIG_HOME || path.join(ctx.home, ".config");
28
28
  return path.join(configHome, "Claude", file);
29
29
  }
30
+
31
+ /** Codex's shared user configuration directory (CLI, app, and IDE extension).
32
+ * https://learn.chatgpt.com/docs/config-file/config-advanced
33
+ */
34
+ export function codexUserDir(ctx) {
35
+ return ctx.env.CODEX_HOME || path.join(ctx.home, ".codex");
36
+ }
@@ -0,0 +1,91 @@
1
+ /** Edit only one MCP entry, preserving other TOML settings and comments. */
2
+ import { parse, patch, TomlDocument } from "@decimalturn/toml-patch";
3
+
4
+ export function applyTomlEntry(text, rootKey, serverName, entry) {
5
+ const config = parseConfig(text);
6
+ const section = config[rootKey];
7
+ if (section !== undefined && !isTable(section)) {
8
+ throw new Error(`Existing config's ${rootKey} is not a TOML table. Nothing was changed.`);
9
+ }
10
+ config[rootKey] = { ...section, [serverName]: entry };
11
+ return patchConfig(text, config, rootKey, serverName, !section || !Object.hasOwn(section, serverName));
12
+ }
13
+
14
+ export function removeTomlEntry(text, rootKey, serverName) {
15
+ const config = parseConfig(text);
16
+ const section = config[rootKey];
17
+ if (!isTable(section) || !Object.hasOwn(section, serverName)) return null;
18
+
19
+ delete section[serverName];
20
+ if (Object.keys(section).length === 0) delete config[rootKey];
21
+ return patchConfig(text, config, rootKey, serverName);
22
+ }
23
+
24
+ function isTable(value) {
25
+ return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date);
26
+ }
27
+
28
+ function parseConfig(text) {
29
+ try {
30
+ return parse(text);
31
+ } catch {
32
+ // Parser errors can contain source lines with API keys; do not print them.
33
+ throw new Error("Existing config is not valid TOML. Nothing was changed — fix or move the file, then re-run.");
34
+ }
35
+ }
36
+
37
+ function patchConfig(text, config, rootKey, serverName, newEntry = false) {
38
+ try {
39
+ const bom = text.startsWith("\uFEFF") ? "\uFEFF" : "";
40
+ if (bom) text = text.slice(1);
41
+ // toml-patch attaches comments before a header to the preceding table.
42
+ // Keep other tables' leading comments out of the entry being replaced.
43
+ const comments = tableAnchors(text).filter(({ keys, commentStart, start }) =>
44
+ !(keys[0] === rootKey && (keys[1] === serverName || !Object.hasOwn(config, rootKey))) &&
45
+ commentStart < start,
46
+ ).map((anchor) => ({ ...anchor, text: text.slice(anchor.commentStart, anchor.start) }));
47
+ for (const comment of comments.slice().reverse()) {
48
+ text = text.slice(0, comment.commentStart) + text.slice(comment.start);
49
+ }
50
+ let updated = patch(text, config, newEntry ? { inlineTableStart: 2 } : undefined);
51
+ const anchors = new Map(tableAnchors(updated).map((anchor) => [anchor.id, anchor.start]));
52
+ for (const comment of comments.slice().reverse()) {
53
+ const start = anchors.get(comment.id);
54
+ if (start === undefined) throw new Error("An unrelated table was removed");
55
+ updated = updated.slice(0, start) + comment.text + updated.slice(start);
56
+ }
57
+ parse(updated);
58
+ return bom + (updated.endsWith("\n") ? updated : `${updated}\n`);
59
+ } catch {
60
+ throw new Error("Could not update the TOML config. Nothing was changed — fix or move the file, then re-run.");
61
+ }
62
+ }
63
+
64
+ /** Locate real TOML headers and their standalone leading comments via the CST. */
65
+ function tableAnchors(text) {
66
+ const nodes = new TomlDocument(text).cst;
67
+ const lines = [0];
68
+ for (let i = 0; i < text.length; i++) if (text[i] === "\n") lines.push(i + 1);
69
+ const occurrences = new Map();
70
+ const anchors = [];
71
+ for (let i = 0; i < nodes.length; i++) {
72
+ const table = nodes[i];
73
+ if (table.type !== "Table" && table.type !== "TableArray") continue;
74
+ const keys = table.key.item.value;
75
+ const key = JSON.stringify([table.type, keys]);
76
+ const occurrence = occurrences.get(key) ?? 0;
77
+ occurrences.set(key, occurrence + 1);
78
+ const start = lines[table.loc.start.line - 1];
79
+ const preceding = nodes[i - 1]?.items ?? nodes.slice(0, i);
80
+ let commentStart = start;
81
+ for (let j = preceding.length - 1; j >= 0; j--) {
82
+ const item = preceding[j];
83
+ if (item.type !== "Comment") break;
84
+ const lineStart = lines[item.loc.start.line - 1];
85
+ if (text.slice(lineStart, lineStart + item.loc.start.column).trim()) break;
86
+ commentStart = lineStart;
87
+ }
88
+ anchors.push({ id: `${key}:${occurrence}`, keys, start, commentStart });
89
+ }
90
+ return anchors;
91
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mergesafe-io/connect",
3
- "version": "0.3.0",
4
- "description": "One command connects every AI client on your machine to MergeSafe MCP — Claude Code, Claude Desktop, Cursor, Windsurf, VS Code and more, globally.",
3
+ "version": "0.5.0",
4
+ "description": "One command connects every AI client on your machine to MergeSafe MCP — Claude Code, Claude Desktop, Codex, Cursor, Windsurf, VS Code and more, globally.",
5
5
  "keywords": ["mergesafe", "mcp", "model-context-protocol", "installer"],
6
6
  "homepage": "https://app.mergesafe.io",
7
7
  "license": "MIT",
@@ -18,5 +18,8 @@
18
18
  },
19
19
  "scripts": {
20
20
  "test": "node --test"
21
+ },
22
+ "dependencies": {
23
+ "@decimalturn/toml-patch": "3.1.2"
21
24
  }
22
25
  }
package/lib/rules.js DELETED
@@ -1,80 +0,0 @@
1
- /** Agent rules files: make the AI call MergeSafe after every coding session.
2
- *
3
- * MCP server instructions are honored unevenly across clients; project rules
4
- * files are the reliable trigger. This module owns:
5
- * - .cursor/rules/mergesafe.mdc (whole file, alwaysApply)
6
- * - AGENTS.md / CLAUDE.md (one marked block, upserted in place)
7
- */
8
- import fs from "node:fs/promises";
9
- import path from "node:path";
10
-
11
- export const RULES_START = "<!-- mergesafe:start -->";
12
- export const RULES_END = "<!-- mergesafe:end -->";
13
-
14
- const RULE_TEXT = `After generating or modifying code in this project, run the MergeSafe MCP tool \`analyze_directory\` on the project root before you conclude, and report its verdict. If \`release_status\` is "blocked", fix the listed blockers (each finding ships a ready-to-use fix prompt) and re-run the check before declaring the work done.`;
15
-
16
- const CURSOR_RULE = `---
17
- description: MergeSafe post-generation check
18
- alwaysApply: true
19
- ---
20
-
21
- ${RULE_TEXT}
22
- `;
23
-
24
- const MARKDOWN_BLOCK = `${RULES_START}
25
- ## MergeSafe check
26
-
27
- ${RULE_TEXT}
28
- ${RULES_END}`;
29
-
30
- /** Upsert the marked MergeSafe block into a markdown document (idempotent). */
31
- export function applyRulesBlock(existingText) {
32
- const text = existingText ?? "";
33
- const start = text.indexOf(RULES_START);
34
- const end = text.indexOf(RULES_END);
35
- if (start !== -1 && end !== -1 && end > start) {
36
- return text.slice(0, start) + MARKDOWN_BLOCK + text.slice(end + RULES_END.length);
37
- }
38
- if (!text.trim()) return `${MARKDOWN_BLOCK}\n`;
39
- return `${text.replace(/\n*$/, "")}\n\n${MARKDOWN_BLOCK}\n`;
40
- }
41
-
42
- async function readIfExists(filePath) {
43
- try {
44
- return await fs.readFile(filePath, "utf8");
45
- } catch (err) {
46
- if (err.code === "ENOENT") return null;
47
- throw err;
48
- }
49
- }
50
-
51
- /**
52
- * Write the rules files for a project.
53
- *
54
- * AGENTS.md is always upserted (created if missing — it is the cross-agent
55
- * standard); CLAUDE.md is only updated when it already exists, so we never
56
- * plant a client-specific file uninvited.
57
- *
58
- * @returns {Promise<string[]>} the paths written.
59
- */
60
- export async function writeRulesFiles({ cwd }) {
61
- const written = [];
62
-
63
- const mdcPath = path.join(cwd, ".cursor", "rules", "mergesafe.mdc");
64
- await fs.mkdir(path.dirname(mdcPath), { recursive: true });
65
- await fs.writeFile(mdcPath, CURSOR_RULE, "utf8");
66
- written.push(mdcPath);
67
-
68
- const agentsPath = path.join(cwd, "AGENTS.md");
69
- await fs.writeFile(agentsPath, applyRulesBlock(await readIfExists(agentsPath)), "utf8");
70
- written.push(agentsPath);
71
-
72
- const claudePath = path.join(cwd, "CLAUDE.md");
73
- const claudeText = await readIfExists(claudePath);
74
- if (claudeText !== null) {
75
- await fs.writeFile(claudePath, applyRulesBlock(claudeText), "utf8");
76
- written.push(claudePath);
77
- }
78
-
79
- return written;
80
- }