@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/src/cli.mjs CHANGED
@@ -1,283 +1,367 @@
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,
18
21
  removeCodexTomlServer,
19
22
  removeJsonServerEntry,
20
- upsertCodexTomlServer,
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
- 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
- };
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
- 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}`);
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
- return { command, options };
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 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);
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 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;
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 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(/\/$/, "");
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
- if (!options.yes) {
144
- const rl = readline.createInterface({
145
- input: process.stdin,
146
- output: process.stdout,
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
- try {
149
- const promptedScope = await promptLine(
150
- rl,
151
- "Scope [global/project]",
152
- scope,
153
- );
154
- scope = normalizeScope(promptedScope);
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
155
124
 
156
- const promptedHosts = await promptLine(
157
- rl,
158
- "Hosts (comma-separated codex,claude,opencode)",
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
- baseUrl = (await promptLine(rl, "Flywheel base URL", baseUrl)).replace(
164
- /\/$/,
165
- "",
166
- );
167
- } finally {
168
- rl.close();
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
- const serverUrl = (options.serverUrl || `${baseUrl}/mcp-server`)
173
- .trim()
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
- scope,
178
- hosts,
179
- baseUrl,
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
- async function resolveUninstallInputs(options) {
185
- let scope = (options.scope || "all").trim().toLowerCase();
186
- if (!["all", "global", "project"].includes(scope)) {
187
- scope = "all";
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
- let hosts = parseHostsValue(options.hosts);
207
+ log.blank();
208
+ }
190
209
 
191
- if (!options.yes) {
192
- const rl = readline.createInterface({
193
- input: process.stdin,
194
- output: process.stdout,
195
- });
196
- try {
197
- const promptedScope = await promptLine(
198
- rl,
199
- "Scope [all/global/project]",
200
- scope,
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
- return { scope, hosts };
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
- 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
- });
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
- 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 });
248
+ function scopesFromUninstallScope(scope) {
249
+ return scope === "all" ? ["global", "project"] : [scope];
250
+ }
231
251
 
232
- if (config.configType === "toml") {
233
- const result = await upsertCodexTomlServer({
234
- filePath,
235
- serverName: SERVER_NAME,
236
- entry,
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
- return {
239
- host,
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
- 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);
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 removeHostConfig({ host, scope }) {
271
- const config = AGENT_CONFIG[host];
272
- const filePath = resolveHostConfigPath(host, scope);
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
- if (config.configType === "toml") {
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: config.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 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
- );
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
- console.log(`\nStored API key: ${SHARED_KEY_PATH}`);
314
- }
403
+ spinner.succeed("Uninstall complete");
315
404
 
316
- function printUninstallSummary(results, deletedKey) {
317
- console.log("\nFlywheel MCP uninstall complete\n");
405
+ log.blank();
406
+ log.plain(pc.green(" Flywheel uninstall complete"));
407
+ log.blank();
318
408
  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}`);
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
- 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
- }),
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
- 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
- }
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
- printUninstallSummary(results, deletedKey);
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
- function cryptoRandomHex(bytes) {
394
- return randomBytes(bytes).toString("hex");
466
+ return program;
395
467
  }
396
468
 
397
469
  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;
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
  }