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