@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,304 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import fsp from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { RyuuError } from '../utils/errors.js';
6
+ import { normalizeModelId, normalizeProviderId } from '../providers/catalog.js';
7
+
8
+ /**
9
+ * Global user settings.
10
+ *
11
+ * This file is the only place configuration lives. Unlike
12
+ * `.ryuu/preferences.json` (per workspace), it belongs to the user: it is found
13
+ * from any working directory, holds the provider credentials and the agent
14
+ * defaults, and is edited with `ryuu --settings`.
15
+ *
16
+ * Shape (every section optional; unknown keys are preserved):
17
+ * {
18
+ * "version": 1,
19
+ * "provider": "nvidia",
20
+ * "providerModel": { "nvidia": "…" },
21
+ * "providers": { "nvidia": { "apiKey": "", "baseURL": "…" } },
22
+ * "extraProviders": [ { "id": "myproxy", "name": "…", "baseURL": "…", "models": ["…"] } ],
23
+ * "agent": { "maxSteps": 0, "approvalPolicy": "ask", "allowShell": false },
24
+ * "webSearch": { "serperApiKey": "", "providers": ["you", "firecrawl"] }
25
+ * }
26
+ *
27
+ * The file may contain credentials, so it is written atomically with owner-only
28
+ * permissions and is never read or written through a symlink.
29
+ */
30
+
31
+ export const SETTINGS_VERSION = 1;
32
+ export const SETTINGS_FILE_NAME = 'config.json';
33
+ export const SETTINGS_DIR_ENV = 'RYUU_CONFIG_DIR';
34
+
35
+ const BLOCKED_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
36
+ const SECTIONS = ['providers', 'agent', 'webSearch', 'providerModel'];
37
+ const LIST_SECTIONS = ['extraProviders'];
38
+
39
+ function settingsError(message, { cause = null, file = null } = {}) {
40
+ return new RyuuError(message, {
41
+ code: 'SETTINGS_INVALID',
42
+ cause,
43
+ details: file ? { file } : undefined,
44
+ });
45
+ }
46
+
47
+ /**
48
+ * The platform's standard per-user configuration directory.
49
+ *
50
+ * `RYUU_CONFIG_DIR` overrides it (used by tests and by anyone who keeps their
51
+ * config elsewhere). Windows: %APPDATA%\ryuu, macOS: ~/Library/Application
52
+ * Support/ryuu, Linux/BSD: $XDG_CONFIG_HOME/ryuu or ~/.config/ryuu.
53
+ */
54
+ export function settingsDir({ env = process.env, home = os.homedir(), platform = process.platform } = {}) {
55
+ const override = env[SETTINGS_DIR_ENV];
56
+ if (typeof override === 'string' && override.trim()) return path.resolve(override.trim());
57
+ if (platform === 'win32') {
58
+ const appData = env.APPDATA ? String(env.APPDATA) : path.join(home, 'AppData', 'Roaming');
59
+ return path.join(appData, 'ryuu');
60
+ }
61
+ if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'ryuu');
62
+ const xdg = typeof env.XDG_CONFIG_HOME === 'string' ? env.XDG_CONFIG_HOME.trim() : '';
63
+ return path.join(xdg ? path.resolve(xdg) : path.join(home, '.config'), 'ryuu');
64
+ }
65
+
66
+ /** Full path of the settings file. */
67
+ export function settingsFile(options = {}) {
68
+ return path.join(settingsDir(options), SETTINGS_FILE_NAME);
69
+ }
70
+
71
+ /** Refuse to read or replace a path that is a symlink. */
72
+ async function assertNotSymlink(file) {
73
+ try {
74
+ const stats = await fsp.lstat(file);
75
+ if (stats.isSymbolicLink()) {
76
+ throw settingsError(
77
+ `Refusing to use the settings file through a symlink: ${file}. Replace it with a regular file.`,
78
+ { file },
79
+ );
80
+ }
81
+ } catch (error) {
82
+ if (error.code === 'ENOENT') return;
83
+ if (error instanceof RyuuError) throw error;
84
+ throw settingsError(`Cannot inspect the settings file ${file}: ${error.message}`, { cause: error, file });
85
+ }
86
+ }
87
+
88
+ function assertSections(data, file) {
89
+ for (const section of SECTIONS) {
90
+ const value = data[section];
91
+ if (value === undefined) continue;
92
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
93
+ throw settingsError(
94
+ `Settings file ${file} has an invalid "${section}" section (expected an object). It was NOT modified.`,
95
+ { file },
96
+ );
97
+ }
98
+ }
99
+ for (const section of LIST_SECTIONS) {
100
+ const value = data[section];
101
+ if (value === undefined) continue;
102
+ if (!Array.isArray(value)) {
103
+ throw settingsError(
104
+ `Settings file ${file} has an invalid "${section}" section (expected an array). It was NOT modified.`,
105
+ { file },
106
+ );
107
+ }
108
+ }
109
+ }
110
+
111
+ /** Read the settings file. Missing is not an error; malformed is. */
112
+ export async function readSettings({ file = null, ...options } = {}) {
113
+ const target = file || settingsFile(options);
114
+ await assertNotSymlink(target);
115
+ let text;
116
+ try {
117
+ text = await fsp.readFile(target, 'utf8');
118
+ } catch (error) {
119
+ if (error.code === 'ENOENT') {
120
+ return { file: target, exists: false, data: { version: SETTINGS_VERSION } };
121
+ }
122
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
123
+ throw settingsError(
124
+ `Permission denied reading settings ${target}. Fix the file permissions or set ${SETTINGS_DIR_ENV}.`,
125
+ { cause: error, file: target },
126
+ );
127
+ }
128
+ if (error.code === 'EISDIR') {
129
+ throw settingsError(`Settings path is a directory, not a file: ${target}`, { file: target });
130
+ }
131
+ throw settingsError(`Cannot read settings ${target}: ${error.message}`, { cause: error, file: target });
132
+ }
133
+
134
+ let data;
135
+ try {
136
+ data = JSON.parse(text);
137
+ } catch (error) {
138
+ throw settingsError(
139
+ `Settings file ${target} is not valid JSON (${error.message}). It was NOT modified: fix it or move it aside before running --settings.`,
140
+ { cause: error, file: target },
141
+ );
142
+ }
143
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
144
+ throw settingsError(`Settings file ${target} must contain a JSON object. It was NOT modified.`, {
145
+ file: target,
146
+ });
147
+ }
148
+ assertSections(data, target);
149
+ return { file: target, exists: true, data };
150
+ }
151
+
152
+ /** A JSON-only deep copy, so a draft never mutates the loaded object. */
153
+ export function cloneSettings(data) {
154
+ return JSON.parse(JSON.stringify(data ?? {}));
155
+ }
156
+
157
+ function segmentsOf(key) {
158
+ const segments = String(key ?? '').split('.');
159
+ if (segments.length === 0 || segments.some((segment) => !segment || BLOCKED_SEGMENTS.has(segment))) {
160
+ throw new Error(`Invalid settings key: ${JSON.stringify(key)}`);
161
+ }
162
+ return segments;
163
+ }
164
+
165
+ /** Read a dotted settings key; undefined when absent. */
166
+ export function getSetting(data, key) {
167
+ let cursor = data;
168
+ for (const segment of segmentsOf(key)) {
169
+ if (!cursor || typeof cursor !== 'object' || !(segment in cursor)) return undefined;
170
+ cursor = cursor[segment];
171
+ }
172
+ return cursor;
173
+ }
174
+
175
+ /** Write a dotted settings key into `data` (mutates it and returns it). */
176
+ export function setSetting(data, key, value) {
177
+ const segments = segmentsOf(key);
178
+ let cursor = data;
179
+ for (const segment of segments.slice(0, -1)) {
180
+ const next = cursor[segment];
181
+ if (!next || typeof next !== 'object' || Array.isArray(next)) cursor[segment] = {};
182
+ cursor = cursor[segment];
183
+ }
184
+ cursor[segments[segments.length - 1]] = value;
185
+ return data;
186
+ }
187
+
188
+ /**
189
+ * Write the settings atomically: a temp file in the same directory, fsync,
190
+ * rename over the target, then owner-only permissions. A crash can never leave
191
+ * a half-written config.json behind.
192
+ */
193
+ export async function writeSettings(data, { file = null, ...options } = {}) {
194
+ const target = file || settingsFile(options);
195
+ const dir = path.dirname(target);
196
+ let json;
197
+ try {
198
+ json = `${JSON.stringify(data, null, 2)}\n`;
199
+ } catch (error) {
200
+ throw settingsError(`Settings cannot be saved: ${error.message}`, { cause: error, file: target });
201
+ }
202
+
203
+ try {
204
+ await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
205
+ } catch (error) {
206
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
207
+ throw settingsError(
208
+ `Permission denied creating the settings directory ${dir}. Set ${SETTINGS_DIR_ENV} to a writable path.`,
209
+ { cause: error, file: target },
210
+ );
211
+ }
212
+ throw settingsError(`Cannot create the settings directory ${dir}: ${error.message}`, {
213
+ cause: error,
214
+ file: target,
215
+ });
216
+ }
217
+ await assertNotSymlink(target);
218
+
219
+ const tmp = path.join(dir, `.${SETTINGS_FILE_NAME}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
220
+ let handle = null;
221
+ try {
222
+ handle = await fsp.open(tmp, 'wx', 0o600);
223
+ await handle.writeFile(json, 'utf8');
224
+ try {
225
+ await handle.sync();
226
+ } catch (error) {
227
+ // Some filesystems do not support fsync on a file handle; the rename is
228
+ // still atomic, so only that durability guarantee is skipped.
229
+ if (!['EINVAL', 'ENOTSUP', 'EISDIR'].includes(error.code)) throw error;
230
+ }
231
+ await handle.close();
232
+ handle = null;
233
+ await fsp.rename(tmp, target);
234
+ // Best effort: Windows ignores POSIX modes; the file is already created 0600.
235
+ if (process.platform !== 'win32') await fsp.chmod(target, 0o600).catch(() => {});
236
+ } catch (error) {
237
+ await handle?.close().catch(() => {});
238
+ await fsp.rm(tmp, { force: true }).catch(() => {});
239
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
240
+ throw settingsError(
241
+ `Permission denied writing settings ${target}. The previous file was left unchanged.`,
242
+ { cause: error, file: target },
243
+ );
244
+ }
245
+ throw settingsError(
246
+ `Failed to save settings ${target}: ${error.message}. The previous file was left unchanged.`,
247
+ { cause: error, file: target },
248
+ );
249
+ }
250
+ return target;
251
+ }
252
+
253
+ /**
254
+ * Read-modify-write one save. Re-reading first means a value saved by another
255
+ * process is never silently overwritten by a stale in-memory copy.
256
+ */
257
+ export async function updateSettings(mutator, { file = null, ...options } = {}) {
258
+ const target = file || settingsFile(options);
259
+ const { data } = await readSettings({ file: target });
260
+ const draft = cloneSettings(data);
261
+ const next = (await mutator(draft)) ?? draft;
262
+ await writeSettings(next, { file: target });
263
+ return next;
264
+ }
265
+
266
+ /** Small facade over one settings file, used by both editors. */
267
+ export class SettingsStore {
268
+ constructor({ file = null, env = process.env, home = undefined, platform = undefined } = {}) {
269
+ this.file = file || settingsFile({ env, home, platform });
270
+ }
271
+
272
+ read() {
273
+ return readSettings({ file: this.file });
274
+ }
275
+
276
+ save(data) {
277
+ return writeSettings(data, { file: this.file });
278
+ }
279
+
280
+ update(mutator) {
281
+ return updateSettings(mutator, { file: this.file });
282
+ }
283
+ }
284
+
285
+ /** A fixed-width mask: the value's length and characters are never revealed. */
286
+ export function maskSettingsSecret(value) {
287
+ const text = typeof value === 'string' ? value.trim() : '';
288
+ return text ? '••••••••' : '(not set)';
289
+ }
290
+
291
+ /** Persisted default provider, or null when the file does not choose one. */
292
+ export function settingsProviderId(settings) {
293
+ return normalizeProviderId(settings?.provider) || null;
294
+ }
295
+
296
+ /** Persisted model for one provider, or null. */
297
+ export function settingsModelFor(settings, providerId) {
298
+ const map = settings?.providerModel;
299
+ if (!map || typeof map !== 'object' || Array.isArray(map)) return null;
300
+ const id = normalizeProviderId(providerId);
301
+ if (!id || typeof map[id] !== 'string') return null;
302
+ return normalizeModelId(map[id]);
303
+ }
304
+