@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,1155 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_PROVIDER_ID,
|
|
4
|
+
MAX_EXTRA_PROVIDERS,
|
|
5
|
+
modelsFor,
|
|
6
|
+
normalizeModelId,
|
|
7
|
+
normalizeProviderId,
|
|
8
|
+
providerById,
|
|
9
|
+
} from '../providers/catalog.js';
|
|
10
|
+
import {
|
|
11
|
+
addExtraProvider,
|
|
12
|
+
addExtraProviderModel,
|
|
13
|
+
isBuiltinProviderId,
|
|
14
|
+
removeExtraProvider,
|
|
15
|
+
removeExtraProviderModel,
|
|
16
|
+
} from '../services/provider-settings.js';
|
|
17
|
+
import {
|
|
18
|
+
MAX_CUSTOM_MODELS,
|
|
19
|
+
addCustomModel,
|
|
20
|
+
preferencesFile,
|
|
21
|
+
readCustomModels,
|
|
22
|
+
removeCustomModel,
|
|
23
|
+
stateDirFor,
|
|
24
|
+
writeModelPreference,
|
|
25
|
+
} from '../services/preferences.js';
|
|
26
|
+
import { describeConfig } from '../config/validate.js';
|
|
27
|
+
import { describeReasoning } from '../providers/river.js';
|
|
28
|
+
import { formatDoctor, runDoctor } from '../services/doctor.js';
|
|
29
|
+
import { createUnifiedDiff, diffStats } from '../services/diff.js';
|
|
30
|
+
import { PROVIDER_INFO } from '../services/websearch/providers.js';
|
|
31
|
+
import { runUserShell } from '../services/ushell.js';
|
|
32
|
+
import { RyuuError } from '../utils/errors.js';
|
|
33
|
+
|
|
34
|
+
export function parseCommandLine(line) {
|
|
35
|
+
const trimmed = String(line || '').trim();
|
|
36
|
+
if (!trimmed.startsWith('/')) return null;
|
|
37
|
+
const [name, ...rest] = trimmed.slice(1).split(/\s+/);
|
|
38
|
+
if (!name) return null;
|
|
39
|
+
return { name: name.toLowerCase(), args: rest.join(' '), parts: rest };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `!cmd` is a shell line, not a prompt: it runs `/bash cmd` without the model.
|
|
44
|
+
* Returns null for anything that is not a shell line.
|
|
45
|
+
*/
|
|
46
|
+
export function shellCommandFromLine(line) {
|
|
47
|
+
const trimmed = String(line ?? '').trim();
|
|
48
|
+
if (!trimmed.startsWith('!')) return null;
|
|
49
|
+
const command = trimmed.slice(1).trim();
|
|
50
|
+
return { name: 'bash', args: command, parts: command ? command.split(/\s+/) : [] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatBytes(bytes) {
|
|
54
|
+
if (bytes === null || bytes === undefined) return '';
|
|
55
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
56
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
57
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* CLI flags accepted at startup (see parseArgs in src/index.js), shown by
|
|
62
|
+
* /help so users can discover them without leaving the REPL.
|
|
63
|
+
*/
|
|
64
|
+
const CLI_FLAGS = [
|
|
65
|
+
['--help', 'show the startup help and exit'],
|
|
66
|
+
['--version', 'show the version and exit'],
|
|
67
|
+
['--doctor', 'run offline diagnostics'],
|
|
68
|
+
['--doctor --live', 'diagnostics plus one real API request'],
|
|
69
|
+
['--workspace <path>', 'workspace override for this run'],
|
|
70
|
+
['--provider <id>', 'provider override for this run, e.g. tokenharbor'],
|
|
71
|
+
['--model <id>', 'model override for this run'],
|
|
72
|
+
['--max-steps <n>', 'step cap; 0 = unlimited (default)'],
|
|
73
|
+
['--yes', 'auto-approve file writes; commands still ask'],
|
|
74
|
+
['--auto-approve', 'auto-approve ALL actions incl. commands; the agent works without asking'],
|
|
75
|
+
['--read-only', 'deny all writes and command execution'],
|
|
76
|
+
['--no-color', 'disable ANSI colors'],
|
|
77
|
+
['--plain', 'plain output; no spinner or colors'],
|
|
78
|
+
['--tui', 'full-screen UI with expandable blocks (needs a TTY)'],
|
|
79
|
+
['--debug', 'verbose logs and stack traces'],
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Slash commands. Handlers print through the renderer and may return
|
|
84
|
+
* { exit: true } to leave the REPL.
|
|
85
|
+
*/
|
|
86
|
+
export function createCommands(state) {
|
|
87
|
+
const { config, workspace, registry, processes, sessionStore, undo, renderer, provider, interactive } = state;
|
|
88
|
+
const webSearch = state.webSearch || registry?.webSearchManager || null;
|
|
89
|
+
|
|
90
|
+
const print = (text) => renderer.lines(text);
|
|
91
|
+
|
|
92
|
+
const commands = new Map();
|
|
93
|
+
|
|
94
|
+
// `details` is the optional tutorial shown by `/help <command>`.
|
|
95
|
+
const define = (name, description, usage, handler, details = null) => {
|
|
96
|
+
commands.set(name, { name, description, usage, handler, details });
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const stateDir = () => stateDirFor(config.workspace);
|
|
100
|
+
|
|
101
|
+
/** Effective provider descriptors (built-in plus config.json extraProviders). */
|
|
102
|
+
const providerList = () => (Array.isArray(config.providers) ? config.providers : []);
|
|
103
|
+
const currentProviderId = () => config.providerId || DEFAULT_PROVIDER_ID;
|
|
104
|
+
const currentProvider = () => providerById(providerList(), currentProviderId()) || providerList()[0] || null;
|
|
105
|
+
|
|
106
|
+
/** Provider catalog models plus the ids added with /addmodel, de-duplicated. */
|
|
107
|
+
const modelList = async () => {
|
|
108
|
+
const provider = currentProvider();
|
|
109
|
+
const custom = await readCustomModels(stateDir(), provider?.id || currentProviderId());
|
|
110
|
+
const entries = modelsFor(provider, custom);
|
|
111
|
+
return { custom, entries, all: entries.map((entry) => entry.id), provider };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Strip one pair of surrounding quotes (`/provider add "river" "https://…"`). */
|
|
115
|
+
const unquote = (value) => {
|
|
116
|
+
const text = String(value ?? '').trim();
|
|
117
|
+
if (text.length >= 2) {
|
|
118
|
+
const first = text[0];
|
|
119
|
+
const last = text[text.length - 1];
|
|
120
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) return text.slice(1, -1).trim();
|
|
121
|
+
}
|
|
122
|
+
return text;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Split arguments on whitespace while keeping quoted values together. */
|
|
126
|
+
const tokenize = (args) => {
|
|
127
|
+
const out = [];
|
|
128
|
+
let current = '';
|
|
129
|
+
let quote = null;
|
|
130
|
+
for (const char of String(args ?? '')) {
|
|
131
|
+
if (quote) {
|
|
132
|
+
if (char === quote) quote = null;
|
|
133
|
+
else current += char;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (char === '"' || char === "'") {
|
|
137
|
+
quote = char;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (/\s/.test(char)) {
|
|
141
|
+
if (current) out.push(current);
|
|
142
|
+
current = '';
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
current += char;
|
|
146
|
+
}
|
|
147
|
+
if (current) out.push(current);
|
|
148
|
+
return out.map(unquote).filter((part) => part !== '');
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Find a provider by id or display name (case-insensitive). */
|
|
152
|
+
const findProvider = (reference) => {
|
|
153
|
+
const id = normalizeProviderId(reference);
|
|
154
|
+
const name = String(reference ?? '').trim().toLowerCase();
|
|
155
|
+
return (
|
|
156
|
+
providerList().find((entry) => entry.id === id) ||
|
|
157
|
+
providerList().find((entry) => String(entry.name || '').trim().toLowerCase() === name) ||
|
|
158
|
+
null
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const modelLabel = (entry) => (entry.name && entry.name !== entry.id ? ` (${entry.name})` : '');
|
|
163
|
+
const capabilityText = (capabilities = {}) =>
|
|
164
|
+
[
|
|
165
|
+
capabilities.streaming === false ? null : 'streaming',
|
|
166
|
+
capabilities.tools === false ? null : 'tools',
|
|
167
|
+
capabilities.reasoning ? 'reasoning' : null,
|
|
168
|
+
capabilities.multimodal ? 'multimodal' : null,
|
|
169
|
+
]
|
|
170
|
+
.filter(Boolean)
|
|
171
|
+
.join('+') || 'text only';
|
|
172
|
+
|
|
173
|
+
define('help', 'Show commands or details for one command', '/help [command]', async (args) => {
|
|
174
|
+
const target = args.trim().replace(/^\//, '');
|
|
175
|
+
if (target) {
|
|
176
|
+
const command = commands.get(target);
|
|
177
|
+
if (!command) return print(`Unknown command: /${target}. Run /help.`);
|
|
178
|
+
const details = command.details ? `\n\n${command.details}` : '';
|
|
179
|
+
return print(`/${command.name} ${command.usage}\n ${command.description}${details}`);
|
|
180
|
+
}
|
|
181
|
+
const rows = [...commands.values()].map((command) => ` /${command.name.padEnd(12)} ${command.description}`);
|
|
182
|
+
const flagRows = CLI_FLAGS.map(([flag, description]) => ` ${flag.padEnd(20)} ${description}`);
|
|
183
|
+
print(
|
|
184
|
+
[
|
|
185
|
+
'Ryuu commands',
|
|
186
|
+
...rows,
|
|
187
|
+
'',
|
|
188
|
+
'CLI flags (set at startup, e.g. `ryuu --auto-approve "task"`, and listed in `ryuu --help`):',
|
|
189
|
+
...flagRows,
|
|
190
|
+
'',
|
|
191
|
+
'Anything not starting with / is sent to the agent as a task.',
|
|
192
|
+
'Run /help <command> for usage and examples (e.g. /help addmodel).',
|
|
193
|
+
].join('\n'),
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
define('exit', 'Exit the CLI', '/exit', async () => ({ exit: true }));
|
|
198
|
+
|
|
199
|
+
define('clear', 'Forget conversation memory so the next task starts fresh', '/clear', async () => {
|
|
200
|
+
const agent = state.getAgent();
|
|
201
|
+
if (agent?.session) agent.session = null;
|
|
202
|
+
agent?.clearMemory?.();
|
|
203
|
+
undo.reset(null);
|
|
204
|
+
print('Conversation memory and the session worklog were cleared. The next task starts with no prior context; saved session files are untouched.');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
define('memory', 'Show what the agent remembers between tasks', '/memory [clear]', async (args) => {
|
|
208
|
+
const sub = args.trim().toLowerCase();
|
|
209
|
+
const agent = state.getAgent();
|
|
210
|
+
if (sub === 'clear') {
|
|
211
|
+
agent?.clearMemory?.();
|
|
212
|
+
return print('Conversation memory cleared.');
|
|
213
|
+
}
|
|
214
|
+
const conversation = agent?.getConversation?.() || [];
|
|
215
|
+
if (conversation.length === 0) {
|
|
216
|
+
return print(
|
|
217
|
+
'Conversation memory is empty.\nEvery completed task is remembered for the next one in this session; /clear forgets it.',
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const tasks = conversation.filter((entry) => entry.role === 'user');
|
|
221
|
+
const chars = conversation.reduce((total, entry) => total + String(entry.content || '').length, 0);
|
|
222
|
+
print(
|
|
223
|
+
[
|
|
224
|
+
`Conversation memory: ${tasks.length} task(s) · ${conversation.length} message(s) · ${chars} chars`,
|
|
225
|
+
`Worklog: ${agent?.getWorklog?.()?.size ?? 0} verified fact(s) — run /worklog to inspect.`,
|
|
226
|
+
'Remembered tasks (oldest first):',
|
|
227
|
+
...tasks.map((entry) => ` · ${String(entry.content).replace(/\s+/g, ' ').slice(0, 110)}`),
|
|
228
|
+
'',
|
|
229
|
+
'Used as context for the next task. /clear forgets it, /resume <id> loads a saved one.',
|
|
230
|
+
].join('\n'),
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
define('worklog', 'Show the verified facts the agent will reuse instead of re-reading', '/worklog', async () => {
|
|
235
|
+
const agent = state.getAgent();
|
|
236
|
+
const worklog = agent?.getWorklog?.();
|
|
237
|
+
if (!worklog || worklog.size === 0) {
|
|
238
|
+
return print('The worklog is empty. Files read/changed, commands and findings are recorded as the agent works.');
|
|
239
|
+
}
|
|
240
|
+
print(worklog.brief({ maxChars: 8000 }));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
define('resume', 'Restore a saved session\u2019s conversation memory', '/resume <session-id>', async (args) => {
|
|
244
|
+
const id = args.trim();
|
|
245
|
+
if (!id) return print('Usage: /resume <session-id> (list ids with /history)');
|
|
246
|
+
const agent = state.getAgent();
|
|
247
|
+
if (!agent) return print('Agent is not available.');
|
|
248
|
+
const session = await sessionStore.load(id);
|
|
249
|
+
const conversation = Array.isArray(session.conversation) ? session.conversation : [];
|
|
250
|
+
if (conversation.length === 0) {
|
|
251
|
+
return print(`Session ${id} has no stored conversation memory to resume.`);
|
|
252
|
+
}
|
|
253
|
+
agent.setConversation(conversation, { worklog: session.worklog || null });
|
|
254
|
+
print(`Resumed ${conversation.length} message(s) from session ${id}.`);
|
|
255
|
+
print(
|
|
256
|
+
session.worklog
|
|
257
|
+
? 'The saved worklog was restored too, so files already read or changed are not re-read.'
|
|
258
|
+
: 'The next task will be answered with that context. Run /memory to inspect it.',
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
define('status', 'Show provider, model, workspace, limits and the last task result', '/status', async () => {
|
|
263
|
+
const last = state.getLastSummary();
|
|
264
|
+
const running = processes.list({ includeCompleted: false }).filter((proc) => proc.status === 'running');
|
|
265
|
+
// The reasoning effort that will actually be sent, so a provider that
|
|
266
|
+
// thinks only when asked (River's DeepSeek models) is visible at a glance.
|
|
267
|
+
const reasoningLine = describeReasoning(config);
|
|
268
|
+
const lines = [
|
|
269
|
+
`model ${config.model}`,
|
|
270
|
+
`provider ${config.providerName || 'NVIDIA Integrate API'} (${currentProviderId()})`,
|
|
271
|
+
`endpoint ${config.baseURL}`,
|
|
272
|
+
`key ${config.apiKeyPath || 'providers.nvidia.apiKey'} ${config.apiKey ? 'set (redacted)' : '(not set)'}`,
|
|
273
|
+
`caps ${capabilityText(config.capabilities)}`,
|
|
274
|
+
...(reasoningLine ? [`reasoning ${reasoningLine}`] : []),
|
|
275
|
+
`workspace ${workspace.root}`,
|
|
276
|
+
`node ${process.version} (${process.platform})`,
|
|
277
|
+
`approval ${config.approvalPolicy}`,
|
|
278
|
+
`limits ${config.maxSteps > 0 ? `${config.maxSteps} steps` : 'unlimited steps'} · ${config.maxOutputTokens} output tokens · ${config.requestTimeoutMs}ms timeout`,
|
|
279
|
+
`state ${config.historyDir}`,
|
|
280
|
+
`log ${config.logDir}/agent.log`,
|
|
281
|
+
`processes ${running.length} running`,
|
|
282
|
+
`undo ${undo.size} recorded change(s)`,
|
|
283
|
+
];
|
|
284
|
+
if (last) {
|
|
285
|
+
lines.push(
|
|
286
|
+
'',
|
|
287
|
+
`last task ${last.status} · ${last.steps} step(s) · ${(last.durationMs / 1000).toFixed(1)}s`,
|
|
288
|
+
`changed ${last.changedFiles.length ? last.changedFiles.join(', ') : 'none'}`,
|
|
289
|
+
`tokens ${last.tokenUsage.prompt} prompt / ${last.tokenUsage.completion} completion`,
|
|
290
|
+
);
|
|
291
|
+
if (last.metrics) {
|
|
292
|
+
lines.push(
|
|
293
|
+
`model ${last.metrics.modelCalls} call(s) · avg ${((last.metrics.avgRequestMs || 0) / 1000).toFixed(1)}s` +
|
|
294
|
+
(last.metrics.firstTokenMs !== null ? ` · first token ${(last.metrics.firstTokenMs / 1000).toFixed(1)}s` : '') +
|
|
295
|
+
(last.metrics.cachedReads ? ` · ${last.metrics.cachedReads} read(s) reused` : ''),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (state.getActiveTask()) lines.push('', 'A task is currently running. Use /cancel to abort it.');
|
|
300
|
+
print(lines.join('\n'));
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const MODEL_LIST_HINT =
|
|
304
|
+
'Add a model with /addmodel <provider> <model-id>, remove one with /delmodel <provider> <model-id>.\n' +
|
|
305
|
+
'The CLI does not check whether a model exists on the server; a wrong id fails when used.\n' +
|
|
306
|
+
'Models are listed per provider: /provider user <name> switches first.';
|
|
307
|
+
|
|
308
|
+
const PROVIDER_COMMANDS = [
|
|
309
|
+
' /provider list providers, keys and defaults',
|
|
310
|
+
' /provider user <name> switch provider (alias: /provider use <name>)',
|
|
311
|
+
' /provider add <name> <url> [key] add your own OpenAI-compatible provider',
|
|
312
|
+
' /provider del <name> remove a provider you added',
|
|
313
|
+
];
|
|
314
|
+
|
|
315
|
+
const switchProviderTo = async (reference) => {
|
|
316
|
+
if (typeof state.switchProvider !== 'function') {
|
|
317
|
+
return print('Provider switching is not available in this session.');
|
|
318
|
+
}
|
|
319
|
+
const known = findProvider(reference);
|
|
320
|
+
if (!known) {
|
|
321
|
+
return print(
|
|
322
|
+
`Unknown provider: ${reference}. Known providers: ${providerList().map((entry) => entry.id).join(', ')}.`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
const selection = await state.switchProvider(known.id);
|
|
327
|
+
print(
|
|
328
|
+
`Provider set to ${selection.providerName} (${selection.providerId}) · model ${selection.model}` +
|
|
329
|
+
(selection.persisted ? '; saved, so it stays selected in future runs.' : '.') +
|
|
330
|
+
' Conversation history and the worklog were kept.',
|
|
331
|
+
);
|
|
332
|
+
for (const note of state.providerLimitations?.() || []) print(note);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
print(`Cannot switch provider: ${error.message}`);
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
define(
|
|
339
|
+
'provider',
|
|
340
|
+
'List providers, switch with /provider user <name>, or add/del your own',
|
|
341
|
+
'/provider [user <name> | add <name> <url> [apiKey] | del <name>]',
|
|
342
|
+
async (args) => {
|
|
343
|
+
const parts = tokenize(args);
|
|
344
|
+
const list = providerList();
|
|
345
|
+
if (list.length === 0) {
|
|
346
|
+
return print('No providers are configured. This build has no provider catalog.');
|
|
347
|
+
}
|
|
348
|
+
if (parts.length === 0) {
|
|
349
|
+
const rows = list.map((entry) => {
|
|
350
|
+
const active = entry.id === currentProviderId();
|
|
351
|
+
const key = entry.apiKey ? 'key set (redacted)' : `no key (${entry.apiKeyPath})`;
|
|
352
|
+
const models = `${entry.models?.length || 0} model(s) · default ${entry.defaultModel}`;
|
|
353
|
+
return ` ${entry.id.padEnd(12)} ${String(entry.name).padEnd(24)} ${key.padEnd(28)} ${models}${active ? ' \u2190 current' : ''}`;
|
|
354
|
+
});
|
|
355
|
+
return print(
|
|
356
|
+
[
|
|
357
|
+
'PROVIDERS',
|
|
358
|
+
...rows,
|
|
359
|
+
'',
|
|
360
|
+
`Current provider: ${config.providerName || currentProviderId()} (${currentProviderId()}) · model ${config.model}`,
|
|
361
|
+
'A key is only ever shown as set/not set; values are never printed.',
|
|
362
|
+
...PROVIDER_COMMANDS,
|
|
363
|
+
].join('\n'),
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const sub = parts[0].toLowerCase();
|
|
368
|
+
|
|
369
|
+
if (sub === 'add') {
|
|
370
|
+
const name = parts[1] || '';
|
|
371
|
+
const url = parts[2] || '';
|
|
372
|
+
const apiKey = parts[3] || '';
|
|
373
|
+
if (!name || !url) {
|
|
374
|
+
return print(
|
|
375
|
+
[
|
|
376
|
+
'Usage: /provider add <name> <url> [apiKey]',
|
|
377
|
+
'Example: /provider add river https://api.river.ai/v1 rv_…',
|
|
378
|
+
'The name becomes the provider id; add models with /addmodel <name> <model-id>.',
|
|
379
|
+
].join('\n'),
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
if (!normalizeProviderId(name)) {
|
|
383
|
+
return print(
|
|
384
|
+
`"${name}" is not a usable provider name. Use lowercase letters, digits, _ and - (no spaces).`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
if (isBuiltinProviderId(name)) {
|
|
388
|
+
return print(`${name} is a built-in provider and cannot be added. Set its key in config.json instead.`);
|
|
389
|
+
}
|
|
390
|
+
const result = await addExtraProvider({ name, baseURL: url, apiKey });
|
|
391
|
+
if (!result.ok) {
|
|
392
|
+
const reasons = {
|
|
393
|
+
exists: `${name} is already a provider. Pass a key as the third argument to update it.`,
|
|
394
|
+
url: `"${url}" is not a usable base URL. Use an http(s) URL without embedded credentials.`,
|
|
395
|
+
limit: `At most ${MAX_EXTRA_PROVIDERS} user providers can be stored. Remove one with /provider del <name> first.`,
|
|
396
|
+
write: 'Could not update config.json. Nothing was changed.',
|
|
397
|
+
invalid: `"${name}" is not a usable provider name.`,
|
|
398
|
+
builtin: `${name} is a built-in provider and cannot be added.`,
|
|
399
|
+
};
|
|
400
|
+
return print(reasons[result.reason] || 'Could not add that provider.');
|
|
401
|
+
}
|
|
402
|
+
await state.reloadProviders?.();
|
|
403
|
+
const added = findProvider(result.id);
|
|
404
|
+
const lines = [
|
|
405
|
+
result.updated
|
|
406
|
+
? `Updated the key for provider ${result.id} (always shown as redacted).`
|
|
407
|
+
: `Added provider ${result.id} (${result.baseURL}).`,
|
|
408
|
+
];
|
|
409
|
+
if (!result.updated) {
|
|
410
|
+
lines.push(
|
|
411
|
+
added?.apiKey
|
|
412
|
+
? 'Key saved (always shown as redacted).'
|
|
413
|
+
: `No key yet: pass it as the third argument or set providers.${result.id}.apiKey in config.json.`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
lines.push(
|
|
417
|
+
added?.models?.length
|
|
418
|
+
? `${added.models.length} model(s) available. Switch with /provider user ${result.id}.`
|
|
419
|
+
: `No models yet: add one with /addmodel ${result.id} <model-id>, then switch with /provider user ${result.id}.`,
|
|
420
|
+
);
|
|
421
|
+
return print(lines.join('\n'));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (sub === 'del' || sub === 'delete' || sub === 'rm') {
|
|
425
|
+
const name = parts[1] || '';
|
|
426
|
+
if (!name) {
|
|
427
|
+
return print(
|
|
428
|
+
['Usage: /provider del <name>', 'Only providers added with /provider add can be removed.'].join('\n'),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
const known = findProvider(name);
|
|
432
|
+
if (!known) {
|
|
433
|
+
return print(`Unknown provider: ${name}. Known providers: ${list.map((entry) => entry.id).join(', ')}.`);
|
|
434
|
+
}
|
|
435
|
+
if (isBuiltinProviderId(known.id)) {
|
|
436
|
+
return print(
|
|
437
|
+
`${known.id} is a built-in provider and cannot be removed. /provider del only removes providers added with /provider add.`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
const result = await removeExtraProvider(known.id, { active: known.id === currentProviderId() });
|
|
441
|
+
if (!result.ok) {
|
|
442
|
+
const reasons = {
|
|
443
|
+
active: `${known.id} is the current provider. Switch to another provider first (/provider user <name>), then delete it.`,
|
|
444
|
+
missing: `${known.id} is not a user provider, so there is nothing to delete.`,
|
|
445
|
+
write: 'Could not update config.json. Nothing was changed.',
|
|
446
|
+
};
|
|
447
|
+
return print(reasons[result.reason] || 'Could not delete that provider.');
|
|
448
|
+
}
|
|
449
|
+
await state.reloadProviders?.();
|
|
450
|
+
return print(`Removed provider ${known.id} and its stored key/model from config.json.`);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const reference = sub === 'user' || sub === 'use' ? parts[1] || '' : parts[0];
|
|
454
|
+
if (!reference) return print(['Usage: /provider user <name>', ...PROVIDER_COMMANDS].join('\n'));
|
|
455
|
+
return switchProviderTo(reference);
|
|
456
|
+
},
|
|
457
|
+
[
|
|
458
|
+
' How to use:',
|
|
459
|
+
' 1. /provider list providers, keys and defaults',
|
|
460
|
+
' 2. /provider add river https://api.river.ai/v1 rv_…',
|
|
461
|
+
' 3. /addmodel river deepseek-ai/DeepSeek-V4.1-Flash-1017',
|
|
462
|
+
' 4. /provider user river switch for this and later runs',
|
|
463
|
+
' 5. /model list that provider\u2019s models',
|
|
464
|
+
'',
|
|
465
|
+
' Notes:',
|
|
466
|
+
' - /provider add stores the provider in config.json extraProviders and its',
|
|
467
|
+
' key under providers.<id>.apiKey. The third argument is optional; without',
|
|
468
|
+
' it, set the key later with `ryuu --settings`.',
|
|
469
|
+
' - A new provider has no models: /addmodel <name> <model-id> adds them and',
|
|
470
|
+
' the first one becomes the provider default.',
|
|
471
|
+
' - Built-in providers (nvidia, tokenharbor) cannot be added or deleted, and',
|
|
472
|
+
' the current provider cannot be deleted until you switch away.',
|
|
473
|
+
' - Switching keeps conversation history and the worklog.',
|
|
474
|
+
].join('\n'),
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
define(
|
|
478
|
+
'model',
|
|
479
|
+
'List the current provider\u2019s models, or switch model by number',
|
|
480
|
+
'/model [n]',
|
|
481
|
+
async (args) => {
|
|
482
|
+
const requested = args.trim();
|
|
483
|
+
const { custom, entries, all, provider: activeProvider } = await modelList();
|
|
484
|
+
if (!requested) {
|
|
485
|
+
const rows = entries.map(
|
|
486
|
+
(entry, index) =>
|
|
487
|
+
` ${index + 1} ${entry.id}${modelLabel(entry)}${entry.id === config.model ? ' \u2190 current' : ''}${custom.includes(entry.id) ? ' (added)' : ''}`,
|
|
488
|
+
);
|
|
489
|
+
const builtin = activeProvider?.models?.length || 0;
|
|
490
|
+
return print(
|
|
491
|
+
[
|
|
492
|
+
`Current model: ${config.model}`,
|
|
493
|
+
`Provider: ${activeProvider?.name || currentProviderId()} (${activeProvider?.id || currentProviderId()}) · ${capabilityText(activeProvider?.capabilities || config.capabilities)}`,
|
|
494
|
+
'',
|
|
495
|
+
'Available models:',
|
|
496
|
+
...rows,
|
|
497
|
+
'',
|
|
498
|
+
`Usage: /model <n> (e.g. /model ${Math.min(2, all.length)})`,
|
|
499
|
+
`${all.length} listed · ${builtin} built-in · ${custom.length} added`,
|
|
500
|
+
MODEL_LIST_HINT,
|
|
501
|
+
].join('\n'),
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
if (!/^\d+$/.test(requested)) {
|
|
505
|
+
return print(
|
|
506
|
+
`Use a number, not a model id. Run /model to see the numbered list, or /addmodel ${currentProviderId()} <model-id> to add one.`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
const index = Number(requested) - 1;
|
|
510
|
+
if (index < 0 || index >= all.length) {
|
|
511
|
+
return print(`Unknown model number: ${requested}. Valid: 1-${all.length}. Run /model to see the list.`);
|
|
512
|
+
}
|
|
513
|
+
const model = all[index];
|
|
514
|
+
config.model = model;
|
|
515
|
+
if (provider.config) provider.config.model = model;
|
|
516
|
+
const saved = await writeModelPreference(stateDir(), model, { providerId: currentProviderId() });
|
|
517
|
+
print(
|
|
518
|
+
`Model set to ${model} (#${index + 1}) for subsequent requests` +
|
|
519
|
+
(saved ? '; saved, so it stays selected in future runs.' : '.') +
|
|
520
|
+
' Tool-calling support depends on the model and endpoint.',
|
|
521
|
+
);
|
|
522
|
+
},
|
|
523
|
+
[
|
|
524
|
+
' The list is the provider catalog (config.json for user providers) plus ids',
|
|
525
|
+
' added with /addmodel, so it survives restarts. /model <n> and /cmodel <name>',
|
|
526
|
+
' change the current model; /addmodel and /delmodel change the list.',
|
|
527
|
+
].join('\n'),
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
define(
|
|
531
|
+
'addmodel',
|
|
532
|
+
'Add a model id to a provider (existence is not checked)',
|
|
533
|
+
'/addmodel <provider> <model-id>',
|
|
534
|
+
async (args) => {
|
|
535
|
+
const parts = tokenize(args);
|
|
536
|
+
if (parts.length < 2) {
|
|
537
|
+
return print(
|
|
538
|
+
[
|
|
539
|
+
'Usage: /addmodel <provider> <model-id>',
|
|
540
|
+
'Example: /addmodel river deepseek-ai/DeepSeek-V4.1-Flash-1017',
|
|
541
|
+
'User providers (from /provider add) keep the model in config.json;',
|
|
542
|
+
'built-in providers keep it in this workspace .ryuu/preferences.json.',
|
|
543
|
+
'No availability check is made: a wrong id only fails when it is used.',
|
|
544
|
+
'Run /help addmodel for the full tutorial.',
|
|
545
|
+
].join('\n'),
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
const [reference, name] = parts;
|
|
549
|
+
const target = findProvider(reference);
|
|
550
|
+
if (!target) {
|
|
551
|
+
return print(
|
|
552
|
+
`Unknown provider: ${reference}. Known providers: ${providerList().map((entry) => entry.id).join(', ')}.`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
if (!normalizeModelId(name)) {
|
|
556
|
+
return print(
|
|
557
|
+
[
|
|
558
|
+
`"${name}" is not a usable model id.`,
|
|
559
|
+
'Use letters, digits and . _ : / - (no spaces), e.g. meta-llama/llama-3.3-70b-instruct.',
|
|
560
|
+
'Nothing was added.',
|
|
561
|
+
].join('\n'),
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (target.source === 'settings') {
|
|
566
|
+
const result = await addExtraProviderModel(target.id, name);
|
|
567
|
+
if (!result.ok) {
|
|
568
|
+
if (result.reason === 'exists') {
|
|
569
|
+
return print(
|
|
570
|
+
`${name} is already a model for ${target.id}. Run /provider user ${target.id}, then /model to see its number.`,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
if (result.reason === 'limit') {
|
|
574
|
+
return print(`Provider ${target.id} already holds the maximum number of models.`);
|
|
575
|
+
}
|
|
576
|
+
return print('Could not update config.json. The model list is unchanged.');
|
|
577
|
+
}
|
|
578
|
+
await state.reloadProviders?.();
|
|
579
|
+
const updated = findProvider(target.id);
|
|
580
|
+
const index = (updated?.models || []).findIndex((entry) => entry.id === name) + 1;
|
|
581
|
+
return print(
|
|
582
|
+
[
|
|
583
|
+
`Added ${name} to provider ${target.id} in config.json (#${index || 1}).`,
|
|
584
|
+
target.id === currentProviderId()
|
|
585
|
+
? `Switch to it with /model ${index}.`
|
|
586
|
+
: `Switch to that provider with /provider user ${target.id}, then /model ${index}.`,
|
|
587
|
+
'No availability check was made: a wrong id only fails when it is used.',
|
|
588
|
+
].join('\n'),
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if ((target.models || []).some((entry) => entry.id === name)) {
|
|
593
|
+
return print(
|
|
594
|
+
`${name} is already a built-in catalog model for ${target.id}. Run /provider user ${target.id}, then /model to see its number.`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
const result = await addCustomModel(stateDir(), name, target.id);
|
|
598
|
+
if (!result.ok) {
|
|
599
|
+
if (result.reason === 'exists') {
|
|
600
|
+
return print(
|
|
601
|
+
`${name} is already in the model list for ${target.id}. Run /provider user ${target.id}, then /model to see its number.`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
if (result.reason === 'limit') {
|
|
605
|
+
return print(
|
|
606
|
+
`The list already holds ${MAX_CUSTOM_MODELS} added model(s) for ${target.id}. Remove one with /delmodel ${target.id} <model-id> first.`,
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
return print(
|
|
610
|
+
`Could not save ${name} to ${preferencesFile(stateDir())}. The model list is unchanged.`,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
const entries = modelsFor(target, await readCustomModels(stateDir(), target.id));
|
|
614
|
+
const index = entries.findIndex((entry) => entry.id === name) + 1;
|
|
615
|
+
print(
|
|
616
|
+
[
|
|
617
|
+
`Added ${name} to provider ${target.id} (#${index}).`,
|
|
618
|
+
target.id === currentProviderId()
|
|
619
|
+
? `Switch to it with /model ${index}.`
|
|
620
|
+
: `Switch to that provider with /provider user ${target.id}, then /model ${index}.`,
|
|
621
|
+
'No availability check was made: if this id does not exist (or the endpoint cannot call',
|
|
622
|
+
'tools for it), requests using it will fail — that is on the id you entered.',
|
|
623
|
+
].join('\n'),
|
|
624
|
+
);
|
|
625
|
+
},
|
|
626
|
+
[
|
|
627
|
+
' How to use:',
|
|
628
|
+
' 1. /provider add river https://api.river.ai/v1 rv_…',
|
|
629
|
+
' 2. /addmodel river deepseek-ai/DeepSeek-V4.1-Flash-1017',
|
|
630
|
+
' 3. /provider user river switch to that provider',
|
|
631
|
+
' 4. /model see the numbered list, added ids marked "(added)"',
|
|
632
|
+
' 5. /model <n> switch to it for subsequent requests',
|
|
633
|
+
'',
|
|
634
|
+
' Notes:',
|
|
635
|
+
' - For a user provider the id is stored in config.json extraProviders and',
|
|
636
|
+
' the first model becomes its default. For nvidia/tokenharbor it goes to',
|
|
637
|
+
' this workspace\u2019s .ryuu/preferences.json.',
|
|
638
|
+
' - The id is stored as typed (letters, digits, . _ : / -; no spaces).',
|
|
639
|
+
' - Existence, quota and tool-calling support are NOT checked. A bad id',
|
|
640
|
+
' only fails when the model is used, and that is the user\u2019s risk.',
|
|
641
|
+
' - Built-in providers hold at most ' + MAX_CUSTOM_MODELS + ' added models;',
|
|
642
|
+
' /delmodel <provider> <model-id> removes one.',
|
|
643
|
+
].join('\n'),
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
define(
|
|
647
|
+
'delmodel',
|
|
648
|
+
'Remove a model from a provider',
|
|
649
|
+
'/delmodel <provider> <model-id>',
|
|
650
|
+
async (args) => {
|
|
651
|
+
const parts = tokenize(args);
|
|
652
|
+
if (parts.length < 2) {
|
|
653
|
+
return print(
|
|
654
|
+
[
|
|
655
|
+
'Usage: /delmodel <provider> <model-id>',
|
|
656
|
+
'Example: /delmodel river deepseek-ai/DeepSeek-V4.1-Flash-1017',
|
|
657
|
+
'Only ids added with /addmodel can be removed; built-in catalog models stay.',
|
|
658
|
+
'Run /help delmodel for the full tutorial.',
|
|
659
|
+
].join('\n'),
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
const [reference, name] = parts;
|
|
663
|
+
const target = findProvider(reference);
|
|
664
|
+
if (!target) {
|
|
665
|
+
return print(
|
|
666
|
+
`Unknown provider: ${reference}. Known providers: ${providerList().map((entry) => entry.id).join(', ')}.`,
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (target.source === 'settings') {
|
|
671
|
+
const result = await removeExtraProviderModel(target.id, name);
|
|
672
|
+
if (!result.ok) {
|
|
673
|
+
if (result.reason === 'absent') {
|
|
674
|
+
return print(`${name} is not a model for ${target.id}. Run /provider user ${target.id}, then /model to see the list.`);
|
|
675
|
+
}
|
|
676
|
+
return print('Could not update config.json. The model list is unchanged.');
|
|
677
|
+
}
|
|
678
|
+
await state.reloadProviders?.();
|
|
679
|
+
const lines = [`Removed ${name} from provider ${target.id} in config.json.`];
|
|
680
|
+
if (result.models.length === 0) {
|
|
681
|
+
lines.push(`Provider ${target.id} now has no models; add one with /addmodel ${target.id} <model-id>.`);
|
|
682
|
+
} else if (target.id === currentProviderId() && config.model === name) {
|
|
683
|
+
lines.push('It is still the current model for this session; pick another with /model <n>.');
|
|
684
|
+
}
|
|
685
|
+
return print(lines.join('\n'));
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if ((target.models || []).some((entry) => entry.id === name)) {
|
|
689
|
+
return print(
|
|
690
|
+
`${name} is a built-in catalog model and cannot be removed (provider ${target.id}). /delmodel only removes ids added with /addmodel.`,
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
const result = await removeCustomModel(stateDir(), name, target.id);
|
|
694
|
+
if (!result.ok) {
|
|
695
|
+
if (result.reason === 'missing') {
|
|
696
|
+
return print(
|
|
697
|
+
`${name} is not in the added-model list for ${target.id}. Run /provider user ${target.id}, then /model to see it (built-in ids are listed too).`,
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
return print(`Could not update ${preferencesFile(stateDir())}. The model list is unchanged.`);
|
|
701
|
+
}
|
|
702
|
+
const lines = [`Removed ${name} from the model list for ${target.id}.`];
|
|
703
|
+
if (target.id === currentProviderId() && config.model === name) {
|
|
704
|
+
lines.push('It is still the current model for this session; pick another with /model <n> or /cmodel <name>.');
|
|
705
|
+
}
|
|
706
|
+
print(lines.join('\n'));
|
|
707
|
+
},
|
|
708
|
+
[
|
|
709
|
+
' How to use:',
|
|
710
|
+
' 1. /delmodel river deepseek-ai/DeepSeek-V4.1-Flash-1017',
|
|
711
|
+
' 2. /model confirm the remaining list',
|
|
712
|
+
'',
|
|
713
|
+
' Notes:',
|
|
714
|
+
' - User providers lose the id from config.json; a built-in provider loses',
|
|
715
|
+
' an id added with /addmodel from .ryuu/preferences.json.',
|
|
716
|
+
' - Built-in catalog models cannot be deleted; only added ids.',
|
|
717
|
+
' - Removing the current model keeps it selected for this session.',
|
|
718
|
+
' Choose another with /model <n>, or set one directly with /cmodel <name>.',
|
|
719
|
+
' - Removing the last model of a user provider leaves it unusable until',
|
|
720
|
+
' /addmodel adds one again.',
|
|
721
|
+
].join('\n'),
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
define('cmodel', 'Set a custom model id for the current provider (user risk if it does not exist)', '/cmodel <name> | /custommodel <name>', async (args) => {
|
|
725
|
+
const name = args.trim();
|
|
726
|
+
if (!name) {
|
|
727
|
+
return print(
|
|
728
|
+
'Usage: /cmodel <name> (alias: /custommodel <name>)\n' +
|
|
729
|
+
`Example: /cmodel ${currentProvider()?.defaultModel || 'deepseek-v4.1-flash:free'}\n` +
|
|
730
|
+
'Peringatan: ketersediaan model di server provider adalah tanggung jawab user. Jika model tidak ada, gunakan /model untuk daftar model yang tersedia atau coba nama custom model lain.',
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
if (!normalizeModelId(name)) {
|
|
734
|
+
return print(
|
|
735
|
+
[
|
|
736
|
+
`"${name}" is not a usable model id.`,
|
|
737
|
+
'Use letters, digits and . _ : / - (no spaces), e.g. deepseek-v4.1-flash:free.',
|
|
738
|
+
'Nothing was changed.',
|
|
739
|
+
].join('\n'),
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
config.model = name;
|
|
743
|
+
if (provider.config) provider.config.model = name;
|
|
744
|
+
const saved = await writeModelPreference(stateDirFor(config.workspace), name, { providerId: currentProviderId() });
|
|
745
|
+
print(
|
|
746
|
+
`Custom model set to ${name} for ${config.providerName || currentProviderId()} for subsequent requests` +
|
|
747
|
+
(saved ? '; saved, so it stays selected in future runs.' : '.') +
|
|
748
|
+
' Jika model ini tidak tersedia di server provider, permintaan akan gagal — gunakan /model untuk model yang tersedia atau coba nama custom model lain.',
|
|
749
|
+
);
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
define('custommodel', 'Alias for /cmodel — set a custom NVIDIA model name (user risk if the model does not exist)', '/custommodel <name>', async (args) => {
|
|
753
|
+
commands.get('cmodel').handler(args);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
let runningShell = null;
|
|
757
|
+
|
|
758
|
+
define(
|
|
759
|
+
'bash',
|
|
760
|
+
'Run a shell command yourself (not a prompt); output is added to the AI context',
|
|
761
|
+
'/bash <command>',
|
|
762
|
+
async (args) => {
|
|
763
|
+
const command = args.trim().replace(/^!\s*/, '').trim();
|
|
764
|
+
if (!command) {
|
|
765
|
+
return print(
|
|
766
|
+
[
|
|
767
|
+
'Usage: /bash <command>',
|
|
768
|
+
'Example: /bash npm test',
|
|
769
|
+
'Tip: a line starting with "!" runs the same way, e.g. !git status',
|
|
770
|
+
'Output is printed in full and both the command and its output are added to the session context.',
|
|
771
|
+
].join('\n'),
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
if (config.approvalPolicy === 'read-only') {
|
|
775
|
+
return renderer.warn('The read-only policy blocks shell commands. Start without --read-only to use /bash.');
|
|
776
|
+
}
|
|
777
|
+
if (runningShell) {
|
|
778
|
+
return renderer.warn('A shell command is already running. Wait for it to finish or press Ctrl+C.');
|
|
779
|
+
}
|
|
780
|
+
const controller = new AbortController();
|
|
781
|
+
runningShell = { controller, command };
|
|
782
|
+
// The REPL checks this to cancel a long shell command with Ctrl+C.
|
|
783
|
+
state.activeShell = runningShell;
|
|
784
|
+
try {
|
|
785
|
+
const result = await runUserShell({
|
|
786
|
+
command,
|
|
787
|
+
cwd: workspace.root,
|
|
788
|
+
maxBytes: Math.max(20_000, Number(config.maxToolOutputBytes) * 4 || 200_000),
|
|
789
|
+
signal: controller.signal,
|
|
790
|
+
});
|
|
791
|
+
renderer.shellResult?.({
|
|
792
|
+
command,
|
|
793
|
+
cwd: workspace.root,
|
|
794
|
+
stdout: result.stdout,
|
|
795
|
+
stderr: result.stderr,
|
|
796
|
+
exitCode: result.exitCode,
|
|
797
|
+
durationMs: result.durationMs,
|
|
798
|
+
truncated: result.truncated,
|
|
799
|
+
signal: result.signal,
|
|
800
|
+
error: result.error,
|
|
801
|
+
});
|
|
802
|
+
state.getAgent()?.recordUserShell?.({
|
|
803
|
+
command,
|
|
804
|
+
cwd: workspace.root,
|
|
805
|
+
stdout: result.stdout,
|
|
806
|
+
stderr: result.stderr,
|
|
807
|
+
exitCode: result.exitCode,
|
|
808
|
+
durationMs: result.durationMs,
|
|
809
|
+
truncated: result.truncated,
|
|
810
|
+
});
|
|
811
|
+
if (result.error) renderer.warn(`Shell command failed to start: ${result.error}`);
|
|
812
|
+
else print(' added to session context — the AI will see it on the next task.');
|
|
813
|
+
} finally {
|
|
814
|
+
runningShell = null;
|
|
815
|
+
state.activeShell = null;
|
|
816
|
+
}
|
|
817
|
+
},
|
|
818
|
+
[
|
|
819
|
+
' How to use:',
|
|
820
|
+
' /bash npm test run one command in the workspace',
|
|
821
|
+
' !git status same thing, without typing /bash',
|
|
822
|
+
'',
|
|
823
|
+
' Notes:',
|
|
824
|
+
' - This is your own shell, not a model tool call: no approval prompt is',
|
|
825
|
+
' shown, but --read-only still refuses it.',
|
|
826
|
+
' - The full captured output is printed; very long output keeps the tail',
|
|
827
|
+
' (bounded capture) and is marked as truncated.',
|
|
828
|
+
' - The command and its output are stored as a user turn in the session',
|
|
829
|
+
' conversation, so the next task knows what you ran and what happened.',
|
|
830
|
+
' - Ctrl+C cancels a running shell command.',
|
|
831
|
+
].join('\n'),
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
define('workspace', 'Show the workspace the agent is allowed to touch', '/workspace', async () => {
|
|
835
|
+
print(
|
|
836
|
+
[
|
|
837
|
+
`Workspace: ${workspace.root}`,
|
|
838
|
+
'Path switching is intentionally not supported mid-session: processes, approvals and sessions are scoped to it.',
|
|
839
|
+
'Start a new session with ryuu --workspace /path.',
|
|
840
|
+
].join('\n'),
|
|
841
|
+
);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
define('files', 'List files in the workspace', '/files [path]', async (args) => {
|
|
845
|
+
const listing = await workspace.list(args.trim() || '.', { limit: 200, includeHidden: true });
|
|
846
|
+
const rows = listing.entries.map((entry) => ` ${entry.kind === 'dir' ? 'd' : entry.kind === 'file' ? 'f' : '?'} ${entry.path}${entry.size !== null ? ` (${formatBytes(entry.size)})` : ''}`);
|
|
847
|
+
print([`${listing.total} entr${listing.total === 1 ? 'y' : 'ies'} in ${listing.path}`, ...rows, listing.truncated ? ' … truncated (use limit/pagination via tools)' : ''].join('\n'));
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
define('tree', 'Show a bounded directory tree', '/tree [path]', async (args) => {
|
|
851
|
+
const result = await workspace.tree(args.trim() || '.', { depth: 3, maxEntries: 200 });
|
|
852
|
+
print(result.text);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
define('tools', 'List registered tools and their risk level', '/tools', async () => {
|
|
856
|
+
const rows = registry.list().map((tool) => ` ${tool.name.padEnd(20)} ${tool.risk.padEnd(12)} ${tool.requiresApproval ? 'approval' : 'auto'}`);
|
|
857
|
+
print([`${registry.list().length} tools`, ...rows].join('\n'));
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
define('processes', 'List running and recent processes', '/processes', async () => {
|
|
861
|
+
const rows = processes.list();
|
|
862
|
+
if (rows.length === 0) return print('No processes started in this session.');
|
|
863
|
+
print(
|
|
864
|
+
rows
|
|
865
|
+
.map((proc) => ` ${proc.id.padEnd(4)} ${proc.status.padEnd(8)} pid ${String(proc.pid ?? '-').padEnd(8)} ${proc.argv.join(' ')}${proc.exitCode !== null ? ` · exit ${proc.exitCode}` : ''}`)
|
|
866
|
+
.join('\n'),
|
|
867
|
+
);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
define('logs', 'Show captured output for a process, or recent process logs', '/logs [process-id]', async (args) => {
|
|
871
|
+
const id = args.trim();
|
|
872
|
+
if (!id) {
|
|
873
|
+
const rows = processes.list();
|
|
874
|
+
print(
|
|
875
|
+
[
|
|
876
|
+
`Log file: ${config.logDir}/agent.log`,
|
|
877
|
+
rows.length ? 'Processes (use /logs <id>):' : 'No processes started in this session.',
|
|
878
|
+
...rows.map((proc) => ` ${proc.id} · ${proc.label} · ${proc.status}`),
|
|
879
|
+
].join('\n'),
|
|
880
|
+
);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const output = processes.getOutput(id, { tailBytes: 12000 });
|
|
884
|
+
print(
|
|
885
|
+
[
|
|
886
|
+
`${output.id} · ${output.status} · exit ${output.exitCode ?? '-'} · ${output.durationMs ?? '?'}ms`,
|
|
887
|
+
'--- stdout ---',
|
|
888
|
+
output.stdout || '(empty)',
|
|
889
|
+
'--- stderr ---',
|
|
890
|
+
output.stderr || '(empty)',
|
|
891
|
+
output.truncated ? '(captured output was truncated)' : '',
|
|
892
|
+
].join('\n'),
|
|
893
|
+
);
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
define('expand', 'List reasoning / run_command blocks, or open one in full', '/expand [block-id]', async (args) => {
|
|
897
|
+
const raw = args.trim().replace(/^#/, '');
|
|
898
|
+
if (!raw) {
|
|
899
|
+
renderer.lines(renderer.blockListText());
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const id = Number(raw);
|
|
903
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
904
|
+
renderer.warn('Usage: /expand <block-id> (run /expand with no argument to list blocks)');
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (!renderer.hasBlock(id)) {
|
|
908
|
+
renderer.warn(`No block #${id}. Run /expand to list the blocks of this session.`);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
renderer.openBlockView(id);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
define('copy', 'Copy a prompt you already sent to the terminal clipboard', '/copy [n]', async (args) => {
|
|
915
|
+
const raw = args.trim();
|
|
916
|
+
if (raw && !/^\d+$/.test(raw)) {
|
|
917
|
+
renderer.warn('Usage: /copy [n] (n = how many prompts back; default: the latest)');
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
const result = renderer.copyTask?.(raw ? Number(raw) : 0);
|
|
921
|
+
if (!result) {
|
|
922
|
+
renderer.warn('This interface cannot copy to the clipboard.');
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
if (result.ok) return;
|
|
926
|
+
if (result.reason === 'no-task') {
|
|
927
|
+
renderer.warn('Nothing to copy yet: send a prompt first.');
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
renderer.warn('Clipboard copy needs an interactive terminal that supports OSC 52.');
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
define('cancel', 'Cancel the running task or stop a process', '/cancel [process-id]', async (args) => {
|
|
934
|
+
const target = args.trim();
|
|
935
|
+
if (/^p\d+$/.test(target)) {
|
|
936
|
+
const result = await processes.stop(target);
|
|
937
|
+
return print(`Process ${target}: ${result.status}${result.exitCode !== null ? ` (exit ${result.exitCode})` : ''}`);
|
|
938
|
+
}
|
|
939
|
+
if (state.cancelActiveTask()) return print('Cancellation requested for the running task.');
|
|
940
|
+
if (processes.list({ includeCompleted: false }).some((proc) => proc.status === 'running')) {
|
|
941
|
+
return print('No task is running. Use /cancel <process-id> to stop a process.');
|
|
942
|
+
}
|
|
943
|
+
print('Nothing to cancel.');
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
define('history', 'Show recent saved sessions', '/history', async () => {
|
|
947
|
+
const sessions = await sessionStore.list({ limit: 15 });
|
|
948
|
+
if (sessions.length === 0) return print('No saved sessions yet. Each completed task is stored locally (secrets excluded).');
|
|
949
|
+
print(
|
|
950
|
+
[
|
|
951
|
+
'Recent sessions:',
|
|
952
|
+
...sessions.map(
|
|
953
|
+
(session) =>
|
|
954
|
+
` ${session.id} ${String(session.status || '?').padEnd(10)} ${String(session.updatedAt || '').slice(0, 19)} ` +
|
|
955
|
+
`${session.durationMs ? `${(session.durationMs / 1000).toFixed(0)}s`.padEnd(6) : ''.padEnd(6)} ${session.task}`,
|
|
956
|
+
),
|
|
957
|
+
'',
|
|
958
|
+
`Storage: ${config.historyDir}`,
|
|
959
|
+
'Use /sessions show <id> for details.',
|
|
960
|
+
].join('\n'),
|
|
961
|
+
);
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
define('sessions', 'Manage local sessions: list | show <id> | delete <id> | clear', '/sessions [list|show <id>|delete <id>|clear]', async (args) => {
|
|
965
|
+
const [sub, id] = args.trim().split(/\s+/);
|
|
966
|
+
if (!sub || sub === 'list') return commands.get('history').handler('');
|
|
967
|
+
if (sub === 'show') {
|
|
968
|
+
if (!id) return print('Usage: /sessions show <id>');
|
|
969
|
+
const session = await sessionStore.load(id);
|
|
970
|
+
print(
|
|
971
|
+
[
|
|
972
|
+
`Session ${session.id}`,
|
|
973
|
+
`task ${session.task}`,
|
|
974
|
+
`status ${session.status}`,
|
|
975
|
+
`created ${session.createdAt}`,
|
|
976
|
+
`updated ${session.updatedAt}`,
|
|
977
|
+
`model ${session.model}`,
|
|
978
|
+
`steps ${session.steps}`,
|
|
979
|
+
`duration ${session.durationMs ? `${(session.durationMs / 1000).toFixed(1)}s` : 'unknown'}`,
|
|
980
|
+
`changed ${(session.changedFiles || []).join(', ') || 'none'}`,
|
|
981
|
+
`commands ${(session.commands || []).map((cmd) => (cmd.argv || []).join(' ')).join('; ') || 'none'}`,
|
|
982
|
+
`issues ${(session.issues || []).join('; ') || 'none'}`,
|
|
983
|
+
`tokens ${session.tokenUsage?.prompt ?? 0} prompt / ${session.tokenUsage?.completion ?? 0} completion` +
|
|
984
|
+
(session.modelCalls ? ` · ${session.modelCalls} model call(s)` : ''),
|
|
985
|
+
].join('\n'),
|
|
986
|
+
);
|
|
987
|
+
print('\nTranscript:');
|
|
988
|
+
for (const entry of (session.transcript || []).slice(-20)) {
|
|
989
|
+
print(` [${entry.role}${entry.name ? `:${entry.name}` : ''}] ${String(entry.content || '').split('\n')[0].slice(0, 160)}`);
|
|
990
|
+
}
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
if (sub === 'delete') {
|
|
994
|
+
if (!id) return print('Usage: /sessions delete <id>');
|
|
995
|
+
const removed = await sessionStore.delete(id);
|
|
996
|
+
return print(removed ? `Deleted session ${id}.` : `Session not found: ${id}`);
|
|
997
|
+
}
|
|
998
|
+
if (sub === 'clear') {
|
|
999
|
+
if (!interactive) return print('Refusing to clear session history in non-interactive mode.');
|
|
1000
|
+
const answer = await state.confirmLocal('Delete ALL locally stored sessions? [y/N] ');
|
|
1001
|
+
if (!answer) return print('Cancelled.');
|
|
1002
|
+
const count = await sessionStore.clear();
|
|
1003
|
+
return print(`Deleted ${count} session file(s).`);
|
|
1004
|
+
}
|
|
1005
|
+
print('Usage: /sessions [list|show <id>|delete <id>|clear]');
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
define('diff', 'Show a diff of agent changes (or git diff if available)', '/diff', async () => {
|
|
1009
|
+
const entries = undo.entries.filter((entry) => entry.kind === 'write' || entry.kind === 'delete');
|
|
1010
|
+
if (entries.length > 0) {
|
|
1011
|
+
const chunks = [];
|
|
1012
|
+
for (const entry of entries.slice(-5)) {
|
|
1013
|
+
let current = '';
|
|
1014
|
+
try {
|
|
1015
|
+
current = (await workspace.readText(entry.path)).text;
|
|
1016
|
+
} catch {
|
|
1017
|
+
current = entry.kind === 'delete' ? entry.before : '';
|
|
1018
|
+
}
|
|
1019
|
+
const before = entry.kind === 'delete' ? entry.before : entry.before ?? '';
|
|
1020
|
+
const diff = createUnifiedDiff(before, current, { oldLabel: `a/${entry.path}`, newLabel: `b/${entry.path}`, maxLines: 120 });
|
|
1021
|
+
const stats = diffStats(before, current);
|
|
1022
|
+
chunks.push(diff ? `${diff}\n(${stats.added} added, ${stats.removed} removed)` : `(no changes) ${entry.path}`);
|
|
1023
|
+
}
|
|
1024
|
+
return print(chunks.join('\n\n'));
|
|
1025
|
+
}
|
|
1026
|
+
try {
|
|
1027
|
+
const tool = registry.get('inspect_git');
|
|
1028
|
+
const result = await tool.handler({ action: 'diff', max_lines: 300 }, { workspace, config });
|
|
1029
|
+
if (result.available === false) return print('No agent-recorded changes and Git is not available.');
|
|
1030
|
+
return print(result.diff || result.output || 'Working tree is clean.');
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
return print(`No agent-recorded changes. Git diff failed: ${error.message}`);
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
define('undo', 'Revert the last agent file change', '/undo [confirm]', async (args = '') => {
|
|
1037
|
+
if (undo.size === 0) return print('Nothing to undo in this session.');
|
|
1038
|
+
const last = undo.list().at(-1);
|
|
1039
|
+
print(`Last change: ${last.kind} ${last.path}${last.to ? ` -> ${last.to}` : ''} (${last.at})`);
|
|
1040
|
+
if (interactive) {
|
|
1041
|
+
const approved = await state.confirmLocal('Revert this change? [y/N] ');
|
|
1042
|
+
if (!approved) return print('Cancelled.');
|
|
1043
|
+
} else if (args.trim() !== 'confirm') {
|
|
1044
|
+
return print('Refusing to undo in non-interactive mode without the explicit "confirm" argument.');
|
|
1045
|
+
}
|
|
1046
|
+
const entry = await undo.revertLast();
|
|
1047
|
+
highlight(renderer, `Reverted ${entry.kind} on ${entry.path}${entry.to ? ` -> ${entry.path}` : ''}.`);
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
define('web', 'Manage multi-provider web search: status | providers | test | enable/disable <id>', '/web [status|providers|test|enable <id>|disable <id>]', async (args = '') => {
|
|
1051
|
+
const parts = String(args).trim().split(/\s+/).filter(Boolean);
|
|
1052
|
+
const sub = (parts[0] || 'status').toLowerCase();
|
|
1053
|
+
const id = (parts[1] || '').toLowerCase();
|
|
1054
|
+
if (!webSearch) return print('Web search is not available in this session.');
|
|
1055
|
+
|
|
1056
|
+
if (sub === 'providers') {
|
|
1057
|
+
const rows = webSearch.list().map((item) => {
|
|
1058
|
+
const info = PROVIDER_INFO[item.id] || {};
|
|
1059
|
+
const key = info.requiresKey ? `key: ${info.keySetting}` : info.keySetting ? `optional key: ${info.keySetting}` : 'no key';
|
|
1060
|
+
return [
|
|
1061
|
+
` ${item.label.padEnd(12)} ${item.transport.padEnd(16)} ${key}`,
|
|
1062
|
+
` ${info.note || ''}`,
|
|
1063
|
+
` docs: ${info.docs || '-'}`,
|
|
1064
|
+
].join('\n');
|
|
1065
|
+
});
|
|
1066
|
+
return print(['WEB SEARCH PROVIDERS', ...rows].join('\n'));
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (sub === 'test') {
|
|
1070
|
+
print('Checking MCP connections and tool discovery (no search query is run, so no search credits are used)…');
|
|
1071
|
+
const rows = await webSearch.probe();
|
|
1072
|
+
const lines = rows.map((row) => {
|
|
1073
|
+
if (row.skipped) return ` ${row.label.padEnd(12)} ! ${row.reason}`;
|
|
1074
|
+
if (!row.ok) return ` ${row.label.padEnd(12)} ✕ ${row.reason} · ${row.durationMs}ms`;
|
|
1075
|
+
const tools = row.tools?.length ? `tools: ${row.tools.join(', ')}` : 'no tools discovered';
|
|
1076
|
+
return ` ${row.label.padEnd(12)} ✓ ${row.durationMs}ms · ${tools}`;
|
|
1077
|
+
});
|
|
1078
|
+
return print(['WEB SEARCH TEST', ...lines, '', 'Discovery only: no provider was queried.'].join('\n'));
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (sub === 'enable' || sub === 'disable') {
|
|
1082
|
+
if (!id) return print(`Usage: /web ${sub} <provider-id>`);
|
|
1083
|
+
if (!webSearch.setEnabled(id, sub === 'enable')) {
|
|
1084
|
+
return print(`Unknown provider: ${id}. Use /web providers to list them.`);
|
|
1085
|
+
}
|
|
1086
|
+
return print(`${id} ${sub === 'enable' ? 'enabled' : 'disabled'} for this session (not written to disk).`);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
if (sub !== 'status') return print('Usage: /web [status|providers|test|enable <id>|disable <id>]');
|
|
1090
|
+
const rows = webSearch.list();
|
|
1091
|
+
const lines = rows.map((item) => {
|
|
1092
|
+
const state = item.enabled === false
|
|
1093
|
+
? 'disabled'
|
|
1094
|
+
: !item.configured
|
|
1095
|
+
? 'missing key'
|
|
1096
|
+
: item.circuitOpen
|
|
1097
|
+
? `circuit open (${Math.ceil(item.circuitRetryInMs / 1000)}s)`
|
|
1098
|
+
: item.connected
|
|
1099
|
+
? 'connected'
|
|
1100
|
+
: 'ready';
|
|
1101
|
+
const caps = [item.capabilities?.search ? 'search' : null, item.capabilities?.extract ? 'extract' : null].filter(Boolean).join('+') || 'none';
|
|
1102
|
+
const last = item.lastStatus
|
|
1103
|
+
? ` · last: ${item.lastStatus}${item.lastReason ? ` (${item.lastReason})` : ''}${item.lastDurationMs ? ` ${item.lastDurationMs}ms` : ''}`
|
|
1104
|
+
: '';
|
|
1105
|
+
return ` ${item.label.padEnd(12)} ${state.padEnd(20)} ${caps.padEnd(14)}${last}`;
|
|
1106
|
+
});
|
|
1107
|
+
return print(
|
|
1108
|
+
[
|
|
1109
|
+
'WEB SEARCH',
|
|
1110
|
+
` order: ${rows.map((item) => item.id).join(' → ') || '(none)'}`,
|
|
1111
|
+
` fallback: ${webSearch.fallbackEnabled ? 'on' : 'off'} · timeout: ${webSearch.timeoutMs}ms · retries: ${webSearch.maxRetries}`,
|
|
1112
|
+
...lines,
|
|
1113
|
+
'',
|
|
1114
|
+
'Use /web test to check connections, /web enable <id> to change this session.',
|
|
1115
|
+
].join('\n'),
|
|
1116
|
+
);
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
define('config', 'Show effective configuration (secrets redacted)', '/config', async () => {
|
|
1120
|
+
const described = describeConfig(config);
|
|
1121
|
+
const width = Math.max(...Object.keys(described).map((key) => key.length));
|
|
1122
|
+
print(Object.entries(described).map(([key, value]) => `${key.padEnd(width)} ${value}`).join('\n'));
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
define('doctor', 'Run diagnostics; add "live" for one real API request', '/doctor [live]', async (args) => {
|
|
1126
|
+
const live = args.trim() === 'live' || args.trim() === '--live';
|
|
1127
|
+
print('Running diagnostics…');
|
|
1128
|
+
const report = await runDoctor({ config, workspace, provider, live });
|
|
1129
|
+
print(formatDoctor(report));
|
|
1130
|
+
if (live) print('Note: a live check consumes provider quota and depends on network access.');
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
return {
|
|
1134
|
+
list: () => [...commands.keys()],
|
|
1135
|
+
has: (name) => commands.has(name),
|
|
1136
|
+
get: (name) => commands.get(name),
|
|
1137
|
+
async execute(parsed) {
|
|
1138
|
+
const command = commands.get(parsed.name);
|
|
1139
|
+
if (!command) {
|
|
1140
|
+
print(`Unknown command: /${parsed.name}. Run /help.`);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
try {
|
|
1144
|
+
return await command.handler(parsed.args, parsed.parts);
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
const message = error instanceof RyuuError ? error.message : `${error.message}`;
|
|
1147
|
+
print(`Command failed: ${message}`);
|
|
1148
|
+
}
|
|
1149
|
+
},
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function highlight(renderer, message) {
|
|
1154
|
+
renderer.success(message);
|
|
1155
|
+
}
|