@mergesafe-io/connect 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -20
- package/lib/actions.js +104 -0
- package/lib/cli.js +88 -54
- package/lib/clients.js +52 -54
- package/lib/config-file.js +34 -0
- package/lib/detect.js +14 -0
- package/lib/paths.js +29 -0
- package/lib/rules.js +80 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
# @mergesafe-io/connect
|
|
2
2
|
|
|
3
|
-
One command
|
|
3
|
+
One command connects every AI client on your machine to the
|
|
4
|
+
[MergeSafe](https://app.mergesafe.io) MCP server — globally, so it works in
|
|
5
|
+
every project. No JSON editing.
|
|
4
6
|
|
|
5
7
|
```bash
|
|
6
|
-
npx @mergesafe-io/connect
|
|
8
|
+
npx @mergesafe-io/connect --key msk_your_api_key
|
|
7
9
|
```
|
|
8
10
|
|
|
9
|
-
That's it.
|
|
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,
|
|
13
|
+
and backs up every file it touches. Restart your clients and the `mergesafe`
|
|
14
|
+
tools are available everywhere.
|
|
15
|
+
|
|
16
|
+
To uninstall from every detected client:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx @mergesafe-io/connect --uninstall
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
That removes only the mergesafe entry — all other configs are preserved.
|
|
10
23
|
|
|
11
24
|
## What it does
|
|
12
25
|
|
|
13
|
-
-
|
|
26
|
+
- Detects the AI clients installed on your machine (or targets just one if
|
|
27
|
+
you name it: `npx @mergesafe-io/connect cursor --key msk_…`)
|
|
28
|
+
- Finds the right **global** MCP config file for each client and OS
|
|
14
29
|
- Backs up the existing file next to it (`*.bak-<timestamp>`)
|
|
15
30
|
- Merges in the `mergesafe` server entry — every other setting and server is preserved
|
|
16
31
|
- Refuses to touch a file it can't parse (broken JSON aborts with a clear message)
|
|
@@ -19,43 +34,43 @@ No dependencies, no telemetry, Node 18+.
|
|
|
19
34
|
|
|
20
35
|
## Supported clients
|
|
21
36
|
|
|
22
|
-
| Client |
|
|
37
|
+
| Client | Global config written |
|
|
23
38
|
|---|---|
|
|
24
|
-
| `claude-
|
|
25
|
-
| `
|
|
26
|
-
| `cursor
|
|
39
|
+
| `claude-code` | `~/.claude.json` (user scope — every project and session) |
|
|
40
|
+
| `claude-desktop` | `claude_desktop_config.json` (per-OS path) — bridges through `mcp-remote` |
|
|
41
|
+
| `cursor` | `~/.cursor/mcp.json` (`--project` for `./.cursor/mcp.json`) |
|
|
27
42
|
| `windsurf` | `~/.codeium/windsurf/mcp_config.json` |
|
|
28
|
-
| `vscode` | `./.vscode/mcp.json`
|
|
29
|
-
| `opencode` |
|
|
43
|
+
| `vscode` | user-profile `mcp.json` (`--project` for `./.vscode/mcp.json`) |
|
|
44
|
+
| `opencode` | `~/.config/opencode/config.json` |
|
|
30
45
|
| `cline` | `cline_mcp_settings.json` (stock VS Code global storage, per-OS path) |
|
|
31
|
-
| `roo-code` | `./.roo/mcp.json`
|
|
32
|
-
| `continue` |
|
|
46
|
+
| `roo-code` | `mcp_settings.json` in VS Code global storage (`--project` for `./.roo/mcp.json`) |
|
|
47
|
+
| `continue` | `~/.continue/mcpServers/mergesafe.yaml` (`--project` for the project copy) |
|
|
33
48
|
|
|
34
49
|
Not covered: Zed (its `settings.json` is JSONC with comments — a rewrite would
|
|
35
|
-
destroy them; configure manually).
|
|
36
|
-
|
|
37
|
-
`claude mcp add --transport http mergesafe https://mcp.mergesafe.io/mcp --header "Authorization: Bearer msk_…"`
|
|
50
|
+
destroy them; configure manually). Gemini CLI: use its built-in command:
|
|
51
|
+
`gemini mcp add --transport http mergesafe https://mcp.mergesafe.io/mcp -H "Authorization: Bearer msk_…"`
|
|
38
52
|
|
|
39
53
|
## Options
|
|
40
54
|
|
|
41
55
|
| Flag | Effect |
|
|
42
56
|
|---|---|
|
|
43
57
|
| `--key <msk_…>` | Your MergeSafe API key (from app.mergesafe.io → Settings). Prompted if omitted. |
|
|
44
|
-
| `--project` | Write the project-local config instead of the global one (cursor) |
|
|
45
|
-
| `--
|
|
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 |
|
|
60
|
+
| `--dry-run` | Print the target paths and resulting files without writing |
|
|
61
|
+
| `--uninstall` | Remove the `mergesafe` entry (no client = every detected client) |
|
|
46
62
|
| `--help` | Usage |
|
|
47
63
|
|
|
48
64
|
## Development
|
|
49
65
|
|
|
50
66
|
```bash
|
|
51
67
|
npm test # node --test, zero deps
|
|
52
|
-
node bin/mergesafe-connect.js
|
|
68
|
+
node bin/mergesafe-connect.js --key msk_x --dry-run # auto-detect
|
|
69
|
+
node bin/mergesafe-connect.js cursor --key msk_x --dry-run # single client
|
|
53
70
|
```
|
|
54
71
|
|
|
55
72
|
## Publishing
|
|
56
73
|
|
|
57
|
-
The `@mergesafe` npm scope must exist (create the org on npmjs.com), then:
|
|
58
|
-
|
|
59
74
|
```bash
|
|
60
75
|
npm publish --access public
|
|
61
76
|
```
|
package/lib/actions.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** Install/uninstall actions for one client: back up, merge, write. */
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { SERVER_NAME } from "./clients.js";
|
|
6
|
+
import { applyEntry, removeEntry } from "./config-file.js";
|
|
7
|
+
import { writeRulesFiles } from "./rules.js";
|
|
8
|
+
|
|
9
|
+
export async function handleInstall(client, key, configPath, dryRun, withRules, cwd) {
|
|
10
|
+
const existingText = await readIfExists(configPath);
|
|
11
|
+
|
|
12
|
+
const newText = client.buildFileContent
|
|
13
|
+
? // Dedicated-file clients (Continue): whole-file write
|
|
14
|
+
client.buildFileContent(key)
|
|
15
|
+
: // Merge via applyEntry — only the mergesafe entry is touched
|
|
16
|
+
applyEntry(existingText ?? "", client.rootKey, SERVER_NAME, client.buildEntry(key));
|
|
17
|
+
|
|
18
|
+
if (dryRun) {
|
|
19
|
+
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
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
await backupIfPresent(existingText, configPath);
|
|
27
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
28
|
+
await fs.writeFile(configPath, newText, "utf8");
|
|
29
|
+
|
|
30
|
+
console.log(`✔ ${client.label} is connected to MergeSafe (${configPath})`);
|
|
31
|
+
console.log(`→ ${client.restartHint}`);
|
|
32
|
+
|
|
33
|
+
if (withRules) {
|
|
34
|
+
await reportRules(cwd);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function handleUninstall(client, configPath, dryRun) {
|
|
39
|
+
const existingText = await readIfExists(configPath);
|
|
40
|
+
|
|
41
|
+
if (existingText === null) {
|
|
42
|
+
console.log(`⚠ No config found at ${configPath} — nothing to remove.`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (client.deleteFile) {
|
|
47
|
+
// Continue: delete the dedicated YAML file entirely
|
|
48
|
+
if (!dryRun) {
|
|
49
|
+
await fs.unlink(configPath);
|
|
50
|
+
}
|
|
51
|
+
console.log(`🗑 ${dryRun ? "Would remove" : "Removed"} ${configPath}`);
|
|
52
|
+
console.log(`→ ${client.restartHint}`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!client.deleteKey) {
|
|
57
|
+
throw new Error(`Client "${client.label}" does not support --uninstall`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const newText = removeEntry(existingText, client.rootKey, SERVER_NAME);
|
|
61
|
+
|
|
62
|
+
if (newText === null) {
|
|
63
|
+
console.log(`⚠ No mergesafe entry found in ${configPath} — nothing to remove.`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (dryRun) {
|
|
68
|
+
console.log(`Would write ${configPath}:\n\n${newText}`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await backupIfPresent(existingText, configPath);
|
|
73
|
+
await fs.writeFile(configPath, newText, "utf8");
|
|
74
|
+
console.log(`🗑 Removed mergesafe entry from ${configPath}`);
|
|
75
|
+
console.log(`→ ${client.restartHint}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
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
|
+
async function backupIfPresent(existingText, configPath) {
|
|
87
|
+
if (existingText === null) return;
|
|
88
|
+
const backupPath = `${configPath}.bak-${timestamp()}`;
|
|
89
|
+
await fs.copyFile(configPath, backupPath);
|
|
90
|
+
console.log(`• Backed up existing config to ${backupPath}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function readIfExists(filePath) {
|
|
94
|
+
try {
|
|
95
|
+
return await fs.readFile(filePath, "utf8");
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err.code === "ENOENT") return null;
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function timestamp() {
|
|
103
|
+
return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
104
|
+
}
|
package/lib/cli.js
CHANGED
|
@@ -1,35 +1,40 @@
|
|
|
1
|
-
/** CLI flow: parse args,
|
|
2
|
-
import fs from "node:fs/promises";
|
|
1
|
+
/** CLI flow: parse args, detect or resolve clients, delegate to actions. */
|
|
3
2
|
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
3
|
import readline from "node:readline/promises";
|
|
6
4
|
import { parseArgs } from "node:util";
|
|
7
5
|
|
|
6
|
+
import { handleInstall, handleUninstall, reportRules } from "./actions.js";
|
|
8
7
|
import { SERVER_NAME, getClient, listClientIds } from "./clients.js";
|
|
9
|
-
import {
|
|
8
|
+
import { detectClients } from "./detect.js";
|
|
10
9
|
|
|
11
|
-
const USAGE = `Usage: npx @mergesafe-io/connect
|
|
10
|
+
const USAGE = `Usage: npx @mergesafe-io/connect [client] [options]
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
Connects your AI clients to the MergeSafe MCP server — globally, so it works
|
|
13
|
+
in every project. With no <client> argument, every installed client is
|
|
14
|
+
detected and configured in one go. Existing files are backed up first; only
|
|
15
|
+
the "${SERVER_NAME}" entry is touched.
|
|
15
16
|
|
|
16
|
-
Clients:
|
|
17
|
-
claude-
|
|
18
|
-
|
|
19
|
-
cursor
|
|
17
|
+
Clients (all global unless noted):
|
|
18
|
+
claude-code Claude Code — ~/.claude.json (user scope, every project)
|
|
19
|
+
claude-desktop Claude Desktop — claude_desktop_config.json (via mcp-remote bridge)
|
|
20
|
+
cursor Cursor — ~/.cursor/mcp.json (--project for ./.cursor/mcp.json)
|
|
20
21
|
windsurf Windsurf — ~/.codeium/windsurf/mcp_config.json
|
|
21
|
-
vscode VS Code — .vscode/mcp.json
|
|
22
|
-
opencode opencode — opencode.json
|
|
22
|
+
vscode VS Code — user-profile mcp.json (--project for ./.vscode/mcp.json)
|
|
23
|
+
opencode opencode — ~/.config/opencode/config.json
|
|
23
24
|
cline Cline — cline_mcp_settings.json (stock VS Code global storage)
|
|
24
|
-
roo-code Roo Code —
|
|
25
|
-
continue Continue —
|
|
25
|
+
roo-code Roo Code — VS Code global storage (--project for ./.roo/mcp.json)
|
|
26
|
+
continue Continue — ~/.continue/mcpServers/mergesafe.yaml (--project for ./.continue/…)
|
|
26
27
|
|
|
27
28
|
Options:
|
|
28
29
|
--key <msk_...> MergeSafe API key (create one on app.mergesafe.io/dashboard/settings;
|
|
29
30
|
prompted interactively if omitted)
|
|
30
|
-
--project Write the project-local config instead of the global one
|
|
31
|
+
--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
|
|
31
35
|
--dry-run Print the target path and resulting file without writing
|
|
32
|
-
--
|
|
36
|
+
--uninstall Remove the mergesafe entry (no client = every detected client)
|
|
37
|
+
--help Show usage
|
|
33
38
|
`;
|
|
34
39
|
|
|
35
40
|
export async function main(argv) {
|
|
@@ -39,21 +44,18 @@ export async function main(argv) {
|
|
|
39
44
|
options: {
|
|
40
45
|
key: { type: "string" },
|
|
41
46
|
project: { type: "boolean", default: false },
|
|
47
|
+
"with-rules": { type: "boolean", default: false },
|
|
42
48
|
"dry-run": { type: "boolean", default: false },
|
|
49
|
+
uninstall: { type: "boolean", default: false },
|
|
43
50
|
help: { type: "boolean", default: false },
|
|
44
51
|
},
|
|
45
52
|
});
|
|
46
53
|
|
|
47
|
-
if (values.help
|
|
54
|
+
if (values.help) {
|
|
48
55
|
console.log(USAGE);
|
|
49
|
-
if (!values.help) {
|
|
50
|
-
throw new Error(`Missing <client> argument. Supported: ${listClientIds().join(", ")}`);
|
|
51
|
-
}
|
|
52
56
|
return;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
const client = getClient(positionals[0]);
|
|
56
|
-
const key = await resolveKey(values.key);
|
|
57
59
|
const ctx = {
|
|
58
60
|
platform: process.platform,
|
|
59
61
|
home: os.homedir(),
|
|
@@ -62,29 +64,74 @@ export async function main(argv) {
|
|
|
62
64
|
project: values.project,
|
|
63
65
|
};
|
|
64
66
|
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const newText = client.buildFileContent
|
|
70
|
-
? client.buildFileContent(key)
|
|
71
|
-
: applyEntry(existingText ?? "", client.rootKey, SERVER_NAME, client.buildEntry(key));
|
|
72
|
-
|
|
73
|
-
if (values["dry-run"]) {
|
|
74
|
-
console.log(`Would write ${configPath}:\n\n${newText}`);
|
|
67
|
+
// --uninstall can come before the client name: "uninstall cursor" or "cursor --uninstall"
|
|
68
|
+
const filtered = positionals.filter((p) => p !== "--uninstall");
|
|
69
|
+
if (filtered.length === 0) {
|
|
70
|
+
await runAllDetected(values, ctx);
|
|
75
71
|
return;
|
|
76
72
|
}
|
|
77
73
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
74
|
+
const client = getClient(filtered[0]);
|
|
75
|
+
const configPath = client.configPath(ctx);
|
|
76
|
+
if (values.uninstall) {
|
|
77
|
+
await handleUninstall(client, configPath, values["dry-run"]);
|
|
78
|
+
} else {
|
|
79
|
+
const key = await resolveKey(values.key);
|
|
80
|
+
await handleInstall(client, key, configPath, values["dry-run"], values["with-rules"], ctx.cwd);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** No-client mode: detect every installed client and configure them globally. */
|
|
85
|
+
async function runAllDetected(values, ctx) {
|
|
86
|
+
if (values.project) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
"--project needs an explicit client (e.g. `npx @mergesafe-io/connect cursor --project`).",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const detected = detectClients(ctx);
|
|
93
|
+
if (detected.length === 0) {
|
|
94
|
+
console.log(USAGE);
|
|
95
|
+
throw new Error(
|
|
96
|
+
`No supported AI client detected on this machine. Specify one explicitly: ${listClientIds().join(", ")}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
console.log(`Detected clients: ${detected.map((id) => getClient(id).label).join(", ")}\n`);
|
|
100
|
+
|
|
101
|
+
const key = values.uninstall ? null : await resolveKey(values.key);
|
|
102
|
+
const failures = [];
|
|
103
|
+
for (const id of detected) {
|
|
104
|
+
const client = getClient(id);
|
|
105
|
+
try {
|
|
106
|
+
if (values.uninstall) {
|
|
107
|
+
await handleUninstall(client, client.configPath(ctx), values["dry-run"]);
|
|
108
|
+
} else {
|
|
109
|
+
await handleInstall(client, key, client.configPath(ctx), values["dry-run"], false, ctx.cwd);
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
failures.push(`${client.label}: ${err.message}`);
|
|
113
|
+
}
|
|
114
|
+
console.log("");
|
|
82
115
|
}
|
|
83
|
-
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
84
|
-
await fs.writeFile(configPath, newText, "utf8");
|
|
85
116
|
|
|
86
|
-
|
|
87
|
-
|
|
117
|
+
if (values["with-rules"] && !values.uninstall && !values["dry-run"]) {
|
|
118
|
+
await reportRules(ctx.cwd);
|
|
119
|
+
}
|
|
120
|
+
reportOutcome(detected.length, failures, values.uninstall);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function reportOutcome(total, failures, uninstall) {
|
|
124
|
+
for (const failure of failures) {
|
|
125
|
+
console.error(`⚠ ${failure}`);
|
|
126
|
+
}
|
|
127
|
+
if (failures.length === total) {
|
|
128
|
+
throw new Error("Nothing was configured — every detected client failed (see above).");
|
|
129
|
+
}
|
|
130
|
+
if (!uninstall) {
|
|
131
|
+
console.log(
|
|
132
|
+
"✔ MergeSafe is connected globally — restart your clients and the tools work in every project.",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
88
135
|
}
|
|
89
136
|
|
|
90
137
|
async function resolveKey(flagValue) {
|
|
@@ -102,16 +149,3 @@ async function resolveKey(flagValue) {
|
|
|
102
149
|
}
|
|
103
150
|
return key;
|
|
104
151
|
}
|
|
105
|
-
|
|
106
|
-
async function readIfExists(filePath) {
|
|
107
|
-
try {
|
|
108
|
-
return await fs.readFile(filePath, "utf8");
|
|
109
|
-
} catch (err) {
|
|
110
|
-
if (err.code === "ENOENT") return null;
|
|
111
|
-
throw err;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function timestamp() {
|
|
116
|
-
return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
117
|
-
}
|
package/lib/clients.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
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";
|
|
5
|
+
|
|
4
6
|
export const MCP_HTTP_URL = "https://mcp.mergesafe.io/mcp";
|
|
5
7
|
// Legacy endpoint, kept for clients without solid Streamable HTTP support
|
|
6
8
|
// (opencode: SSE-only "remote" type; cline: open transport bugs; continue:
|
|
@@ -11,12 +13,29 @@ export const SERVER_NAME = "mergesafe";
|
|
|
11
13
|
/**
|
|
12
14
|
* ctx: { platform, home, cwd, env, project } — injected by the CLI so
|
|
13
15
|
* every path decision stays testable without touching the real machine.
|
|
16
|
+
*
|
|
17
|
+
* Every client is global (user-wide) by default; `--project` opts the few
|
|
18
|
+
* clients that support it into a project-local config instead.
|
|
19
|
+
* `detectPaths` lists markers whose existence means the client is installed.
|
|
14
20
|
*/
|
|
15
21
|
const CLIENTS = {
|
|
22
|
+
"claude-code": {
|
|
23
|
+
label: "Claude Code",
|
|
24
|
+
rootKey: "mcpServers",
|
|
25
|
+
configPath: (ctx) => path.join(ctx.home, ".claude.json"),
|
|
26
|
+
detectPaths: (ctx) => [path.join(ctx.home, ".claude.json"), path.join(ctx.home, ".claude")],
|
|
27
|
+
deleteKey: true,
|
|
28
|
+
// Same entry `claude mcp add --scope user --transport http` writes:
|
|
29
|
+
// user scope, so every project and session sees the server.
|
|
30
|
+
buildEntry: (key) => ({ type: "http", url: MCP_HTTP_URL, headers: bearer(key) }),
|
|
31
|
+
restartHint: "Restart any open Claude Code sessions — the server is user-wide, every project gets it.",
|
|
32
|
+
},
|
|
16
33
|
"claude-desktop": {
|
|
17
34
|
label: "Claude Desktop",
|
|
18
35
|
rootKey: "mcpServers",
|
|
19
36
|
configPath: (ctx) => claudeDesktopConfigPath(ctx),
|
|
37
|
+
detectPaths: (ctx) => [path.dirname(claudeDesktopConfigPath(ctx))],
|
|
38
|
+
deleteKey: true,
|
|
20
39
|
// Claude Desktop has no native remote + Bearer support; bridge via
|
|
21
40
|
// mcp-remote (http-first: tries Streamable HTTP, falls back to SSE).
|
|
22
41
|
// No space after "Authorization:" and env-expanded token — both
|
|
@@ -35,6 +54,8 @@ const CLIENTS = {
|
|
|
35
54
|
ctx.project
|
|
36
55
|
? path.join(ctx.cwd, ".cursor", "mcp.json")
|
|
37
56
|
: path.join(ctx.home, ".cursor", "mcp.json"),
|
|
57
|
+
detectPaths: (ctx) => [path.join(ctx.home, ".cursor")],
|
|
58
|
+
deleteKey: true,
|
|
38
59
|
// Cursor auto-detects Streamable HTTP from the URL (SSE fallback built in).
|
|
39
60
|
buildEntry: (key) => ({ url: MCP_HTTP_URL, headers: bearer(key) }),
|
|
40
61
|
restartHint: "Restart Cursor or reload the window.",
|
|
@@ -43,60 +64,63 @@ const CLIENTS = {
|
|
|
43
64
|
label: "Windsurf",
|
|
44
65
|
rootKey: "mcpServers",
|
|
45
66
|
configPath: (ctx) => path.join(ctx.home, ".codeium", "windsurf", "mcp_config.json"),
|
|
67
|
+
detectPaths: (ctx) => [path.join(ctx.home, ".codeium", "windsurf")],
|
|
68
|
+
deleteKey: true,
|
|
46
69
|
buildEntry: (key) => ({ serverUrl: MCP_HTTP_URL, headers: bearer(key) }),
|
|
47
70
|
restartHint: "Fully quit and reopen Windsurf.",
|
|
48
71
|
},
|
|
49
72
|
vscode: {
|
|
50
|
-
label: "VS Code
|
|
73
|
+
label: "VS Code",
|
|
51
74
|
rootKey: "servers",
|
|
52
|
-
configPath: (ctx) =>
|
|
75
|
+
configPath: (ctx) =>
|
|
76
|
+
ctx.project
|
|
77
|
+
? path.join(ctx.cwd, ".vscode", "mcp.json")
|
|
78
|
+
: path.join(vscodeUserDir(ctx), "mcp.json"),
|
|
79
|
+
detectPaths: (ctx) => [vscodeUserDir(ctx)],
|
|
80
|
+
deleteKey: true,
|
|
53
81
|
buildEntry: (key) => ({ type: "http", url: MCP_HTTP_URL, headers: bearer(key) }),
|
|
54
|
-
restartHint: "
|
|
82
|
+
restartHint: "Restart VS Code, then check MCP: List Servers in the command palette.",
|
|
55
83
|
},
|
|
56
84
|
opencode: {
|
|
57
|
-
label: "opencode
|
|
85
|
+
label: "opencode",
|
|
58
86
|
rootKey: "mcp",
|
|
59
|
-
configPath: (ctx) => path.join(ctx.
|
|
87
|
+
configPath: (ctx) => path.join(ctx.home, ".config", "opencode", "config.json"),
|
|
88
|
+
detectPaths: (ctx) => [path.join(ctx.home, ".config", "opencode")],
|
|
89
|
+
deleteKey: true,
|
|
60
90
|
buildEntry: (key) => ({ type: "remote", url: MCP_SSE_URL, enabled: true, headers: bearer(key) }),
|
|
61
91
|
restartHint: "Restart opencode for changes to take effect.",
|
|
62
92
|
},
|
|
63
|
-
"cursor-local": {
|
|
64
|
-
label: "Cursor (local server)",
|
|
65
|
-
rootKey: "mcpServers",
|
|
66
|
-
configPath: (ctx) => path.join(ctx.cwd, ".cursor", "mcp.json"),
|
|
67
|
-
// Local stdio server so Cursor can scan the local project — requires the
|
|
68
|
-
// mergesafe-mcp command (Python package) on PATH; Cursor starts it itself.
|
|
69
|
-
buildEntry: (key) => ({
|
|
70
|
-
command: "mergesafe-mcp",
|
|
71
|
-
env: {
|
|
72
|
-
MCP_TRANSPORT: "stdio",
|
|
73
|
-
MERGESAFE_API_URL: "https://api.mergesafe.io",
|
|
74
|
-
MERGESAFE_FRONTEND_URL: "https://app.mergesafe.io",
|
|
75
|
-
MERGESAFE_API_KEY: key,
|
|
76
|
-
},
|
|
77
|
-
}),
|
|
78
|
-
restartHint:
|
|
79
|
-
"Restart Cursor. Requires the mergesafe-mcp command installed (Python 3.11+).",
|
|
80
|
-
},
|
|
81
93
|
cline: {
|
|
82
94
|
label: "Cline",
|
|
83
95
|
rootKey: "mcpServers",
|
|
84
96
|
configPath: (ctx) =>
|
|
85
97
|
path.join(vscodeUserDir(ctx), "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"),
|
|
98
|
+
detectPaths: (ctx) => [path.join(vscodeUserDir(ctx), "globalStorage", "saoudrizwan.claude-dev")],
|
|
99
|
+
deleteKey: true,
|
|
86
100
|
buildEntry: (key) => ({ url: MCP_SSE_URL, headers: bearer(key) }),
|
|
87
101
|
restartHint: "Reload the VS Code window (stock VS Code path — not Insiders/VSCodium).",
|
|
88
102
|
},
|
|
89
103
|
"roo-code": {
|
|
90
|
-
label: "Roo Code
|
|
104
|
+
label: "Roo Code",
|
|
91
105
|
rootKey: "mcpServers",
|
|
92
|
-
configPath: (ctx) =>
|
|
106
|
+
configPath: (ctx) =>
|
|
107
|
+
ctx.project
|
|
108
|
+
? path.join(ctx.cwd, ".roo", "mcp.json")
|
|
109
|
+
: path.join(vscodeUserDir(ctx), "globalStorage", "rooveterinaryinc.roo-cline", "settings", "mcp_settings.json"),
|
|
110
|
+
detectPaths: (ctx) => [path.join(vscodeUserDir(ctx), "globalStorage", "rooveterinaryinc.roo-cline")],
|
|
111
|
+
deleteKey: true,
|
|
93
112
|
buildEntry: (key) => ({ type: "sse", url: MCP_SSE_URL, headers: bearer(key), disabled: false }),
|
|
94
|
-
restartHint: "Reload the VS Code window
|
|
113
|
+
restartHint: "Reload the VS Code window.",
|
|
95
114
|
},
|
|
96
115
|
continue: {
|
|
97
|
-
label: "Continue
|
|
98
|
-
configPath: (ctx) =>
|
|
116
|
+
label: "Continue",
|
|
117
|
+
configPath: (ctx) =>
|
|
118
|
+
ctx.project
|
|
119
|
+
? path.join(ctx.cwd, ".continue", "mcpServers", "mergesafe.yaml")
|
|
120
|
+
: path.join(ctx.home, ".continue", "mcpServers", "mergesafe.yaml"),
|
|
121
|
+
detectPaths: (ctx) => [path.join(ctx.home, ".continue")],
|
|
99
122
|
// Continue uses one YAML file per MCP server — whole-file write, no merge.
|
|
123
|
+
deleteFile: true,
|
|
100
124
|
buildFileContent: (key) =>
|
|
101
125
|
[
|
|
102
126
|
"name: Mergesafe MCP",
|
|
@@ -119,32 +143,6 @@ function bearer(key) {
|
|
|
119
143
|
return { Authorization: `Bearer ${key}` };
|
|
120
144
|
}
|
|
121
145
|
|
|
122
|
-
/** Stock VS Code user dir per OS (globalStorage lives under it). */
|
|
123
|
-
function vscodeUserDir(ctx) {
|
|
124
|
-
if (ctx.platform === "darwin") {
|
|
125
|
-
return path.join(ctx.home, "Library", "Application Support", "Code", "User");
|
|
126
|
-
}
|
|
127
|
-
if (ctx.platform === "win32") {
|
|
128
|
-
const appData = ctx.env.APPDATA || path.join(ctx.home, "AppData", "Roaming");
|
|
129
|
-
return path.join(appData, "Code", "User");
|
|
130
|
-
}
|
|
131
|
-
const configHome = ctx.env.XDG_CONFIG_HOME || path.join(ctx.home, ".config");
|
|
132
|
-
return path.join(configHome, "Code", "User");
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function claudeDesktopConfigPath(ctx) {
|
|
136
|
-
const file = "claude_desktop_config.json";
|
|
137
|
-
if (ctx.platform === "darwin") {
|
|
138
|
-
return path.join(ctx.home, "Library", "Application Support", "Claude", file);
|
|
139
|
-
}
|
|
140
|
-
if (ctx.platform === "win32") {
|
|
141
|
-
const appData = ctx.env.APPDATA || path.join(ctx.home, "AppData", "Roaming");
|
|
142
|
-
return path.join(appData, "Claude", file);
|
|
143
|
-
}
|
|
144
|
-
const configHome = ctx.env.XDG_CONFIG_HOME || path.join(ctx.home, ".config");
|
|
145
|
-
return path.join(configHome, "Claude", file);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
146
|
export function getClient(id) {
|
|
149
147
|
const client = CLIENTS[id];
|
|
150
148
|
if (!client) {
|
package/lib/config-file.js
CHANGED
|
@@ -14,6 +14,40 @@ export function applyEntry(text, rootKey, serverName, entry) {
|
|
|
14
14
|
return `${JSON.stringify(config, null, 2)}\n`;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Remove the `serverName` entry under `rootKey` from the JSON text.
|
|
19
|
+
* Preserves all other entries. If the section becomes empty, removes it too.
|
|
20
|
+
* Returns the new file text (2-space indent, trailing newline).
|
|
21
|
+
*/
|
|
22
|
+
export function removeEntry(text, rootKey, serverName) {
|
|
23
|
+
const config = parseConfig(text);
|
|
24
|
+
const section = config[rootKey];
|
|
25
|
+
|
|
26
|
+
if (!section || typeof section !== "object" || Array.isArray(section)) {
|
|
27
|
+
// Nothing to remove — the config has no such section
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const copy = { ...section };
|
|
32
|
+
delete copy[serverName];
|
|
33
|
+
|
|
34
|
+
if (Object.keys(copy).length === 0) {
|
|
35
|
+
delete config[rootKey];
|
|
36
|
+
} else {
|
|
37
|
+
config[rootKey] = copy;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Remove the mergesafe server entry from a dedicated YAML file (Continue).
|
|
45
|
+
* Returns the new file text (overwrite), or null if the file doesn't exist.
|
|
46
|
+
*/
|
|
47
|
+
export function removeFileContent() {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
|
|
17
51
|
function parseConfig(text) {
|
|
18
52
|
if (!text || !text.trim()) {
|
|
19
53
|
return {};
|
package/lib/detect.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Detect which supported AI clients are installed on this machine. */
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
|
|
4
|
+
import { getClient, listClientIds } from "./clients.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Return the ids of every client whose marker path exists.
|
|
8
|
+
* `exists` is injectable so detection stays testable without a real machine.
|
|
9
|
+
*/
|
|
10
|
+
export function detectClients(ctx, exists = fs.existsSync) {
|
|
11
|
+
return listClientIds().filter((id) =>
|
|
12
|
+
getClient(id).detectPaths(ctx).some((markerPath) => exists(markerPath)),
|
|
13
|
+
);
|
|
14
|
+
}
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Per-OS filesystem locations shared by several clients. */
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Stock VS Code user dir per OS (globalStorage and user mcp.json live under it). */
|
|
5
|
+
export function vscodeUserDir(ctx) {
|
|
6
|
+
if (ctx.platform === "darwin") {
|
|
7
|
+
return path.join(ctx.home, "Library", "Application Support", "Code", "User");
|
|
8
|
+
}
|
|
9
|
+
if (ctx.platform === "win32") {
|
|
10
|
+
const appData = ctx.env.APPDATA || path.join(ctx.home, "AppData", "Roaming");
|
|
11
|
+
return path.join(appData, "Code", "User");
|
|
12
|
+
}
|
|
13
|
+
const configHome = ctx.env.XDG_CONFIG_HOME || path.join(ctx.home, ".config");
|
|
14
|
+
return path.join(configHome, "Code", "User");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Claude Desktop's claude_desktop_config.json per OS. */
|
|
18
|
+
export function claudeDesktopConfigPath(ctx) {
|
|
19
|
+
const file = "claude_desktop_config.json";
|
|
20
|
+
if (ctx.platform === "darwin") {
|
|
21
|
+
return path.join(ctx.home, "Library", "Application Support", "Claude", file);
|
|
22
|
+
}
|
|
23
|
+
if (ctx.platform === "win32") {
|
|
24
|
+
const appData = ctx.env.APPDATA || path.join(ctx.home, "AppData", "Roaming");
|
|
25
|
+
return path.join(appData, "Claude", file);
|
|
26
|
+
}
|
|
27
|
+
const configHome = ctx.env.XDG_CONFIG_HOME || path.join(ctx.home, ".config");
|
|
28
|
+
return path.join(configHome, "Claude", file);
|
|
29
|
+
}
|
package/lib/rules.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mergesafe-io/connect",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "One
|
|
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.",
|
|
5
5
|
"keywords": ["mergesafe", "mcp", "model-context-protocol", "installer"],
|
|
6
6
|
"homepage": "https://app.mergesafe.io",
|
|
7
7
|
"license": "MIT",
|