@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
package/src/index.js ADDED
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import process from 'node:process';
6
+ import { buildRawConfig, checkRuntime } from './config/load.js';
7
+ import { validateConfig } from './config/validate.js';
8
+ import { ConfigError, formatErrorBlock } from './utils/errors.js';
9
+ import { createApp } from './cli/app.js';
10
+ import { formatDoctor, runDoctor } from './services/doctor.js';
11
+ import { createActiveProvider } from './providers/index.js';
12
+ import { SettingsStore, settingsModelFor, settingsProviderId } from './config/settings.js';
13
+ import { buildSettingsRegistry } from './config/settings-registry.js';
14
+ import { buildSettingsSkeleton, ensureSettingsFile } from './config/scaffold.js';
15
+ import { buildProviders, parseExtraProviders } from './providers/catalog.js';
16
+ import { SettingsSession, runSettingsEditor } from './cli/settings-editor.js';
17
+ import { SettingsTui } from './cli/settings-tui.js';
18
+ import { applyPersistedModel, applyPersistedProvider, stateDirFor } from './services/preferences.js';
19
+ import { createLogger } from './services/logger.js';
20
+ import { Workspace } from './services/workspace.js';
21
+ import { readVersion } from './utils/version.js';
22
+
23
+ // The version lives in package.json; this module re-exports the reader so the
24
+ // CLI entry point and the tests keep one import site.
25
+ export { readVersion };
26
+
27
+ const HELP = `Ryuu Agent CLI — autonomous coding agent in your terminal
28
+
29
+ Usage:
30
+ ryuu Start the interactive agent
31
+ ryuu "task" Run one task non-interactively
32
+ ryuu --task "task" Same, explicit flag
33
+ echo "task" | ryuu Read the task from stdin
34
+
35
+ Options:
36
+ -h, --help Show this help
37
+ -v, --version Show the version
38
+ --doctor Run diagnostics (offline)
39
+ --live With --doctor: one real API request
40
+ --workspace <path> Workspace override for this run
41
+ --provider <id> Provider override for this run, e.g. tokenharbor
42
+ --model <id> Model override for this run
43
+ --max-steps <n> Optional step cap (0 = unlimited, default)
44
+ -y, --yes Auto-approve file writes (commands still ask)
45
+ --auto-approve Auto-approve ALL actions incl. commands (AI works without asking)
46
+ --read-only Deny all writes and command execution
47
+ --settings Edit the global settings file and exit
48
+ --settings --tui Full-screen settings editor (requires a TTY)
49
+ --settings --list Print the editable settings and exit
50
+ --no-color Disable ANSI colors
51
+ --plain Plain output (no spinner/colors)
52
+ --tui Full-screen UI: scrollable transcript, click/tap
53
+ blocks to expand, live composer (requires a TTY)
54
+ --debug Verbose logs and stack traces
55
+
56
+ Every setting lives in one file: ~/.config/ryuu/config.json (Windows:
57
+ %APPDATA%\\ryuu, macOS: ~/Library/Application Support/ryuu; RYUU_CONFIG_DIR
58
+ overrides the folder). CLI flags win for one run, and the .ryuu preferences file
59
+ of the current workspace keeps the last /provider and /model choice.
60
+
61
+ Commands inside the CLI: /help, /status, /worklog, /files, /tree, /tools,
62
+ /processes, /logs, /expand, /cancel, /history, /sessions, /diff, /undo, /config,
63
+ /doctor, /provider, /model, /addmodel, /delmodel, /cmodel, /custommodel,
64
+ /workspace, /clear, /memory, /resume, /bash, /exit.
65
+ A line starting with "!" runs a shell command yourself (same as /bash).
66
+ While a task runs, slash commands still work; a new prompt waits until it ends.
67
+
68
+ Documentation: README.md · Secrets: provider keys in config.json, written
69
+ owner-only and never printed. Run ryuu --settings to add them.
70
+ Providers: nvidia, tokenharbor; list them with /provider, switch with
71
+ /provider user <name>, or add your own with /provider add <name> <url> [apiKey].
72
+ `;
73
+
74
+ const SETTINGS_HELP = `Ryuu Agent CLI — global settings
75
+
76
+ Usage:
77
+ ryuu --settings Numbered line editor (saves every change immediately)
78
+ ryuu --settings --tui Full-screen editor (requires an interactive terminal)
79
+ ryuu --settings --list Print the editable settings and exit
80
+ ryuu --settings --help Show this help
81
+
82
+ Editor commands:
83
+ <number> <value> Save a value (values may contain spaces)
84
+ /list Redraw the numbered list
85
+ /show <number> Show a setting, its description and constraints
86
+ /reset <number> Reset one setting to its default (asks first)
87
+ /help Show the editor commands
88
+ /exit Leave the editor (each change is already saved)
89
+
90
+ Secrets are always masked in the list and are never shown back.
91
+ The file is created with every key empty on first run (or first save) and
92
+ written atomically with owner-only permissions; a malformed file is reported
93
+ and never overwritten.
94
+
95
+ Precedence: CLI flags > workspace .ryuu preferences > config.json > built-in
96
+ defaults. Environment variables are not read (RYUU_CONFIG_DIR only sets where
97
+ this file lives).
98
+ `;
99
+
100
+ /** Parse argv into options plus a positional task. */
101
+ export function parseArgs(argv) {
102
+ const options = {
103
+ task: null,
104
+ doctor: false,
105
+ live: false,
106
+ help: false,
107
+ version: false,
108
+ tui: false,
109
+ settings: false,
110
+ list: false,
111
+ };
112
+ const positional = [];
113
+ for (let index = 0; index < argv.length; index += 1) {
114
+ const arg = argv[index];
115
+ const [flag, inlineValue] = arg.startsWith('--') && arg.includes('=') ? arg.split(/=(.*)/s, 2) : [arg, undefined];
116
+ const value = () => {
117
+ if (inlineValue !== undefined) return inlineValue;
118
+ index += 1;
119
+ return argv[index];
120
+ };
121
+ switch (flag) {
122
+ case '-h':
123
+ case '--help':
124
+ options.help = true;
125
+ break;
126
+ case '-v':
127
+ case '--version':
128
+ options.version = true;
129
+ break;
130
+ case '--doctor':
131
+ options.doctor = true;
132
+ break;
133
+ case '--live':
134
+ options.live = true;
135
+ break;
136
+ case '--task':
137
+ options.task = value();
138
+ break;
139
+ case '--workspace':
140
+ options.workspace = value();
141
+ break;
142
+ case '--model':
143
+ options.model = value();
144
+ break;
145
+ case '--provider':
146
+ options.provider = value();
147
+ break;
148
+ case '--max-steps':
149
+ options.maxSteps = value();
150
+ break;
151
+ case '-y':
152
+ case '--yes':
153
+ options.yes = true;
154
+ break;
155
+ case '--auto-approve':
156
+ options.autoApprove = true;
157
+ break;
158
+ case '--read-only':
159
+ options.readOnly = true;
160
+ break;
161
+ case '--no-color':
162
+ options.color = false;
163
+ break;
164
+ case '--plain':
165
+ options.plain = true;
166
+ break;
167
+ case '--tui':
168
+ options.tui = true;
169
+ break;
170
+ case '--settings':
171
+ options.settings = true;
172
+ break;
173
+ case '--list':
174
+ options.list = true;
175
+ break;
176
+ case '--debug':
177
+ options.debug = true;
178
+ break;
179
+ case '--':
180
+ positional.push(...argv.slice(index + 1));
181
+ index = argv.length;
182
+ break;
183
+ default:
184
+ if (arg.startsWith('-')) {
185
+ throw new ConfigError(`Unknown option: ${arg}. Run ryuu --help.`);
186
+ }
187
+ positional.push(arg);
188
+ }
189
+ }
190
+ if (!options.task && positional.length > 0) options.task = positional.join(' ');
191
+ return options;
192
+ }
193
+
194
+ async function readStdin() {
195
+ if (process.stdin.isTTY) return '';
196
+ let text = '';
197
+ for await (const chunk of process.stdin) text += chunk;
198
+ return text.trim();
199
+ }
200
+
201
+ function applyOverrides(raw, options) {
202
+ if (options.workspace) raw.workspace = options.workspace;
203
+ if (options.provider) raw.provider = options.provider;
204
+ if (options.model) raw.model = options.model;
205
+ if (options.maxSteps !== undefined) raw.maxSteps = options.maxSteps;
206
+ if (options.readOnly) raw.approvalPolicy = 'read-only';
207
+ if (options.debug) {
208
+ raw.debug = 'true';
209
+ raw.logLevel = 'debug';
210
+ }
211
+ return raw;
212
+ }
213
+
214
+ /** Providers available to the settings editor (built-ins plus extraProviders). */
215
+ function settingsProviders(settings) {
216
+ const raw = buildRawConfig(settings);
217
+ let extras = [];
218
+ try {
219
+ extras = parseExtraProviders(raw.extraProviders, []);
220
+ } catch {
221
+ extras = [];
222
+ }
223
+ return buildProviders({ credentials: raw.providerCredentials, extras });
224
+ }
225
+
226
+ /**
227
+ * `ryuu --settings [--tui|--list]`.
228
+ *
229
+ * Only the settings file and the provider catalog are touched: no agent, no
230
+ * tools, no provider client and no model request are created here.
231
+ */
232
+ async function runSettingsMode(options) {
233
+ const store = new SettingsStore();
234
+ const { data, exists } = await store.read();
235
+ const makeRegistry = (current) =>
236
+ buildSettingsRegistry({ providers: settingsProviders(current), settings: current });
237
+
238
+ if (options.list) {
239
+ const session = new SettingsSession({ store, makeRegistry, data, file: store.file });
240
+ process.stdout.write(`${session.listLines().join('\n')}\n`);
241
+ return 0;
242
+ }
243
+
244
+ // A missing file is initialized before the editor opens, so an immediate exit
245
+ // still leaves a private (0600) settings file behind instead of nothing. The
246
+ // scaffold lists every editable key with an empty value.
247
+ if (!exists) await store.save(buildSettingsSkeleton());
248
+
249
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
250
+ if (!interactive) {
251
+ const needed = options.tui
252
+ ? '--settings --tui needs an interactive terminal.'
253
+ : '--settings needs an interactive terminal.';
254
+ process.stderr.write(`[error] ${needed} Use \`ryuu --settings --list\` for a non-interactive listing.\n`);
255
+ return 2;
256
+ }
257
+ if (options.tui) {
258
+ const tui = new SettingsTui({
259
+ input: process.stdin,
260
+ output: process.stdout,
261
+ store,
262
+ makeRegistry,
263
+ data,
264
+ version: readVersion(),
265
+ color: options.color ?? null,
266
+ });
267
+ try {
268
+ await tui.run();
269
+ } finally {
270
+ tui.close();
271
+ }
272
+ return 0;
273
+ }
274
+ await runSettingsEditor({ store, makeRegistry, data });
275
+ return 0;
276
+ }
277
+
278
+ export async function main(argv = process.argv.slice(2)) {
279
+ // Wipe the terminal when the CLI is invoked so the banner starts on a clean
280
+ // screen. console.clear() is a no-op when stdout is not a TTY (pipes, CI).
281
+ console.clear();
282
+ let options;
283
+ try {
284
+ options = parseArgs(argv);
285
+ } catch (error) {
286
+ process.stderr.write(`${error.message}\n`);
287
+ return 2;
288
+ }
289
+
290
+ if (options.help) {
291
+ process.stdout.write(options.settings ? SETTINGS_HELP : HELP);
292
+ return 0;
293
+ }
294
+ if (options.version) {
295
+ process.stdout.write(`ryuu-agent-cli ${readVersion()}\n`);
296
+ return 0;
297
+ }
298
+ if (options.list && !options.settings) {
299
+ process.stderr.write('--list is only valid with --settings. Run ryuu --settings --list.\n');
300
+ return 2;
301
+ }
302
+
303
+ try {
304
+ checkRuntime();
305
+ } catch (error) {
306
+ process.stderr.write(`${formatErrorBlock(error)}\n`);
307
+ return 2;
308
+ }
309
+
310
+ // Settings mode never builds the agent, tools or a provider: it only reads
311
+ // and writes the global settings file.
312
+ if (options.settings) {
313
+ try {
314
+ return await runSettingsMode(options);
315
+ } catch (error) {
316
+ process.stderr.write(`${formatErrorBlock(error, { debug: options.debug })}\n`);
317
+ return 2;
318
+ }
319
+ }
320
+
321
+ // config.json is the only configuration source. A missing file is created
322
+ // with every key present but empty, so a new install has something to edit; a
323
+ // malformed file warns and is left untouched, and the resulting "no API key"
324
+ // error points at --settings.
325
+ let settings = null;
326
+ try {
327
+ settings = (await ensureSettingsFile()).data;
328
+ } catch (error) {
329
+ process.stderr.write(`[warn] ${error.message}\n`);
330
+ }
331
+
332
+ let config;
333
+ try {
334
+ const raw = applyOverrides(buildRawConfig(settings ?? {}, { cwd: process.cwd() }), options);
335
+ // A provider/model picked with /provider and /model is persisted under
336
+ // <workspace>/.ryuu and wins over config.json on later runs; explicit
337
+ // --provider/--model flags win for a single run and never rewrite the
338
+ // saved choice. config.json is the fallback before the catalog defaults.
339
+ const storedProvider = settings ? settingsProviderId(settings) : null;
340
+ raw.provider = options.provider || null;
341
+ raw.model = null;
342
+ const stateDir = stateDirFor(raw.workspace);
343
+ await applyPersistedProvider(raw, { flagProvider: options.provider, stateDir });
344
+ if (!raw.provider) raw.provider = storedProvider;
345
+ await applyPersistedModel(raw, {
346
+ flagModel: options.model,
347
+ stateDir,
348
+ providerId: raw.provider || null,
349
+ });
350
+ if (!raw.model && settings && raw.provider) raw.model = settingsModelFor(settings, raw.provider);
351
+ const result = validateConfig(raw, { requireApiKey: !options.doctor, cwd: process.cwd() });
352
+ config = result.config;
353
+ for (const warning of result.warnings) process.stderr.write(`[warn] ${warning}\n`);
354
+ } catch (error) {
355
+ process.stderr.write(`${formatErrorBlock(error, { debug: options.debug })}\n`);
356
+ return 2;
357
+ }
358
+
359
+ if (options.doctor) {
360
+ const logger = createLogger(config, { stderr: process.stderr });
361
+ let workspace = null;
362
+ try {
363
+ workspace = new Workspace(config.workspace, { logger });
364
+ const provider = config.apiKey ? createActiveProvider(config, { logger }) : null;
365
+ const report = await runDoctor({ config, workspace, provider, live: options.live, logger });
366
+ process.stdout.write(`${formatDoctor(report)}\n`);
367
+ return report.ok ? 0 : 1;
368
+ } catch (error) {
369
+ process.stderr.write(`${formatErrorBlock(error, { debug: options.debug })}\n`);
370
+ return 1;
371
+ }
372
+ }
373
+
374
+ const task = options.task || (await readStdin());
375
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !options.task && !task;
376
+
377
+ let app;
378
+ try {
379
+ app = await createApp({
380
+ config,
381
+ cliOptions: {
382
+ interactive,
383
+ yes: options.yes,
384
+ autoApprove: options.autoApprove,
385
+ plain: options.plain || !interactive,
386
+ color: options.color,
387
+ tui: options.tui,
388
+ },
389
+ version: readVersion(),
390
+ });
391
+ } catch (error) {
392
+ process.stderr.write(`${formatErrorBlock(error, { debug: options.debug })}\n`);
393
+ return 2;
394
+ }
395
+
396
+ if (interactive) {
397
+ await app.runInteractive();
398
+ // Belt and braces: whatever the UI left attached, a paused stdin lets the
399
+ // process end as soon as /exit returns, instead of waiting for a keypress.
400
+ process.stdin.pause?.();
401
+ return 0;
402
+ }
403
+
404
+ if (!task) {
405
+ process.stderr.write('No task provided in non-interactive mode.\n\n');
406
+ process.stderr.write(HELP);
407
+ await app.close();
408
+ return 2;
409
+ }
410
+
411
+ app.banner();
412
+ app.renderer.info(
413
+ options.autoApprove
414
+ ? 'Non-interactive mode: --auto-approve is active; all writes, commands and destructive actions are auto-approved. Shell execution is NOT a sandbox.'
415
+ : "Non-interactive mode: approvals are denied unless --yes (writes) or --auto-approve (all actions) is used.",
416
+ );
417
+ try {
418
+ const summary = await app.runTask(task);
419
+ await app.close();
420
+ if (summary.status === 'completed' || summary.status === 'step-limit') return summary.status === 'completed' ? 0 : 1;
421
+ if (summary.status === 'cancelled') return 130;
422
+ return 1;
423
+ } catch (error) {
424
+ process.stderr.write(`${formatErrorBlock(error, { debug: options.debug })}\n`);
425
+ await app.close();
426
+ return 3;
427
+ }
428
+ }
429
+
430
+ function isInvokedDirectly() {
431
+ if (!process.argv[1]) return false;
432
+ try {
433
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
434
+ } catch {
435
+ return path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
436
+ }
437
+ }
438
+
439
+ const invokedDirectly = isInvokedDirectly();
440
+ if (invokedDirectly) {
441
+ main()
442
+ .then((code) => {
443
+ process.exitCode = code;
444
+ })
445
+ .catch((error) => {
446
+ process.stderr.write(`${formatErrorBlock(error, { debug: true })}\n`);
447
+ process.exitCode = 1;
448
+ });
449
+ }