@paradigma-inc/flywheel 0.1.0 → 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 +15 -34
- package/package.json +19 -2
- package/src/agents.mjs +220 -61
- package/src/cli.mjs +482 -330
- package/src/mcp-writer.mjs +171 -80
- package/src/setup-auth.mjs +90 -9
- package/tests/mcp-writer.test.mjs +0 -117
package/src/cli.mjs
CHANGED
|
@@ -1,422 +1,574 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { checkbox, select } from "@inquirer/prompts";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import pc from "picocolors";
|
|
4
5
|
import path from "node:path";
|
|
5
|
-
import
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
6
7
|
|
|
7
8
|
import {
|
|
8
|
-
|
|
9
|
-
HOST_LABELS,
|
|
10
|
-
HOSTS,
|
|
9
|
+
ALL_HOST_NAMES,
|
|
11
10
|
SERVER_NAME,
|
|
11
|
+
SETUP_HOST_NAMES,
|
|
12
|
+
detectHosts,
|
|
13
|
+
getHost,
|
|
12
14
|
normalizeHosts,
|
|
13
|
-
normalizeScope,
|
|
14
|
-
resolveHostConfigPath,
|
|
15
15
|
} from "./agents.mjs";
|
|
16
16
|
import {
|
|
17
|
+
appendTomlServer,
|
|
18
|
+
mergeServerEntry,
|
|
17
19
|
readJsonConfig,
|
|
20
|
+
readTomlServerExists,
|
|
21
|
+
readYamlConfig,
|
|
18
22
|
removeCodexTomlServer,
|
|
19
23
|
removeJsonServerEntry,
|
|
20
|
-
|
|
21
|
-
upsertJsonServerEntry,
|
|
24
|
+
resolveMcpPath,
|
|
22
25
|
writeJsonConfig,
|
|
26
|
+
writeYamlConfig,
|
|
23
27
|
} from "./mcp-writer.mjs";
|
|
24
28
|
import { acquireApiKeyViaBrowserBridge } from "./setup-auth.mjs";
|
|
25
29
|
|
|
26
|
-
// This setup flow intentionally follows Context7's structure:
|
|
27
|
-
// - packages/cli/src/commands/setup.ts
|
|
28
|
-
// - packages/cli/src/setup/agents.ts
|
|
29
|
-
// - packages/cli/src/setup/mcp-writer.ts
|
|
30
30
|
const DEFAULT_BASE_URL =
|
|
31
31
|
process.env.FLYWHEEL_PUBLIC_BASE_URL || "https://flywheel.paradigma.inc";
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
32
|
+
|
|
33
|
+
const log = {
|
|
34
|
+
info: (message) => console.log(pc.cyan(message)),
|
|
35
|
+
warn: (message) => console.log(pc.yellow(`⚠ ${message}`)),
|
|
36
|
+
error: (message) => console.log(pc.red(`✖ ${message}`)),
|
|
37
|
+
plain: (message) => console.log(message),
|
|
38
|
+
blank: () => console.log(""),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const CHECKBOX_THEME = {
|
|
42
|
+
style: {
|
|
43
|
+
highlight: (text) => pc.green(text),
|
|
44
|
+
disabledChoice: (text) => ` ${pc.dim("◯")} ${pc.dim(text)}`,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function normalizeBaseUrl(value) {
|
|
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;
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
function
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function selectedHostsFromOptions(options) {
|
|
75
|
+
const hosts = [];
|
|
76
|
+
if (options.claude) hosts.push("claude");
|
|
77
|
+
if (options.opencode) hosts.push("opencode");
|
|
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");
|
|
83
|
+
return hosts;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseHostsList(value) {
|
|
87
|
+
if (!value) return [];
|
|
88
|
+
return normalizeHosts(
|
|
89
|
+
String(value)
|
|
90
|
+
.split(",")
|
|
91
|
+
.map((item) => item.trim())
|
|
92
|
+
.filter(Boolean),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function mcpCandidatesForScope(host, scope) {
|
|
97
|
+
if (scope === "global") return host.mcp.globalPaths;
|
|
98
|
+
return host.mcp.projectPaths.map((candidate) =>
|
|
99
|
+
path.join(process.cwd(), candidate),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
74
102
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
break;
|
|
87
|
-
case "--base-url":
|
|
88
|
-
options.baseUrl = next || "";
|
|
89
|
-
i += 1;
|
|
90
|
-
break;
|
|
91
|
-
case "--server-url":
|
|
92
|
-
options.serverUrl = next || "";
|
|
93
|
-
i += 1;
|
|
94
|
-
break;
|
|
95
|
-
case "--api-key":
|
|
96
|
-
options.apiKey = next || "";
|
|
97
|
-
i += 1;
|
|
98
|
-
break;
|
|
99
|
-
case "--name":
|
|
100
|
-
options.name = next || "";
|
|
101
|
-
i += 1;
|
|
102
|
-
break;
|
|
103
|
-
case "--yes":
|
|
104
|
-
case "-y":
|
|
105
|
-
options.yes = true;
|
|
106
|
-
break;
|
|
107
|
-
case "--delete-key":
|
|
108
|
-
options.deleteKey = true;
|
|
109
|
-
break;
|
|
110
|
-
case "--help":
|
|
111
|
-
case "-h":
|
|
112
|
-
options.help = true;
|
|
113
|
-
break;
|
|
114
|
-
default:
|
|
115
|
-
throw new Error(`Unknown option: ${arg}`);
|
|
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 {};
|
|
116
114
|
}
|
|
115
|
+
const next = current[part];
|
|
116
|
+
if (!next || typeof next !== "object" || Array.isArray(next)) {
|
|
117
|
+
return {};
|
|
118
|
+
}
|
|
119
|
+
current = next;
|
|
117
120
|
}
|
|
121
|
+
return current;
|
|
122
|
+
}
|
|
118
123
|
|
|
119
|
-
|
|
124
|
+
async function isAlreadyConfigured(hostName, scope, serverName) {
|
|
125
|
+
const host = getHost(hostName);
|
|
126
|
+
const mcpPath = await resolveMcpPath(mcpCandidatesForScope(host, scope));
|
|
127
|
+
if (host.mcp.configType === "toml") {
|
|
128
|
+
return readTomlServerExists(mcpPath, serverName);
|
|
129
|
+
}
|
|
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;
|
|
120
136
|
}
|
|
121
137
|
|
|
122
|
-
function
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
138
|
+
async function promptHosts(scope, detected, serverName) {
|
|
139
|
+
const choices = await Promise.all(
|
|
140
|
+
ALL_HOST_NAMES.map(async (hostName) => {
|
|
141
|
+
const configured = await isAlreadyConfigured(
|
|
142
|
+
hostName,
|
|
143
|
+
scope,
|
|
144
|
+
serverName,
|
|
145
|
+
).catch(() => false);
|
|
146
|
+
return {
|
|
147
|
+
name: SETUP_HOST_NAMES[hostName],
|
|
148
|
+
value: hostName,
|
|
149
|
+
checked: detected.includes(hostName),
|
|
150
|
+
disabled: configured ? "(already configured)" : false,
|
|
151
|
+
};
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
if (choices.every((choice) => Boolean(choice.disabled))) {
|
|
156
|
+
log.info("Flywheel is already configured for all detected hosts.");
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
return await checkbox({
|
|
162
|
+
message: "Which hosts do you want to set up?",
|
|
163
|
+
choices,
|
|
164
|
+
loop: false,
|
|
165
|
+
theme: CHECKBOX_THEME,
|
|
166
|
+
validate: (selected) =>
|
|
167
|
+
selected.length > 0 || "Select at least one host.",
|
|
168
|
+
});
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
129
172
|
}
|
|
130
173
|
|
|
131
|
-
async function
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
174
|
+
async function resolveHosts(options, scope, serverName) {
|
|
175
|
+
const explicit = selectedHostsFromOptions(options);
|
|
176
|
+
if (explicit.length > 0) return explicit;
|
|
177
|
+
|
|
178
|
+
const detected = await detectHosts(scope);
|
|
179
|
+
if (detected.length > 0 && options.yes) return detected;
|
|
180
|
+
|
|
181
|
+
log.blank();
|
|
182
|
+
const selected = await promptHosts(scope, detected, serverName);
|
|
183
|
+
if (!selected) {
|
|
184
|
+
log.warn("Setup cancelled");
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
return selected;
|
|
136
188
|
}
|
|
137
189
|
|
|
138
|
-
async function
|
|
139
|
-
|
|
140
|
-
let hosts = parseHostsValue(options.hosts);
|
|
141
|
-
let baseUrl = (options.baseUrl || DEFAULT_BASE_URL).trim().replace(/\/$/, "");
|
|
190
|
+
async function resolveApiKey(options, baseUrl) {
|
|
191
|
+
if (options.apiKey) return options.apiKey.trim();
|
|
142
192
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
193
|
+
const spinner = ora("Configuring authentication...").start();
|
|
194
|
+
try {
|
|
195
|
+
const keyName = `flywheel-setup-${randomBytes(3).toString("hex")}`;
|
|
196
|
+
const authResult = await acquireApiKeyViaBrowserBridge({
|
|
197
|
+
baseUrl,
|
|
198
|
+
keyName,
|
|
147
199
|
});
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
200
|
+
spinner.succeed("Authenticated");
|
|
201
|
+
return authResult.apiKey;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
spinner.fail("Authentication failed");
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
155
207
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
hosts.join(","),
|
|
160
|
-
);
|
|
161
|
-
hosts = parseHostsValue(promptedHosts);
|
|
208
|
+
async function setupHost(hostName, scope, serverUrl, apiKey, serverName) {
|
|
209
|
+
const host = getHost(hostName);
|
|
210
|
+
const mcpPath = await resolveMcpPath(mcpCandidatesForScope(host, scope));
|
|
162
211
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
212
|
+
if (host.mcp.configType === "toml") {
|
|
213
|
+
const { alreadyExists } = await appendTomlServer(
|
|
214
|
+
mcpPath,
|
|
215
|
+
serverName,
|
|
216
|
+
host.mcp.buildEntry({ serverUrl, apiKey }),
|
|
217
|
+
);
|
|
218
|
+
return {
|
|
219
|
+
host: host.displayName,
|
|
220
|
+
status: alreadyExists ? "already configured" : "configured with API Key",
|
|
221
|
+
filePath: mcpPath,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const existing =
|
|
226
|
+
host.mcp.configType === "yaml"
|
|
227
|
+
? await readYamlConfig(mcpPath)
|
|
228
|
+
: await readJsonConfig(mcpPath);
|
|
229
|
+
const { config, alreadyExists } = mergeServerEntry(
|
|
230
|
+
existing,
|
|
231
|
+
host.mcp.configKey,
|
|
232
|
+
serverName,
|
|
233
|
+
host.mcp.buildEntry({ serverUrl, apiKey }),
|
|
234
|
+
);
|
|
235
|
+
if (config !== existing) {
|
|
236
|
+
if (host.mcp.configType === "yaml") {
|
|
237
|
+
await writeYamlConfig(mcpPath, config);
|
|
238
|
+
} else {
|
|
239
|
+
await writeJsonConfig(mcpPath, config);
|
|
169
240
|
}
|
|
170
241
|
}
|
|
242
|
+
return {
|
|
243
|
+
host: host.displayName,
|
|
244
|
+
status: alreadyExists ? "already configured" : "configured with API Key",
|
|
245
|
+
filePath: mcpPath,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function printSetupResults(results, scope, serverUrl) {
|
|
250
|
+
log.blank();
|
|
251
|
+
log.plain(pc.green("✔ Flywheel setup complete"));
|
|
252
|
+
log.blank();
|
|
253
|
+
log.plain(` Scope: ${pc.bold(scope)}`);
|
|
254
|
+
log.plain(` Server URL: ${pc.bold(serverUrl)}`);
|
|
255
|
+
for (const result of results) {
|
|
256
|
+
const icon = result.status.startsWith("configured")
|
|
257
|
+
? pc.green("+")
|
|
258
|
+
: pc.dim("~");
|
|
259
|
+
log.plain(` ${pc.bold(result.host)}`);
|
|
260
|
+
log.plain(` ${icon} ${result.status}`);
|
|
261
|
+
log.plain(` ${pc.dim(result.filePath)}`);
|
|
262
|
+
}
|
|
263
|
+
log.blank();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function runSetupCommand(options) {
|
|
267
|
+
const scope = options.project ? "project" : "global";
|
|
268
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl || DEFAULT_BASE_URL);
|
|
269
|
+
const serverUrl = `${baseUrl}/mcp-server`;
|
|
270
|
+
const serverName = normalizeServerName(options.name || SERVER_NAME);
|
|
271
|
+
const hosts = await resolveHosts(options, scope, serverName);
|
|
272
|
+
if (hosts.length === 0) return;
|
|
273
|
+
|
|
274
|
+
const apiKey = await resolveApiKey(options, baseUrl);
|
|
275
|
+
if (!apiKey) {
|
|
276
|
+
log.warn("Setup cancelled");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
171
279
|
|
|
172
|
-
const
|
|
280
|
+
const spinner = ora("Setting up Flywheel...").start();
|
|
281
|
+
const results = [];
|
|
282
|
+
for (const hostName of hosts) {
|
|
283
|
+
spinner.text = `Setting up ${getHost(hostName).displayName}...`;
|
|
284
|
+
// eslint-disable-next-line no-await-in-loop
|
|
285
|
+
results.push(
|
|
286
|
+
await setupHost(hostName, scope, serverUrl, apiKey, serverName),
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
spinner.succeed("Setup complete");
|
|
290
|
+
printSetupResults(results, scope, serverUrl);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function parseUninstallScope(value) {
|
|
294
|
+
const normalized = String(value || "")
|
|
173
295
|
.trim()
|
|
174
|
-
.
|
|
296
|
+
.toLowerCase();
|
|
297
|
+
if (
|
|
298
|
+
normalized === "global" ||
|
|
299
|
+
normalized === "project" ||
|
|
300
|
+
normalized === "all"
|
|
301
|
+
) {
|
|
302
|
+
return normalized;
|
|
303
|
+
}
|
|
304
|
+
return "all";
|
|
305
|
+
}
|
|
175
306
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
hosts,
|
|
179
|
-
baseUrl,
|
|
180
|
-
serverUrl,
|
|
181
|
-
};
|
|
307
|
+
function scopesFromUninstallScope(scope) {
|
|
308
|
+
return scope === "all" ? ["global", "project"] : [scope];
|
|
182
309
|
}
|
|
183
310
|
|
|
184
|
-
async function
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
311
|
+
async function isConfiguredForUninstallScope(hostName, scope, serverName) {
|
|
312
|
+
const scopes = scopesFromUninstallScope(scope);
|
|
313
|
+
for (const singleScope of scopes) {
|
|
314
|
+
// eslint-disable-next-line no-await-in-loop
|
|
315
|
+
const configured = await isAlreadyConfigured(
|
|
316
|
+
hostName,
|
|
317
|
+
singleScope,
|
|
318
|
+
serverName,
|
|
319
|
+
).catch(() => false);
|
|
320
|
+
if (configured) return true;
|
|
188
321
|
}
|
|
189
|
-
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
190
324
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
325
|
+
async function promptUninstallScope(defaultScope) {
|
|
326
|
+
try {
|
|
327
|
+
return await select({
|
|
328
|
+
message: "Which scope do you want to uninstall from?",
|
|
329
|
+
choices: [
|
|
330
|
+
{ name: "All (global and project)", value: "all" },
|
|
331
|
+
{ name: "Global", value: "global" },
|
|
332
|
+
{ name: "Project", value: "project" },
|
|
333
|
+
],
|
|
334
|
+
default: defaultScope,
|
|
335
|
+
theme: CHECKBOX_THEME,
|
|
195
336
|
});
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
337
|
+
} catch {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function promptUninstallHosts(scope, serverName) {
|
|
343
|
+
const choices = await Promise.all(
|
|
344
|
+
ALL_HOST_NAMES.map(async (hostName) => {
|
|
345
|
+
const configured = await isConfiguredForUninstallScope(
|
|
346
|
+
hostName,
|
|
200
347
|
scope,
|
|
348
|
+
serverName,
|
|
201
349
|
);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
:
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
350
|
+
return {
|
|
351
|
+
name: SETUP_HOST_NAMES[hostName],
|
|
352
|
+
value: hostName,
|
|
353
|
+
checked: configured,
|
|
354
|
+
disabled: configured ? false : "(not configured)",
|
|
355
|
+
};
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
if (choices.every((choice) => Boolean(choice.disabled))) {
|
|
360
|
+
log.info("Flywheel is not configured for the selected scope.");
|
|
361
|
+
return null;
|
|
214
362
|
}
|
|
215
363
|
|
|
216
|
-
|
|
364
|
+
try {
|
|
365
|
+
return await checkbox({
|
|
366
|
+
message: "Which hosts do you want to uninstall?",
|
|
367
|
+
choices,
|
|
368
|
+
loop: false,
|
|
369
|
+
theme: CHECKBOX_THEME,
|
|
370
|
+
validate: (selected) =>
|
|
371
|
+
selected.length > 0 || "Select at least one host.",
|
|
372
|
+
});
|
|
373
|
+
} catch {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
217
376
|
}
|
|
218
377
|
|
|
219
|
-
async function
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
378
|
+
async function resolveUninstallTargets(options, serverName) {
|
|
379
|
+
const explicitHostsFromFlags = selectedHostsFromOptions(options);
|
|
380
|
+
const explicitHostsFromList =
|
|
381
|
+
explicitHostsFromFlags.length === 0 ? parseHostsList(options.hosts) : [];
|
|
382
|
+
const hasExplicitHosts =
|
|
383
|
+
explicitHostsFromFlags.length > 0 || explicitHostsFromList.length > 0;
|
|
384
|
+
const hasExplicitScope =
|
|
385
|
+
typeof options.scope === "string" && options.scope.trim().length > 0;
|
|
386
|
+
|
|
387
|
+
let scope = parseUninstallScope(options.scope || "all");
|
|
388
|
+
let hosts =
|
|
389
|
+
explicitHostsFromFlags.length > 0
|
|
390
|
+
? explicitHostsFromFlags
|
|
391
|
+
: explicitHostsFromList.length > 0
|
|
392
|
+
? explicitHostsFromList
|
|
393
|
+
: [...ALL_HOST_NAMES];
|
|
226
394
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
395
|
+
if (!options.yes) {
|
|
396
|
+
if (!hasExplicitScope) {
|
|
397
|
+
log.blank();
|
|
398
|
+
const selectedScope = await promptUninstallScope(scope);
|
|
399
|
+
if (!selectedScope) {
|
|
400
|
+
log.warn("Uninstall cancelled");
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
scope = selectedScope;
|
|
404
|
+
}
|
|
231
405
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
filePath,
|
|
241
|
-
status: result.changed
|
|
242
|
-
? result.hadExisting
|
|
243
|
-
? "updated"
|
|
244
|
-
: "installed"
|
|
245
|
-
: "already configured",
|
|
246
|
-
};
|
|
406
|
+
if (!hasExplicitHosts) {
|
|
407
|
+
const selectedHosts = await promptUninstallHosts(scope, serverName);
|
|
408
|
+
if (!selectedHosts) {
|
|
409
|
+
log.warn("Uninstall cancelled");
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
hosts = selectedHosts;
|
|
413
|
+
}
|
|
247
414
|
}
|
|
248
415
|
|
|
249
|
-
|
|
250
|
-
const next = upsertJsonServerEntry({
|
|
251
|
-
config: current,
|
|
252
|
-
configKey: config.configKey,
|
|
253
|
-
serverName: SERVER_NAME,
|
|
254
|
-
entry,
|
|
255
|
-
});
|
|
256
|
-
if (next.changed) {
|
|
257
|
-
await writeJsonConfig(filePath, next.config);
|
|
258
|
-
}
|
|
259
|
-
return {
|
|
260
|
-
host,
|
|
261
|
-
filePath,
|
|
262
|
-
status: next.changed
|
|
263
|
-
? next.hadExisting
|
|
264
|
-
? "updated"
|
|
265
|
-
: "installed"
|
|
266
|
-
: "already configured",
|
|
267
|
-
};
|
|
416
|
+
return { scope, hosts };
|
|
268
417
|
}
|
|
269
418
|
|
|
270
|
-
async function removeHostConfig(
|
|
271
|
-
const
|
|
272
|
-
const filePath =
|
|
419
|
+
async function removeHostConfig(hostName, scope, serverName) {
|
|
420
|
+
const host = getHost(hostName);
|
|
421
|
+
const filePath = await resolveMcpPath(mcpCandidatesForScope(host, scope));
|
|
273
422
|
|
|
274
|
-
if (
|
|
423
|
+
if (host.mcp.configType === "toml") {
|
|
275
424
|
const result = await removeCodexTomlServer({
|
|
276
425
|
filePath,
|
|
277
|
-
serverName
|
|
426
|
+
serverName,
|
|
278
427
|
});
|
|
279
428
|
return {
|
|
280
|
-
host,
|
|
429
|
+
host: host.displayName,
|
|
281
430
|
scope,
|
|
282
431
|
filePath,
|
|
283
432
|
status: result.changed ? "removed" : "not present",
|
|
284
433
|
};
|
|
285
434
|
}
|
|
286
435
|
|
|
287
|
-
const current =
|
|
436
|
+
const current =
|
|
437
|
+
host.mcp.configType === "yaml"
|
|
438
|
+
? await readYamlConfig(filePath)
|
|
439
|
+
: await readJsonConfig(filePath);
|
|
288
440
|
const next = removeJsonServerEntry({
|
|
289
441
|
config: current,
|
|
290
|
-
configKey:
|
|
291
|
-
serverName
|
|
442
|
+
configKey: host.mcp.configKey,
|
|
443
|
+
serverName,
|
|
292
444
|
});
|
|
293
445
|
if (next.changed) {
|
|
294
|
-
|
|
446
|
+
if (host.mcp.configType === "yaml") {
|
|
447
|
+
await writeYamlConfig(filePath, next.config);
|
|
448
|
+
} else {
|
|
449
|
+
await writeJsonConfig(filePath, next.config);
|
|
450
|
+
}
|
|
295
451
|
}
|
|
296
452
|
return {
|
|
297
|
-
host,
|
|
453
|
+
host: host.displayName,
|
|
298
454
|
scope,
|
|
299
455
|
filePath,
|
|
300
456
|
status: next.changed ? "removed" : "not present",
|
|
301
457
|
};
|
|
302
458
|
}
|
|
303
459
|
|
|
304
|
-
function
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
460
|
+
async function runUninstallCommand(options) {
|
|
461
|
+
const serverName = normalizeServerName(options.name || SERVER_NAME);
|
|
462
|
+
const resolved = await resolveUninstallTargets(options, serverName);
|
|
463
|
+
if (!resolved) return;
|
|
464
|
+
const { scope, hosts } = resolved;
|
|
465
|
+
const scopes = scopesFromUninstallScope(scope);
|
|
466
|
+
|
|
467
|
+
const spinner = ora("Removing Flywheel MCP entries...").start();
|
|
468
|
+
const results = [];
|
|
469
|
+
for (const singleScope of scopes) {
|
|
470
|
+
for (const host of hosts) {
|
|
471
|
+
spinner.text = `Removing from ${getHost(host).displayName} (${singleScope})...`;
|
|
472
|
+
// eslint-disable-next-line no-await-in-loop
|
|
473
|
+
results.push(await removeHostConfig(host, singleScope, serverName));
|
|
474
|
+
}
|
|
312
475
|
}
|
|
313
|
-
|
|
314
|
-
}
|
|
476
|
+
spinner.succeed("Uninstall complete");
|
|
315
477
|
|
|
316
|
-
|
|
317
|
-
|
|
478
|
+
log.blank();
|
|
479
|
+
log.plain(pc.green("✔ Flywheel uninstall complete"));
|
|
480
|
+
log.blank();
|
|
318
481
|
for (const result of results) {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
);
|
|
322
|
-
|
|
323
|
-
if (deletedKey) {
|
|
324
|
-
console.log(`\nDeleted shared API key: ${SHARED_KEY_PATH}`);
|
|
482
|
+
const icon = result.status === "removed" ? pc.green("-") : pc.dim("~");
|
|
483
|
+
log.plain(` ${pc.bold(result.host)} ${pc.dim(`(${result.scope})`)}`);
|
|
484
|
+
log.plain(` ${icon} ${result.status}`);
|
|
485
|
+
log.plain(` ${pc.dim(result.filePath)}`);
|
|
325
486
|
}
|
|
487
|
+
log.blank();
|
|
326
488
|
}
|
|
327
489
|
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
throw new Error("No API key available for setup.");
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
await writeSharedApiKey(apiKey);
|
|
348
|
-
|
|
349
|
-
const results = [];
|
|
350
|
-
for (const host of inputs.hosts) {
|
|
351
|
-
results.push(
|
|
352
|
-
await configureHost({
|
|
353
|
-
host,
|
|
354
|
-
scope: inputs.scope,
|
|
355
|
-
serverUrl: inputs.serverUrl,
|
|
356
|
-
apiKey,
|
|
357
|
-
}),
|
|
490
|
+
function buildProgram() {
|
|
491
|
+
const program = new Command();
|
|
492
|
+
program
|
|
493
|
+
.name("flywheel")
|
|
494
|
+
.description("Flywheel setup CLI")
|
|
495
|
+
.addHelpText(
|
|
496
|
+
"after",
|
|
497
|
+
`
|
|
498
|
+
Examples:
|
|
499
|
+
${pc.green("npx @paradigma-inc/flywheel setup")}
|
|
500
|
+
${pc.green("npx @paradigma-inc/flywheel setup --codex --project")}
|
|
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
|
+
)}
|
|
505
|
+
`,
|
|
358
506
|
);
|
|
359
|
-
}
|
|
360
507
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
await
|
|
384
|
-
|
|
385
|
-
} catch {
|
|
386
|
-
deletedKey = false;
|
|
387
|
-
}
|
|
388
|
-
}
|
|
508
|
+
program
|
|
509
|
+
.command("setup")
|
|
510
|
+
.description("Set up Flywheel for your AI coding host")
|
|
511
|
+
.option("--claude", "Set up for Claude Code")
|
|
512
|
+
.option("--opencode", "Set up for OpenCode")
|
|
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")
|
|
518
|
+
.option(
|
|
519
|
+
"-p, --project",
|
|
520
|
+
"Configure for current project instead of globally",
|
|
521
|
+
)
|
|
522
|
+
.option("-y, --yes", "Skip host selection prompts")
|
|
523
|
+
.option("--api-key <key>", "Use API key authentication")
|
|
524
|
+
.option(
|
|
525
|
+
"--base-url <url>",
|
|
526
|
+
`Public Flywheel origin used for setup and MCP config (default: ${DEFAULT_BASE_URL})`,
|
|
527
|
+
)
|
|
528
|
+
.option("--name <name>", `MCP server name (default: ${SERVER_NAME})`)
|
|
529
|
+
.action(async (options) => {
|
|
530
|
+
await runSetupCommand(options);
|
|
531
|
+
});
|
|
389
532
|
|
|
390
|
-
|
|
391
|
-
|
|
533
|
+
program
|
|
534
|
+
.command("uninstall")
|
|
535
|
+
.description("Remove Flywheel MCP entries from host configs")
|
|
536
|
+
.option("--claude", "Uninstall for Claude Code")
|
|
537
|
+
.option("--opencode", "Uninstall for OpenCode")
|
|
538
|
+
.option("--codex", "Uninstall for Codex")
|
|
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
|
+
)
|
|
547
|
+
.option("--scope <scope>", "all | global | project")
|
|
548
|
+
.option(
|
|
549
|
+
"--name <name>",
|
|
550
|
+
`MCP server name to remove (default: ${SERVER_NAME})`,
|
|
551
|
+
)
|
|
552
|
+
.option("-y, --yes", "Skip uninstall selection prompts")
|
|
553
|
+
.action(async (options) => {
|
|
554
|
+
await runUninstallCommand(options);
|
|
555
|
+
});
|
|
392
556
|
|
|
393
|
-
|
|
394
|
-
return randomBytes(bytes).toString("hex");
|
|
557
|
+
return program;
|
|
395
558
|
}
|
|
396
559
|
|
|
397
560
|
export async function runCli(argv = process.argv) {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
if (command === "setup") {
|
|
412
|
-
await runSetup(options);
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
if (command === "uninstall") {
|
|
417
|
-
await runUninstall(options);
|
|
418
|
-
return;
|
|
561
|
+
try {
|
|
562
|
+
const program = buildProgram();
|
|
563
|
+
await program.parseAsync(argv);
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (error instanceof Error && error.name === "ExitPromptError") {
|
|
566
|
+
process.exit(0);
|
|
567
|
+
}
|
|
568
|
+
if (error instanceof Error && /cancelled/i.test(error.message)) {
|
|
569
|
+
log.warn(error.message);
|
|
570
|
+
process.exit(0);
|
|
571
|
+
}
|
|
572
|
+
throw error;
|
|
419
573
|
}
|
|
420
|
-
|
|
421
|
-
throw new Error(`Unknown command '${command}'. Use --help for usage.`);
|
|
422
574
|
}
|