@ryuu-reinzz/ryuu-cli 0.4.3 → 0.5.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 +100 -0
- package/README.md +26 -0
- package/docs/drive.md +484 -0
- package/docs/github.md +464 -0
- package/package.json +4 -1
- package/plugins/github/README.md +151 -0
- package/plugins/github/api.js +78 -0
- package/plugins/github/auth.js +45 -0
- package/plugins/github/backend.js +191 -0
- package/plugins/github/command.js +307 -0
- package/plugins/github/config.js +209 -0
- package/plugins/github/credentials.js +161 -0
- package/plugins/github/errors.js +49 -0
- package/plugins/github/index.js +41 -0
- package/plugins/github/manager.js +613 -0
- package/plugins/github/tool-helpers.js +259 -0
- package/plugins/github/tools-account.js +64 -0
- package/plugins/github/tools-actions.js +126 -0
- package/plugins/github/tools-issues.js +214 -0
- package/plugins/github/tools-pulls.js +267 -0
- package/plugins/github/tools-releases.js +87 -0
- package/plugins/github/tools-repos.js +392 -0
- package/plugins/github/tools.js +25 -0
- package/plugins/google-drive/auth.js +57 -0
- package/plugins/google-drive/backup.js +282 -0
- package/plugins/google-drive/command.js +266 -0
- package/plugins/google-drive/config.js +252 -0
- package/plugins/google-drive/credentials.js +171 -0
- package/plugins/google-drive/drive-api.js +304 -0
- package/plugins/google-drive/index.js +26 -0
- package/plugins/google-drive/manager.js +507 -0
- package/plugins/google-drive/oauth-backend.js +161 -0
- package/plugins/google-drive/secret.js +50 -0
- package/plugins/google-drive/token-client.js +80 -0
- package/plugins/google-drive/tool-helpers.js +46 -0
- package/plugins/google-drive/tools-backup.js +204 -0
- package/plugins/google-drive/tools-files.js +315 -0
- package/plugins/google-drive/tools.js +13 -0
- package/plugins/google-drive/zip-reader.js +217 -0
- package/plugins/google-drive/zip-writer.js +204 -0
- package/plugins/index.js +58 -0
- package/src/agent/agent.js +12 -2
- package/src/cli/app.js +16 -0
- package/src/cli/commands.js +44 -0
- package/src/cli/markdown.js +10 -5
- package/src/cli/renderer.js +38 -5
- package/src/cli/tui.js +80 -9
- package/src/tools/registry.js +13 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import zlib from 'node:zlib';
|
|
5
|
+
import { Writable } from 'node:stream';
|
|
6
|
+
import { once } from 'node:events';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
import { RyuuError } from '../../src/utils/errors.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Dependency-free streaming ZIP writer.
|
|
12
|
+
*
|
|
13
|
+
* Only Node built-ins are used: each file is deflated through a stream straight
|
|
14
|
+
* into the archive, so a 1 GB project never sits in memory. Sizes and CRC32 are
|
|
15
|
+
* carried by data descriptors (general-purpose bit 3), which is what allows the
|
|
16
|
+
* writer to stream without a second pass over every entry.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const SIG_LOCAL = 0x04034b50;
|
|
20
|
+
const SIG_DESCRIPTOR = 0x08074b50;
|
|
21
|
+
const SIG_CENTRAL = 0x02014b50;
|
|
22
|
+
const SIG_EOCD = 0x06054b50;
|
|
23
|
+
const MAX32 = 0xffffffff;
|
|
24
|
+
|
|
25
|
+
const CRC_TABLE = (() => {
|
|
26
|
+
const table = new Int32Array(256);
|
|
27
|
+
for (let i = 0; i < 256; i += 1) {
|
|
28
|
+
let c = i;
|
|
29
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
30
|
+
table[i] = c;
|
|
31
|
+
}
|
|
32
|
+
return table;
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
/** Running CRC32 so entries stream without buffering. */
|
|
36
|
+
export function createCrc32() {
|
|
37
|
+
let crc = -1;
|
|
38
|
+
return {
|
|
39
|
+
update(chunk) {
|
|
40
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
41
|
+
for (let i = 0; i < buffer.length; i += 1) crc = CRC_TABLE[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8);
|
|
42
|
+
return this;
|
|
43
|
+
},
|
|
44
|
+
digest() {
|
|
45
|
+
return (crc ^ -1) >>> 0;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function dosDateTime(date = new Date()) {
|
|
51
|
+
const year = Math.max(1980, date.getFullYear());
|
|
52
|
+
const time = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2);
|
|
53
|
+
const day = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
|
|
54
|
+
return { time: time & 0xffff, date: day & 0xffff };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Normalise an entry name to forward slashes with no absolute or parent parts. */
|
|
58
|
+
export function safeEntryName(name) {
|
|
59
|
+
const normalised = String(name).replace(/\\/g, '/').replace(/^\/+/, '');
|
|
60
|
+
const parts = normalised.split('/').filter((part) => part && part !== '.');
|
|
61
|
+
if (parts.some((part) => part === '..') || /^[A-Za-z]:/.test(normalised)) {
|
|
62
|
+
throw new RyuuError(`Nama entri arsip tidak aman: ${name}`, { code: 'ZIP_UNSAFE' });
|
|
63
|
+
}
|
|
64
|
+
return parts.join('/');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class ZipWriter {
|
|
68
|
+
constructor({ file, compressionLevel = 6 } = {}) {
|
|
69
|
+
this.file = file;
|
|
70
|
+
this.level = Math.min(Math.max(Number(compressionLevel ?? 6), 0), 9);
|
|
71
|
+
this.entries = [];
|
|
72
|
+
this.offset = 0;
|
|
73
|
+
this.stream = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async open() {
|
|
77
|
+
await fsp.mkdir(path.dirname(this.file), { recursive: true });
|
|
78
|
+
this.stream = fs.createWriteStream(this.file, { mode: 0o600 });
|
|
79
|
+
return this;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async #write(buffer) {
|
|
83
|
+
this.offset += buffer.length;
|
|
84
|
+
if (!this.stream.write(buffer)) await once(this.stream, 'drain');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Stream one local file into the archive under `entryName`. */
|
|
88
|
+
async addFile(entryName, absPath, { signal = null } = {}) {
|
|
89
|
+
const name = Buffer.from(safeEntryName(entryName), 'utf8');
|
|
90
|
+
const stat = await fsp.stat(absPath);
|
|
91
|
+
const { time, date } = dosDateTime(stat.mtime);
|
|
92
|
+
const method = this.level === 0 || stat.size === 0 ? 0 : 8;
|
|
93
|
+
const headerOffset = this.offset;
|
|
94
|
+
|
|
95
|
+
const header = Buffer.alloc(30);
|
|
96
|
+
header.writeUInt32LE(SIG_LOCAL, 0);
|
|
97
|
+
header.writeUInt16LE(20, 4); // version needed
|
|
98
|
+
header.writeUInt16LE(0x0808, 6); // data descriptor + UTF-8 names
|
|
99
|
+
header.writeUInt16LE(method, 8);
|
|
100
|
+
header.writeUInt16LE(time, 10);
|
|
101
|
+
header.writeUInt16LE(date, 12);
|
|
102
|
+
header.writeUInt32LE(0, 14); // crc32 (in descriptor)
|
|
103
|
+
header.writeUInt32LE(0, 18); // compressed size (in descriptor)
|
|
104
|
+
header.writeUInt32LE(0, 22); // uncompressed size (in descriptor)
|
|
105
|
+
header.writeUInt16LE(name.length, 26);
|
|
106
|
+
header.writeUInt16LE(0, 28);
|
|
107
|
+
await this.#write(Buffer.concat([header, name]));
|
|
108
|
+
|
|
109
|
+
const crc = createCrc32();
|
|
110
|
+
let uncompressed = 0;
|
|
111
|
+
let compressed = 0;
|
|
112
|
+
const source = fs.createReadStream(absPath);
|
|
113
|
+
// CRC32 and the uncompressed size are measured before deflate; the
|
|
114
|
+
// compressed size is measured on the deflated bytes.
|
|
115
|
+
const meter = async function* (stream) {
|
|
116
|
+
for await (const chunk of stream) {
|
|
117
|
+
if (signal?.aborted) throw new RyuuError('Backup dibatalkan.', { code: 'CANCELLED' });
|
|
118
|
+
crc.update(chunk);
|
|
119
|
+
uncompressed += chunk.length;
|
|
120
|
+
yield chunk;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const sink = new Writable({
|
|
124
|
+
write: (chunk, _encoding, callback) => {
|
|
125
|
+
compressed += chunk.length;
|
|
126
|
+
this.#write(chunk).then(() => callback(), (error) => callback(error));
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
if (method === 8) {
|
|
130
|
+
await pipeline(source, meter, zlib.createDeflateRaw({ level: this.level }), sink);
|
|
131
|
+
} else {
|
|
132
|
+
await pipeline(source, meter, sink);
|
|
133
|
+
}
|
|
134
|
+
if (compressed > MAX32 || uncompressed > MAX32) {
|
|
135
|
+
throw new RyuuError('Arsip melebihi 4 GB: ZIP64 tidak didukung. Pecah backup menjadi beberapa bagian.', {
|
|
136
|
+
code: 'ZIP_TOO_LARGE',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const descriptor = Buffer.alloc(16);
|
|
141
|
+
descriptor.writeUInt32LE(SIG_DESCRIPTOR, 0);
|
|
142
|
+
descriptor.writeUInt32LE(crc.digest(), 4);
|
|
143
|
+
descriptor.writeUInt32LE(compressed, 8);
|
|
144
|
+
descriptor.writeUInt32LE(uncompressed, 12);
|
|
145
|
+
await this.#write(descriptor);
|
|
146
|
+
|
|
147
|
+
this.entries.push({
|
|
148
|
+
name: name.toString('utf8'),
|
|
149
|
+
method,
|
|
150
|
+
crc32: crc.digest(),
|
|
151
|
+
compressedSize: compressed,
|
|
152
|
+
uncompressedSize: uncompressed,
|
|
153
|
+
offset: headerOffset,
|
|
154
|
+
time,
|
|
155
|
+
date,
|
|
156
|
+
});
|
|
157
|
+
return { name: name.toString('utf8'), size: uncompressed, storedSize: compressed };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async close() {
|
|
161
|
+
const centralStart = this.offset;
|
|
162
|
+
for (const entry of this.entries) {
|
|
163
|
+
const name = Buffer.from(entry.name, 'utf8');
|
|
164
|
+
const record = Buffer.alloc(46);
|
|
165
|
+
record.writeUInt32LE(SIG_CENTRAL, 0);
|
|
166
|
+
record.writeUInt16LE(20, 4); // version made by
|
|
167
|
+
record.writeUInt16LE(20, 6); // version needed
|
|
168
|
+
record.writeUInt16LE(0x0808, 8);
|
|
169
|
+
record.writeUInt16LE(entry.method, 10);
|
|
170
|
+
record.writeUInt16LE(entry.time, 12);
|
|
171
|
+
record.writeUInt16LE(entry.date, 14);
|
|
172
|
+
record.writeUInt32LE(entry.crc32, 16);
|
|
173
|
+
record.writeUInt32LE(entry.compressedSize, 20);
|
|
174
|
+
record.writeUInt32LE(entry.uncompressedSize, 24);
|
|
175
|
+
record.writeUInt16LE(name.length, 28);
|
|
176
|
+
record.writeUInt16LE(0, 30); // extra
|
|
177
|
+
record.writeUInt16LE(0, 32); // comment
|
|
178
|
+
record.writeUInt16LE(0, 34); // disk
|
|
179
|
+
record.writeUInt16LE(0, 36); // internal attrs
|
|
180
|
+
record.writeUInt32LE(0, 38); // external attrs
|
|
181
|
+
record.writeUInt32LE(entry.offset, 42);
|
|
182
|
+
await this.#write(Buffer.concat([record, name]));
|
|
183
|
+
}
|
|
184
|
+
const centralSize = this.offset - centralStart;
|
|
185
|
+
if (this.offset > MAX32) {
|
|
186
|
+
throw new RyuuError('Arsip melewati 4 GB: ZIP64 tidak didukung.', { code: 'ZIP_TOO_LARGE' });
|
|
187
|
+
}
|
|
188
|
+
const eocd = Buffer.alloc(22);
|
|
189
|
+
eocd.writeUInt32LE(SIG_EOCD, 0);
|
|
190
|
+
eocd.writeUInt16LE(0, 4);
|
|
191
|
+
eocd.writeUInt16LE(0, 6);
|
|
192
|
+
eocd.writeUInt16LE(this.entries.length, 8);
|
|
193
|
+
eocd.writeUInt16LE(this.entries.length, 10);
|
|
194
|
+
eocd.writeUInt32LE(centralSize, 12);
|
|
195
|
+
eocd.writeUInt32LE(centralStart, 16);
|
|
196
|
+
eocd.writeUInt16LE(0, 20);
|
|
197
|
+
await this.#write(eocd);
|
|
198
|
+
this.stream.end();
|
|
199
|
+
await once(this.stream, 'finish');
|
|
200
|
+
return { file: this.file, bytes: this.offset, fileCount: this.entries.length };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export { SIG_LOCAL, SIG_CENTRAL, SIG_EOCD, MAX32 };
|
package/plugins/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createGoogleDrivePlugin, googleDrivePluginDescriptor } from './google-drive/index.js';
|
|
2
|
+
import { createGitHubPlugin, githubPluginDescriptor } from './github/index.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plugin host for Ryuu CLI.
|
|
6
|
+
*
|
|
7
|
+
* Plugins live outside `src/` on purpose: they are optional integrations that
|
|
8
|
+
* are wired into the existing tool registry and command parser instead of
|
|
9
|
+
* becoming part of the core service layer. A plugin never registers its tools
|
|
10
|
+
* directly — the host does, and only when the integration is enabled and
|
|
11
|
+
* authenticated.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const FACTORIES = [
|
|
15
|
+
{ descriptor: googleDrivePluginDescriptor, create: (options) => createGoogleDrivePlugin(options) },
|
|
16
|
+
{ descriptor: githubPluginDescriptor, create: (options) => createGitHubPlugin(options) },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** Instantiate every known plugin. Failures are reported, not fatal. */
|
|
20
|
+
export function loadPlugins({ logger = null, workspace = null, config = null } = {}) {
|
|
21
|
+
const plugins = [];
|
|
22
|
+
for (const entry of FACTORIES) {
|
|
23
|
+
try {
|
|
24
|
+
plugins.push(entry.create({ logger, workspace, config }));
|
|
25
|
+
} catch (error) {
|
|
26
|
+
logger?.warn?.(`plugin ${entry.descriptor.name} failed to load`, { error: error.message });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return plugins;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Attach the plugin tools to a registry when its lifecycle state allows it.
|
|
34
|
+
*
|
|
35
|
+
* Returns the number of tools registered. Registration is idempotent per
|
|
36
|
+
* process: a second call with the same state reuses the registry's plugin
|
|
37
|
+
* bookkeeping instead of throwing a collision.
|
|
38
|
+
*/
|
|
39
|
+
export async function syncPluginTools(plugin, registry) {
|
|
40
|
+
if (!plugin || !registry) return 0;
|
|
41
|
+
const ready = (await plugin.isEnabled()) && (await plugin.isAuthenticated());
|
|
42
|
+
if (!ready) return 0;
|
|
43
|
+
if (registry.plugins?.has?.(plugin.name)) return plugin.tools.length;
|
|
44
|
+
registry.registerPlugin({ name: plugin.name, version: plugin.version, tools: plugin.tools });
|
|
45
|
+
return plugin.tools.length;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Remove one plugin's tools from the registry (used by /drive disable). */
|
|
49
|
+
export function unregisterPluginTools(plugin, registry) {
|
|
50
|
+
if (!plugin || !registry?.plugins?.has?.(plugin.name)) return 0;
|
|
51
|
+
const entry = registry.plugins.get(plugin.name);
|
|
52
|
+
const names = entry?.toolNames || [];
|
|
53
|
+
for (const name of names) registry.tools.delete(name);
|
|
54
|
+
registry.plugins.delete(plugin.name);
|
|
55
|
+
return names.length;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { googleDrivePluginDescriptor, githubPluginDescriptor };
|
package/src/agent/agent.js
CHANGED
|
@@ -1018,6 +1018,11 @@ export class Agent {
|
|
|
1018
1018
|
for (const call of calls) {
|
|
1019
1019
|
checkAbort();
|
|
1020
1020
|
callIndex += 1;
|
|
1021
|
+
// The plugin a tool belongs to, if any: the CLI paints plugin tools in
|
|
1022
|
+
// their own colour. Resolved once here so every event of this call
|
|
1023
|
+
// carries the same flag.
|
|
1024
|
+
const callPlugin = typeof this.registry?.pluginFor === 'function' ? this.registry.pluginFor(call.name) : null;
|
|
1025
|
+
const pluginName = callPlugin ? callPlugin.name : null;
|
|
1021
1026
|
const duplicateKey = `${call.name}\u0000${call.argumentsText}`;
|
|
1022
1027
|
if (seenInStep.has(duplicateKey)) {
|
|
1023
1028
|
this.#toolReply(messages, call, { ok: false, error: { code: 'DUPLICATE_CALL', message: 'Duplicate tool call in the same step was ignored.' } }, session);
|
|
@@ -1100,6 +1105,7 @@ export class Agent {
|
|
|
1100
1105
|
ok: true,
|
|
1101
1106
|
durationMs: 0,
|
|
1102
1107
|
cached: true,
|
|
1108
|
+
plugin: pluginName,
|
|
1103
1109
|
summary: `${args.path} already read and unchanged — not read again`,
|
|
1104
1110
|
});
|
|
1105
1111
|
this.#toolReply(
|
|
@@ -1147,7 +1153,7 @@ export class Agent {
|
|
|
1147
1153
|
ok: false,
|
|
1148
1154
|
error: { code: previewError.code || 'PREVIEW_FAILED', message: previewError.message },
|
|
1149
1155
|
}, session);
|
|
1150
|
-
this.#emit('toolEnd', { name: call.name, ok: false, durationMs: 0, summary: previewError.message });
|
|
1156
|
+
this.#emit('toolEnd', { name: call.name, ok: false, durationMs: 0, plugin: pluginName, summary: previewError.message });
|
|
1151
1157
|
continue;
|
|
1152
1158
|
}
|
|
1153
1159
|
|
|
@@ -1177,12 +1183,15 @@ export class Agent {
|
|
|
1177
1183
|
}
|
|
1178
1184
|
}
|
|
1179
1185
|
|
|
1186
|
+
// Plugin tools are flagged so the CLI can paint them differently from
|
|
1187
|
+
// built-in tooling. It is metadata only: execution is unchanged.
|
|
1180
1188
|
this.#emit('toolStart', {
|
|
1181
1189
|
name: call.name,
|
|
1182
1190
|
args,
|
|
1183
1191
|
risk: tool.risk,
|
|
1184
1192
|
index: callIndex,
|
|
1185
1193
|
total: calls.length,
|
|
1194
|
+
plugin: pluginName,
|
|
1186
1195
|
detail: describeToolTarget(call.name, args),
|
|
1187
1196
|
});
|
|
1188
1197
|
const toolStarted = Date.now();
|
|
@@ -1210,6 +1219,7 @@ export class Agent {
|
|
|
1210
1219
|
name: call.name,
|
|
1211
1220
|
ok: true,
|
|
1212
1221
|
durationMs,
|
|
1222
|
+
plugin: pluginName,
|
|
1213
1223
|
summary: summarizeToolResult(call.name, result),
|
|
1214
1224
|
});
|
|
1215
1225
|
// The command the user approved and what it actually printed: the
|
|
@@ -1235,7 +1245,7 @@ export class Agent {
|
|
|
1235
1245
|
const durationMs = Date.now() - toolStarted;
|
|
1236
1246
|
const message = toolError?.message || String(toolError);
|
|
1237
1247
|
failureCounts.set(callKey, previousFailures + 1);
|
|
1238
|
-
this.#emit('toolEnd', { name: call.name, ok: false, durationMs, summary: message });
|
|
1248
|
+
this.#emit('toolEnd', { name: call.name, ok: false, durationMs, plugin: pluginName, summary: message });
|
|
1239
1249
|
session.issues.push(`${call.name}: ${message.slice(0, 300)}`);
|
|
1240
1250
|
this.#toolReply(messages, call, {
|
|
1241
1251
|
ok: false,
|
package/src/cli/app.js
CHANGED
|
@@ -7,6 +7,7 @@ import { UndoManager } from '../services/undo.js';
|
|
|
7
7
|
import { createLogger } from '../services/logger.js';
|
|
8
8
|
import { buildProjectSummary } from '../tools/project.js';
|
|
9
9
|
import { buildToolRegistry } from '../tools/index.js';
|
|
10
|
+
import { loadPlugins, syncPluginTools } from '../../plugins/index.js';
|
|
10
11
|
import { createActiveProvider, selectionFor } from '../providers/index.js';
|
|
11
12
|
import { buildProviders, parseExtraProviders, PROVIDER_REQUEST_FIELDS } from '../providers/catalog.js';
|
|
12
13
|
import { buildRawConfig } from '../config/load.js';
|
|
@@ -110,6 +111,16 @@ export async function createApp({
|
|
|
110
111
|
const provider = injectedProvider || createActiveProvider(config, { logger });
|
|
111
112
|
if (!provider.config) provider.config = config;
|
|
112
113
|
provider.processes = processes;
|
|
114
|
+
// Optional integrations live in plugins/ and attach their tools to the same
|
|
115
|
+
// registry. A plugin whose state forbids it (disabled / not logged in) leaves
|
|
116
|
+
// the registry untouched, so the model is never offered a tool that cannot run.
|
|
117
|
+
const plugins = loadPlugins({ logger, workspace, config });
|
|
118
|
+
for (const plugin of plugins) {
|
|
119
|
+
plugin.registry = registry;
|
|
120
|
+
await syncPluginTools(plugin, registry).catch((error) => {
|
|
121
|
+
logger.warn('plugin tools not registered', { plugin: plugin.name, error: error.message });
|
|
122
|
+
});
|
|
123
|
+
}
|
|
113
124
|
const projectCache = new ProjectSummaryCache({ file: path.join(config.cacheDir, 'project-summary.json') });
|
|
114
125
|
|
|
115
126
|
let prompts = null;
|
|
@@ -330,6 +341,9 @@ export async function createApp({
|
|
|
330
341
|
provider,
|
|
331
342
|
approvals,
|
|
332
343
|
webSearch: registry.webSearchManager || injectedWebManager || null,
|
|
344
|
+
plugins,
|
|
345
|
+
getPlugin: (name) => plugins.find((plugin) => plugin.name === name) || null,
|
|
346
|
+
syncPluginTools: (plugin) => syncPluginTools(plugin, registry),
|
|
333
347
|
interactive,
|
|
334
348
|
getAgent: () => agent,
|
|
335
349
|
getLastSummary: () => lastSummary,
|
|
@@ -343,6 +357,7 @@ export async function createApp({
|
|
|
343
357
|
return true;
|
|
344
358
|
},
|
|
345
359
|
confirmLocal,
|
|
360
|
+
showCode: typeof renderer.showCode === 'function' ? (block) => renderer.showCode(block) : null,
|
|
346
361
|
};
|
|
347
362
|
|
|
348
363
|
const commands = createCommands(state);
|
|
@@ -451,6 +466,7 @@ export async function createApp({
|
|
|
451
466
|
undo,
|
|
452
467
|
approvals,
|
|
453
468
|
webSearch: registry.webSearchManager || injectedWebManager || null,
|
|
469
|
+
plugins,
|
|
454
470
|
renderer,
|
|
455
471
|
logger,
|
|
456
472
|
commands,
|
package/src/cli/commands.js
CHANGED
|
@@ -29,6 +29,8 @@ import { formatDoctor, runDoctor } from '../services/doctor.js';
|
|
|
29
29
|
import { createUnifiedDiff, diffStats } from '../services/diff.js';
|
|
30
30
|
import { PROVIDER_INFO } from '../services/websearch/providers.js';
|
|
31
31
|
import { runUserShell } from '../services/ushell.js';
|
|
32
|
+
import { createDriveCommand } from '../../plugins/google-drive/command.js';
|
|
33
|
+
import { createGitHubCommand } from '../../plugins/github/command.js';
|
|
32
34
|
import { RyuuError } from '../utils/errors.js';
|
|
33
35
|
|
|
34
36
|
export function parseCommandLine(line) {
|
|
@@ -1116,6 +1118,48 @@ export function createCommands(state) {
|
|
|
1116
1118
|
);
|
|
1117
1119
|
});
|
|
1118
1120
|
|
|
1121
|
+
// Optional plugins (plugins/): each contributes one namespaced command so the
|
|
1122
|
+
// core parser stays untouched. `/drive` is only defined when the plugin loaded.
|
|
1123
|
+
for (const plugin of state.plugins || []) {
|
|
1124
|
+
if (plugin.name === 'github') {
|
|
1125
|
+
const handler = createGitHubCommand({
|
|
1126
|
+
plugin,
|
|
1127
|
+
registry,
|
|
1128
|
+
confirm: (question) => state.confirmLocal(question),
|
|
1129
|
+
openBrowser: true,
|
|
1130
|
+
showCode: state.showCode || null,
|
|
1131
|
+
});
|
|
1132
|
+
define(
|
|
1133
|
+
'github',
|
|
1134
|
+
'GitHub integration: login | logout | status | account | test | enable | disable | config',
|
|
1135
|
+
'/github [login [--device]|logout|status|account|test|enable|disable|config]',
|
|
1136
|
+
async (args = '') => handler(args, print),
|
|
1137
|
+
[
|
|
1138
|
+
' Autentikasi memakai GitHub App lewat backend Ryuu, bukan token di CLI.',
|
|
1139
|
+
' login membuka browser lalu menunggu persetujuan',
|
|
1140
|
+
' login --device device flow untuk mesin headless',
|
|
1141
|
+
' enable mendaftarkan tool GitHub untuk AI Agent',
|
|
1142
|
+
' disable melepas tool tanpa menghapus akun/token',
|
|
1143
|
+
].join('\n'),
|
|
1144
|
+
);
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
if (plugin.name !== 'google-drive') continue;
|
|
1148
|
+
const handler = createDriveCommand({
|
|
1149
|
+
plugin,
|
|
1150
|
+
registry,
|
|
1151
|
+
confirm: (question) => state.confirmLocal(question),
|
|
1152
|
+
openBrowser: true,
|
|
1153
|
+
showCode: state.showCode || null,
|
|
1154
|
+
});
|
|
1155
|
+
define(
|
|
1156
|
+
plugin.command || plugin.name,
|
|
1157
|
+
`Google Drive integration: login | logout | account | enable | disable | status | config | test`,
|
|
1158
|
+
'/drive [login|logout|account|enable|disable|status|config|test]',
|
|
1159
|
+
async (args = '') => handler(args, print),
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1119
1163
|
define('config', 'Show effective configuration (secrets redacted)', '/config', async () => {
|
|
1120
1164
|
const described = describeConfig(config);
|
|
1121
1165
|
const width = Math.max(...Object.keys(described).map((key) => key.length));
|
package/src/cli/markdown.js
CHANGED
|
@@ -356,14 +356,18 @@ function renderCodeBlock(lines, lang, { width, c, color, accent = null, copy = t
|
|
|
356
356
|
// narrow to read as a button instead of eating the whole rule.
|
|
357
357
|
const chip = copy ? ' ⧉ copy ' : '';
|
|
358
358
|
const showChip = Boolean(chip) && width - 2 - visibleLength(label) - visibleLength(chip) >= 4;
|
|
359
|
-
|
|
359
|
+
// The top rule must end in a corner and keep the same visible width as the
|
|
360
|
+
// bottom one, or the frame looks ragged in a fixed-width terminal.
|
|
361
|
+
const rule = Math.max(1, width - 3 - visibleLength(label) - (showChip ? visibleLength(chip) : 0));
|
|
360
362
|
const paint = toAccent(accent, c);
|
|
361
363
|
const chipPaint = showChip ? c.bgHex('#1e293b')(c.hex(SLATE)(chip)) : '';
|
|
362
364
|
const output = [
|
|
363
|
-
`${paint('╭─')}${c.bold(label)}${c.dim('─'.repeat(rule))}${chipPaint}`,
|
|
365
|
+
`${paint('╭─')}${c.bold(label)}${c.dim('─'.repeat(rule))}${chipPaint}${paint('╮')}`,
|
|
364
366
|
];
|
|
365
367
|
const body = lines.length > 0 ? lines : [''];
|
|
366
|
-
|
|
368
|
+
// Every body row is padded and closed with a right edge, so the frame is a
|
|
369
|
+
// real box instead of three ragged vertical bars.
|
|
370
|
+
const codeWidth = Math.max(8, width - 3);
|
|
367
371
|
for (const line of body) {
|
|
368
372
|
const chunks = [];
|
|
369
373
|
for (let index = 0; index < line.length; index += codeWidth) {
|
|
@@ -371,10 +375,11 @@ function renderCodeBlock(lines, lang, { width, c, color, accent = null, copy = t
|
|
|
371
375
|
}
|
|
372
376
|
if (chunks.length === 0) chunks.push('');
|
|
373
377
|
for (const chunk of chunks) {
|
|
374
|
-
|
|
378
|
+
const pad = ' '.repeat(Math.max(0, width - 3 - visibleLength(chunk)));
|
|
379
|
+
output.push(`${paint('│')} ${highlightCode(chunk, lang, c, color)}${pad}${paint('│')}`);
|
|
375
380
|
}
|
|
376
381
|
}
|
|
377
|
-
output.push(paint(`╰${'─'.repeat(Math.max(1, width - 2))}
|
|
382
|
+
output.push(paint(`╰${'─'.repeat(Math.max(1, width - 2))}╯`));
|
|
378
383
|
return output;
|
|
379
384
|
}
|
|
380
385
|
|
package/src/cli/renderer.js
CHANGED
|
@@ -311,6 +311,15 @@ export class Renderer {
|
|
|
311
311
|
return this.c.hex(PALETTE[name] || PALETTE.white)(String(text));
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Tool name colour: built-ins stay flat blue, plugin tools are sky blue and
|
|
316
|
+
* bold so an installed integration is visible at a glance.
|
|
317
|
+
*/
|
|
318
|
+
#toolName(name, plugin) {
|
|
319
|
+
const text = String(name ?? '');
|
|
320
|
+
return plugin ? this.c.bold(this.c.hex(PALETTE.sky)(text)) : this.c.hex(PALETTE.blue)(text);
|
|
321
|
+
}
|
|
322
|
+
|
|
314
323
|
#visibleLength(text) {
|
|
315
324
|
return displayWidth(text, { ansi: true });
|
|
316
325
|
}
|
|
@@ -510,6 +519,29 @@ export class Renderer {
|
|
|
510
519
|
for (const line of String(text).split('\n')) this.#write(line);
|
|
511
520
|
}
|
|
512
521
|
|
|
522
|
+
/**
|
|
523
|
+
* A framed monospace block with a `⧉ copy` chip — used by commands that must
|
|
524
|
+
* show something copyable, such as a login URL. It reuses the Markdown code
|
|
525
|
+
* block path, so the whole box is the clipboard hit target.
|
|
526
|
+
*/
|
|
527
|
+
showCode({ label = '', text = '' } = {}) {
|
|
528
|
+
const body = String(text ?? '');
|
|
529
|
+
if (!body) return;
|
|
530
|
+
// A piped or plain run has no frame and no click target: print the value.
|
|
531
|
+
if (this.plain) {
|
|
532
|
+
this.lines(body);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (/(^|\n)\s*```/.test(body)) {
|
|
536
|
+
this.lines(body);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const lang = String(label ?? '')
|
|
540
|
+
.replace(/[^A-Za-z0-9_+#.-]+/g, '-')
|
|
541
|
+
.replace(/^-+|-+$/g, '');
|
|
542
|
+
this.#markdown('```' + `${lang}\n${body}\n` + '```');
|
|
543
|
+
}
|
|
544
|
+
|
|
513
545
|
banner({ version, model, provider, workspace, node, approvalPolicy, logFile }) {
|
|
514
546
|
const c = this.c;
|
|
515
547
|
if (this.plain) {
|
|
@@ -1344,7 +1376,7 @@ export class Renderer {
|
|
|
1344
1376
|
}
|
|
1345
1377
|
}
|
|
1346
1378
|
|
|
1347
|
-
toolStart({ name, args, index = 0, total = 0, detail = '' }) {
|
|
1379
|
+
toolStart({ name, args, index = 0, total = 0, detail = '', plugin = null }) {
|
|
1348
1380
|
if (this.reasoning.active) this.reasoningEnd({});
|
|
1349
1381
|
if (this.answer.active) this.answerEnd();
|
|
1350
1382
|
// Running a tool is exactly the `working` phase the user asked for.
|
|
@@ -1365,11 +1397,11 @@ export class Renderer {
|
|
|
1365
1397
|
return;
|
|
1366
1398
|
}
|
|
1367
1399
|
this.#write(
|
|
1368
|
-
` ${this.c.dim('│')} ${this.#paint('violet', '◆')} ${this
|
|
1400
|
+
` ${this.c.dim('│')} ${this.#paint('violet', '◆')} ${this.#toolName(name, plugin)} ${this.c.dim(this.#fit(argsPreview, this.#innerWidth() - 20))}`,
|
|
1369
1401
|
);
|
|
1370
1402
|
}
|
|
1371
1403
|
|
|
1372
|
-
toolEnd({ name, ok, durationMs, summary, cached = false }) {
|
|
1404
|
+
toolEnd({ name, ok, durationMs, summary, cached = false, plugin = null }) {
|
|
1373
1405
|
this.status.tool = '';
|
|
1374
1406
|
this.status.toolIndex = 0;
|
|
1375
1407
|
this.status.toolTotal = 0;
|
|
@@ -1384,7 +1416,7 @@ export class Renderer {
|
|
|
1384
1416
|
this.#write(this.c.dim(` [cached] ${name} · ${detail}`));
|
|
1385
1417
|
return;
|
|
1386
1418
|
}
|
|
1387
|
-
this.#write(` ${this.c.dim('│')} ${this.#paint('cyan', '↺')} ${this
|
|
1419
|
+
this.#write(` ${this.c.dim('│')} ${this.#paint('cyan', '↺')} ${this.#toolName(name, plugin)} ${this.c.dim(`· ${detail}`)}`);
|
|
1388
1420
|
return;
|
|
1389
1421
|
}
|
|
1390
1422
|
if (this.plain) {
|
|
@@ -1396,8 +1428,9 @@ export class Renderer {
|
|
|
1396
1428
|
}
|
|
1397
1429
|
const symbol = ok ? this.#paint('green', '✓') : this.#paint('red', '✕');
|
|
1398
1430
|
const stats = [duration, detail].filter(Boolean).join(' · ');
|
|
1431
|
+
const label = ok ? this.#toolName(name, plugin) : this.c.hex(PALETTE.red)(String(name ?? ''));
|
|
1399
1432
|
this.#write(
|
|
1400
|
-
` ${this.c.dim('│')} ${symbol} ${
|
|
1433
|
+
` ${this.c.dim('│')} ${symbol} ${label}${stats ? ` ${this.c.dim(`· ${stats}`)}` : ''}`,
|
|
1401
1434
|
);
|
|
1402
1435
|
}
|
|
1403
1436
|
|