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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +560 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1213 -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 +463 -0
  11. package/src/cli/commands.js +1150 -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 +132 -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 +451 -0
  25. package/src/index.js +449 -0
  26. package/src/providers/catalog.js +282 -0
  27. package/src/providers/index.js +84 -0
  28. package/src/providers/nvidia.js +645 -0
  29. package/src/providers/registry.js +104 -0
  30. package/src/services/clipboard.js +38 -0
  31. package/src/services/diff.js +153 -0
  32. package/src/services/doctor.js +126 -0
  33. package/src/services/logger.js +190 -0
  34. package/src/services/preferences.js +249 -0
  35. package/src/services/processes.js +345 -0
  36. package/src/services/provider-settings.js +226 -0
  37. package/src/services/sessions.js +279 -0
  38. package/src/services/undo.js +133 -0
  39. package/src/services/ushell.js +146 -0
  40. package/src/services/websearch/manager.js +616 -0
  41. package/src/services/websearch/mcp.js +233 -0
  42. package/src/services/websearch/normalize.js +284 -0
  43. package/src/services/websearch/providers.js +178 -0
  44. package/src/services/websearch/serper.js +263 -0
  45. package/src/services/workspace.js +589 -0
  46. package/src/tools/filesystem.js +304 -0
  47. package/src/tools/git.js +114 -0
  48. package/src/tools/index.js +48 -0
  49. package/src/tools/project.js +160 -0
  50. package/src/tools/registry.js +118 -0
  51. package/src/tools/search.js +210 -0
  52. package/src/tools/shell.js +279 -0
  53. package/src/tools/url.js +542 -0
  54. package/src/tools/web.js +67 -0
  55. package/src/utils/errors.js +160 -0
  56. package/src/utils/network.js +129 -0
  57. package/src/utils/redaction.js +73 -0
  58. package/src/utils/validation.js +370 -0
  59. package/src/utils/version.js +24 -0
@@ -0,0 +1,495 @@
1
+ import {
2
+ getSetting,
3
+ maskSettingsSecret,
4
+ settingsModelFor,
5
+ settingsProviderId,
6
+ } from './settings.js';
7
+ import { APPROVAL_POLICIES, CONFIG_LIMITS, LOG_LEVELS } from './validate.js';
8
+ import { MAX_MODEL_ID_LENGTH, MODEL_ID_PATTERN, normalizeProviderId } from '../providers/catalog.js';
9
+
10
+ /**
11
+ * The editable settings registry.
12
+ *
13
+ * One descriptor per user-editable value, built from the provider catalog and
14
+ * the same limits/defaults startup validation uses. The numbered editor and the
15
+ * settings TUI both render this list, so there is a single source of truth for
16
+ * labels, defaults, secret masking and validation.
17
+ */
18
+
19
+ const WEB_PROVIDER_IDS = ['serper', 'you', 'firecrawl', 'duckduckgo', 'tavily'];
20
+ const PLACEHOLDER_KEY = /^(your[_-]?.*api[_-]?key|changeme|replace[_-]?me|<.*>)$/i;
21
+
22
+ const AGENT_META = {
23
+ maxSteps: { description: 'Optional cap on agent steps per task (0 = unlimited).' },
24
+ maxOutputTokens: { description: 'Maximum tokens the model may emit per response.' },
25
+ requestTimeoutMs: { description: 'Timeout for one model request, in milliseconds.' },
26
+ streamIdleTimeoutMs: { description: 'Abort a response stream idle for this long (0 disables).' },
27
+ maxRetries: { description: 'Transport retries per model request.' },
28
+ maxRecoveryAttempts: { description: 'Heartbeat reconnect attempts after a provider failure (0 = unlimited).' },
29
+ recoveryBackoffMs: { description: 'First heartbeat reconnect delay, in milliseconds.' },
30
+ maxFileBytes: { description: 'Largest file read in full, in bytes.' },
31
+ maxSearchResults: { description: 'Maximum matches returned by search_files.' },
32
+ maxToolOutputBytes: { description: 'Maximum captured tool/command output, in bytes.' },
33
+ maxTaskDurationMs: { description: 'Wall-clock budget for one task, in milliseconds.' },
34
+ maxContextChars: { description: 'Local context budget before older exchanges are compacted.' },
35
+ approvalPolicy: {
36
+ description: 'Approval gate: ask for every change, auto-approve writes, or deny all writes and commands.',
37
+ choices: APPROVAL_POLICIES,
38
+ default: 'ask',
39
+ },
40
+ logLevel: {
41
+ description: 'Log verbosity.',
42
+ choices: LOG_LEVELS,
43
+ default: 'info',
44
+ },
45
+ allowShell: {
46
+ description: 'Allow shell syntax in run_command after explicit approval. Not a sandbox.',
47
+ boolean: true,
48
+ },
49
+ debug: { description: 'Verbose logs and stack traces.', boolean: true },
50
+ historyDir: { description: 'Session history directory, relative to the workspace (empty uses .ryuu/sessions).' },
51
+ logDir: { description: 'Log directory, relative to the workspace (empty uses .ryuu/logs).' },
52
+ };
53
+
54
+ const WEB_BOOLEAN_KEYS = ['enabled', 'fallback', 'debug', 'duckduckgoEnabled'];
55
+
56
+ const WEB_META = {
57
+ serperApiKey: { description: 'Serper API key for web_search (optional).' },
58
+ youApiKey: { description: 'You.com API key for web_search (optional).' },
59
+ firecrawlApiKey: { description: 'Firecrawl API key for web_search (optional).' },
60
+ tavilyApiKey: { description: 'Tavily API key for web_search (optional).' },
61
+ endpoint: { description: 'Serper-compatible search endpoint.', type: 'url' },
62
+ authHeader: {
63
+ description: 'Header that carries the Serper-compatible search key.',
64
+ choices: ['X-API-KEY', 'Authorization'],
65
+ default: 'X-API-KEY',
66
+ },
67
+ enabled: { description: 'Register the web_search tool at all.', boolean: true, default: true },
68
+ fallback: { description: 'Fall back to the next web search provider when one fails.', boolean: true, default: true },
69
+ debug: { description: 'Extra web search diagnostics in the log (never prints keys).', boolean: true },
70
+ duckduckgoEnabled: {
71
+ description: 'Enable the local DuckDuckGo MCP server (runs third-party code via uvx).',
72
+ boolean: true,
73
+ },
74
+ duckduckgoCommand: { description: 'Command used to start the DuckDuckGo MCP server (default: uvx).' },
75
+ duckduckgoArgs: { description: 'Arguments for the DuckDuckGo MCP command (default: free-search-mcp).' },
76
+ };
77
+
78
+ function ok(value, warning = null) {
79
+ return { ok: true, value, warning };
80
+ }
81
+
82
+ function fail(error) {
83
+ return { ok: false, error };
84
+ }
85
+
86
+ function validateText(value, { allowEmpty = true, max = 4096 } = {}) {
87
+ if (typeof value !== 'string') return fail('Expected text.');
88
+ const text = value.trim();
89
+ if (!text && !allowEmpty) return fail('A value is required.');
90
+ if (text.length > max) return fail(`Value is too long (maximum ${max} characters).`);
91
+ if (/[\u0000-\u001f\u007f]/.test(text)) return fail('Control characters are not allowed.');
92
+ return ok(text);
93
+ }
94
+
95
+ /** Keys are never sent anywhere to be checked; only local format rules apply. */
96
+ function validateSecret(value) {
97
+ const base = validateText(value);
98
+ if (!base.ok) return base;
99
+ const text = base.value;
100
+ if (!text) return base;
101
+ if (/\s/.test(text)) return fail('A key cannot contain spaces or line breaks.');
102
+ if (PLACEHOLDER_KEY.test(text)) return fail('That looks like a placeholder, not a real key.');
103
+ return ok(text);
104
+ }
105
+
106
+ function validateUrl(value, label) {
107
+ const base = validateText(value);
108
+ if (!base.ok) return base;
109
+ const text = base.value;
110
+ if (!text) return base;
111
+ try {
112
+ const parsed = new URL(text);
113
+ if (!['http:', 'https:'].includes(parsed.protocol)) return fail(`${label} must use http or https.`);
114
+ if (parsed.username || parsed.password) return fail(`${label} must not embed credentials.`);
115
+ return ok(parsed.toString().replace(/\/$/, ''));
116
+ } catch {
117
+ return fail(`${label} is not a valid URL.`);
118
+ }
119
+ }
120
+
121
+ function validateInteger(value, { min, max, label }) {
122
+ const text = typeof value === 'number' ? String(value) : String(value ?? '').trim();
123
+ if (!text) return fail(`${label} needs a number.`);
124
+ if (!/^-?\d+$/.test(text)) return fail(`${label} must be an integer.`);
125
+ const number = Number(text);
126
+ if (!Number.isSafeInteger(number)) return fail(`${label} is outside the supported range.`);
127
+ if (number < min || number > max) return fail(`${label} must be between ${min} and ${max}.`);
128
+ return ok(number);
129
+ }
130
+
131
+ function validateProvider(value, ids) {
132
+ const text = String(value ?? '').trim().toLowerCase();
133
+ if (!text || text === 'auto') return ok(null);
134
+ const id = normalizeProviderId(text);
135
+ if (!id || !ids.includes(id)) {
136
+ return fail(`Unknown provider ${JSON.stringify(text)}. Choose one of: ${ids.join(', ')} (or "auto").`);
137
+ }
138
+ return ok(id);
139
+ }
140
+
141
+ function validateModel(value, { providerId, choices }) {
142
+ const text = String(value ?? '').trim();
143
+ if (!text) return fail('A model id is required.');
144
+ if (text.length > MAX_MODEL_ID_LENGTH || !MODEL_ID_PATTERN.test(text)) {
145
+ return fail('Model id contains unsupported characters.');
146
+ }
147
+ if (choices.length > 0 && !choices.includes(text)) {
148
+ return ok(text, `Model ${text} is not in the ${providerId} catalog; it will be sent as-is and may fail.`);
149
+ }
150
+ return ok(text);
151
+ }
152
+
153
+ function validateWebProviderList(value) {
154
+ const items = Array.isArray(value) ? value : String(value ?? '').split(/[\s,]+/);
155
+ const ids = [];
156
+ for (const item of items) {
157
+ const id = String(item ?? '').trim().toLowerCase();
158
+ if (!id) continue;
159
+ if (id === 'none' || id === 'off') return ok([]);
160
+ if (!WEB_PROVIDER_IDS.includes(id)) {
161
+ return fail(`Unknown web search provider ${JSON.stringify(id)} (allowed: ${WEB_PROVIDER_IDS.join(', ')}).`);
162
+ }
163
+ if (!ids.includes(id)) ids.push(id);
164
+ }
165
+ return ok(ids);
166
+ }
167
+
168
+ function descriptor(entry) {
169
+ return {
170
+ editable: true,
171
+ required: false,
172
+ secret: false,
173
+ type: 'text',
174
+ choices: null,
175
+ min: null,
176
+ max: null,
177
+ defaultValue: null,
178
+ ...entry,
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Build the settings list for the given provider catalog and file contents.
184
+ *
185
+ * `providers` comes from the provider catalog (`buildProviders`), so user-added
186
+ * providers get their own key/base URL/model entries without a parallel list.
187
+ */
188
+ export function buildSettingsRegistry({ providers = [], settings = {}, customModels = {} } = {}) {
189
+ const entries = [];
190
+ const providerIds = providers.map((provider) => provider.id);
191
+
192
+ entries.push(
193
+ descriptor({
194
+ key: 'provider',
195
+ label: 'provider',
196
+ description: 'Provider used by default. "auto" picks the first provider with a key (NVIDIA preferred).',
197
+ type: 'provider',
198
+ value: settingsProviderId(settings),
199
+ choices: ['auto', ...providerIds],
200
+ validate: (value) => validateProvider(value, providerIds),
201
+ }),
202
+ );
203
+
204
+ for (const provider of providers) {
205
+ const stored = settings.providers?.[provider.id] || {};
206
+ const keyPath = `providers.${provider.id}.apiKey`;
207
+ const urlPath = `providers.${provider.id}.baseURL`;
208
+ const modelPath = `providerModel.${provider.id}`;
209
+ const modelChoices = [
210
+ ...new Set([
211
+ ...(provider.models || []).map((entry) => entry.id),
212
+ ...(customModels[provider.id] || []),
213
+ ]),
214
+ ];
215
+ entries.push(
216
+ descriptor({
217
+ key: keyPath,
218
+ label: keyPath,
219
+ description: `API key for ${provider.name}${provider.docsUrl ? ` — ${provider.docsUrl}` : ''}. Masked in every list and written owner-only.`,
220
+ type: 'secret',
221
+ secret: true,
222
+ value: typeof stored.apiKey === 'string' ? stored.apiKey : '',
223
+ validate: validateSecret,
224
+ }),
225
+ );
226
+ entries.push(
227
+ descriptor({
228
+ key: urlPath,
229
+ label: urlPath,
230
+ description: `Base URL for ${provider.name}. Empty uses ${provider.baseURL}.`,
231
+ type: 'url',
232
+ value: typeof stored.baseURL === 'string' ? stored.baseURL : '',
233
+ defaultValue: provider.baseURL,
234
+ validate: (value) => validateUrl(value, 'Base URL'),
235
+ }),
236
+ );
237
+ entries.push(
238
+ descriptor({
239
+ key: modelPath,
240
+ label: modelPath,
241
+ description: `Model used with ${provider.name}. Empty uses ${provider.defaultModel}.`,
242
+ type: 'model',
243
+ value: settingsModelFor(settings, provider.id),
244
+ defaultValue: provider.defaultModel,
245
+ choices: modelChoices,
246
+ validate: (value) => validateModel(value, { providerId: provider.id, choices: modelChoices }),
247
+ }),
248
+ );
249
+ }
250
+
251
+ const pushBoolean = (field, meta) => {
252
+ const fallback = meta.default ?? false;
253
+ entries.push(
254
+ descriptor({
255
+ key: field,
256
+ label: field,
257
+ description: meta.description,
258
+ type: 'enum',
259
+ choices: ['true', 'false'],
260
+ value: getSetting(settings, field) ?? fallback,
261
+ defaultValue: fallback,
262
+ validate: (value) => {
263
+ const text = String(value ?? '').trim().toLowerCase();
264
+ if (text === 'true') return ok(true);
265
+ if (text === 'false') return ok(false);
266
+ return fail(`${field} must be true or false.`);
267
+ },
268
+ }),
269
+ );
270
+ };
271
+
272
+ const pushInteger = (field, limits, description) => {
273
+ const [min, max, fallback] = limits;
274
+ entries.push(
275
+ descriptor({
276
+ key: field,
277
+ label: field,
278
+ description,
279
+ type: 'integer',
280
+ min,
281
+ max,
282
+ value: getSetting(settings, field) ?? fallback,
283
+ defaultValue: fallback,
284
+ validate: (value) => validateInteger(value, { min, max, label: field }),
285
+ }),
286
+ );
287
+ };
288
+
289
+ for (const key of Object.keys(AGENT_META)) {
290
+ const meta = AGENT_META[key];
291
+ const field = `agent.${key}`;
292
+ if (meta.boolean) {
293
+ pushBoolean(field, meta);
294
+ continue;
295
+ }
296
+ if (meta.choices) {
297
+ entries.push(
298
+ descriptor({
299
+ key: field,
300
+ label: field,
301
+ description: meta.description,
302
+ type: 'enum',
303
+ choices: meta.choices,
304
+ value: getSetting(settings, field) ?? meta.default,
305
+ defaultValue: meta.default,
306
+ validate: (value) => {
307
+ const text = String(value ?? '').trim().toLowerCase();
308
+ if (!meta.choices.includes(text)) {
309
+ return fail(`${field} must be one of ${meta.choices.join(', ')}.`);
310
+ }
311
+ return ok(text);
312
+ },
313
+ }),
314
+ );
315
+ continue;
316
+ }
317
+ const limits = CONFIG_LIMITS[key];
318
+ if (limits) {
319
+ pushInteger(field, limits, meta.description);
320
+ continue;
321
+ }
322
+ entries.push(
323
+ descriptor({
324
+ key: field,
325
+ label: field,
326
+ description: meta.description,
327
+ type: 'text',
328
+ value: getSetting(settings, field) ?? '',
329
+ validate: (value) => validateText(value),
330
+ }),
331
+ );
332
+ }
333
+
334
+ for (const [key, meta] of Object.entries(WEB_META)) {
335
+ const field = `webSearch.${key}`;
336
+ if (WEB_BOOLEAN_KEYS.includes(key)) {
337
+ pushBoolean(field, meta);
338
+ continue;
339
+ }
340
+ if (meta.choices) {
341
+ entries.push(
342
+ descriptor({
343
+ key: field,
344
+ label: field,
345
+ description: meta.description,
346
+ type: 'enum',
347
+ choices: meta.choices,
348
+ value: getSetting(settings, field) ?? meta.default,
349
+ defaultValue: meta.default,
350
+ validate: (value) => {
351
+ const text = String(value ?? '').trim();
352
+ const match = meta.choices.find((choice) => choice.toLowerCase() === text.toLowerCase());
353
+ return match ? ok(match) : fail(`${field} must be one of ${meta.choices.join(', ')}.`);
354
+ },
355
+ }),
356
+ );
357
+ continue;
358
+ }
359
+ if (meta.type === 'url') {
360
+ entries.push(
361
+ descriptor({
362
+ key: field,
363
+ label: field,
364
+ description: meta.description,
365
+ type: 'url',
366
+ value: getSetting(settings, field) ?? '',
367
+ defaultValue: 'https://google.serper.dev/search',
368
+ validate: (value) => validateUrl(value, field),
369
+ }),
370
+ );
371
+ continue;
372
+ }
373
+ if (key.endsWith('ApiKey')) {
374
+ entries.push(
375
+ descriptor({
376
+ key: field,
377
+ label: field,
378
+ description: meta.description,
379
+ type: 'secret',
380
+ secret: true,
381
+ value: getSetting(settings, field) ?? '',
382
+ validate: validateSecret,
383
+ }),
384
+ );
385
+ continue;
386
+ }
387
+ entries.push(
388
+ descriptor({
389
+ key: field,
390
+ label: field,
391
+ description: meta.description,
392
+ type: 'text',
393
+ value: getSetting(settings, field) ?? '',
394
+ validate: (value) => validateText(value),
395
+ }),
396
+ );
397
+ }
398
+ pushInteger('webSearch.results', CONFIG_LIMITS.webSearchResults, 'Results requested per web search.');
399
+ pushInteger('webSearch.timeoutMs', CONFIG_LIMITS.webSearchTimeoutMs, 'Timeout for one web search request, in milliseconds.');
400
+ pushInteger('webSearch.mcpTimeoutMs', CONFIG_LIMITS.webMcpTimeoutMs, 'Timeout for one MCP web search call, in milliseconds.');
401
+ pushInteger('webSearch.mcpMaxRetries', CONFIG_LIMITS.webMcpMaxRetries, 'Bounded retries for transient MCP web search failures.');
402
+ const defaultOrder = ['you', 'firecrawl', 'duckduckgo', 'tavily'];
403
+ entries.push(
404
+ descriptor({
405
+ key: 'webSearch.providers',
406
+ label: 'webSearch.providers',
407
+ description: 'Web search providers tried in order. Comma-separated; "none" disables all.',
408
+ type: 'list',
409
+ choices: WEB_PROVIDER_IDS,
410
+ value: getSetting(settings, 'webSearch.providers') ?? defaultOrder,
411
+ defaultValue: defaultOrder,
412
+ validate: validateWebProviderList,
413
+ }),
414
+ );
415
+ entries.push(
416
+ descriptor({
417
+ key: 'webSearch.disabled',
418
+ label: 'webSearch.disabled',
419
+ description: 'Web search providers to skip even when listed in the order.',
420
+ type: 'list',
421
+ choices: WEB_PROVIDER_IDS,
422
+ value: getSetting(settings, 'webSearch.disabled') ?? [],
423
+ defaultValue: [],
424
+ validate: validateWebProviderList,
425
+ }),
426
+ );
427
+
428
+ return entries;
429
+ }
430
+
431
+ /** Look a setting up by 1-based list number or by key/label (case-insensitive). */
432
+ export function findSetting(registry = [], reference, { allowKey = false } = {}) {
433
+ const text = String(reference ?? '').trim();
434
+ if (!text) return null;
435
+ if (/^\d+$/.test(text)) {
436
+ const index = Number(text);
437
+ return index >= 1 && index <= registry.length ? registry[index - 1] : null;
438
+ }
439
+ if (!allowKey) return null;
440
+ return (
441
+ registry.find((entry) => entry.key === text || entry.label.toLowerCase() === text.toLowerCase()) || null
442
+ );
443
+ }
444
+
445
+ /** Display value; secrets are always masked and never reveal their length. */
446
+ export function formatSettingValue(entry) {
447
+ if (!entry) return '';
448
+ if (entry.secret) return maskSettingsSecret(entry.value);
449
+ const value = entry.value;
450
+ if (Array.isArray(value)) return value.length > 0 ? value.join(', ') : '(none)';
451
+ if (value === null || value === undefined || value === '') {
452
+ if (entry.type === 'provider') return '(auto)';
453
+ if (entry.defaultValue !== null && entry.defaultValue !== undefined && entry.defaultValue !== '') {
454
+ return `${formatDefault(entry)} (default)`;
455
+ }
456
+ return '(not set)';
457
+ }
458
+ return String(value);
459
+ }
460
+
461
+ function formatDefault(entry) {
462
+ return Array.isArray(entry.defaultValue) ? entry.defaultValue.join(', ') : String(entry.defaultValue);
463
+ }
464
+
465
+ /** Human-readable constraints for the selected setting (TUI detail pane). */
466
+ export function describeSetting(entry) {
467
+ if (!entry) return '';
468
+ const bits = [];
469
+ if (entry.type === 'integer') bits.push(`integer ${entry.min}–${entry.max}`);
470
+ if (entry.type === 'secret') bits.push('masked');
471
+ if (entry.choices?.length) {
472
+ const choices = entry.choices.length > 8 ? `${entry.choices.slice(0, 8).join(', ')}, …` : entry.choices.join(', ');
473
+ bits.push(`${entry.type === 'model' ? 'catalog' : 'choices'}: ${choices}`);
474
+ }
475
+ if (!entry.secret && entry.defaultValue !== null && entry.defaultValue !== undefined && entry.defaultValue !== '') {
476
+ bits.push(`default: ${formatDefault(entry)}`);
477
+ }
478
+ return bits.join(' · ');
479
+ }
480
+
481
+ /** Apply one raw editor value through the descriptor's validator. */
482
+ export function validateSetting(entry, rawValue) {
483
+ if (!entry) return fail('Unknown setting.');
484
+ try {
485
+ return entry.validate(rawValue);
486
+ } catch (error) {
487
+ return fail(error?.message || 'Invalid value.');
488
+ }
489
+ }
490
+
491
+ /** The value to store for a descriptor: validator output, not raw text. */
492
+ export function normalizeSettingValue(entry, rawValue) {
493
+ const result = validateSetting(entry, rawValue);
494
+ return result;
495
+ }