@paradigma-inc/flywheel 0.1.1 → 0.1.4
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 +13 -26
- package/package.json +8 -2
- package/src/agents.mjs +110 -3
- package/src/cli.mjs +134 -43
- package/src/mcp-writer.mjs +85 -23
- package/src/setup-auth.mjs +83 -7
package/README.md
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
# flywheel setup CLI
|
|
2
2
|
|
|
3
|
-
This package provides
|
|
4
|
-
|
|
5
|
-
Design intentionally mirrors Context7's setup implementation patterns from:
|
|
6
|
-
|
|
7
|
-
- `packages/cli/src/commands/setup.ts`
|
|
8
|
-
- `packages/cli/src/setup/agents.ts`
|
|
9
|
-
- `packages/cli/src/setup/mcp-writer.ts`
|
|
3
|
+
This package provides setup and uninstall workflows for Flywheel MCP.
|
|
10
4
|
|
|
11
5
|
## Usage
|
|
12
6
|
|
|
@@ -15,21 +9,15 @@ npx @paradigma-inc/flywheel setup
|
|
|
15
9
|
npx @paradigma-inc/flywheel uninstall
|
|
16
10
|
```
|
|
17
11
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
- `npx @paradigma-inc/flywheel setup` defaults to production
|
|
21
|
-
(`https://flywheel.paradigma.inc`).
|
|
22
|
-
- To target staging explicitly, pass a staging base URL:
|
|
12
|
+
`--base-url` must be the public Flywheel origin, not a backend URL or an `/api`
|
|
13
|
+
path. Example:
|
|
23
14
|
|
|
24
15
|
```bash
|
|
25
|
-
npx @paradigma-inc/flywheel setup
|
|
16
|
+
npx @paradigma-inc/flywheel@staging setup \
|
|
17
|
+
--name flywheel-staging \
|
|
18
|
+
--base-url https://flywheel-staging.paradigma.inc
|
|
26
19
|
```
|
|
27
20
|
|
|
28
|
-
- Staging remains protected by the existing staging password gate in the WebUI.
|
|
29
|
-
The browser auth step must pass that gate before key creation can complete.
|
|
30
|
-
- Setup writes one Flywheel server entry per host+scope. If a host is already
|
|
31
|
-
configured, setup leaves that entry in place (Context7-style).
|
|
32
|
-
|
|
33
21
|
## Commands
|
|
34
22
|
|
|
35
23
|
- `setup`: interview + browser auth bridge + idempotent host config writes.
|
|
@@ -37,16 +25,15 @@ npx @paradigma-inc/flywheel setup --base-url https://flywheel-staging.paradigma.
|
|
|
37
25
|
- Add `--yes` to skip interactive prompts in the Flywheel wizard.
|
|
38
26
|
- `npx --yes` only auto-confirms `npx`; it does not imply `flywheel --yes`.
|
|
39
27
|
|
|
40
|
-
## Release channels
|
|
41
|
-
|
|
42
|
-
- Public production installs use npm (`latest`):
|
|
43
|
-
|
|
44
|
-
```bash
|
|
45
|
-
npx @paradigma-inc/flywheel setup
|
|
46
|
-
```
|
|
47
|
-
|
|
48
28
|
## Supported hosts
|
|
49
29
|
|
|
50
30
|
- Codex (`~/.codex/config.toml` or `.codex/config.toml`)
|
|
51
31
|
- Claude Code (`~/.claude.json` or `.mcp.json`)
|
|
52
32
|
- OpenCode (`~/.config/opencode/opencode.json` or `opencode.json`)
|
|
33
|
+
- Cursor (`~/.cursor/mcp.json` or `.cursor/mcp.json`)
|
|
34
|
+
- Hermes Agent (`~/.hermes/config.yaml` or `.hermes/config.yaml`)
|
|
35
|
+
- OpenClaw (`~/.openclaw/openclaw.json` or `.openclaw/openclaw.json`)
|
|
36
|
+
- Pi (pi-mono) (`~/.pi/agent/settings.json` or `.pi/settings.json`)
|
|
37
|
+
|
|
38
|
+
Pi (pi-mono) support writes `mcp.servers` into Pi settings. It requires an
|
|
39
|
+
MCP-capable Pi extension/package to consume those entries.
|
package/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paradigma-inc/flywheel",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "One-command setup for Flywheel MCP
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "One-command setup for Flywheel MCP hosts",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/paradigma-inc/paradigma",
|
|
8
|
+
"directory": "project/flywheel-setup"
|
|
9
|
+
},
|
|
5
10
|
"type": "module",
|
|
6
11
|
"files": [
|
|
7
12
|
"bin/",
|
|
@@ -17,6 +22,7 @@
|
|
|
17
22
|
"dependencies": {
|
|
18
23
|
"@inquirer/prompts": "^8.2.0",
|
|
19
24
|
"commander": "^14.0.1",
|
|
25
|
+
"js-yaml": "^4.1.0",
|
|
20
26
|
"ora": "^9.0.0",
|
|
21
27
|
"picocolors": "^1.1.1"
|
|
22
28
|
},
|
package/src/agents.mjs
CHANGED
|
@@ -3,11 +3,23 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
export const SERVER_NAME = "flywheel";
|
|
6
|
-
export const ALL_HOST_NAMES = [
|
|
6
|
+
export const ALL_HOST_NAMES = [
|
|
7
|
+
"claude",
|
|
8
|
+
"opencode",
|
|
9
|
+
"codex",
|
|
10
|
+
"cursor",
|
|
11
|
+
"pi-mono",
|
|
12
|
+
"hermes-agent",
|
|
13
|
+
"openclaw",
|
|
14
|
+
];
|
|
7
15
|
export const SETUP_HOST_NAMES = {
|
|
8
16
|
claude: "Claude Code",
|
|
9
17
|
opencode: "OpenCode",
|
|
10
18
|
codex: "Codex",
|
|
19
|
+
cursor: "Cursor",
|
|
20
|
+
"pi-mono": "Pi (pi-mono)",
|
|
21
|
+
"hermes-agent": "Hermes Agent",
|
|
22
|
+
openclaw: "OpenClaw",
|
|
11
23
|
};
|
|
12
24
|
|
|
13
25
|
const HOME = os.homedir();
|
|
@@ -42,10 +54,17 @@ const hosts = {
|
|
|
42
54
|
displayName: "OpenCode",
|
|
43
55
|
mcp: {
|
|
44
56
|
configType: "json",
|
|
45
|
-
projectPaths: [
|
|
57
|
+
projectPaths: [
|
|
58
|
+
"opencode.json",
|
|
59
|
+
"opencode.jsonc",
|
|
60
|
+
".opencode.json",
|
|
61
|
+
".opencode.jsonc",
|
|
62
|
+
],
|
|
46
63
|
globalPaths: [
|
|
47
64
|
path.join(HOME, ".config", "opencode", "opencode.json"),
|
|
65
|
+
path.join(HOME, ".config", "opencode", "opencode.jsonc"),
|
|
48
66
|
path.join(HOME, ".config", "opencode", ".opencode.json"),
|
|
67
|
+
path.join(HOME, ".config", "opencode", ".opencode.jsonc"),
|
|
49
68
|
],
|
|
50
69
|
configKey: "mcp",
|
|
51
70
|
buildEntry: ({ serverUrl, apiKey }) => ({
|
|
@@ -58,7 +77,12 @@ const hosts = {
|
|
|
58
77
|
}),
|
|
59
78
|
},
|
|
60
79
|
detect: {
|
|
61
|
-
projectPaths: [
|
|
80
|
+
projectPaths: [
|
|
81
|
+
"opencode.json",
|
|
82
|
+
"opencode.jsonc",
|
|
83
|
+
".opencode.json",
|
|
84
|
+
".opencode.jsonc",
|
|
85
|
+
],
|
|
62
86
|
globalPaths: [path.join(HOME, ".config", "opencode")],
|
|
63
87
|
},
|
|
64
88
|
},
|
|
@@ -83,6 +107,89 @@ const hosts = {
|
|
|
83
107
|
globalPaths: [path.join(HOME, ".codex")],
|
|
84
108
|
},
|
|
85
109
|
},
|
|
110
|
+
cursor: {
|
|
111
|
+
name: "cursor",
|
|
112
|
+
displayName: "Cursor",
|
|
113
|
+
mcp: {
|
|
114
|
+
configType: "json",
|
|
115
|
+
projectPaths: [path.join(".cursor", "mcp.json")],
|
|
116
|
+
globalPaths: [path.join(HOME, ".cursor", "mcp.json")],
|
|
117
|
+
configKey: "mcpServers",
|
|
118
|
+
buildEntry: ({ serverUrl, apiKey }) => ({
|
|
119
|
+
url: serverUrl,
|
|
120
|
+
headers: {
|
|
121
|
+
Authorization: `Bearer ${apiKey}`,
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
},
|
|
125
|
+
detect: {
|
|
126
|
+
projectPaths: [".cursor", path.join(".cursor", "mcp.json")],
|
|
127
|
+
globalPaths: [path.join(HOME, ".cursor")],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
"pi-mono": {
|
|
131
|
+
name: "pi-mono",
|
|
132
|
+
displayName: "Pi (pi-mono)",
|
|
133
|
+
mcp: {
|
|
134
|
+
configType: "json",
|
|
135
|
+
projectPaths: [path.join(".pi", "settings.json")],
|
|
136
|
+
globalPaths: [path.join(HOME, ".pi", "agent", "settings.json")],
|
|
137
|
+
configKey: ["mcp", "servers"],
|
|
138
|
+
buildEntry: ({ serverUrl, apiKey }) => ({
|
|
139
|
+
url: serverUrl,
|
|
140
|
+
headers: {
|
|
141
|
+
Authorization: `Bearer ${apiKey}`,
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
},
|
|
145
|
+
detect: {
|
|
146
|
+
projectPaths: [".pi", path.join(".pi", "settings.json")],
|
|
147
|
+
globalPaths: [path.join(HOME, ".pi"), path.join(HOME, ".pi", "agent")],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
"hermes-agent": {
|
|
151
|
+
name: "hermes-agent",
|
|
152
|
+
displayName: "Hermes Agent",
|
|
153
|
+
mcp: {
|
|
154
|
+
configType: "yaml",
|
|
155
|
+
projectPaths: [path.join(".hermes", "config.yaml")],
|
|
156
|
+
globalPaths: [path.join(HOME, ".hermes", "config.yaml")],
|
|
157
|
+
configKey: "mcp_servers",
|
|
158
|
+
buildEntry: ({ serverUrl, apiKey }) => ({
|
|
159
|
+
url: serverUrl,
|
|
160
|
+
headers: {
|
|
161
|
+
Authorization: `Bearer ${apiKey}`,
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
},
|
|
165
|
+
detect: {
|
|
166
|
+
projectPaths: [".hermes", path.join(".hermes", "config.yaml")],
|
|
167
|
+
globalPaths: [path.join(HOME, ".hermes"), path.join(HOME, ".hermes", "config.yaml")],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
openclaw: {
|
|
171
|
+
name: "openclaw",
|
|
172
|
+
displayName: "OpenClaw",
|
|
173
|
+
mcp: {
|
|
174
|
+
configType: "json",
|
|
175
|
+
projectPaths: [path.join(".openclaw", "openclaw.json")],
|
|
176
|
+
globalPaths: [path.join(HOME, ".openclaw", "openclaw.json")],
|
|
177
|
+
configKey: ["mcp", "servers"],
|
|
178
|
+
buildEntry: ({ serverUrl, apiKey }) => ({
|
|
179
|
+
url: serverUrl,
|
|
180
|
+
headers: {
|
|
181
|
+
Authorization: `Bearer ${apiKey}`,
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
},
|
|
185
|
+
detect: {
|
|
186
|
+
projectPaths: [".openclaw", path.join(".openclaw", "openclaw.json")],
|
|
187
|
+
globalPaths: [
|
|
188
|
+
path.join(HOME, ".openclaw"),
|
|
189
|
+
path.join(HOME, ".openclaw", "openclaw.json"),
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
},
|
|
86
193
|
};
|
|
87
194
|
|
|
88
195
|
export function getHost(name) {
|
package/src/cli.mjs
CHANGED
|
@@ -18,10 +18,12 @@ import {
|
|
|
18
18
|
mergeServerEntry,
|
|
19
19
|
readJsonConfig,
|
|
20
20
|
readTomlServerExists,
|
|
21
|
+
readYamlConfig,
|
|
21
22
|
removeCodexTomlServer,
|
|
22
23
|
removeJsonServerEntry,
|
|
23
24
|
resolveMcpPath,
|
|
24
25
|
writeJsonConfig,
|
|
26
|
+
writeYamlConfig,
|
|
25
27
|
} from "./mcp-writer.mjs";
|
|
26
28
|
import { acquireApiKeyViaBrowserBridge } from "./setup-auth.mjs";
|
|
27
29
|
|
|
@@ -44,9 +46,29 @@ const CHECKBOX_THEME = {
|
|
|
44
46
|
};
|
|
45
47
|
|
|
46
48
|
function normalizeBaseUrl(value) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
const normalized = String(value || "").trim();
|
|
50
|
+
let parsedUrl;
|
|
51
|
+
try {
|
|
52
|
+
parsedUrl = new URL(normalized);
|
|
53
|
+
} catch {
|
|
54
|
+
throw new Error("Base URL must be a valid absolute URL.");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (parsedUrl.pathname !== "/" || parsedUrl.search || parsedUrl.hash) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
"Base URL must be a public Flywheel origin without a path, query, or hash.",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return parsedUrl.origin;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeServerName(value) {
|
|
67
|
+
const normalized = String(value || "").trim();
|
|
68
|
+
if (!normalized) {
|
|
69
|
+
throw new Error("Server name cannot be empty.");
|
|
70
|
+
}
|
|
71
|
+
return normalized;
|
|
50
72
|
}
|
|
51
73
|
|
|
52
74
|
function selectedHostsFromOptions(options) {
|
|
@@ -54,6 +76,10 @@ function selectedHostsFromOptions(options) {
|
|
|
54
76
|
if (options.claude) hosts.push("claude");
|
|
55
77
|
if (options.opencode) hosts.push("opencode");
|
|
56
78
|
if (options.codex) hosts.push("codex");
|
|
79
|
+
if (options.cursor) hosts.push("cursor");
|
|
80
|
+
if (options.piMono) hosts.push("pi-mono");
|
|
81
|
+
if (options.hermesAgent) hosts.push("hermes-agent");
|
|
82
|
+
if (options.openclaw) hosts.push("openclaw");
|
|
57
83
|
return hosts;
|
|
58
84
|
}
|
|
59
85
|
|
|
@@ -74,26 +100,49 @@ function mcpCandidatesForScope(host, scope) {
|
|
|
74
100
|
);
|
|
75
101
|
}
|
|
76
102
|
|
|
77
|
-
|
|
103
|
+
function readConfigSection(config, configKey) {
|
|
104
|
+
const pathParts = Array.isArray(configKey) ? configKey : [configKey];
|
|
105
|
+
let current = config && typeof config === "object" ? config : {};
|
|
106
|
+
for (const part of pathParts) {
|
|
107
|
+
if (
|
|
108
|
+
!current ||
|
|
109
|
+
typeof current !== "object" ||
|
|
110
|
+
Array.isArray(current) ||
|
|
111
|
+
!(part in current)
|
|
112
|
+
) {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
const next = current[part];
|
|
116
|
+
if (!next || typeof next !== "object" || Array.isArray(next)) {
|
|
117
|
+
return {};
|
|
118
|
+
}
|
|
119
|
+
current = next;
|
|
120
|
+
}
|
|
121
|
+
return current;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function isAlreadyConfigured(hostName, scope, serverName) {
|
|
78
125
|
const host = getHost(hostName);
|
|
79
126
|
const mcpPath = await resolveMcpPath(mcpCandidatesForScope(host, scope));
|
|
80
127
|
if (host.mcp.configType === "toml") {
|
|
81
|
-
return readTomlServerExists(mcpPath,
|
|
128
|
+
return readTomlServerExists(mcpPath, serverName);
|
|
82
129
|
}
|
|
83
|
-
const existing =
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return
|
|
130
|
+
const existing =
|
|
131
|
+
host.mcp.configType === "yaml"
|
|
132
|
+
? await readYamlConfig(mcpPath)
|
|
133
|
+
: await readJsonConfig(mcpPath);
|
|
134
|
+
const section = readConfigSection(existing, host.mcp.configKey);
|
|
135
|
+
return serverName in section;
|
|
89
136
|
}
|
|
90
137
|
|
|
91
|
-
async function promptHosts(scope, detected) {
|
|
138
|
+
async function promptHosts(scope, detected, serverName) {
|
|
92
139
|
const choices = await Promise.all(
|
|
93
140
|
ALL_HOST_NAMES.map(async (hostName) => {
|
|
94
|
-
const configured = await isAlreadyConfigured(
|
|
95
|
-
|
|
96
|
-
|
|
141
|
+
const configured = await isAlreadyConfigured(
|
|
142
|
+
hostName,
|
|
143
|
+
scope,
|
|
144
|
+
serverName,
|
|
145
|
+
).catch(() => false);
|
|
97
146
|
return {
|
|
98
147
|
name: SETUP_HOST_NAMES[hostName],
|
|
99
148
|
value: hostName,
|
|
@@ -122,7 +171,7 @@ async function promptHosts(scope, detected) {
|
|
|
122
171
|
}
|
|
123
172
|
}
|
|
124
173
|
|
|
125
|
-
async function resolveHosts(options, scope) {
|
|
174
|
+
async function resolveHosts(options, scope, serverName) {
|
|
126
175
|
const explicit = selectedHostsFromOptions(options);
|
|
127
176
|
if (explicit.length > 0) return explicit;
|
|
128
177
|
|
|
@@ -130,7 +179,7 @@ async function resolveHosts(options, scope) {
|
|
|
130
179
|
if (detected.length > 0 && options.yes) return detected;
|
|
131
180
|
|
|
132
181
|
log.blank();
|
|
133
|
-
const selected = await promptHosts(scope, detected);
|
|
182
|
+
const selected = await promptHosts(scope, detected, serverName);
|
|
134
183
|
if (!selected) {
|
|
135
184
|
log.warn("Setup cancelled");
|
|
136
185
|
return [];
|
|
@@ -156,14 +205,14 @@ async function resolveApiKey(options, baseUrl) {
|
|
|
156
205
|
}
|
|
157
206
|
}
|
|
158
207
|
|
|
159
|
-
async function setupHost(hostName, scope, serverUrl, apiKey) {
|
|
208
|
+
async function setupHost(hostName, scope, serverUrl, apiKey, serverName) {
|
|
160
209
|
const host = getHost(hostName);
|
|
161
210
|
const mcpPath = await resolveMcpPath(mcpCandidatesForScope(host, scope));
|
|
162
211
|
|
|
163
212
|
if (host.mcp.configType === "toml") {
|
|
164
213
|
const { alreadyExists } = await appendTomlServer(
|
|
165
214
|
mcpPath,
|
|
166
|
-
|
|
215
|
+
serverName,
|
|
167
216
|
host.mcp.buildEntry({ serverUrl, apiKey }),
|
|
168
217
|
);
|
|
169
218
|
return {
|
|
@@ -173,15 +222,22 @@ async function setupHost(hostName, scope, serverUrl, apiKey) {
|
|
|
173
222
|
};
|
|
174
223
|
}
|
|
175
224
|
|
|
176
|
-
const existing =
|
|
225
|
+
const existing =
|
|
226
|
+
host.mcp.configType === "yaml"
|
|
227
|
+
? await readYamlConfig(mcpPath)
|
|
228
|
+
: await readJsonConfig(mcpPath);
|
|
177
229
|
const { config, alreadyExists } = mergeServerEntry(
|
|
178
230
|
existing,
|
|
179
231
|
host.mcp.configKey,
|
|
180
|
-
|
|
232
|
+
serverName,
|
|
181
233
|
host.mcp.buildEntry({ serverUrl, apiKey }),
|
|
182
234
|
);
|
|
183
235
|
if (config !== existing) {
|
|
184
|
-
|
|
236
|
+
if (host.mcp.configType === "yaml") {
|
|
237
|
+
await writeYamlConfig(mcpPath, config);
|
|
238
|
+
} else {
|
|
239
|
+
await writeJsonConfig(mcpPath, config);
|
|
240
|
+
}
|
|
185
241
|
}
|
|
186
242
|
return {
|
|
187
243
|
host: host.displayName,
|
|
@@ -211,7 +267,8 @@ async function runSetupCommand(options) {
|
|
|
211
267
|
const scope = options.project ? "project" : "global";
|
|
212
268
|
const baseUrl = normalizeBaseUrl(options.baseUrl || DEFAULT_BASE_URL);
|
|
213
269
|
const serverUrl = `${baseUrl}/mcp-server`;
|
|
214
|
-
const
|
|
270
|
+
const serverName = normalizeServerName(options.name || SERVER_NAME);
|
|
271
|
+
const hosts = await resolveHosts(options, scope, serverName);
|
|
215
272
|
if (hosts.length === 0) return;
|
|
216
273
|
|
|
217
274
|
const apiKey = await resolveApiKey(options, baseUrl);
|
|
@@ -225,7 +282,9 @@ async function runSetupCommand(options) {
|
|
|
225
282
|
for (const hostName of hosts) {
|
|
226
283
|
spinner.text = `Setting up ${getHost(hostName).displayName}...`;
|
|
227
284
|
// eslint-disable-next-line no-await-in-loop
|
|
228
|
-
results.push(
|
|
285
|
+
results.push(
|
|
286
|
+
await setupHost(hostName, scope, serverUrl, apiKey, serverName),
|
|
287
|
+
);
|
|
229
288
|
}
|
|
230
289
|
spinner.succeed("Setup complete");
|
|
231
290
|
printSetupResults(results, scope, serverUrl);
|
|
@@ -249,13 +308,15 @@ function scopesFromUninstallScope(scope) {
|
|
|
249
308
|
return scope === "all" ? ["global", "project"] : [scope];
|
|
250
309
|
}
|
|
251
310
|
|
|
252
|
-
async function isConfiguredForUninstallScope(hostName, scope) {
|
|
311
|
+
async function isConfiguredForUninstallScope(hostName, scope, serverName) {
|
|
253
312
|
const scopes = scopesFromUninstallScope(scope);
|
|
254
313
|
for (const singleScope of scopes) {
|
|
255
314
|
// eslint-disable-next-line no-await-in-loop
|
|
256
|
-
const configured = await isAlreadyConfigured(
|
|
257
|
-
|
|
258
|
-
|
|
315
|
+
const configured = await isAlreadyConfigured(
|
|
316
|
+
hostName,
|
|
317
|
+
singleScope,
|
|
318
|
+
serverName,
|
|
319
|
+
).catch(() => false);
|
|
259
320
|
if (configured) return true;
|
|
260
321
|
}
|
|
261
322
|
return false;
|
|
@@ -278,10 +339,14 @@ async function promptUninstallScope(defaultScope) {
|
|
|
278
339
|
}
|
|
279
340
|
}
|
|
280
341
|
|
|
281
|
-
async function promptUninstallHosts(scope) {
|
|
342
|
+
async function promptUninstallHosts(scope, serverName) {
|
|
282
343
|
const choices = await Promise.all(
|
|
283
344
|
ALL_HOST_NAMES.map(async (hostName) => {
|
|
284
|
-
const configured = await isConfiguredForUninstallScope(
|
|
345
|
+
const configured = await isConfiguredForUninstallScope(
|
|
346
|
+
hostName,
|
|
347
|
+
scope,
|
|
348
|
+
serverName,
|
|
349
|
+
);
|
|
285
350
|
return {
|
|
286
351
|
name: SETUP_HOST_NAMES[hostName],
|
|
287
352
|
value: hostName,
|
|
@@ -310,7 +375,7 @@ async function promptUninstallHosts(scope) {
|
|
|
310
375
|
}
|
|
311
376
|
}
|
|
312
377
|
|
|
313
|
-
async function resolveUninstallTargets(options) {
|
|
378
|
+
async function resolveUninstallTargets(options, serverName) {
|
|
314
379
|
const explicitHostsFromFlags = selectedHostsFromOptions(options);
|
|
315
380
|
const explicitHostsFromList =
|
|
316
381
|
explicitHostsFromFlags.length === 0 ? parseHostsList(options.hosts) : [];
|
|
@@ -339,7 +404,7 @@ async function resolveUninstallTargets(options) {
|
|
|
339
404
|
}
|
|
340
405
|
|
|
341
406
|
if (!hasExplicitHosts) {
|
|
342
|
-
const selectedHosts = await promptUninstallHosts(scope);
|
|
407
|
+
const selectedHosts = await promptUninstallHosts(scope, serverName);
|
|
343
408
|
if (!selectedHosts) {
|
|
344
409
|
log.warn("Uninstall cancelled");
|
|
345
410
|
return null;
|
|
@@ -351,14 +416,14 @@ async function resolveUninstallTargets(options) {
|
|
|
351
416
|
return { scope, hosts };
|
|
352
417
|
}
|
|
353
418
|
|
|
354
|
-
async function removeHostConfig(hostName, scope) {
|
|
419
|
+
async function removeHostConfig(hostName, scope, serverName) {
|
|
355
420
|
const host = getHost(hostName);
|
|
356
421
|
const filePath = await resolveMcpPath(mcpCandidatesForScope(host, scope));
|
|
357
422
|
|
|
358
423
|
if (host.mcp.configType === "toml") {
|
|
359
424
|
const result = await removeCodexTomlServer({
|
|
360
425
|
filePath,
|
|
361
|
-
serverName
|
|
426
|
+
serverName,
|
|
362
427
|
});
|
|
363
428
|
return {
|
|
364
429
|
host: host.displayName,
|
|
@@ -368,14 +433,21 @@ async function removeHostConfig(hostName, scope) {
|
|
|
368
433
|
};
|
|
369
434
|
}
|
|
370
435
|
|
|
371
|
-
const current =
|
|
436
|
+
const current =
|
|
437
|
+
host.mcp.configType === "yaml"
|
|
438
|
+
? await readYamlConfig(filePath)
|
|
439
|
+
: await readJsonConfig(filePath);
|
|
372
440
|
const next = removeJsonServerEntry({
|
|
373
441
|
config: current,
|
|
374
442
|
configKey: host.mcp.configKey,
|
|
375
|
-
serverName
|
|
443
|
+
serverName,
|
|
376
444
|
});
|
|
377
445
|
if (next.changed) {
|
|
378
|
-
|
|
446
|
+
if (host.mcp.configType === "yaml") {
|
|
447
|
+
await writeYamlConfig(filePath, next.config);
|
|
448
|
+
} else {
|
|
449
|
+
await writeJsonConfig(filePath, next.config);
|
|
450
|
+
}
|
|
379
451
|
}
|
|
380
452
|
return {
|
|
381
453
|
host: host.displayName,
|
|
@@ -386,7 +458,8 @@ async function removeHostConfig(hostName, scope) {
|
|
|
386
458
|
}
|
|
387
459
|
|
|
388
460
|
async function runUninstallCommand(options) {
|
|
389
|
-
const
|
|
461
|
+
const serverName = normalizeServerName(options.name || SERVER_NAME);
|
|
462
|
+
const resolved = await resolveUninstallTargets(options, serverName);
|
|
390
463
|
if (!resolved) return;
|
|
391
464
|
const { scope, hosts } = resolved;
|
|
392
465
|
const scopes = scopesFromUninstallScope(scope);
|
|
@@ -397,7 +470,7 @@ async function runUninstallCommand(options) {
|
|
|
397
470
|
for (const host of hosts) {
|
|
398
471
|
spinner.text = `Removing from ${getHost(host).displayName} (${singleScope})...`;
|
|
399
472
|
// eslint-disable-next-line no-await-in-loop
|
|
400
|
-
results.push(await removeHostConfig(host, singleScope));
|
|
473
|
+
results.push(await removeHostConfig(host, singleScope, serverName));
|
|
401
474
|
}
|
|
402
475
|
}
|
|
403
476
|
spinner.succeed("Uninstall complete");
|
|
@@ -425,8 +498,10 @@ function buildProgram() {
|
|
|
425
498
|
Examples:
|
|
426
499
|
${pc.green("npx @paradigma-inc/flywheel setup")}
|
|
427
500
|
${pc.green("npx @paradigma-inc/flywheel setup --codex --project")}
|
|
428
|
-
${pc.green("npx @paradigma-inc/flywheel setup --yes --codex --claude")}
|
|
429
|
-
${pc.green(
|
|
501
|
+
${pc.green("npx @paradigma-inc/flywheel setup --yes --codex --claude --cursor")}
|
|
502
|
+
${pc.green(
|
|
503
|
+
"npx @paradigma-inc/flywheel uninstall --scope all --hosts codex,claude,opencode,cursor,pi-mono,hermes-agent,openclaw",
|
|
504
|
+
)}
|
|
430
505
|
`,
|
|
431
506
|
);
|
|
432
507
|
|
|
@@ -436,6 +511,10 @@ Examples:
|
|
|
436
511
|
.option("--claude", "Set up for Claude Code")
|
|
437
512
|
.option("--opencode", "Set up for OpenCode")
|
|
438
513
|
.option("--codex", "Set up for Codex")
|
|
514
|
+
.option("--cursor", "Set up for Cursor")
|
|
515
|
+
.option("--pi-mono", "Set up for Pi (pi-mono)")
|
|
516
|
+
.option("--hermes-agent", "Set up for Hermes Agent")
|
|
517
|
+
.option("--openclaw", "Set up for OpenClaw")
|
|
439
518
|
.option(
|
|
440
519
|
"-p, --project",
|
|
441
520
|
"Configure for current project instead of globally",
|
|
@@ -444,8 +523,9 @@ Examples:
|
|
|
444
523
|
.option("--api-key <key>", "Use API key authentication")
|
|
445
524
|
.option(
|
|
446
525
|
"--base-url <url>",
|
|
447
|
-
`Flywheel
|
|
526
|
+
`Public Flywheel origin used for setup and MCP config (default: ${DEFAULT_BASE_URL})`,
|
|
448
527
|
)
|
|
528
|
+
.option("--name <name>", `MCP server name (default: ${SERVER_NAME})`)
|
|
449
529
|
.action(async (options) => {
|
|
450
530
|
await runSetupCommand(options);
|
|
451
531
|
});
|
|
@@ -456,8 +536,19 @@ Examples:
|
|
|
456
536
|
.option("--claude", "Uninstall for Claude Code")
|
|
457
537
|
.option("--opencode", "Uninstall for OpenCode")
|
|
458
538
|
.option("--codex", "Uninstall for Codex")
|
|
459
|
-
.option("--
|
|
539
|
+
.option("--cursor", "Uninstall for Cursor")
|
|
540
|
+
.option("--pi-mono", "Uninstall for Pi (pi-mono)")
|
|
541
|
+
.option("--hermes-agent", "Uninstall for Hermes Agent")
|
|
542
|
+
.option("--openclaw", "Uninstall for OpenClaw")
|
|
543
|
+
.option(
|
|
544
|
+
"--hosts <list>",
|
|
545
|
+
"Comma-separated: codex,claude,opencode,cursor,pi-mono,hermes-agent,openclaw",
|
|
546
|
+
)
|
|
460
547
|
.option("--scope <scope>", "all | global | project")
|
|
548
|
+
.option(
|
|
549
|
+
"--name <name>",
|
|
550
|
+
`MCP server name to remove (default: ${SERVER_NAME})`,
|
|
551
|
+
)
|
|
461
552
|
.option("-y, --yes", "Skip uninstall selection prompts")
|
|
462
553
|
.action(async (options) => {
|
|
463
554
|
await runUninstallCommand(options);
|
package/src/mcp-writer.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { load as loadYaml, dump as dumpYaml } from "js-yaml";
|
|
3
4
|
|
|
4
5
|
function stripJsonComments(text) {
|
|
5
6
|
let result = "";
|
|
@@ -52,6 +53,35 @@ export async function writeJsonConfig(filePath, config) {
|
|
|
52
53
|
});
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
export async function readYamlConfig(filePath) {
|
|
57
|
+
let raw;
|
|
58
|
+
try {
|
|
59
|
+
raw = await readFile(filePath, "utf8");
|
|
60
|
+
} catch {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
const trimmed = raw.trim();
|
|
64
|
+
if (!trimmed) return {};
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const parsed = loadYaml(trimmed);
|
|
68
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
return {};
|
|
72
|
+
} catch {
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function writeYamlConfig(filePath, config) {
|
|
78
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
79
|
+
await writeFile(filePath, dumpYaml(config, { noRefs: true }), {
|
|
80
|
+
encoding: "utf8",
|
|
81
|
+
mode: 0o600,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
55
85
|
export async function resolveMcpPath(candidates) {
|
|
56
86
|
for (const candidate of candidates) {
|
|
57
87
|
try {
|
|
@@ -65,37 +95,72 @@ export async function resolveMcpPath(candidates) {
|
|
|
65
95
|
return candidates[0];
|
|
66
96
|
}
|
|
67
97
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
98
|
+
function keyPath(configKey) {
|
|
99
|
+
return Array.isArray(configKey) ? configKey : [configKey];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readConfigSection(config, configKey) {
|
|
103
|
+
const pathParts = keyPath(configKey);
|
|
104
|
+
let current = config && typeof config === "object" ? config : {};
|
|
105
|
+
for (const part of pathParts) {
|
|
106
|
+
if (
|
|
107
|
+
!current ||
|
|
108
|
+
typeof current !== "object" ||
|
|
109
|
+
Array.isArray(current) ||
|
|
110
|
+
!(part in current)
|
|
111
|
+
) {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
const next = current[part];
|
|
115
|
+
if (!next || typeof next !== "object" || Array.isArray(next)) {
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
current = next;
|
|
119
|
+
}
|
|
120
|
+
return current;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function writeConfigSection(config, configKey, section) {
|
|
124
|
+
const pathParts = keyPath(configKey);
|
|
125
|
+
if (pathParts.length === 0) return config || {};
|
|
126
|
+
|
|
127
|
+
const root =
|
|
128
|
+
config && typeof config === "object" && !Array.isArray(config)
|
|
129
|
+
? { ...config }
|
|
74
130
|
: {};
|
|
131
|
+
let cursor = root;
|
|
132
|
+
|
|
133
|
+
for (let index = 0; index < pathParts.length - 1; index += 1) {
|
|
134
|
+
const part = pathParts[index];
|
|
135
|
+
const existing = cursor[part];
|
|
136
|
+
cursor[part] =
|
|
137
|
+
existing && typeof existing === "object" && !Array.isArray(existing)
|
|
138
|
+
? { ...existing }
|
|
139
|
+
: {};
|
|
140
|
+
cursor = cursor[part];
|
|
141
|
+
}
|
|
142
|
+
cursor[pathParts[pathParts.length - 1]] = section;
|
|
143
|
+
return root;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function mergeServerEntry(existing, configKey, serverName, entry) {
|
|
147
|
+
const section = readConfigSection(existing, configKey);
|
|
75
148
|
|
|
76
149
|
if (serverName in section) {
|
|
77
150
|
return { config: existing, alreadyExists: true };
|
|
78
151
|
}
|
|
79
152
|
|
|
80
153
|
return {
|
|
81
|
-
config: {
|
|
82
|
-
...
|
|
83
|
-
[
|
|
84
|
-
|
|
85
|
-
[serverName]: entry,
|
|
86
|
-
},
|
|
87
|
-
},
|
|
154
|
+
config: writeConfigSection(existing, configKey, {
|
|
155
|
+
...section,
|
|
156
|
+
[serverName]: entry,
|
|
157
|
+
}),
|
|
88
158
|
alreadyExists: false,
|
|
89
159
|
};
|
|
90
160
|
}
|
|
91
161
|
|
|
92
162
|
export function removeJsonServerEntry({ config, configKey, serverName }) {
|
|
93
|
-
const section =
|
|
94
|
-
config &&
|
|
95
|
-
typeof config[configKey] === "object" &&
|
|
96
|
-
config[configKey] !== null
|
|
97
|
-
? { ...config[configKey] }
|
|
98
|
-
: {};
|
|
163
|
+
const section = { ...readConfigSection(config, configKey) };
|
|
99
164
|
|
|
100
165
|
if (!(serverName in section)) {
|
|
101
166
|
return { config: config || {}, changed: false };
|
|
@@ -103,10 +168,7 @@ export function removeJsonServerEntry({ config, configKey, serverName }) {
|
|
|
103
168
|
|
|
104
169
|
delete section[serverName];
|
|
105
170
|
return {
|
|
106
|
-
config:
|
|
107
|
-
...(config || {}),
|
|
108
|
-
[configKey]: section,
|
|
109
|
-
},
|
|
171
|
+
config: writeConfigSection(config, configKey, section),
|
|
110
172
|
changed: true,
|
|
111
173
|
};
|
|
112
174
|
}
|
package/src/setup-auth.mjs
CHANGED
|
@@ -32,6 +32,62 @@ function renderCallbackPage({ ok, message }) {
|
|
|
32
32
|
</html>`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
async function redeemSetupExchangeToken({
|
|
36
|
+
baseUrl,
|
|
37
|
+
exchangeToken,
|
|
38
|
+
state,
|
|
39
|
+
redirectUri,
|
|
40
|
+
}) {
|
|
41
|
+
const redeemUrl = new URL(
|
|
42
|
+
"/api/auth/mcp-api-keys/setup-exchange/redeem",
|
|
43
|
+
baseUrl,
|
|
44
|
+
);
|
|
45
|
+
const response = await fetch(redeemUrl, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: {
|
|
48
|
+
"content-type": "application/json",
|
|
49
|
+
"Idempotency-Key": `setup-redeem:${state}`,
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
exchange_token: exchangeToken,
|
|
53
|
+
state,
|
|
54
|
+
redirect_uri: redirectUri,
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
let payload = {};
|
|
58
|
+
try {
|
|
59
|
+
payload = await response.json();
|
|
60
|
+
} catch {
|
|
61
|
+
payload = {};
|
|
62
|
+
}
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
const detail = resolveRedeemErrorMessage(payload);
|
|
65
|
+
throw new Error(detail);
|
|
66
|
+
}
|
|
67
|
+
const key = (payload?.key || "").trim();
|
|
68
|
+
if (!key) {
|
|
69
|
+
throw new Error("Setup exchange response missing API key.");
|
|
70
|
+
}
|
|
71
|
+
return { apiKey: key };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveRedeemErrorMessage(payload) {
|
|
75
|
+
if (!payload || typeof payload !== "object") {
|
|
76
|
+
return "Setup exchange redemption failed.";
|
|
77
|
+
}
|
|
78
|
+
const detail = payload.detail;
|
|
79
|
+
if (typeof detail === "string" && detail.trim()) {
|
|
80
|
+
return detail.trim();
|
|
81
|
+
}
|
|
82
|
+
if (detail && typeof detail === "object") {
|
|
83
|
+
const message = detail.message;
|
|
84
|
+
if (typeof message === "string" && message.trim()) {
|
|
85
|
+
return message.trim();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return "Setup exchange redemption failed.";
|
|
89
|
+
}
|
|
90
|
+
|
|
35
91
|
export async function acquireApiKeyViaBrowserBridge({
|
|
36
92
|
baseUrl,
|
|
37
93
|
keyName,
|
|
@@ -44,6 +100,7 @@ export async function acquireApiKeyViaBrowserBridge({
|
|
|
44
100
|
let settled = false;
|
|
45
101
|
let timeout = null;
|
|
46
102
|
let server = null;
|
|
103
|
+
let callbackUrl = "";
|
|
47
104
|
|
|
48
105
|
const cleanup = () => {
|
|
49
106
|
if (timeout) {
|
|
@@ -72,7 +129,7 @@ export async function acquireApiKeyViaBrowserBridge({
|
|
|
72
129
|
resolve(result);
|
|
73
130
|
};
|
|
74
131
|
|
|
75
|
-
server = http.createServer((req, res) => {
|
|
132
|
+
server = http.createServer(async (req, res) => {
|
|
76
133
|
const reqUrl = new URL(req.url || "/", "http://127.0.0.1");
|
|
77
134
|
if (reqUrl.pathname !== "/callback") {
|
|
78
135
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
@@ -81,7 +138,9 @@ export async function acquireApiKeyViaBrowserBridge({
|
|
|
81
138
|
}
|
|
82
139
|
|
|
83
140
|
const callbackState = (reqUrl.searchParams.get("state") || "").trim();
|
|
84
|
-
const
|
|
141
|
+
const exchangeToken = (
|
|
142
|
+
reqUrl.searchParams.get("exchange_token") || ""
|
|
143
|
+
).trim();
|
|
85
144
|
const error = (reqUrl.searchParams.get("error") || "").trim();
|
|
86
145
|
|
|
87
146
|
if (callbackState !== state) {
|
|
@@ -103,15 +162,32 @@ export async function acquireApiKeyViaBrowserBridge({
|
|
|
103
162
|
return;
|
|
104
163
|
}
|
|
105
164
|
|
|
106
|
-
if (!
|
|
165
|
+
if (!exchangeToken) {
|
|
107
166
|
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
108
167
|
res.end(
|
|
109
168
|
renderCallbackPage({
|
|
110
169
|
ok: false,
|
|
111
|
-
message: "No
|
|
170
|
+
message: "No setup exchange token was returned.",
|
|
112
171
|
}),
|
|
113
172
|
);
|
|
114
|
-
fail(new Error("Setup callback missing
|
|
173
|
+
fail(new Error("Setup callback missing exchange token."));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let redeemed;
|
|
178
|
+
try {
|
|
179
|
+
redeemed = await redeemSetupExchangeToken({
|
|
180
|
+
baseUrl,
|
|
181
|
+
exchangeToken,
|
|
182
|
+
state,
|
|
183
|
+
redirectUri: callbackUrl,
|
|
184
|
+
});
|
|
185
|
+
} catch (err) {
|
|
186
|
+
const message =
|
|
187
|
+
err instanceof Error ? err.message : "Failed to redeem setup exchange.";
|
|
188
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
189
|
+
res.end(renderCallbackPage({ ok: false, message }));
|
|
190
|
+
fail(new Error(message));
|
|
115
191
|
return;
|
|
116
192
|
}
|
|
117
193
|
|
|
@@ -122,7 +198,7 @@ export async function acquireApiKeyViaBrowserBridge({
|
|
|
122
198
|
message: "Flywheel MCP was authorized successfully.",
|
|
123
199
|
}),
|
|
124
200
|
);
|
|
125
|
-
succeed({ apiKey:
|
|
201
|
+
succeed({ apiKey: redeemed.apiKey });
|
|
126
202
|
});
|
|
127
203
|
|
|
128
204
|
server.once("error", (error) => {
|
|
@@ -136,7 +212,7 @@ export async function acquireApiKeyViaBrowserBridge({
|
|
|
136
212
|
return;
|
|
137
213
|
}
|
|
138
214
|
|
|
139
|
-
|
|
215
|
+
callbackUrl = `http://127.0.0.1:${address.port}/callback`;
|
|
140
216
|
const setupUrl = new URL("/auth/mcp/setup", baseUrl);
|
|
141
217
|
setupUrl.searchParams.set("state", state);
|
|
142
218
|
setupUrl.searchParams.set("redirect_uri", callbackUrl);
|