@nearform/fling 0.3.0 → 0.5.0

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/dist/cli.js CHANGED
@@ -1088,7 +1088,7 @@ function exitCodeFor(error) {
1088
1088
  // package.json
1089
1089
  var package_default = {
1090
1090
  name: "@nearform/fling",
1091
- version: "0.3.0",
1091
+ version: "0.5.0",
1092
1092
  license: "MIT",
1093
1093
  description: "Fling a folder of static files at a Fling server and get a link back. CLI + MCP server.",
1094
1094
  type: "module",
@@ -1308,18 +1308,19 @@ var ENV = {
1308
1308
  configDir: "FLING_CONFIG_DIR",
1309
1309
  debug: "FLING_DEBUG",
1310
1310
  /** Where to look for files given by name (the .mcpb extension sets it from its settings). */
1311
- downloadsDir: "FLING_DOWNLOADS_DIR"
1311
+ downloadsDir: "FLING_DOWNLOADS_DIR",
1312
+ /** A server the local MCP server adds to the remembered ones on start (the .mcpb bakes it in). */
1313
+ addServer: "FLING_ADD_SERVER"
1312
1314
  };
1313
- var cliConfigSchema = z9.object({
1314
- serverUrl: z9.string().url().optional(),
1315
- /** What `serverUrl` said about itself at login. A copy a newer CLI can't read is just refetched. */
1315
+ var serverEntrySchema = z9.object({
1316
+ /** What the server said about itself at login. A copy this CLI can't read is just refetched. */
1316
1317
  instance: InstanceMetadataSchema.optional().catch(void 0),
1317
- apiKey: z9.string().min(1).optional(),
1318
+ apiKey: z9.string().min(1).optional().catch(void 0),
1318
1319
  user: z9.object({
1319
1320
  email: z9.string().optional(),
1320
1321
  name: z9.string().optional(),
1321
1322
  userSlug: z9.string().optional()
1322
- }).optional()
1323
+ }).optional().catch(void 0)
1323
1324
  });
1324
1325
  function systemLocateOptions(env = process.env) {
1325
1326
  return { cwd: process.cwd(), home: homedir(), platform: process.platform, env };
@@ -1328,9 +1329,43 @@ function fromEnv(value) {
1328
1329
  const v = value?.trim();
1329
1330
  return v && !v.startsWith("${") ? v : void 0;
1330
1331
  }
1332
+ function serverToAdd(env = process.env) {
1333
+ return fromEnv(env[ENV.addServer]);
1334
+ }
1331
1335
  function resolveConfigDir(env = process.env) {
1332
1336
  return env[ENV.configDir] ?? join(env.HOME ?? homedir(), CONFIG_DIR_NAME);
1333
1337
  }
1338
+ function safeNormalize(input) {
1339
+ if (typeof input !== "string") return void 0;
1340
+ try {
1341
+ return normalizeServerUrl(input);
1342
+ } catch {
1343
+ return void 0;
1344
+ }
1345
+ }
1346
+ function isObject(value) {
1347
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1348
+ }
1349
+ function parseConfig(json) {
1350
+ if (!isObject(json)) throw new Error("expected a JSON object");
1351
+ const servers = {};
1352
+ let current;
1353
+ if ("servers" in json) {
1354
+ if (isObject(json.servers)) {
1355
+ for (const [key, value] of Object.entries(json.servers)) {
1356
+ const url = safeNormalize(key);
1357
+ const entry = isObject(value) ? serverEntrySchema.safeParse(value) : void 0;
1358
+ if (url && entry?.success) servers[url] = { ...servers[url], ...entry.data };
1359
+ }
1360
+ }
1361
+ current = safeNormalize(json.current);
1362
+ } else {
1363
+ current = safeNormalize(json.serverUrl);
1364
+ if (current) servers[current] = serverEntrySchema.parse(json);
1365
+ }
1366
+ if (current && !servers[current]) servers[current] = {};
1367
+ return current ? { current, servers } : { servers };
1368
+ }
1334
1369
  function createConfigStore(options = {}) {
1335
1370
  const env = options.env ?? process.env;
1336
1371
  const dir = options.dir ?? resolveConfigDir(env);
@@ -1340,7 +1375,7 @@ function createConfigStore(options = {}) {
1340
1375
  try {
1341
1376
  raw = await readFile(file, "utf8");
1342
1377
  } catch (error) {
1343
- if (error.code === "ENOENT") return {};
1378
+ if (error.code === "ENOENT") return { servers: {} };
1344
1379
  throw error;
1345
1380
  }
1346
1381
  let json;
@@ -1349,57 +1384,115 @@ function createConfigStore(options = {}) {
1349
1384
  } catch {
1350
1385
  throw new Error(`Config file ${file} is not valid JSON`);
1351
1386
  }
1352
- const parsed = cliConfigSchema.safeParse(json);
1353
- if (!parsed.success) {
1354
- throw new Error(`Config file ${file} is invalid: ${parsed.error.issues[0]?.message}`);
1387
+ try {
1388
+ return parseConfig(json);
1389
+ } catch (error) {
1390
+ throw new Error(`Config file ${file} is invalid: ${error.message}`);
1355
1391
  }
1356
- return parsed.data;
1357
1392
  }
1358
1393
  async function save(config) {
1359
1394
  await mkdir(dir, { recursive: true, mode: 448 });
1360
1395
  await chmod(dir, 448).catch(() => {
1361
1396
  });
1362
- await writeFile(file, `${JSON.stringify(config, null, 2)}
1397
+ const body = { current: config.current, servers: config.servers };
1398
+ await writeFile(file, `${JSON.stringify(body, null, 2)}
1363
1399
  `, { mode: 384 });
1364
1400
  await chmod(file, 384);
1365
1401
  }
1402
+ async function edit(change) {
1403
+ const config = await load();
1404
+ change(config);
1405
+ await save(config);
1406
+ return config;
1407
+ }
1366
1408
  return {
1367
1409
  dir,
1368
1410
  file,
1369
1411
  load,
1370
1412
  save,
1371
- async update(patch) {
1372
- const next = { ...await load(), ...patch };
1373
- await save(next);
1374
- return next;
1413
+ putServer(serverUrl, patch, opts = {}) {
1414
+ const url = normalizeServerUrl(serverUrl);
1415
+ return edit((config) => {
1416
+ config.servers[url] = { ...config.servers[url], ...patch };
1417
+ if (opts.makeCurrent ?? !config.current) config.current = url;
1418
+ });
1419
+ },
1420
+ async setCurrent(serverUrl) {
1421
+ const url = normalizeServerUrl(serverUrl);
1422
+ await edit((config) => {
1423
+ config.servers[url] ??= {};
1424
+ config.current = url;
1425
+ });
1375
1426
  },
1376
- async clearCredentials() {
1377
- const { serverUrl, instance } = await load();
1378
- await save({ serverUrl, instance });
1427
+ async clearCredentials(serverUrl) {
1428
+ const url = normalizeServerUrl(serverUrl);
1429
+ await edit((config) => {
1430
+ const entry = config.servers[url];
1431
+ if (entry) config.servers[url] = entry.instance ? { instance: entry.instance } : {};
1432
+ });
1433
+ },
1434
+ async removeServer(serverUrl) {
1435
+ const url = normalizeServerUrl(serverUrl);
1436
+ await edit((config) => {
1437
+ delete config.servers[url];
1438
+ if (config.current === url) config.current = void 0;
1439
+ });
1379
1440
  },
1380
1441
  async resolve(overrides = {}) {
1381
1442
  const config = await load();
1382
1443
  const envUrl = fromEnv(env[ENV.url]);
1383
1444
  const envKey = fromEnv(env[ENV.apiKey]);
1384
- const [rawUrl, serverUrlSource] = overrides.serverUrl ? [overrides.serverUrl, "flag"] : envUrl ? [envUrl, "env"] : config.serverUrl ? [config.serverUrl, "config"] : [void 0, "none"];
1385
- const serverUrl = rawUrl === void 0 ? void 0 : normalizeServerUrl(rawUrl);
1386
- const [apiKey, apiKeySource] = envKey ? [envKey, "env"] : config.apiKey ? [config.apiKey, "config"] : [void 0, "none"];
1387
- const stored = config.serverUrl && normalizeServerUrl(config.serverUrl);
1445
+ const [rawUrl, serverUrlSource] = overrides.serverUrl ? [overrides.serverUrl, "flag"] : envUrl ? [envUrl, "env"] : config.current ? [config.current, "config"] : [void 0, "none"];
1446
+ const serverUrl = rawUrl === void 0 ? void 0 : matchServer(Object.keys(config.servers), rawUrl, { loose: true }).url;
1447
+ const entry = serverUrl === void 0 ? void 0 : config.servers[serverUrl];
1448
+ const [apiKey, apiKeySource] = envKey ? [envKey, "env"] : entry?.apiKey ? [entry.apiKey, "config"] : [void 0, "none"];
1388
1449
  return {
1389
1450
  serverUrl,
1390
1451
  serverUrlSource,
1391
- instance: serverUrl && stored === serverUrl ? config.instance : void 0,
1452
+ current: config.current,
1453
+ remembered: entry !== void 0,
1454
+ instance: entry?.instance,
1392
1455
  apiKey,
1393
1456
  apiKeySource,
1394
- user: config.user,
1457
+ user: entry?.user,
1395
1458
  debug: Boolean(env[ENV.debug])
1396
1459
  };
1397
1460
  }
1398
1461
  };
1399
1462
  }
1463
+ var HAS_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
1464
+ function matchServer(remembered, input, options = {}) {
1465
+ const trimmed = input.trim();
1466
+ const normalized = safeNormalize(trimmed);
1467
+ if (normalized && remembered.includes(normalized)) return { url: normalized, remembered: true };
1468
+ if (!HAS_SCHEME.test(trimmed)) {
1469
+ const bare = trimmed.replace(/\/+$/, "").toLowerCase();
1470
+ const parts = remembered.map((url) => {
1471
+ const u = new URL(url);
1472
+ return { url, host: u.host, hostname: u.hostname, address: `${u.host}${u.pathname}` };
1473
+ });
1474
+ const tiers = [
1475
+ parts.filter((p) => p.host === bare || p.address === bare),
1476
+ options.loose && bare ? parts.filter((p) => p.address.startsWith(bare) || p.hostname.endsWith(`.${bare}`)) : []
1477
+ ];
1478
+ for (const found of tiers) {
1479
+ const [only] = found;
1480
+ if (only && found.length === 1) return { url: only.url, remembered: true };
1481
+ if (found.length > 1)
1482
+ throw new CliError(`"${input}" matches more than one server you have signed in to`, {
1483
+ hint: `Use the full address: ${found.map((p) => p.url).join(", ")}`
1484
+ });
1485
+ }
1486
+ }
1487
+ if (!normalized)
1488
+ throw new CliError(`"${input}" is not a server address`, {
1489
+ hint: "Use your Fling's address, e.g. https://fling.example.com."
1490
+ });
1491
+ return { url: normalized, remembered: false };
1492
+ }
1400
1493
  function normalizeServerUrl(input) {
1401
1494
  const trimmed = input.trim();
1402
- const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
1495
+ const withScheme = HAS_SCHEME.test(trimmed) ? trimmed : `https://${trimmed}`;
1403
1496
  const url = new URL(withScheme);
1404
1497
  if (url.protocol !== "https:" && url.protocol !== "http:") {
1405
1498
  throw new Error(`Not an http(s) URL: ${input}`);
@@ -1437,7 +1530,8 @@ async function loadInstance(store, config, fetch2) {
1437
1530
  if (!config.serverUrl) return null;
1438
1531
  try {
1439
1532
  const instance = await fetchInstance(config.serverUrl, fetch2);
1440
- if (config.serverUrlSource === "config") await store.update({ instance });
1533
+ if (config.remembered)
1534
+ await store.putServer(config.serverUrl, { instance }, { makeCurrent: false });
1441
1535
  return instance;
1442
1536
  } catch {
1443
1537
  return null;
@@ -1469,12 +1563,15 @@ async function loginFlow(options) {
1469
1563
  const { key } = await callback.result;
1470
1564
  const client = createApiClient({ serverUrl, apiKey: key, fetch: options.fetch });
1471
1565
  const user = await client.request("GET", "/api/me", { schema: MeSchema });
1472
- await store.update({
1566
+ await store.putServer(
1473
1567
  serverUrl,
1474
- instance,
1475
- apiKey: key,
1476
- user: { email: user.email, name: user.name, userSlug: user.userSlug }
1477
- });
1568
+ {
1569
+ instance,
1570
+ apiKey: key,
1571
+ user: { email: user.email, name: user.name, userSlug: user.userSlug }
1572
+ },
1573
+ { makeCurrent: options.makeCurrent ?? true }
1574
+ );
1478
1575
  const keyPrefix = redactKey(key);
1479
1576
  log.success(`Signed in as ${user.email}`);
1480
1577
  log.hint(`Server: ${serverUrl}`);
@@ -1950,45 +2047,120 @@ function commentsEnabled(instance) {
1950
2047
  }
1951
2048
 
1952
2049
  // src/commands/auth.ts
1953
- function loginServer(ctx, url) {
2050
+ async function loginServer(ctx, url) {
1954
2051
  if (url === void 0) return requireServer(ctx);
1955
- try {
1956
- return normalizeServerUrl(url);
1957
- } catch {
1958
- throw new CliError(`"${url}" is not a server address`, {
1959
- hint: `Run \`${ctx.binName} login <url>\` with your Fling's address, e.g. https://fling.example.com.`
1960
- });
1961
- }
2052
+ const { servers } = await ctx.store.load();
2053
+ return matchServer(Object.keys(servers), url).url;
1962
2054
  }
1963
2055
  async function runLogin(ctx, url) {
1964
2056
  const result = await loginFlow({
1965
- serverUrl: loginServer(ctx, url),
2057
+ serverUrl: await loginServer(ctx, url),
1966
2058
  store: ctx.store,
1967
2059
  log: ctx.log,
1968
2060
  open: ctx.open,
1969
- fetch: ctx.fetch
2061
+ fetch: ctx.fetch,
2062
+ makeCurrent: true
1970
2063
  });
1971
2064
  if (ctx.json) ctx.out.data(result, () => "");
1972
2065
  }
1973
- async function runLogout(ctx) {
2066
+ async function runUse(ctx, url) {
2067
+ const config = await ctx.store.load();
2068
+ const urls = Object.keys(config.servers);
2069
+ const overridden = ctx.config.serverUrlSource === "env" || ctx.config.serverUrlSource === "flag" ? ctx.config.serverUrl : void 0;
2070
+ const overrideHint = () => {
2071
+ if (overridden)
2072
+ ctx.log.hint(
2073
+ `${ctx.config.serverUrlSource === "env" ? "FLING_URL" : "--server"} is set: commands run with it use ${overridden}.`
2074
+ );
2075
+ };
2076
+ if (url === void 0) {
2077
+ const servers = urls.map((server) => {
2078
+ const entry2 = config.servers[server] ?? {};
2079
+ return {
2080
+ url: server,
2081
+ current: server === config.current,
2082
+ instance: entry2.instance?.name ?? null,
2083
+ email: entry2.apiKey ? entry2.user?.email ?? null : null,
2084
+ signedIn: Boolean(entry2.apiKey)
2085
+ };
2086
+ });
2087
+ if (servers.length === 0 && !ctx.json) {
2088
+ ctx.log.info("No Fling servers yet");
2089
+ ctx.log.hint(`Run \`${ctx.binName} login <url>\` with your Fling's address.`);
2090
+ return;
2091
+ }
2092
+ const width = Math.max(...servers.map((s) => s.url.length));
2093
+ ctx.out.data(
2094
+ { current: config.current ?? null, servers },
2095
+ () => servers.map(
2096
+ (s) => [
2097
+ s.current ? chalk5.bold("*") : " ",
2098
+ s.current ? chalk5.bold(s.url.padEnd(width)) : s.url.padEnd(width),
2099
+ s.instance ?? chalk5.dim("(unknown)"),
2100
+ s.signedIn ? s.email ?? chalk5.dim("(signed in)") : chalk5.dim("(signed out)")
2101
+ ].join(" ")
2102
+ ).join("\n")
2103
+ );
2104
+ if (!ctx.json) {
2105
+ ctx.log.hint(`* = current. Switch with \`${ctx.binName} use <url>\`.`);
2106
+ overrideHint();
2107
+ }
2108
+ return;
2109
+ }
2110
+ const match = matchServer(urls, url, { loose: true });
2111
+ if (!match.remembered)
2112
+ throw new CliError(`Not signed in to ${match.url} on this computer`, {
2113
+ hint: `Run \`${ctx.binName} login ${match.url}\` to add it, or \`${ctx.binName} use\` to list your servers.`
2114
+ });
2115
+ await ctx.store.setCurrent(match.url);
2116
+ const entry = config.servers[match.url] ?? {};
2117
+ const name = entry.instance?.name;
2118
+ const email = entry.apiKey ? entry.user?.email : void 0;
2119
+ ctx.out.data(
2120
+ { current: match.url, instance: name ?? null, email: email ?? null },
2121
+ () => `Now using ${chalk5.bold(match.url)}${name ? chalk5.dim(` (${name})`) : ""}`
2122
+ );
2123
+ if (!ctx.json) {
2124
+ if (entry.apiKey) ctx.log.hint(`Signed in as ${email ?? "an unknown user"}.`);
2125
+ else ctx.log.hint(`Signed out there: run \`${ctx.binName} login\` to sign in.`);
2126
+ overrideHint();
2127
+ }
2128
+ }
2129
+ async function runLogout(ctx, url) {
1974
2130
  const config = await ctx.store.load();
1975
- if (!config.apiKey) {
1976
- ctx.log.info("Not logged in \u2014 nothing to do");
2131
+ const target = url === void 0 ? ctx.config.serverUrl : matchServer(Object.keys(config.servers), url, { loose: true }).url;
2132
+ const entry = target === void 0 ? void 0 : config.servers[target];
2133
+ if (target === void 0 || entry === void 0) {
2134
+ ctx.log.info(`Not logged in${target ? ` to ${target}` : ""} \u2014 nothing to do`);
1977
2135
  return;
1978
2136
  }
1979
- await ctx.store.clearCredentials();
1980
- ctx.log.success("Logged out; API key removed from this machine");
1981
- if (ctx.config.serverUrl)
1982
- ctx.log.hint(`Revoke it on the server under Connections at ${ctx.config.serverUrl}`);
2137
+ if (target === config.current) {
2138
+ if (!entry.apiKey) {
2139
+ ctx.log.info("Not logged in \u2014 nothing to do");
2140
+ return;
2141
+ }
2142
+ await ctx.store.clearCredentials(target);
2143
+ ctx.log.success("Logged out; API key removed from this machine");
2144
+ } else {
2145
+ await ctx.store.removeServer(target);
2146
+ ctx.log.success(
2147
+ entry.apiKey ? `Logged out of ${target}; API key and server removed from this machine` : `Removed ${target} from this machine`
2148
+ );
2149
+ }
2150
+ if (entry.apiKey) ctx.log.hint(`Revoke it on the server under Connections at ${target}`);
1983
2151
  }
1984
2152
  async function runWhoami(ctx) {
1985
2153
  const client = requireClient(ctx);
1986
2154
  const me = await whoami(client);
1987
2155
  const instance = await loadInstance(ctx.store, ctx.config, ctx.fetch);
2156
+ const { serverUrl, current } = ctx.config;
2157
+ const others = Object.keys((await ctx.store.load()).servers).filter((s) => s !== serverUrl);
1988
2158
  ctx.out.data(
1989
2159
  {
1990
2160
  ...me,
1991
- serverUrl: ctx.config.serverUrl,
2161
+ serverUrl,
2162
+ serverUrlSource: ctx.config.serverUrlSource,
2163
+ current: current ?? null,
1992
2164
  instance: instance?.name ?? null,
1993
2165
  apiKey: redactKey(ctx.config.apiKey),
1994
2166
  apiKeySource: ctx.config.apiKeySource
@@ -1996,8 +2168,15 @@ async function runWhoami(ctx) {
1996
2168
  () => [
1997
2169
  `${chalk5.bold("User:")} ${me.name} <${me.email}>`,
1998
2170
  `${chalk5.bold("Sites:")} ${sitesPattern(sitesBaseOf(instance))} ${chalk5.dim("(names are first come, first served)")}`,
1999
- `${chalk5.bold("Server:")} ${ctx.config.serverUrl}${instance ? chalk5.dim(` (${instance.name})`) : ""}`,
2000
- `${chalk5.bold("API key:")} ${redactKey(ctx.config.apiKey)} ${chalk5.dim(`(from ${ctx.config.apiKeySource})`)}`
2171
+ `${chalk5.bold("Server:")} ${serverUrl}${instance ? chalk5.dim(` (${instance.name})`) : ""} ${chalk5.dim(
2172
+ serverUrl === current ? "(current)" : `(from ${ctx.config.serverUrlSource === "env" ? "FLING_URL" : "--server"}; current: ${current ?? "none"})`
2173
+ )}`,
2174
+ `${chalk5.bold("API key:")} ${redactKey(ctx.config.apiKey)} ${chalk5.dim(`(from ${ctx.config.apiKeySource})`)}`,
2175
+ ...others.length > 0 ? [
2176
+ chalk5.dim(
2177
+ `${others.length} other server${others.length === 1 ? "" : "s"} on this computer: \`${ctx.binName} use\` lists them`
2178
+ )
2179
+ ] : []
2001
2180
  ].join("\n")
2002
2181
  );
2003
2182
  }
@@ -3119,12 +3298,13 @@ async function runSetup(baseCtx, opts, deps = {}) {
3119
3298
  `);
3120
3299
  intro(chalk8.bold(" fling setup "), { output: process.stderr });
3121
3300
  }
3301
+ const login = await stepLogin(ctx, opts.url);
3122
3302
  const results = {
3123
- login: await stepLogin(ctx, opts.url),
3303
+ login: login.result,
3124
3304
  claudeCode: "skipped",
3125
3305
  claudeDesktop: "skipped"
3126
3306
  };
3127
- const after = results.login === "done" ? { ...ctx, config: await ctx.store.resolve() } : ctx;
3307
+ const after = login.serverUrl ? { ...ctx, config: await ctx.store.resolve({ serverUrl: login.serverUrl }) } : ctx;
3128
3308
  results.claudeCode = await stepClaudeCode(after, run, scope, ask, fancy);
3129
3309
  results.claudeDesktop = await stepClaudeDesktop(after, deps, ask, fancy);
3130
3310
  const failed = Object.values(results).includes("failed");
@@ -3137,10 +3317,22 @@ async function runSetup(baseCtx, opts, deps = {}) {
3137
3317
  if (failed) process.exitCode = 1;
3138
3318
  }
3139
3319
  async function stepLogin(ctx, url) {
3140
- const sameServer = url === void 0 || safeNormalize(url) === ctx.config.serverUrl;
3141
- if (ctx.config.apiKey && sameServer) {
3320
+ let serverUrl;
3321
+ let target;
3322
+ try {
3323
+ serverUrl = await loginServer(ctx, url);
3324
+ target = { ...ctx, config: await ctx.store.resolve({ serverUrl }) };
3325
+ } catch (error) {
3326
+ ctx.log.error(error instanceof Error ? error.message : String(error));
3327
+ return { result: "failed" };
3328
+ }
3329
+ return { result: await signIn(target, serverUrl), serverUrl };
3330
+ }
3331
+ async function signIn(ctx, serverUrl) {
3332
+ if (ctx.config.apiKey) {
3142
3333
  try {
3143
3334
  const me = await whoami(requireClient(ctx));
3335
+ if (ctx.config.remembered) await ctx.store.setCurrent(serverUrl);
3144
3336
  ctx.log.success(`Signed in as ${chalk8.bold(me.name)} ${chalk8.dim(`<${me.email}>`)}`);
3145
3337
  return "already";
3146
3338
  } catch (error) {
@@ -3152,20 +3344,13 @@ async function stepLogin(ctx, url) {
3152
3344
  }
3153
3345
  }
3154
3346
  try {
3155
- await runLogin({ ...ctx, json: false }, url);
3347
+ await runLogin({ ...ctx, json: false }, serverUrl);
3156
3348
  return "done";
3157
3349
  } catch (error) {
3158
3350
  ctx.log.error(error instanceof Error ? error.message : String(error));
3159
3351
  return "failed";
3160
3352
  }
3161
3353
  }
3162
- function safeNormalize(url) {
3163
- try {
3164
- return normalizeServerUrl(url);
3165
- } catch {
3166
- return void 0;
3167
- }
3168
- }
3169
3354
  async function stepClaudeCode(ctx, run, scope, ask, fancy) {
3170
3355
  const status = await claudeCodeStatus(run);
3171
3356
  if (status.claude === "missing") {
@@ -3661,11 +3846,132 @@ async function findFiles(opts, find = {}) {
3661
3846
  return { files, searched: roots, complete };
3662
3847
  }
3663
3848
 
3849
+ // src/mcp-servers.ts
3850
+ function serverLabel(url) {
3851
+ const u = new URL(url);
3852
+ return `${u.host}${u.pathname}`.replace(/\/+$/, "");
3853
+ }
3854
+ function describeServer(s) {
3855
+ const facts = [
3856
+ s.instance?.name,
3857
+ s.signedIn ? s.email ? `signed in as ${s.email}` : "signed in" : "signed out",
3858
+ s.current ? "current: the user's usual one" : void 0
3859
+ ].filter(Boolean);
3860
+ return `${serverLabel(s.url)} (${facts.join("; ")})`;
3861
+ }
3862
+ function knownServersOf(config) {
3863
+ return Object.entries(config.servers).map(([url, entry]) => ({
3864
+ url,
3865
+ instance: entry.instance,
3866
+ current: url === config.current,
3867
+ signedIn: Boolean(entry.apiKey),
3868
+ ...entry.apiKey && entry.user?.email ? { email: entry.user.email } : {}
3869
+ }));
3870
+ }
3871
+ var listing = (servers) => servers.map((s) => `- ${describeServer(s)}`).join("\n");
3872
+ var ServerNeededError = class extends CliError {
3873
+ servers;
3874
+ constructor(servers, tool2) {
3875
+ super(
3876
+ `Not run: several Flings are set up on this computer, so ${tool2} needs \`server\`.
3877
+ ${listing(servers)}
3878
+ Unless the conversation already says which Fling to use, ask the user (suggest their usual one, marked current). Then call ${tool2} again with \`server\` (e.g. "${serverLabel(servers[0]?.url ?? "https://fling.example.com")}"), and pass the same \`server\` on every call for the rest of the conversation.`
3879
+ );
3880
+ this.name = "ServerNeededError";
3881
+ this.servers = servers;
3882
+ }
3883
+ };
3884
+ function rememberedServer(config, input) {
3885
+ const servers = knownServersOf(config);
3886
+ const known = servers.length ? `The Flings set up on this computer:
3887
+ ${listing(servers)}` : "No Fling is set up on this computer yet.";
3888
+ let match;
3889
+ try {
3890
+ match = matchServer(Object.keys(config.servers), input, { loose: true });
3891
+ } catch (error) {
3892
+ throw new CliError(error.message, { hint: known });
3893
+ }
3894
+ if (!match.remembered)
3895
+ throw new CliError(`"${input}" is not a Fling set up on this computer`, {
3896
+ hint: `${known}
3897
+ To add one, call fling_login with its address as \`server\`.`
3898
+ });
3899
+ return match.url;
3900
+ }
3901
+ var isPinned = (config) => config.serverUrlSource === "flag" || config.serverUrlSource === "env";
3902
+ async function within(promise, ms, fallback) {
3903
+ let timer;
3904
+ const late = new Promise((r) => {
3905
+ timer = setTimeout(() => r(fallback), ms);
3906
+ });
3907
+ try {
3908
+ return await Promise.race([promise.catch(() => fallback), late]);
3909
+ } finally {
3910
+ clearTimeout(timer);
3911
+ }
3912
+ }
3913
+ var START_FETCH_MS = 5e3;
3914
+ async function addServerOnStart(store, address, fetch2) {
3915
+ if (!address) return;
3916
+ let url;
3917
+ try {
3918
+ url = normalizeServerUrl(address);
3919
+ } catch {
3920
+ return;
3921
+ }
3922
+ if (url in (await store.load()).servers) return;
3923
+ const instance = await within(fetchInstance(url, fetch2), START_FETCH_MS, null);
3924
+ await store.putServer(url, instance ? { instance } : {});
3925
+ }
3926
+ async function knownServers(store, fetch2, pin) {
3927
+ const resolved = await store.resolve({ serverUrl: pin });
3928
+ const config = await store.load();
3929
+ const all = knownServersOf(config);
3930
+ const servers = isPinned(resolved) ? resolved.serverUrl ? [
3931
+ all.find((s) => s.url === resolved.serverUrl) ?? {
3932
+ url: resolved.serverUrl,
3933
+ current: false,
3934
+ signedIn: Boolean(resolved.apiKey)
3935
+ }
3936
+ ] : [] : all;
3937
+ return Promise.all(
3938
+ servers.map(async (s) => {
3939
+ if (s.instance) return s;
3940
+ const instance = await within(fetchInstance(s.url, fetch2), START_FETCH_MS, null);
3941
+ if (instance && s.url in config.servers)
3942
+ await store.putServer(s.url, { instance }, { makeCurrent: false }).catch(() => {
3943
+ });
3944
+ return { ...s, instance };
3945
+ })
3946
+ );
3947
+ }
3948
+ function serverArgDescription(servers) {
3949
+ const [only] = servers;
3950
+ if (servers.length > 1)
3951
+ return `Which Fling: ${servers.map((s) => serverLabel(s.url)).join(", ")} (an address, host or unique part of it). Required while several Flings are set up: the one settled on for this conversation, on every call`;
3952
+ return `Which Fling, by address or host (any set up on this computer; see fling_servers). Omit it to use ${only ? serverLabel(only.url) : "the one this computer is signed in to"}`;
3953
+ }
3954
+ function serverToolNote(servers) {
3955
+ if (servers.length < 2) return "";
3956
+ return ` Pass \`server\`: several Flings are set up (${servers.map((s) => serverLabel(s.url)).join(", ")}), and every call names the one settled on for this conversation.`;
3957
+ }
3958
+ function serversInstructions(servers) {
3959
+ if (servers.length < 2) return void 0;
3960
+ const usual = servers.find((s) => s.current);
3961
+ return [
3962
+ `Several Flings are set up on this computer:
3963
+ ${listing(servers)}`,
3964
+ `Settle which one this conversation uses before anything else: from the user's words ("on ${serverLabel(servers[0]?.url ?? "")}", "on the ${servers[0]?.instance?.name ?? "other"} one") or, if they don't say, by asking them${usual ? ` (suggest ${serverLabel(usual.url)}, their usual one)` : ""}. Say which Fling you are using, and pass it as \`server\` on every call for the rest of the conversation; switch only when the user asks. Calls without \`server\` do not run while several Flings are set up. Each Fling has its own sites, sign-in and sites domain: give the user the links that come back, which name the Fling.`,
3965
+ "This list was read when this server started and may have changed: call fling_servers to see the Flings set up now. To sign in to a Fling, or add one, call fling_login with its address as `server`."
3966
+ ].join("\n\n");
3967
+ }
3968
+
3664
3969
  // src/mcp.ts
3665
3970
  var LOGIN_HINT = `Not logged in to Fling. Call fling_login (it opens the browser to sign in), or ask the user to run \`${NPX_COMMAND} login\` in a terminal (in Claude Code: \`! ${NPX_COMMAND} login\`), then try again.`;
3666
- var NO_SERVER_HINT = `No Fling server is set up on this computer. Ask the user for their Fling's address and to run \`${NPX_COMMAND} login <url>\` in a terminal (in Claude Code: \`! ${NPX_COMMAND} login <url>\`), then try again.`;
3971
+ var NO_SERVER_HINT = `No Fling server is set up on this computer. Ask the user for their Fling's address, then call fling_login with it as \`server\` (or ask them to run \`${NPX_COMMAND} login <url>\` in a terminal; in Claude Code: \`! ${NPX_COMMAND} login <url>\`), then try again.`;
3667
3972
  var mcpLocalInstructions = (w, opts = {}) => [
3668
3973
  `${w.name} publishes static sites (HTML/CSS/JS, images, documents) at ${w.sitesPattern}. This server runs on the user's computer.`,
3974
+ ...[serversInstructions(opts.servers ?? [])].filter((p) => p !== void 0),
3669
3975
  "Content you created in this conversation (an HTML page, a report, a small app): call fling_publish with every file inline (text as utf8, binary as base64). Never pass file paths from your sandbox (e.g. /mnt/user-data/outputs/\u2026, /sessions/\u2026) \u2014 this computer cannot see them \u2014 and never curl or upload from the sandbox.",
3670
3976
  'A file or folder the user has on this computer (a downloaded .html, a .zip, a build folder): call fling_deploy with its path, or just its file name (e.g. "Pitch Deck.html") \u2014 it is looked up in Downloads, Desktop, Documents/Claude and Claude Desktop output folders. Use fling_find_files to see recent candidates when unsure which file the user means.',
3671
3977
  "Confirm the site name with the user before publishing (fling_check_name shows whether it is free and, for a path, the generated name). Names are global, first come first served; when one is taken, offer the suggestions.",
@@ -3675,20 +3981,26 @@ var mcpLocalInstructions = (w, opts = {}) => [
3675
3981
  `Sites are private (only ${w.privateAudience}) by default; only make one public if asked. Use fling_share for a link people who cannot sign in can open. ${opts.comments ? "Always give the user the links that come back: the review link first, as the one to share for feedback, then the site URL." : "Always give the user the URL that comes back."}`,
3676
3982
  ...opts.comments ? [COMMENTS_NOTE(w)] : []
3677
3983
  ].join("\n\n");
3678
- var toolDef = (def) => ({
3679
- title: def.title,
3680
- description: def.description,
3681
- inputSchema: def.inputSchema,
3682
- annotations: def.annotations
3683
- });
3984
+ var onFling = (url, instance) => `
3985
+ Fling: ${serverLabel(url)}${instance ? ` (${instance.name})` : ""}`;
3684
3986
  function createMcpServer(deps = {}) {
3685
3987
  const store = deps.store ?? createConfigStore();
3686
3988
  const fetchImpl = deps.fetch ?? ((input, init) => fetch(input, init));
3687
3989
  const cwd = deps.cwd ?? process.cwd();
3688
3990
  const locate = deps.locate ?? { ...systemLocateOptions(), cwd };
3689
3991
  const waitReady = deps.waitReady ?? createReadyWaiter();
3690
- const wording = mcpWording(deps.instance);
3992
+ const servers = deps.servers ?? [];
3993
+ const several = servers.length > 1;
3994
+ const wording = mcpWording(several ? null : servers[0]?.instance);
3691
3995
  const mcpTools2 = mcpToolsFor(wording);
3996
+ const serverArg = z11.string().min(1).max(500).optional().describe(serverArgDescription(servers));
3997
+ const note = serverToolNote(servers);
3998
+ const serverTool = (def, description = def.description) => ({
3999
+ title: def.title,
4000
+ description: `${description}${note}`,
4001
+ inputSchema: { ...def.inputSchema, server: serverArg },
4002
+ annotations: def.annotations
4003
+ });
3692
4004
  async function edgeReady(url, paths) {
3693
4005
  const r = await waitReady(url, paths).catch(() => ({ ready: false, waitedMs: 0 }));
3694
4006
  return {
@@ -3697,27 +4009,44 @@ function createMcpServer(deps = {}) {
3697
4009
  Not reachable yet at ${url}: ${NOT_READY_NOTE}.`
3698
4010
  };
3699
4011
  }
3700
- async function client() {
3701
- const config = await store.resolve({ serverUrl: deps.server });
4012
+ async function choose(server2, tool2) {
4013
+ if (server2 !== void 0)
4014
+ return store.resolve({ serverUrl: rememberedServer(await store.load(), server2) });
4015
+ const resolved = await store.resolve({ serverUrl: deps.server });
4016
+ if (isPinned(resolved)) return resolved;
4017
+ const config = await store.load();
4018
+ const known = knownServersOf(config);
4019
+ if (known.length > 1) throw new ServerNeededError(known, tool2);
4020
+ const only = known[0];
4021
+ return !resolved.serverUrl && only ? store.resolve({ serverUrl: only.url }) : resolved;
4022
+ }
4023
+ async function target(server2, tool2) {
4024
+ const config = await choose(server2, tool2);
3702
4025
  if (!config.serverUrl) throw new NoServerError("fling");
3703
4026
  if (!config.apiKey) throw new CliError("Not logged in", { exitCode: EXIT_NOT_LOGGED_IN });
3704
- return createApiClient({ serverUrl: config.serverUrl, apiKey: config.apiKey, fetch: fetchImpl });
3705
- }
3706
- async function sitesBase() {
3707
- const config = await store.resolve({ serverUrl: deps.server });
3708
- return sitesBaseOf(await loadInstance(store, config, fetchImpl));
4027
+ const url = config.serverUrl;
4028
+ let instance;
4029
+ return {
4030
+ client: createApiClient({ serverUrl: url, apiKey: config.apiKey, fetch: fetchImpl }),
4031
+ url,
4032
+ config,
4033
+ instance: () => {
4034
+ instance ??= loadInstance(store, config, fetchImpl);
4035
+ return instance;
4036
+ }
4037
+ };
3709
4038
  }
3710
- async function run(op) {
4039
+ async function run(tool2, server2, op) {
3711
4040
  try {
3712
- const { summary, data } = await op(await client());
3713
- return { content: [{ type: "text", text: `${summary}
4041
+ const { summary, data } = await op(await target(server2, tool2));
4042
+ return textResult(`${summary}
3714
4043
 
3715
- ${JSON.stringify(data, null, 2)}` }] };
4044
+ ${JSON.stringify(data, null, 2)}`);
3716
4045
  } catch (error) {
3717
- return { isError: true, content: [{ type: "text", text: describeError(error) }] };
4046
+ return failure(error);
3718
4047
  }
3719
4048
  }
3720
- async function deployFolder(c, dir, opts) {
4049
+ async function deployFolder(t2, dir, opts) {
3721
4050
  let local = await buildManifest(dir);
3722
4051
  local.skipped.dotfiles.unshift(...opts.skippedDotfiles ?? []);
3723
4052
  const skippedNote = describeSkipped(local.skipped).map((l) => `
@@ -3744,14 +4073,14 @@ ${l}.`).join("");
3744
4073
  }
3745
4074
  }
3746
4075
  const result = await deploy(
3747
- { client: c, fetch: fetchImpl },
4076
+ { client: t2.client, fetch: fetchImpl },
3748
4077
  { dir, name: opts.name, manifest: local, spa: opts.spa, public: opts.public }
3749
4078
  ).catch((error) => {
3750
4079
  if (!(error instanceof NameTakenError)) throw error;
3751
4080
  const how = opts.nameGiven ? "" : ` (generated from ${opts.nameFrom ?? "the folder"})`;
3752
4081
  const pick = error.suggestions.length ? ` Free alternatives: ${error.suggestions.join(", ")}.` : "";
3753
4082
  throw new CliError(
3754
- `Not deployed: the site name "${error.siteName}"${how} is taken by someone else.${pick} Ask the user which name to use (one of these or their own), then call ${opts.retryWith} again with \`name\`.`
4083
+ `Not deployed: the site name "${error.siteName}"${how} is taken by someone else on ${serverLabel(t2.url)}.${pick} Ask the user which name to use (one of these or their own), then call ${opts.retryWith} again with \`name\`.`
3755
4084
  );
3756
4085
  });
3757
4086
  const what = result.status === "unchanged" ? "Unchanged \u2014 already live" : result.status === "repointed" ? `Restored version ${result.versionId}` : `Deployed ${result.fileCount} files (uploaded ${result.uploaded})`;
@@ -3762,19 +4091,20 @@ Warnings:
3762
4091
  result.url,
3763
4092
  local.files.map((f) => f.path)
3764
4093
  );
3765
- const instance = deps.instance;
4094
+ const instance = await t2.instance();
3766
4095
  const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
3767
- const listing = generatedIndex ? " \u2014 with a generated index.html listing the HTML pages" : "";
3768
- const head = review ? `${what}${listing}.
4096
+ const listing2 = generatedIndex ? " \u2014 with a generated index.html listing the HTML pages" : "";
4097
+ const head = review ? `${what}${listing2}.
3769
4098
  ${shareForFeedback(review, instance?.name ?? "Fling")}
3770
- Site: ${result.url} (${result.site.visibility})` : `${what}: ${result.url} (${result.site.visibility})${listing}`;
4099
+ Site: ${result.url} (${result.site.visibility})` : `${what}: ${result.url} (${result.site.visibility})${listing2}`;
3771
4100
  return {
3772
4101
  kind: "deployed",
3773
- summary: `${head}${warnings}${skippedNote}${edge.note}
4102
+ summary: `${head}${warnings}${skippedNote}${edge.note}${onFling(t2.url, instance)}
3774
4103
  To check exactly which files the site has, call fling_files with name "${result.name}".`,
3775
4104
  data: {
3776
4105
  status: result.status,
3777
4106
  ready: edge.ready,
4107
+ server: t2.url,
3778
4108
  name: result.name,
3779
4109
  nameGenerated: !opts.nameGiven,
3780
4110
  versionId: result.versionId,
@@ -3790,7 +4120,8 @@ To check exactly which files the site has, call fling_files with name "${result.
3790
4120
  { name: "fling", version: VERSION },
3791
4121
  {
3792
4122
  instructions: mcpLocalInstructions(wording, {
3793
- comments: commentsEnabled(deps.instance)
4123
+ comments: servers.some((s) => commentsEnabled(s.instance)),
4124
+ servers
3794
4125
  })
3795
4126
  }
3796
4127
  );
@@ -3799,7 +4130,7 @@ To check exactly which files the site has, call fling_files with name "${result.
3799
4130
  "fling_deploy",
3800
4131
  {
3801
4132
  title: "Deploy a file or folder from this computer",
3802
- description: `Fling something that is on the user's computer and return its stable URL (${wording.sitesPattern}): a folder of static files (e.g. a build output like dist/), a single .html file (served as the site's index.html), a .zip of a site (extracted; a single top-level folder is unwrapped), or any other single file (.pdf, .svg, .png\u2026, served next to a generated page that shows it). \`path\` can be absolute, relative to the working directory, or just a file name ("Pitch Deck.html"): names are looked up, newest first, in Downloads, Desktop, Documents/Claude and Claude Desktop output folders, and the result says where the file was found. Never pass paths from your own sandbox (/mnt/user-data/\u2026, /sessions/\u2026): for content you created, use fling_publish. Without \`name\`, a name is generated: from the file name for files ("Pitch Deck (1).html" \u2192 pitch-deck), from the project and folder for folders (~/work/rocketpod/docs \u2192 rocketpod-docs). Site names are one global namespace, first come first served \u2014 confirm the name with the user first (fling_check_name with \`path\` shows the generated one). If the name is taken it fails with free suggestions \u2014 ask the user to pick one, then retry with \`name\`. If there is no index.html but there are HTML pages, it does not deploy: ask the user whether to generate an index page listing them (\`listHtml: true\`) or deploy without one (\`listHtml: false\`), then retry. Re-deploying identical content is a no-op. Sites are private (only ${wording.privateAudience}) unless \`public\` is set. ` + NEVER_PUBLISHED,
4133
+ description: `Fling something that is on the user's computer and return its stable URL (${wording.sitesPattern}): a folder of static files (e.g. a build output like dist/), a single .html file (served as the site's index.html), a .zip of a site (extracted; a single top-level folder is unwrapped), or any other single file (.pdf, .svg, .png\u2026, served next to a generated page that shows it). \`path\` can be absolute, relative to the working directory, or just a file name ("Pitch Deck.html"): names are looked up, newest first, in Downloads, Desktop, Documents/Claude and Claude Desktop output folders, and the result says where the file was found. Never pass paths from your own sandbox (/mnt/user-data/\u2026, /sessions/\u2026): for content you created, use fling_publish. Without \`name\`, a name is generated: from the file name for files ("Pitch Deck (1).html" \u2192 pitch-deck), from the project and folder for folders (~/work/rocketpod/docs \u2192 rocketpod-docs). Site names are one global namespace, first come first served \u2014 confirm the name with the user first (fling_check_name with \`path\` shows the generated one). If the name is taken it fails with free suggestions \u2014 ask the user to pick one, then retry with \`name\`. If there is no index.html but there are HTML pages, it does not deploy: ask the user whether to generate an index page listing them (\`listHtml: true\`) or deploy without one (\`listHtml: false\`), then retry. Re-deploying identical content is a no-op. Sites are private (only ${wording.privateAudience}) unless \`public\` is set. ${NEVER_PUBLISHED}${note}`,
3803
4134
  inputSchema: {
3804
4135
  path: z11.string().min(1).describe(
3805
4136
  "File or folder on this computer: absolute, relative to the working directory, or just its name (looked up in Downloads, Desktop and Claude output folders)"
@@ -3809,16 +4140,17 @@ To check exactly which files the site has, call fling_files with name "${result.
3809
4140
  ),
3810
4141
  spa: spa2,
3811
4142
  public: isPublic,
3812
- listHtml: listHtml2
4143
+ listHtml: listHtml2,
4144
+ server: serverArg
3813
4145
  },
3814
4146
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true }
3815
4147
  },
3816
- (args) => run(async (c) => {
4148
+ (args) => run("fling_deploy", args.server, async (t2) => {
3817
4149
  const located = await resolveInput(args.path, locate);
3818
4150
  const prepared = await prepareInput(located.path);
3819
4151
  try {
3820
4152
  const name = args.name !== void 0 ? resolveSiteName(prepared.dir, args.name) : prepared.defaultName;
3821
- const outcome = await deployFolder(c, prepared.dir, {
4153
+ const outcome = await deployFolder(t2, prepared.dir, {
3822
4154
  name,
3823
4155
  nameGiven: args.name !== void 0,
3824
4156
  nameFrom: prepared.kind === "folder" ? "the folder" : "the file name",
@@ -3872,33 +4204,31 @@ ${lines.join("\n")}` : `Nothing found from the last ${args.days ?? 14} days${arg
3872
4204
  searched: searched.map((r) => ({ label: r.label, path: r.path })),
3873
4205
  ...complete ? {} : { truncated: true }
3874
4206
  };
3875
- return {
3876
- content: [{ type: "text", text: `${summary}
4207
+ return textResult(`${summary}
3877
4208
 
3878
- ${JSON.stringify(data, null, 2)}` }]
3879
- };
4209
+ ${JSON.stringify(data, null, 2)}`);
3880
4210
  } catch (error) {
3881
- return { isError: true, content: [{ type: "text", text: describeError(error) }] };
4211
+ return failure(error);
3882
4212
  }
3883
4213
  }
3884
4214
  );
3885
4215
  const publishDef = mcpTools2.publish;
3886
4216
  server.registerTool(
3887
4217
  publishDef.name,
3888
- {
3889
- ...toolDef(publishDef),
4218
+ serverTool(
4219
+ publishDef,
3890
4220
  // This server has no chunked upload tools; big sites go through fling_deploy instead.
3891
- description: publishDef.description.replace(
4221
+ publishDef.description.replace(
3892
4222
  /\(whole site, up to [^;]*; bigger sites: fling_upload_begin \/ fling_upload_file \/ fling_upload_finish\)/,
3893
4223
  "(whole site; for a big site that is already on this computer, use fling_deploy with its folder)"
3894
4224
  )
3895
- },
3896
- (args) => run(async (c) => {
4225
+ ),
4226
+ (args) => run(publishDef.name, args.server, async (t2) => {
3897
4227
  const name = resolveSiteName(".", args.name);
3898
4228
  const tmp = await mkdtemp3(join6(tmpdir3(), "fling-publish-"));
3899
4229
  try {
3900
4230
  const { skippedDotfiles } = await writeInlineFiles(tmp, args.files);
3901
- const outcome = await deployFolder(c, tmp, {
4231
+ return await deployFolder(t2, tmp, {
3902
4232
  skippedDotfiles,
3903
4233
  name,
3904
4234
  nameGiven: true,
@@ -3907,7 +4237,6 @@ ${JSON.stringify(data, null, 2)}` }]
3907
4237
  listHtml: args.listHtml,
3908
4238
  retryWith: "fling_publish"
3909
4239
  });
3910
- return outcome;
3911
4240
  } finally {
3912
4241
  await rm2(tmp, { recursive: true, force: true });
3913
4242
  }
@@ -3916,14 +4245,15 @@ ${JSON.stringify(data, null, 2)}` }]
3916
4245
  server.registerTool(
3917
4246
  "fling_check_name",
3918
4247
  {
3919
- ...toolDef(mcpTools2.checkName),
3920
- description: `${mcpTools2.checkName.description} Pass \`path\` instead of \`name\` to see the name fling_deploy would generate for a file or folder on this computer (same lookup as fling_deploy), so you can confirm it with the user.`,
4248
+ ...serverTool(mcpTools2.checkName),
4249
+ description: `${mcpTools2.checkName.description} Pass \`path\` instead of \`name\` to see the name fling_deploy would generate for a file or folder on this computer (same lookup as fling_deploy), so you can confirm it with the user.${note}`,
3921
4250
  inputSchema: {
3922
4251
  name: z11.string().min(1).max(200).optional().describe("Site name to check"),
3923
- path: z11.string().optional().describe("File or folder whose generated default name to check (path or file name)")
4252
+ path: z11.string().optional().describe("File or folder whose generated default name to check (path or file name)"),
4253
+ server: serverArg
3924
4254
  }
3925
4255
  },
3926
- (args) => run(async (c) => {
4256
+ (args) => run("fling_check_name", args.server, async (t2) => {
3927
4257
  if (args.name === void 0 && args.path === void 0)
3928
4258
  throw new CliError("Pass `name` or `path`");
3929
4259
  let name = args.name;
@@ -3932,8 +4262,8 @@ ${JSON.stringify(data, null, 2)}` }]
3932
4262
  source = await resolveInput(args.path, locate);
3933
4263
  name = defaultNameFor(source.path, source.isDirectory);
3934
4264
  }
3935
- const check2 = await checkName(c, name);
3936
- const base = check2.status === "invalid" ? null : await sitesBase();
4265
+ const check2 = await checkName(t2.client, name);
4266
+ const base = check2.status === "invalid" ? null : sitesBaseOf(await t2.instance());
3937
4267
  const url = base ? siteUrlFor(name, base) : null;
3938
4268
  const suggestions = check2.suggestions.length ? ` Suggestions: ${check2.suggestions.join(", ")}.` : "";
3939
4269
  const summary = {
@@ -3947,57 +4277,77 @@ ${JSON.stringify(data, null, 2)}` }]
3947
4277
  data: {
3948
4278
  ...check2,
3949
4279
  url,
4280
+ server: t2.url,
3950
4281
  ...source ? { generated: true, source: { path: source.path, foundIn: source.foundIn } } : {}
3951
4282
  }
3952
4283
  };
3953
4284
  })
3954
4285
  );
3955
- let pendingLogin;
4286
+ const pendingLogins = /* @__PURE__ */ new Map();
3956
4287
  server.registerTool(
3957
4288
  "fling_login",
3958
4289
  {
3959
4290
  title: "Sign in to Fling",
3960
- description: `Sign the user in to ${wording.name}: opens their browser to sign in with ${wording.signIn} and stores the key on this computer (shared with the fling CLI). Waits up to ~30 seconds; if sign-in is not finished by then, it keeps waiting in the background for up to 5 minutes \u2014 tell the user to finish signing in in the browser, then retry what you were doing. Call it when another tool says the user is not logged in.`,
3961
- inputSchema: {},
4291
+ description: `Sign the user in to ${several ? "a Fling" : wording.name}: opens their browser to sign in with ${wording.signIn} and stores the key on this computer (shared with the fling CLI). Waits up to ~30 seconds; if sign-in is not finished by then, it keeps waiting in the background for up to 5 minutes \u2014 tell the user to finish signing in in the browser, then retry what you were doing. Call it when another tool says the user is not logged in. Pass \`server\` (its address) to sign in to another Fling, including one not set up on this computer yet: it is remembered, and becomes the current one only when none is.` + note,
4292
+ inputSchema: {
4293
+ server: z11.string().min(1).max(500).optional().describe(
4294
+ `The Fling to sign in to: its address (e.g. "https://fling.example.com"), or the host of one set up on this computer. ${several ? `Required while several Flings are set up (${servers.map((s) => serverLabel(s.url)).join(", ")})` : `Omit it to use ${servers[0] ? serverLabel(servers[0].url) : "the one this computer knows"}`}`
4295
+ )
4296
+ },
3962
4297
  annotations: { readOnlyHint: false, openWorldHint: true }
3963
4298
  },
3964
- async () => {
4299
+ async (args) => {
3965
4300
  try {
3966
- const config = await store.resolve({ serverUrl: deps.server });
3967
- const serverUrl = config.serverUrl;
4301
+ let serverUrl;
4302
+ if (args.server !== void 0) {
4303
+ const config2 = await store.load();
4304
+ try {
4305
+ serverUrl = matchServer(Object.keys(config2.servers), args.server, { loose: true }).url;
4306
+ } catch (error) {
4307
+ const known = knownServersOf(config2).map((s) => `- ${describeServer(s)}`);
4308
+ throw new CliError(error.message, {
4309
+ hint: known.length ? `The Flings set up on this computer:
4310
+ ${known.join("\n")}` : ""
4311
+ });
4312
+ }
4313
+ } else {
4314
+ serverUrl = (await choose(void 0, "fling_login")).serverUrl;
4315
+ }
3968
4316
  if (!serverUrl) throw new NoServerError("fling");
3969
- if (config.apiKey && !pendingLogin) {
4317
+ const config = await store.resolve({ serverUrl });
4318
+ let login = pendingLogins.get(serverUrl);
4319
+ if (config.apiKey && !login) {
3970
4320
  const me = await whoami(
3971
- createApiClient({
3972
- serverUrl,
3973
- apiKey: config.apiKey,
3974
- fetch: fetchImpl
3975
- })
4321
+ createApiClient({ serverUrl, apiKey: config.apiKey, fetch: fetchImpl })
3976
4322
  ).catch(() => null);
3977
- if (me) return textResult(`Already signed in to ${config.serverUrl} as ${me.email}.`);
4323
+ if (me) return textResult(`Already signed in to ${serverUrl} as ${me.email}.`);
3978
4324
  }
3979
- if (!pendingLogin) {
3980
- const login = {
4325
+ if (!login) {
4326
+ const started = {
3981
4327
  done: Promise.resolve()
3982
4328
  };
3983
- login.done = loginFlow({
4329
+ started.done = loginFlow({
3984
4330
  serverUrl,
3985
4331
  store,
3986
4332
  log: silentLogger,
3987
4333
  open: deps.open ?? (async (url) => (await import("open")).default(url)),
3988
4334
  fetch: fetchImpl,
3989
4335
  timeoutMs: deps.loginTimeoutMs,
4336
+ // Signing in to another Fling leaves the CLI's current server as it is.
4337
+ makeCurrent: !config.current || config.current === serverUrl,
3990
4338
  onAuthUrl: (url) => {
3991
- login.url = url;
4339
+ started.url = url;
3992
4340
  }
3993
4341
  });
3994
- login.done.catch(() => {
4342
+ const key = serverUrl;
4343
+ started.done.catch(() => {
3995
4344
  }).finally(() => {
3996
- if (pendingLogin === login) pendingLogin = void 0;
4345
+ if (pendingLogins.get(key) === started) pendingLogins.delete(key);
3997
4346
  });
3998
- pendingLogin = login;
4347
+ pendingLogins.set(serverUrl, started);
4348
+ login = started;
3999
4349
  }
4000
- const current = pendingLogin;
4350
+ const current = login;
4001
4351
  const waited = await Promise.race([
4002
4352
  current.done.then(
4003
4353
  (r) => ({ ok: true, r }),
@@ -4010,34 +4360,82 @@ ${JSON.stringify(data, null, 2)}` }]
4010
4360
  `Finish signing in in your browser, then try again. (The sign-in page is open; if it did not open, visit: ${current.url ?? "the Fling site"} \u2014 this stays valid for 5 minutes.)`
4011
4361
  );
4012
4362
  if (!waited.ok) throw waited.e;
4013
- return textResult(`Signed in as ${waited.r.user.email} (${waited.r.serverUrl}).`);
4363
+ return textResult(
4364
+ `Signed in as ${waited.r.user.email} (${waited.r.serverUrl}, ${waited.r.instance.name}).`
4365
+ );
4014
4366
  } catch (error) {
4015
- return { isError: true, content: [{ type: "text", text: describeError(error) }] };
4367
+ return failure(error);
4368
+ }
4369
+ }
4370
+ );
4371
+ server.registerTool(
4372
+ "fling_servers",
4373
+ {
4374
+ title: "List the Flings on this computer",
4375
+ description: `List the Flings set up on this computer, read now: each one's address, name, who is signed in (or "signed out") and which is current, the user's usual one. Call it when unsure which Fling the user means or which exist; the list may have changed since this conversation started. Pass \`use\` to make one the current Fling, the same as running \`${NPX_COMMAND} use <server>\`: it changes the default of the fling CLI too, so only do it when the user asks. It does not replace passing \`server\` to the other tools while several Flings are set up.`,
4376
+ inputSchema: {
4377
+ use: z11.string().min(1).max(500).optional().describe(
4378
+ "Make this Fling current (address, host or unique part of it), like `fling use`"
4379
+ )
4380
+ },
4381
+ annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false }
4382
+ },
4383
+ async (args) => {
4384
+ try {
4385
+ let switched = "";
4386
+ if (args.use !== void 0) {
4387
+ const url = rememberedServer(await store.load(), args.use);
4388
+ await store.setCurrent(url);
4389
+ switched = `Now using ${serverLabel(url)} as the current Fling (as \`fling use\` does).
4390
+ `;
4391
+ }
4392
+ const config = await store.load();
4393
+ const known = knownServersOf(config);
4394
+ const pinned = await store.resolve({ serverUrl: deps.server });
4395
+ const summary = known.length ? `${switched}${known.length} Fling(s) set up on this computer:
4396
+ ${known.map((s) => `- ${describeServer(s)}`).join("\n")}` + (isPinned(pinned) && pinned.serverUrl ? `
4397
+ Calls without \`server\` go to ${serverLabel(pinned.serverUrl)} (set by this server's configuration).` : known.length > 1 ? "\nPass `server` on every call: the Fling settled on for this conversation." : "") : NO_SERVER_HINT;
4398
+ const data = {
4399
+ current: config.current ?? null,
4400
+ servers: known.map((s) => ({
4401
+ url: s.url,
4402
+ host: serverLabel(s.url),
4403
+ instance: s.instance?.name ?? null,
4404
+ signedIn: s.signedIn,
4405
+ email: s.email ?? null,
4406
+ current: s.current
4407
+ }))
4408
+ };
4409
+ return textResult(`${summary}
4410
+
4411
+ ${JSON.stringify(data, null, 2)}`);
4412
+ } catch (error) {
4413
+ return failure(error);
4016
4414
  }
4017
4415
  }
4018
4416
  );
4019
4417
  const t = mcpTools2;
4020
4418
  server.registerTool(
4021
4419
  t.list.name,
4022
- toolDef(t.list),
4023
- (args) => run(async (c) => {
4024
- const sites = await listSites(c, { archived: args.archived });
4025
- return { summary: `${sites.length} site(s)`, data: { sites } };
4420
+ serverTool(t.list),
4421
+ (args) => run(t.list.name, args.server, async ({ client, url }) => {
4422
+ const sites = await listSites(client, { archived: args.archived });
4423
+ return { summary: `${sites.length} site(s) on ${serverLabel(url)}`, data: { sites } };
4026
4424
  })
4027
4425
  );
4028
4426
  server.registerTool(
4029
4427
  t.info.name,
4030
- toolDef(t.info),
4031
- (args) => run(async (c) => {
4032
- const site = await getSite(c, args.name);
4428
+ serverTool(t.info),
4429
+ (args) => run(t.info.name, args.server, async ({ client }) => {
4430
+ const site = await getSite(client, args.name);
4033
4431
  return { summary: `${site.name}: ${site.url}`, data: site };
4034
4432
  })
4035
4433
  );
4036
4434
  server.registerTool(
4037
4435
  t.files.name,
4038
- toolDef(t.files),
4039
- (args) => run(async (c) => {
4040
- const result = await versionFiles(c, args.name, args.versionId);
4436
+ serverTool(t.files),
4437
+ (args) => run(t.files.name, args.server, async ({ client }) => {
4438
+ const result = await versionFiles(client, args.name, args.versionId);
4041
4439
  return {
4042
4440
  summary: describeVersionFiles(result.name, result, {
4043
4441
  current: result.current,
@@ -4049,15 +4447,15 @@ ${JSON.stringify(data, null, 2)}` }]
4049
4447
  );
4050
4448
  server.registerTool(
4051
4449
  t.share.name,
4052
- toolDef(t.share),
4053
- (args) => run(async (c) => {
4450
+ serverTool(t.share),
4451
+ (args) => run(t.share.name, args.server, async ({ client }) => {
4054
4452
  if (args.refresh && args.revoke) throw new CliError("Use either refresh or revoke, not both");
4055
- const result = await share(c, args.name, args);
4453
+ const result = await share(client, args.name, args);
4056
4454
  if (result.action === "revoked")
4057
4455
  return { summary: `Share link for ${args.name} revoked`, data: result };
4058
4456
  let edge = { ready: true, note: "" };
4059
4457
  if (result.action !== "existing") {
4060
- const files = await versionFiles(c, args.name).catch(() => null);
4458
+ const files = await versionFiles(client, args.name).catch(() => null);
4061
4459
  edge = await edgeReady(result.shareUrl, files?.files.map((f) => f.path) ?? []);
4062
4460
  }
4063
4461
  return {
@@ -4068,78 +4466,86 @@ ${JSON.stringify(data, null, 2)}` }]
4068
4466
  );
4069
4467
  server.registerTool(
4070
4468
  t.setVisibility.name,
4071
- toolDef(t.setVisibility),
4072
- (args) => run(async (c) => {
4073
- const site = await setVisibility(c, args.name, args.visibility);
4469
+ serverTool(t.setVisibility),
4470
+ (args) => run(t.setVisibility.name, args.server, async ({ client }) => {
4471
+ const site = await setVisibility(client, args.name, args.visibility);
4074
4472
  return { summary: `${site.name} is now ${site.visibility}: ${site.url}`, data: site };
4075
4473
  })
4076
4474
  );
4077
4475
  server.registerTool(
4078
4476
  t.archive.name,
4079
- toolDef(t.archive),
4080
- (args) => run(async (c) => {
4081
- const site = await archiveSite(c, args.name, { unarchive: args.unarchive });
4477
+ serverTool(t.archive),
4478
+ (args) => run(t.archive.name, args.server, async ({ client }) => {
4479
+ const site = await archiveSite(client, args.name, { unarchive: args.unarchive });
4082
4480
  return { summary: `${site.name} ${args.unarchive ? "restored" : "archived"}`, data: site };
4083
4481
  })
4084
4482
  );
4085
4483
  server.registerTool(
4086
4484
  t.delete.name,
4087
- toolDef(t.delete),
4088
- (args) => run(async (c) => {
4089
- const result = await deleteSite(c, args.name);
4090
- return { summary: `${args.name} deleted`, data: result };
4485
+ serverTool(t.delete),
4486
+ (args) => run(t.delete.name, args.server, async ({ client, url }) => {
4487
+ const result = await deleteSite(client, args.name);
4488
+ return { summary: `${args.name} deleted from ${serverLabel(url)}`, data: result };
4091
4489
  })
4092
4490
  );
4093
4491
  server.registerTool(
4094
4492
  t.rollback.name,
4095
- toolDef(t.rollback),
4096
- (args) => run(async (c) => {
4097
- const site = await rollback(c, args.name, args.versionId);
4493
+ serverTool(t.rollback),
4494
+ (args) => run(t.rollback.name, args.server, async ({ client }) => {
4495
+ const site = await rollback(client, args.name, args.versionId);
4098
4496
  return {
4099
4497
  summary: `${site.name} now serves version ${site.currentVersionId}: ${site.url}`,
4100
4498
  data: site
4101
4499
  };
4102
4500
  })
4103
4501
  );
4104
- if (deps.instance && commentsEnabled(deps.instance)) {
4105
- const appOrigin = deps.instance.appOrigin;
4106
- const review = (name) => reviewUrl(appOrigin, name);
4502
+ if (servers.some((s) => commentsEnabled(s.instance))) {
4503
+ async function reviewOn(target2) {
4504
+ const instance = await target2.instance();
4505
+ if (instance?.capabilities.comments === false)
4506
+ throw new CliError(`${instance.name} (${serverLabel(target2.url)}) has comments turned off`);
4507
+ const appOrigin = instance?.appOrigin ?? target2.url;
4508
+ return {
4509
+ review: (name) => reviewUrl(appOrigin, name),
4510
+ fling: onFling(target2.url, instance)
4511
+ };
4512
+ }
4107
4513
  server.registerTool(
4108
4514
  t.commentsList.name,
4109
- toolDef(t.commentsList),
4110
- (args) => run(async (c) => {
4111
- const list = await listComments(c, args.name, { path: args.path });
4515
+ serverTool(t.commentsList),
4516
+ (args) => run(t.commentsList.name, args.server, async (target2) => {
4517
+ const { review, fling } = await reviewOn(target2);
4518
+ const list = await listComments(target2.client, args.name, { path: args.path });
4112
4519
  const reviewPage2 = review(list.site.name);
4113
4520
  return {
4114
- summary: describeComments(list, {
4115
- includeResolved: args.includeResolved,
4116
- reviewUrl: reviewPage2
4117
- }),
4521
+ summary: `${describeComments(list, { includeResolved: args.includeResolved, reviewUrl: reviewPage2 })}${fling}`,
4118
4522
  data: { ...list, reviewUrl: reviewPage2 }
4119
4523
  };
4120
4524
  })
4121
4525
  );
4122
4526
  server.registerTool(
4123
4527
  t.commentAdd.name,
4124
- toolDef(t.commentAdd),
4125
- (args) => run(async (c) => {
4126
- const comment = await addComment(c, args.name, {
4528
+ serverTool(t.commentAdd),
4529
+ (args) => run(t.commentAdd.name, args.server, async (target2) => {
4530
+ const { review, fling } = await reviewOn(target2);
4531
+ const comment = await addComment(target2.client, args.name, {
4127
4532
  body: args.body,
4128
4533
  path: args.path,
4129
4534
  parentId: args.replyTo
4130
4535
  });
4131
4536
  const what = comment.parentId ? `Replied to thread ${comment.parentId}` : "Comment added";
4132
4537
  return {
4133
- summary: `${what} on ${args.name} (${comment.path}, id ${comment.id}). Review page: ${review(args.name)}`,
4538
+ summary: `${what} on ${args.name} (${comment.path}, id ${comment.id}). Review page: ${review(args.name)}${fling}`,
4134
4539
  data: comment
4135
4540
  };
4136
4541
  })
4137
4542
  );
4138
4543
  server.registerTool(
4139
4544
  t.commentResolve.name,
4140
- toolDef(t.commentResolve),
4141
- (args) => run(async (c) => {
4142
- const comment = await resolveComment(c, args.name, args.id, !args.reopen);
4545
+ serverTool(t.commentResolve),
4546
+ (args) => run(t.commentResolve.name, args.server, async (target2) => {
4547
+ await reviewOn(target2);
4548
+ const comment = await resolveComment(target2.client, args.name, args.id, !args.reopen);
4143
4549
  return {
4144
4550
  summary: `Thread ${comment.id} on ${args.name} ${args.reopen ? "reopened" : "resolved"}`,
4145
4551
  data: comment
@@ -4148,22 +4554,24 @@ ${JSON.stringify(data, null, 2)}` }]
4148
4554
  );
4149
4555
  server.registerTool(
4150
4556
  t.commentDelete.name,
4151
- toolDef(t.commentDelete),
4152
- (args) => run(async (c) => {
4153
- const result = await deleteComment(c, args.name, args.id);
4557
+ serverTool(t.commentDelete),
4558
+ (args) => run(t.commentDelete.name, args.server, async (target2) => {
4559
+ await reviewOn(target2);
4560
+ const result = await deleteComment(target2.client, args.name, args.id);
4154
4561
  return { summary: `Comment ${args.id} on ${args.name} deleted`, data: result };
4155
4562
  })
4156
4563
  );
4157
4564
  }
4158
4565
  server.registerTool(
4159
4566
  t.whoami.name,
4160
- toolDef(t.whoami),
4161
- () => run(async (c) => {
4162
- const me = await whoami(c);
4163
- const base = await sitesBase();
4567
+ serverTool(t.whoami),
4568
+ (args) => run(t.whoami.name, args.server, async (target2) => {
4569
+ const me = await whoami(target2.client);
4570
+ const instance = await target2.instance();
4571
+ const base = sitesBaseOf(instance);
4164
4572
  return {
4165
- summary: `Signed in as ${me.email} (sites are served at ${sitesPattern(base)})`,
4166
- data: me
4573
+ summary: `Signed in to ${serverLabel(target2.url)}${instance ? ` (${instance.name})` : ""} as ${me.email} (sites are served at ${sitesPattern(base)})`,
4574
+ data: { ...me, server: target2.url }
4167
4575
  };
4168
4576
  })
4169
4577
  );
@@ -4200,6 +4608,25 @@ async function writeInlineFiles(dir, files) {
4200
4608
  if (secretFiles.length > 0) throw new SecretFilesError(secretFiles);
4201
4609
  return { skippedDotfiles };
4202
4610
  }
4611
+ function failure(error) {
4612
+ if (error instanceof ServerNeededError) {
4613
+ const data = {
4614
+ status: "needs_server",
4615
+ servers: error.servers.map((s) => ({
4616
+ url: s.url,
4617
+ host: serverLabel(s.url),
4618
+ instance: s.instance?.name ?? null,
4619
+ signedIn: s.signedIn,
4620
+ email: s.email ?? null,
4621
+ current: s.current
4622
+ }))
4623
+ };
4624
+ return textResult(`${error.message}
4625
+
4626
+ ${JSON.stringify(data, null, 2)}`);
4627
+ }
4628
+ return { isError: true, content: [{ type: "text", text: describeError(error) }] };
4629
+ }
4203
4630
  function describeError(error) {
4204
4631
  if (error instanceof NoServerError) return NO_SERVER_HINT;
4205
4632
  if (error instanceof CliError && error.exitCode === EXIT_NOT_LOGGED_IN) return LOGIN_HINT;
@@ -4211,8 +4638,11 @@ ${error.hint}` : error.message;
4211
4638
  }
4212
4639
  async function runMcpServer(deps = {}) {
4213
4640
  const store = deps.store ?? createConfigStore();
4214
- const instance = deps.instance ?? await store.resolve({ serverUrl: deps.server }).then((config) => loadInstance(store, config, deps.fetch ?? ((i, init) => fetch(i, init)))).catch(() => null);
4215
- const server = createMcpServer({ ...deps, store, instance });
4641
+ const fetchImpl = deps.fetch ?? ((i, init) => fetch(i, init));
4642
+ await addServerOnStart(store, serverToAdd(), fetchImpl).catch(() => {
4643
+ });
4644
+ const servers = deps.servers ?? await knownServers(store, fetchImpl, deps.server).catch(() => []);
4645
+ const server = createMcpServer({ ...deps, store, servers });
4216
4646
  await server.connect(new StdioServerTransport());
4217
4647
  }
4218
4648
 
@@ -4266,7 +4696,10 @@ function routeOptionsFor(program2) {
4266
4696
 
4267
4697
  // src/cli.ts
4268
4698
  var program = new Command();
4269
- program.name(BIN_NAME).description("Fling a folder of static files at Fling and get a link back").version(VERSION).option("--server <url>", `server URL (default: $${ENV.url}, else the one you logged in to)`).option("--json", "print JSON instead of human output", false).showHelpAfterError().configureHelp({
4699
+ program.name(BIN_NAME).description("Fling a folder of static files at Fling and get a link back").version(VERSION).option(
4700
+ "--server <url>",
4701
+ `server for this command: a URL, or the host of one you signed in to (default: $${ENV.url}, else the current one)`
4702
+ ).option("--json", "print JSON instead of human output", false).showHelpAfterError().configureHelp({
4270
4703
  sortSubcommands: false,
4271
4704
  styleTitle: (title) => chalk10.bold(brand.primary(title)),
4272
4705
  styleCommandText: (text2) => chalk10.bold(text2),
@@ -4282,6 +4715,7 @@ program.name(BIN_NAME).description("Fling a folder of static files at Fling and
4282
4715
  ${chalk10.bold(brand.primary("Examples:"))}
4283
4716
  $ ${BIN_NAME} login https://fling.example.com sign in to your Fling (once; there is no default)
4284
4717
  $ ${BIN_NAME} setup sign in and add Fling to Claude Code / Claude Desktop
4718
+ $ ${BIN_NAME} use list the Flings you signed in to; \`use <url>\` switches
4285
4719
  $ ${BIN_NAME} deploy ./dist --name my-app \u2192 https://my-app.<your sites domain>
4286
4720
  $ ${BIN_NAME} ./dist the same: an existing path deploys
4287
4721
  $ ${BIN_NAME} ./dist --yes no prompts (CI): use the generated name, fail if it is taken
@@ -4342,22 +4776,27 @@ program.command("deploy [path]").helpGroup("Deploy:").description(
4342
4776
  )
4343
4777
  );
4344
4778
  program.command("setup [url]").helpGroup("Setup:").description(
4345
- "sign in (to the Fling at url, the first time), then add the Fling plugin to Claude Code and the extension to Claude Desktop"
4779
+ "sign in (to the Fling at url, which becomes current), then add the Fling plugin to Claude Code and the extension to Claude Desktop"
4346
4780
  ).option("-y, --yes", "do every step without asking").option("-s, --scope <scope>", "Claude Code plugin scope: user, project or local", "user").action(
4347
4781
  action(
4348
4782
  (ctx, url, opts) => runSetup(ctx, { ...opts, url })
4349
4783
  )
4350
4784
  );
4351
4785
  program.command("login [url]").helpGroup("Setup:").description(
4352
- "sign in through the browser to the Fling at url (remembered; later logins can omit it) and store an API key"
4786
+ "sign in through the browser to the Fling at url and store an API key; it becomes the current server (default: the current one)"
4353
4787
  ).action(
4354
4788
  action(async (ctx, url) => {
4355
4789
  await runLogin(ctx, url);
4356
4790
  ctx.log.hint(`Next: \`${BIN_NAME} setup\` adds Fling to Claude Code and Claude Desktop.`);
4357
4791
  })
4358
4792
  );
4359
- program.command("logout").helpGroup("Setup:").description("remove the stored API key").action(action((ctx) => runLogout(ctx)));
4360
- program.command("whoami").helpGroup("Setup:").description("show the signed-in user").action(action((ctx) => runWhoami(ctx)));
4793
+ program.command("use [url]").helpGroup("Setup:").description(
4794
+ "switch to another Fling you signed in to (a URL or its host); without url, list them"
4795
+ ).action(action((ctx, url) => runUse(ctx, url)));
4796
+ program.command("logout [url]").helpGroup("Setup:").description(
4797
+ "remove a server's API key (default: the current server's, which stays current); any other server is forgotten"
4798
+ ).action(action((ctx, url) => runLogout(ctx, url)));
4799
+ program.command("whoami").helpGroup("Setup:").description("show the signed-in user and the current server").action(action((ctx) => runWhoami(ctx)));
4361
4800
  program.commandsGroup("Setup:").helpCommand("help [command]", "show help for a command");
4362
4801
  program.command("ls").helpGroup("Sites:").alias("list").description("list your sites").option("--archived", "list archived sites").action(action((ctx, opts) => runList(ctx, opts)));
4363
4802
  program.command("info <name>").helpGroup("Sites:").description("show a site").action(action((ctx, name) => runInfo(ctx, name)));