@paradigma-inc/flywheel 0.1.4 → 0.1.9

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.
Files changed (28) hide show
  1. package/README.md +129 -24
  2. package/package.json +10 -8
  3. package/skills/flywheel/SKILL.md +52 -0
  4. package/skills/flywheel/agents/openai.yaml +15 -0
  5. package/skills/flywheel/campaigns/participating-in-a-campaign.md +18 -0
  6. package/skills/flywheel/compute/credits-and-billing.md +11 -0
  7. package/skills/flywheel/compute/managed-compute.md +44 -0
  8. package/skills/flywheel/example-workflows/organizing-exploring-and-iterating-on-a-research-topic.md +255 -0
  9. package/skills/flywheel/example-workflows/reproducing-papers-on-a-budget.md +151 -0
  10. package/skills/flywheel/getting-started/account-access.md +8 -0
  11. package/skills/flywheel/getting-started/flywheel-quickstart.md +23 -0
  12. package/skills/flywheel/getting-started/flywheel-tutorial-overview.md +30 -0
  13. package/skills/flywheel/reference/experiment-design-protocol.md +200 -0
  14. package/skills/flywheel/reference/flywheel-mcp-tool-map.md +160 -0
  15. package/skills/flywheel/setting-up-flywheel/claude-code-cli-installation.md +16 -0
  16. package/skills/flywheel/setting-up-flywheel/codex-cli-installation.md +16 -0
  17. package/skills/flywheel/setting-up-flywheel/how-can-i-get-an-authorized-client_id-for-the-oauth-flow.md +50 -0
  18. package/skills/flywheel/setting-up-flywheel/installation-overview.md +26 -0
  19. package/skills/flywheel/setting-up-flywheel/other-hosts-installation.md +40 -0
  20. package/skills/flywheel/setting-up-flywheel/updating-flywheel-mcp.md +21 -0
  21. package/skills/flywheel/usage-and-workflows/using-local-hardware-with-flywheel.md +57 -0
  22. package/skills/flywheel/usage-and-workflows/what-to-do-with-flywheel.md +34 -0
  23. package/skills/flywheel/web-ui/flywheel-webui-map.md +28 -0
  24. package/skills/flywheel/web-ui/the-flywheel-web-ui.md +17 -0
  25. package/src/cli.mjs +508 -54
  26. package/src/mcp-writer.mjs +128 -3
  27. package/src/setup-auth.mjs +231 -27
  28. package/src/skill-installer.mjs +542 -0
package/src/cli.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  import { Command } from "commander";
2
- import { checkbox, select } from "@inquirer/prompts";
2
+ import { checkbox, confirm, select } from "@inquirer/prompts";
3
3
  import ora from "ora";
4
4
  import pc from "picocolors";
5
+ import { rm } from "node:fs/promises";
5
6
  import path from "node:path";
6
7
  import { randomBytes } from "node:crypto";
7
8
 
@@ -16,8 +17,9 @@ import {
16
17
  import {
17
18
  appendTomlServer,
18
19
  mergeServerEntry,
20
+ readNamedConfigEntry,
19
21
  readJsonConfig,
20
- readTomlServerExists,
22
+ readTomlServerEntry,
21
23
  readYamlConfig,
22
24
  removeCodexTomlServer,
23
25
  removeJsonServerEntry,
@@ -25,10 +27,32 @@ import {
25
27
  writeJsonConfig,
26
28
  writeYamlConfig,
27
29
  } from "./mcp-writer.mjs";
28
- import { acquireApiKeyViaBrowserBridge } from "./setup-auth.mjs";
30
+ import {
31
+ acquireApiKeyViaBrowserBridge,
32
+ acquireApiKeyViaDeviceFlow,
33
+ resolveSetupAuthMode,
34
+ } from "./setup-auth.mjs";
35
+ import {
36
+ cleanupInstalledSkillArtifactsForHosts,
37
+ formatSupportedInstallSkillHosts,
38
+ inspectInstalledSkillArtifactsForHosts,
39
+ installBundledSkillForHosts,
40
+ listBundledSkills,
41
+ partitionInstallSkillHosts,
42
+ resolveInstalledSkillDir,
43
+ resolvePackageRoot,
44
+ } from "./skill-installer.mjs";
29
45
 
46
+ const INSTALL_SKILL_BASE_URL = "https://flywheel.paradigma.inc";
30
47
  const DEFAULT_BASE_URL =
31
- process.env.FLYWHEEL_PUBLIC_BASE_URL || "https://flywheel.paradigma.inc";
48
+ process.env.FLYWHEEL_PUBLIC_BASE_URL || INSTALL_SKILL_BASE_URL;
49
+ const NON_INTERACTIVE_SKILL_DECISION_ERROR =
50
+ "Non-interactive setup requires one of --install-skill or --skip-skill.";
51
+ const MUTUALLY_EXCLUSIVE_SKILL_FLAGS_ERROR =
52
+ "--install-skill and --skip-skill cannot be used together.";
53
+ const GUIDED_SKILL_DECISION_TEST_ENV =
54
+ "FLYWHEEL_GUIDED_SKILL_DECISION_FOR_TESTS";
55
+ const ENABLE_TEST_SEAMS_ENV = "FLYWHEEL_ENABLE_TEST_SEAMS";
32
56
 
33
57
  const log = {
34
58
  info: (message) => console.log(pc.cyan(message)),
@@ -71,6 +95,20 @@ function normalizeServerName(value) {
71
95
  return normalized;
72
96
  }
73
97
 
98
+ function normalizeAuthMode(value) {
99
+ const normalized = String(value || "auto")
100
+ .trim()
101
+ .toLowerCase();
102
+ if (
103
+ normalized === "auto" ||
104
+ normalized === "loopback" ||
105
+ normalized === "device"
106
+ ) {
107
+ return normalized;
108
+ }
109
+ throw new Error("Auth mode must be one of: auto, loopback, device.");
110
+ }
111
+
74
112
  function selectedHostsFromOptions(options) {
75
113
  const hosts = [];
76
114
  if (options.claude) hosts.push("claude");
@@ -100,39 +138,122 @@ function mcpCandidatesForScope(host, scope) {
100
138
  );
101
139
  }
102
140
 
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;
141
+ async function isAlreadyConfigured(hostName, scope, serverName) {
142
+ const existingState = await readExistingServerState(
143
+ hostName,
144
+ scope,
145
+ serverName,
146
+ );
147
+ return existingState.exists;
122
148
  }
123
149
 
124
- async function isAlreadyConfigured(hostName, scope, serverName) {
150
+ async function readExistingServerState(hostName, scope, serverName) {
125
151
  const host = getHost(hostName);
126
152
  const mcpPath = await resolveMcpPath(mcpCandidatesForScope(host, scope));
153
+
127
154
  if (host.mcp.configType === "toml") {
128
- return readTomlServerExists(mcpPath, serverName);
155
+ const { exists, url } = await readTomlServerEntry(mcpPath, serverName);
156
+ return { exists, url, mcpPath };
129
157
  }
158
+
130
159
  const existing =
131
160
  host.mcp.configType === "yaml"
132
161
  ? await readYamlConfig(mcpPath)
133
162
  : await readJsonConfig(mcpPath);
134
- const section = readConfigSection(existing, host.mcp.configKey);
135
- return serverName in section;
163
+ const entry = readNamedConfigEntry(existing, host.mcp.configKey, serverName);
164
+ return {
165
+ exists: entry !== null,
166
+ url: typeof entry?.url === "string" ? entry.url : null,
167
+ mcpPath,
168
+ };
169
+ }
170
+
171
+ async function inspectInstallSkillHosts({
172
+ hostNames,
173
+ scope,
174
+ serverName,
175
+ serverUrl,
176
+ }) {
177
+ const hostStates = new Map();
178
+ const needsSetupHosts = [];
179
+ const driftHosts = [];
180
+
181
+ for (const hostName of hostNames) {
182
+ // eslint-disable-next-line no-await-in-loop
183
+ const existingState = await readExistingServerState(
184
+ hostName,
185
+ scope,
186
+ serverName,
187
+ );
188
+ if (!existingState.exists) {
189
+ needsSetupHosts.push(hostName);
190
+ hostStates.set(hostName, {
191
+ classification: "needs-setup",
192
+ mcpPath: existingState.mcpPath,
193
+ });
194
+ continue;
195
+ }
196
+
197
+ if (existingState.url === serverUrl) {
198
+ hostStates.set(hostName, {
199
+ classification: "matching",
200
+ mcpPath: existingState.mcpPath,
201
+ });
202
+ continue;
203
+ }
204
+
205
+ driftHosts.push({
206
+ hostName,
207
+ mcpPath: existingState.mcpPath,
208
+ existingUrl: existingState.url ?? "(missing URL)",
209
+ });
210
+ hostStates.set(hostName, {
211
+ classification: "drift",
212
+ mcpPath: existingState.mcpPath,
213
+ });
214
+ }
215
+
216
+ return { hostStates, needsSetupHosts, driftHosts };
217
+ }
218
+
219
+ function formatInstallSkillDriftError({
220
+ hostName,
221
+ scope,
222
+ serverName,
223
+ existingUrl,
224
+ requestedUrl,
225
+ }) {
226
+ const host = getHost(hostName);
227
+ const scopeFlag = scope === "project" ? "--scope project" : "--scope global";
228
+ return `Flywheel MCP entry '${serverName}' for ${host.displayName} already points to ${existingUrl}, not ${requestedUrl}. Run 'flywheel uninstall ${scopeFlag} --${hostName} --name ${serverName}' first or choose a different --name.`;
229
+ }
230
+
231
+ function getGuidedSkillDecisionOverrideForTests() {
232
+ const testSeamsEnabled =
233
+ String(process.env[ENABLE_TEST_SEAMS_ENV] || "").trim() === "1";
234
+ if (!testSeamsEnabled) {
235
+ return "";
236
+ }
237
+
238
+ const normalizedGuidedDecisionOverride = String(
239
+ process.env[GUIDED_SKILL_DECISION_TEST_ENV] || "",
240
+ )
241
+ .trim()
242
+ .toLowerCase();
243
+ if (normalizedGuidedDecisionOverride.length === 0) {
244
+ return "";
245
+ }
246
+ if (
247
+ normalizedGuidedDecisionOverride === "accept" ||
248
+ normalizedGuidedDecisionOverride === "decline" ||
249
+ normalizedGuidedDecisionOverride === "abort"
250
+ ) {
251
+ return normalizedGuidedDecisionOverride;
252
+ }
253
+
254
+ throw new Error(
255
+ `${GUIDED_SKILL_DECISION_TEST_ENV} must be one of: accept, decline, abort.`,
256
+ );
136
257
  }
137
258
 
138
259
  async function promptHosts(scope, detected, serverName) {
@@ -171,12 +292,23 @@ async function promptHosts(scope, detected, serverName) {
171
292
  }
172
293
  }
173
294
 
174
- async function resolveHosts(options, scope, serverName) {
295
+ async function resolveHosts(options, scope, serverName, allowPrompt = true) {
175
296
  const explicit = selectedHostsFromOptions(options);
176
297
  if (explicit.length > 0) return explicit;
177
298
 
178
299
  const detected = await detectHosts(scope);
179
- if (detected.length > 0 && options.yes) return detected;
300
+ const promptDisabled =
301
+ options.yes || process.stdout.isTTY !== true || !allowPrompt;
302
+ if (detected.length > 0 && promptDisabled) {
303
+ return detected;
304
+ }
305
+
306
+ if (promptDisabled) {
307
+ log.warn(
308
+ "No hosts were detected for prompt-free setup. Pass explicit host flags such as --codex.",
309
+ );
310
+ return [];
311
+ }
180
312
 
181
313
  log.blank();
182
314
  const selected = await promptHosts(scope, detected, serverName);
@@ -188,20 +320,43 @@ async function resolveHosts(options, scope, serverName) {
188
320
  }
189
321
 
190
322
  async function resolveApiKey(options, baseUrl) {
191
- if (options.apiKey) return options.apiKey.trim();
323
+ if (Object.hasOwn(options, "apiKey")) {
324
+ const normalizedApiKey = String(options.apiKey || "").trim();
325
+ if (normalizedApiKey.length === 0) {
326
+ throw new Error("--api-key cannot be empty.");
327
+ }
328
+ return normalizedApiKey;
329
+ }
330
+
331
+ const requestedAuthMode = normalizeAuthMode(options.authMode || "auto");
332
+ const resolvedAuthMode = resolveSetupAuthMode({
333
+ requestedMode: requestedAuthMode,
334
+ });
192
335
 
193
336
  const spinner = ora("Configuring authentication...").start();
194
337
  try {
195
338
  const keyName = `flywheel-setup-${randomBytes(3).toString("hex")}`;
196
- const authResult = await acquireApiKeyViaBrowserBridge({
197
- baseUrl,
198
- keyName,
199
- });
200
- spinner.succeed("Authenticated");
339
+ const authResult =
340
+ resolvedAuthMode === "device"
341
+ ? await acquireApiKeyViaDeviceFlow({
342
+ baseUrl,
343
+ keyName,
344
+ verificationBaseUrl: baseUrl,
345
+ })
346
+ : await acquireApiKeyViaBrowserBridge({
347
+ baseUrl,
348
+ keyName,
349
+ });
350
+ spinner.succeed(`Authenticated (${resolvedAuthMode})`);
201
351
  return authResult.apiKey;
202
352
  } catch (error) {
203
353
  spinner.fail("Authentication failed");
204
- throw error;
354
+ const message =
355
+ error instanceof Error ? error.message : "Authentication failed.";
356
+ if (resolvedAuthMode === "loopback" && requestedAuthMode === "auto") {
357
+ throw new Error(`${message} Try --auth-mode device on remote shells.`);
358
+ }
359
+ throw new Error(message);
205
360
  }
206
361
  }
207
362
 
@@ -263,31 +418,319 @@ function printSetupResults(results, scope, serverUrl) {
263
418
  log.blank();
264
419
  }
265
420
 
421
+ async function promptGuidedSkillInstall(
422
+ bundledSkillNames,
423
+ unsupportedHosts,
424
+ guidedDecisionOverride = "",
425
+ ) {
426
+ if (guidedDecisionOverride === "accept") {
427
+ return true;
428
+ }
429
+ if (
430
+ guidedDecisionOverride === "decline" ||
431
+ guidedDecisionOverride === "abort"
432
+ ) {
433
+ return false;
434
+ }
435
+
436
+ const message = buildGuidedSkillInstallPrompt({
437
+ unsupportedHosts,
438
+ bundledSkillNames,
439
+ });
440
+
441
+ try {
442
+ return await confirm({
443
+ message,
444
+ default: true,
445
+ theme: CHECKBOX_THEME,
446
+ });
447
+ } catch {
448
+ return false;
449
+ }
450
+ }
451
+
452
+ export function buildGuidedSkillInstallPrompt({
453
+ unsupportedHosts,
454
+ bundledSkillNames,
455
+ }) {
456
+ const usesPlural = bundledSkillNames.length > 1;
457
+ if (unsupportedHosts.length > 0) {
458
+ return usesPlural
459
+ ? `Also install or refresh the bundled Flywheel skills for supported hosts? Unsupported hosts (${unsupportedHosts.join(", ")}) will remain MCP-only.`
460
+ : `Also install or refresh the bundled Flywheel skill for supported hosts? Unsupported hosts (${unsupportedHosts.join(", ")}) will remain MCP-only.`;
461
+ }
462
+ return usesPlural
463
+ ? "Also install or refresh the bundled Flywheel skills?"
464
+ : "Also install or refresh the bundled Flywheel skill?";
465
+ }
466
+
467
+ function logBundledSkillInstallPlan({
468
+ bundledSkillNames,
469
+ projectRoot,
470
+ scope,
471
+ supportedHosts,
472
+ }) {
473
+ for (const hostName of supportedHosts) {
474
+ const hostDisplayName = getHost(hostName).displayName;
475
+ const resolvedSkillRoot = path.dirname(
476
+ resolveInstalledSkillDir(
477
+ projectRoot,
478
+ hostName,
479
+ scope,
480
+ bundledSkillNames[0],
481
+ ),
482
+ );
483
+ log.plain(
484
+ `Installing bundled skills for ${hostDisplayName} (${scope}: ${resolvedSkillRoot}): ${bundledSkillNames.join(", ")}`,
485
+ );
486
+ }
487
+ }
488
+
489
+ function normalizeSkillDecisionMode(options) {
490
+ if (options.installSkill && options.skipSkill) {
491
+ throw new Error(MUTUALLY_EXCLUSIVE_SKILL_FLAGS_ERROR);
492
+ }
493
+ if (options.installSkill) return "install";
494
+ if (options.skipSkill) return "skip";
495
+ return "guided";
496
+ }
497
+
498
+ async function discardInstallSkillCleanupState(cleanupState) {
499
+ if (!cleanupState?.backupRoot) {
500
+ return null;
501
+ }
502
+ await rm(cleanupState.backupRoot, { force: true, recursive: true });
503
+ return null;
504
+ }
505
+
266
506
  async function runSetupCommand(options) {
507
+ const projectRoot = process.cwd();
508
+ const skillDecisionMode = normalizeSkillDecisionMode(options);
509
+ const guidedSkillDecisionOverride = getGuidedSkillDecisionOverrideForTests();
510
+ const nonInteractive = process.stdout.isTTY !== true;
511
+ const hasGuidedDecisionOverride = guidedSkillDecisionOverride.length > 0;
512
+ if (
513
+ nonInteractive &&
514
+ skillDecisionMode === "guided" &&
515
+ !hasGuidedDecisionOverride
516
+ ) {
517
+ throw new Error(NON_INTERACTIVE_SKILL_DECISION_ERROR);
518
+ }
519
+
267
520
  const scope = options.project ? "project" : "global";
268
521
  const baseUrl = normalizeBaseUrl(options.baseUrl || DEFAULT_BASE_URL);
269
522
  const serverUrl = `${baseUrl}/mcp-server`;
270
523
  const serverName = normalizeServerName(options.name || SERVER_NAME);
271
- const hosts = await resolveHosts(options, scope, serverName);
524
+ const hosts = await resolveHosts(
525
+ options,
526
+ scope,
527
+ serverName,
528
+ skillDecisionMode === "guided",
529
+ );
272
530
  if (hosts.length === 0) return;
273
531
 
274
- const apiKey = await resolveApiKey(options, baseUrl);
275
- if (!apiKey) {
276
- log.warn("Setup cancelled");
277
- return;
532
+ const { supportedHosts, unsupportedHosts } =
533
+ partitionInstallSkillHosts(hosts);
534
+ const packageRoot = resolvePackageRoot();
535
+ const bundledSkillNames =
536
+ skillDecisionMode === "skip" || supportedHosts.length === 0
537
+ ? []
538
+ : await listBundledSkills(packageRoot);
539
+
540
+ if (skillDecisionMode === "install" && supportedHosts.length === 0) {
541
+ throw new Error(
542
+ `--install-skill is not supported for: ${unsupportedHosts.join(", ")}. Supported hosts: ${formatSupportedInstallSkillHosts()}.`,
543
+ );
544
+ }
545
+ if (
546
+ skillDecisionMode !== "skip" &&
547
+ supportedHosts.length > 0 &&
548
+ bundledSkillNames.length === 0
549
+ ) {
550
+ throw new Error(`No bundled skills found under ${path.join(packageRoot, "skills")}.`);
278
551
  }
279
552
 
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),
553
+ const {
554
+ hostStates = new Map(),
555
+ needsSetupHosts = [],
556
+ driftHosts = [],
557
+ } = await inspectInstallSkillHosts({
558
+ hostNames: hosts,
559
+ scope,
560
+ serverName,
561
+ serverUrl,
562
+ });
563
+
564
+ const installDriftHosts = driftHosts;
565
+
566
+ if (skillDecisionMode === "install" && installDriftHosts.length > 0) {
567
+ throw new Error(
568
+ formatInstallSkillDriftError({
569
+ hostName: installDriftHosts[0].hostName,
570
+ scope,
571
+ serverName,
572
+ existingUrl: installDriftHosts[0].existingUrl,
573
+ requestedUrl: serverUrl,
574
+ }),
575
+ );
576
+ }
577
+
578
+ let cleanupState = null;
579
+ try {
580
+ if (skillDecisionMode === "install") {
581
+ logBundledSkillInstallPlan({
582
+ bundledSkillNames,
583
+ projectRoot,
584
+ scope,
585
+ supportedHosts,
586
+ });
587
+ cleanupState = await inspectInstalledSkillArtifactsForHosts({
588
+ projectRoot,
589
+ hostNames: supportedHosts,
590
+ scope,
591
+ skillNames: bundledSkillNames,
592
+ });
593
+ await installBundledSkillForHosts({
594
+ projectRoot,
595
+ hostNames: supportedHosts,
596
+ scope,
597
+ serverName,
598
+ serverUrl,
599
+ skillNames: bundledSkillNames,
600
+ });
601
+ if (unsupportedHosts.length > 0) {
602
+ log.plain(
603
+ `--install-skill skipped unsupported hosts (MCP-only): ${unsupportedHosts.join(", ")}.`,
604
+ );
605
+ }
606
+ }
607
+
608
+ const hostsNeedingSetup = needsSetupHosts;
609
+ let apiKey = null;
610
+ if (hostsNeedingSetup.length > 0) {
611
+ apiKey = await resolveApiKey(options, baseUrl);
612
+ if (!apiKey) {
613
+ if (cleanupState) {
614
+ await cleanupInstalledSkillArtifactsForHosts({
615
+ projectRoot,
616
+ hostNames: supportedHosts,
617
+ priorState: cleanupState,
618
+ scope,
619
+ skillNames: bundledSkillNames,
620
+ });
621
+ cleanupState = null;
622
+ }
623
+ log.warn("Setup cancelled");
624
+ return;
625
+ }
626
+ }
627
+
628
+ let setupResults = [];
629
+ const spinner = ora("Setting up Flywheel MCP...").start();
630
+ try {
631
+ const results = [];
632
+ for (const hostName of hosts) {
633
+ if (!needsSetupHosts.includes(hostName)) {
634
+ results.push({
635
+ host: getHost(hostName).displayName,
636
+ status: "already configured",
637
+ filePath: hostStates.get(hostName).mcpPath,
638
+ });
639
+ continue;
640
+ }
641
+
642
+ spinner.text = `Setting up ${getHost(hostName).displayName}...`;
643
+ // eslint-disable-next-line no-await-in-loop
644
+ results.push(
645
+ await setupHost(hostName, scope, serverUrl, apiKey, serverName),
646
+ );
647
+ }
648
+ spinner.succeed("MCP setup complete");
649
+ setupResults = results;
650
+ } catch (error) {
651
+ spinner.fail("Setup failed");
652
+ throw error;
653
+ }
654
+
655
+ if (skillDecisionMode !== "guided") {
656
+ const completedCleanupState = cleanupState;
657
+ cleanupState = null;
658
+ await discardInstallSkillCleanupState(completedCleanupState);
659
+ printSetupResults(setupResults, scope, serverUrl);
660
+ return;
661
+ }
662
+
663
+ if (supportedHosts.length === 0) {
664
+ log.plain(
665
+ `Bundled skill installation is unavailable for the selected hosts (${hosts.join(", ")}). Continuing with MCP-only setup.`,
666
+ );
667
+ printSetupResults(setupResults, scope, serverUrl);
668
+ return;
669
+ }
670
+
671
+ const shouldInstallSkill = await promptGuidedSkillInstall(
672
+ bundledSkillNames,
673
+ unsupportedHosts,
674
+ guidedSkillDecisionOverride,
287
675
  );
676
+ if (!shouldInstallSkill) {
677
+ printSetupResults(setupResults, scope, serverUrl);
678
+ return;
679
+ }
680
+
681
+ if (installDriftHosts.length > 0) {
682
+ throw new Error(
683
+ formatInstallSkillDriftError({
684
+ hostName: installDriftHosts[0].hostName,
685
+ scope,
686
+ serverName,
687
+ existingUrl: installDriftHosts[0].existingUrl,
688
+ requestedUrl: serverUrl,
689
+ }),
690
+ );
691
+ }
692
+
693
+ logBundledSkillInstallPlan({
694
+ bundledSkillNames,
695
+ projectRoot,
696
+ scope,
697
+ supportedHosts,
698
+ });
699
+ cleanupState = await inspectInstalledSkillArtifactsForHosts({
700
+ projectRoot,
701
+ hostNames: supportedHosts,
702
+ scope,
703
+ skillNames: bundledSkillNames,
704
+ });
705
+ await installBundledSkillForHosts({
706
+ projectRoot,
707
+ hostNames: supportedHosts,
708
+ scope,
709
+ serverName,
710
+ serverUrl,
711
+ skillNames: bundledSkillNames,
712
+ });
713
+ if (unsupportedHosts.length > 0) {
714
+ log.plain(
715
+ `Guided skill install skipped unsupported hosts (MCP-only): ${unsupportedHosts.join(", ")}.`,
716
+ );
717
+ }
718
+ const completedCleanupState = cleanupState;
719
+ cleanupState = null;
720
+ await discardInstallSkillCleanupState(completedCleanupState);
721
+ printSetupResults(setupResults, scope, serverUrl);
722
+ } catch (error) {
723
+ if (cleanupState) {
724
+ await cleanupInstalledSkillArtifactsForHosts({
725
+ projectRoot,
726
+ hostNames: supportedHosts,
727
+ priorState: cleanupState,
728
+ scope,
729
+ skillNames: bundledSkillNames,
730
+ });
731
+ }
732
+ throw error;
288
733
  }
289
- spinner.succeed("Setup complete");
290
- printSetupResults(results, scope, serverUrl);
291
734
  }
292
735
 
293
736
  function parseUninstallScope(value) {
@@ -496,11 +939,14 @@ function buildProgram() {
496
939
  "after",
497
940
  `
498
941
  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")}
942
+ ${pc.green("npx --yes @paradigma-inc/flywheel setup")}
943
+ ${pc.green("npx --yes @paradigma-inc/flywheel setup --install-skill")}
944
+ ${pc.green("npx --yes @paradigma-inc/flywheel setup --skip-skill")}
945
+ ${pc.green("npx --yes @paradigma-inc/flywheel setup --auth-mode device --codex --project")}
946
+ ${pc.green("npx --yes @paradigma-inc/flywheel setup --codex --project")}
947
+ ${pc.green("npx --yes @paradigma-inc/flywheel setup --yes --codex --claude --cursor")}
502
948
  ${pc.green(
503
- "npx @paradigma-inc/flywheel uninstall --scope all --hosts codex,claude,opencode,cursor,pi-mono,hermes-agent,openclaw",
949
+ "npx --yes @paradigma-inc/flywheel uninstall --scope all --hosts codex,claude,opencode,cursor,pi-mono,hermes-agent,openclaw",
504
950
  )}
505
951
  `,
506
952
  );
@@ -521,6 +967,14 @@ Examples:
521
967
  )
522
968
  .option("-y, --yes", "Skip host selection prompts")
523
969
  .option("--api-key <key>", "Use API key authentication")
970
+ .option(
971
+ "--auth-mode <mode>",
972
+ "Authentication mode: auto | loopback | device (default: auto)",
973
+ normalizeAuthMode,
974
+ "auto",
975
+ )
976
+ .option("--install-skill", "Install or refresh the bundled Flywheel skills")
977
+ .option("--skip-skill", "Skip bundled skill installation (MCP-only setup)")
524
978
  .option(
525
979
  "--base-url <url>",
526
980
  `Public Flywheel origin used for setup and MCP config (default: ${DEFAULT_BASE_URL})`,