@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,104 @@
|
|
|
1
|
+
import { ProviderError } from '../utils/errors.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Multi-provider registry.
|
|
5
|
+
*
|
|
6
|
+
* Providers are registered by ID and can be instantiated on demand with a
|
|
7
|
+
* config object. The registry keeps a cache of created providers so the same
|
|
8
|
+
* instance is reused across requests within a session.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* const registry = new ProviderRegistry();
|
|
12
|
+
* registry.register('tokenharbor', createTokenHarborProvider);
|
|
13
|
+
* const provider = await registry.resolve('tokenharbor', config, { logger });
|
|
14
|
+
*
|
|
15
|
+
* Provider factories receive (config, deps) and must return an object with at
|
|
16
|
+
* least `chat(...)`, `ping()`, and a `model` getter (matching the existing
|
|
17
|
+
* NvidiaProvider interface).
|
|
18
|
+
*/
|
|
19
|
+
export class ProviderRegistry {
|
|
20
|
+
constructor(factories = {}) {
|
|
21
|
+
this.factories = new Map();
|
|
22
|
+
this.providers = new Map();
|
|
23
|
+
for (const [id, factory] of Object.entries(factories)) {
|
|
24
|
+
this.factories.set(id, factory);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Register a provider factory under an id. Overwrites any existing one. */
|
|
29
|
+
register(id, factory) {
|
|
30
|
+
if (!id || typeof id !== 'string') {
|
|
31
|
+
throw new ProviderError('Provider id must be a non-empty string.', {
|
|
32
|
+
code: 'INVALID_PROVIDER_ID',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (typeof factory !== 'function') {
|
|
36
|
+
throw new ProviderError(`Provider factory for ${id} must be a function.`, {
|
|
37
|
+
code: 'INVALID_PROVIDER_FACTORY',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
this.factories.set(id, factory);
|
|
41
|
+
this.providers.delete(id); // drop any cached instance for the old factory
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Unregister a provider and drop any cached instance. */
|
|
45
|
+
unregister(id) {
|
|
46
|
+
this.factories.delete(id);
|
|
47
|
+
this.providers.delete(id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Check whether a provider id is known (registered or already instantiated). */
|
|
51
|
+
has(id) {
|
|
52
|
+
return this.factories.has(id) || this.providers.has(id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** List all known provider ids. */
|
|
56
|
+
list() {
|
|
57
|
+
return [...this.factories.keys()];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Return a cached provider instance, or null when none exists yet. */
|
|
61
|
+
get(id) {
|
|
62
|
+
return this.providers.get(id) || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Create (and cache) a provider instance for the given id. */
|
|
66
|
+
create(id, config, deps = {}) {
|
|
67
|
+
const factory = this.factories.get(id);
|
|
68
|
+
if (!factory) {
|
|
69
|
+
throw new ProviderError(`Unknown provider: ${id}. Registered: ${this.list().join(', ') || '(none)'}`, {
|
|
70
|
+
code: 'UNKNOWN_PROVIDER',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const provider = factory(config, deps);
|
|
74
|
+
this.providers.set(id, provider);
|
|
75
|
+
return provider;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Resolve a provider: return the cached instance or create a new one. */
|
|
79
|
+
resolve(id, config, deps = {}) {
|
|
80
|
+
if (!this.has(id)) {
|
|
81
|
+
throw new ProviderError(`Unknown provider: ${id}. Registered: ${this.list().join(', ') || '(none)'}`, {
|
|
82
|
+
code: 'UNKNOWN_PROVIDER',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const existing = this.providers.get(id);
|
|
86
|
+
if (existing) return existing;
|
|
87
|
+
return this.create(id, config, deps);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Create providers for every registered id (e.g. for health checks). */
|
|
91
|
+
async createAll(config, deps = {}) {
|
|
92
|
+
const instances = new Map();
|
|
93
|
+
for (const id of this.list()) {
|
|
94
|
+
try {
|
|
95
|
+
instances.set(id, this.create(id, config, deps));
|
|
96
|
+
} catch {
|
|
97
|
+
// Providers that fail to instantiate are simply skipped.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return instances;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const defaultRegistry = new ProviderRegistry();
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copying to the terminal clipboard with OSC 52.
|
|
3
|
+
*
|
|
4
|
+
* The sequence is written to the tty, so the copy happens in the terminal
|
|
5
|
+
* emulator (or terminal multiplexer) rather than through an OS command: it works
|
|
6
|
+
* over SSH, needs no `pbcopy`/`xclip`/`clip.exe`, and touches no other process.
|
|
7
|
+
* Terminals that ignore OSC 52 simply do nothing, which is why callers report the
|
|
8
|
+
* result instead of assuming the clipboard changed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Terminals cap OSC 52 payloads (xterm's default is ~75k of base64). */
|
|
12
|
+
const MAX_CHARS = 100_000;
|
|
13
|
+
|
|
14
|
+
/** The raw sequence for `text`, or null when there is nothing to copy. */
|
|
15
|
+
export function clipboardPayload(text, { limit = MAX_CHARS } = {}) {
|
|
16
|
+
const value = String(text ?? '');
|
|
17
|
+
if (!value.trim()) return null;
|
|
18
|
+
const kept = value.length > limit ? value.slice(0, limit) : value;
|
|
19
|
+
return {
|
|
20
|
+
sequence: `\x1b]52;c;${Buffer.from(kept, 'utf8').toString('base64')}\x1b\\`,
|
|
21
|
+
chars: [...kept].length,
|
|
22
|
+
truncated: kept.length < value.length,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Put `text` on the clipboard. Returns `{ ok, chars, truncated, reason }`; a
|
|
28
|
+
* failure is reported, never thrown, because a copy must not break a session.
|
|
29
|
+
*/
|
|
30
|
+
export function copyToClipboard(text, { output = process.stdout } = {}) {
|
|
31
|
+
const payload = clipboardPayload(text);
|
|
32
|
+
if (!payload) return { ok: false, reason: 'empty', chars: 0 };
|
|
33
|
+
if (!output || output.isTTY === false || !output.write) {
|
|
34
|
+
return { ok: false, reason: 'no-tty', chars: 0 };
|
|
35
|
+
}
|
|
36
|
+
output.write(payload.sequence);
|
|
37
|
+
return { ok: true, chars: payload.chars, truncated: payload.truncated };
|
|
38
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified diff generation for edit previews and /diff.
|
|
3
|
+
* Line-based LCS with a bounded matrix; very large files fall back to a
|
|
4
|
+
* whole-file replacement hunk instead of consuming unbounded memory.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const MAX_CELLS = 2_000_000;
|
|
8
|
+
|
|
9
|
+
function splitLines(text) {
|
|
10
|
+
return String(text ?? '').split('\n');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** @returns {{type: 'equal'|'del'|'add', line: string}[]} */
|
|
14
|
+
export function diffLines(oldText, newText) {
|
|
15
|
+
const a = splitLines(oldText);
|
|
16
|
+
const b = splitLines(newText);
|
|
17
|
+
const n = a.length;
|
|
18
|
+
const m = b.length;
|
|
19
|
+
|
|
20
|
+
if (n * m > MAX_CELLS) {
|
|
21
|
+
return [
|
|
22
|
+
...a.map((line) => ({ type: 'del', line })),
|
|
23
|
+
...b.map((line) => ({ type: 'add', line })),
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const width = m + 1;
|
|
28
|
+
const dp = new Int32Array((n + 1) * width);
|
|
29
|
+
for (let i = n - 1; i >= 0; i -= 1) {
|
|
30
|
+
for (let j = m - 1; j >= 0; j -= 1) {
|
|
31
|
+
dp[i * width + j] = a[i] === b[j]
|
|
32
|
+
? dp[(i + 1) * width + (j + 1)] + 1
|
|
33
|
+
: Math.max(dp[(i + 1) * width + j], dp[i * width + (j + 1)]);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ops = [];
|
|
38
|
+
let i = 0;
|
|
39
|
+
let j = 0;
|
|
40
|
+
while (i < n && j < m) {
|
|
41
|
+
if (a[i] === b[j]) {
|
|
42
|
+
ops.push({ type: 'equal', line: a[i] });
|
|
43
|
+
i += 1;
|
|
44
|
+
j += 1;
|
|
45
|
+
} else if (dp[(i + 1) * width + j] >= dp[i * width + (j + 1)]) {
|
|
46
|
+
ops.push({ type: 'del', line: a[i] });
|
|
47
|
+
i += 1;
|
|
48
|
+
} else {
|
|
49
|
+
ops.push({ type: 'add', line: b[j] });
|
|
50
|
+
j += 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
while (i < n) {
|
|
54
|
+
ops.push({ type: 'del', line: a[i] });
|
|
55
|
+
i += 1;
|
|
56
|
+
}
|
|
57
|
+
while (j < m) {
|
|
58
|
+
ops.push({ type: 'add', line: b[j] });
|
|
59
|
+
j += 1;
|
|
60
|
+
}
|
|
61
|
+
return ops;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Produce a standard unified diff. Returns '' when contents are identical.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} oldText
|
|
68
|
+
* @param {string} newText
|
|
69
|
+
* @param {{oldLabel?: string, newLabel?: string, context?: number, maxLines?: number}} options
|
|
70
|
+
*/
|
|
71
|
+
export function createUnifiedDiff(oldText, newText, {
|
|
72
|
+
oldLabel = 'a/file',
|
|
73
|
+
newLabel = 'b/file',
|
|
74
|
+
context = 3,
|
|
75
|
+
maxLines = 400,
|
|
76
|
+
} = {}) {
|
|
77
|
+
if (oldText === newText) return '';
|
|
78
|
+
const ops = diffLines(oldText, newText);
|
|
79
|
+
|
|
80
|
+
// Mark which ops are changes, then group with context.
|
|
81
|
+
const changed = ops.map((op) => op.type !== 'equal');
|
|
82
|
+
const hunks = [];
|
|
83
|
+
let index = 0;
|
|
84
|
+
while (index < ops.length) {
|
|
85
|
+
if (!changed[index]) {
|
|
86
|
+
index += 1;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
let start = Math.max(0, index - context);
|
|
90
|
+
let end = index;
|
|
91
|
+
// Extend while later changes are within 2*context of the current end.
|
|
92
|
+
while (end < ops.length) {
|
|
93
|
+
if (changed[end]) {
|
|
94
|
+
end += 1;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
let nextChange = end;
|
|
98
|
+
while (nextChange < ops.length && !changed[nextChange]) nextChange += 1;
|
|
99
|
+
if (nextChange < ops.length && nextChange - end <= context * 2) {
|
|
100
|
+
end = nextChange;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
const hunkEnd = Math.min(ops.length, end + context);
|
|
106
|
+
hunks.push({ start, end: hunkEnd });
|
|
107
|
+
index = hunkEnd;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const oldLineAt = new Array(ops.length);
|
|
111
|
+
const newLineAt = new Array(ops.length);
|
|
112
|
+
let oldNo = 1;
|
|
113
|
+
let newNo = 1;
|
|
114
|
+
for (let k = 0; k < ops.length; k += 1) {
|
|
115
|
+
oldLineAt[k] = oldNo;
|
|
116
|
+
newLineAt[k] = newNo;
|
|
117
|
+
if (ops[k].type !== 'add') oldNo += 1;
|
|
118
|
+
if (ops[k].type !== 'del') newNo += 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const output = [`--- ${oldLabel}`, `+++ ${newLabel}`];
|
|
122
|
+
let emitted = 0;
|
|
123
|
+
for (const hunk of hunks) {
|
|
124
|
+
if (emitted >= maxLines) {
|
|
125
|
+
output.push(`@@ diff truncated after ${maxLines} lines @@`);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
const slice = ops.slice(hunk.start, hunk.end);
|
|
129
|
+
const oldCount = slice.filter((op) => op.type !== 'add').length;
|
|
130
|
+
const newCount = slice.filter((op) => op.type !== 'del').length;
|
|
131
|
+
const oldStart = oldCount === 0 ? oldLineAt[hunk.start] - 1 : oldLineAt[hunk.start];
|
|
132
|
+
const newStart = newCount === 0 ? newLineAt[hunk.start] - 1 : newLineAt[hunk.start];
|
|
133
|
+
output.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
|
|
134
|
+
for (const op of slice) {
|
|
135
|
+
const prefix = op.type === 'equal' ? ' ' : op.type === 'del' ? '-' : '+';
|
|
136
|
+
output.push(`${prefix}${op.line}`);
|
|
137
|
+
emitted += 1;
|
|
138
|
+
if (emitted >= maxLines) break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return output.join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function diffStats(oldText, newText) {
|
|
145
|
+
const ops = diffLines(oldText, newText);
|
|
146
|
+
let added = 0;
|
|
147
|
+
let removed = 0;
|
|
148
|
+
for (const op of ops) {
|
|
149
|
+
if (op.type === 'add') added += 1;
|
|
150
|
+
else if (op.type === 'del') removed += 1;
|
|
151
|
+
}
|
|
152
|
+
return { added, removed, changed: added + removed };
|
|
153
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { checkRuntime } from '../config/load.js';
|
|
8
|
+
import { redact } from '../utils/redaction.js';
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
const PASS = 'pass';
|
|
13
|
+
const WARN = 'warn';
|
|
14
|
+
const FAIL = 'fail';
|
|
15
|
+
const SKIP = 'skip';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Diagnostics that never print secrets. With { live: true } it performs one
|
|
19
|
+
* small chat request; otherwise it stays fully offline.
|
|
20
|
+
*/
|
|
21
|
+
export async function runDoctor({ config, workspace, provider = null, live = false, logger = null } = {}) {
|
|
22
|
+
const checks = [];
|
|
23
|
+
const add = (name, status, detail) => checks.push({ name, status, detail });
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const runtime = checkRuntime();
|
|
27
|
+
add('runtime', runtime.major >= 25 ? PASS : WARN, `Node.js ${process.version} (target: Node.js 25.x)`);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
add('runtime', FAIL, error.message);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const apiKeyPath = config.apiKeyPath || 'providers.nvidia.apiKey';
|
|
33
|
+
const providerName = config.providerName || 'NVIDIA Integrate API';
|
|
34
|
+
const providerId = config.providerId || 'nvidia';
|
|
35
|
+
add(
|
|
36
|
+
'config',
|
|
37
|
+
config.apiKey ? PASS : FAIL,
|
|
38
|
+
config.apiKey ? `${apiKeyPath} is set (value hidden)` : `${apiKeyPath} is missing`,
|
|
39
|
+
);
|
|
40
|
+
add('provider.endpoint', PASS, `${providerName} (${providerId}) at ${config.baseURL} (OpenAI-compatible chat.completions)`);
|
|
41
|
+
add('provider.model', config.apiKey ? PASS : WARN, config.model);
|
|
42
|
+
const providerRows = Array.isArray(config.providers) ? config.providers : [];
|
|
43
|
+
add(
|
|
44
|
+
'provider.catalog',
|
|
45
|
+
providerRows.length > 0 ? PASS : FAIL,
|
|
46
|
+
providerRows.length > 0
|
|
47
|
+
? providerRows
|
|
48
|
+
.map((provider) => `${provider.id}${provider.id === providerId ? '*' : ''}: ${provider.apiKey ? 'key set' : `no key (${provider.apiKeyPath})`}`)
|
|
49
|
+
.join(' · ')
|
|
50
|
+
: 'no providers configured',
|
|
51
|
+
);
|
|
52
|
+
add(
|
|
53
|
+
'web_search',
|
|
54
|
+
config.webSearchApiKey ? PASS : SKIP,
|
|
55
|
+
config.webSearchApiKey
|
|
56
|
+
? `${config.webSearchEndpoint} (key hidden, ${config.webSearchResults} result cap)`
|
|
57
|
+
: 'Disabled: set webSearch.serperApiKey in config.json to enable the web_search tool.',
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (workspace) {
|
|
61
|
+
try {
|
|
62
|
+
const probeName = `.ryuu/doctor-probe-${crypto.randomBytes(3).toString('hex')}.txt`;
|
|
63
|
+
await workspace.writeText(probeName, 'ryuu doctor probe\n');
|
|
64
|
+
const readBack = await workspace.readText(probeName);
|
|
65
|
+
await workspace.remove(probeName);
|
|
66
|
+
add('workspace.write', readBack.text.startsWith('ryuu doctor probe') ? PASS : FAIL, `Read/write OK in ${workspace.root}`);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
add('workspace.write', FAIL, `Cannot write inside workspace: ${error.message}`);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
add('workspace.write', FAIL, 'Workspace is not available');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const [label, dir] of [['state.history', config.historyDir], ['state.logs', config.logDir], ['state.undo', config.undoDir]]) {
|
|
75
|
+
try {
|
|
76
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
77
|
+
await fsp.access(dir, fs.constants.W_OK);
|
|
78
|
+
add(label, PASS, dir);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
add(label, FAIL, `${dir} is not writable: ${error.message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const { stdout } = await execFileAsync('git', ['--version'], { timeout: 5000 });
|
|
86
|
+
add('git', PASS, stdout.trim());
|
|
87
|
+
} catch {
|
|
88
|
+
add('git', WARN, 'Git is not installed; /diff and inspect_git will report unavailable.');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!config.apiKey) {
|
|
92
|
+
add('provider.connectivity', SKIP, `Skipped: no API key. Set ${apiKeyPath}, then run /doctor live.`);
|
|
93
|
+
} else if (!live) {
|
|
94
|
+
add('provider.connectivity', SKIP, 'Offline check only. Run /doctor live (or `ryuu --doctor --live`) for one small API call.');
|
|
95
|
+
} else if (!provider) {
|
|
96
|
+
add('provider.connectivity', FAIL, 'Provider is not initialized.');
|
|
97
|
+
} else {
|
|
98
|
+
const result = await provider.ping();
|
|
99
|
+
if (result.ok) {
|
|
100
|
+
add('provider.connectivity', PASS, `${providerName} · ${result.model} responded in ${result.latencyMs}ms`);
|
|
101
|
+
} else {
|
|
102
|
+
add('provider.connectivity', FAIL, redact(`${result.error.code}: ${result.error.message}`));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
logger?.info('doctor finished', { ok: !checks.some((check) => check.status === FAIL), live });
|
|
107
|
+
return {
|
|
108
|
+
live,
|
|
109
|
+
ok: !checks.some((check) => check.status === FAIL),
|
|
110
|
+
checks,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function formatDoctor(report) {
|
|
115
|
+
const icon = { pass: 'PASS', warn: 'WARN', fail: 'FAIL', skip: 'SKIP' };
|
|
116
|
+
const lines = ['Ryuu Agent CLI doctor'];
|
|
117
|
+
for (const check of report.checks) {
|
|
118
|
+
lines.push(`[${icon[check.status] || check.status}] ${check.name}: ${check.detail}`);
|
|
119
|
+
}
|
|
120
|
+
lines.push(report.ok ? 'Result: ready (warnings are informational).' : 'Result: blocking issues found.');
|
|
121
|
+
return lines.join('\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function doctorLogPath(config) {
|
|
125
|
+
return path.join(config.logDir, 'agent.log');
|
|
126
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createRedactor } from '../utils/redaction.js';
|
|
4
|
+
|
|
5
|
+
export const LEVELS = { debug: 10, info: 20, warn: 30, error: 40, silent: 100 };
|
|
6
|
+
|
|
7
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
8
|
+
const MAX_REASONING_CHARS = 2_000_000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Leveled logger. Everything written to the log file and to stderr passes
|
|
12
|
+
* through the redactor first. Model output and tool output are logged as
|
|
13
|
+
* bounded previews, never full credential material.
|
|
14
|
+
*/
|
|
15
|
+
export class Logger {
|
|
16
|
+
constructor({ level = 'info', file = null, secrets = [], stderr = null, echo = false, maxPreview = 2000 } = {}) {
|
|
17
|
+
this.level = LEVELS[level] ?? LEVELS.info;
|
|
18
|
+
this.levelName = level;
|
|
19
|
+
this.file = file;
|
|
20
|
+
this.stderr = stderr;
|
|
21
|
+
this.echo = echo;
|
|
22
|
+
this.maxPreview = maxPreview;
|
|
23
|
+
this.redact = createRedactor(secrets);
|
|
24
|
+
this.enabled = this.level < LEVELS.silent;
|
|
25
|
+
if (this.file && this.enabled) {
|
|
26
|
+
try {
|
|
27
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
28
|
+
this.#restrictPermissions();
|
|
29
|
+
this.#rotateIfNeeded();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
this.file = null;
|
|
32
|
+
this.stderr?.write(`[warn] Cannot create log file: ${error.message}\n`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get logFile() {
|
|
38
|
+
return this.file;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setLevel(level) {
|
|
42
|
+
if (LEVELS[level] === undefined) return;
|
|
43
|
+
this.level = LEVELS[level];
|
|
44
|
+
this.levelName = level;
|
|
45
|
+
this.enabled = this.level < LEVELS.silent;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
child(bindings = {}) {
|
|
49
|
+
const logger = Object.create(this);
|
|
50
|
+
logger.bindings = { ...(this.bindings || {}), ...bindings };
|
|
51
|
+
logger.child = Logger.prototype.child;
|
|
52
|
+
logger.log = Logger.prototype.log;
|
|
53
|
+
logger.writeLine = Logger.prototype.writeLine;
|
|
54
|
+
return logger;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
debug(message, meta) {
|
|
58
|
+
this.log('debug', message, meta);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
info(message, meta) {
|
|
62
|
+
this.log('info', message, meta);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
warn(message, meta) {
|
|
66
|
+
this.log('warn', message, meta);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
error(message, meta) {
|
|
70
|
+
this.log('error', message, meta);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
log(level, message, meta) {
|
|
74
|
+
if (LEVELS[level] < this.level) return;
|
|
75
|
+
const entry = {
|
|
76
|
+
ts: new Date().toISOString(),
|
|
77
|
+
level,
|
|
78
|
+
msg: this.#preview(message),
|
|
79
|
+
...(this.bindings || {}),
|
|
80
|
+
};
|
|
81
|
+
if (meta !== undefined) {
|
|
82
|
+
entry.meta = this.#sanitizeMeta(meta);
|
|
83
|
+
}
|
|
84
|
+
this.writeLine(entry);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#preview(value) {
|
|
88
|
+
const text = typeof value === 'string' ? value : safeStringify(value);
|
|
89
|
+
const bounded = text.length > this.maxPreview ? `${text.slice(0, this.maxPreview)}…[truncated]` : text;
|
|
90
|
+
return this.redact(bounded);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#sanitizeMeta(meta) {
|
|
94
|
+
const text = safeStringify(meta);
|
|
95
|
+
const bounded = text.length > this.maxPreview ? `${text.slice(0, this.maxPreview)}…[truncated]` : text;
|
|
96
|
+
const cleaned = this.redact(bounded);
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(cleaned);
|
|
99
|
+
} catch {
|
|
100
|
+
return cleaned;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Persist provider-exposed reasoning separately from the main log. The text
|
|
106
|
+
* is redacted and bounded; the returned path is safe to show in the UI.
|
|
107
|
+
*/
|
|
108
|
+
writeReasoning({ sessionId = 'session', step = null, text = '' } = {}) {
|
|
109
|
+
if (!this.file || !this.enabled || !text) return null;
|
|
110
|
+
const safeId = String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80) || 'session';
|
|
111
|
+
const dir = path.join(path.dirname(this.file), 'reasoning');
|
|
112
|
+
const file = path.join(dir, `reasoning-${safeId}.log`);
|
|
113
|
+
const clean = this.redact(String(text));
|
|
114
|
+
const body = clean.length > MAX_REASONING_CHARS
|
|
115
|
+
? `${clean.slice(0, MAX_REASONING_CHARS)}\n…[reasoning truncated at ${MAX_REASONING_CHARS} chars]`
|
|
116
|
+
: clean;
|
|
117
|
+
try {
|
|
118
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
119
|
+
if (process.platform !== 'win32') {
|
|
120
|
+
try {
|
|
121
|
+
fs.chmodSync(dir, 0o700);
|
|
122
|
+
if (fs.existsSync(file)) fs.chmodSync(file, 0o600);
|
|
123
|
+
} catch {
|
|
124
|
+
// Non-fatal; the text is still redacted.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const header = `\n=== step ${step ?? '?'} · ${new Date().toISOString()} ===\n`;
|
|
128
|
+
fs.appendFileSync(file, `${header}${body}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
129
|
+
return file;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
this.stderr?.write(`[warn] Cannot write reasoning log: ${error.message}\n`);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
writeLine(entry) {
|
|
137
|
+
const line = this.redact(safeStringify(entry));
|
|
138
|
+
if (this.file && this.enabled) {
|
|
139
|
+
try {
|
|
140
|
+
fs.appendFileSync(this.file, `${line}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
141
|
+
} catch {
|
|
142
|
+
// Logging must never crash the agent; keep stderr usable instead.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (this.echo && this.stderr) {
|
|
146
|
+
this.stderr.write(`${line}\n`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Logs may contain workspace excerpts, so keep them owner-only on POSIX. */
|
|
151
|
+
#restrictPermissions() {
|
|
152
|
+
if (!this.file || process.platform === 'win32') return;
|
|
153
|
+
try {
|
|
154
|
+
fs.chmodSync(path.dirname(this.file), 0o700);
|
|
155
|
+
if (fs.existsSync(this.file)) fs.chmodSync(this.file, 0o600);
|
|
156
|
+
} catch {
|
|
157
|
+
// Non-fatal: the log is still redacted even if chmod is unavailable.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#rotateIfNeeded() {
|
|
162
|
+
if (!this.file || !fs.existsSync(this.file)) return;
|
|
163
|
+
const stat = fs.statSync(this.file);
|
|
164
|
+
if (stat.size < MAX_LOG_BYTES) return;
|
|
165
|
+
const backup = `${this.file}.1`;
|
|
166
|
+
fs.rmSync(backup, { force: true });
|
|
167
|
+
fs.renameSync(this.file, backup);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function createLogger(config, { stderr = process.stderr, secrets = [], echo = false } = {}) {
|
|
172
|
+
return new Logger({
|
|
173
|
+
level: config.logLevel,
|
|
174
|
+
file: config.logDir ? path.join(config.logDir, 'agent.log') : null,
|
|
175
|
+
secrets: [config.apiKey, ...secrets].filter(Boolean),
|
|
176
|
+
stderr,
|
|
177
|
+
echo,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function safeStringify(value) {
|
|
182
|
+
if (value instanceof Error) {
|
|
183
|
+
return JSON.stringify({ name: value.name, message: value.message, code: value.code, stack: value.stack });
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
return JSON.stringify(value, (key, item) => (typeof item === 'bigint' ? item.toString() : item));
|
|
187
|
+
} catch {
|
|
188
|
+
return String(value);
|
|
189
|
+
}
|
|
190
|
+
}
|