@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +26 -0
  3. package/docs/drive.md +484 -0
  4. package/docs/github.md +464 -0
  5. package/package.json +4 -1
  6. package/plugins/github/README.md +151 -0
  7. package/plugins/github/api.js +78 -0
  8. package/plugins/github/auth.js +45 -0
  9. package/plugins/github/backend.js +191 -0
  10. package/plugins/github/command.js +307 -0
  11. package/plugins/github/config.js +209 -0
  12. package/plugins/github/credentials.js +161 -0
  13. package/plugins/github/errors.js +49 -0
  14. package/plugins/github/index.js +41 -0
  15. package/plugins/github/manager.js +613 -0
  16. package/plugins/github/tool-helpers.js +259 -0
  17. package/plugins/github/tools-account.js +64 -0
  18. package/plugins/github/tools-actions.js +126 -0
  19. package/plugins/github/tools-issues.js +214 -0
  20. package/plugins/github/tools-pulls.js +267 -0
  21. package/plugins/github/tools-releases.js +87 -0
  22. package/plugins/github/tools-repos.js +392 -0
  23. package/plugins/github/tools.js +25 -0
  24. package/plugins/google-drive/auth.js +57 -0
  25. package/plugins/google-drive/backup.js +282 -0
  26. package/plugins/google-drive/command.js +266 -0
  27. package/plugins/google-drive/config.js +252 -0
  28. package/plugins/google-drive/credentials.js +171 -0
  29. package/plugins/google-drive/drive-api.js +304 -0
  30. package/plugins/google-drive/index.js +26 -0
  31. package/plugins/google-drive/manager.js +507 -0
  32. package/plugins/google-drive/oauth-backend.js +161 -0
  33. package/plugins/google-drive/secret.js +50 -0
  34. package/plugins/google-drive/token-client.js +80 -0
  35. package/plugins/google-drive/tool-helpers.js +46 -0
  36. package/plugins/google-drive/tools-backup.js +204 -0
  37. package/plugins/google-drive/tools-files.js +315 -0
  38. package/plugins/google-drive/tools.js +13 -0
  39. package/plugins/google-drive/zip-reader.js +217 -0
  40. package/plugins/google-drive/zip-writer.js +204 -0
  41. package/plugins/index.js +58 -0
  42. package/src/agent/agent.js +12 -2
  43. package/src/cli/app.js +16 -0
  44. package/src/cli/commands.js +44 -0
  45. package/src/cli/markdown.js +10 -5
  46. package/src/cli/renderer.js +38 -5
  47. package/src/cli/tui.js +80 -9
  48. 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 };
@@ -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 };
@@ -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,
@@ -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));
@@ -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
- const rule = Math.max(1, width - 2 - visibleLength(label) - (showChip ? visibleLength(chip) : 0));
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
- const codeWidth = Math.max(8, width - 2);
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
- output.push(`${paint('│')} ${highlightCode(chunk, lang, c, color)}`);
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
 
@@ -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.c.bold(this.c.hex(PALETTE.blue)(name))} ${this.c.dim(this.#fit(argsPreview, this.#innerWidth() - 20))}`,
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.c.hex(PALETTE.blue)(name)} ${this.c.dim(`· ${detail}`)}`);
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} ${this.c.hex(PALETTE.blue)(name)}${stats ? ` ${this.c.dim(`· ${stats}`)}` : ''}`,
1433
+ ` ${this.c.dim('│')} ${symbol} ${label}${stats ? ` ${this.c.dim(`· ${stats}`)}` : ''}`,
1401
1434
  );
1402
1435
  }
1403
1436