@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.
- package/CHANGELOG.md +592 -0
- package/LICENSE +21 -0
- package/README.md +1269 -2
- package/package.json +45 -4
- package/src/agent/agent.js +1194 -0
- package/src/agent/approvals.js +76 -0
- package/src/agent/context.js +106 -0
- package/src/agent/planner.js +237 -0
- package/src/agent/worklog.js +318 -0
- package/src/cli/app.js +471 -0
- package/src/cli/commands.js +1155 -0
- package/src/cli/input.js +287 -0
- package/src/cli/markdown.js +527 -0
- package/src/cli/prompts.js +255 -0
- package/src/cli/renderer.js +2155 -0
- package/src/cli/settings-editor.js +280 -0
- package/src/cli/settings-tui.js +669 -0
- package/src/cli/tui.js +1737 -0
- package/src/cli/width.js +48 -0
- package/src/config/load.js +137 -0
- package/src/config/scaffold.js +50 -0
- package/src/config/settings-registry.js +495 -0
- package/src/config/settings.js +304 -0
- package/src/config/validate.js +461 -0
- package/src/index.js +449 -0
- package/src/providers/catalog.js +407 -0
- package/src/providers/ids.js +24 -0
- package/src/providers/index.js +103 -0
- package/src/providers/nvidia.js +671 -0
- package/src/providers/registry.js +104 -0
- package/src/providers/river.js +198 -0
- package/src/providers/tokenharbor.js +38 -0
- package/src/providers/vendor-extras.js +82 -0
- package/src/services/clipboard.js +38 -0
- package/src/services/diff.js +153 -0
- package/src/services/doctor.js +126 -0
- package/src/services/logger.js +190 -0
- package/src/services/preferences.js +249 -0
- package/src/services/processes.js +345 -0
- package/src/services/provider-settings.js +226 -0
- package/src/services/sessions.js +279 -0
- package/src/services/undo.js +133 -0
- package/src/services/ushell.js +146 -0
- package/src/services/websearch/manager.js +616 -0
- package/src/services/websearch/mcp.js +233 -0
- package/src/services/websearch/normalize.js +284 -0
- package/src/services/websearch/providers.js +178 -0
- package/src/services/websearch/serper.js +263 -0
- package/src/services/workspace.js +589 -0
- package/src/tools/filesystem.js +304 -0
- package/src/tools/git.js +114 -0
- package/src/tools/index.js +48 -0
- package/src/tools/project.js +160 -0
- package/src/tools/registry.js +118 -0
- package/src/tools/search.js +210 -0
- package/src/tools/shell.js +279 -0
- package/src/tools/url.js +542 -0
- package/src/tools/web.js +67 -0
- package/src/utils/errors.js +160 -0
- package/src/utils/network.js +129 -0
- package/src/utils/redaction.js +73 -0
- package/src/utils/validation.js +370 -0
- package/src/utils/version.js +24 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ConfigError } from '../utils/errors.js';
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_PROVIDER_ID,
|
|
6
|
+
MAX_MODEL_ID_LENGTH,
|
|
7
|
+
MODEL_ID_PATTERN,
|
|
8
|
+
PROVIDER_REQUEST_FIELDS,
|
|
9
|
+
buildProviders,
|
|
10
|
+
isCatalogModel,
|
|
11
|
+
parseExtraProviders,
|
|
12
|
+
providerById,
|
|
13
|
+
resolveProviderId,
|
|
14
|
+
} from '../providers/catalog.js';
|
|
15
|
+
|
|
16
|
+
/** Approval policies accepted by agent.approvalPolicy (also used by settings). */
|
|
17
|
+
export const APPROVAL_POLICIES = ['ask', 'auto', 'read-only'];
|
|
18
|
+
/** Log levels accepted by LOG_LEVEL (also used by settings). */
|
|
19
|
+
export const LOG_LEVELS = ['debug', 'info', 'warn', 'error', 'silent'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Numeric config limits: [min, max, default]. Exported so the settings editor
|
|
23
|
+
* validates against exactly the same ranges as startup instead of copying them.
|
|
24
|
+
*/
|
|
25
|
+
export const CONFIG_LIMITS = {
|
|
26
|
+
maxSteps: [0, 100000, 0],
|
|
27
|
+
maxOutputTokens: [64, 32768, 4096],
|
|
28
|
+
requestTimeoutMs: [1000, 600000, 120000],
|
|
29
|
+
// Abort a response stream that stops producing bytes (0 disables). Without
|
|
30
|
+
// this a dribbling endpoint can hold a step open for tens of minutes.
|
|
31
|
+
streamIdleTimeoutMs: [0, 600000, 45000],
|
|
32
|
+
maxRetries: [0, 10, 3],
|
|
33
|
+
// 0 = keep reconnecting until the task ends (the heartbeat never gives up).
|
|
34
|
+
maxRecoveryAttempts: [0, 1000, 0],
|
|
35
|
+
recoveryBackoffMs: [100, 60000, 1000],
|
|
36
|
+
maxFileBytes: [1024, 5 * 1024 * 1024, 262144],
|
|
37
|
+
maxSearchResults: [1, 500, 50],
|
|
38
|
+
maxToolOutputBytes: [1024, 1024 * 1024, 32768],
|
|
39
|
+
maxTaskDurationMs: [10000, 24 * 60 * 60 * 1000, 15 * 60 * 1000],
|
|
40
|
+
maxContextChars: [8000, 1000000, 120000],
|
|
41
|
+
webSearchResults: [1, 10, 5],
|
|
42
|
+
webSearchTimeoutMs: [1000, 60000, 15000],
|
|
43
|
+
webMcpTimeoutMs: [1000, 30000, 6000],
|
|
44
|
+
webMcpMaxRetries: [0, 5, 1],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const WEB_PROVIDER_IDS = ['serper', 'you', 'firecrawl', 'duckduckgo', 'tavily'];
|
|
48
|
+
const WEB_PROVIDER_DEFAULT = ['you', 'firecrawl', 'duckduckgo', 'tavily'];
|
|
49
|
+
const WEB_PLACEHOLDER_KEY = /^(your[_-]?.*api[_-]?key|changeme|replace[_-]?me|<.*>)$/i;
|
|
50
|
+
|
|
51
|
+
const DEFAULT_WEB_SEARCH_ENDPOINT = 'https://google.serper.dev/search';
|
|
52
|
+
const WEB_SEARCH_AUTH_HEADERS = ['X-API-KEY', 'Authorization'];
|
|
53
|
+
const SEARCH_PLACEHOLDER_KEY = /^(your[_-]?(search|serper|websearch)[_-]?api[_-]?key|changeme|replace[_-]?me|<.*>)$/i;
|
|
54
|
+
|
|
55
|
+
const PLACEHOLDER_KEY = /^(your[_-]?.*api[_-]?key|changeme|replace[_-]?me|<.*>)$/i;
|
|
56
|
+
|
|
57
|
+
function toInt(name, value, issues) {
|
|
58
|
+
const [min, max, fallback] = CONFIG_LIMITS[name];
|
|
59
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
60
|
+
const parsed = typeof value === 'number' ? value : Number(String(value).trim());
|
|
61
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
|
62
|
+
issues.push(`${name} must be an integer (received ${JSON.stringify(value)})`);
|
|
63
|
+
return fallback;
|
|
64
|
+
}
|
|
65
|
+
if (parsed < min || parsed > max) {
|
|
66
|
+
issues.push(`${name} must be between ${min} and ${max} (received ${parsed})`);
|
|
67
|
+
return fallback;
|
|
68
|
+
}
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toBool(name, value, issues) {
|
|
73
|
+
if (value === undefined || value === null || value === '') return false;
|
|
74
|
+
if (typeof value === 'boolean') return value;
|
|
75
|
+
const text = String(value).trim().toLowerCase();
|
|
76
|
+
if (['1', 'true', 'yes', 'on'].includes(text)) return true;
|
|
77
|
+
if (['0', 'false', 'no', 'off'].includes(text)) return false;
|
|
78
|
+
issues.push(`${name} must be a boolean (true/false), received ${JSON.stringify(value)}`);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function toBoolDefault(name, value, issues, fallback) {
|
|
83
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
84
|
+
return toBool(name, value, issues);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Parse a comma-separated provider list; unknown ids are a config error. */
|
|
88
|
+
function parseProviderList(name, value, issues) {
|
|
89
|
+
if (value === undefined || value === null) return null;
|
|
90
|
+
const text = String(value).trim().toLowerCase();
|
|
91
|
+
if (!text) return null;
|
|
92
|
+
if (['none', 'off', 'disabled'].includes(text)) return [];
|
|
93
|
+
const ids = [];
|
|
94
|
+
for (const part of text.split(',')) {
|
|
95
|
+
const id = part.trim();
|
|
96
|
+
if (!id) continue;
|
|
97
|
+
if (!WEB_PROVIDER_IDS.includes(id)) {
|
|
98
|
+
issues.push(`${name} contains unknown provider ${JSON.stringify(id)} (allowed: ${WEB_PROVIDER_IDS.join(', ')})`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (!ids.includes(id)) ids.push(id);
|
|
102
|
+
}
|
|
103
|
+
return ids;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Empty keys and obvious placeholders disable a provider instead of failing startup. */
|
|
107
|
+
function optionalKey(name, value, warnings) {
|
|
108
|
+
let key = typeof value === 'string' ? value.trim() : '';
|
|
109
|
+
if (key && WEB_PLACEHOLDER_KEY.test(key)) {
|
|
110
|
+
warnings.push(`${name} looks like a placeholder; that provider stays disabled until a real key is set.`);
|
|
111
|
+
key = '';
|
|
112
|
+
}
|
|
113
|
+
return key;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function resolveDir(value, base, label, issues) {
|
|
117
|
+
if (!value) return null;
|
|
118
|
+
try {
|
|
119
|
+
return path.resolve(base, String(value));
|
|
120
|
+
} catch {
|
|
121
|
+
issues.push(`${label} is not a valid path: ${value}`);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Normalize and validate a raw config.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} raw
|
|
130
|
+
* @param {object} options
|
|
131
|
+
* @param {boolean} [options.requireApiKey=true] doctor/mock paths may pass false
|
|
132
|
+
* @param {string} [options.cwd]
|
|
133
|
+
* @returns {{config: object, warnings: string[]}}
|
|
134
|
+
* @throws {ConfigError} when validation fails, listing every issue found
|
|
135
|
+
*/
|
|
136
|
+
export function validateConfig(raw = {}, { requireApiKey = true, cwd = raw.cwd || process.cwd() } = {}) {
|
|
137
|
+
const issues = [];
|
|
138
|
+
const warnings = [];
|
|
139
|
+
|
|
140
|
+
// ---- providers -------------------------------------------------------
|
|
141
|
+
// Built-in providers (nvidia, tokenharbor) plus any extraProviders entries,
|
|
142
|
+
// each with its own base URL, key, model list and capabilities. Credentials
|
|
143
|
+
// come from config.json only.
|
|
144
|
+
const providerCredentials =
|
|
145
|
+
raw.providerCredentials && typeof raw.providerCredentials === 'object' ? raw.providerCredentials : {};
|
|
146
|
+
const extraProviders = parseExtraProviders(raw.extraProviders, issues, warnings);
|
|
147
|
+
const providers = buildProviders({ credentials: providerCredentials, extras: extraProviders });
|
|
148
|
+
|
|
149
|
+
const explicitProvider = raw.provider ? String(raw.provider).trim().toLowerCase() : null;
|
|
150
|
+
const providerId = resolveProviderId({ explicit: explicitProvider, providers });
|
|
151
|
+
let activeProvider = providerById(providers, providerId);
|
|
152
|
+
if (!activeProvider) {
|
|
153
|
+
issues.push(
|
|
154
|
+
`provider ${JSON.stringify(raw.provider)} is not a known provider (known: ${providers
|
|
155
|
+
.map((provider) => provider.id)
|
|
156
|
+
.join(', ')}).`,
|
|
157
|
+
);
|
|
158
|
+
activeProvider = providerById(providers, DEFAULT_PROVIDER_ID) || providers[0];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Generic overrides describe the active provider only (an explicit --model or
|
|
162
|
+
// a caller building a config for tests). Per-provider values are already
|
|
163
|
+
// baked into the descriptors above.
|
|
164
|
+
const keyOverride = typeof raw.apiKey === 'string' ? raw.apiKey.trim() : '';
|
|
165
|
+
if (keyOverride) activeProvider.apiKey = keyOverride;
|
|
166
|
+
const baseUrlOverride = typeof raw.baseURL === 'string' ? raw.baseURL.trim() : '';
|
|
167
|
+
if (baseUrlOverride) activeProvider.baseURL = baseUrlOverride;
|
|
168
|
+
|
|
169
|
+
// Empty keys and obvious placeholders disable a provider instead of failing
|
|
170
|
+
// startup; only the active provider's key is required.
|
|
171
|
+
const placeholderProviders = new Set();
|
|
172
|
+
for (const provider of providers) {
|
|
173
|
+
if (!provider.apiKey) continue;
|
|
174
|
+
if (PLACEHOLDER_KEY.test(provider.apiKey)) {
|
|
175
|
+
placeholderProviders.add(provider.id);
|
|
176
|
+
provider.apiKey = '';
|
|
177
|
+
warnings.push(
|
|
178
|
+
`${provider.apiKeyPath} looks like a placeholder; provider ${provider.id} stays unavailable until a real key is set.`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const apiKey = activeProvider.apiKey;
|
|
184
|
+
const apiKeyPath = activeProvider.apiKeyPath;
|
|
185
|
+
if (!apiKey) {
|
|
186
|
+
if (requireApiKey) {
|
|
187
|
+
if (placeholderProviders.has(activeProvider.id)) {
|
|
188
|
+
issues.push(
|
|
189
|
+
`${apiKeyPath} still contains the example placeholder. Replace it with a real ${activeProvider.name} key${activeProvider.docsUrl ? ` from ${activeProvider.docsUrl}` : ''}.`,
|
|
190
|
+
);
|
|
191
|
+
} else {
|
|
192
|
+
issues.push(
|
|
193
|
+
`Provider ${activeProvider.id} has no API key. Add your ${activeProvider.name} key to config.json with \`ryuu --settings\` (${apiKeyPath}).`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} else if (activeProvider.id === DEFAULT_PROVIDER_ID && !/^nvapi-[A-Za-z0-9._-]+$/.test(apiKey)) {
|
|
198
|
+
warnings.push(
|
|
199
|
+
`The ${apiKeyPath} value does not look like a typical NVIDIA key (nvapi-...). Continuing in case you use a proxy or gateway.`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const requestedModel = typeof raw.model === 'string' ? raw.model.trim() : '';
|
|
204
|
+
const model = requestedModel || activeProvider.defaultModel;
|
|
205
|
+
if (!model) {
|
|
206
|
+
issues.push(`No model is selected for provider ${activeProvider.id}. Set ${activeProvider.modelPath}.`);
|
|
207
|
+
} else if (!MODEL_ID_PATTERN.test(model) || model.length > MAX_MODEL_ID_LENGTH) {
|
|
208
|
+
issues.push(`Model id contains unsupported characters: ${JSON.stringify(model)}`);
|
|
209
|
+
} else if (!isCatalogModel(activeProvider, model)) {
|
|
210
|
+
warnings.push(
|
|
211
|
+
`Model ${JSON.stringify(model)} is not listed for provider ${activeProvider.id}. It is sent as-is and may fail; add it with /addmodel to keep it in the list.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const baseUrlLabel = activeProvider.baseUrlPath;
|
|
216
|
+
let baseURL = String(activeProvider.baseURL || '').trim();
|
|
217
|
+
try {
|
|
218
|
+
const parsed = new URL(baseURL);
|
|
219
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
220
|
+
issues.push(`${baseUrlLabel} must use http or https, received ${parsed.protocol}`);
|
|
221
|
+
} else if (parsed.protocol === 'http:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
|
|
222
|
+
warnings.push(`${baseUrlLabel} is plain http (${parsed.host}); the API key travels unencrypted.`);
|
|
223
|
+
}
|
|
224
|
+
baseURL = parsed.toString().replace(/\/$/, '');
|
|
225
|
+
} catch {
|
|
226
|
+
issues.push(`${baseUrlLabel} is not a valid URL: ${JSON.stringify(baseURL)}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let workspace = resolveDir(raw.workspace || '.', cwd, 'workspace', issues) || path.resolve(cwd);
|
|
230
|
+
if (!fs.existsSync(workspace)) {
|
|
231
|
+
issues.push(`Workspace does not exist: ${workspace}`);
|
|
232
|
+
} else {
|
|
233
|
+
try {
|
|
234
|
+
if (!fs.statSync(workspace).isDirectory()) issues.push(`Workspace is not a directory: ${workspace}`);
|
|
235
|
+
workspace = fs.realpathSync(workspace);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
issues.push(`Cannot access workspace ${workspace}: ${error.message}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const logLevel = String(raw.logLevel || 'info').toLowerCase();
|
|
242
|
+
if (!LOG_LEVELS.includes(logLevel)) {
|
|
243
|
+
issues.push(`agent.logLevel must be one of ${LOG_LEVELS.join(', ')} (received ${JSON.stringify(raw.logLevel)})`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const approvalPolicy = String(raw.approvalPolicy || 'ask').toLowerCase();
|
|
247
|
+
if (!APPROVAL_POLICIES.includes(approvalPolicy)) {
|
|
248
|
+
issues.push(
|
|
249
|
+
`agent.approvalPolicy must be one of ${APPROVAL_POLICIES.join(', ')} (received ${JSON.stringify(raw.approvalPolicy)})`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const values = {};
|
|
254
|
+
for (const name of Object.keys(CONFIG_LIMITS)) {
|
|
255
|
+
values[name] = toInt(name, raw[name], issues);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Web search is opt-in: an empty key disables the tool instead of failing startup.
|
|
259
|
+
let webSearchApiKey = typeof raw.webSearchApiKey === 'string' ? raw.webSearchApiKey.trim() : '';
|
|
260
|
+
if (webSearchApiKey && SEARCH_PLACEHOLDER_KEY.test(webSearchApiKey)) {
|
|
261
|
+
warnings.push('webSearch.serperApiKey looks like a placeholder; web_search is disabled until a real key is set.');
|
|
262
|
+
webSearchApiKey = '';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let webSearchEndpoint = String(raw.webSearchEndpoint || DEFAULT_WEB_SEARCH_ENDPOINT).trim();
|
|
266
|
+
try {
|
|
267
|
+
const parsed = new URL(webSearchEndpoint);
|
|
268
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
269
|
+
issues.push(`webSearch.endpoint must use http or https, received ${parsed.protocol}`);
|
|
270
|
+
} else if (parsed.protocol === 'http:') {
|
|
271
|
+
warnings.push(`webSearch.endpoint is plain http (${parsed.host}); the search query and API key travel unencrypted.`);
|
|
272
|
+
}
|
|
273
|
+
webSearchEndpoint = parsed.toString();
|
|
274
|
+
} catch {
|
|
275
|
+
issues.push(`webSearch.endpoint is not a valid URL: ${JSON.stringify(webSearchEndpoint)}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const webSearchAuthHeader = WEB_SEARCH_AUTH_HEADERS.find(
|
|
279
|
+
(candidate) => candidate.toLowerCase() === String(raw.webSearchAuthHeader || 'X-API-KEY').toLowerCase(),
|
|
280
|
+
);
|
|
281
|
+
if (!webSearchAuthHeader) {
|
|
282
|
+
issues.push(
|
|
283
|
+
`webSearch.authHeader must be one of ${WEB_SEARCH_AUTH_HEADERS.join(', ')} (received ${JSON.stringify(raw.webSearchAuthHeader)})`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const webSearchEnabled = toBoolDefault('webSearch.enabled', raw.webSearchEnabled, issues, true);
|
|
288
|
+
const webSearchFallback = toBoolDefault('webSearch.fallback', raw.webSearchFallback, issues, true);
|
|
289
|
+
const webMcpDebug = toBoolDefault('webSearch.debug', raw.webMcpDebug, issues, false);
|
|
290
|
+
const duckduckgoMcpEnabled = toBoolDefault('webSearch.duckduckgoEnabled', raw.duckduckgoMcpEnabled, issues, false);
|
|
291
|
+
const youApiKey = optionalKey('webSearch.youApiKey', raw.youApiKey, warnings);
|
|
292
|
+
const firecrawlApiKey = optionalKey('webSearch.firecrawlApiKey', raw.firecrawlApiKey, warnings);
|
|
293
|
+
const tavilyApiKey = optionalKey('webSearch.tavilyApiKey', raw.tavilyApiKey, warnings);
|
|
294
|
+
|
|
295
|
+
const explicitOrder = parseProviderList('webSearch.providers', raw.webSearchProviders, issues);
|
|
296
|
+
const webSearchProviders = explicitOrder ?? [
|
|
297
|
+
...(webSearchApiKey ? ['serper'] : []),
|
|
298
|
+
...WEB_PROVIDER_DEFAULT,
|
|
299
|
+
];
|
|
300
|
+
const webSearchDisabled = parseProviderList('webSearch.disabled', raw.webSearchDisabled, issues) || [];
|
|
301
|
+
if (webSearchProviders.length === 0) {
|
|
302
|
+
warnings.push('webSearch.providers is empty; web search is registered but no provider will be tried.');
|
|
303
|
+
}
|
|
304
|
+
const duckduckgoMcpCommand = String(raw.duckduckgoMcpCommand || 'uvx').trim();
|
|
305
|
+
const duckduckgoMcpArgs = String(raw.duckduckgoMcpArgs || 'free-search-mcp')
|
|
306
|
+
.split(/\s+/)
|
|
307
|
+
.map((part) => part.trim())
|
|
308
|
+
.filter(Boolean);
|
|
309
|
+
if (duckduckgoMcpEnabled && webSearchProviders.includes('duckduckgo')) {
|
|
310
|
+
warnings.push(
|
|
311
|
+
'DuckDuckGo MCP is enabled: it starts the third-party command "' +
|
|
312
|
+
`${duckduckgoMcpCommand} ${duckduckgoMcpArgs.join(' ')}" on first use with a minimal environment. Review that server before relying on it.`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (values.maxOutputTokens > 8192) {
|
|
317
|
+
warnings.push('agent.maxOutputTokens is high; the selected model may reject it or bill more aggressively.');
|
|
318
|
+
}
|
|
319
|
+
if (values.maxSteps > 50) {
|
|
320
|
+
warnings.push('agent.maxSteps is high; long autonomous runs cost more tokens and time.');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const allowShell = toBool('agent.allowShell', raw.allowShell, issues);
|
|
324
|
+
const debug = raw.debug === true || raw.debug === 'true' || raw.debug === '1';
|
|
325
|
+
if (allowShell) {
|
|
326
|
+
warnings.push('agent.allowShell=true enables shell syntax after explicit approval. Shell execution is NOT a sandbox.');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const stateRoot = path.join(workspace, '.ryuu');
|
|
330
|
+
const historyDir = resolveDir(raw.historyDir, workspace, 'agent.historyDir', issues) || path.join(stateRoot, 'sessions');
|
|
331
|
+
const logDir = resolveDir(raw.logDir, workspace, 'agent.logDir', issues) || path.join(stateRoot, 'logs');
|
|
332
|
+
const undoDir = path.join(stateRoot, 'undo');
|
|
333
|
+
const cacheDir = path.join(stateRoot, 'cache');
|
|
334
|
+
|
|
335
|
+
const webSearch = {
|
|
336
|
+
webSearchApiKey,
|
|
337
|
+
webSearchEndpoint,
|
|
338
|
+
webSearchAuthHeader: webSearchAuthHeader || 'X-API-KEY',
|
|
339
|
+
webSearchResults: values.webSearchResults,
|
|
340
|
+
webSearchTimeoutMs: values.webSearchTimeoutMs,
|
|
341
|
+
webSearchEnabled,
|
|
342
|
+
webSearchFallback,
|
|
343
|
+
webSearchProviders,
|
|
344
|
+
webSearchDisabled,
|
|
345
|
+
webMcpTimeoutMs: values.webMcpTimeoutMs,
|
|
346
|
+
webMcpMaxRetries: values.webMcpMaxRetries,
|
|
347
|
+
webMcpDebug,
|
|
348
|
+
youApiKey,
|
|
349
|
+
firecrawlApiKey,
|
|
350
|
+
tavilyApiKey,
|
|
351
|
+
duckduckgoMcpEnabled,
|
|
352
|
+
duckduckgoMcpCommand,
|
|
353
|
+
duckduckgoMcpArgs,
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
if (issues.length > 0) {
|
|
357
|
+
throw new ConfigError(
|
|
358
|
+
`Configuration is invalid:\n- ${issues.join('\n- ')}`,
|
|
359
|
+
{ issues },
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
config: {
|
|
365
|
+
// Active provider selection (descriptors carry the per-provider keys so a
|
|
366
|
+
// runtime /provider switch needs no extra environment lookup).
|
|
367
|
+
providerId: activeProvider.id,
|
|
368
|
+
providerName: activeProvider.name,
|
|
369
|
+
provider: activeProvider.id,
|
|
370
|
+
providers,
|
|
371
|
+
apiKeyPath,
|
|
372
|
+
capabilities: { ...activeProvider.capabilities },
|
|
373
|
+
apiKey,
|
|
374
|
+
model,
|
|
375
|
+
baseURL,
|
|
376
|
+
// Per-provider request overrides reach the client through the effective
|
|
377
|
+
// config: a user provider may declare a vendor field such as River's
|
|
378
|
+
// reasoningEffort, and `switchProvider` has no entry for it.
|
|
379
|
+
...Object.fromEntries(
|
|
380
|
+
PROVIDER_REQUEST_FIELDS.filter((field) => activeProvider[field] !== undefined).map((field) => [
|
|
381
|
+
field,
|
|
382
|
+
activeProvider[field],
|
|
383
|
+
]),
|
|
384
|
+
),
|
|
385
|
+
workspace,
|
|
386
|
+
logLevel,
|
|
387
|
+
approvalPolicy,
|
|
388
|
+
allowShell,
|
|
389
|
+
debug,
|
|
390
|
+
...values,
|
|
391
|
+
...webSearch,
|
|
392
|
+
historyDir,
|
|
393
|
+
logDir,
|
|
394
|
+
undoDir,
|
|
395
|
+
cacheDir,
|
|
396
|
+
},
|
|
397
|
+
warnings,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Redacted config view for /config and /status. Never exposes the key. */
|
|
402
|
+
export function describeConfig(config) {
|
|
403
|
+
const providers = Array.isArray(config.providers) ? config.providers : [];
|
|
404
|
+
const capabilityText = (capabilities = {}) =>
|
|
405
|
+
[
|
|
406
|
+
capabilities.streaming === false ? null : 'streaming',
|
|
407
|
+
capabilities.tools === false ? null : 'tools',
|
|
408
|
+
capabilities.reasoning ? 'reasoning' : null,
|
|
409
|
+
capabilities.multimodal ? 'multimodal' : null,
|
|
410
|
+
]
|
|
411
|
+
.filter(Boolean)
|
|
412
|
+
.join('+') || 'text only';
|
|
413
|
+
return {
|
|
414
|
+
provider: `${config.providerName || 'NVIDIA Integrate API'} (${config.providerId || DEFAULT_PROVIDER_ID})`,
|
|
415
|
+
baseURL: config.baseURL,
|
|
416
|
+
model: config.model,
|
|
417
|
+
apiKey: config.apiKey ? 'set (redacted)' : '(not set)',
|
|
418
|
+
apiKeyPath: config.apiKeyPath || `providers.${DEFAULT_PROVIDER_ID}.apiKey`,
|
|
419
|
+
capabilities: capabilityText(config.capabilities),
|
|
420
|
+
providers: providers.length
|
|
421
|
+
? providers
|
|
422
|
+
.map((provider) =>
|
|
423
|
+
`${provider.id}${provider.id === (config.providerId || DEFAULT_PROVIDER_ID) ? '*' : ''}: ${
|
|
424
|
+
provider.apiKey ? 'key set' : `no key (${provider.apiKeyPath})`
|
|
425
|
+
}`,
|
|
426
|
+
)
|
|
427
|
+
.join(' · ')
|
|
428
|
+
: 'unavailable',
|
|
429
|
+
workspace: config.workspace,
|
|
430
|
+
approvalPolicy: config.approvalPolicy,
|
|
431
|
+
allowShell: config.allowShell,
|
|
432
|
+
logLevel: config.logLevel,
|
|
433
|
+
maxSteps: config.maxSteps,
|
|
434
|
+
maxOutputTokens: config.maxOutputTokens,
|
|
435
|
+
requestTimeoutMs: config.requestTimeoutMs,
|
|
436
|
+
streamIdleTimeoutMs: config.streamIdleTimeoutMs,
|
|
437
|
+
maxRetries: config.maxRetries,
|
|
438
|
+
maxRecoveryAttempts: config.maxRecoveryAttempts > 0 ? config.maxRecoveryAttempts : 'unlimited',
|
|
439
|
+
recoveryBackoffMs: config.recoveryBackoffMs,
|
|
440
|
+
maxFileBytes: config.maxFileBytes,
|
|
441
|
+
maxSearchResults: config.maxSearchResults,
|
|
442
|
+
maxToolOutputBytes: config.maxToolOutputBytes,
|
|
443
|
+
maxTaskDurationMs: config.maxTaskDurationMs,
|
|
444
|
+
maxContextChars: config.maxContextChars,
|
|
445
|
+
webSearch: config.webSearchEnabled === false
|
|
446
|
+
? 'disabled (webSearch.enabled=false)'
|
|
447
|
+
: `${config.webSearchProviders?.join(' → ') || 'no providers'} · fallback ${
|
|
448
|
+
config.webSearchFallback === false ? 'off' : 'on'
|
|
449
|
+
} · ${config.webMcpTimeoutMs}ms · ${config.webSearchResults} result(s)${
|
|
450
|
+
config.webSearchApiKey ? ` · serper endpoint ${config.webSearchEndpoint}` : ''
|
|
451
|
+
}`,
|
|
452
|
+
webSearchKeys: {
|
|
453
|
+
serper: Boolean(config.webSearchApiKey),
|
|
454
|
+
you: Boolean(config.youApiKey),
|
|
455
|
+
firecrawl: Boolean(config.firecrawlApiKey),
|
|
456
|
+
tavily: Boolean(config.tavilyApiKey),
|
|
457
|
+
},
|
|
458
|
+
historyDir: config.historyDir,
|
|
459
|
+
logDir: config.logDir,
|
|
460
|
+
};
|
|
461
|
+
}
|