@ryuu-reinzz/ryuu-cli 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +592 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1269 -2
  4. package/package.json +45 -4
  5. package/src/agent/agent.js +1194 -0
  6. package/src/agent/approvals.js +76 -0
  7. package/src/agent/context.js +106 -0
  8. package/src/agent/planner.js +237 -0
  9. package/src/agent/worklog.js +318 -0
  10. package/src/cli/app.js +471 -0
  11. package/src/cli/commands.js +1155 -0
  12. package/src/cli/input.js +287 -0
  13. package/src/cli/markdown.js +527 -0
  14. package/src/cli/prompts.js +255 -0
  15. package/src/cli/renderer.js +2155 -0
  16. package/src/cli/settings-editor.js +280 -0
  17. package/src/cli/settings-tui.js +669 -0
  18. package/src/cli/tui.js +1737 -0
  19. package/src/cli/width.js +48 -0
  20. package/src/config/load.js +137 -0
  21. package/src/config/scaffold.js +50 -0
  22. package/src/config/settings-registry.js +495 -0
  23. package/src/config/settings.js +304 -0
  24. package/src/config/validate.js +461 -0
  25. package/src/index.js +449 -0
  26. package/src/providers/catalog.js +407 -0
  27. package/src/providers/ids.js +24 -0
  28. package/src/providers/index.js +103 -0
  29. package/src/providers/nvidia.js +671 -0
  30. package/src/providers/registry.js +104 -0
  31. package/src/providers/river.js +198 -0
  32. package/src/providers/tokenharbor.js +38 -0
  33. package/src/providers/vendor-extras.js +82 -0
  34. package/src/services/clipboard.js +38 -0
  35. package/src/services/diff.js +153 -0
  36. package/src/services/doctor.js +126 -0
  37. package/src/services/logger.js +190 -0
  38. package/src/services/preferences.js +249 -0
  39. package/src/services/processes.js +345 -0
  40. package/src/services/provider-settings.js +226 -0
  41. package/src/services/sessions.js +279 -0
  42. package/src/services/undo.js +133 -0
  43. package/src/services/ushell.js +146 -0
  44. package/src/services/websearch/manager.js +616 -0
  45. package/src/services/websearch/mcp.js +233 -0
  46. package/src/services/websearch/normalize.js +284 -0
  47. package/src/services/websearch/providers.js +178 -0
  48. package/src/services/websearch/serper.js +263 -0
  49. package/src/services/workspace.js +589 -0
  50. package/src/tools/filesystem.js +304 -0
  51. package/src/tools/git.js +114 -0
  52. package/src/tools/index.js +48 -0
  53. package/src/tools/project.js +160 -0
  54. package/src/tools/registry.js +118 -0
  55. package/src/tools/search.js +210 -0
  56. package/src/tools/shell.js +279 -0
  57. package/src/tools/url.js +542 -0
  58. package/src/tools/web.js +67 -0
  59. package/src/utils/errors.js +160 -0
  60. package/src/utils/network.js +129 -0
  61. package/src/utils/redaction.js +73 -0
  62. package/src/utils/validation.js +370 -0
  63. package/src/utils/version.js +24 -0
@@ -0,0 +1,345 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ import { StringDecoder } from 'node:string_decoder';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { ProcessError } from '../utils/errors.js';
7
+ import { redact } from '../utils/redaction.js';
8
+
9
+ const SECRET_ENV_PATTERN = /(API_KEY|ACCESS_TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE_KEY|GITHUB_TOKEN|GH_TOKEN|NVIDIA_API_KEY)/i;
10
+ const POSIX = process.platform !== 'win32';
11
+
12
+ /** Running child processes, used by the last-resort exit cleanup. */
13
+ const ACTIVE = new Set();
14
+ let exitHookInstalled = false;
15
+
16
+ function installExitHook() {
17
+ if (exitHookInstalled) return;
18
+ exitHookInstalled = true;
19
+ process.once('exit', () => {
20
+ for (const record of ACTIVE) {
21
+ try {
22
+ if (POSIX && record.pid) process.kill(-record.pid, 'SIGKILL');
23
+ else if (record.pid) process.kill(record.pid, 'SIGKILL');
24
+ } catch {
25
+ // Already gone.
26
+ }
27
+ }
28
+ });
29
+ }
30
+
31
+ /** Strip credential-looking variables from a child environment. */
32
+ export function buildChildEnv(base = process.env, { allow = [], extra = {} } = {}) {
33
+ const allowSet = new Set(allow);
34
+ const env = {};
35
+ for (const [key, value] of Object.entries(base)) {
36
+ if (value === undefined) continue;
37
+ if (SECRET_ENV_PATTERN.test(key) && !allowSet.has(key)) continue;
38
+ env[key] = value;
39
+ }
40
+ env.RYUU_AGENT = '1';
41
+ return { ...env, ...extra };
42
+ }
43
+
44
+ function appendBounded(state, chunk, limit) {
45
+ const combined = state.text + chunk;
46
+ if (combined.length <= limit) {
47
+ state.text = combined;
48
+ return;
49
+ }
50
+ state.truncated = true;
51
+ const head = Math.max(0, Math.floor(limit * 0.4));
52
+ const tail = Math.max(0, limit - head);
53
+ state.text = `${combined.slice(0, head)}\n…[output truncated]…\n${combined.slice(combined.length - tail)}`;
54
+ }
55
+
56
+ /**
57
+ * Session-scoped process manager. Commands use spawn with an argument array
58
+ * and shell:false by default; shell syntax requires an explicit, separately
59
+ * approved tool path. Shell execution is not a sandbox.
60
+ */
61
+ export class ProcessManager extends EventEmitter {
62
+ constructor({ workspace, logger = null, maxBufferBytes = 65536, defaultTimeoutMs = 120000, maxProcesses = 8, logDir = null } = {}) {
63
+ super();
64
+ if (!workspace) throw new ProcessError('ProcessManager requires a workspace.');
65
+ this.workspace = workspace;
66
+ this.logger = logger;
67
+ this.maxBufferBytes = maxBufferBytes;
68
+ this.defaultTimeoutMs = defaultTimeoutMs;
69
+ this.maxProcesses = maxProcesses;
70
+ this.logDir = logDir;
71
+ this.records = new Map();
72
+ this.completed = [];
73
+ this.counter = 0;
74
+ installExitHook();
75
+ }
76
+
77
+ #nextId() {
78
+ this.counter += 1;
79
+ return `p${this.counter}`;
80
+ }
81
+
82
+ #runningCount() {
83
+ return [...this.records.values()].filter((record) => record.status === 'running').length;
84
+ }
85
+
86
+ /**
87
+ * @param {object} options
88
+ * @param {string[]} [options.argv] argument array (preferred, shell:false)
89
+ * @param {string} [options.shellCommand] raw shell string (requires allowShell)
90
+ * @param {string} [options.cwd] workspace-relative working directory
91
+ * @param {number} [options.timeoutMs] 0 disables the timeout
92
+ * @param {string} [options.label]
93
+ */
94
+ start({ argv, shellCommand = null, cwd = '.', timeoutMs, label, env = {}, allowShell = false } = {}) {
95
+ if (this.#runningCount() >= this.maxProcesses) {
96
+ throw new ProcessError(`Too many running processes (limit ${this.maxProcesses}). Stop one with stop_process.`);
97
+ }
98
+
99
+ const useShell = Boolean(shellCommand);
100
+ if (useShell && !allowShell) {
101
+ throw new ProcessError('Shell execution is disabled. Set agent.allowShell=true in config.json to permit an approved shell command.');
102
+ }
103
+ let command;
104
+ let args;
105
+ if (useShell) {
106
+ command = shellCommand;
107
+ args = [];
108
+ } else {
109
+ if (!Array.isArray(argv) || argv.length === 0) {
110
+ throw new ProcessError('argv must be a non-empty array of strings, e.g. ["npm", "test"].');
111
+ }
112
+ if (argv.length > 200) throw new ProcessError('Too many arguments (max 200).');
113
+ for (const part of argv) {
114
+ if (typeof part !== 'string' || part.length === 0) throw new ProcessError('Every argv entry must be a non-empty string.');
115
+ if (part.includes('\0')) throw new ProcessError('Arguments must not contain NUL bytes.');
116
+ if (part.length > 20000) throw new ProcessError('Argument is too long (max 20000 chars).');
117
+ }
118
+ [command, ...args] = argv;
119
+ }
120
+
121
+ const resolvedCwd = this.workspace.resolve(cwd || '.');
122
+ let stat;
123
+ try {
124
+ stat = fs.statSync(resolvedCwd.absPath);
125
+ } catch {
126
+ throw new ProcessError(`Working directory not found: ${resolvedCwd.relPath}`);
127
+ }
128
+ if (!stat.isDirectory()) throw new ProcessError(`Working directory is not a directory: ${resolvedCwd.relPath}`);
129
+
130
+ const id = this.#nextId();
131
+ const startedAt = Date.now();
132
+ const record = {
133
+ id,
134
+ pid: null,
135
+ label: label || (useShell ? command : argv.join(' ')).slice(0, 120),
136
+ argv: useShell ? [command] : argv,
137
+ shellCommand: useShell ? command : null,
138
+ cwd: resolvedCwd.relPath,
139
+ status: 'running',
140
+ startedAt,
141
+ startedAtIso: new Date(startedAt).toISOString(),
142
+ endedAt: null,
143
+ durationMs: null,
144
+ exitCode: null,
145
+ signal: null,
146
+ timedOut: false,
147
+ stopRequested: false,
148
+ error: null,
149
+ truncated: false,
150
+ stdout: { text: '', truncated: false },
151
+ stderr: { text: '', truncated: false },
152
+ };
153
+ record.done = new Promise((resolveDone) => {
154
+ record.resolveDone = resolveDone;
155
+ });
156
+ this.records.set(id, record);
157
+
158
+ const child = spawn(command, args, {
159
+ cwd: resolvedCwd.absPath,
160
+ shell: useShell,
161
+ detached: POSIX,
162
+ stdio: ['ignore', 'pipe', 'pipe'],
163
+ env: buildChildEnv(process.env, { extra: env }),
164
+ windowsHide: true,
165
+ });
166
+ record.pid = child.pid ?? null;
167
+ record.child = child;
168
+ if (POSIX && child.pid) ACTIVE.add(record);
169
+
170
+ const outDecoder = new StringDecoder('utf8');
171
+ const errDecoder = new StringDecoder('utf8');
172
+ child.stdout?.on('data', (chunk) => {
173
+ const text = outDecoder.write(chunk);
174
+ appendBounded(record.stdout, text, this.maxBufferBytes);
175
+ record.truncated = record.truncated || record.stdout.truncated;
176
+ this.emit('output', { id, stream: 'stdout', text, record });
177
+ });
178
+ child.stderr?.on('data', (chunk) => {
179
+ const text = errDecoder.write(chunk);
180
+ appendBounded(record.stderr, text, this.maxBufferBytes);
181
+ record.truncated = record.truncated || record.stderr.truncated;
182
+ this.emit('output', { id, stream: 'stderr', text, record });
183
+ });
184
+
185
+ let timeoutTimer = null;
186
+ const effectiveTimeout = timeoutMs === undefined ? this.defaultTimeoutMs : timeoutMs;
187
+ if (effectiveTimeout > 0) {
188
+ timeoutTimer = setTimeout(() => {
189
+ if (record.status !== 'running') return;
190
+ record.timedOut = true;
191
+ this.logger?.warn('process timeout, terminating', { id, timeoutMs: effectiveTimeout });
192
+ this.#kill(record, 'SIGTERM');
193
+ const force = setTimeout(() => this.#kill(record, 'SIGKILL'), 2000);
194
+ force.unref();
195
+ }, effectiveTimeout);
196
+ timeoutTimer.unref?.();
197
+ }
198
+
199
+ const finalize = (exitCode, signal, spawnError = null) => {
200
+ if (record.status !== 'running') return;
201
+ if (timeoutTimer) clearTimeout(timeoutTimer);
202
+ if (outDecoder.end) appendBounded(record.stdout, outDecoder.end() || '', this.maxBufferBytes);
203
+ if (errDecoder.end) appendBounded(record.stderr, errDecoder.end() || '', this.maxBufferBytes);
204
+ const endedAt = Date.now();
205
+ record.endedAt = endedAt;
206
+ record.endedAtIso = new Date(endedAt).toISOString();
207
+ record.durationMs = endedAt - startedAt;
208
+ record.exitCode = exitCode;
209
+ record.signal = signal;
210
+ record.error = spawnError ? spawnError.message : null;
211
+ if (spawnError) record.status = 'failed';
212
+ else if (record.timedOut) record.status = 'timeout';
213
+ else if (record.stopRequested) record.status = 'killed';
214
+ else record.status = exitCode === 0 ? 'exited' : 'failed';
215
+ delete record.child;
216
+ ACTIVE.delete(record);
217
+ this.completed.unshift(record);
218
+ if (this.completed.length > 100) this.completed.pop();
219
+ this.emit('exit', { record });
220
+ this.#persist(record);
221
+ record.resolveDone(record);
222
+ };
223
+
224
+ child.on('error', (error) => {
225
+ finalize(null, null, error);
226
+ });
227
+ child.on('close', (code, signal) => {
228
+ finalize(code, signal);
229
+ });
230
+
231
+ this.emit('started', { record });
232
+ this.logger?.info('process started', { id, pid: record.pid, argv: record.argv, cwd: record.cwd, shell: useShell });
233
+ return record;
234
+ }
235
+
236
+ #kill(record, signal) {
237
+ if (!record.pid || record.status !== 'running') return;
238
+ try {
239
+ if (POSIX) process.kill(-record.pid, signal);
240
+ else process.kill(record.pid, signal);
241
+ } catch (error) {
242
+ if (error.code !== 'ESRCH') this.logger?.warn('failed to signal process', { id: record.id, signal, error: error.message });
243
+ }
244
+ }
245
+
246
+ /** Stop a process: graceful SIGTERM, then SIGKILL after graceMs. */
247
+ async stop(id, { graceMs = 3000, signal = 'SIGTERM' } = {}) {
248
+ const record = this.records.get(id);
249
+ if (!record) throw new ProcessError(`Unknown process id: ${id}`, { id });
250
+ if (record.status !== 'running') return { id, stopped: false, status: record.status, exitCode: record.exitCode };
251
+ record.stopRequested = true;
252
+ this.#kill(record, signal);
253
+ const killed = await Promise.race([
254
+ record.done.then(() => true),
255
+ new Promise((resolve) => setTimeout(() => resolve(false), graceMs)),
256
+ ]);
257
+ if (!killed) {
258
+ this.#kill(record, 'SIGKILL');
259
+ await Promise.race([record.done, new Promise((resolve) => setTimeout(resolve, 1000))]);
260
+ }
261
+ return { id, stopped: true, status: record.status, exitCode: record.exitCode, signal: record.signal, durationMs: record.durationMs };
262
+ }
263
+
264
+ async stopAll({ graceMs = 2000 } = {}) {
265
+ const running = [...this.records.values()].filter((record) => record.status === 'running');
266
+ await Promise.allSettled(running.map((record) => this.stop(record.id, { graceMs })));
267
+ return running.length;
268
+ }
269
+
270
+ get(id) {
271
+ const record = this.records.get(id) || this.completed.find((item) => item.id === id);
272
+ if (!record) throw new ProcessError(`Unknown process id: ${id}`, { id });
273
+ return record;
274
+ }
275
+
276
+ getOutput(id, { stream = 'both', tailBytes = 8000 } = {}) {
277
+ const record = this.get(id);
278
+ const tail = (state) => {
279
+ if (state.text.length <= tailBytes) return state.text;
280
+ return `…[showing last ${tailBytes} chars]…\n${state.text.slice(state.text.length - tailBytes)}`;
281
+ };
282
+ const result = { id: record.id, status: record.status, exitCode: record.exitCode, signal: record.signal, durationMs: record.durationMs };
283
+ if (stream === 'stdout' || stream === 'both') result.stdout = tail(record.stdout);
284
+ if (stream === 'stderr' || stream === 'both') result.stderr = tail(record.stderr);
285
+ result.truncated = record.stdout.truncated || record.stderr.truncated;
286
+ return result;
287
+ }
288
+
289
+ list({ includeCompleted = true } = {}) {
290
+ const running = [...this.records.values()].filter((record) => record.status === 'running');
291
+ const rows = running.map(summarize);
292
+ if (includeCompleted) rows.push(...this.completed.slice(0, 20).map(summarize));
293
+ return rows;
294
+ }
295
+
296
+ #persist(record) {
297
+ if (!this.logDir) return;
298
+ try {
299
+ fs.mkdirSync(this.logDir, { recursive: true, mode: 0o700 });
300
+ const payload = {
301
+ id: record.id,
302
+ label: record.label,
303
+ argv: record.argv,
304
+ cwd: record.cwd,
305
+ status: record.status,
306
+ exitCode: record.exitCode,
307
+ signal: record.signal,
308
+ startedAt: record.startedAtIso,
309
+ endedAt: record.endedAtIso,
310
+ durationMs: record.durationMs,
311
+ truncated: record.truncated,
312
+ stdout: redact(record.stdout.text),
313
+ stderr: redact(record.stderr.text),
314
+ };
315
+ fs.writeFileSync(path.join(this.logDir, `process-${record.id}.log`), `${JSON.stringify(payload, null, 2)}\n`, {
316
+ mode: 0o600,
317
+ });
318
+ } catch {
319
+ // Persistence failures must not break execution.
320
+ }
321
+ }
322
+
323
+ /** All records with metadata, used for final summaries. */
324
+ history() {
325
+ return [...this.completed].reverse().map(summarize);
326
+ }
327
+ }
328
+
329
+ function summarize(record) {
330
+ return {
331
+ id: record.id,
332
+ pid: record.pid,
333
+ label: record.label,
334
+ argv: record.argv,
335
+ cwd: record.cwd,
336
+ status: record.status,
337
+ exitCode: record.exitCode,
338
+ signal: record.signal,
339
+ startedAt: record.startedAtIso,
340
+ endedAt: record.endedAtIso,
341
+ durationMs: record.durationMs,
342
+ timedOut: record.timedOut,
343
+ truncated: record.truncated,
344
+ };
345
+ }
@@ -0,0 +1,226 @@
1
+ import { getSetting, updateSettings } from '../config/settings.js';
2
+ import {
3
+ BUILTIN_PROVIDERS,
4
+ MAX_EXTRA_PROVIDERS,
5
+ MAX_MODELS_PER_PROVIDER,
6
+ normalizeModelId,
7
+ normalizeProviderId,
8
+ } from '../providers/catalog.js';
9
+
10
+ /**
11
+ * Provider and model management for config.json.
12
+ *
13
+ * `/provider add|del` and `/addmodel|/delmodel` edit the same `extraProviders`
14
+ * list the file already uses, so a provider added at runtime is indistinguishable
15
+ * from one written by hand and survives restarts. Built-in providers are
16
+ * read-only: only their added model ids (in .ryuu/preferences.json) are editable.
17
+ */
18
+
19
+ const BUILTIN_IDS = new Set(BUILTIN_PROVIDERS.map((provider) => provider.id));
20
+
21
+ export function isBuiltinProviderId(id) {
22
+ return BUILTIN_IDS.has(String(id ?? '').trim().toLowerCase());
23
+ }
24
+
25
+ /** The stored extraProviders array, never the module-level default. */
26
+ export function readExtraProviders(settings) {
27
+ const value = getSetting(settings, 'extraProviders');
28
+ return Array.isArray(value) ? value : [];
29
+ }
30
+
31
+ function modelIdsOf(entry) {
32
+ if (!Array.isArray(entry?.models)) return [];
33
+ return entry.models
34
+ .map((model) => normalizeModelId(typeof model === 'string' ? model : model?.id))
35
+ .filter(Boolean);
36
+ }
37
+
38
+ function normalizeBaseUrl(value) {
39
+ const text = String(value ?? '').trim();
40
+ if (!text) return null;
41
+ try {
42
+ const parsed = new URL(text);
43
+ if (!['http:', 'https:'].includes(parsed.protocol)) return null;
44
+ if (parsed.username || parsed.password) return null;
45
+ return parsed.toString().replace(/\/$/, '');
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Add a user provider. The id comes from the name, so `/provider add river …`
53
+ * creates `river` and stores its key under `providers.river.apiKey`.
54
+ * Returns { ok, reason } with reason 'builtin' | 'exists' | 'invalid' |
55
+ * 'url' | 'limit' | 'write'.
56
+ */
57
+ export async function addExtraProvider({ name, baseURL, apiKey = '', label = null }) {
58
+ const id = normalizeProviderId(name);
59
+ if (!id) return { ok: false, reason: 'invalid' };
60
+ if (BUILTIN_IDS.has(id)) return { ok: false, reason: 'builtin' };
61
+ const url = normalizeBaseUrl(baseURL);
62
+ if (!url) return { ok: false, reason: 'url' };
63
+ let reason = null;
64
+ let updated = false;
65
+ try {
66
+ await updateSettings((draft) => {
67
+ const entries = readExtraProviders(draft).filter((entry) => entry && typeof entry === 'object');
68
+ const existing = entries.find((entry) => normalizeProviderId(entry.id) === id);
69
+ if (existing) {
70
+ // Re-adding with a key is the documented way to update it.
71
+ if (!apiKey) {
72
+ reason = 'exists';
73
+ return draft;
74
+ }
75
+ draft.providers = draft.providers && typeof draft.providers === 'object' ? draft.providers : {};
76
+ const stored = draft.providers[id] && typeof draft.providers[id] === 'object' ? draft.providers[id] : {};
77
+ draft.providers[id] = { ...stored, apiKey: String(apiKey).trim() };
78
+ updated = true;
79
+ return draft;
80
+ }
81
+ if (entries.length >= MAX_EXTRA_PROVIDERS) {
82
+ reason = 'limit';
83
+ return draft;
84
+ }
85
+ entries.push({
86
+ id,
87
+ name: label || name,
88
+ baseURL: url,
89
+ models: [],
90
+ capabilities: { streaming: true, tools: true, reasoning: false, multimodal: false },
91
+ });
92
+ draft.extraProviders = entries;
93
+ if (apiKey) {
94
+ draft.providers = draft.providers && typeof draft.providers === 'object' ? draft.providers : {};
95
+ const stored = draft.providers[id] && typeof draft.providers[id] === 'object' ? draft.providers[id] : {};
96
+ draft.providers[id] = { ...stored, apiKey: String(apiKey).trim() };
97
+ } else if (draft.providers && typeof draft.providers === 'object' && !draft.providers[id]) {
98
+ draft.providers[id] = { apiKey: '' };
99
+ }
100
+ return draft;
101
+ });
102
+ } catch {
103
+ return { ok: false, reason: 'write' };
104
+ }
105
+ return reason ? { ok: false, reason } : { ok: true, reason: null, id, baseURL: url, updated };
106
+ }
107
+
108
+ /**
109
+ * Remove a user provider plus its credentials and remembered model.
110
+ * Returns { ok, reason } with reason 'builtin' | 'missing' | 'active' | 'write'.
111
+ */
112
+ export async function removeExtraProvider(id, { active = false } = {}) {
113
+ const provider = normalizeProviderId(id);
114
+ if (!provider) return { ok: false, reason: 'missing' };
115
+ if (BUILTIN_IDS.has(provider)) return { ok: false, reason: 'builtin' };
116
+ if (active) return { ok: false, reason: 'active' };
117
+ let reason = null;
118
+ try {
119
+ await updateSettings((draft) => {
120
+ const entries = readExtraProviders(draft);
121
+ const index = entries.findIndex((entry) => normalizeProviderId(entry?.id) === provider);
122
+ if (index === -1) {
123
+ reason = 'missing';
124
+ return draft;
125
+ }
126
+ entries.splice(index, 1);
127
+ draft.extraProviders = entries;
128
+ if (draft.providers && typeof draft.providers === 'object') delete draft.providers[provider];
129
+ if (draft.providerModel && typeof draft.providerModel === 'object') delete draft.providerModel[provider];
130
+ return draft;
131
+ });
132
+ } catch {
133
+ return { ok: false, reason: 'write' };
134
+ }
135
+ return reason ? { ok: false, reason } : { ok: true, reason: null };
136
+ }
137
+
138
+ /**
139
+ * Append a model id to a user provider's `models`. The first model also becomes
140
+ * the provider's default. Returns { ok, reason, added, models } with reason
141
+ * 'missing' | 'builtin' | 'invalid' | 'exists' | 'limit' | 'write'.
142
+ */
143
+ export async function addExtraProviderModel(id, model, { label = null } = {}) {
144
+ const provider = normalizeProviderId(id);
145
+ const modelId = normalizeModelId(model);
146
+ if (!provider) return { ok: false, reason: 'missing' };
147
+ if (!modelId) return { ok: false, reason: 'invalid' };
148
+ if (BUILTIN_IDS.has(provider)) return { ok: false, reason: 'builtin' };
149
+ let reason = null;
150
+ let models = [];
151
+ try {
152
+ await updateSettings((draft) => {
153
+ const entries = readExtraProviders(draft);
154
+ const entry = entries.find((item) => normalizeProviderId(item?.id) === provider);
155
+ if (!entry) {
156
+ reason = 'missing';
157
+ return draft;
158
+ }
159
+ models = modelIdsOf(entry);
160
+ if (models.includes(modelId)) {
161
+ reason = 'exists';
162
+ return draft;
163
+ }
164
+ if (models.length >= MAX_MODELS_PER_PROVIDER) {
165
+ reason = 'limit';
166
+ return draft;
167
+ }
168
+ models.push(modelId);
169
+ const storedNames = Array.isArray(entry.models)
170
+ ? new Map(entry.models.map((item) => [normalizeModelId(typeof item === 'string' ? item : item?.id), typeof item === 'object' ? item?.name : null]))
171
+ : new Map();
172
+ entry.models = models.map((item) => {
173
+ const display = storedNames.get(item);
174
+ return display && display !== item ? { id: item, name: display } : item;
175
+ });
176
+ if (!normalizeModelId(entry.defaultModel)) entry.defaultModel = modelId;
177
+ entry.name = entry.name || label || provider;
178
+ return draft;
179
+ });
180
+ } catch {
181
+ return { ok: false, reason: 'write' };
182
+ }
183
+ return reason ? { ok: false, reason, models } : { ok: true, reason: null, added: modelId, models };
184
+ }
185
+
186
+ /**
187
+ * Remove a model id from a user provider and recompute its default.
188
+ * Returns { ok, reason, models, defaultModel } with reason 'missing' | 'builtin'
189
+ * | 'invalid' | 'absent' | 'write'.
190
+ */
191
+ export async function removeExtraProviderModel(id, model) {
192
+ const provider = normalizeProviderId(id);
193
+ const modelId = normalizeModelId(model);
194
+ if (!provider) return { ok: false, reason: 'missing' };
195
+ if (!modelId) return { ok: false, reason: 'invalid' };
196
+ if (BUILTIN_IDS.has(provider)) return { ok: false, reason: 'builtin' };
197
+ let reason = null;
198
+ let models = [];
199
+ let defaultModel = null;
200
+ try {
201
+ await updateSettings((draft) => {
202
+ const entries = readExtraProviders(draft);
203
+ const entry = entries.find((item) => normalizeProviderId(item?.id) === provider);
204
+ if (!entry) {
205
+ reason = 'missing';
206
+ return draft;
207
+ }
208
+ const current = modelIdsOf(entry);
209
+ if (!current.includes(modelId)) {
210
+ reason = 'absent';
211
+ models = current;
212
+ return draft;
213
+ }
214
+ models = current.filter((item) => item !== modelId);
215
+ entry.models = models;
216
+ if (normalizeModelId(entry.defaultModel) === modelId) entry.defaultModel = models[0] || null;
217
+ defaultModel = normalizeModelId(entry.defaultModel);
218
+ return draft;
219
+ });
220
+ } catch {
221
+ return { ok: false, reason: 'write' };
222
+ }
223
+ return reason
224
+ ? { ok: false, reason, models }
225
+ : { ok: true, reason: null, models, defaultModel };
226
+ }