openmausbot 0.1.60 → 0.1.62
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 +71 -8
- package/dist/assets/index-BSI5e_fY.js +264 -0
- package/dist/assets/{index-BIzf_lw1.js → index-DTeYtYoC.js} +1 -1
- package/dist/index.html +1 -1
- package/dist-server/container-mcp.js +16 -1
- package/dist-server/drivers/agents-proxy.js +8 -4
- package/dist-server/index.js +463 -134
- package/dist-server/openmausbot.js +33585 -14506
- package/dist-server/pair-cli.js +33585 -14506
- package/dist-server/server/browser-bundle-release.js +56 -0
- package/dist-server/server/browser-engine-release.js +13 -5
- package/dist-server/server/browser-engine.js +114 -15
- package/dist-server/server/cli-api-setup.js +144 -0
- package/dist-server/server/cli-phone-setup.js +130 -0
- package/dist-server/server/cli-prompts.js +185 -0
- package/dist-server/server/cli-setup.js +303 -0
- package/dist-server/server/cli.js +375 -70
- package/dist-server/server/config.js +25 -1
- package/dist-server/server/default-model-selection.js +21 -0
- package/dist-server/server/drivers/agents-proxy.js +13 -4
- package/dist-server/server/drivers/claude.js +26 -1
- package/dist-server/server/drivers/openai-compat.js +4 -2
- package/dist-server/server/index.js +117 -80
- package/dist-server/server/recall-disclosure.js +45 -0
- package/dist-server/server/request-auth.js +6 -0
- package/dist-server/server/screen-frame-gate.js +36 -1
- package/dist-server/server/screen-frame-source.js +57 -0
- package/dist-server/server/sessions.js +52 -4
- package/dist-server/vps-container-mcp.js +16 -1
- package/package.json +1 -1
- package/dist/assets/index-Tha1umgl.js +0 -264
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { confirm as clackConfirm, isCancel, password, select, text } from "@clack/prompts";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { PassThrough } from "node:stream";
|
|
4
|
+
import { stripVTControlCharacters } from "node:util";
|
|
5
|
+
export class SetupCancelled extends Error {
|
|
6
|
+
constructor() {
|
|
7
|
+
super("Setup cancelled.");
|
|
8
|
+
this.name = "SetupCancelled";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function displayText(value, multiline = false) {
|
|
12
|
+
const plain = stripVTControlCharacters(value);
|
|
13
|
+
// Provider-supplied labels must not issue terminal control commands.
|
|
14
|
+
// eslint-disable-next-line no-control-regex
|
|
15
|
+
return plain.replace(/[\u0000-\u001f\u007f-\u009f]/g, (character) => character === "\n" && multiline ? "\n" : " ");
|
|
16
|
+
}
|
|
17
|
+
/** A line-based fallback with no cursor/color output. Readline has no output
|
|
18
|
+
* stream for secrets, so even pasted input cannot be echoed. */
|
|
19
|
+
function plainText(question, hidden, context) {
|
|
20
|
+
const { input, output, signal } = context;
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
input.setRawMode?.(hidden);
|
|
23
|
+
const rl = createInterface({ input, terminal: hidden });
|
|
24
|
+
let finished = false;
|
|
25
|
+
const finish = (answer, error) => {
|
|
26
|
+
if (finished)
|
|
27
|
+
return;
|
|
28
|
+
finished = true;
|
|
29
|
+
signal.removeEventListener("abort", cancel);
|
|
30
|
+
rl.close();
|
|
31
|
+
output.write("\n");
|
|
32
|
+
if (error)
|
|
33
|
+
reject(error);
|
|
34
|
+
else
|
|
35
|
+
resolve(answer ?? "");
|
|
36
|
+
};
|
|
37
|
+
const cancel = () => finish(undefined, signal.reason ?? new SetupCancelled());
|
|
38
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
39
|
+
rl.once("line", (answer) => finish(answer));
|
|
40
|
+
rl.once("close", () => finish(undefined, new SetupCancelled()));
|
|
41
|
+
output.write(question);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** Streams are injectable; fixtures never read the user's real terminal. */
|
|
45
|
+
export function defaultSetupIo(input = process.stdin, output = process.stdout) {
|
|
46
|
+
const log = (line) => { output.write(`${displayText(line, true)}\n`); };
|
|
47
|
+
const rich = () => output.isTTY === true && process.env.TERM !== "dumb" && process.env.NO_COLOR === undefined
|
|
48
|
+
&& (output.columns ?? 80) >= 30 && (output.rows ?? 24) >= 8;
|
|
49
|
+
const run = (prompt) => {
|
|
50
|
+
if (!input.isTTY || !input.setRawMode)
|
|
51
|
+
throw new Error("Setup needs an interactive terminal.");
|
|
52
|
+
const raw = input.isRaw === true;
|
|
53
|
+
const flowing = input.readableFlowing === true;
|
|
54
|
+
const controller = new AbortController();
|
|
55
|
+
// Clack owns this prompt's readline/key decoder, not the shared stdin.
|
|
56
|
+
// Discarding this stream also discards unfinished escape/paste sequences.
|
|
57
|
+
const promptInput = Object.assign(new PassThrough(), {
|
|
58
|
+
isTTY: true,
|
|
59
|
+
setRawMode: (mode) => input.setRawMode(mode),
|
|
60
|
+
});
|
|
61
|
+
let failure;
|
|
62
|
+
let settled = false;
|
|
63
|
+
const fail = (error) => {
|
|
64
|
+
failure ??= error;
|
|
65
|
+
controller.abort(failure);
|
|
66
|
+
};
|
|
67
|
+
const cancel = () => fail(new SetupCancelled());
|
|
68
|
+
const forward = (chunk) => {
|
|
69
|
+
// Ctrl-D and cancellation during an incomplete escape must not wait
|
|
70
|
+
// for a key decoder. No prompt input is ever copied to its output.
|
|
71
|
+
const interrupted = typeof chunk === "string"
|
|
72
|
+
? chunk.includes("\u0003") || chunk.includes("\u0004")
|
|
73
|
+
: chunk.includes(3) || chunk.includes(4);
|
|
74
|
+
if (interrupted)
|
|
75
|
+
cancel();
|
|
76
|
+
else if (!controller.signal.aborted)
|
|
77
|
+
promptInput.write(chunk);
|
|
78
|
+
};
|
|
79
|
+
const restore = () => {
|
|
80
|
+
input.setRawMode(raw);
|
|
81
|
+
if (flowing)
|
|
82
|
+
input.resume();
|
|
83
|
+
else
|
|
84
|
+
input.pause();
|
|
85
|
+
};
|
|
86
|
+
return (async () => {
|
|
87
|
+
input.on("data", forward);
|
|
88
|
+
input.once("end", cancel);
|
|
89
|
+
input.once("close", cancel);
|
|
90
|
+
input.once("error", fail);
|
|
91
|
+
output.once("error", fail);
|
|
92
|
+
process.once("SIGINT", cancel);
|
|
93
|
+
process.once("SIGTERM", cancel);
|
|
94
|
+
process.once("exit", restore);
|
|
95
|
+
try {
|
|
96
|
+
if (input.readableEnded || input.destroyed)
|
|
97
|
+
throw new SetupCancelled();
|
|
98
|
+
const pending = prompt({ input: promptInput, output, signal: controller.signal });
|
|
99
|
+
input.resume();
|
|
100
|
+
const value = await pending;
|
|
101
|
+
settled = true;
|
|
102
|
+
if (failure)
|
|
103
|
+
throw failure;
|
|
104
|
+
if (isCancel(value))
|
|
105
|
+
throw new SetupCancelled();
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (!settled)
|
|
110
|
+
controller.abort(error);
|
|
111
|
+
throw failure ?? error;
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
input.removeListener("data", forward);
|
|
115
|
+
input.removeListener("end", cancel);
|
|
116
|
+
input.removeListener("close", cancel);
|
|
117
|
+
input.removeListener("error", fail);
|
|
118
|
+
output.removeListener("error", fail);
|
|
119
|
+
process.removeListener("SIGINT", cancel);
|
|
120
|
+
process.removeListener("SIGTERM", cancel);
|
|
121
|
+
process.removeListener("exit", restore);
|
|
122
|
+
promptInput.destroy();
|
|
123
|
+
restore();
|
|
124
|
+
}
|
|
125
|
+
})();
|
|
126
|
+
};
|
|
127
|
+
const ask = (question) => run((context) => rich()
|
|
128
|
+
? text({ ...context, message: displayText(question) })
|
|
129
|
+
: plainText(displayText(question), false, context));
|
|
130
|
+
const secret = (question) => run((context) => rich()
|
|
131
|
+
? password({ ...context, message: displayText(question), mask: "*" })
|
|
132
|
+
: plainText(displayText(question), true, context));
|
|
133
|
+
const choose = async (question, options, defaultIndex = 0) => {
|
|
134
|
+
if (!options.length)
|
|
135
|
+
throw new Error("There are no choices available.");
|
|
136
|
+
if (!Number.isInteger(defaultIndex) || defaultIndex < 0 || defaultIndex >= options.length) {
|
|
137
|
+
throw new Error("The default choice is not available.");
|
|
138
|
+
}
|
|
139
|
+
if (rich())
|
|
140
|
+
return run((context) => select({
|
|
141
|
+
...context,
|
|
142
|
+
message: displayText(question),
|
|
143
|
+
options: options.map((label, value) => ({ value, label: displayText(label) })),
|
|
144
|
+
initialValue: defaultIndex,
|
|
145
|
+
maxItems: 7,
|
|
146
|
+
}));
|
|
147
|
+
const pageSize = 20;
|
|
148
|
+
let start = Math.floor(defaultIndex / pageSize) * pageSize;
|
|
149
|
+
for (;;) {
|
|
150
|
+
log(question);
|
|
151
|
+
options.slice(start, start + pageSize).forEach((label, offset) => {
|
|
152
|
+
const index = start + offset;
|
|
153
|
+
log(` ${index + 1}. ${label}${index === defaultIndex ? " (default)" : ""}`);
|
|
154
|
+
});
|
|
155
|
+
const paged = options.length > pageSize;
|
|
156
|
+
if (paged)
|
|
157
|
+
log(`Showing ${start + 1}–${Math.min(start + pageSize, options.length)} of ${options.length}. Type n/p for next/previous page.`);
|
|
158
|
+
const answer = (await ask(`Choose 1–${options.length} [${defaultIndex + 1}]: `)).trim();
|
|
159
|
+
if (!answer)
|
|
160
|
+
return defaultIndex;
|
|
161
|
+
if (paged && /^[np]$/i.test(answer)) {
|
|
162
|
+
start = Math.max(0, Math.min(Math.floor((options.length - 1) / pageSize) * pageSize, start + (answer.toLowerCase() === "n" ? pageSize : -pageSize)));
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (/^\d+$/.test(answer) && Number(answer) >= 1 && Number(answer) <= options.length)
|
|
166
|
+
return Number(answer) - 1;
|
|
167
|
+
log(`Enter a number from 1 to ${options.length}.`);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
const confirm = async (question, defaultYes = false) => {
|
|
171
|
+
if (rich())
|
|
172
|
+
return run((context) => clackConfirm({ ...context, message: displayText(question), initialValue: defaultYes }));
|
|
173
|
+
for (;;) {
|
|
174
|
+
const answer = (await ask(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"}: `)).trim();
|
|
175
|
+
if (!answer)
|
|
176
|
+
return defaultYes;
|
|
177
|
+
if (/^y(es)?$/i.test(answer))
|
|
178
|
+
return true;
|
|
179
|
+
if (/^n(o)?$/i.test(answer))
|
|
180
|
+
return false;
|
|
181
|
+
log("Enter yes or no.");
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
return { log, ask, secret, choose, confirm };
|
|
185
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// Guided setup stays outside server/index: no server, store, or bot is started
|
|
2
|
+
// until the user has saved a connection and explicitly starts the application.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { DATA_DIR, instanceConfigs, loadConfig, parseStoredConfig, saveConfig, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, } from "./config.js";
|
|
8
|
+
import { acquireDataDirLease } from "./data-dir-lease.js";
|
|
9
|
+
import { augmentedPath, resetPathCache } from "./env-path.js";
|
|
10
|
+
import { resolveCli } from "./procs.js";
|
|
11
|
+
import { ProviderRegistry } from "./harness/registry.js";
|
|
12
|
+
import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
|
|
13
|
+
import { defaultSetupIo, SetupCancelled } from "./cli-prompts.js";
|
|
14
|
+
import { API_ENDPOINTS, fetchSetupModels, normalizeApiUrl, verifySetupCompletion } from "./cli-api-setup.js";
|
|
15
|
+
async function inspect(id, entry) {
|
|
16
|
+
const registry = new ProviderRegistry(BUILT_IN_DRIVERS);
|
|
17
|
+
try {
|
|
18
|
+
await registry.load({ [id]: entry });
|
|
19
|
+
const provider = registry.get(id);
|
|
20
|
+
if (!provider)
|
|
21
|
+
throw new Error("This provider configuration could not be loaded. Check it in app Settings.");
|
|
22
|
+
const snapshot = await provider.snapshot();
|
|
23
|
+
return { snapshot, models: provider.models };
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
await registry.disposeAll();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Native CLIs own their OAuth flow; no tokens pass through our prompts. */
|
|
30
|
+
export async function runSetupCli(cli, args, environment = {}) {
|
|
31
|
+
const env = { ...process.env, ...environment, PATH: augmentedPath() };
|
|
32
|
+
stripWorkspaceCredentialEnv(env);
|
|
33
|
+
for (const key of PROVIDER_CREDENTIAL_ENV)
|
|
34
|
+
delete env[key];
|
|
35
|
+
delete env.CLAUDECODE;
|
|
36
|
+
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
37
|
+
const command = resolveCli(cli, args);
|
|
38
|
+
await new Promise((done, reject) => {
|
|
39
|
+
const child = spawn(command.command, command.args, { env, stdio: "inherit" });
|
|
40
|
+
child.once("error", () => reject(new Error(`Could not start ${cli}. Check that it is installed and on PATH.`)));
|
|
41
|
+
child.once("exit", (code, signal) => {
|
|
42
|
+
if (code === 0)
|
|
43
|
+
done();
|
|
44
|
+
else if (signal === "SIGINT" || code === 130)
|
|
45
|
+
reject(new SetupCancelled());
|
|
46
|
+
else
|
|
47
|
+
reject(new Error(`${cli} did not finish successfully. Fix the error shown above, then try again.`));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
resetPathCache();
|
|
51
|
+
}
|
|
52
|
+
const dependencies = { inspect, runCli: runSetupCli, models: fetchSetupModels, verify: verifySetupCompletion };
|
|
53
|
+
function assertDataDir(dataDir) {
|
|
54
|
+
if (resolve(dataDir) !== resolve(DATA_DIR))
|
|
55
|
+
throw new Error("Setup data directory mismatch. Restart the CLI with --data-dir.");
|
|
56
|
+
}
|
|
57
|
+
// loadConfig deliberately tolerates broken files at server boot. An onboarding
|
|
58
|
+
// write must instead refuse a broken existing file, never replace its contents.
|
|
59
|
+
function checkStoredConfig(dataDir) {
|
|
60
|
+
try {
|
|
61
|
+
parseStoredConfig(JSON.parse(readFileSync(join(dataDir, "config.json"), "utf8")));
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error.code === "ENOENT")
|
|
65
|
+
return;
|
|
66
|
+
throw new Error("Existing config.json could not be read or validated. Setup has not changed it; repair it before continuing.");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export async function isSetupComplete(dataDir) {
|
|
70
|
+
assertDataDir(dataDir);
|
|
71
|
+
checkStoredConfig(dataDir);
|
|
72
|
+
const cfg = loadConfig();
|
|
73
|
+
const saved = cfg.defaultModelSelection;
|
|
74
|
+
const instances = instanceConfigs(cfg);
|
|
75
|
+
return !!(saved && Object.hasOwn(instances, saved.instanceId) && instances[saved.instanceId]?.enabled !== false);
|
|
76
|
+
}
|
|
77
|
+
export function readCliStartup(dataDir) {
|
|
78
|
+
assertDataDir(dataDir);
|
|
79
|
+
checkStoredConfig(dataDir);
|
|
80
|
+
return loadConfig().cliStartup;
|
|
81
|
+
}
|
|
82
|
+
export function saveCliStartup(dataDir, settings) {
|
|
83
|
+
assertDataDir(dataDir);
|
|
84
|
+
const lease = acquireDataDirLease(dataDir);
|
|
85
|
+
try {
|
|
86
|
+
checkStoredConfig(dataDir);
|
|
87
|
+
saveConfig({ cliStartup: settings });
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
lease.release();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function rawObject(value) {
|
|
94
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
95
|
+
}
|
|
96
|
+
async function chooseModel(io, models) {
|
|
97
|
+
if (!models.options.length)
|
|
98
|
+
throw new Error("This connection returned no models. Check the provider and try again.");
|
|
99
|
+
let query = "";
|
|
100
|
+
for (;;) {
|
|
101
|
+
const matches = models.options.filter((m) => `${m.label} ${m.id}`.toLowerCase().includes(query.toLowerCase()));
|
|
102
|
+
const preferred = matches.find((m) => m.id === models.default);
|
|
103
|
+
const visible = (preferred ? [preferred, ...matches.filter((m) => m !== preferred)] : matches).slice(0, 20);
|
|
104
|
+
const defaultIndex = visible.findIndex((m) => m.id === models.default);
|
|
105
|
+
const selected = await io.choose("Choose your model", [
|
|
106
|
+
...visible.map((m) => m.label.trim() || m.id),
|
|
107
|
+
"Search models…",
|
|
108
|
+
], defaultIndex < 0 ? undefined : defaultIndex);
|
|
109
|
+
if (selected < visible.length)
|
|
110
|
+
return visible[selected].id;
|
|
111
|
+
query = (await io.ask("Model name or part of its ID (Enter shows all): ")).trim();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const NATIVE = [
|
|
115
|
+
{ id: "codex", driver: "codex", label: "ChatGPT / Codex — sign in with your account", cli: "codex", pkg: "@openai/codex" },
|
|
116
|
+
{ id: "claude", driver: "claudeAgent", label: "Claude Code — sign in with your account", cli: "claude", pkg: "@anthropic-ai/claude-code" },
|
|
117
|
+
];
|
|
118
|
+
async function connectNative(choice, id, entry, io, deps) {
|
|
119
|
+
const cli = typeof rawObject(entry.config).cli === "string" ? rawObject(entry.config).cli : choice.cli;
|
|
120
|
+
io.log("Connecting your account…");
|
|
121
|
+
io.log("Sign-in stays with your provider. Its account limits apply; OMB never asks for your password.");
|
|
122
|
+
let state = await deps.inspect(id, entry);
|
|
123
|
+
if (state.snapshot.state !== "available") {
|
|
124
|
+
if (cli !== choice.cli)
|
|
125
|
+
throw new Error("Your custom CLI path is unavailable. Fix that path in Settings before running setup again.");
|
|
126
|
+
if (!await io.confirm(`Install ${choice.cli} with npm install -g ${choice.pkg}?`, true))
|
|
127
|
+
throw new SetupCancelled();
|
|
128
|
+
await deps.runCli("npm", ["install", "-g", choice.pkg]);
|
|
129
|
+
state = await deps.inspect(id, entry);
|
|
130
|
+
if (state.snapshot.state !== "available")
|
|
131
|
+
throw new Error(`${choice.cli} is still unavailable. Check the installation output and try again.`);
|
|
132
|
+
}
|
|
133
|
+
if (!state.snapshot.authenticated) {
|
|
134
|
+
let args = ["auth", "login"];
|
|
135
|
+
if (choice.id === "codex") {
|
|
136
|
+
const method = await io.choose("How would you like to sign in?", [
|
|
137
|
+
"Open a browser on this computer", "Use a device code (SSH / remote server)",
|
|
138
|
+
], process.env.SSH_CONNECTION ? 1 : 0);
|
|
139
|
+
args = method === 1 ? ["login", "--device-auth"] : ["login"];
|
|
140
|
+
}
|
|
141
|
+
if (args.includes("--device-auth"))
|
|
142
|
+
io.log("Enable device-code login in ChatGPT security settings if your account requests it.");
|
|
143
|
+
await deps.runCli(cli, args, entry.environment);
|
|
144
|
+
state = await deps.inspect(id, entry);
|
|
145
|
+
if (state.snapshot.state !== "available" || !state.snapshot.authenticated) {
|
|
146
|
+
throw new Error("Sign-in was not confirmed. Your OMB settings are unchanged; complete provider sign-in and try again.");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
io.log("Existing sign-in found — you do not need to sign in again.");
|
|
151
|
+
}
|
|
152
|
+
if (state.snapshot.update)
|
|
153
|
+
io.log(`${state.snapshot.update.title}: ${state.snapshot.update.command}`);
|
|
154
|
+
io.log("Sign-in confirmed. Model access is checked by the provider when you send your first message.");
|
|
155
|
+
return state.models;
|
|
156
|
+
}
|
|
157
|
+
export async function runSetup(options, io = defaultSetupIo(), deps = dependencies) {
|
|
158
|
+
assertDataDir(options.dataDir);
|
|
159
|
+
const lease = acquireDataDirLease(options.dataDir);
|
|
160
|
+
try {
|
|
161
|
+
checkStoredConfig(options.dataDir);
|
|
162
|
+
const cfg = loadConfig();
|
|
163
|
+
const runtime = instanceConfigs(cfg);
|
|
164
|
+
const existing = Object.entries(runtime).filter(([id, entry]) => !!cfg.instances?.[id] && ["codex", "claudeAgent", "openai-compat"].includes(entry.driver) && entry.enabled !== false);
|
|
165
|
+
io.log("\nWelcome to OpenMausBot\n");
|
|
166
|
+
io.log("Let's connect your AI. Choose a provider, then a model.");
|
|
167
|
+
io.log("Existing bots and conversations stay untouched. Ctrl-C cancels.");
|
|
168
|
+
io.log("You can add integrations and change settings later.\n");
|
|
169
|
+
const existingDefault = existing.findIndex(([id]) => id === cfg.defaultModelSelection?.instanceId);
|
|
170
|
+
const providerOptions = [
|
|
171
|
+
...NATIVE.map((n) => n.label),
|
|
172
|
+
"API key — OpenAI, OpenRouter, Groq or a compatible service (chat only)",
|
|
173
|
+
...existing.map(([id, entry]) => `Use existing: ${entry.displayName ?? id}${id === cfg.defaultModelSelection?.instanceId ? " — current" : ""}`),
|
|
174
|
+
];
|
|
175
|
+
let pick;
|
|
176
|
+
let id;
|
|
177
|
+
let entry;
|
|
178
|
+
let model;
|
|
179
|
+
for (;;) {
|
|
180
|
+
pick ??= await io.choose("Choose your AI connection", providerOptions, existingDefault < 0 ? 0 : existingDefault + 3);
|
|
181
|
+
const prior = pick >= 3 ? existing[pick - 3] : undefined;
|
|
182
|
+
const native = pick < 2 ? NATIVE[pick] : NATIVE.find((n) => n.driver === prior?.[1].driver);
|
|
183
|
+
try {
|
|
184
|
+
if (native) {
|
|
185
|
+
// Reuse native provider settings, including custom CLI paths. If a user
|
|
186
|
+
// repurposed the familiar ID, do not overwrite their connection.
|
|
187
|
+
id = prior?.[0] ?? native.id;
|
|
188
|
+
if (!prior && runtime[id] && runtime[id].driver !== native.driver)
|
|
189
|
+
id = `${native.id}-${randomUUID().slice(0, 8)}`;
|
|
190
|
+
entry = { ...(cfg.instances?.[id] ?? { driver: native.driver }), enabled: true };
|
|
191
|
+
const models = await connectNative(native, id, entry, io, deps);
|
|
192
|
+
const saved = cfg.defaultModelSelection;
|
|
193
|
+
model = await chooseModel(io, {
|
|
194
|
+
...models,
|
|
195
|
+
default: saved?.instanceId === id ? saved.model : models.default,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
io.log("Connect an API key");
|
|
200
|
+
io.log("API usage is billed separately from ChatGPT/Claude subscriptions.");
|
|
201
|
+
io.log("This connection supports chat, not agent tools or computer use. Choose Codex or Claude for those.");
|
|
202
|
+
let url;
|
|
203
|
+
let key;
|
|
204
|
+
let label;
|
|
205
|
+
let reuse = false;
|
|
206
|
+
let routingProvider;
|
|
207
|
+
if (prior) {
|
|
208
|
+
const config = rawObject(prior[1].config);
|
|
209
|
+
const decoded = BUILT_IN_DRIVERS.find((d) => d.driverKind === "openai-compat").decodeConfig(config);
|
|
210
|
+
url = normalizeApiUrl(decoded.url);
|
|
211
|
+
key = decoded.key ?? prior[1].environment?.[decoded.apiKeyEnv]
|
|
212
|
+
?? prior[1].environment?.OPENAI_COMPAT_API_KEY
|
|
213
|
+
?? process.env[decoded.apiKeyEnv] ?? process.env.OPENAI_COMPAT_API_KEY ?? "";
|
|
214
|
+
routingProvider = decoded.provider;
|
|
215
|
+
label = prior[1].displayName ?? prior[0];
|
|
216
|
+
reuse = !!key;
|
|
217
|
+
if (!key)
|
|
218
|
+
key = (await io.secret(`API key for ${url} (hidden): `)).trim();
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
const endpoint = await io.choose("Which API service?", [...API_ENDPOINTS.map((e) => e.label), "Other OpenAI-compatible endpoint"]);
|
|
222
|
+
const preset = API_ENDPOINTS[endpoint];
|
|
223
|
+
url = preset ? preset.url : normalizeApiUrl((await io.ask("API base URL (including /v1): ")).trim());
|
|
224
|
+
label = preset?.label ?? new URL(url).hostname;
|
|
225
|
+
if (preset)
|
|
226
|
+
io.log(`Create a key: ${preset.keyUrl}`);
|
|
227
|
+
key = (await io.secret(`API key for ${url} (hidden): `)).trim();
|
|
228
|
+
}
|
|
229
|
+
if (!key)
|
|
230
|
+
throw new Error("An API key is required. Nothing was saved.");
|
|
231
|
+
io.log("Checking the model catalog…");
|
|
232
|
+
let models;
|
|
233
|
+
try {
|
|
234
|
+
models = await deps.models(url, key);
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
if (error instanceof SetupCancelled)
|
|
238
|
+
throw error;
|
|
239
|
+
io.log(error instanceof Error ? error.message : "Could not load this model catalog.");
|
|
240
|
+
if (!await io.confirm("Enter an exact chat model ID and check it directly instead?", false))
|
|
241
|
+
throw error;
|
|
242
|
+
const manual = (await io.ask("Chat model ID: ")).trim();
|
|
243
|
+
if (!manual)
|
|
244
|
+
throw new Error("A model ID is required. Nothing was saved.");
|
|
245
|
+
models = [{ id: manual, label: manual }];
|
|
246
|
+
}
|
|
247
|
+
const previousModel = prior ? rawObject(prior[1].config).model : undefined;
|
|
248
|
+
const preferredModel = cfg.defaultModelSelection?.instanceId === prior?.[0]
|
|
249
|
+
? cfg.defaultModelSelection?.model : previousModel;
|
|
250
|
+
io.log("Choose a chat model; image, audio and embedding-only models cannot reply here.");
|
|
251
|
+
model = await chooseModel(io, { default: typeof preferredModel === "string" ? preferredModel : "", options: models });
|
|
252
|
+
if (!await io.confirm("Send one short test message? Your API provider may charge for this request.", true))
|
|
253
|
+
throw new SetupCancelled();
|
|
254
|
+
if (routingProvider)
|
|
255
|
+
await deps.verify(url, key, model, routingProvider);
|
|
256
|
+
else
|
|
257
|
+
await deps.verify(url, key, model);
|
|
258
|
+
io.log("Test reply received.");
|
|
259
|
+
// API additions never change the URL/key behind an existing bot. A new
|
|
260
|
+
// isolated instance also avoids inherited global URL/key overrides.
|
|
261
|
+
id = reuse && prior ? prior[0] : `api-${randomUUID().slice(0, 8)}`;
|
|
262
|
+
entry = reuse && prior
|
|
263
|
+
? { ...cfg.instances[id], config: { ...rawObject(cfg.instances[id].config), model } }
|
|
264
|
+
: { driver: "openai-compat", displayName: label, config: { url, key, model, provider: routingProvider ?? "" } };
|
|
265
|
+
if (!reuse)
|
|
266
|
+
io.log("The key will be stored in your private config.json (plaintext, owner-only permissions on Unix). Never share this file.");
|
|
267
|
+
}
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
if (error instanceof SetupCancelled)
|
|
272
|
+
throw error;
|
|
273
|
+
io.log(error instanceof Error ? error.message : "This connection could not be set up.");
|
|
274
|
+
io.log("Your saved connection and model have not changed.");
|
|
275
|
+
const recovery = await io.choose("What would you like to do?", [
|
|
276
|
+
"Try this connection again", "Choose another connection or API key", "Cancel setup",
|
|
277
|
+
], 0);
|
|
278
|
+
if (recovery === 2)
|
|
279
|
+
throw new SetupCancelled();
|
|
280
|
+
if (recovery === 1)
|
|
281
|
+
pick = undefined;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
io.log(`\nDefault for new bots: ${entry.displayName ?? id} / ${model}`);
|
|
285
|
+
if (!await io.confirm("Save this setup?", true))
|
|
286
|
+
throw new SetupCancelled();
|
|
287
|
+
// An explicit fleet replaces the implicit fleet. Start from an EMPTY
|
|
288
|
+
// config's defaults, not instanceConfigs(cfg), whose env contains secrets.
|
|
289
|
+
const instances = { ...(cfg.instances && Object.keys(cfg.instances).length ? cfg.instances : instanceConfigs({})), [id]: entry };
|
|
290
|
+
saveConfig({ instances, defaultModelSelection: { instanceId: id, model } });
|
|
291
|
+
io.log("\nSetup saved. Existing bots and conversations were not changed.");
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
if (!(error instanceof SetupCancelled))
|
|
296
|
+
throw error;
|
|
297
|
+
io.log("\nSetup cancelled. No OMB settings were changed. Provider sign-ins or installs already completed are kept.");
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
lease.release();
|
|
302
|
+
}
|
|
303
|
+
}
|