@super-hands/connect 0.1.7 → 0.1.9
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 +27 -2
- package/client.mjs +302 -21
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -20,9 +20,34 @@ variable too.
|
|
|
20
20
|
left untouched. Restart Cursor and enable the server under Settings → MCP.
|
|
21
21
|
- **Claude Code** — registers the server through Claude Code's own CLI
|
|
22
22
|
(`claude mcp add`, user scope). A new `claude` session connects on start.
|
|
23
|
+
- **Codex** — appends the `superhands` block to `~/.codex/config.toml`, and
|
|
24
|
+
only when no such block is there. Both the app and the CLI read that file.
|
|
25
|
+
- **The skill** — writes `skills/superhands/SKILL.md` under each connected
|
|
26
|
+
client's own directory. It routes UI work through the team's guidance and
|
|
27
|
+
carries no credential.
|
|
23
28
|
|
|
24
|
-
With no flags it connects every client it finds; `--cursor
|
|
25
|
-
narrow it to one.
|
|
29
|
+
With no flags it connects every client it finds; `--cursor`, `--claude` or
|
|
30
|
+
`--codex` narrow it to one.
|
|
31
|
+
|
|
32
|
+
## Removing it
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
npx -y @super-hands/connect@latest uninstall
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The exact inverse, in this order: it reads every Superhands credential on the
|
|
39
|
+
machine out of the configs, hands each one back to the deployment that issued
|
|
40
|
+
it, and only then removes the entries and the skill files. The other way round
|
|
41
|
+
would delete the only copy of the credential needed to tell the server
|
|
42
|
+
anything.
|
|
43
|
+
|
|
44
|
+
It removes only what this tool wrote — one key out of `~/.cursor/mcp.json`,
|
|
45
|
+
its own block out of `~/.codex/config.toml`, and the Claude Code entry through
|
|
46
|
+
`claude mcp remove`. Everything else in those files stays. Running it twice, or
|
|
47
|
+
on a machine that was never connected, does nothing and says so; a machine that
|
|
48
|
+
cannot reach the internet is still cleaned locally, with a warning that the
|
|
49
|
+
credential is still live. The same `--cursor` / `--claude` / `--codex` flags
|
|
50
|
+
remove one client and leave the rest.
|
|
26
51
|
|
|
27
52
|
## What it does not do
|
|
28
53
|
|
package/client.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
// lib/connect-client-entry.ts
|
|
13
13
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
14
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
import { dirname, join } from "node:path";
|
|
17
17
|
|
|
@@ -28,6 +28,9 @@ var MCP_SERVER_KEY = "superhands";
|
|
|
28
28
|
function mcpAuthorizationHeader(token) {
|
|
29
29
|
return `Bearer ${token}`;
|
|
30
30
|
}
|
|
31
|
+
function mcpRevokeEndpoint(mcpEndpoint) {
|
|
32
|
+
return `${mcpEndpoint.replace(/\/+$/, "")}/oauth/revoke`;
|
|
33
|
+
}
|
|
31
34
|
function codexConfigBlock(args) {
|
|
32
35
|
return [
|
|
33
36
|
`[mcp_servers.${MCP_SERVER_KEY}]`,
|
|
@@ -37,7 +40,7 @@ function codexConfigBlock(args) {
|
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
// lib/connect-skill.ts
|
|
40
|
-
var CONNECT_SKILL_VERSION =
|
|
43
|
+
var CONNECT_SKILL_VERSION = 4;
|
|
41
44
|
var CONNECT_SKILL_DIR = MCP_SERVER_KEY;
|
|
42
45
|
var CONNECT_SKILL_FILENAME = "SKILL.md";
|
|
43
46
|
var VERSION_MARKER = `[//]: # (superhands-skill-version: ${CONNECT_SKILL_VERSION})`;
|
|
@@ -80,6 +83,23 @@ mention that the team writes its guidance on the Superhands Guidance page.
|
|
|
80
83
|
If the \`${MCP_SERVER_KEY}\` MCP server is not reachable in this session, say
|
|
81
84
|
so rather than guessing at the team's decisions \u2014 it is configured on this
|
|
82
85
|
machine, so a missing connection is worth telling the person about.
|
|
86
|
+
|
|
87
|
+
## Removing Superhands from this machine
|
|
88
|
+
|
|
89
|
+
If the person asks to uninstall, remove, or disconnect Superhands, run:
|
|
90
|
+
|
|
91
|
+
\`\`\`
|
|
92
|
+
npx -y ${CONNECT_CLIENT_SPEC} uninstall
|
|
93
|
+
\`\`\`
|
|
94
|
+
|
|
95
|
+
and tell them what it printed. It hands every Superhands credential on this
|
|
96
|
+
machine back to the server, then takes the MCP entries and this file out of
|
|
97
|
+
each client it finds. Add \`--claude\`, \`--cursor\` or \`--codex\` to remove
|
|
98
|
+
one client and leave the rest.
|
|
99
|
+
|
|
100
|
+
Do not do this by hand. Editing an MCP config to take one server out is how
|
|
101
|
+
every other server in that file gets lost, and deleting the files without the
|
|
102
|
+
command leaves credentials live on the server with nothing left to end them.
|
|
83
103
|
`;
|
|
84
104
|
function installedSkillVersion(contents) {
|
|
85
105
|
const match = contents.match(/superhands-skill-version:\s*(\d+)/);
|
|
@@ -96,6 +116,11 @@ function stop(message) {
|
|
|
96
116
|
`);
|
|
97
117
|
process.exit(1);
|
|
98
118
|
}
|
|
119
|
+
function bearerToken(value) {
|
|
120
|
+
if (typeof value !== "string") return null;
|
|
121
|
+
const match = /^Bearer\s+(\S+)$/i.exec(value.trim());
|
|
122
|
+
return match ? match[1] : null;
|
|
123
|
+
}
|
|
99
124
|
function cursorServerEntry(args) {
|
|
100
125
|
return {
|
|
101
126
|
url: args.endpoint,
|
|
@@ -124,6 +149,59 @@ function mergedCursorConfig(existing, args) {
|
|
|
124
149
|
return { text: `${JSON.stringify(config, null, 2)}
|
|
125
150
|
`, replaced };
|
|
126
151
|
}
|
|
152
|
+
function cursorConfigWithoutServer(existing) {
|
|
153
|
+
if (existing === null || existing.trim() === "") return { text: existing ?? "", removed: false };
|
|
154
|
+
let parsed;
|
|
155
|
+
try {
|
|
156
|
+
parsed = JSON.parse(existing);
|
|
157
|
+
} catch {
|
|
158
|
+
throw new Error("not valid JSON");
|
|
159
|
+
}
|
|
160
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
161
|
+
throw new Error("not a JSON object");
|
|
162
|
+
}
|
|
163
|
+
const config = parsed;
|
|
164
|
+
const serversRaw = config.mcpServers;
|
|
165
|
+
if (typeof serversRaw !== "object" || serversRaw === null || Array.isArray(serversRaw)) {
|
|
166
|
+
return { text: existing, removed: false };
|
|
167
|
+
}
|
|
168
|
+
const servers = serversRaw;
|
|
169
|
+
if (!(MCP_SERVER_KEY in servers)) return { text: existing, removed: false };
|
|
170
|
+
delete servers[MCP_SERVER_KEY];
|
|
171
|
+
return { text: `${JSON.stringify(config, null, 2)}
|
|
172
|
+
`, removed: true };
|
|
173
|
+
}
|
|
174
|
+
function cursorConnection(existing) {
|
|
175
|
+
if (!existing || existing.trim() === "") return null;
|
|
176
|
+
let parsed;
|
|
177
|
+
try {
|
|
178
|
+
parsed = JSON.parse(existing);
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const entry = parsed?.mcpServers?.[MCP_SERVER_KEY];
|
|
183
|
+
if (!entry || typeof entry.url !== "string") return null;
|
|
184
|
+
const token = bearerToken(entry.headers?.Authorization);
|
|
185
|
+
return token ? { endpoint: entry.url, token } : null;
|
|
186
|
+
}
|
|
187
|
+
var CODEX_OWN_BLOCK = `\\[mcp_servers\\.${MCP_SERVER_KEY}\\]\\nurl = "([^"\\n]*)"\\n(?:http_headers = \\{ "Authorization" = "([^"\\n]*)" \\}|bearer_token = "([^"\\n]*)")`;
|
|
188
|
+
function codexConfigWithoutServer(existing) {
|
|
189
|
+
const block = new RegExp(`${CODEX_OWN_BLOCK}\\n?`);
|
|
190
|
+
if (block.test(existing)) {
|
|
191
|
+
return { text: existing.replace(block, ""), removed: true, foreign: false };
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
text: existing,
|
|
195
|
+
removed: false,
|
|
196
|
+
foreign: existing.includes(`[mcp_servers.${MCP_SERVER_KEY}]`)
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function codexConnection(existing) {
|
|
200
|
+
const match = new RegExp(CODEX_OWN_BLOCK).exec(existing);
|
|
201
|
+
if (!match) return null;
|
|
202
|
+
const token = match[2] ? bearerToken(match[2]) : match[3] ?? null;
|
|
203
|
+
return token ? { endpoint: match[1], token } : null;
|
|
204
|
+
}
|
|
127
205
|
function appendedCodexConfig(existing, args) {
|
|
128
206
|
const staleOwnBlock = new RegExp(
|
|
129
207
|
`\\[mcp_servers\\.${MCP_SERVER_KEY}\\]\\nurl = "[^"\\n]*"\\nbearer_token = "[^"\\n]*"`
|
|
@@ -144,19 +222,92 @@ function appendedCodexConfig(existing, args) {
|
|
|
144
222
|
}
|
|
145
223
|
function writeSkill(clientDir) {
|
|
146
224
|
const skillPath = join(clientDir, "skills", CONNECT_SKILL_DIR, CONNECT_SKILL_FILENAME);
|
|
225
|
+
let existing = "";
|
|
226
|
+
try {
|
|
227
|
+
existing = readFileSync(skillPath, "utf8");
|
|
228
|
+
} catch {
|
|
229
|
+
existing = "";
|
|
230
|
+
}
|
|
231
|
+
const existingVersion = existing === "" ? 0 : installedSkillVersion(existing);
|
|
232
|
+
if (existingVersion >= CONNECT_SKILL_VERSION) return existingVersion;
|
|
147
233
|
try {
|
|
148
|
-
let existing = "";
|
|
149
|
-
try {
|
|
150
|
-
existing = readFileSync(skillPath, "utf8");
|
|
151
|
-
} catch {
|
|
152
|
-
existing = "";
|
|
153
|
-
}
|
|
154
|
-
if (existing !== "" && installedSkillVersion(existing) >= CONNECT_SKILL_VERSION) return;
|
|
155
234
|
mkdirSync(dirname(skillPath), { recursive: true });
|
|
156
235
|
writeFileSync(skillPath, CONNECT_SKILL_CONTENT);
|
|
157
236
|
say(` Wrote the ${CONNECT_SKILL_DIR} skill to ${skillPath} \u2014 it routes UI work through the team's guidance.`);
|
|
237
|
+
return CONNECT_SKILL_VERSION;
|
|
158
238
|
} catch {
|
|
159
239
|
say(` Could not write the ${CONNECT_SKILL_DIR} skill at ${skillPath}. The connection works without it.`);
|
|
240
|
+
return existingVersion;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function connectReportUrl(endpoint) {
|
|
244
|
+
return `${endpoint.replace(/\/+$/, "")}/connect`;
|
|
245
|
+
}
|
|
246
|
+
async function reportSkillVersion(args) {
|
|
247
|
+
try {
|
|
248
|
+
await fetch(connectReportUrl(args.endpoint), {
|
|
249
|
+
method: "POST",
|
|
250
|
+
headers: {
|
|
251
|
+
Authorization: mcpAuthorizationHeader(args.token),
|
|
252
|
+
"content-type": "application/json"
|
|
253
|
+
},
|
|
254
|
+
body: JSON.stringify({ skill_version: args.version }),
|
|
255
|
+
signal: AbortSignal.timeout(5e3)
|
|
256
|
+
});
|
|
257
|
+
} catch {
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function removeSkill(clientDir) {
|
|
261
|
+
const skillPath = join(clientDir, "skills", CONNECT_SKILL_DIR, CONNECT_SKILL_FILENAME);
|
|
262
|
+
if (!existsSync(skillPath)) return false;
|
|
263
|
+
try {
|
|
264
|
+
rmSync(skillPath);
|
|
265
|
+
try {
|
|
266
|
+
rmdirSync(dirname(skillPath));
|
|
267
|
+
} catch {
|
|
268
|
+
}
|
|
269
|
+
say(` Removed the ${CONNECT_SKILL_DIR} skill at ${skillPath}.`);
|
|
270
|
+
return true;
|
|
271
|
+
} catch {
|
|
272
|
+
say(` Could not remove the ${CONNECT_SKILL_DIR} skill at ${skillPath} \u2014 delete it by hand.`);
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async function revokeToken(connection) {
|
|
277
|
+
try {
|
|
278
|
+
const response = await fetch(mcpRevokeEndpoint(connection.endpoint), {
|
|
279
|
+
method: "POST",
|
|
280
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
281
|
+
body: new URLSearchParams({ token: connection.token }).toString(),
|
|
282
|
+
// Bounded, because an unreachable host is the expected failure here and
|
|
283
|
+
// a hung uninstall is worse than one that says it could not revoke.
|
|
284
|
+
signal: AbortSignal.timeout(15e3)
|
|
285
|
+
});
|
|
286
|
+
return response.ok;
|
|
287
|
+
} catch {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function claudeConnectionFrom(output) {
|
|
292
|
+
const url = /^\s*URL:\s*(\S+)\s*$/m.exec(output);
|
|
293
|
+
const authorization = /^\s*Authorization:\s*(Bearer\s+\S+)\s*$/m.exec(output);
|
|
294
|
+
if (!url) return null;
|
|
295
|
+
const token = bearerToken(authorization?.[1]);
|
|
296
|
+
return token ? { endpoint: url[1], token } : null;
|
|
297
|
+
}
|
|
298
|
+
function claudeSaysNothingToRemove(output) {
|
|
299
|
+
return new RegExp(`No MCP server named\\s+"?${MCP_SERVER_KEY}"?`, "i").test(output);
|
|
300
|
+
}
|
|
301
|
+
function claudeConnection() {
|
|
302
|
+
try {
|
|
303
|
+
return claudeConnectionFrom(
|
|
304
|
+
execFileSync("claude", ["mcp", "get", MCP_SERVER_KEY], {
|
|
305
|
+
encoding: "utf8",
|
|
306
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
307
|
+
})
|
|
308
|
+
);
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
160
311
|
}
|
|
161
312
|
}
|
|
162
313
|
function hasCli(bin) {
|
|
@@ -178,7 +329,24 @@ function openCursor() {
|
|
|
178
329
|
}
|
|
179
330
|
return false;
|
|
180
331
|
}
|
|
332
|
+
function chosenClients(flags) {
|
|
333
|
+
const cursor = flags.has("--cursor");
|
|
334
|
+
const claude = flags.has("--claude");
|
|
335
|
+
const codex = flags.has("--codex");
|
|
336
|
+
const explicit = cursor || claude || codex;
|
|
337
|
+
return explicit ? { cursor, claude, codex, explicit } : { cursor: true, claude: true, codex: true, explicit };
|
|
338
|
+
}
|
|
181
339
|
async function main() {
|
|
340
|
+
const argv = process.argv.slice(2);
|
|
341
|
+
const flags = new Set(argv.filter((arg) => arg.startsWith("--")));
|
|
342
|
+
const command = argv.find((arg) => !arg.startsWith("--")) ?? "install";
|
|
343
|
+
if (command === "uninstall") return uninstall(flags);
|
|
344
|
+
if (command !== "install") {
|
|
345
|
+
stop(`unknown command "${command}". This tool takes "install" (the default) or "uninstall".`);
|
|
346
|
+
}
|
|
347
|
+
return install(flags);
|
|
348
|
+
}
|
|
349
|
+
async function install(flags) {
|
|
182
350
|
const token = process.env[CONNECT_TOKEN_ENV]?.trim();
|
|
183
351
|
const endpoint = process.env[CONNECT_URL_ENV]?.trim() || CONNECT_DEFAULT_MCP_URL;
|
|
184
352
|
if (!token) {
|
|
@@ -186,15 +354,12 @@ async function main() {
|
|
|
186
354
|
`this command needs ${CONNECT_TOKEN_ENV} set on the same line. Copy the whole command from Superhands setup and run it unchanged.`
|
|
187
355
|
);
|
|
188
356
|
}
|
|
189
|
-
const
|
|
190
|
-
const onlyCursor = flags.has("--cursor");
|
|
191
|
-
const onlyClaude = flags.has("--claude");
|
|
192
|
-
const onlyCodex = flags.has("--codex");
|
|
193
|
-
const autodetect = !onlyCursor && !onlyClaude && !onlyCodex;
|
|
357
|
+
const wanted = chosenClients(flags);
|
|
194
358
|
let connected = 0;
|
|
195
359
|
let attempted = 0;
|
|
360
|
+
const skillVersions = [];
|
|
196
361
|
const cursorDir = join(homedir(), ".cursor");
|
|
197
|
-
if (
|
|
362
|
+
if (wanted.cursor && (wanted.explicit || existsSync(cursorDir))) {
|
|
198
363
|
attempted += 1;
|
|
199
364
|
const configPath = join(cursorDir, "mcp.json");
|
|
200
365
|
let existing = null;
|
|
@@ -224,7 +389,7 @@ async function main() {
|
|
|
224
389
|
} else {
|
|
225
390
|
say(` Open Cursor (restart it if it was running), then enable ${MCP_SERVER_KEY} under Settings \u2192 MCP.`);
|
|
226
391
|
}
|
|
227
|
-
writeSkill(cursorDir);
|
|
392
|
+
skillVersions.push(writeSkill(cursorDir));
|
|
228
393
|
connected += 1;
|
|
229
394
|
} catch {
|
|
230
395
|
say(`Cursor \u2014 ${configPath} could not be written, so it was left as it was.`);
|
|
@@ -238,7 +403,7 @@ async function main() {
|
|
|
238
403
|
}
|
|
239
404
|
}
|
|
240
405
|
}
|
|
241
|
-
if (
|
|
406
|
+
if (wanted.claude && (wanted.explicit || hasCli("claude"))) {
|
|
242
407
|
attempted += 1;
|
|
243
408
|
try {
|
|
244
409
|
try {
|
|
@@ -265,14 +430,14 @@ async function main() {
|
|
|
265
430
|
);
|
|
266
431
|
say(`Claude Code \u2014 added the ${MCP_SERVER_KEY} server (user scope).`);
|
|
267
432
|
say(" Open a new claude session and it connects on start.");
|
|
268
|
-
writeSkill(join(homedir(), ".claude"));
|
|
433
|
+
skillVersions.push(writeSkill(join(homedir(), ".claude")));
|
|
269
434
|
connected += 1;
|
|
270
435
|
} catch {
|
|
271
436
|
say("Claude Code \u2014 `claude mcp add` failed. Run it by hand from the Superhands MCP page.");
|
|
272
437
|
}
|
|
273
438
|
}
|
|
274
439
|
const codexDir = join(homedir(), ".codex");
|
|
275
|
-
if (
|
|
440
|
+
if (wanted.codex && (wanted.explicit || existsSync(codexDir) || hasCli("codex"))) {
|
|
276
441
|
attempted += 1;
|
|
277
442
|
const configPath = join(codexDir, "config.toml");
|
|
278
443
|
let existing = "";
|
|
@@ -285,7 +450,7 @@ async function main() {
|
|
|
285
450
|
if (result.alreadyPresent) {
|
|
286
451
|
say(`Codex \u2014 ${configPath} already names a ${MCP_SERVER_KEY} server, so it was left as it is.`);
|
|
287
452
|
say(" If that connection is stale, update the http_headers Authorization value there by hand.");
|
|
288
|
-
writeSkill(codexDir);
|
|
453
|
+
skillVersions.push(writeSkill(codexDir));
|
|
289
454
|
connected += 1;
|
|
290
455
|
} else {
|
|
291
456
|
try {
|
|
@@ -295,7 +460,7 @@ async function main() {
|
|
|
295
460
|
result.repaired ? `Codex \u2014 replaced the ${MCP_SERVER_KEY} entry in ${configPath}: its old bearer_token spelling makes current Codex reject the whole config.` : `Codex \u2014 added the ${MCP_SERVER_KEY} server to ${configPath}.`
|
|
296
461
|
);
|
|
297
462
|
say(" The app and the CLI both read this config \u2014 open either and it connects when a session starts.");
|
|
298
|
-
writeSkill(codexDir);
|
|
463
|
+
skillVersions.push(writeSkill(codexDir));
|
|
299
464
|
connected += 1;
|
|
300
465
|
} catch {
|
|
301
466
|
say(`Codex \u2014 ${configPath} could not be written, so it was left as it was.`);
|
|
@@ -312,8 +477,124 @@ async function main() {
|
|
|
312
477
|
attempted > 0 ? "nothing was connected. Fix the issue above, then run this command again." : `no supported client was found on this machine. Add it to yours by hand: the server URL is ${endpoint}, sent with the header "Authorization: ${mcpAuthorizationHeader(token)}".`
|
|
313
478
|
);
|
|
314
479
|
}
|
|
480
|
+
const installed = skillVersions.length > 0 ? Math.min(...skillVersions) : 0;
|
|
481
|
+
if (installed > 0) {
|
|
482
|
+
await reportSkillVersion({ endpoint, token, version: installed });
|
|
483
|
+
}
|
|
315
484
|
say("Done. Superhands notices the moment an agent connects \u2014 setup ticks by itself.");
|
|
316
485
|
}
|
|
486
|
+
async function uninstall(flags) {
|
|
487
|
+
const wanted = chosenClients(flags);
|
|
488
|
+
const cursorDir = join(homedir(), ".cursor");
|
|
489
|
+
const codexDir = join(homedir(), ".codex");
|
|
490
|
+
const cursorConfigPath = join(cursorDir, "mcp.json");
|
|
491
|
+
const codexConfigPath = join(codexDir, "config.toml");
|
|
492
|
+
const found = [];
|
|
493
|
+
const claudeAvailable = wanted.claude && hasCli("claude");
|
|
494
|
+
let cursorText = null;
|
|
495
|
+
if (wanted.cursor) {
|
|
496
|
+
try {
|
|
497
|
+
cursorText = readFileSync(cursorConfigPath, "utf8");
|
|
498
|
+
} catch {
|
|
499
|
+
cursorText = null;
|
|
500
|
+
}
|
|
501
|
+
const connection = cursorConnection(cursorText);
|
|
502
|
+
if (connection) found.push(connection);
|
|
503
|
+
}
|
|
504
|
+
if (claudeAvailable) {
|
|
505
|
+
const connection = claudeConnection();
|
|
506
|
+
if (connection) found.push(connection);
|
|
507
|
+
}
|
|
508
|
+
let codexText = "";
|
|
509
|
+
if (wanted.codex) {
|
|
510
|
+
try {
|
|
511
|
+
codexText = readFileSync(codexConfigPath, "utf8");
|
|
512
|
+
} catch {
|
|
513
|
+
codexText = "";
|
|
514
|
+
}
|
|
515
|
+
const connection = codexConnection(codexText);
|
|
516
|
+
if (connection) found.push(connection);
|
|
517
|
+
}
|
|
518
|
+
const unique = new Map(found.map((c) => [`${c.endpoint}\0${c.token}`, c]));
|
|
519
|
+
let revoked = 0;
|
|
520
|
+
let unreachable = 0;
|
|
521
|
+
for (const connection of unique.values()) {
|
|
522
|
+
if (await revokeToken(connection)) revoked += 1;
|
|
523
|
+
else unreachable += 1;
|
|
524
|
+
}
|
|
525
|
+
if (revoked > 0) {
|
|
526
|
+
say(
|
|
527
|
+
`Ended ${revoked} credential${revoked === 1 ? "" : "s"} on the server \u2014 those agents now show as disconnected in Superhands.`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
if (unreachable > 0) {
|
|
531
|
+
say(
|
|
532
|
+
`Could not reach Superhands to end ${unreachable} credential${unreachable === 1 ? "" : "s"} \u2014 cleaning this machine anyway. Press Disconnect on the Superhands Agents page to finish it.`
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
let removed = 0;
|
|
536
|
+
if (wanted.cursor) {
|
|
537
|
+
let stripped = null;
|
|
538
|
+
try {
|
|
539
|
+
stripped = cursorConfigWithoutServer(cursorText);
|
|
540
|
+
} catch {
|
|
541
|
+
say(`Cursor \u2014 ${cursorConfigPath} is not valid JSON, so it was left untouched.`);
|
|
542
|
+
say(` Remove the "${MCP_SERVER_KEY}" entry under "mcpServers" by hand.`);
|
|
543
|
+
}
|
|
544
|
+
if (stripped?.removed) {
|
|
545
|
+
try {
|
|
546
|
+
writeFileSync(cursorConfigPath, stripped.text);
|
|
547
|
+
say(`Cursor \u2014 removed the ${MCP_SERVER_KEY} server from ${cursorConfigPath}.`);
|
|
548
|
+
removed += 1;
|
|
549
|
+
} catch {
|
|
550
|
+
say(`Cursor \u2014 ${cursorConfigPath} could not be written, so it was left as it was.`);
|
|
551
|
+
say(
|
|
552
|
+
` A sandboxed agent usually cannot write outside its workspace. Run this same command in a regular terminal, or delete the "${MCP_SERVER_KEY}" entry under "mcpServers" in that file yourself.`
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (removeSkill(cursorDir)) removed += 1;
|
|
557
|
+
}
|
|
558
|
+
if (claudeAvailable) {
|
|
559
|
+
const result = spawnSync("claude", ["mcp", "remove", "--scope", "user", MCP_SERVER_KEY], {
|
|
560
|
+
encoding: "utf8"
|
|
561
|
+
});
|
|
562
|
+
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
563
|
+
if (!result.error && result.status === 0) {
|
|
564
|
+
say(`Claude Code \u2014 removed the ${MCP_SERVER_KEY} server (user scope).`);
|
|
565
|
+
removed += 1;
|
|
566
|
+
} else if (!claudeSaysNothingToRemove(output)) {
|
|
567
|
+
say(
|
|
568
|
+
`Claude Code \u2014 \`claude mcp remove --scope user ${MCP_SERVER_KEY}\` failed. Run it by hand.`
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
if (removeSkill(join(homedir(), ".claude"))) removed += 1;
|
|
572
|
+
}
|
|
573
|
+
if (wanted.codex) {
|
|
574
|
+
const stripped = codexConfigWithoutServer(codexText);
|
|
575
|
+
if (stripped.removed) {
|
|
576
|
+
try {
|
|
577
|
+
writeFileSync(codexConfigPath, stripped.text);
|
|
578
|
+
say(`Codex \u2014 removed the ${MCP_SERVER_KEY} server from ${codexConfigPath}.`);
|
|
579
|
+
removed += 1;
|
|
580
|
+
} catch {
|
|
581
|
+
say(`Codex \u2014 ${codexConfigPath} could not be written, so it was left as it was.`);
|
|
582
|
+
say(
|
|
583
|
+
` A sandboxed agent usually cannot write outside its workspace. Run this same command in a regular terminal, or delete the [mcp_servers.${MCP_SERVER_KEY}] block from that file yourself.`
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
} else if (stripped.foreign) {
|
|
587
|
+
say(`Codex \u2014 ${codexConfigPath} names a ${MCP_SERVER_KEY} server this tool did not write.`);
|
|
588
|
+
say(` Delete its [mcp_servers.${MCP_SERVER_KEY}] block by hand.`);
|
|
589
|
+
}
|
|
590
|
+
if (removeSkill(codexDir)) removed += 1;
|
|
591
|
+
}
|
|
592
|
+
if (removed === 0 && unique.size === 0) {
|
|
593
|
+
say("Nothing to remove \u2014 Superhands is not installed for these clients on this machine.");
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
say("Done. Restart any client that was open to drop the connection it already loaded.");
|
|
597
|
+
}
|
|
317
598
|
|
|
318
599
|
// connect-client.ts
|
|
319
600
|
await main();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@super-hands/connect",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Connect the coding agents on this machine to your team's Superhands MCP server
|
|
3
|
+
"version": "0.1.9",
|
|
4
|
+
"description": "Connect the coding agents on this machine to your team's Superhands MCP server, and take it back off again with `uninstall`. Writes each client's own config; reads no repository, uploads nothing.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"superhands-connect": "client.mjs"
|
|
7
7
|
},
|