@ryuu-reinzz/ryuu-cli 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +560 -0
- package/LICENSE +21 -0
- package/README.md +1213 -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 +463 -0
- package/src/cli/commands.js +1150 -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 +132 -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 +451 -0
- package/src/index.js +449 -0
- package/src/providers/catalog.js +282 -0
- package/src/providers/index.js +84 -0
- package/src/providers/nvidia.js +645 -0
- package/src/providers/registry.js +104 -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,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central error types. Every failure surfaced to the user should be one of
|
|
3
|
+
* these so the CLI can render a consistent, actionable message.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class RyuuError extends Error {
|
|
7
|
+
constructor(message, { code = 'RYUU_ERROR', details, cause, retryable = false } = {}) {
|
|
8
|
+
super(message, cause ? { cause } : undefined);
|
|
9
|
+
this.name = new.target.name;
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.details = details;
|
|
12
|
+
this.retryable = retryable;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
toJSON() {
|
|
16
|
+
return {
|
|
17
|
+
name: this.name,
|
|
18
|
+
code: this.code,
|
|
19
|
+
message: this.message,
|
|
20
|
+
details: this.details,
|
|
21
|
+
retryable: this.retryable,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class ConfigError extends RyuuError {
|
|
27
|
+
constructor(message, details) {
|
|
28
|
+
super(message, { code: 'CONFIG_INVALID', details });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class ValidationError extends RyuuError {
|
|
33
|
+
constructor(message, details) {
|
|
34
|
+
super(message, { code: 'VALIDATION_FAILED', details });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class PathEscapeError extends RyuuError {
|
|
39
|
+
constructor(message, details) {
|
|
40
|
+
super(message, { code: 'PATH_ESCAPE', details });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class SecretFileError extends RyuuError {
|
|
45
|
+
constructor(message, details) {
|
|
46
|
+
super(message, { code: 'SECRET_FILE_BLOCKED', details });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class NotFoundError extends RyuuError {
|
|
51
|
+
constructor(message, details) {
|
|
52
|
+
super(message, { code: 'NOT_FOUND', details });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class FileChangedError extends RyuuError {
|
|
57
|
+
constructor(message, details) {
|
|
58
|
+
super(message, { code: 'FILE_CHANGED', details });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class ApprovalDeniedError extends RyuuError {
|
|
63
|
+
constructor(message = 'Action denied by the user.', details) {
|
|
64
|
+
super(message, { code: 'APPROVAL_DENIED', details });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class ToolNotFoundError extends RyuuError {
|
|
69
|
+
constructor(name) {
|
|
70
|
+
super(`Unknown tool: ${name}`, { code: 'UNKNOWN_TOOL' });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class ToolError extends RyuuError {
|
|
75
|
+
constructor(message, details) {
|
|
76
|
+
super(message, { code: 'TOOL_ERROR', details });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class SearchError extends RyuuError {
|
|
81
|
+
constructor(message, { code = 'SEARCH_FAILED', status, retryable = false, details } = {}) {
|
|
82
|
+
super(message, { code, retryable, details });
|
|
83
|
+
this.status = status;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class FetchError extends RyuuError {
|
|
88
|
+
constructor(message, { code = 'FETCH_FAILED', status, retryable = false, details } = {}) {
|
|
89
|
+
super(message, { code, retryable, details });
|
|
90
|
+
this.status = status;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class ProviderError extends RyuuError {
|
|
95
|
+
constructor(message, { code = 'PROVIDER_ERROR', status, retryable = false, details, cause } = {}) {
|
|
96
|
+
super(message, { code, retryable, details, cause });
|
|
97
|
+
this.status = status;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class CancelledError extends RyuuError {
|
|
102
|
+
constructor(message = 'Operation cancelled.') {
|
|
103
|
+
super(message, { code: 'CANCELLED' });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class ProcessError extends RyuuError {
|
|
108
|
+
constructor(message, details) {
|
|
109
|
+
super(message, { code: 'PROCESS_ERROR', details });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class SessionError extends RyuuError {
|
|
114
|
+
constructor(message, details) {
|
|
115
|
+
super(message, { code: 'SESSION_ERROR', details });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Human-friendly one-line description, including provider status when present. */
|
|
120
|
+
export function describeError(error) {
|
|
121
|
+
if (!error) return 'Unknown error';
|
|
122
|
+
const status = error.status ? ` (HTTP ${error.status})` : '';
|
|
123
|
+
const code = error.code ? ` [${error.code}]` : '';
|
|
124
|
+
return `${error.message || String(error)}${status}${code}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Full block including stack, intended for --debug or the log file. */
|
|
128
|
+
export function formatErrorBlock(error, { debug = false } = {}) {
|
|
129
|
+
if (!error) return 'Unknown error';
|
|
130
|
+
const lines = [describeError(error)];
|
|
131
|
+
if (error.details) {
|
|
132
|
+
lines.push(`Details: ${typeof error.details === 'string' ? error.details : JSON.stringify(error.details)}`);
|
|
133
|
+
}
|
|
134
|
+
if (debug && error.stack) lines.push(error.stack);
|
|
135
|
+
else lines.push('Run with --debug (or press /doctor) for more diagnostics.');
|
|
136
|
+
return lines.join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function isAbortError(error) {
|
|
140
|
+
return error?.name === 'AbortError' || error?.code === 'ABORT_ERR' || error?.code === 'CANCELLED';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* True when a failure is worth another attempt.
|
|
145
|
+
*
|
|
146
|
+
* Two kinds qualify:
|
|
147
|
+
* - transient provider failures (network, timeout, 5xx, rate limit) that
|
|
148
|
+
* are flagged `retryable`;
|
|
149
|
+
* - a poisoned tool-call payload, which the agent repairs locally before
|
|
150
|
+
* replaying the request.
|
|
151
|
+
*
|
|
152
|
+
* Cancellation is never recoverable.
|
|
153
|
+
*/
|
|
154
|
+
export function isRecoverableError(error) {
|
|
155
|
+
if (!error) return false;
|
|
156
|
+
if (error instanceof CancelledError) return false;
|
|
157
|
+
if (error.code === 'CANCELLED') return false;
|
|
158
|
+
if (error.retryable === true) return true;
|
|
159
|
+
return error.code === 'INVALID_TOOL_ARGUMENTS';
|
|
160
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network guards for URLs supplied by the model or found on a web page.
|
|
3
|
+
*
|
|
4
|
+
* `read_url` and MCP extraction must not become a way to reach the machine's
|
|
5
|
+
* loopback services, the LAN, or cloud metadata endpoints. The guards reject
|
|
6
|
+
* obvious names and private IP literals everywhere, and additionally resolve
|
|
7
|
+
* DNS before a real local fetch. DNS rebinding is not fully solved by a
|
|
8
|
+
* pre-check, but combined with re-checking every redirect it closes the common
|
|
9
|
+
* paths without a custom HTTP agent.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import dns from 'node:dns/promises';
|
|
13
|
+
import net from 'node:net';
|
|
14
|
+
import { FetchError } from './errors.js';
|
|
15
|
+
|
|
16
|
+
const BLOCKED_HOSTS = new Set([
|
|
17
|
+
'localhost',
|
|
18
|
+
'metadata',
|
|
19
|
+
'metadata.google.internal',
|
|
20
|
+
'instance-data',
|
|
21
|
+
'instance-data.ec2.internal',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const BLOCKED_SUFFIXES = ['.localhost', '.local', '.internal', '.home.arpa'];
|
|
25
|
+
|
|
26
|
+
function blocked(message) {
|
|
27
|
+
return new FetchError(message, { code: 'READ_URL_BLOCKED' });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** True for IPv4/IPv6 addresses that are loopback, private, link-local or metadata. */
|
|
31
|
+
export function isPrivateAddress(address) {
|
|
32
|
+
const value = String(address || '').trim().toLowerCase();
|
|
33
|
+
if (!value) return true;
|
|
34
|
+
if (net.isIPv4(value)) {
|
|
35
|
+
const [a, b] = value.split('.').map(Number);
|
|
36
|
+
if (a === 0 || a === 10 || a === 127) return true;
|
|
37
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
|
38
|
+
if (a === 169 && b === 254) return true; // link-local, cloud metadata
|
|
39
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
40
|
+
if (a === 192 && b === 168) return true;
|
|
41
|
+
if (a === 192 && b === 0) return true;
|
|
42
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
43
|
+
if (a === 198 && b === 51) return true;
|
|
44
|
+
if (a === 203 && b === 0) return true;
|
|
45
|
+
if (a >= 224) return true; // multicast + reserved
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (net.isIPv6(value)) {
|
|
49
|
+
if (value === '::' || value === '::1') return true;
|
|
50
|
+
if (value.startsWith('fe8') || value.startsWith('fe9') || value.startsWith('fea') || value.startsWith('feb')) return true;
|
|
51
|
+
if (/^f[cd]/.test(value)) return true; // unique local
|
|
52
|
+
if (/^ff/.test(value)) return true; // multicast
|
|
53
|
+
if (value.startsWith('2001:db8')) return true;
|
|
54
|
+
const mapped = value.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
55
|
+
if (mapped) return isPrivateAddress(mapped[1]);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Reject hostnames that are local by name (before any DNS lookup). */
|
|
62
|
+
export function isBlockedHostname(hostname) {
|
|
63
|
+
const host = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase();
|
|
64
|
+
if (!host) return true;
|
|
65
|
+
if (BLOCKED_HOSTS.has(host)) return true;
|
|
66
|
+
if (BLOCKED_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
|
|
67
|
+
// Single-label names are resolved through local search domains.
|
|
68
|
+
if (!host.includes('.') && !net.isIP(host)) return true;
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Throw READ_URL_BLOCKED when a URL targets a local/private destination.
|
|
74
|
+
* @param {URL|string} input
|
|
75
|
+
* @param {object} [options]
|
|
76
|
+
* @param {boolean} [options.resolve] also resolve DNS and inspect every address
|
|
77
|
+
*/
|
|
78
|
+
export async function assertPublicUrl(input, { resolve = false } = {}) {
|
|
79
|
+
let url;
|
|
80
|
+
try {
|
|
81
|
+
url = input instanceof URL ? input : new URL(String(input));
|
|
82
|
+
} catch {
|
|
83
|
+
throw blocked('The URL is not valid.');
|
|
84
|
+
}
|
|
85
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
86
|
+
throw blocked(`Only http(s) URLs are allowed (received ${url.protocol}).`);
|
|
87
|
+
}
|
|
88
|
+
if (url.username || url.password) {
|
|
89
|
+
throw blocked('URLs with embedded credentials are refused.');
|
|
90
|
+
}
|
|
91
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
92
|
+
if (isBlockedHostname(host)) {
|
|
93
|
+
throw blocked(`Refusing to fetch a local or internal host: ${host}`);
|
|
94
|
+
}
|
|
95
|
+
if (net.isIP(host)) {
|
|
96
|
+
if (isPrivateAddress(host)) throw blocked(`Refusing to fetch a private address: ${host}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!resolve) return;
|
|
100
|
+
let records;
|
|
101
|
+
let timer;
|
|
102
|
+
try {
|
|
103
|
+
records = await Promise.race([
|
|
104
|
+
dns.lookup(host, { all: true, verbatim: true }),
|
|
105
|
+
new Promise((_, reject) => {
|
|
106
|
+
timer = setTimeout(() => {
|
|
107
|
+
reject(new FetchError(`DNS lookup for ${host} timed out.`, {
|
|
108
|
+
code: 'READ_URL_TIMEOUT',
|
|
109
|
+
retryable: true,
|
|
110
|
+
}));
|
|
111
|
+
}, 5000);
|
|
112
|
+
if (timer.unref) timer.unref();
|
|
113
|
+
}),
|
|
114
|
+
]);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error instanceof FetchError) throw error;
|
|
117
|
+
throw new FetchError(`Cannot resolve ${host}: ${error.message}`, {
|
|
118
|
+
code: 'READ_URL_NETWORK',
|
|
119
|
+
retryable: true,
|
|
120
|
+
});
|
|
121
|
+
} finally {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
}
|
|
124
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
125
|
+
if (isPrivateAddress(record.address)) {
|
|
126
|
+
throw blocked(`Refusing to fetch ${host}: it resolves to a private address (${record.address}).`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction. Applied before anything is logged, persisted, or rendered
|
|
3
|
+
* in an error message. Redaction is best-effort defense in depth; credentials
|
|
4
|
+
* are also never read from secret files by the tools.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const TOKEN_PATTERNS = [
|
|
8
|
+
[/nvapi-[A-Za-z0-9._-]{6,}/g, '[REDACTED:nvidia-key]'],
|
|
9
|
+
[/sk-[A-Za-z0-9._-]{6,}/g, '[REDACTED:api-key]'],
|
|
10
|
+
[/\bghp_[A-Za-z0-9]{10,}\b/g, '[REDACTED:github-token]'],
|
|
11
|
+
[/\bgithub_pat_[A-Za-z0-9_]{10,}\b/g, '[REDACTED:github-token]'],
|
|
12
|
+
[/\bxox[baprs]-[A-Za-z0-9-]{8,}\b/g, '[REDACTED:slack-token]'],
|
|
13
|
+
[/(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi, '$1[REDACTED]'],
|
|
14
|
+
[/((?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password|passwd)\s*[:=]\s*["']?)([^\s"',;}]{4,})/gi, '$1[REDACTED]'],
|
|
15
|
+
[/(-----BEGIN [A-Z ]*PRIVATE KEY-----)[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----)/g, '$1[REDACTED]$2'],
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build a redactor bound to a set of concrete secret values (e.g. the loaded
|
|
20
|
+
* API key) plus the generic token patterns.
|
|
21
|
+
*/
|
|
22
|
+
export function createRedactor(secrets = []) {
|
|
23
|
+
const values = [...new Set(secrets.filter((s) => typeof s === 'string' && s.trim().length >= 4))];
|
|
24
|
+
return function redact(input) {
|
|
25
|
+
if (input == null) return input;
|
|
26
|
+
let text = typeof input === 'string' ? input : safeStringify(input);
|
|
27
|
+
for (const value of values) {
|
|
28
|
+
text = text.split(value).join('[REDACTED]');
|
|
29
|
+
}
|
|
30
|
+
for (const [pattern, replacement] of TOKEN_PATTERNS) {
|
|
31
|
+
text = text.replace(pattern, replacement);
|
|
32
|
+
}
|
|
33
|
+
return text;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const defaultRedact = createRedactor([]);
|
|
38
|
+
|
|
39
|
+
export function redact(input) {
|
|
40
|
+
return defaultRedact(input);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Deep-redact strings inside plain objects/arrays; cycles become "[Circular]". */
|
|
44
|
+
export function redactDeep(value, seen = new WeakSet()) {
|
|
45
|
+
if (typeof value === 'string') return defaultRedact(value);
|
|
46
|
+
if (value == null || typeof value !== 'object') return value;
|
|
47
|
+
if (seen.has(value)) return '[Circular]';
|
|
48
|
+
seen.add(value);
|
|
49
|
+
if (Array.isArray(value)) return value.map((item) => redactDeep(item, seen));
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const [key, item] of Object.entries(value)) {
|
|
52
|
+
if (/password|secret|token|api[_-]?key/i.test(key) && typeof item === 'string' && item) {
|
|
53
|
+
out[key] = '[REDACTED]';
|
|
54
|
+
} else {
|
|
55
|
+
out[key] = redactDeep(item, seen);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Mask a configured secret without revealing length or characters. */
|
|
62
|
+
export function maskSecret(value) {
|
|
63
|
+
if (!value) return '(not set)';
|
|
64
|
+
return 'set (redacted)';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function safeStringify(value) {
|
|
68
|
+
try {
|
|
69
|
+
return JSON.stringify(value, (key, item) => (typeof item === 'bigint' ? item.toString() : item));
|
|
70
|
+
} catch {
|
|
71
|
+
return String(value);
|
|
72
|
+
}
|
|
73
|
+
}
|