@paradigma-inc/flywheel 0.1.1 → 0.1.5

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