fullsendai 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +19 -5
  2. package/dist/cli.mjs +416 -68
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,8 +6,9 @@ Connect your coding tools to [FullsendAI](https://fullsendai.com) in one command
6
6
  npx fullsendai connect
7
7
  ```
8
8
 
9
- It finds the tools on your computer, signs you in through the browser, gives each tool its own API key, writes each
10
- tool's settings, and makes a real test call through each one. Your existing settings, comments and other providers
9
+ It finds the tools on your computer, signs you in through the browser, lets you pick the model (FullsendAI's own
10
+ Nefarian and Sulfuras first), gives each tool its own API key, writes each tool's settings, and makes a real test call
11
+ through each one. When a model's provider goes down, a backup provider answers, so your tools keep working. Your existing settings, comments and other providers
11
12
  are kept, and a backup is made before anything changes.
12
13
 
13
14
  Works on macOS, Linux and Windows (PowerShell, cmd, Windows Terminal, WSL), with Node.js 18.17 or newer.
@@ -22,17 +23,30 @@ Works on macOS, Linux and Windows (PowerShell, cmd, Windows Terminal, WSL), with
22
23
  | Kilo Code CLI | `~/.config/kilo/kilo.json`, a `fullsend` provider |
23
24
  | Aider | `~/.aider.conf.yml` |
24
25
  | Continue | `~/.continue/config.yaml` (or `CONTINUE_GLOBAL_DIR`), FullsendAI's models added first |
25
- | Cline, Roo Code, Cursor | These keep settings inside the editor; you get the exact values and the key to paste |
26
+ | Cline (command line) | `~/.cline/data/settings/providers.json`, through Cline's own `cline auth` |
27
+ | Cline and Roo Code (VS Code), Cursor | These keep settings inside the editor; you get the exact values and the key to paste |
28
+
29
+ Each tool is also told what the models can do, so it uses them fully: the context window (so Claude Code and Codex
30
+ don't compact early), the output limit, whether a model reads images, reasons and calls tools, and its prices (so
31
+ OpenCode and Aider show what a session costs).
26
32
 
27
33
  `CLAUDE_CONFIG_DIR`, `CODEX_HOME` and `XDG_CONFIG_HOME` are respected.
28
34
 
35
+ ## Always current
36
+
37
+ Nothing about FullsendAI's models is built into this package: it reads them from the API every time. When a model is
38
+ added, renamed or given new abilities or prices, `status` and `doctor` notice and offer to update your tools, and
39
+ `npx fullsendai sync` does it. A tool whose model was renamed follows it to its new ID; one whose model was removed
40
+ moves to the recommended model, and says so.
41
+
29
42
  ## Commands
30
43
 
31
44
  ```sh
32
45
  npx fullsendai connect # find tools, sign in, connect (the default)
33
- npx fullsendai status # what's connected, each tool's model and spend this month
34
- npx fullsendai model glm-5.3-flash # switch every connected tool to another model
46
+ npx fullsendai status # each tool's model and spend this month, and calls a backup provider answered
47
+ npx fullsendai model # switch every connected tool to another model (or name it: model sulfuras)
35
48
  npx fullsendai doctor --fix # check settings, keys and a real request; repair settings
49
+ npx fullsendai sync # update every tool with FullsendAI's current models, abilities and prices
36
50
  npx fullsendai key codex # copy a tool's key to the clipboard (--show prints it)
37
51
  npx fullsendai disconnect # put each tool's settings back and revoke the keys it made
38
52
  ```
package/dist/cli.mjs CHANGED
@@ -7322,17 +7322,22 @@ var require_dist = __commonJS({
7322
7322
  }
7323
7323
  });
7324
7324
 
7325
+ // src/main.ts
7326
+ import { createHash as createHash2 } from "node:crypto";
7327
+
7325
7328
  // src/api.ts
7326
- var VERSION = true ? "0.1.0" : "0.0.0-dev";
7329
+ var VERSION = true ? "0.2.1" : "0.0.0-dev";
7327
7330
  var agent = `fullsendai-cli/${VERSION} (${process.platform}; node ${process.versions.node})`;
7328
7331
  var Api = class {
7329
7332
  constructor(origin, timeoutMs = 6e4) {
7330
7333
  this.origin = origin;
7331
7334
  this.timeoutMs = timeoutMs;
7332
7335
  }
7333
- /** The chat models a key can call, best first as the site lists them. */
7336
+ /** The chat models a key can call: FullsendAI's own first, then the rest as the site lists them. */
7334
7337
  models(key2) {
7335
- return this.call("GET", "/v1/models", key2).then((result) => result.ok ? { ...result, value: result.value.data.filter((model2) => model2.id !== "fullsend/auto") } : result);
7338
+ return this.call("GET", "/v1/models", key2).then(
7339
+ (result) => result.ok ? { ...result, value: result.value.data.filter((model2) => model2.id !== "fullsend/auto").sort((a, b) => Number(!!b.house) - Number(!!a.house)) } : result
7340
+ );
7336
7341
  }
7337
7342
  /** The key's own details: its name, this month's spend, the balance. */
7338
7343
  key(key2) {
@@ -7359,7 +7364,8 @@ var Api = class {
7359
7364
  const json = await response.json().catch(() => void 0);
7360
7365
  if (response.ok) return { ok: true, value: json, ms };
7361
7366
  const message = typeof json?.error === "string" ? json.error : json?.error?.message;
7362
- return { ok: false, status: response.status, error: explain(response.status, message, this.origin), ms };
7367
+ const code = typeof json?.error === "string" ? void 0 : json?.error?.code ?? json?.error?.type;
7368
+ return { ok: false, status: response.status, error: explain(response.status, message, this.origin, code), ms };
7363
7369
  } catch (error) {
7364
7370
  const ms = Date.now() - started;
7365
7371
  const timedOut = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
@@ -7368,10 +7374,11 @@ var Api = class {
7368
7374
  }
7369
7375
  };
7370
7376
  var host = (origin) => origin.replace(/^https?:\/\//, "");
7371
- function explain(status2, message, origin) {
7377
+ function explain(status2, message, origin, code) {
7372
7378
  if (status2 === 401) return "This key isn't valid or was revoked. Run `npx fullsendai connect` to get new ones.";
7373
7379
  if (status2 === 402) return `Your FullsendAI balance is empty. Add credit at ${origin}/dashboard/billing.`;
7374
7380
  if (status2 === 404 && /model/i.test(message ?? "")) return `${message} Run \`npx fullsendai model\` to pick another.`;
7381
+ if (status2 === 429 && message && code !== "rate_limit_exceeded") return message;
7375
7382
  if (status2 === 429) return "Rate limited for a moment. Try again shortly.";
7376
7383
  return message ? `${message} (${status2})` : `The request failed with status ${status2}.`;
7377
7384
  }
@@ -7513,6 +7520,8 @@ var paths = {
7513
7520
  opencode: (w) => preferExisting([join2(configHome(w), "opencode", "opencode.jsonc"), join2(configHome(w), "opencode", "opencode.json")]),
7514
7521
  kilo: (w) => preferExisting([join2(configHome(w), "kilo", "kilo.jsonc"), join2(configHome(w), "kilo", "kilo.json")]),
7515
7522
  aider: (w) => join2(w.home, ".aider.conf.yml"),
7523
+ /** Cline's command line keeps its providers here (its VS Code extension keeps them inside VS Code). */
7524
+ cline: (w) => join2(w.home, ".cline", "data", "settings", "providers.json"),
7516
7525
  continue: (w) => join2(w.env.CONTINUE_GLOBAL_DIR || join2(w.home, ".continue"), "config.yaml")
7517
7526
  };
7518
7527
  function preferExisting(candidates) {
@@ -7522,7 +7531,7 @@ function onPath(command, w) {
7522
7531
  const dirs = (w.env.PATH || w.env.Path || "").split(w.platform === "win32" ? ";" : delimiter).filter(Boolean);
7523
7532
  const extensions = w.platform === "win32" ? (w.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";") : [""];
7524
7533
  for (const dir of dirs)
7525
- for (const ext of w.platform === "win32" ? ["", ...extensions] : extensions) {
7534
+ for (const ext of w.platform === "win32" ? /\.[^\\/]+$/.test(command) ? [""] : extensions : extensions) {
7526
7535
  const file = join2(dir, command + ext);
7527
7536
  try {
7528
7537
  if (statSync(file).isFile()) return file;
@@ -7573,8 +7582,8 @@ function copy(text, w) {
7573
7582
 
7574
7583
  // src/tools.ts
7575
7584
  import { spawnSync as spawnSync2 } from "node:child_process";
7576
- import { existsSync as existsSync3, readdirSync } from "node:fs";
7577
- import { dirname as dirname2 } from "node:path";
7585
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync, rmSync as rmSync2 } from "node:fs";
7586
+ import { dirname as dirname2, join as join4 } from "node:path";
7578
7587
 
7579
7588
  // node_modules/jsonc-parser/lib/esm/impl/scanner.js
7580
7589
  function createScanner(text, ignoreTrivia = false) {
@@ -9706,7 +9715,7 @@ function assertValid(text) {
9706
9715
  }
9707
9716
 
9708
9717
  // src/tools.ts
9709
- var CLAUDE_KEYS = ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL"];
9718
+ var CLAUDE_KEYS = ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL", "CLAUDE_CODE_MAX_CONTEXT_TOKENS", "CLAUDE_CODE_MAX_OUTPUT_TOKENS"];
9710
9719
  var claudeCode = {
9711
9720
  id: "claude-code",
9712
9721
  name: "Claude Code",
@@ -9727,7 +9736,12 @@ var claudeCode = {
9727
9736
  ANTHROPIC_DEFAULT_OPUS_MODEL: ctx.model,
9728
9737
  ANTHROPIC_DEFAULT_SONNET_MODEL: ctx.model,
9729
9738
  ANTHROPIC_DEFAULT_HAIKU_MODEL: ctx.model,
9730
- CLAUDE_CODE_SUBAGENT_MODEL: ctx.model
9739
+ CLAUDE_CODE_SUBAGENT_MODEL: ctx.model,
9740
+ // Claude Code doesn't know our model IDs, so it would assume 200K tokens and compact there; this is the real
9741
+ // window (code.claude.com/docs/en/model-config, "Correct the window for a gateway or custom model ID").
9742
+ CLAUDE_CODE_MAX_CONTEXT_TOKENS: windowOf(ctx),
9743
+ // Claude Code asks for up to 32K tokens of output by default; this is what the model can give.
9744
+ CLAUDE_CODE_MAX_OUTPUT_TOKENS: outputOf(ctx) ? String(outputOf(ctx)) : void 0
9731
9745
  };
9732
9746
  write(file, sameEndings(text, setJson(text, Object.entries(values).map(([name, value]) => [["env", name], value]))));
9733
9747
  return { files: [file], previous };
@@ -9739,6 +9753,12 @@ var claudeCode = {
9739
9753
  const env2 = parseJson(text, file).env ?? {};
9740
9754
  return { connected: env2.ANTHROPIC_BASE_URL === ctx.origin && !!env2.ANTHROPIC_AUTH_TOKEN, key: env2.ANTHROPIC_AUTH_TOKEN, model: env2.ANTHROPIC_MODEL, file };
9741
9755
  },
9756
+ now(w) {
9757
+ const text = read(paths.claude(w));
9758
+ const env2 = (text && parseJson(text, paths.claude(w)).env) ?? {};
9759
+ const where = env2.ANTHROPIC_BASE_URL ? hostOf(env2.ANTHROPIC_BASE_URL) : env2.ANTHROPIC_API_KEY ? "Anthropic API key" : "Anthropic";
9760
+ return [where, env2.ANTHROPIC_MODEL].filter(Boolean).join(" \xB7 ");
9761
+ },
9742
9762
  disconnect(ctx, previous) {
9743
9763
  const file = paths.claude(ctx.w);
9744
9764
  const text = read(file);
@@ -9753,6 +9773,8 @@ var claudeCode = {
9753
9773
  }
9754
9774
  };
9755
9775
  var PROVIDER = "model_providers.fullsend";
9776
+ var CODEX_KEYS = ["model", "model_provider", "model_context_window", "model_auto_compact_token_limit", "model_supports_reasoning_summaries"];
9777
+ var tomlValue = (value) => typeof value === "string" ? tomlString(value) : typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" ? String(value) : void 0;
9756
9778
  var codex = {
9757
9779
  id: "codex",
9758
9780
  name: "Codex CLI",
@@ -9763,8 +9785,18 @@ var codex = {
9763
9785
  const file = paths.codex(ctx.w);
9764
9786
  const text = read(file) ?? "";
9765
9787
  const parsed = tomlOf(text, file);
9766
- const previous = { model: parsed.model ?? null, model_provider: parsed.model_provider ?? null };
9767
- let updated = setTopLevel(text, { model: tomlString(ctx.model), model_provider: tomlString("fullsend") });
9788
+ const previous = Object.fromEntries(CODEX_KEYS.map((name) => [name, parsed[name] ?? null]));
9789
+ const model2 = modelOf(ctx);
9790
+ const window = model2?.context_length;
9791
+ let updated = setTopLevel(text, {
9792
+ model: tomlString(ctx.model),
9793
+ model_provider: tomlString("fullsend"),
9794
+ // Codex doesn't know our models, so it's told their window and compacts near its end, as it does for its own.
9795
+ model_context_window: window ? String(window) : void 0,
9796
+ model_auto_compact_token_limit: window ? String(Math.floor(window * 0.9)) : void 0,
9797
+ // A reasoning model's thinking is asked for and shown as Codex shows its own models'.
9798
+ model_supports_reasoning_summaries: model2?.reasoning ? "true" : void 0
9799
+ });
9768
9800
  updated = setTable(updated, PROVIDER, {
9769
9801
  name: tomlString("FullsendAI"),
9770
9802
  base_url: tomlString(ctx.base),
@@ -9783,6 +9815,12 @@ var codex = {
9783
9815
  const problem = provider && provider.wire_api !== "responses" ? `wire_api isn't "responses", which current Codex needs` : void 0;
9784
9816
  return { connected: parsed.model_provider === "fullsend" && provider?.base_url === ctx.base, key: provider?.experimental_bearer_token, model: parsed.model, file, problem };
9785
9817
  },
9818
+ now(w) {
9819
+ const text = read(paths.codex(w));
9820
+ const parsed = text ? tomlOf(text, paths.codex(w)) : {};
9821
+ const provider = typeof parsed.model_provider === "string" && parsed.model_provider !== "openai" ? parsed.model_provider : "OpenAI";
9822
+ return [provider, parsed.model].filter(Boolean).join(" \xB7 ");
9823
+ },
9786
9824
  disconnect(ctx, previous) {
9787
9825
  const file = paths.codex(ctx.w);
9788
9826
  const text = read(file);
@@ -9790,10 +9828,7 @@ var codex = {
9790
9828
  const parsed = tomlOf(text, file);
9791
9829
  let updated = removeTable(text, PROVIDER);
9792
9830
  if (parsed.model_provider === "fullsend")
9793
- updated = setTopLevel(updated, {
9794
- model: typeof previous.model === "string" ? tomlString(previous.model) : void 0,
9795
- model_provider: typeof previous.model_provider === "string" ? tomlString(previous.model_provider) : void 0
9796
- });
9831
+ updated = setTopLevel(updated, Object.fromEntries(CODEX_KEYS.map((name) => [name, tomlValue(previous[name])])));
9797
9832
  write(file, sameEndings(text, assertValid(updated)));
9798
9833
  return [file];
9799
9834
  }
@@ -9818,10 +9853,16 @@ function openCodeStyle(id, name, binary, file, extensionPrefix) {
9818
9853
  model2.id,
9819
9854
  {
9820
9855
  name: model2.name ?? model2.id,
9821
- tool_call: true,
9822
- ...model2.reasoning !== void 0 && { reasoning: model2.reasoning },
9823
- ...model2.images && { attachment: true },
9824
- ...model2.context_length && { limit: { context: model2.context_length, output: 32768 } }
9856
+ tool_call: model2.tools ?? true,
9857
+ reasoning: !!model2.reasoning,
9858
+ attachment: !!model2.images,
9859
+ temperature: true,
9860
+ modalities: { input: model2.images ? ["text", "image"] : ["text"], output: ["text"] },
9861
+ ...model2.context_length && { limit: { context: model2.context_length, output: model2.max_output_tokens ?? 8192 } },
9862
+ // Per million tokens, as OpenCode shows a session's cost.
9863
+ ...model2.pricing && {
9864
+ cost: { input: model2.pricing.input, output: model2.pricing.output, ...model2.pricing.cached_input != null && { cache_read: model2.pricing.cached_input } }
9865
+ }
9825
9866
  }
9826
9867
  ])
9827
9868
  );
@@ -9840,6 +9881,10 @@ function openCodeStyle(id, name, binary, file, extensionPrefix) {
9840
9881
  const provider = parsed.provider?.fullsend;
9841
9882
  return { connected: provider?.options?.baseURL === ctx.base, key: provider?.options?.apiKey, model: parsed.model?.replace(/^fullsend\//, ""), file: path };
9842
9883
  },
9884
+ now(w) {
9885
+ const text = read(file(w));
9886
+ return text && parseJson(text, file(w)).model || void 0;
9887
+ },
9843
9888
  disconnect(ctx, previous) {
9844
9889
  const path = file(ctx.w);
9845
9890
  const text = read(path);
@@ -9854,7 +9899,9 @@ function openCodeStyle(id, name, binary, file, extensionPrefix) {
9854
9899
  }
9855
9900
  };
9856
9901
  }
9857
- var AIDER_KEYS = ["model", "openai-api-base", "openai-api-key"];
9902
+ var AIDER_KEYS = ["model", "openai-api-base", "openai-api-key", "model-metadata-file", "model-settings-file"];
9903
+ var aiderMetadata = (w) => join4(stateDir(w), "aider.model.metadata.json");
9904
+ var aiderSettings = (w) => join4(stateDir(w), "aider.model.settings.yml");
9858
9905
  var aider = {
9859
9906
  id: "aider",
9860
9907
  name: "Aider",
@@ -9869,8 +9916,31 @@ var aider = {
9869
9916
  doc.set("model", `openai/${ctx.model}`);
9870
9917
  doc.set("openai-api-base", ctx.base);
9871
9918
  doc.set("openai-api-key", key2);
9919
+ doc.set("model-metadata-file", aiderMetadata(ctx.w));
9920
+ doc.set("model-settings-file", aiderSettings(ctx.w));
9921
+ const names = ctx.models.map((model2) => `openai/${model2.id}`);
9922
+ const metadata = Object.fromEntries(
9923
+ ctx.models.map((model2, index) => [
9924
+ names[index],
9925
+ {
9926
+ ...model2.context_length && { max_input_tokens: model2.context_length },
9927
+ ...model2.max_output_tokens && { max_tokens: model2.max_output_tokens, max_output_tokens: model2.max_output_tokens },
9928
+ ...model2.pricing && { input_cost_per_token: model2.pricing.input / 1e6, output_cost_per_token: model2.pricing.output / 1e6 },
9929
+ litellm_provider: "openai",
9930
+ mode: "chat",
9931
+ supports_vision: !!model2.images,
9932
+ supports_function_calling: model2.tools ?? true,
9933
+ supports_reasoning: !!model2.reasoning
9934
+ }
9935
+ ])
9936
+ );
9937
+ const settings = ctx.models.map((model2, index) => ({ name: names[index], edit_format: "diff", use_repo_map: true, ...model2.reasoning && { accepts_settings: ["reasoning_effort"] } }));
9938
+ mkdirSync3(stateDir(ctx.w), { recursive: true, mode: 448 });
9939
+ write(aiderMetadata(ctx.w), `${JSON.stringify(metadata, null, 2)}
9940
+ `);
9941
+ write(aiderSettings(ctx.w), (0, import_yaml.stringify)(settings));
9872
9942
  write(file, sameEndings(text, String(doc)));
9873
- return { files: [file], previous };
9943
+ return { files: [file, aiderMetadata(ctx.w), aiderSettings(ctx.w)], previous };
9874
9944
  },
9875
9945
  read(ctx) {
9876
9946
  const file = paths.aider(ctx.w);
@@ -9879,6 +9949,10 @@ var aider = {
9879
9949
  const doc = yamlOf(text, file);
9880
9950
  return { connected: doc.get("openai-api-base") === ctx.base, key: doc.get("openai-api-key"), model: String(doc.get("model") ?? "").replace(/^openai\//, ""), file };
9881
9951
  },
9952
+ now(w) {
9953
+ const text = read(paths.aider(w));
9954
+ return text && yamlOf(text, paths.aider(w)).get("model") || void 0;
9955
+ },
9882
9956
  disconnect(ctx, previous) {
9883
9957
  const file = paths.aider(ctx.w);
9884
9958
  const text = read(file);
@@ -9891,6 +9965,8 @@ var aider = {
9891
9965
  else doc.delete(name);
9892
9966
  }
9893
9967
  write(file, sameEndings(text, String(doc)));
9968
+ rmSync2(aiderMetadata(ctx.w), { force: true });
9969
+ rmSync2(aiderSettings(ctx.w), { force: true });
9894
9970
  return [file];
9895
9971
  }
9896
9972
  };
@@ -9912,7 +9988,21 @@ var cont = {
9912
9988
  models.items = models.items.filter((item) => !(item instanceof import_yaml.YAMLMap && item.get("apiBase") === ctx.base));
9913
9989
  const ordered = [...ctx.models].sort((a, b) => Number(b.id === ctx.model) - Number(a.id === ctx.model));
9914
9990
  ordered.forEach(
9915
- (model2, index) => models.items.splice(index, 0, doc.createNode({ name: `FullsendAI: ${model2.name ?? model2.id}`, provider: "openai", model: model2.id, apiBase: ctx.base, apiKey: key2, roles: ["chat", "edit", "apply"] }))
9991
+ (model2, index) => models.items.splice(
9992
+ index,
9993
+ 0,
9994
+ doc.createNode({
9995
+ name: `FullsendAI: ${model2.name ?? model2.id}`,
9996
+ provider: "openai",
9997
+ model: model2.id,
9998
+ apiBase: ctx.base,
9999
+ apiKey: key2,
10000
+ roles: ["chat", "edit", "apply"],
10001
+ // What it can do, so Continue offers agent mode (tools) and image input only where they work.
10002
+ capabilities: [...model2.tools ?? true ? ["tool_use"] : [], ...model2.images ? ["image_input"] : []],
10003
+ ...model2.context_length && { defaultCompletionOptions: { contextLength: model2.context_length, ...model2.max_output_tokens && { maxTokens: model2.max_output_tokens } } }
10004
+ })
10005
+ )
9916
10006
  );
9917
10007
  write(file, sameEndings(text, String(doc)));
9918
10008
  return { files: [file], previous: {} };
@@ -9925,6 +10015,12 @@ var cont = {
9925
10015
  const ours = models instanceof import_yaml.YAMLSeq ? models.items.filter((item) => item instanceof import_yaml.YAMLMap && item.get("apiBase") === ctx.base) : [];
9926
10016
  return { connected: ours.length > 0, key: ours[0]?.get("apiKey"), model: ours[0]?.get("model"), file };
9927
10017
  },
10018
+ now(w) {
10019
+ const text = read(paths.continue(w));
10020
+ const models = text ? yamlOf(text, paths.continue(w)).get("models") : void 0;
10021
+ const first = models instanceof import_yaml.YAMLSeq ? models.items.find((item) => item instanceof import_yaml.YAMLMap) : void 0;
10022
+ return first?.get("name") ?? void 0;
10023
+ },
9928
10024
  disconnect(ctx) {
9929
10025
  const file = paths.continue(ctx.w);
9930
10026
  const text = read(file);
@@ -9936,6 +10032,7 @@ var cont = {
9936
10032
  return [file];
9937
10033
  }
9938
10034
  };
10035
+ var CLINE_PROVIDER = "openai-compatible";
9939
10036
  var cline = {
9940
10037
  id: "cline",
9941
10038
  name: "Cline",
@@ -9945,20 +10042,60 @@ var cline = {
9945
10042
  const result = found(w, "cline", false);
9946
10043
  return result.installed ? result : extensionFound(w, "saoudrizwan.claude-dev-") ? { installed: true, how: "VS Code extension" } : result;
9947
10044
  },
10045
+ files: (w) => [paths.cline(w)],
9948
10046
  connect(ctx, key2) {
9949
10047
  const command = onPath("cline", ctx.w);
9950
10048
  if (!command) throw new Error("Cline's command line isn't installed; set up the VS Code extension with the steps shown.");
10049
+ const file = paths.cline(ctx.w);
10050
+ const before = parseJson(read(file) ?? "{}", file);
10051
+ const previous = { lastUsedProvider: before.lastUsedProvider ?? null, entry: before.providers?.[CLINE_PROVIDER] ?? null };
9951
10052
  const result = run(command, ["auth", "-p", "openai", "-k", key2, "-m", ctx.model, "-b", ctx.base], ctx.w);
9952
10053
  if (result.status !== 0) throw new Error(`cline auth failed: ${(result.stderr || result.stdout).trim().split("\n").pop()}`);
9953
- return { files: [], previous: {} };
10054
+ const text = read(file);
10055
+ if (!text) throw new ConfigError(`cline auth didn't write ${file}.`);
10056
+ const model2 = modelOf(ctx);
10057
+ const capabilities = ["streaming", ...model2?.tools ?? true ? ["tools"] : [], ...model2?.images ? ["vision"] : [], ...model2?.reasoning ? ["reasoning"] : []];
10058
+ write(
10059
+ file,
10060
+ sameEndings(
10061
+ text,
10062
+ setJson(text, [
10063
+ [["providers", CLINE_PROVIDER, "settings", "capabilities"], capabilities],
10064
+ [["providers", CLINE_PROVIDER, "settings", "contextWindow"], model2?.context_length ?? void 0],
10065
+ [["providers", CLINE_PROVIDER, "settings", "maxTokens"], model2?.max_output_tokens ?? void 0]
10066
+ ])
10067
+ )
10068
+ );
10069
+ return { files: [file], previous };
10070
+ },
10071
+ read(ctx) {
10072
+ const file = paths.cline(ctx.w);
10073
+ const text = read(file);
10074
+ if (!text) return { connected: false, file, problem: "Cline's providers.json is missing" };
10075
+ const settings = parseJson(text, file).providers?.[CLINE_PROVIDER]?.settings;
10076
+ return { connected: settings?.baseUrl === ctx.base, key: settings?.apiKey, model: settings?.model, file };
10077
+ },
10078
+ disconnect(ctx, previous) {
10079
+ const file = paths.cline(ctx.w);
10080
+ const text = read(file);
10081
+ if (!text) return [];
10082
+ const parsed = parseJson(text, file);
10083
+ if (parsed.providers?.[CLINE_PROVIDER]?.settings?.baseUrl !== ctx.base) return [];
10084
+ write(
10085
+ file,
10086
+ sameEndings(
10087
+ text,
10088
+ setJson(text, [
10089
+ [["providers", CLINE_PROVIDER], previous.entry ?? void 0],
10090
+ [["lastUsedProvider"], previous.lastUsedProvider ?? void 0]
10091
+ ])
10092
+ )
10093
+ );
10094
+ return [file];
9954
10095
  },
9955
10096
  guide: (ctx) => ({
9956
- steps: ["Open Cline's settings (the gear in its panel).", "Set API Provider to OpenAI Compatible.", "Paste the values below, then Save."],
9957
- values: [
9958
- ["Base URL", ctx.base],
9959
- ["API Key", "(copied to your clipboard)"],
9960
- ["Model ID", ctx.model]
9961
- ]
10097
+ steps: ["Open Cline's settings (the gear in its panel).", "Set API Provider to OpenAI Compatible.", "Paste the values below; the rest go under Model Configuration. Then Save."],
10098
+ values: [["Base URL", ctx.base], ["API Key", "(copied to your clipboard)"], ["Model ID", ctx.model], ...modelConfiguration(ctx)]
9962
10099
  })
9963
10100
  };
9964
10101
  var cursor = {
@@ -9983,12 +10120,8 @@ var roo = {
9983
10120
  kind: "guided",
9984
10121
  detect: (w) => ({ installed: extensionFound(w, "rooveterinaryinc.roo-cline-"), how: "VS Code extension" }),
9985
10122
  guide: (ctx) => ({
9986
- steps: ["Open Roo Code's settings (the gear in its panel) \u203A Providers.", "Set API Provider to OpenAI Compatible.", "Paste the values below, then Save."],
9987
- values: [
9988
- ["Base URL", ctx.base],
9989
- ["API Key", "(copied to your clipboard)"],
9990
- ["Model", ctx.model]
9991
- ]
10123
+ steps: ["Open Roo Code's settings (the gear in its panel) \u203A Providers.", "Set API Provider to OpenAI Compatible.", "Paste the values below; the rest go under Model Configuration. Then Save."],
10124
+ values: [["Base URL", ctx.base], ["API Key", "(copied to your clipboard)"], ["Model", ctx.model], ...modelConfiguration(ctx)]
9992
10125
  })
9993
10126
  };
9994
10127
  var TOOLS = [claudeCode, codex, openCodeStyle("opencode", "OpenCode", "opencode", paths.opencode), openCodeStyle("kilo", "Kilo Code", "kilo", paths.kilo, "kilocode.kilo-code-"), aider, cont, cline, cursor, roo];
@@ -9997,7 +10130,7 @@ function found(w, binary, configured) {
9997
10130
  const command = onPath(binary, w);
9998
10131
  if (!command) return { installed: configured, how: configured ? "settings found" : void 0 };
9999
10132
  const result = run(command, ["--version"], w, 4e3);
10000
- const version = (result.stdout || result.stderr).match(/\d+\.\d+\.\d+(?:[-.][\w.]+)?/)?.[0];
10133
+ const version = (result.stdout || result.stderr || "").match(/\d+\.\d+\.\d+(?:[-.][\w.]+)?/)?.[0];
10001
10134
  return { installed: true, version, how: "command line" };
10002
10135
  }
10003
10136
  function extensionFound(w, prefix) {
@@ -10016,6 +10149,30 @@ function run(command, args, w, timeout = 3e4) {
10016
10149
  }
10017
10150
  var ConfigError = class extends Error {
10018
10151
  };
10152
+ function modelConfiguration(ctx) {
10153
+ const model2 = modelOf(ctx);
10154
+ if (!model2) return [];
10155
+ return [
10156
+ ...model2.context_length ? [["Context Window Size", String(model2.context_length)]] : [],
10157
+ ...model2.max_output_tokens ? [["Max Output Tokens", String(model2.max_output_tokens)]] : [],
10158
+ ["Supports Images", model2.images ? "on" : "off"],
10159
+ ...model2.pricing ? [["Input Price / 1M tokens", String(model2.pricing.input)], ["Output Price / 1M tokens", String(model2.pricing.output)]] : []
10160
+ ];
10161
+ }
10162
+ var modelOf = (ctx) => ctx.models.find((model2) => model2.id === ctx.model);
10163
+ var outputOf = (ctx) => modelOf(ctx)?.max_output_tokens ?? void 0;
10164
+ var windowOf = (ctx) => {
10165
+ const tokens = ctx.models.find((model2) => model2.id === ctx.model)?.context_length;
10166
+ return tokens ? String(tokens) : void 0;
10167
+ };
10168
+ var hostOf = (url) => url.replace(/^https?:\/\//, "").split("/")[0];
10169
+ function currentSetup(tool, w) {
10170
+ try {
10171
+ return tool.now?.(w);
10172
+ } catch {
10173
+ return void 0;
10174
+ }
10175
+ }
10019
10176
  function parseJson(text, file) {
10020
10177
  const errors = [];
10021
10178
  const value = parse2(text, errors, { allowTrailingComma: true });
@@ -10145,6 +10302,7 @@ async function checklist(question, choices) {
10145
10302
  const chosen = () => choices.filter((choice) => choice.checked && !choice.disabled).map((choice) => choice.id);
10146
10303
  if (!interactive || !choices.length) return chosen();
10147
10304
  let at = Math.max(0, choices.findIndex((choice) => !choice.disabled));
10305
+ const width = Math.min(24, Math.max(...choices.map((choice) => choice.label.length)) + 1);
10148
10306
  const lines = choices.length + 2;
10149
10307
  const draw = (first) => {
10150
10308
  if (!first) out.write(`\x1B[${lines}A`);
@@ -10153,19 +10311,20 @@ async function checklist(question, choices) {
10153
10311
  choices.forEach((choice, index) => {
10154
10312
  const pointer = index === at ? cyan(sym.pointer) : " ";
10155
10313
  const box = choice.disabled ? dim(sym.off) : choice.checked ? green(sym.on) : sym.off;
10156
- const label = choice.disabled ? dim(choice.label) : index === at ? bold(choice.label) : choice.label;
10314
+ const padded = choice.label.padEnd(width);
10315
+ const label = choice.disabled ? dim(padded) : index === at ? bold(padded) : padded;
10157
10316
  const hint = choice.disabled ? dim(choice.disabled) : choice.hint ? dim(choice.hint) : "";
10158
- out.write(`\x1B[2K ${pointer} ${box} ${label.padEnd(22)} ${hint}
10317
+ out.write(`\x1B[2K ${pointer} ${box} ${label} ${hint}
10159
10318
  `);
10160
10319
  });
10161
10320
  out.write(`\x1B[2K
10162
10321
  `);
10163
10322
  };
10164
- out.write("\x1B[?25l");
10165
- draw(true);
10166
10323
  emitKeypressEvents(process.stdin);
10167
10324
  process.stdin.setRawMode?.(true);
10168
10325
  process.stdin.resume();
10326
+ out.write("\x1B[?25l");
10327
+ draw(true);
10169
10328
  return new Promise((resolve) => {
10170
10329
  const move = (step) => {
10171
10330
  for (let tries = 0; tries < choices.length; tries++) {
@@ -10196,6 +10355,49 @@ async function checklist(question, choices) {
10196
10355
  process.stdin.on("keypress", onKey);
10197
10356
  });
10198
10357
  }
10358
+ async function select(question, options, initial) {
10359
+ if (!interactive || !options.length) return initial;
10360
+ let at = Math.max(0, options.findIndex((option) => option.id === initial));
10361
+ const width = Math.min(28, Math.max(...options.map((option) => option.label.length)) + 1);
10362
+ const hintWidth = Math.min(56, Math.max(...options.map((option) => (option.hint ?? "").length)));
10363
+ const lines = options.length + 2;
10364
+ const draw = (first) => {
10365
+ if (!first) out.write(`\x1B[${lines}A`);
10366
+ out.write(`\x1B[2K ${cyan("?")} ${question} ${dim("(\u2191/\u2193 to move, enter to choose)")}
10367
+ `);
10368
+ options.forEach((option, index) => {
10369
+ const here2 = index === at;
10370
+ const tag = option.tag ? ` ${volt(option.tag)}` : "";
10371
+ out.write(`\x1B[2K ${here2 ? cyan(sym.pointer) : " "} ${here2 ? bold(option.label.padEnd(width)) : option.label.padEnd(width)} ${dim((option.hint ?? "").padEnd(hintWidth))}${tag}
10372
+ `);
10373
+ });
10374
+ out.write("\x1B[2K\n");
10375
+ };
10376
+ emitKeypressEvents(process.stdin);
10377
+ process.stdin.setRawMode?.(true);
10378
+ process.stdin.resume();
10379
+ out.write("\x1B[?25l");
10380
+ draw(true);
10381
+ return new Promise((resolve) => {
10382
+ const onKey = (text, key2 = {}) => {
10383
+ if (key2.ctrl && key2.name === "c") {
10384
+ restoreTerminal();
10385
+ process.exit(130);
10386
+ }
10387
+ if (key2.name === "up" || text === "k") at = (at - 1 + options.length) % options.length;
10388
+ else if (key2.name === "down" || text === "j") at = (at + 1) % options.length;
10389
+ else if (key2.name === "return" || key2.name === "enter") {
10390
+ process.stdin.off("keypress", onKey);
10391
+ process.stdin.setRawMode?.(false);
10392
+ process.stdin.pause();
10393
+ out.write("\x1B[?25h");
10394
+ return resolve(options[at].id);
10395
+ }
10396
+ draw(false);
10397
+ };
10398
+ process.stdin.on("keypress", onKey);
10399
+ });
10400
+ }
10199
10401
  function restoreTerminal() {
10200
10402
  if (out.isTTY) out.write("\x1B[?25h");
10201
10403
  if (process.stdin.isTTY) process.stdin.setRawMode?.(false);
@@ -10208,7 +10410,6 @@ for (const signal of ["SIGINT", "SIGTERM"])
10208
10410
 
10209
10411
  // src/main.ts
10210
10412
  var DEFAULT_ORIGIN = "https://fullsendai.com";
10211
- var PREFERRED = ["nefarian-6.1-flash", "sulfuras", "deepseek-v4.1-flash", "glm-5.3-flash"];
10212
10413
  async function main(argv) {
10213
10414
  const { command, args, flags } = parse4(argv);
10214
10415
  setJson2(flags.json);
@@ -10224,6 +10425,8 @@ async function main(argv) {
10224
10425
  return model(api, origin, args[0] ?? flags.model, flags);
10225
10426
  case "doctor":
10226
10427
  return doctor(api, origin, flags);
10428
+ case "sync":
10429
+ return sync(api, origin, flags);
10227
10430
  case "disconnect":
10228
10431
  return disconnect(api, origin, flags);
10229
10432
  case "key":
@@ -10253,16 +10456,22 @@ async function connect(api, origin, flags) {
10253
10456
  print(` ${dim(`or see ${origin}/agents for every tool's setup.`)}`);
10254
10457
  return output(flags, { connected: [], reason: "no tools found" }, 0);
10255
10458
  }
10256
- print(" Found on this machine:");
10257
- for (const { tool, found: found2 } of installed) row("ok", tool.name, dim([found2.version, found2.how].filter(Boolean).join(sym.dot === "\xB7" ? " \xB7 " : " - ")));
10258
- print();
10459
+ const asking = !flags.tools && interactive;
10460
+ if (!asking) {
10461
+ print(" Found on this machine:");
10462
+ for (const { tool, found: found2 } of installed) row("ok", tool.name, dim([found2.version, found2.how].filter(Boolean).join(sym.dot === "\xB7" ? " \xB7 " : " - ")));
10463
+ print();
10464
+ } else {
10465
+ print(` Found ${installed.length === 1 ? "1 coding tool" : `${installed.length} coding tools`}. What each uses now is backed up, and ${cyan("npx fullsendai disconnect")} brings it back.`);
10466
+ print();
10467
+ }
10259
10468
  const chosen = flags.tools ? flags.tools : await checklist(
10260
- "Connect which tools?",
10469
+ "Connect which tools to FullsendAI?",
10261
10470
  installed.map(({ tool }) => ({
10262
10471
  id: tool.id,
10263
10472
  label: tool.name,
10264
10473
  checked: !state.tools[tool.id],
10265
- hint: state.tools[tool.id] ? "already connected" : tool.kind === "guided" ? "you paste 3 settings" : tool.kind === "command" ? "runs its setup command" : "settings written for you"
10474
+ hint: state.tools[tool.id] ? "already connected" : hintFor(tool, w)
10266
10475
  }))
10267
10476
  );
10268
10477
  if (!chosen.length) {
@@ -10275,7 +10484,7 @@ async function connect(api, origin, flags) {
10275
10484
  for (const tool of tools) row("info", tool.name, tool.kind === "file" ? fileFor(tool, origin) : tool.kind === "command" ? "its own setup command" : "settings you paste in the app");
10276
10485
  return output(flags, { dryRun: true, tools: tools.map((tool) => tool.id) }, 0);
10277
10486
  }
10278
- if (!flags.yes && !await confirm(`Connect ${tools.length === 1 ? tools[0].name : `these ${tools.length} tools`} to FullsendAI?`)) {
10487
+ if (!flags.yes && !asking && !await confirm(`Connect ${tools.length === 1 ? tools[0].name : `these ${tools.length} tools`} to FullsendAI?`)) {
10279
10488
  print(" Nothing changed.");
10280
10489
  return output(flags, { connected: [] }, 0);
10281
10490
  }
@@ -10299,15 +10508,19 @@ async function connect(api, origin, flags) {
10299
10508
  const firstKey = Object.values(keys)[0];
10300
10509
  const models = await spin("Fetching the models\u2026", () => api.models(firstKey));
10301
10510
  if (!models.ok) return fail(models.error, flags);
10302
- const modelId = pickModel(models.value, flags.model ?? state.tools[tools[0].id]?.model);
10303
- if (!modelId) return fail(`There's no model called ${flags.model}. Live models: ${models.value.map((model2) => model2.id).join(", ")}.`, flags);
10304
- const pretty = models.value.find((model2) => model2.id === modelId)?.name ?? modelId;
10305
- print(` Model for all tools: ${bold(pretty)} ${dim(`(${modelId} \xB7 change any time with: npx fullsendai model)`)}`);
10511
+ const suggested = pickModel(models.value, flags.model ?? state.tools[tools[0].id]?.model);
10512
+ if (!suggested) return fail(`There's no model called ${flags.model}. Live models: ${models.value.map((model2) => model2.id).join(", ")}.`, flags);
10513
+ const ask2 = !flags.model && !flags.yes && !state.tools[tools[0].id]?.model && models.value.length > 1;
10514
+ if (ask2) print();
10515
+ const modelId = ask2 ? await select("Which model should your tools use?", modelOptions(models.value, suggested), suggested) : suggested;
10516
+ const chosenModel = models.value.find((model2) => model2.id === modelId);
10517
+ print(` Model for all tools: ${bold(chosenModel?.name ?? modelId)} ${dim(`(${modelId} \xB7 change any time with: npx fullsendai model)`)}`);
10306
10518
  print();
10307
10519
  const folder = backupFolder(w);
10308
10520
  const ctx = { w, origin, base: `${origin}/v1`, model: modelId, models: models.value };
10309
10521
  const results = [];
10310
10522
  const guided = [];
10523
+ let unpaid = false;
10311
10524
  for (const tool of tools) {
10312
10525
  const secret = keys[tool.id];
10313
10526
  if (!secret) {
@@ -10317,16 +10530,16 @@ async function connect(api, origin, flags) {
10317
10530
  }
10318
10531
  if (tool.kind === "guided" || tool.kind === "command" && !tool.detect(w).how?.includes("command")) {
10319
10532
  guided.push(tool);
10320
- state.tools[tool.id] = { key: secret, model: modelId, connectedAt: (/* @__PURE__ */ new Date()).toISOString(), kind: "guided", files: [], previous: {}, backups: [], ...given ? { given: true } : {} };
10533
+ state.tools[tool.id] = { key: secret, model: modelId, modelUid: uidOf(models.value, modelId), connectedAt: (/* @__PURE__ */ new Date()).toISOString(), kind: "guided", files: [], previous: {}, backups: [], ...given ? { given: true } : {} };
10321
10534
  saveState(w, { ...state, origin, account });
10322
10535
  continue;
10323
10536
  }
10324
10537
  try {
10325
10538
  const earlier = state.tools[tool.id];
10326
- const files = tool.kind === "file" ? [fileFor(tool, origin)] : [];
10539
+ const files = tool.files?.(w) ?? (tool.kind === "file" ? [fileFor(tool, origin)] : []);
10327
10540
  const backups = files.flatMap((file) => backup(file, folder, tool.id) ?? []);
10328
10541
  const applied = tool.connect(ctx, secret);
10329
- const connection = { key: secret, model: modelId, connectedAt: (/* @__PURE__ */ new Date()).toISOString(), kind: tool.kind, files: applied.files, previous: earlier?.previous ?? applied.previous, backups: [...earlier?.backups ?? [], ...backups], ...given ? { given: true } : {} };
10542
+ const connection = { key: secret, model: modelId, modelUid: uidOf(models.value, modelId), connectedAt: (/* @__PURE__ */ new Date()).toISOString(), kind: tool.kind, files: applied.files, previous: earlier?.previous ?? applied.previous, backups: [...earlier?.backups ?? [], ...backups], ...given ? { given: true } : {} };
10330
10543
  state.tools[tool.id] = connection;
10331
10544
  saveState(w, { ...state, origin, account });
10332
10545
  if (earlier && earlier.key !== secret && !earlier.given) void api.revoke(earlier.key);
@@ -10335,6 +10548,10 @@ async function connect(api, origin, flags) {
10335
10548
  if (probe.ok) {
10336
10549
  row("ok", tool.name, `${where} ${green(`${sym.ok} answered in ${(probe.ms / 1e3).toFixed(1)}s`)}`);
10337
10550
  results.push({ id: tool.id, ok: true, detail: where });
10551
+ } else if (probe.status === 402) {
10552
+ row("warn", tool.name, `${where} ${yellow("waiting for credit")}`);
10553
+ results.push({ id: tool.id, ok: false, detail: probe.error });
10554
+ unpaid = true;
10338
10555
  } else {
10339
10556
  row("warn", tool.name, `${where}, but the test call failed: ${probe.error}`);
10340
10557
  results.push({ id: tool.id, ok: false, detail: probe.error });
@@ -10351,12 +10568,31 @@ async function connect(api, origin, flags) {
10351
10568
  for (const tool of guided) await showGuide(tool, { ...ctx }, keys[tool.id], w);
10352
10569
  const good = results.filter((result) => result.ok).length + guided.length;
10353
10570
  print();
10571
+ if (unpaid) {
10572
+ print(` ${yellow(bold("Add credit to start."))} Your FullsendAI balance is empty, so the test calls were refused.`);
10573
+ print(` Your tools are set up and work as soon as you add credit at ${cyan(`${origin}/dashboard/billing`)}.`);
10574
+ print(` ${dim("Then check them with: npx fullsendai doctor")}`);
10575
+ print();
10576
+ }
10354
10577
  if (good) {
10355
10578
  print(` ${green(bold("Done."))} ${good === 1 ? "1 tool uses" : `${good} tools use`} FullsendAI${account?.email ? ` as ${account.email}` : ""}.`);
10579
+ for (const line of whatYouGet(chosenModel ?? { id: modelId })) print(` ${line}`);
10580
+ const other = models.value.find((model2) => model2.id !== modelId)?.id;
10581
+ const tips = results.filter((result) => result.ok).flatMap((result) => {
10582
+ const tip = tipFor(result.id, other);
10583
+ return tip ? [[toolById(result.id)?.name ?? result.id, tip]] : [];
10584
+ });
10585
+ if (tips.length) {
10586
+ print();
10587
+ print(` ${bold("Using them")}`);
10588
+ const width = Math.max(...tips.map(([name]) => name.length)) + 2;
10589
+ for (const [name, tip] of tips) print(` ${name.padEnd(width)}${tip}`);
10590
+ print(` ${dim(`Cap what a tool can spend: ${origin.replace(/^https?:\/\//, "")}/dashboard/keys, then that tool's key, Monthly spend cap.`)}`);
10591
+ }
10592
+ print();
10356
10593
  print(` ${dim(`Backups: ${short(folder, w.home)} ${sym.dot} Spend per tool: npx fullsendai status ${sym.dot} Undo: npx fullsendai disconnect`)}`);
10357
- const tryIt = results.filter((result) => result.ok).map((result) => ({ "claude-code": "claude", codex: "codex", opencode: "opencode", kilo: "kilo", aider: "aider", continue: "cn", cline: "cline" })[result.id]).filter(Boolean);
10358
- if (tryIt.length) print(` ${dim("Try it:")} ${tryIt.map((command) => cyan(command)).join(dim(", "))}`);
10359
10594
  }
10595
+ saveState(w, { ...loadState(w, origin), catalog: fingerprint(models.value) });
10360
10596
  return output(flags, { connected: results.filter((result) => result.ok).map((result) => result.id), guided: guided.map((tool) => tool.id), failed: results.filter((result) => !result.ok), model: modelId, backups: folder }, results.some((result) => !result.ok) ? 1 : 0);
10361
10597
  }
10362
10598
  async function signInFlow(origin, tools, w) {
@@ -10421,11 +10657,17 @@ async function status(api, origin, flags) {
10421
10657
  const drift = reading && !reading.connected ? yellow(" \xB7 settings changed since connect (npx fullsendai doctor --fix)") : reading?.problem ? yellow(` \xB7 ${reading.problem}`) : "";
10422
10658
  row(info.ok && !drift ? "ok" : "warn", tool?.name ?? "?", `${dim(reading?.model ?? connection.model)} ${spend}${drift}`);
10423
10659
  }
10660
+ const byKey = new Map(report.flatMap(({ connection, info }) => info.ok ? [[connection.key, info.value.failovers ?? 0]] : []));
10661
+ const failovers = [...byKey.values()].reduce((sum, each) => sum + each, 0);
10662
+ const allowance = report.find(({ info }) => info.ok)?.info;
10663
+ const empty = balance !== void 0 && balance <= 0 && allowance?.ok && (allowance.value.plan_allowance_remaining ?? 0) <= 0;
10424
10664
  if (balance !== void 0) print(`
10425
- ${dim("Balance:")} $${balance.toFixed(2)}`);
10665
+ ${dim("Balance:")} ${empty ? yellow("$0.00, so your tools' calls are refused.") : `$${balance.toFixed(2)}`}${empty ? ` Add credit at ${cyan(`${origin}/dashboard/billing`)}.` : ""}`);
10666
+ if (failovers) print(` ${volt(sym.ok)} ${failovers === 1 ? "1 call" : `${failovers} calls`} this month kept working when a provider failed: a backup answered.`);
10667
+ await noticeChanges(api, origin, flags, false);
10426
10668
  return output(
10427
10669
  flags,
10428
- { tools: report.map(({ id, connection, reading, info }) => ({ id, model: reading?.model ?? connection.model, connected: reading?.connected ?? true, usage: info.ok ? info.value.usage : null, error: info.ok ? null : info.error })), balance },
10670
+ { tools: report.map(({ id, connection, reading, info }) => ({ id, model: reading?.model ?? connection.model, connected: reading?.connected ?? true, usage: info.ok ? info.value.usage : null, failovers: info.ok ? info.value.failovers ?? 0 : null, error: info.ok ? null : info.error })), balance, failovers },
10429
10671
  0
10430
10672
  );
10431
10673
  }
@@ -10439,9 +10681,9 @@ async function model(api, origin, wanted, flags) {
10439
10681
  let id = wanted;
10440
10682
  if (!id) {
10441
10683
  header2("pick a model");
10442
- models.value.forEach((option, index) => print(` ${dim(`${index + 1}.`)} ${option.name ?? option.id} ${dim(option.id)}`));
10443
- const answer = (await ask("Number or model ID:")).trim();
10444
- id = /^\d+$/.test(answer) ? models.value[Number(answer) - 1]?.id : answer;
10684
+ const current = state.tools[ids[0]].model;
10685
+ if (!interactive) return fail(`Name the model: npx fullsendai model <id>. Live models: ${models.value.map((option) => option.id).join(", ")}.`, flags);
10686
+ id = await select("Switch every tool to:", modelOptions(models.value, pickModel(models.value, void 0) ?? current).map((option) => option.id === current ? { ...option, tag: "current" } : option), current);
10445
10687
  }
10446
10688
  if (!id || !models.value.some((option) => option.id === id)) return fail(`There's no model called ${id ?? "(none)"}. Live models: ${models.value.map((option) => option.id).join(", ")}.`, flags);
10447
10689
  const ctx = { w, origin, base: `${origin}/v1`, model: id, models: models.value };
@@ -10450,19 +10692,85 @@ async function model(api, origin, wanted, flags) {
10450
10692
  const connection = state.tools[toolId];
10451
10693
  if (connection.kind === "guided") {
10452
10694
  row("info", tool.name, `set the model to ${cyan(id)} in its settings`);
10695
+ state.tools[toolId] = { ...connection, model: id, modelUid: uidOf(models.value, id) };
10453
10696
  continue;
10454
10697
  }
10455
10698
  try {
10456
10699
  tool.connect(ctx, connection.key);
10457
- state.tools[toolId] = { ...connection, model: id };
10700
+ state.tools[toolId] = { ...connection, model: id, modelUid: uidOf(models.value, id) };
10458
10701
  row("ok", tool.name, `now uses ${bold(id)}`);
10459
10702
  } catch (error) {
10460
10703
  row("fail", tool.name, error.message);
10461
10704
  }
10462
10705
  }
10463
- saveState(w, state);
10706
+ saveState(w, { ...state, catalog: fingerprint(models.value) });
10707
+ const picked = models.value.find((option) => option.id === id);
10708
+ if (picked) {
10709
+ print();
10710
+ for (const line of whatYouGet(picked)) print(` ${line}`);
10711
+ }
10464
10712
  return output(flags, { model: id }, 0);
10465
10713
  }
10714
+ async function sync(api, origin, flags, quiet = false) {
10715
+ const w = here();
10716
+ const state = loadState(w, origin);
10717
+ const ids = Object.keys(state.tools);
10718
+ if (!quiet) header2("sync", "Brings each connected tool up to date with FullsendAI's models.");
10719
+ if (!ids.length) {
10720
+ print(` No tools are connected. Run ${cyan("npx fullsendai connect")}.`);
10721
+ return output(flags, { synced: [], moved: [] }, 0);
10722
+ }
10723
+ const models = await spin("Fetching the models\u2026", () => api.models(state.tools[ids[0]].key));
10724
+ if (!models.ok) return fail(models.error, flags);
10725
+ const folder = backupFolder(w);
10726
+ const synced = [];
10727
+ const moved = [];
10728
+ for (const id of ids) {
10729
+ const tool = toolById(id);
10730
+ const connection = state.tools[id];
10731
+ if (!tool) continue;
10732
+ const stays = models.value.some((model2) => model2.id === connection.model);
10733
+ const renamed = stays ? void 0 : models.value.find((model2) => connection.modelUid && model2.uid === connection.modelUid)?.id;
10734
+ const modelId = stays ? connection.model : renamed ?? pickModel(models.value, void 0);
10735
+ if (!modelId) continue;
10736
+ if (!stays) moved.push({ id, from: connection.model, to: modelId });
10737
+ const gone = stays ? "" : renamed ? `${connection.model} is now called ${bold(modelId)}` : `${connection.model} is no longer offered, so it now uses ${bold(modelId)}`;
10738
+ if (connection.kind === "guided" || !tool.connect) {
10739
+ if (!stays) row("warn", tool.name, `${gone}: set it in ${tool.name}'s settings`);
10740
+ state.tools[id] = { ...connection, model: modelId, modelUid: uidOf(models.value, modelId) };
10741
+ continue;
10742
+ }
10743
+ try {
10744
+ for (const file of tool.files?.(w) ?? (tool.kind === "file" ? [fileFor(tool, origin)] : [])) backup(file, folder, id);
10745
+ tool.connect({ w, origin, base: `${origin}/v1`, model: modelId, models: models.value }, connection.key);
10746
+ state.tools[id] = { ...connection, model: modelId, modelUid: uidOf(models.value, modelId) };
10747
+ synced.push(id);
10748
+ row(stays ? "ok" : "warn", tool.name, stays ? dim("up to date") : gone);
10749
+ } catch (error) {
10750
+ row("fail", tool.name, error.message);
10751
+ }
10752
+ }
10753
+ saveState(w, { ...state, catalog: fingerprint(models.value) });
10754
+ return output(flags, { synced, moved }, 0);
10755
+ }
10756
+ async function noticeChanges(api, origin, flags, apply) {
10757
+ const w = here();
10758
+ const state = loadState(w, origin);
10759
+ const first = Object.values(state.tools).find((connection) => connection.kind !== "guided");
10760
+ if (!first) return;
10761
+ const models = await api.models(first.key);
10762
+ if (!models.ok || fingerprint(models.value) === state.catalog) return;
10763
+ print();
10764
+ print(` ${yellow(sym.warn)} FullsendAI's models have changed since your tools were set up ${dim("(new or renamed models, abilities or prices)")}.`);
10765
+ const update = apply || interactive && !flags.json && await confirm("Update your tools now?");
10766
+ if (update) await sync(api, origin, flags, true);
10767
+ else print(` Run ${cyan("npx fullsendai sync")} to update them.`);
10768
+ }
10769
+ var uidOf = (models, id) => models.find((model2) => model2.id === id)?.uid;
10770
+ function fingerprint(models) {
10771
+ const described = [...models].sort((a, b) => a.id.localeCompare(b.id)).map((model2) => [model2.id, model2.name, model2.context_length, model2.max_output_tokens, model2.images, model2.reasoning, model2.tools, model2.pricing?.input, model2.pricing?.output, model2.pricing?.cached_input]);
10772
+ return createHash2("sha256").update(JSON.stringify(described)).digest("hex").slice(0, 16);
10773
+ }
10466
10774
  async function doctor(api, origin, flags) {
10467
10775
  const w = here();
10468
10776
  const state = loadState(w, origin);
@@ -10498,9 +10806,14 @@ async function doctor(api, origin, flags) {
10498
10806
  else row(fixed ? "ok" : "warn", tool.name, fixed ? `fixed: ${issues.join("; ")}` : issues.join("; "));
10499
10807
  for (const issue of issues) problems.push({ id, problem: issue, fixed });
10500
10808
  }
10809
+ await noticeChanges(api, origin, flags, flags.fix);
10501
10810
  const open = problems.filter((problem) => !problem.fixed);
10811
+ const billing = `${origin}/dashboard/billing`;
10812
+ const settings = open.filter((problem) => !problem.problem.includes(billing));
10502
10813
  print();
10503
- print(open.length ? ` ${yellow(`${open.length} problem${open.length > 1 ? "s" : ""}.`)} ${flags.fix ? "" : `Run ${cyan("npx fullsendai doctor --fix")} to repair settings.`}` : ` ${green("Everything works.")}`);
10814
+ if (!open.length) print(` ${green("Everything works.")}`);
10815
+ else if (!settings.length) print(` ${yellow("Your balance is empty.")} Add credit at ${cyan(billing)}; the settings themselves are fine.`);
10816
+ else print(` ${yellow(`${open.length} problem${open.length > 1 ? "s" : ""}.`)} ${flags.fix ? "" : `Run ${cyan("npx fullsendai doctor --fix")} to repair settings.`}`);
10504
10817
  return output(flags, { problems }, open.length ? 1 : 0);
10505
10818
  }
10506
10819
  async function disconnect(api, origin, flags) {
@@ -10523,9 +10836,9 @@ async function disconnect(api, origin, flags) {
10523
10836
  const connection = state.tools[id];
10524
10837
  if (!tool || !connection) continue;
10525
10838
  try {
10526
- const files = connection.kind === "file" ? tool.disconnect?.({ w, origin, base: `${origin}/v1` }, connection.previous) ?? [] : [];
10839
+ const files = connection.kind !== "guided" ? tool.disconnect?.({ w, origin, base: `${origin}/v1` }, connection.previous) ?? [] : [];
10527
10840
  const revoked = flags.keepKeys || connection.given ? void 0 : await api.revoke(connection.key);
10528
- const note = connection.kind === "file" ? files.length ? "settings restored" : "settings were already changed, left as they are" : `remove FullsendAI from ${tool.name}'s settings yourself`;
10841
+ const note = connection.kind !== "guided" && tool.disconnect ? files.length ? "settings restored" : "settings were already changed, left as they are" : `remove FullsendAI from ${tool.name}'s settings yourself`;
10529
10842
  row("ok", tool.name, `${note}${revoked ? revoked.ok ? ", key revoked" : ", key was already revoked" : connection.given ? ", your key still works" : ""}`);
10530
10843
  delete state.tools[id];
10531
10844
  done.push(id);
@@ -10550,8 +10863,42 @@ function key(origin, id, flags) {
10550
10863
  }
10551
10864
  function pickModel(models, wanted) {
10552
10865
  if (wanted) return models.some((model2) => model2.id === wanted) ? wanted : void 0;
10553
- return PREFERRED.find((id) => models.some((model2) => model2.id === id)) ?? models[0]?.id;
10866
+ return (models.find((model2) => model2.recommended) ?? models.find((model2) => model2.house) ?? models[0])?.id;
10867
+ }
10868
+ function tipFor(id, other) {
10869
+ const switchTo = (example) => other ? `${dim("Another model for one run:")} ${cyan(example)}` : "";
10870
+ const tips = {
10871
+ "claude-code": `Start a new ${cyan("claude")} session ${dim("(open ones keep their old settings)")}.`,
10872
+ codex: `Run ${cyan("codex")}. ${switchTo(`codex -m ${other}`)}`,
10873
+ opencode: `Run ${cyan("opencode")}. ${dim("/models lists every FullsendAI model.")}`,
10874
+ kilo: `Run ${cyan("kilo")}. ${dim("/models lists every FullsendAI model.")}`,
10875
+ aider: `Run ${cyan("aider")}. ${switchTo(`aider --model openai/${other}`)}`,
10876
+ continue: `In VS Code, reload the window; FullsendAI's models top Continue's model menu. ${dim("In a terminal: cn")}`
10877
+ };
10878
+ return tips[id]?.trim();
10879
+ }
10880
+ function hintFor(tool, w) {
10881
+ const now = currentSetup(tool, w);
10882
+ if (now) return `now: ${now}`;
10883
+ return tool.kind === "guided" ? "you paste 3 settings" : tool.kind === "command" ? "runs its setup command" : "not set up yet";
10884
+ }
10885
+ function modelOptions(models, suggested) {
10886
+ return models.map((model2) => ({
10887
+ id: model2.id,
10888
+ label: model2.name ?? model2.id,
10889
+ hint: model2.house && model2.tagline ? model2.tagline : model2.pricing ? `${price(model2.pricing.input)} in \xB7 ${price(model2.pricing.output)} out per 1M tokens` : model2.id,
10890
+ tag: model2.id === suggested ? "recommended" : model2.house ? "FullsendAI" : void 0
10891
+ }));
10892
+ }
10893
+ function whatYouGet(model2) {
10894
+ const name = bold(model2.name ?? model2.id);
10895
+ return [
10896
+ model2.house ? `Your tools now run ${name}, FullsendAI's own model${model2.tagline ? `: ${lowerFirst(model2.tagline)}` : ""}.` : `Your tools now run ${name}.`,
10897
+ ...model2.backup ? [`${volt(sym.ok)} If its provider goes down, a backup answers and your tools keep working. ${dim("You pay the same price.")}`] : []
10898
+ ];
10554
10899
  }
10900
+ var lowerFirst = (text) => text.charAt(0).toLowerCase() + text.slice(1);
10901
+ var price = (usd) => `$${usd < 1 ? usd.toFixed(2) : usd.toFixed(usd % 1 ? 2 : 0)}`;
10555
10902
  function fileFor(tool, origin) {
10556
10903
  const reading = tool.read?.({ w: here(), origin, base: `${origin}/v1` });
10557
10904
  return reading?.file ?? "";
@@ -10581,6 +10928,7 @@ function help() {
10581
10928
  status What's connected, each tool's model and spend this month
10582
10929
  model [id] Switch every connected tool to another model
10583
10930
  doctor [--fix] Check settings, keys and a real request; --fix repairs settings
10931
+ sync Update every tool with FullsendAI's current models, abilities and prices
10584
10932
  disconnect Put each tool's settings back and revoke its key
10585
10933
  key <tool> Copy a tool's key to the clipboard (--show prints it)
10586
10934
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullsendai",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Connect Claude Code, Codex, OpenCode, Aider, Continue and more to FullsendAI in one command.",
5
5
  "keywords": ["fullsendai", "claude-code", "codex", "opencode", "aider", "continue", "cline", "cursor", "llm", "cli"],
6
6
  "homepage": "https://fullsendai.com/docs/agents",