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.
- package/README.md +19 -5
- package/dist/cli.mjs +416 -68
- 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,
|
|
10
|
-
|
|
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
|
|
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 #
|
|
34
|
-
npx fullsendai 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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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" ? [""
|
|
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 =
|
|
9767
|
-
|
|
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
|
-
|
|
9823
|
-
|
|
9824
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
10257
|
-
|
|
10258
|
-
|
|
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
|
|
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
|
|
10303
|
-
if (!
|
|
10304
|
-
const
|
|
10305
|
-
|
|
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:")}
|
|
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
|
-
|
|
10443
|
-
|
|
10444
|
-
id =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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",
|