@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,370 @@
1
+ /**
2
+ * Small dependency-free JSON-schema subset validator.
3
+ *
4
+ * Supported keywords: type, properties, required, additionalProperties,
5
+ * enum, items, minItems, maxItems, minLength, maxLength, minimum, maximum,
6
+ * pattern. Unknown keywords are ignored (they are documentation for the model).
7
+ *
8
+ * This intentionally validates only what the tool registry actually declares,
9
+ * so there is one source of truth and no schema-library dependency.
10
+ */
11
+
12
+ const TYPE_CHECKS = {
13
+ string: (v) => typeof v === 'string',
14
+ number: (v) => typeof v === 'number' && Number.isFinite(v),
15
+ integer: (v) => typeof v === 'number' && Number.isInteger(v),
16
+ boolean: (v) => typeof v === 'boolean',
17
+ object: (v) => v !== null && typeof v === 'object' && !Array.isArray(v),
18
+ array: (v) => Array.isArray(v),
19
+ null: (v) => v === null,
20
+ };
21
+
22
+ function typeMatches(type, value) {
23
+ const check = TYPE_CHECKS[type];
24
+ if (!check) return true;
25
+ return check(value);
26
+ }
27
+
28
+ function describeType(value) {
29
+ if (value === null) return 'null';
30
+ if (Array.isArray(value)) return 'array';
31
+ return typeof value;
32
+ }
33
+
34
+ /**
35
+ * @returns {string[]} list of human-readable validation errors (empty = valid)
36
+ */
37
+ export function validateSchema(schema, value, path = '$') {
38
+ const errors = [];
39
+ if (!schema || typeof schema !== 'object') return errors;
40
+
41
+ if (schema.type !== undefined) {
42
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
43
+ if (!types.some((type) => typeMatches(type, value))) {
44
+ errors.push(`${path}: expected ${types.join(' | ')}, received ${describeType(value)}`);
45
+ return errors;
46
+ }
47
+ }
48
+
49
+ if (schema.enum !== undefined && !schema.enum.some((allowed) => deepEqual(allowed, value))) {
50
+ errors.push(`${path}: must be one of ${schema.enum.map((v) => JSON.stringify(v)).join(', ')}`);
51
+ }
52
+
53
+ if (typeof value === 'string') {
54
+ if (schema.minLength !== undefined && value.length < schema.minLength) {
55
+ errors.push(`${path}: must be at least ${schema.minLength} character(s)`);
56
+ }
57
+ if (schema.maxLength !== undefined && value.length > schema.maxLength) {
58
+ errors.push(`${path}: must be at most ${schema.maxLength} character(s)`);
59
+ }
60
+ if (schema.pattern !== undefined && !new RegExp(schema.pattern).test(value)) {
61
+ errors.push(`${path}: must match pattern ${schema.pattern}`);
62
+ }
63
+ }
64
+
65
+ if (typeof value === 'number' && Number.isFinite(value)) {
66
+ if (schema.minimum !== undefined && value < schema.minimum) {
67
+ errors.push(`${path}: must be >= ${schema.minimum}`);
68
+ }
69
+ if (schema.maximum !== undefined && value > schema.maximum) {
70
+ errors.push(`${path}: must be <= ${schema.maximum}`);
71
+ }
72
+ }
73
+
74
+ if (Array.isArray(value)) {
75
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
76
+ errors.push(`${path}: must contain at least ${schema.minItems} item(s)`);
77
+ }
78
+ if (schema.maxItems !== undefined && value.length > schema.maxItems) {
79
+ errors.push(`${path}: must contain at most ${schema.maxItems} item(s)`);
80
+ }
81
+ if (schema.items) {
82
+ value.forEach((item, index) => {
83
+ errors.push(...validateSchema(schema.items, item, `${path}[${index}]`));
84
+ });
85
+ }
86
+ }
87
+
88
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
89
+ for (const key of schema.required || []) {
90
+ if (value[key] === undefined || value[key] === null) {
91
+ errors.push(`${path}.${key}: is required`);
92
+ }
93
+ }
94
+ const properties = schema.properties || {};
95
+ for (const [key, item] of Object.entries(value)) {
96
+ if (properties[key]) {
97
+ errors.push(...validateSchema(properties[key], item, `${path}.${key}`));
98
+ } else if (schema.additionalProperties === false) {
99
+ errors.push(`${path}.${key}: unexpected property`);
100
+ }
101
+ }
102
+ }
103
+
104
+ return errors;
105
+ }
106
+
107
+ export function assertSchema(schema, value, label = 'input') {
108
+ const errors = validateSchema(schema, value);
109
+ if (errors.length > 0) {
110
+ const error = new Error(`Invalid ${label}: ${errors.join('; ')}`);
111
+ error.code = 'VALIDATION_FAILED';
112
+ error.validationErrors = errors;
113
+ throw error;
114
+ }
115
+ return value;
116
+ }
117
+
118
+ function deepEqual(a, b) {
119
+ if (a === b) return true;
120
+ if (typeof a !== typeof b) return false;
121
+ if (a && b && typeof a === 'object') {
122
+ return JSON.stringify(a) === JSON.stringify(b);
123
+ }
124
+ return false;
125
+ }
126
+
127
+ /** Parse JSON with a useful, bounded error message (never echo huge payloads). */
128
+ export function parseJsonArguments(text, { maxLength = 20000 } = {}) {
129
+ if (text == null || text === '') return {};
130
+ if (typeof text !== 'string') return text;
131
+ const trimmed = text.trim();
132
+ if (trimmed.length > maxLength) {
133
+ throw new Error(`Tool arguments JSON is too large (${trimmed.length} bytes)`);
134
+ }
135
+ try {
136
+ const parsed = JSON.parse(trimmed);
137
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
138
+ throw new Error('Tool arguments must be a JSON object');
139
+ }
140
+ return parsed;
141
+ } catch (error) {
142
+ if (error.message.startsWith('Tool arguments')) throw error;
143
+ throw new Error(`Malformed tool arguments JSON: ${error.message}`);
144
+ }
145
+ }
146
+
147
+ /* ------------------------------------------------------------------------- *
148
+ * Tolerant JSON repair.
149
+ *
150
+ * Streamed tool-call arguments are frequently truncated mid-object (the model
151
+ * stopped, the socket closed, or a token budget cut the payload). A truncated
152
+ * payload used to be stored verbatim in the conversation, and the provider then
153
+ * rejected the *next* request with
154
+ * `messages[n].tool_calls[i].function.arguments must be a valid JSON object
155
+ * string` — killing the whole task even though only one call was broken.
156
+ *
157
+ * The scanner below finds the longest prefix of a payload that is a valid
158
+ * JSON value and closes the containers that were still open, so a truncated
159
+ * call is usually still usable.
160
+ * ------------------------------------------------------------------------- */
161
+
162
+ const LITERAL_RE = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/;
163
+
164
+ function skipWhitespace(text, index) {
165
+ let i = index;
166
+ while (i < text.length && /\s/.test(text[i])) i += 1;
167
+ return i;
168
+ }
169
+
170
+ /** Scan a JSON string literal; returns the index after the closing quote. */
171
+ function scanString(text, start) {
172
+ let i = start + 1;
173
+ let escaped = false;
174
+ while (i < text.length) {
175
+ const char = text[i];
176
+ if (escaped) {
177
+ escaped = false;
178
+ i += 1;
179
+ continue;
180
+ }
181
+ if (char === '\\') {
182
+ escaped = true;
183
+ i += 1;
184
+ continue;
185
+ }
186
+ if (char === '"') return i + 1;
187
+ i += 1;
188
+ }
189
+ return -1;
190
+ }
191
+
192
+ /**
193
+ * Scan a JSON value.
194
+ * @returns {{complete: boolean, end: number, safe: {index: number, closers: string}|null}}
195
+ */
196
+ function scanValue(text, start, depth = 0) {
197
+ if (depth > 64) return { complete: false, end: start, safe: null };
198
+ const i = skipWhitespace(text, start);
199
+ if (i >= text.length) return { complete: false, end: i, safe: null };
200
+ const char = text[i];
201
+ if (char === '"') {
202
+ const end = scanString(text, i);
203
+ if (end < 0) {
204
+ // Truncated inside the string: the value can still be recovered by
205
+ // closing the quote at the end of the payload.
206
+ let backslashes = 0;
207
+ while (backslashes < text.length && text[text.length - 1 - backslashes] === '\\') backslashes += 1;
208
+ // An odd number of trailing backslashes means the last one starts an
209
+ // unfinished escape sequence: drop it before closing the quote.
210
+ const index = backslashes % 2 === 1 ? text.length - 1 : text.length;
211
+ return { complete: false, end: text.length, safe: { index, closers: '"' } };
212
+ }
213
+ return { complete: true, end, safe: null };
214
+ }
215
+ if (char === '{') return scanObject(text, i, depth);
216
+ if (char === '[') return scanArray(text, i, depth);
217
+ const literal = LITERAL_RE.exec(text.slice(i));
218
+ if (literal) return { complete: true, end: i + literal[0].length, safe: null };
219
+ return { complete: false, end: i, safe: null };
220
+ }
221
+
222
+ /**
223
+ * Scan a JSON object.
224
+ * `safe` is the longest prefix that is a valid JSON value once the still-open
225
+ * containers are closed: `{ index, closers }`.
226
+ */
227
+ function scanObject(text, start, depth) {
228
+ let i = start + 1;
229
+ let safe = null;
230
+ for (;;) {
231
+ i = skipWhitespace(text, i);
232
+ if (i >= text.length) return { complete: false, end: i, safe };
233
+ if (text[i] === '}') return { complete: true, end: i + 1, safe };
234
+ if (text[i] !== '"') return { complete: false, end: i, safe };
235
+ const keyEnd = scanString(text, i);
236
+ if (keyEnd < 0) return { complete: false, end: i, safe };
237
+ i = skipWhitespace(text, keyEnd);
238
+ if (text[i] !== ':') return { complete: false, end: i, safe };
239
+ i = skipWhitespace(text, i + 1);
240
+ const value = scanValue(text, i, depth + 1);
241
+ if (!value.complete) {
242
+ // The value itself is truncated: fall back to the deepest safe point.
243
+ const inner = value.safe;
244
+ if (inner) return { complete: false, end: value.end, safe: { index: inner.index, closers: `${inner.closers}}` } };
245
+ return { complete: false, end: value.end, safe };
246
+ }
247
+ safe = { index: value.end, closers: '}' };
248
+ i = skipWhitespace(text, value.end);
249
+ if (text[i] === ',') {
250
+ i += 1;
251
+ continue;
252
+ }
253
+ if (text[i] === '}') return { complete: true, end: i + 1, safe };
254
+ return { complete: false, end: i, safe };
255
+ }
256
+ }
257
+
258
+ /** Scan a JSON array (see `scanObject`). */
259
+ function scanArray(text, start, depth) {
260
+ let i = start + 1;
261
+ let safe = null;
262
+ for (;;) {
263
+ i = skipWhitespace(text, i);
264
+ if (i >= text.length) return { complete: false, end: i, safe };
265
+ if (text[i] === ']') return { complete: true, end: i + 1, safe };
266
+ const value = scanValue(text, i, depth + 1);
267
+ if (!value.complete) {
268
+ const inner = value.safe;
269
+ if (inner) return { complete: false, end: value.end, safe: { index: inner.index, closers: `${inner.closers}]` } };
270
+ return { complete: false, end: value.end, safe };
271
+ }
272
+ safe = { index: value.end, closers: ']' };
273
+ i = skipWhitespace(text, value.end);
274
+ if (text[i] === ',') {
275
+ i += 1;
276
+ continue;
277
+ }
278
+ if (text[i] === ']') return { complete: true, end: i + 1, safe };
279
+ return { complete: false, end: i, safe };
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Repair a truncated/malformed JSON payload.
285
+ * @returns {string|null} the repaired JSON text, or null when unrecoverable
286
+ */
287
+ export function repairJsonText(text) {
288
+ if (typeof text !== 'string') return null;
289
+ const trimmed = text.trim();
290
+ if (!trimmed) return null;
291
+ // A markdown fence around the payload is a common model slip.
292
+ const unfenced = trimmed.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
293
+ const source = unfenced || trimmed;
294
+
295
+ const scan = scanValue(source, 0);
296
+ if (scan.complete) {
297
+ // The value is complete; trailing junk after it is ignored.
298
+ const direct = source.slice(0, scan.end);
299
+ try {
300
+ JSON.parse(direct);
301
+ return direct;
302
+ } catch {
303
+ // A trailing comma (`{"a":1,}`) parses nowhere: retry from the safe point.
304
+ }
305
+ }
306
+ const safe = scan.safe;
307
+ if (!safe) return null;
308
+ const candidate = `${source.slice(0, safe.index)}${safe.closers}`;
309
+ try {
310
+ JSON.parse(candidate);
311
+ return candidate;
312
+ } catch {
313
+ return null;
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Parse tool-call arguments without ever throwing.
319
+ *
320
+ * @returns {{ok: true, value: object, repaired: boolean}
321
+ * |{ok: false, error: string, raw: string}}
322
+ */
323
+ export function parseToolArguments(text, { maxLength = 20000 } = {}) {
324
+ const raw = typeof text === 'string' ? text : '';
325
+ if (text == null || raw.trim() === '') return { ok: true, value: {}, repaired: false };
326
+ if (typeof text !== 'string') {
327
+ if (text && typeof text === 'object' && !Array.isArray(text)) return { ok: true, value: text, repaired: false };
328
+ return { ok: false, error: 'Tool arguments must be a JSON object.', raw: '' };
329
+ }
330
+ if (raw.length > maxLength) {
331
+ return { ok: false, error: `Tool arguments JSON is too large (${raw.length} bytes)`, raw: raw.slice(0, 400) };
332
+ }
333
+ try {
334
+ const parsed = JSON.parse(raw.trim());
335
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
336
+ return { ok: false, error: 'Tool arguments must be a JSON object.', raw: raw.slice(0, 400) };
337
+ }
338
+ return { ok: true, value: parsed, repaired: false };
339
+ } catch (error) {
340
+ const repaired = repairJsonText(raw);
341
+ if (repaired) {
342
+ try {
343
+ const parsed = JSON.parse(repaired);
344
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
345
+ return { ok: true, value: parsed, repaired: true };
346
+ }
347
+ } catch {
348
+ // Fall through to the error below.
349
+ }
350
+ }
351
+ return {
352
+ ok: false,
353
+ error: `Malformed tool arguments JSON: ${error.message}`,
354
+ raw: raw.slice(0, 400),
355
+ };
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Always return a valid JSON object string for a tool call.
361
+ *
362
+ * The provider validates `function.arguments` on every request, so a broken
363
+ * payload must never reach the wire: repair it when possible, otherwise send
364
+ * an empty object.
365
+ */
366
+ export function toToolArgumentsString(text, { maxLength = 20000 } = {}) {
367
+ const parsed = parseToolArguments(text, { maxLength });
368
+ if (parsed.ok) return JSON.stringify(parsed.value);
369
+ return '{}';
370
+ }
@@ -0,0 +1,24 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
6
+
7
+ /**
8
+ * The CLI version, always read from package.json.
9
+ *
10
+ * Every banner, prompt and MCP handshake prints this value, so a release only
11
+ * has to bump `package.json`. The read is cheap and cached after the first call.
12
+ */
13
+ let cached = null;
14
+
15
+ export function readVersion() {
16
+ if (cached) return cached;
17
+ try {
18
+ const pkg = JSON.parse(fs.readFileSync(path.join(HERE, '..', '..', 'package.json'), 'utf8'));
19
+ cached = pkg.version || '0.0.0';
20
+ } catch {
21
+ cached = '0.0.0';
22
+ }
23
+ return cached;
24
+ }