@super-hands/connect 0.1.7 → 0.1.14
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 +48 -3
- package/client.mjs +472 -37
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -20,9 +20,54 @@ 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. Re-running it later to pick up a newer skill needs
|
|
31
|
+
no credential — see `update`, below.
|
|
32
|
+
|
|
33
|
+
## Updating it
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
npx -y @super-hands/connect@latest update
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Brings the skill on an already-connected machine up to whatever this build
|
|
40
|
+
ships. It takes **no token**: it reads the endpoint and the credential back out
|
|
41
|
+
of the configs the install wrote, so there is nothing to fetch from Superhands
|
|
42
|
+
and nothing new is minted. It touches no MCP entry — the entry is how the
|
|
43
|
+
credential was found, so it is already right.
|
|
44
|
+
|
|
45
|
+
A client counts as connected when its own config names the `superhands` server;
|
|
46
|
+
anything else is skipped, and a machine with no Superhands entry at all is told
|
|
47
|
+
to install first rather than half-connected. An installed skill at the same
|
|
48
|
+
version or newer is left alone, so your own edits survive an update and the run
|
|
49
|
+
says which files it left as they were. The same `--cursor` / `--claude` /
|
|
50
|
+
`--codex` flags narrow it to one client.
|
|
51
|
+
|
|
52
|
+
## Removing it
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
npx -y @super-hands/connect@latest uninstall
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The exact inverse, in this order: it reads every Superhands credential on the
|
|
59
|
+
machine out of the configs, hands each one back to the deployment that issued
|
|
60
|
+
it, and only then removes the entries and the skill files. The other way round
|
|
61
|
+
would delete the only copy of the credential needed to tell the server
|
|
62
|
+
anything.
|
|
63
|
+
|
|
64
|
+
It removes only what this tool wrote — one key out of `~/.cursor/mcp.json`,
|
|
65
|
+
its own block out of `~/.codex/config.toml`, and the Claude Code entry through
|
|
66
|
+
`claude mcp remove`. Everything else in those files stays. Running it twice, or
|
|
67
|
+
on a machine that was never connected, does nothing and says so; a machine that
|
|
68
|
+
cannot reach the internet is still cleaned locally, with a warning that the
|
|
69
|
+
credential is still live. The same `--cursor` / `--claude` / `--codex` flags
|
|
70
|
+
remove one client and leave the rest.
|
|
26
71
|
|
|
27
72
|
## What it does not do
|
|
28
73
|
|
|
@@ -32,6 +77,6 @@ into the client configs above. The token can be revoked at any time from the
|
|
|
32
77
|
Superhands MCP page.
|
|
33
78
|
|
|
34
79
|
This file is generated from
|
|
35
|
-
[`lib/connect-client-entry.ts`](https://github.com/superhandsai/
|
|
80
|
+
[`lib/connect-client-entry.ts`](https://github.com/superhandsai/superhands/blob/main/lib/connect-client-entry.ts)
|
|
36
81
|
in the Superhands repository — the published bytes are that commit's, and are
|
|
37
82
|
not minified.
|
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
|
|
|
@@ -24,10 +24,14 @@ var CONNECT_URL_ENV = "SUPERHANDS_MCP_URL";
|
|
|
24
24
|
var CONNECT_DEFAULT_MCP_URL = "https://app.superhands.ai/api/mcp";
|
|
25
25
|
var CONNECT_CLIENT_PACKAGE = "@super-hands/connect";
|
|
26
26
|
var CONNECT_CLIENT_SPEC = `${CONNECT_CLIENT_PACKAGE}@latest`;
|
|
27
|
+
var CONNECT_UPDATE_COMMAND = `npx -y ${CONNECT_CLIENT_SPEC} update`;
|
|
27
28
|
var MCP_SERVER_KEY = "superhands";
|
|
28
29
|
function mcpAuthorizationHeader(token) {
|
|
29
30
|
return `Bearer ${token}`;
|
|
30
31
|
}
|
|
32
|
+
function mcpRevokeEndpoint(mcpEndpoint) {
|
|
33
|
+
return `${mcpEndpoint.replace(/\/+$/, "")}/oauth/revoke`;
|
|
34
|
+
}
|
|
31
35
|
function codexConfigBlock(args) {
|
|
32
36
|
return [
|
|
33
37
|
`[mcp_servers.${MCP_SERVER_KEY}]`,
|
|
@@ -37,11 +41,11 @@ function codexConfigBlock(args) {
|
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
// lib/connect-skill.ts
|
|
40
|
-
var CONNECT_SKILL_VERSION =
|
|
44
|
+
var CONNECT_SKILL_VERSION = 9;
|
|
41
45
|
var CONNECT_SKILL_DIR = MCP_SERVER_KEY;
|
|
42
46
|
var CONNECT_SKILL_FILENAME = "SKILL.md";
|
|
43
47
|
var VERSION_MARKER = `[//]: # (superhands-skill-version: ${CONNECT_SKILL_VERSION})`;
|
|
44
|
-
var CONNECT_SKILL_DESCRIPTION = `Build UI the way this team has decided it should be built. Use BEFORE building, changing, or restyling any page, screen, view, form, or component \u2014 "build a page", "add a screen", "create a form", "make a dashboard", "redesign this", "new landing page" \u2014 and when asked how this product should look or behave. Reads the team's
|
|
48
|
+
var CONNECT_SKILL_DESCRIPTION = `Build UI the way this team has decided it should be built. Use BEFORE building, changing, or restyling any page, screen, view, form, or component \u2014 "build a page", "add a screen", "create a form", "make a dashboard", "redesign this", "new landing page" \u2014 and when asked how this product should look or behave. Reads the team's Standards and Playbooks from their Superhands MCP server.`;
|
|
45
49
|
var CONNECT_SKILL_CONTENT = `---
|
|
46
50
|
name: ${CONNECT_SKILL_DIR}
|
|
47
51
|
description: ${CONNECT_SKILL_DESCRIPTION}
|
|
@@ -52,34 +56,107 @@ ${VERSION_MARKER}
|
|
|
52
56
|
# Superhands
|
|
53
57
|
|
|
54
58
|
This machine is connected to the team's Superhands server over MCP (server
|
|
55
|
-
key \`${MCP_SERVER_KEY}\`). It holds the
|
|
56
|
-
|
|
57
|
-
engineers' pull requests are
|
|
59
|
+
key \`${MCP_SERVER_KEY}\`). It holds the team's **Instructions** in two kinds:
|
|
60
|
+
their **Standards**, the decisions they have written about how their product
|
|
61
|
+
should look and behave \u2014 the same decisions their engineers' pull requests are
|
|
62
|
+
reviewed against \u2014 and their **Playbooks**, procedures they imported for their
|
|
63
|
+
agents to follow.
|
|
58
64
|
|
|
59
65
|
Before you build, change, or restyle any UI:
|
|
60
66
|
|
|
61
|
-
1. Call the \`
|
|
67
|
+
1. Call the \`superhands_get_instructions\` MCP tool with \`intent\` set to what
|
|
62
68
|
you have been asked to build, in plain language \u2014 for example "a sign-in
|
|
63
69
|
page with email and password and an error state", and
|
|
64
70
|
\`skill_version: ${CONNECT_SKILL_VERSION}\` \u2014 the version of this file, so
|
|
65
|
-
the server can tell you when this file is out of date.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
the server can tell you when this file is out of date. Both sections come
|
|
72
|
+
back narrowed to that work, with everything not chosen still named.
|
|
73
|
+
- Pass \`paths\` too, when you already know which files this work touches \u2014
|
|
74
|
+
the handful you have open or are about to edit, repository-relative, for
|
|
75
|
+
example \`["app/marketing/pricing.tsx"]\`. The team writes down where each
|
|
76
|
+
Standard applies, and a path answers that far better than a description
|
|
77
|
+
does. Leave it out if you do not know yet; it narrows nothing away.
|
|
78
|
+
- If that tool is not available on this server, it is an older deployment:
|
|
79
|
+
call \`superhands_get_guidance\` and \`superhands_get_skill\` instead,
|
|
80
|
+
with the same \`intent\` \u2014 those two are older and take no \`paths\`.
|
|
81
|
+
2. Plan against what comes back, and note which parts of your plan each set of
|
|
82
|
+
keys covers. The Playbooks section renders what to read first, then names
|
|
83
|
+
under **Reachable** the modules judged to matter to parts you have not
|
|
84
|
+
started \u2014 those keys are what the next step asks with \u2014 and ends with an
|
|
85
|
+
index of the modules it did **not** choose, for anything the selection
|
|
86
|
+
missed. A part of your plan with no keys against it is a real gap in what
|
|
87
|
+
the team has written down; say so rather than quietly using your own
|
|
88
|
+
defaults.
|
|
89
|
+
3. As you start each part \u2014 the sidebar, then the table, then the form \u2014 call
|
|
90
|
+
\`superhands_get_playbook_modules\` with the reachable keys covering the
|
|
91
|
+
part you are starting now. A key ending in \`*\` takes everything under
|
|
92
|
+
it, so \`better-ui/*\` is a whole skill and \`better-ui/SKILL#*\` is one
|
|
93
|
+
file. It runs no selection and makes no model call, so it is a plain, fast
|
|
94
|
+
lookup and it returns exactly what you named.
|
|
95
|
+
- Fetch each part's modules **when you reach that part**, not all of them at
|
|
96
|
+
the start. These are instructions to follow, not facts to know, so modules
|
|
97
|
+
for a part you are not building are a page of directions about the wrong
|
|
98
|
+
thing sitting in your context while you work.
|
|
99
|
+
- The Standards from step 1 need no such loop. They are a short list of
|
|
100
|
+
constraints on the whole task, so one read at the start is the right read.
|
|
101
|
+
- If that tool is not available on this server, it is an older deployment:
|
|
102
|
+
build from what step 1 already returned and skip this step. Do not ask
|
|
103
|
+
for the whole library instead \u2014 that is the wall of text step 1 avoided.
|
|
104
|
+
4. Build to what comes back. Apply both sections. Where imported Playbook text
|
|
105
|
+
directly contradicts a Standard, the Standard governs. Where a Standard
|
|
106
|
+
names a case it deliberately does not govern, your own judgment resumes
|
|
107
|
+
there.
|
|
108
|
+
5. When you have finished \u2014 not part-way through \u2014 send the files you wrote or
|
|
70
109
|
changed with \`superhands_report_completed_build\`, naming the \`route\` if
|
|
71
110
|
you know it. The team looks at what their agents build there; it records
|
|
72
111
|
and does not review, so a failed report costs the work nothing.
|
|
73
|
-
|
|
74
|
-
work passes it or was checked. Reading
|
|
75
|
-
a build is not a review, and nothing has looked at what you built.
|
|
112
|
+
6. When you finish, say you followed the team's guidance \u2014 never that your
|
|
113
|
+
work passes it or was checked. Reading Instructions is not a review,
|
|
114
|
+
reporting a build is not a review, and nothing has looked at what you built.
|
|
76
115
|
|
|
77
|
-
If the tool answers \`no-
|
|
78
|
-
|
|
116
|
+
If the tool answers \`no-instructions\` (or \`no-guidance\` from an older
|
|
117
|
+
server), carry on with what you were asked for and mention that the team
|
|
118
|
+
writes its Standards and imports its Playbooks on the Superhands Instructions
|
|
119
|
+
page.
|
|
79
120
|
|
|
80
121
|
If the \`${MCP_SERVER_KEY}\` MCP server is not reachable in this session, say
|
|
81
122
|
so rather than guessing at the team's decisions \u2014 it is configured on this
|
|
82
123
|
machine, so a missing connection is worth telling the person about.
|
|
124
|
+
|
|
125
|
+
## Updating Superhands on this machine
|
|
126
|
+
|
|
127
|
+
If the person asks to update or upgrade Superhands, or if a Superhands tool
|
|
128
|
+
tells you this file is out of date, run:
|
|
129
|
+
|
|
130
|
+
\`\`\`
|
|
131
|
+
${CONNECT_UPDATE_COMMAND}
|
|
132
|
+
\`\`\`
|
|
133
|
+
|
|
134
|
+
and tell them what it printed. It reuses the credential already configured on
|
|
135
|
+
this machine, so it needs no token and mints nothing \u2014 it only brings this file
|
|
136
|
+
up to whatever the team's server now expects. Add \`--claude\`, \`--cursor\` or
|
|
137
|
+
\`--codex\` to update one client and leave the rest.
|
|
138
|
+
|
|
139
|
+
Run it when you are asked, or when you have finished the work in front of you \u2014
|
|
140
|
+
never in the middle of a build. The guidance you were given came from the
|
|
141
|
+
server, not from this file, so it is current either way and there is nothing to
|
|
142
|
+
redo.
|
|
143
|
+
|
|
144
|
+
## Removing Superhands from this machine
|
|
145
|
+
|
|
146
|
+
If the person asks to uninstall, remove, or disconnect Superhands, run:
|
|
147
|
+
|
|
148
|
+
\`\`\`
|
|
149
|
+
npx -y ${CONNECT_CLIENT_SPEC} uninstall
|
|
150
|
+
\`\`\`
|
|
151
|
+
|
|
152
|
+
and tell them what it printed. It hands every Superhands credential on this
|
|
153
|
+
machine back to the server, then takes the MCP entries and this file out of
|
|
154
|
+
each client it finds. Add \`--claude\`, \`--cursor\` or \`--codex\` to remove
|
|
155
|
+
one client and leave the rest.
|
|
156
|
+
|
|
157
|
+
Do not do this by hand. Editing an MCP config to take one server out is how
|
|
158
|
+
every other server in that file gets lost, and deleting the files without the
|
|
159
|
+
command leaves credentials live on the server with nothing left to end them.
|
|
83
160
|
`;
|
|
84
161
|
function installedSkillVersion(contents) {
|
|
85
162
|
const match = contents.match(/superhands-skill-version:\s*(\d+)/);
|
|
@@ -96,6 +173,11 @@ function stop(message) {
|
|
|
96
173
|
`);
|
|
97
174
|
process.exit(1);
|
|
98
175
|
}
|
|
176
|
+
function bearerToken(value) {
|
|
177
|
+
if (typeof value !== "string") return null;
|
|
178
|
+
const match = /^Bearer\s+(\S+)$/i.exec(value.trim());
|
|
179
|
+
return match ? match[1] : null;
|
|
180
|
+
}
|
|
99
181
|
function cursorServerEntry(args) {
|
|
100
182
|
return {
|
|
101
183
|
url: args.endpoint,
|
|
@@ -124,6 +206,74 @@ function mergedCursorConfig(existing, args) {
|
|
|
124
206
|
return { text: `${JSON.stringify(config, null, 2)}
|
|
125
207
|
`, replaced };
|
|
126
208
|
}
|
|
209
|
+
function cursorConfigWithoutServer(existing) {
|
|
210
|
+
if (existing === null || existing.trim() === "") return { text: existing ?? "", removed: false };
|
|
211
|
+
let parsed;
|
|
212
|
+
try {
|
|
213
|
+
parsed = JSON.parse(existing);
|
|
214
|
+
} catch {
|
|
215
|
+
throw new Error("not valid JSON");
|
|
216
|
+
}
|
|
217
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
218
|
+
throw new Error("not a JSON object");
|
|
219
|
+
}
|
|
220
|
+
const config = parsed;
|
|
221
|
+
const serversRaw = config.mcpServers;
|
|
222
|
+
if (typeof serversRaw !== "object" || serversRaw === null || Array.isArray(serversRaw)) {
|
|
223
|
+
return { text: existing, removed: false };
|
|
224
|
+
}
|
|
225
|
+
const servers = serversRaw;
|
|
226
|
+
if (!(MCP_SERVER_KEY in servers)) return { text: existing, removed: false };
|
|
227
|
+
delete servers[MCP_SERVER_KEY];
|
|
228
|
+
return { text: `${JSON.stringify(config, null, 2)}
|
|
229
|
+
`, removed: true };
|
|
230
|
+
}
|
|
231
|
+
function cursorNamesServer(existing) {
|
|
232
|
+
if (!existing || existing.trim() === "") return false;
|
|
233
|
+
let parsed;
|
|
234
|
+
try {
|
|
235
|
+
parsed = JSON.parse(existing);
|
|
236
|
+
} catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
const servers = parsed?.mcpServers;
|
|
240
|
+
if (typeof servers !== "object" || servers === null || Array.isArray(servers)) return false;
|
|
241
|
+
return MCP_SERVER_KEY in servers;
|
|
242
|
+
}
|
|
243
|
+
function codexNamesServer(existing) {
|
|
244
|
+
return existing.includes(`[mcp_servers.${MCP_SERVER_KEY}]`);
|
|
245
|
+
}
|
|
246
|
+
function cursorConnection(existing) {
|
|
247
|
+
if (!existing || existing.trim() === "") return null;
|
|
248
|
+
let parsed;
|
|
249
|
+
try {
|
|
250
|
+
parsed = JSON.parse(existing);
|
|
251
|
+
} catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
const entry = parsed?.mcpServers?.[MCP_SERVER_KEY];
|
|
255
|
+
if (!entry || typeof entry.url !== "string") return null;
|
|
256
|
+
const token = bearerToken(entry.headers?.Authorization);
|
|
257
|
+
return token ? { endpoint: entry.url, token } : null;
|
|
258
|
+
}
|
|
259
|
+
var CODEX_OWN_BLOCK = `\\[mcp_servers\\.${MCP_SERVER_KEY}\\]\\nurl = "([^"\\n]*)"\\n(?:http_headers = \\{ "Authorization" = "([^"\\n]*)" \\}|bearer_token = "([^"\\n]*)")`;
|
|
260
|
+
function codexConfigWithoutServer(existing) {
|
|
261
|
+
const block = new RegExp(`${CODEX_OWN_BLOCK}\\n?`);
|
|
262
|
+
if (block.test(existing)) {
|
|
263
|
+
return { text: existing.replace(block, ""), removed: true, foreign: false };
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
text: existing,
|
|
267
|
+
removed: false,
|
|
268
|
+
foreign: existing.includes(`[mcp_servers.${MCP_SERVER_KEY}]`)
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function codexConnection(existing) {
|
|
272
|
+
const match = new RegExp(CODEX_OWN_BLOCK).exec(existing);
|
|
273
|
+
if (!match) return null;
|
|
274
|
+
const token = match[2] ? bearerToken(match[2]) : match[3] ?? null;
|
|
275
|
+
return token ? { endpoint: match[1], token } : null;
|
|
276
|
+
}
|
|
127
277
|
function appendedCodexConfig(existing, args) {
|
|
128
278
|
const staleOwnBlock = new RegExp(
|
|
129
279
|
`\\[mcp_servers\\.${MCP_SERVER_KEY}\\]\\nurl = "[^"\\n]*"\\nbearer_token = "[^"\\n]*"`
|
|
@@ -142,23 +292,107 @@ function appendedCodexConfig(existing, args) {
|
|
|
142
292
|
return { text: `${existing}${sep}${codexConfigBlock(args)}
|
|
143
293
|
`, alreadyPresent: false, repaired: false };
|
|
144
294
|
}
|
|
295
|
+
function skillPathIn(clientDir) {
|
|
296
|
+
return join(clientDir, "skills", CONNECT_SKILL_DIR, CONNECT_SKILL_FILENAME);
|
|
297
|
+
}
|
|
298
|
+
function installedSkillVersionIn(clientDir) {
|
|
299
|
+
try {
|
|
300
|
+
return installedSkillVersion(readFileSync(skillPathIn(clientDir), "utf8"));
|
|
301
|
+
} catch {
|
|
302
|
+
return 0;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
145
305
|
function writeSkill(clientDir) {
|
|
146
|
-
const skillPath =
|
|
306
|
+
const skillPath = skillPathIn(clientDir);
|
|
307
|
+
const existingVersion = installedSkillVersionIn(clientDir);
|
|
308
|
+
if (existingVersion >= CONNECT_SKILL_VERSION) {
|
|
309
|
+
return { version: existingVersion, state: "current" };
|
|
310
|
+
}
|
|
147
311
|
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
312
|
mkdirSync(dirname(skillPath), { recursive: true });
|
|
156
313
|
writeFileSync(skillPath, CONNECT_SKILL_CONTENT);
|
|
157
314
|
say(` Wrote the ${CONNECT_SKILL_DIR} skill to ${skillPath} \u2014 it routes UI work through the team's guidance.`);
|
|
315
|
+
return { version: CONNECT_SKILL_VERSION, state: "wrote" };
|
|
158
316
|
} catch {
|
|
159
317
|
say(` Could not write the ${CONNECT_SKILL_DIR} skill at ${skillPath}. The connection works without it.`);
|
|
318
|
+
return { version: existingVersion, state: "blocked" };
|
|
160
319
|
}
|
|
161
320
|
}
|
|
321
|
+
function connectReportUrl(endpoint) {
|
|
322
|
+
return `${endpoint.replace(/\/+$/, "")}/connect`;
|
|
323
|
+
}
|
|
324
|
+
async function reportSkillVersion(args) {
|
|
325
|
+
try {
|
|
326
|
+
await fetch(connectReportUrl(args.endpoint), {
|
|
327
|
+
method: "POST",
|
|
328
|
+
headers: {
|
|
329
|
+
Authorization: mcpAuthorizationHeader(args.token),
|
|
330
|
+
"content-type": "application/json"
|
|
331
|
+
},
|
|
332
|
+
body: JSON.stringify({ skill_version: args.version }),
|
|
333
|
+
signal: AbortSignal.timeout(5e3)
|
|
334
|
+
});
|
|
335
|
+
} catch {
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function removeSkill(clientDir) {
|
|
339
|
+
const skillPath = join(clientDir, "skills", CONNECT_SKILL_DIR, CONNECT_SKILL_FILENAME);
|
|
340
|
+
if (!existsSync(skillPath)) return false;
|
|
341
|
+
try {
|
|
342
|
+
rmSync(skillPath);
|
|
343
|
+
try {
|
|
344
|
+
rmdirSync(dirname(skillPath));
|
|
345
|
+
} catch {
|
|
346
|
+
}
|
|
347
|
+
say(` Removed the ${CONNECT_SKILL_DIR} skill at ${skillPath}.`);
|
|
348
|
+
return true;
|
|
349
|
+
} catch {
|
|
350
|
+
say(` Could not remove the ${CONNECT_SKILL_DIR} skill at ${skillPath} \u2014 delete it by hand.`);
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async function revokeToken(connection) {
|
|
355
|
+
try {
|
|
356
|
+
const response = await fetch(mcpRevokeEndpoint(connection.endpoint), {
|
|
357
|
+
method: "POST",
|
|
358
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
359
|
+
body: new URLSearchParams({ token: connection.token }).toString(),
|
|
360
|
+
// Bounded, because an unreachable host is the expected failure here and
|
|
361
|
+
// a hung uninstall is worse than one that says it could not revoke.
|
|
362
|
+
signal: AbortSignal.timeout(15e3)
|
|
363
|
+
});
|
|
364
|
+
return response.ok;
|
|
365
|
+
} catch {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function claudeConnectionFrom(output) {
|
|
370
|
+
const url = /^\s*URL:\s*(\S+)\s*$/m.exec(output);
|
|
371
|
+
const authorization = /^\s*Authorization:\s*(Bearer\s+\S+)\s*$/m.exec(output);
|
|
372
|
+
if (!url) return null;
|
|
373
|
+
const token = bearerToken(authorization?.[1]);
|
|
374
|
+
return token ? { endpoint: url[1], token } : null;
|
|
375
|
+
}
|
|
376
|
+
function claudeSaysNothingToRemove(output) {
|
|
377
|
+
return new RegExp(`No MCP server named\\s+"?${MCP_SERVER_KEY}"?`, "i").test(output);
|
|
378
|
+
}
|
|
379
|
+
function claudeConnection() {
|
|
380
|
+
try {
|
|
381
|
+
return claudeConnectionFrom(
|
|
382
|
+
execFileSync("claude", ["mcp", "get", MCP_SERVER_KEY], {
|
|
383
|
+
encoding: "utf8",
|
|
384
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
385
|
+
})
|
|
386
|
+
);
|
|
387
|
+
} catch {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function claudeRegistration() {
|
|
392
|
+
const result = spawnSync("claude", ["mcp", "get", MCP_SERVER_KEY], { encoding: "utf8" });
|
|
393
|
+
if (result.error || result.status !== 0) return { registered: false, connection: null };
|
|
394
|
+
return { registered: true, connection: claudeConnectionFrom(result.stdout ?? "") };
|
|
395
|
+
}
|
|
162
396
|
function hasCli(bin) {
|
|
163
397
|
try {
|
|
164
398
|
execFileSync(bin, ["--version"], { stdio: "ignore" });
|
|
@@ -178,7 +412,27 @@ function openCursor() {
|
|
|
178
412
|
}
|
|
179
413
|
return false;
|
|
180
414
|
}
|
|
415
|
+
function chosenClients(flags) {
|
|
416
|
+
const cursor = flags.has("--cursor");
|
|
417
|
+
const claude = flags.has("--claude");
|
|
418
|
+
const codex = flags.has("--codex");
|
|
419
|
+
const explicit = cursor || claude || codex;
|
|
420
|
+
return explicit ? { cursor, claude, codex, explicit } : { cursor: true, claude: true, codex: true, explicit };
|
|
421
|
+
}
|
|
181
422
|
async function main() {
|
|
423
|
+
const argv = process.argv.slice(2);
|
|
424
|
+
const flags = new Set(argv.filter((arg) => arg.startsWith("--")));
|
|
425
|
+
const command = argv.find((arg) => !arg.startsWith("--")) ?? "install";
|
|
426
|
+
if (command === "uninstall") return uninstall(flags);
|
|
427
|
+
if (command === "update") return update(flags);
|
|
428
|
+
if (command !== "install") {
|
|
429
|
+
stop(
|
|
430
|
+
`unknown command "${command}". This tool takes "install" (the default), "update" or "uninstall".`
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
return install(flags);
|
|
434
|
+
}
|
|
435
|
+
async function install(flags) {
|
|
182
436
|
const token = process.env[CONNECT_TOKEN_ENV]?.trim();
|
|
183
437
|
const endpoint = process.env[CONNECT_URL_ENV]?.trim() || CONNECT_DEFAULT_MCP_URL;
|
|
184
438
|
if (!token) {
|
|
@@ -186,15 +440,12 @@ async function main() {
|
|
|
186
440
|
`this command needs ${CONNECT_TOKEN_ENV} set on the same line. Copy the whole command from Superhands setup and run it unchanged.`
|
|
187
441
|
);
|
|
188
442
|
}
|
|
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;
|
|
443
|
+
const wanted = chosenClients(flags);
|
|
194
444
|
let connected = 0;
|
|
195
445
|
let attempted = 0;
|
|
446
|
+
const skillVersions = [];
|
|
196
447
|
const cursorDir = join(homedir(), ".cursor");
|
|
197
|
-
if (
|
|
448
|
+
if (wanted.cursor && (wanted.explicit || existsSync(cursorDir))) {
|
|
198
449
|
attempted += 1;
|
|
199
450
|
const configPath = join(cursorDir, "mcp.json");
|
|
200
451
|
let existing = null;
|
|
@@ -224,7 +475,7 @@ async function main() {
|
|
|
224
475
|
} else {
|
|
225
476
|
say(` Open Cursor (restart it if it was running), then enable ${MCP_SERVER_KEY} under Settings \u2192 MCP.`);
|
|
226
477
|
}
|
|
227
|
-
writeSkill(cursorDir);
|
|
478
|
+
skillVersions.push(writeSkill(cursorDir).version);
|
|
228
479
|
connected += 1;
|
|
229
480
|
} catch {
|
|
230
481
|
say(`Cursor \u2014 ${configPath} could not be written, so it was left as it was.`);
|
|
@@ -238,7 +489,7 @@ async function main() {
|
|
|
238
489
|
}
|
|
239
490
|
}
|
|
240
491
|
}
|
|
241
|
-
if (
|
|
492
|
+
if (wanted.claude && (wanted.explicit || hasCli("claude"))) {
|
|
242
493
|
attempted += 1;
|
|
243
494
|
try {
|
|
244
495
|
try {
|
|
@@ -265,14 +516,14 @@ async function main() {
|
|
|
265
516
|
);
|
|
266
517
|
say(`Claude Code \u2014 added the ${MCP_SERVER_KEY} server (user scope).`);
|
|
267
518
|
say(" Open a new claude session and it connects on start.");
|
|
268
|
-
writeSkill(join(homedir(), ".claude"));
|
|
519
|
+
skillVersions.push(writeSkill(join(homedir(), ".claude")).version);
|
|
269
520
|
connected += 1;
|
|
270
521
|
} catch {
|
|
271
522
|
say("Claude Code \u2014 `claude mcp add` failed. Run it by hand from the Superhands MCP page.");
|
|
272
523
|
}
|
|
273
524
|
}
|
|
274
525
|
const codexDir = join(homedir(), ".codex");
|
|
275
|
-
if (
|
|
526
|
+
if (wanted.codex && (wanted.explicit || existsSync(codexDir) || hasCli("codex"))) {
|
|
276
527
|
attempted += 1;
|
|
277
528
|
const configPath = join(codexDir, "config.toml");
|
|
278
529
|
let existing = "";
|
|
@@ -285,7 +536,7 @@ async function main() {
|
|
|
285
536
|
if (result.alreadyPresent) {
|
|
286
537
|
say(`Codex \u2014 ${configPath} already names a ${MCP_SERVER_KEY} server, so it was left as it is.`);
|
|
287
538
|
say(" If that connection is stale, update the http_headers Authorization value there by hand.");
|
|
288
|
-
writeSkill(codexDir);
|
|
539
|
+
skillVersions.push(writeSkill(codexDir).version);
|
|
289
540
|
connected += 1;
|
|
290
541
|
} else {
|
|
291
542
|
try {
|
|
@@ -295,7 +546,7 @@ async function main() {
|
|
|
295
546
|
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
547
|
);
|
|
297
548
|
say(" The app and the CLI both read this config \u2014 open either and it connects when a session starts.");
|
|
298
|
-
writeSkill(codexDir);
|
|
549
|
+
skillVersions.push(writeSkill(codexDir).version);
|
|
299
550
|
connected += 1;
|
|
300
551
|
} catch {
|
|
301
552
|
say(`Codex \u2014 ${configPath} could not be written, so it was left as it was.`);
|
|
@@ -312,8 +563,192 @@ async function main() {
|
|
|
312
563
|
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
564
|
);
|
|
314
565
|
}
|
|
566
|
+
const installed = skillVersions.length > 0 ? Math.min(...skillVersions) : 0;
|
|
567
|
+
if (installed > 0) {
|
|
568
|
+
await reportSkillVersion({ endpoint, token, version: installed });
|
|
569
|
+
}
|
|
315
570
|
say("Done. Superhands notices the moment an agent connects \u2014 setup ticks by itself.");
|
|
316
571
|
}
|
|
572
|
+
async function update(flags) {
|
|
573
|
+
const wanted = chosenClients(flags);
|
|
574
|
+
const cursorDir = join(homedir(), ".cursor");
|
|
575
|
+
const claudeDir = join(homedir(), ".claude");
|
|
576
|
+
const codexDir = join(homedir(), ".codex");
|
|
577
|
+
const found = [];
|
|
578
|
+
const skillVersions = [];
|
|
579
|
+
let configured = 0;
|
|
580
|
+
let rewritten = 0;
|
|
581
|
+
function refresh(product, clientDir, connection) {
|
|
582
|
+
configured += 1;
|
|
583
|
+
if (connection) found.push(connection);
|
|
584
|
+
const before = installedSkillVersionIn(clientDir);
|
|
585
|
+
if (before >= CONNECT_SKILL_VERSION) {
|
|
586
|
+
say(
|
|
587
|
+
`${product} \u2014 its ${CONNECT_SKILL_DIR} skill is already at version ${before}, so it was left as it is.`
|
|
588
|
+
);
|
|
589
|
+
} else if (before === 0) {
|
|
590
|
+
say(
|
|
591
|
+
`${product} \u2014 connected, but has no ${CONNECT_SKILL_DIR} skill; writing version ${CONNECT_SKILL_VERSION}.`
|
|
592
|
+
);
|
|
593
|
+
} else {
|
|
594
|
+
say(`${product} \u2014 updating its ${CONNECT_SKILL_DIR} skill from version ${before} to ${CONNECT_SKILL_VERSION}.`);
|
|
595
|
+
}
|
|
596
|
+
const result = writeSkill(clientDir);
|
|
597
|
+
skillVersions.push(result.version);
|
|
598
|
+
if (result.state === "wrote") rewritten += 1;
|
|
599
|
+
}
|
|
600
|
+
if (wanted.cursor) {
|
|
601
|
+
let text = null;
|
|
602
|
+
try {
|
|
603
|
+
text = readFileSync(join(cursorDir, "mcp.json"), "utf8");
|
|
604
|
+
} catch {
|
|
605
|
+
text = null;
|
|
606
|
+
}
|
|
607
|
+
if (cursorNamesServer(text)) refresh("Cursor", cursorDir, cursorConnection(text));
|
|
608
|
+
}
|
|
609
|
+
if (wanted.claude) {
|
|
610
|
+
const claude = claudeRegistration();
|
|
611
|
+
if (claude.registered) refresh("Claude Code", claudeDir, claude.connection);
|
|
612
|
+
}
|
|
613
|
+
if (wanted.codex) {
|
|
614
|
+
let text = "";
|
|
615
|
+
try {
|
|
616
|
+
text = readFileSync(join(codexDir, "config.toml"), "utf8");
|
|
617
|
+
} catch {
|
|
618
|
+
text = "";
|
|
619
|
+
}
|
|
620
|
+
if (codexNamesServer(text)) refresh("Codex", codexDir, codexConnection(text));
|
|
621
|
+
}
|
|
622
|
+
if (configured === 0) {
|
|
623
|
+
say(
|
|
624
|
+
wanted.explicit ? "Nothing to update \u2014 none of the clients you named has a Superhands server configured." : "Nothing to update \u2014 no client on this machine has a Superhands server configured."
|
|
625
|
+
);
|
|
626
|
+
say(" Connect one first with the command on the Superhands Agents page; it carries the credential.");
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const installed = skillVersions.length > 0 ? Math.min(...skillVersions) : 0;
|
|
630
|
+
if (installed > 0) {
|
|
631
|
+
const unique = new Map(found.map((c) => [`${c.endpoint}\0${c.token}`, c]));
|
|
632
|
+
for (const connection of unique.values()) {
|
|
633
|
+
await reportSkillVersion({ ...connection, version: installed });
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
say(
|
|
637
|
+
rewritten > 0 ? "Done. Open a new session in any client that was already running \u2014 a skill is read when a session starts." : "Done. Everything on this machine was already up to date."
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
async function uninstall(flags) {
|
|
641
|
+
const wanted = chosenClients(flags);
|
|
642
|
+
const cursorDir = join(homedir(), ".cursor");
|
|
643
|
+
const codexDir = join(homedir(), ".codex");
|
|
644
|
+
const cursorConfigPath = join(cursorDir, "mcp.json");
|
|
645
|
+
const codexConfigPath = join(codexDir, "config.toml");
|
|
646
|
+
const found = [];
|
|
647
|
+
const claudeAvailable = wanted.claude && hasCli("claude");
|
|
648
|
+
let cursorText = null;
|
|
649
|
+
if (wanted.cursor) {
|
|
650
|
+
try {
|
|
651
|
+
cursorText = readFileSync(cursorConfigPath, "utf8");
|
|
652
|
+
} catch {
|
|
653
|
+
cursorText = null;
|
|
654
|
+
}
|
|
655
|
+
const connection = cursorConnection(cursorText);
|
|
656
|
+
if (connection) found.push(connection);
|
|
657
|
+
}
|
|
658
|
+
if (claudeAvailable) {
|
|
659
|
+
const connection = claudeConnection();
|
|
660
|
+
if (connection) found.push(connection);
|
|
661
|
+
}
|
|
662
|
+
let codexText = "";
|
|
663
|
+
if (wanted.codex) {
|
|
664
|
+
try {
|
|
665
|
+
codexText = readFileSync(codexConfigPath, "utf8");
|
|
666
|
+
} catch {
|
|
667
|
+
codexText = "";
|
|
668
|
+
}
|
|
669
|
+
const connection = codexConnection(codexText);
|
|
670
|
+
if (connection) found.push(connection);
|
|
671
|
+
}
|
|
672
|
+
const unique = new Map(found.map((c) => [`${c.endpoint}\0${c.token}`, c]));
|
|
673
|
+
let revoked = 0;
|
|
674
|
+
let unreachable = 0;
|
|
675
|
+
for (const connection of unique.values()) {
|
|
676
|
+
if (await revokeToken(connection)) revoked += 1;
|
|
677
|
+
else unreachable += 1;
|
|
678
|
+
}
|
|
679
|
+
if (revoked > 0) {
|
|
680
|
+
say(
|
|
681
|
+
`Ended ${revoked} credential${revoked === 1 ? "" : "s"} on the server \u2014 those agents now show as disconnected in Superhands.`
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
if (unreachable > 0) {
|
|
685
|
+
say(
|
|
686
|
+
`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.`
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
let removed = 0;
|
|
690
|
+
if (wanted.cursor) {
|
|
691
|
+
let stripped = null;
|
|
692
|
+
try {
|
|
693
|
+
stripped = cursorConfigWithoutServer(cursorText);
|
|
694
|
+
} catch {
|
|
695
|
+
say(`Cursor \u2014 ${cursorConfigPath} is not valid JSON, so it was left untouched.`);
|
|
696
|
+
say(` Remove the "${MCP_SERVER_KEY}" entry under "mcpServers" by hand.`);
|
|
697
|
+
}
|
|
698
|
+
if (stripped?.removed) {
|
|
699
|
+
try {
|
|
700
|
+
writeFileSync(cursorConfigPath, stripped.text);
|
|
701
|
+
say(`Cursor \u2014 removed the ${MCP_SERVER_KEY} server from ${cursorConfigPath}.`);
|
|
702
|
+
removed += 1;
|
|
703
|
+
} catch {
|
|
704
|
+
say(`Cursor \u2014 ${cursorConfigPath} could not be written, so it was left as it was.`);
|
|
705
|
+
say(
|
|
706
|
+
` 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.`
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (removeSkill(cursorDir)) removed += 1;
|
|
711
|
+
}
|
|
712
|
+
if (claudeAvailable) {
|
|
713
|
+
const result = spawnSync("claude", ["mcp", "remove", "--scope", "user", MCP_SERVER_KEY], {
|
|
714
|
+
encoding: "utf8"
|
|
715
|
+
});
|
|
716
|
+
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
717
|
+
if (!result.error && result.status === 0) {
|
|
718
|
+
say(`Claude Code \u2014 removed the ${MCP_SERVER_KEY} server (user scope).`);
|
|
719
|
+
removed += 1;
|
|
720
|
+
} else if (!claudeSaysNothingToRemove(output)) {
|
|
721
|
+
say(
|
|
722
|
+
`Claude Code \u2014 \`claude mcp remove --scope user ${MCP_SERVER_KEY}\` failed. Run it by hand.`
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
if (removeSkill(join(homedir(), ".claude"))) removed += 1;
|
|
726
|
+
}
|
|
727
|
+
if (wanted.codex) {
|
|
728
|
+
const stripped = codexConfigWithoutServer(codexText);
|
|
729
|
+
if (stripped.removed) {
|
|
730
|
+
try {
|
|
731
|
+
writeFileSync(codexConfigPath, stripped.text);
|
|
732
|
+
say(`Codex \u2014 removed the ${MCP_SERVER_KEY} server from ${codexConfigPath}.`);
|
|
733
|
+
removed += 1;
|
|
734
|
+
} catch {
|
|
735
|
+
say(`Codex \u2014 ${codexConfigPath} could not be written, so it was left as it was.`);
|
|
736
|
+
say(
|
|
737
|
+
` 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.`
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
} else if (stripped.foreign) {
|
|
741
|
+
say(`Codex \u2014 ${codexConfigPath} names a ${MCP_SERVER_KEY} server this tool did not write.`);
|
|
742
|
+
say(` Delete its [mcp_servers.${MCP_SERVER_KEY}] block by hand.`);
|
|
743
|
+
}
|
|
744
|
+
if (removeSkill(codexDir)) removed += 1;
|
|
745
|
+
}
|
|
746
|
+
if (removed === 0 && unique.size === 0) {
|
|
747
|
+
say("Nothing to remove \u2014 Superhands is not installed for these clients on this machine.");
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
say("Done. Restart any client that was open to drop the connection it already loaded.");
|
|
751
|
+
}
|
|
317
752
|
|
|
318
753
|
// connect-client.ts
|
|
319
754
|
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.14",
|
|
4
|
+
"description": "Connect the coding agents on this machine to your team's Superhands MCP server, refresh it later with `update` (no token needed), 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
|
},
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"repository": {
|
|
18
18
|
"type": "git",
|
|
19
|
-
"url": "git+https://github.com/superhandsai/
|
|
19
|
+
"url": "git+https://github.com/superhandsai/superhands.git",
|
|
20
20
|
"directory": "packages/connect-client"
|
|
21
21
|
},
|
|
22
22
|
"homepage": "https://app.superhands.ai",
|