@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/src/cli.mjs CHANGED
@@ -1,422 +1,574 @@
1
- import { mkdir, rm, writeFile } from "node:fs/promises";
2
- import { randomBytes } from "node:crypto";
3
- import os from "node:os";
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 readline from "node:readline/promises";
6
+ import { randomBytes } from "node:crypto";
6
7
 
7
8
  import {
8
- AGENT_CONFIG,
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
- upsertCodexTomlServer,
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
- 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
- `);
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 parseArgs(argv) {
61
- const args = argv.slice(2);
62
- const command = args[0];
63
- const options = {
64
- hosts: null,
65
- scope: null,
66
- baseUrl: null,
67
- serverUrl: null,
68
- apiKey: null,
69
- name: null,
70
- yes: false,
71
- deleteKey: false,
72
- help: false,
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
- for (let i = 1; i < args.length; i += 1) {
76
- const arg = args[i];
77
- const next = args[i + 1];
78
- switch (arg) {
79
- case "--hosts":
80
- options.hosts = next || "";
81
- i += 1;
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}`);
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
- return { command, options };
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 parseHostsValue(value) {
123
- if (!value) return [...HOSTS];
124
- const pieces = String(value)
125
- .split(",")
126
- .map((v) => v.trim())
127
- .filter(Boolean);
128
- return normalizeHosts(pieces);
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 promptLine(rl, message, defaultValue = "") {
132
- const suffix = defaultValue ? ` (${defaultValue})` : "";
133
- const answer = await rl.question(`${message}${suffix}: `);
134
- const trimmed = answer.trim();
135
- return trimmed || defaultValue;
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 resolveSetupInputs(options) {
139
- let scope = normalizeScope(options.scope || DEFAULT_SCOPE);
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
- if (!options.yes) {
144
- const rl = readline.createInterface({
145
- input: process.stdin,
146
- output: process.stdout,
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
- try {
149
- const promptedScope = await promptLine(
150
- rl,
151
- "Scope [global/project]",
152
- scope,
153
- );
154
- scope = normalizeScope(promptedScope);
200
+ spinner.succeed("Authenticated");
201
+ return authResult.apiKey;
202
+ } catch (error) {
203
+ spinner.fail("Authentication failed");
204
+ throw error;
205
+ }
206
+ }
155
207
 
156
- const promptedHosts = await promptLine(
157
- rl,
158
- "Hosts (comma-separated codex,claude,opencode)",
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
- baseUrl = (await promptLine(rl, "Flywheel base URL", baseUrl)).replace(
164
- /\/$/,
165
- "",
166
- );
167
- } finally {
168
- rl.close();
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 serverUrl = (options.serverUrl || `${baseUrl}/mcp-server`)
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
- .replace(/\/$/, "");
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
- return {
177
- scope,
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 resolveUninstallInputs(options) {
185
- let scope = (options.scope || "all").trim().toLowerCase();
186
- if (!["all", "global", "project"].includes(scope)) {
187
- scope = "all";
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
- let hosts = parseHostsValue(options.hosts);
322
+ return false;
323
+ }
190
324
 
191
- if (!options.yes) {
192
- const rl = readline.createInterface({
193
- input: process.stdin,
194
- output: process.stdout,
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
- try {
197
- const promptedScope = await promptLine(
198
- rl,
199
- "Scope [all/global/project]",
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
- 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
- }
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
- return { scope, hosts };
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 writeSharedApiKey(apiKey) {
220
- await mkdir(path.dirname(SHARED_KEY_PATH), { recursive: true });
221
- await writeFile(SHARED_KEY_PATH, `${apiKey.trim()}\n`, {
222
- encoding: "utf8",
223
- mode: 0o600,
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
- async function configureHost({ host, scope, serverUrl, apiKey }) {
228
- const config = AGENT_CONFIG[host];
229
- const filePath = resolveHostConfigPath(host, scope);
230
- const entry = config.buildEntry({ serverUrl, apiKey });
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
- if (config.configType === "toml") {
233
- const result = await upsertCodexTomlServer({
234
- filePath,
235
- serverName: SERVER_NAME,
236
- entry,
237
- });
238
- return {
239
- host,
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
- const current = await readJsonConfig(filePath);
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({ host, scope }) {
271
- const config = AGENT_CONFIG[host];
272
- const filePath = resolveHostConfigPath(host, scope);
419
+ async function removeHostConfig(hostName, scope, serverName) {
420
+ const host = getHost(hostName);
421
+ const filePath = await resolveMcpPath(mcpCandidatesForScope(host, scope));
273
422
 
274
- if (config.configType === "toml") {
423
+ if (host.mcp.configType === "toml") {
275
424
  const result = await removeCodexTomlServer({
276
425
  filePath,
277
- serverName: SERVER_NAME,
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 = await readJsonConfig(filePath);
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: config.configKey,
291
- serverName: SERVER_NAME,
442
+ configKey: host.mcp.configKey,
443
+ serverName,
292
444
  });
293
445
  if (next.changed) {
294
- await writeJsonConfig(filePath, next.config);
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 printSetupSummary({ scope, serverUrl, results }) {
305
- console.log("\nFlywheel MCP setup complete\n");
306
- console.log(`Scope: ${scope}`);
307
- console.log(`Server URL: ${serverUrl}`);
308
- for (const result of results) {
309
- console.log(
310
- `- ${HOST_LABELS[result.host]}: ${result.status}\n ${result.filePath}`,
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
- console.log(`\nStored API key: ${SHARED_KEY_PATH}`);
314
- }
476
+ spinner.succeed("Uninstall complete");
315
477
 
316
- function printUninstallSummary(results, deletedKey) {
317
- console.log("\nFlywheel MCP uninstall complete\n");
478
+ log.blank();
479
+ log.plain(pc.green(" Flywheel uninstall complete"));
480
+ log.blank();
318
481
  for (const result of results) {
319
- console.log(
320
- `- ${HOST_LABELS[result.host]} (${result.scope}): ${result.status}\n ${result.filePath}`,
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
- async function runSetup(options) {
329
- const inputs = await resolveSetupInputs(options);
330
- const keyName =
331
- (options.name || `flywheel-setup-${cryptoRandomHex(3)}`).trim() ||
332
- `flywheel-setup-${cryptoRandomHex(3)}`;
333
-
334
- const apiKey =
335
- (options.apiKey && options.apiKey.trim()) ||
336
- (
337
- await acquireApiKeyViaBrowserBridge({
338
- baseUrl: inputs.baseUrl,
339
- keyName,
340
- })
341
- ).apiKey;
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
- }),
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
- printSetupSummary({
362
- scope: inputs.scope,
363
- serverUrl: inputs.serverUrl,
364
- results,
365
- });
366
- }
367
-
368
- async function runUninstall(options) {
369
- const inputs = await resolveUninstallInputs(options);
370
- const scopes =
371
- inputs.scope === "all" ? ["global", "project"] : [inputs.scope];
372
-
373
- const results = [];
374
- for (const scope of scopes) {
375
- for (const host of inputs.hosts) {
376
- results.push(await removeHostConfig({ host, scope }));
377
- }
378
- }
379
-
380
- let deletedKey = false;
381
- if (options.deleteKey) {
382
- try {
383
- await rm(SHARED_KEY_PATH, { force: true });
384
- deletedKey = true;
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
- printUninstallSummary(results, deletedKey);
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
- function cryptoRandomHex(bytes) {
394
- return randomBytes(bytes).toString("hex");
557
+ return program;
395
558
  }
396
559
 
397
560
  export async function runCli(argv = process.argv) {
398
- const { command, options } = parseArgs(argv);
399
-
400
- if (
401
- !command ||
402
- options.help ||
403
- command === "help" ||
404
- command === "--help" ||
405
- command === "-h"
406
- ) {
407
- printHelp();
408
- return;
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
  }