@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
@@ -33,6 +33,77 @@ function normalizeLineEndings(text) {
33
33
  return text.replace(/\r\n/g, "\n");
34
34
  }
35
35
 
36
+ function stripTomlInlineComment(valueText) {
37
+ let inSingleQuotedString = false;
38
+ let inDoubleQuotedString = false;
39
+ let escapeNextChar = false;
40
+
41
+ for (let index = 0; index < valueText.length; index += 1) {
42
+ const char = valueText[index];
43
+
44
+ if (escapeNextChar) {
45
+ escapeNextChar = false;
46
+ continue;
47
+ }
48
+
49
+ if (inDoubleQuotedString && char === "\\") {
50
+ escapeNextChar = true;
51
+ continue;
52
+ }
53
+
54
+ if (!inDoubleQuotedString && char === "'") {
55
+ inSingleQuotedString = !inSingleQuotedString;
56
+ continue;
57
+ }
58
+
59
+ if (!inSingleQuotedString && char === '"') {
60
+ inDoubleQuotedString = !inDoubleQuotedString;
61
+ continue;
62
+ }
63
+
64
+ if (!inSingleQuotedString && !inDoubleQuotedString && char === "#") {
65
+ return valueText.slice(0, index).trim();
66
+ }
67
+ }
68
+
69
+ return valueText.trim();
70
+ }
71
+
72
+ function parseTomlStringValue(valueText) {
73
+ const stripped = stripTomlInlineComment(valueText);
74
+ if (!stripped) {
75
+ return null;
76
+ }
77
+
78
+ if (stripped.startsWith('"""') && stripped.endsWith('"""') && stripped.length >= 6) {
79
+ return stripped.slice(3, -3);
80
+ }
81
+
82
+ if (stripped.startsWith("'''") && stripped.endsWith("'''") && stripped.length >= 6) {
83
+ return stripped.slice(3, -3);
84
+ }
85
+
86
+ if (stripped.startsWith('"') && stripped.endsWith('"')) {
87
+ try {
88
+ return JSON.parse(stripped);
89
+ } catch {
90
+ return stripped.slice(1, -1);
91
+ }
92
+ }
93
+
94
+ if (stripped.startsWith("'") && stripped.endsWith("'")) {
95
+ return stripped.slice(1, -1);
96
+ }
97
+
98
+ return stripped;
99
+ }
100
+
101
+ function parseTomlSectionName(lineText) {
102
+ const withoutComment = stripTomlInlineComment(String(lineText || "").trim());
103
+ const sectionMatch = withoutComment.match(/^\[(.+)\]$/);
104
+ return sectionMatch ? sectionMatch[1] : null;
105
+ }
106
+
36
107
  export async function readJsonConfig(filePath) {
37
108
  let raw;
38
109
  try {
@@ -120,6 +191,15 @@ function readConfigSection(config, configKey) {
120
191
  return current;
121
192
  }
122
193
 
194
+ export function readNamedConfigEntry(config, configKey, serverName) {
195
+ const section = readConfigSection(config, configKey);
196
+ const entry = section[serverName];
197
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
198
+ return null;
199
+ }
200
+ return entry;
201
+ }
202
+
123
203
  function writeConfigSection(config, configKey, section) {
124
204
  const pathParts = keyPath(configKey);
125
205
  if (pathParts.length === 0) return config || {};
@@ -182,6 +262,52 @@ export async function readTomlServerExists(filePath, serverName) {
182
262
  }
183
263
  }
184
264
 
265
+ export async function readTomlServerEntry(filePath, serverName) {
266
+ let raw = "";
267
+ try {
268
+ raw = await readFile(filePath, "utf8");
269
+ } catch {
270
+ return { exists: false, url: null };
271
+ }
272
+
273
+ const normalized = normalizeLineEndings(raw);
274
+ const lines = normalized.split("\n");
275
+ const targetSection = `mcp_servers.${serverName}`;
276
+ let inTargetSection = false;
277
+ let exists = false;
278
+ let url = null;
279
+
280
+ for (const line of lines) {
281
+ const trimmed = line.trim();
282
+ const sectionName = parseTomlSectionName(trimmed);
283
+
284
+ if (sectionName) {
285
+ if (sectionName === targetSection) {
286
+ exists = true;
287
+ inTargetSection = true;
288
+ continue;
289
+ }
290
+
291
+ if (exists && !sectionName.startsWith(`${targetSection}.`)) {
292
+ break;
293
+ }
294
+
295
+ inTargetSection = false;
296
+ continue;
297
+ }
298
+
299
+ if (!inTargetSection) continue;
300
+
301
+ const withoutComment = stripTomlInlineComment(trimmed);
302
+ const urlMatch = withoutComment.match(/^url\s*=\s*(.+)$/);
303
+ if (!urlMatch) continue;
304
+
305
+ url = parseTomlStringValue(urlMatch[1]);
306
+ }
307
+
308
+ return { exists, url };
309
+ }
310
+
185
311
  export function buildTomlServerBlock(serverName, entry) {
186
312
  const lines = [`[mcp_servers.${serverName}]`];
187
313
  const headers =
@@ -242,10 +368,9 @@ function stripCodexTomlServerBlock({ tomlText, serverName }) {
242
368
 
243
369
  for (const line of lines) {
244
370
  const trimmed = line.trim();
245
- const sectionMatch = trimmed.match(/^\[(.+)\]$/);
371
+ const sectionName = parseTomlSectionName(trimmed);
246
372
 
247
- if (sectionMatch) {
248
- const sectionName = sectionMatch[1];
373
+ if (sectionName) {
249
374
  const isTargetSection =
250
375
  sectionName === `mcp_servers.${serverName}` ||
251
376
  sectionName.startsWith(`mcp_servers.${serverName}.`);
@@ -3,6 +3,8 @@ import http from "node:http";
3
3
  import { spawn } from "node:child_process";
4
4
 
5
5
  const DEFAULT_AUTH_TIMEOUT_MS = 5 * 60 * 1000;
6
+ const MIN_POLL_DELAY_MS = 1000;
7
+ const MAX_POLL_DELAY_MS = 10_000;
6
8
 
7
9
  function openUrlInBrowser(url) {
8
10
  const platform = process.platform;
@@ -32,6 +34,39 @@ function renderCallbackPage({ ok, message }) {
32
34
  </html>`;
33
35
  }
34
36
 
37
+ function resolveErrorMessage(payload, fallback) {
38
+ if (!payload || typeof payload !== "object") {
39
+ return fallback;
40
+ }
41
+ const detail = payload.detail;
42
+ if (typeof detail === "string" && detail.trim()) {
43
+ return detail.trim();
44
+ }
45
+ if (detail && typeof detail === "object") {
46
+ const message = detail.message;
47
+ if (typeof message === "string" && message.trim()) {
48
+ return message.trim();
49
+ }
50
+ }
51
+ return fallback;
52
+ }
53
+
54
+ async function parseJsonResponse(response) {
55
+ let payload = {};
56
+ try {
57
+ payload = await response.json();
58
+ } catch {
59
+ payload = {};
60
+ }
61
+ return payload;
62
+ }
63
+
64
+ function nonEmptyString(value) {
65
+ if (typeof value !== "string") return null;
66
+ const candidate = value.trim();
67
+ return candidate ? candidate : null;
68
+ }
69
+
35
70
  async function redeemSetupExchangeToken({
36
71
  baseUrl,
37
72
  exchangeToken,
@@ -54,38 +89,163 @@ async function redeemSetupExchangeToken({
54
89
  redirect_uri: redirectUri,
55
90
  }),
56
91
  });
57
- let payload = {};
58
- try {
59
- payload = await response.json();
60
- } catch {
61
- payload = {};
62
- }
92
+ const payload = await parseJsonResponse(response);
63
93
  if (!response.ok) {
64
- const detail = resolveRedeemErrorMessage(payload);
94
+ const detail = resolveErrorMessage(payload, "Setup exchange redemption failed.");
65
95
  throw new Error(detail);
66
96
  }
67
- const key = (payload?.key || "").trim();
97
+ const key = nonEmptyString(payload?.key);
68
98
  if (!key) {
69
99
  throw new Error("Setup exchange response missing API key.");
70
100
  }
71
101
  return { apiKey: key };
72
102
  }
73
103
 
74
- function resolveRedeemErrorMessage(payload) {
75
- if (!payload || typeof payload !== "object") {
76
- return "Setup exchange redemption failed.";
104
+ async function startSetupDeviceSession({
105
+ baseUrl,
106
+ keyName,
107
+ verificationBaseUrl,
108
+ }) {
109
+ const startUrl = new URL("/api/auth/mcp-api-keys/setup-device/start", baseUrl);
110
+ const response = await fetch(startUrl, {
111
+ method: "POST",
112
+ headers: {
113
+ "content-type": "application/json",
114
+ "Idempotency-Key": `setup-device-start:${crypto.randomUUID()}`,
115
+ },
116
+ body: JSON.stringify({
117
+ name: keyName,
118
+ verification_base_url: verificationBaseUrl,
119
+ }),
120
+ });
121
+ const payload = await parseJsonResponse(response);
122
+ if (!response.ok) {
123
+ const detail = resolveErrorMessage(payload, "Failed to start setup device flow.");
124
+ throw new Error(detail);
77
125
  }
78
- const detail = payload.detail;
79
- if (typeof detail === "string" && detail.trim()) {
80
- return detail.trim();
126
+
127
+ const deviceCode = nonEmptyString(payload.device_code);
128
+ const userCode = nonEmptyString(payload.user_code);
129
+ const verificationUri = nonEmptyString(payload.verification_uri);
130
+ const verificationUriComplete = nonEmptyString(payload.verification_uri_complete);
131
+ const pollIntervalSeconds = Number(payload.poll_interval_seconds);
132
+ const expiresInSeconds = Number(payload.expires_in_seconds);
133
+
134
+ if (!deviceCode || !userCode || !verificationUri || !verificationUriComplete) {
135
+ throw new Error("Setup device session response missing required fields.");
81
136
  }
82
- if (detail && typeof detail === "object") {
83
- const message = detail.message;
84
- if (typeof message === "string" && message.trim()) {
85
- return message.trim();
137
+ if (
138
+ !Number.isFinite(pollIntervalSeconds) ||
139
+ pollIntervalSeconds <= 0 ||
140
+ !Number.isFinite(expiresInSeconds) ||
141
+ expiresInSeconds <= 0
142
+ ) {
143
+ throw new Error("Setup device session response contained invalid timing values.");
144
+ }
145
+
146
+ return {
147
+ deviceCode,
148
+ userCode,
149
+ verificationUri,
150
+ verificationUriComplete,
151
+ pollIntervalSeconds: Math.floor(pollIntervalSeconds),
152
+ expiresInSeconds: Math.floor(expiresInSeconds),
153
+ };
154
+ }
155
+
156
+ async function pollSetupDeviceSession({ baseUrl, userCode, deviceCode }) {
157
+ const pollUrl = new URL("/api/auth/mcp-api-keys/setup-device/poll", baseUrl);
158
+ const response = await fetch(pollUrl, {
159
+ method: "POST",
160
+ headers: {
161
+ "content-type": "application/json",
162
+ "Idempotency-Key": `setup-device-poll:${deviceCode}:${Date.now()}`,
163
+ },
164
+ body: JSON.stringify({
165
+ user_code: userCode,
166
+ device_code: deviceCode,
167
+ }),
168
+ });
169
+ if (response.status === 404) {
170
+ return { status: "expired", pollIntervalSeconds: 0 };
171
+ }
172
+ const payload = await parseJsonResponse(response);
173
+ if (!response.ok) {
174
+ const detail = resolveErrorMessage(payload, "Setup device polling failed.");
175
+ throw new Error(detail);
176
+ }
177
+
178
+ const status = nonEmptyString(payload.status);
179
+ const pollIntervalSeconds = Number(payload.poll_interval_seconds);
180
+ if (!status || !Number.isFinite(pollIntervalSeconds) || pollIntervalSeconds <= 0) {
181
+ throw new Error("Setup device poll response missing required status fields.");
182
+ }
183
+ if (status === "pending") {
184
+ return {
185
+ status,
186
+ pollIntervalSeconds: Math.floor(pollIntervalSeconds),
187
+ };
188
+ }
189
+ if (status === "approved") {
190
+ const key = nonEmptyString(payload.key);
191
+ if (!key) {
192
+ throw new Error("Setup device poll response missing API key.");
193
+ }
194
+ return {
195
+ status,
196
+ pollIntervalSeconds: Math.floor(pollIntervalSeconds),
197
+ apiKey: key,
198
+ };
199
+ }
200
+ throw new Error(`Unsupported setup device session status: ${status}`);
201
+ }
202
+
203
+ function pollDelayMs({ pollIntervalSeconds, attempt }) {
204
+ const baseMs = Math.max(
205
+ MIN_POLL_DELAY_MS,
206
+ Math.floor(pollIntervalSeconds * 1000),
207
+ );
208
+ const rampMs = Math.min(5000, attempt * 250);
209
+ const jitterMs = Math.floor(Math.random() * 250);
210
+ return Math.min(MAX_POLL_DELAY_MS, baseMs + rampMs + jitterMs);
211
+ }
212
+
213
+ function defaultSleep(ms) {
214
+ return new Promise((resolve) => {
215
+ setTimeout(resolve, ms);
216
+ });
217
+ }
218
+
219
+ function launchBrowser(openUrl, url) {
220
+ try {
221
+ const child = openUrl(url);
222
+ if (child && typeof child.once === "function") {
223
+ child.once("error", () => {
224
+ // URL is printed to stdout; opener failures should not abort setup.
225
+ });
226
+ }
227
+ if (child && typeof child.unref === "function") {
228
+ child.unref();
86
229
  }
230
+ } catch {
231
+ // User can still open URL manually.
87
232
  }
88
- return "Setup exchange redemption failed.";
233
+ }
234
+
235
+ export function resolveSetupAuthMode({ requestedMode, env = process.env }) {
236
+ const normalizedRequested = String(requestedMode || "auto")
237
+ .trim()
238
+ .toLowerCase();
239
+
240
+ if (normalizedRequested === "device" || normalizedRequested === "loopback") {
241
+ return normalizedRequested;
242
+ }
243
+
244
+ const isRemoteShell =
245
+ nonEmptyString(env.SSH_CONNECTION) ||
246
+ nonEmptyString(env.SSH_CLIENT) ||
247
+ nonEmptyString(env.SSH_TTY);
248
+ return isRemoteShell ? "device" : "loopback";
89
249
  }
90
250
 
91
251
  export async function acquireApiKeyViaBrowserBridge({
@@ -221,14 +381,7 @@ export async function acquireApiKeyViaBrowserBridge({
221
381
  console.log("Opening browser for Flywheel login and key creation...");
222
382
  console.log(`If it does not open, use this URL:\n${setupUrl.toString()}\n`);
223
383
 
224
- try {
225
- const child = openUrl(setupUrl.toString());
226
- if (child && typeof child.unref === "function") {
227
- child.unref();
228
- }
229
- } catch {
230
- // User can still open URL manually.
231
- }
384
+ launchBrowser(openUrl, setupUrl.toString());
232
385
  });
233
386
 
234
387
  timeout = setTimeout(() => {
@@ -236,3 +389,54 @@ export async function acquireApiKeyViaBrowserBridge({
236
389
  }, timeoutMs);
237
390
  });
238
391
  }
392
+
393
+ export async function acquireApiKeyViaDeviceFlow({
394
+ baseUrl,
395
+ keyName,
396
+ verificationBaseUrl = baseUrl,
397
+ timeoutMs = DEFAULT_AUTH_TIMEOUT_MS,
398
+ openUrl = openUrlInBrowser,
399
+ sleep = defaultSleep,
400
+ }) {
401
+ const startedAt = Date.now();
402
+ const session = await startSetupDeviceSession({
403
+ baseUrl,
404
+ keyName,
405
+ verificationBaseUrl,
406
+ });
407
+
408
+ console.log("Opening browser for Flywheel device authorization...");
409
+ console.log(`Approval URL:\n${session.verificationUriComplete}\n`);
410
+ console.log(`Code: ${session.userCode}`);
411
+ console.log(
412
+ "If the page does not open automatically, open the URL above and approve this code.\n",
413
+ );
414
+
415
+ launchBrowser(openUrl, session.verificationUriComplete);
416
+
417
+ let attempt = 0;
418
+ while (Date.now() - startedAt < timeoutMs) {
419
+ // eslint-disable-next-line no-await-in-loop
420
+ const pollResult = await pollSetupDeviceSession({
421
+ baseUrl,
422
+ userCode: session.userCode,
423
+ deviceCode: session.deviceCode,
424
+ });
425
+ if (pollResult.status === "approved") {
426
+ return { apiKey: pollResult.apiKey };
427
+ }
428
+ if (pollResult.status === "expired") {
429
+ throw new Error("Setup device session expired. Please rerun setup.");
430
+ }
431
+
432
+ attempt += 1;
433
+ const delayMs = pollDelayMs({
434
+ pollIntervalSeconds: pollResult.pollIntervalSeconds,
435
+ attempt,
436
+ });
437
+ // eslint-disable-next-line no-await-in-loop
438
+ await sleep(delayMs);
439
+ }
440
+
441
+ throw new Error("Timed out waiting for device authorization.");
442
+ }