@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,282 @@
1
+ import fsp from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { RyuuError } from '../../src/utils/errors.js';
6
+ import { ZipWriter } from './zip-writer.js';
7
+ import { extractZip, listZipEntries } from './zip-reader.js';
8
+
9
+ /**
10
+ * Backup and restore engine for the Google Drive plugin.
11
+ *
12
+ * Backups are streamed into a temporary ZIP (never fully buffered), uploaded
13
+ * with a resumable request, and the temporary file is always removed. Restore
14
+ * downloads to a temporary file, validates the archive, then extracts with Zip
15
+ * Slip protection.
16
+ */
17
+
18
+ const BACKUP_ERROR = 'DRIVE_BACKUP';
19
+
20
+ /** Patterns that must never be archived silently. */
21
+ export const HARD_EXCLUDES = Object.freeze([
22
+ '.env',
23
+ '.env.*',
24
+ '*.pem',
25
+ '*.key',
26
+ '*.p12',
27
+ '*.pfx',
28
+ 'id_rsa*',
29
+ 'id_ed25519*',
30
+ 'credentials.json',
31
+ 'service-account*.json',
32
+ 'credentials.enc',
33
+ ]);
34
+
35
+ /** Turn a pattern list into matchers; supports `*`, `?`, `**` and bare names. */
36
+ export function compilePatterns(patterns = []) {
37
+ return patterns
38
+ .map((pattern) => String(pattern || '').trim())
39
+ .filter(Boolean)
40
+ .map((pattern) => {
41
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
42
+ const body = escaped.replace(/\*\*/g, '\u0000').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]').replace(/\u0000/g, '.*');
43
+ return { pattern, regex: new RegExp(`^${body}$`) };
44
+ });
45
+ }
46
+
47
+ /** True when `relPath` (posix style) or any of its segments matches a pattern. */
48
+ export function isExcluded(relPath, matchers) {
49
+ const candidates = [relPath, ...relPath.split('/')];
50
+ return matchers.some(({ regex }) => candidates.some((candidate) => regex.test(candidate)));
51
+ }
52
+
53
+ /**
54
+ * Walk `root` and return the files that may be archived, with their sizes.
55
+ * Symlinks are not followed: a backup must not silently pull in /etc.
56
+ */
57
+ export async function collectFiles({ root, includePatterns = [], excludePatterns = [], maxFiles = 20000, signal = null, onProgress = null }) {
58
+ const matchers = compilePatterns([...excludePatterns, ...HARD_EXCLUDES]);
59
+ const includes = includePatterns.length > 0 ? compilePatterns(includePatterns) : null;
60
+ const files = [];
61
+ let totalBytes = 0;
62
+ let skipped = 0;
63
+
64
+ const walk = async (dir) => {
65
+ if (signal?.aborted) throw new RyuuError('Backup dibatalkan.', { code: 'CANCELLED' });
66
+ const entries = await fsp.readdir(dir, { withFileTypes: true });
67
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
68
+ for (const entry of entries) {
69
+ if (files.length >= maxFiles) {
70
+ throw new RyuuError(
71
+ `Backup dibatasi ${maxFiles} file. Persempit sourcePath atau tambah excludePatterns.`,
72
+ { code: BACKUP_ERROR },
73
+ );
74
+ }
75
+ const absPath = path.join(dir, entry.name);
76
+ const relPath = path.relative(root, absPath).split(path.sep).join('/');
77
+ if (entry.isSymbolicLink()) {
78
+ skipped += 1;
79
+ continue;
80
+ }
81
+ if (entry.isDirectory()) {
82
+ if (isExcluded(`${relPath}/`, matchers) || isExcluded(relPath, matchers)) {
83
+ skipped += 1;
84
+ continue;
85
+ }
86
+ await walk(absPath);
87
+ continue;
88
+ }
89
+ if (!entry.isFile()) {
90
+ skipped += 1;
91
+ continue;
92
+ }
93
+ if (isExcluded(relPath, matchers)) {
94
+ skipped += 1;
95
+ continue;
96
+ }
97
+ if (includes && !includes.some(({ regex }) => regex.test(relPath))) {
98
+ skipped += 1;
99
+ continue;
100
+ }
101
+ const stat = await fsp.stat(absPath);
102
+ files.push({ absPath, relPath, size: stat.size, mtime: stat.mtime });
103
+ totalBytes += stat.size;
104
+ if (onProgress) onProgress({ files: files.length, bytes: totalBytes, current: relPath });
105
+ }
106
+ };
107
+
108
+ const stat = await fsp.stat(root).catch((error) => {
109
+ if (error.code === 'ENOENT') throw new RyuuError(`Direktori sumber tidak ditemukan: ${root}`, { code: 'DRIVE_LOCAL' });
110
+ throw new RyuuError(`Direktori sumber tidak dapat dibaca: ${root} (${error.message})`, { code: 'DRIVE_LOCAL' });
111
+ });
112
+ if (!stat.isDirectory()) throw new RyuuError(`Sumber backup bukan direktori: ${root}`, { code: 'DRIVE_LOCAL' });
113
+ await walk(root);
114
+ return { root, files, totalBytes, skipped, excludedPatterns: [...excludePatterns, ...HARD_EXCLUDES] };
115
+ }
116
+
117
+ /** Default archive name: `<name>-backup-YYYYMMDD-HHmmss.zip`. */
118
+ export function defaultArchiveName(baseName = 'project', date = new Date()) {
119
+ const pad = (value) => String(value).padStart(2, '0');
120
+ const stamp =
121
+ `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +
122
+ `-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
123
+ const safe = String(baseName || 'project').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project';
124
+ return `${safe}-backup-${stamp}.zip`;
125
+ }
126
+
127
+ /** Create the ZIP in a temporary directory and return its path and stats. */
128
+ export async function createArchive({ files, archivePath, compressionLevel = 6, signal = null }) {
129
+ const writer = new ZipWriter({ file: archivePath, compressionLevel });
130
+ await writer.open();
131
+ try {
132
+ for (const file of files) {
133
+ await writer.addFile(file.relPath, file.absPath, { signal });
134
+ }
135
+ return await writer.close();
136
+ } catch (error) {
137
+ await fsp.rm(archivePath, { force: true }).catch(() => {});
138
+ throw error instanceof RyuuError
139
+ ? error
140
+ : new RyuuError(`Gagal membuat arsip backup: ${error.message}`, { code: BACKUP_ERROR });
141
+ }
142
+ }
143
+
144
+ /** Temporary scratch directory owned by the plugin. */
145
+ export async function makeTempDir(label = 'drive') {
146
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), `ryuu-${label}-`));
147
+ return dir;
148
+ }
149
+
150
+ function formatBackupName(name) {
151
+ return String(name || '').trim();
152
+ }
153
+
154
+ /**
155
+ * Full backup: collect → archive → upload → cleanup.
156
+ * Nothing is deleted from the source directory, and old backups are kept.
157
+ */
158
+ export async function backupToDrive({
159
+ drive,
160
+ sourceRoot,
161
+ destinationFolderId,
162
+ archiveName = null,
163
+ excludePatterns = [],
164
+ includePatterns = [],
165
+ compressionLevel = 6,
166
+ maxUploadSize = 0,
167
+ signal = null,
168
+ onProgress = null,
169
+ logger = null,
170
+ }) {
171
+ const collected = await collectFiles({ root: sourceRoot, includePatterns, excludePatterns, signal, onProgress });
172
+ if (collected.files.length === 0) {
173
+ throw new RyuuError('Tidak ada file yang bisa dibackup setelah exclusion diterapkan.', { code: BACKUP_ERROR });
174
+ }
175
+ if (maxUploadSize > 0 && collected.totalBytes > maxUploadSize) {
176
+ throw new RyuuError(
177
+ `Ukuran backup ${collected.totalBytes} byte melebihi maxUploadSize ${maxUploadSize} byte. Tambah excludePatterns atau naikkan maxUploadSize.`,
178
+ { code: BACKUP_ERROR },
179
+ );
180
+ }
181
+ const folder = await drive.assertFolder({ folderId: destinationFolderId, label: 'Folder tujuan backup' });
182
+ const name = formatBackupName(archiveName) || defaultArchiveName(path.basename(sourceRoot));
183
+ const tempDir = await makeTempDir('backup');
184
+ const archivePath = path.join(tempDir, name.endsWith('.zip') ? name : `${name}.zip`);
185
+ try {
186
+ const archive = await createArchive({ files: collected.files, archivePath, compressionLevel, signal });
187
+ onProgress?.({ stage: 'uploading', archive: path.basename(archivePath) });
188
+ const uploaded = await drive.uploadFile({
189
+ filePath: archivePath,
190
+ fileName: path.basename(archivePath),
191
+ folderId: folder.id,
192
+ mimeType: 'application/zip',
193
+ onProgress: (event) => onProgress?.({ stage: 'upload', ...event }),
194
+ });
195
+ logger?.info('drive backup uploaded', { fileId: uploaded.id, bytes: uploaded.size });
196
+ return {
197
+ archiveName: path.basename(archivePath),
198
+ fileId: uploaded.id,
199
+ size: uploaded.size ?? archive.bytes,
200
+ sourcePath: sourceRoot,
201
+ destinationFolderId: folder.id,
202
+ destinationFolderName: folder.name || null,
203
+ webViewLink: uploaded.webViewLink,
204
+ fileCount: collected.files.length,
205
+ excludedPatterns: collected.excludedPatterns,
206
+ skipped: collected.skipped,
207
+ createdAt: new Date().toISOString(),
208
+ status: 'berhasil',
209
+ };
210
+ } finally {
211
+ await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => {});
212
+ }
213
+ }
214
+
215
+ /** List ZIP archives that look like Ryuu backups inside a folder. */
216
+ export async function listBackups({ drive, folderId = 'root', pageSize = 50, pageToken = null }) {
217
+ const result = await drive.search({
218
+ query: "mimeType = 'application/zip'",
219
+ folderId,
220
+ pageSize,
221
+ pageToken,
222
+ orderBy: 'modifiedTime desc',
223
+ });
224
+ return { ...result, backups: result.files.filter((file) => /\.zip$/i.test(file.name)) };
225
+ }
226
+
227
+ /**
228
+ * Restore: download → validate → extract. The archive is inspected before any
229
+ * file is written, and an existing target file is only replaced with overwrite.
230
+ */
231
+ export async function restoreFromDrive({
232
+ drive,
233
+ fileId,
234
+ destinationRoot,
235
+ overwrite = false,
236
+ signal = null,
237
+ onProgress = null,
238
+ logger = null,
239
+ }) {
240
+ const tempDir = await makeTempDir('restore');
241
+ const archivePath = path.join(tempDir, `restore-${randomBytes(4).toString('hex')}.zip`);
242
+ try {
243
+ const downloaded = await drive.downloadFile({
244
+ fileId,
245
+ destination: archivePath,
246
+ onProgress: (event) => onProgress?.({ stage: 'download', ...event }),
247
+ });
248
+ const entries = await listZipEntries(archivePath);
249
+ if (entries.length === 0) {
250
+ throw new RyuuError('Arsip backup kosong atau tidak valid.', { code: 'ZIP_INVALID' });
251
+ }
252
+ onProgress?.({ stage: 'extract', entries: entries.length });
253
+ const extracted = await extractZip({ zipPath: archivePath, destination: destinationRoot, overwrite, signal, onProgress });
254
+ logger?.info('drive restore extracted', { fileId, files: extracted.fileCount });
255
+ return {
256
+ fileId,
257
+ archiveName: downloaded.name,
258
+ destinationPath: extracted.destination,
259
+ overwritten: Boolean(overwrite),
260
+ fileCount: extracted.fileCount,
261
+ bytes: extracted.bytes,
262
+ archiveEntries: entries.length,
263
+ files: extracted.files.slice(0, 200),
264
+ restoredAt: new Date().toISOString(),
265
+ status: 'berhasil',
266
+ };
267
+ } finally {
268
+ await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => {});
269
+ }
270
+ }
271
+
272
+ /** Remove one backup archive after an explicit confirmation upstream. */
273
+ export async function deleteBackup({ drive, fileId }) {
274
+ const meta = await drive.getMetadata({ fileId });
275
+ if (!/\.zip$/i.test(meta.name || '')) {
276
+ throw new RyuuError(`File ${meta.name || fileId} bukan arsip backup (.zip), penghapusan dibatalkan.`, {
277
+ code: BACKUP_ERROR,
278
+ });
279
+ }
280
+ await drive.remove({ fileId });
281
+ return { fileId, name: meta.name, size: meta.size, deleted: true, deletedAt: new Date().toISOString() };
282
+ }
@@ -0,0 +1,266 @@
1
+ import { formatBytes } from './tool-helpers.js';
2
+ import { syncPluginTools, unregisterPluginTools } from '../index.js';
3
+
4
+ /**
5
+ * `/drive` command handler.
6
+ *
7
+ * It is wired into the existing command parser through `define()` in
8
+ * cli/commands.js; this module only formats output and calls the manager.
9
+ * Every message is Indonesian, and no token is ever printed.
10
+ */
11
+
12
+ const USAGE = [
13
+ 'Google Drive Integration',
14
+ '',
15
+ 'Autentikasi:',
16
+ ' /drive login',
17
+ ' /drive logout',
18
+ ' /drive account',
19
+ '',
20
+ 'Integrasi:',
21
+ ' /drive enable',
22
+ ' /drive disable',
23
+ ' /drive status',
24
+ '',
25
+ 'Konfigurasi:',
26
+ ' /drive config',
27
+ ' /drive config show',
28
+ ' /drive config set <key> <value>',
29
+ '',
30
+ 'Diagnostik:',
31
+ ' /drive test',
32
+ ].join('\n');
33
+
34
+ function tokenize(args) {
35
+ const out = [];
36
+ let current = '';
37
+ let quote = null;
38
+ for (const char of String(args ?? '')) {
39
+ if (quote) {
40
+ if (char === quote) quote = null;
41
+ else current += char;
42
+ continue;
43
+ }
44
+ if (char === '"' || char === "'") {
45
+ quote = char;
46
+ continue;
47
+ }
48
+ if (/\s/.test(char)) {
49
+ if (current) out.push(current);
50
+ current = '';
51
+ continue;
52
+ }
53
+ current += char;
54
+ }
55
+ if (current) out.push(current);
56
+ return out;
57
+ }
58
+
59
+ function stamp(value) {
60
+ if (!value) return 'belum pernah';
61
+ const date = new Date(value);
62
+ if (Number.isNaN(date.getTime())) return String(value);
63
+ return date.toISOString().replace('T', ' ').slice(0, 19);
64
+ }
65
+
66
+ export function createDriveCommand({ plugin, registry, confirm = null, openBrowser = true, showCode = null }) {
67
+ /**
68
+ * @param {string} args raw argument string after `/drive`
69
+ * @param {(text: string) => void} print
70
+ */
71
+ return async function driveCommand(args, print) {
72
+ const parts = tokenize(args);
73
+ const sub = (parts[0] || '').toLowerCase();
74
+ const rest = parts.slice(1);
75
+
76
+ if (!sub || sub === 'help' || sub === '?') {
77
+ print(USAGE);
78
+ return;
79
+ }
80
+
81
+ if (sub === 'login') {
82
+ try {
83
+ await plugin.login({
84
+ print,
85
+ showCode,
86
+ openBrowser,
87
+ confirmReplace: confirm ? (question) => confirm(question) : null,
88
+ });
89
+ } catch (error) {
90
+ print(`Login gagal: ${error.message}`);
91
+ }
92
+ return;
93
+ }
94
+
95
+ if (sub === 'logout') {
96
+ const authenticated = await plugin.isAuthenticated().catch(() => false);
97
+ if (!authenticated) {
98
+ print('Belum ada akun Google yang terhubung. Tidak ada yang perlu diputus.');
99
+ return;
100
+ }
101
+ if (confirm && !(await confirm('Putuskan koneksi Google Drive dan hapus kredensial lokal? [y/N] '))) {
102
+ print('Logout dibatalkan. Kredensial tidak diubah.');
103
+ return;
104
+ }
105
+ unregisterPluginTools(plugin, registry);
106
+ await plugin.logout({ print });
107
+ return;
108
+ }
109
+
110
+ if (sub === 'enable') {
111
+ try {
112
+ const result = await plugin.enable();
113
+ const count = await syncPluginTools(plugin, registry);
114
+ print(`Integrasi Google Drive diaktifkan. ${count} tool terdaftar untuk AI Agent:`);
115
+ print(` ${result.tools.join(', ')}`);
116
+ } catch (error) {
117
+ print(error.message);
118
+ if (error.code === 'DRIVE_UNAUTHORIZED') print('Jalankan /drive login untuk menghubungkan akun Google.');
119
+ }
120
+ return;
121
+ }
122
+
123
+ if (sub === 'disable') {
124
+ const removed = unregisterPluginTools(plugin, registry);
125
+ await plugin.disable();
126
+ print(
127
+ `Integrasi Google Drive dinonaktifkan (${removed} tool dilepas dari AI Agent). ` +
128
+ 'Token dan konfigurasi tetap disimpan; backup tidak dihapus.',
129
+ );
130
+ return;
131
+ }
132
+
133
+ if (sub === 'status') {
134
+ print(await formatStatus(plugin, registry));
135
+ return;
136
+ }
137
+
138
+ if (sub === 'account') {
139
+ const info = await plugin.authInfo().catch((error) => ({ connected: false, error: error.message }));
140
+ if (!info.connected) {
141
+ print('Belum ada akun Google yang terhubung. Jalankan /drive login.');
142
+ return;
143
+ }
144
+ let quota = null;
145
+ try {
146
+ const client = await plugin.getClient();
147
+ const about = await client.getAbout();
148
+ quota = about.storageQuota;
149
+ print(
150
+ [
151
+ 'AKUN GOOGLE DRIVE',
152
+ ` Nama : ${about.name || '(tidak tersedia)'}`,
153
+ ` Email : ${about.email || '(tidak tersedia)'}`,
154
+ ` Permission : ${info.scope || '(tidak tercatat)'}`,
155
+ ` Token : ${info.hasRefreshToken ? 'izin refresh tersedia (grant)' : 'hanya access token'}`,
156
+ ` Kadaluarsa : ${stamp(info.expiresAt)}`,
157
+ ` Tersimpan : ${stamp(info.savedAt)}`,
158
+ quota?.limit
159
+ ? ` Storage : ${formatBytes(quota.usage)} / ${formatBytes(quota.limit)}`
160
+ : ' Storage : tidak tersedia dari API',
161
+ ].join('\n'),
162
+ );
163
+ } catch (error) {
164
+ print(`Akun tersimpan: ${info.email || '(tanpa email)'} — tetapi API menolak: ${error.message}`);
165
+ }
166
+ return;
167
+ }
168
+
169
+ if (sub === 'config') {
170
+ const action = (rest[0] || 'show').toLowerCase();
171
+ if (action === 'show') {
172
+ const { lines } = await plugin.configView();
173
+ const width = Math.max(...Object.keys(lines).map((key) => key.length));
174
+ print(
175
+ [
176
+ 'KONFIGURASI GOOGLE DRIVE',
177
+ ...Object.entries(lines).map(([key, value]) => ` ${key.padEnd(width)} ${value}`),
178
+ '',
179
+ 'Ubah dengan: /drive config set <key> <value>',
180
+ 'Key: enabled, defaultBackupFolder, backupDirectory, maxUploadSize, confirmDestructive, autoRefresh, excludePatterns',
181
+ ].join('\n'),
182
+ );
183
+ return;
184
+ }
185
+ if (action === 'set') {
186
+ const key = rest[1];
187
+ const value = rest.slice(2).join(' ');
188
+ if (!key || rest.length < 3) {
189
+ print('Usage: /drive config set <key> <value>');
190
+ return;
191
+ }
192
+ try {
193
+ const parsed = await plugin.setConfig(key, value);
194
+ const shown = Array.isArray(parsed) ? parsed.join(', ') || '(kosong)' : String(parsed);
195
+ print(`Konfigurasi diperbarui: ${key} = ${shown}`);
196
+ if (key === 'enabled') {
197
+ if (parsed === true) await syncPluginTools(plugin, registry);
198
+ else unregisterPluginTools(plugin, registry);
199
+ }
200
+ } catch (error) {
201
+ print(error.message);
202
+ }
203
+ return;
204
+ }
205
+ print('Usage: /drive config [show] | /drive config set <key> <value>');
206
+ return;
207
+ }
208
+
209
+ if (sub === 'test') {
210
+ print('Menjalankan pemeriksaan Google Drive…');
211
+ const result = await plugin.diagnose();
212
+ const rows = result.report.map((row) => ` ${row.ok ? '[✓]' : '[!]'} ${row.label.padEnd(24)} ${row.detail}`);
213
+ print(['DIAGNOSTIK GOOGLE DRIVE', ...rows, '', result.ok ? 'Semua pemeriksaan berhasil.' : 'Ada pemeriksaan yang gagal.'].join('\n'));
214
+ return;
215
+ }
216
+
217
+ if (sub === 'tools') {
218
+ const registered = registry?.plugins?.get?.(plugin.name);
219
+ print(
220
+ registered
221
+ ? `Tool Google Drive terdaftar (${registered.toolNames.length}): ${registered.toolNames.join(', ')}`
222
+ : 'Tool Google Drive sedang tidak terdaftar untuk AI Agent (nonaktif atau belum login).',
223
+ );
224
+ return;
225
+ }
226
+
227
+ print(`Subcommand tidak dikenal: /drive ${sub}`);
228
+ print(USAGE);
229
+ };
230
+ }
231
+
232
+ export async function formatStatus(plugin, registry) {
233
+ const status = await plugin.status();
234
+ const registered = registry?.plugins?.get?.(plugin.name);
235
+ const integration = status.enabled ? 'aktif' : 'nonaktif';
236
+ const auth = status.authenticated ? 'terhubung' : 'belum login';
237
+ const lines = [
238
+ 'STATUS GOOGLE DRIVE',
239
+ ` Integrasi : ${integration}`,
240
+ ` Autentikasi : ${auth}`,
241
+ ];
242
+ if (status.configError) lines.push(` Konfigurasi : error — ${status.configError}`);
243
+ if (status.authError) lines.push(` Credential : error — ${status.authError}`);
244
+ if (status.authenticated) {
245
+ lines.push(` Akun : ${status.auth.email || status.account?.email || '(tanpa email)'}`);
246
+ if (status.auth.name || status.account?.name) lines.push(` Nama : ${status.auth.name || status.account.name}`);
247
+ lines.push(` Scope OAuth : ${status.auth.scope || (status.settings?.scopes || []).join(', ')}`);
248
+ lines.push(
249
+ ` Token : ${status.auth.hasRefreshToken ? 'izin refresh tersedia (grant)' : 'hanya access token'} · kadaluarsa ${stamp(status.auth.expiresAt)}`,
250
+ );
251
+ if (status.quota?.limit) {
252
+ lines.push(` Storage : ${formatBytes(status.quota.usage)} / ${formatBytes(status.quota.limit)}`);
253
+ }
254
+ }
255
+ lines.push(
256
+ ` Tool aktif : ${registered ? registered.toolNames.length : 0} dari ${status.toolCount}`,
257
+ ` Folder backup : ${status.settings?.defaultBackupFolder || '(My Drive root / belum diatur)'}`,
258
+ ` Folder arsip : ${status.settings?.backupDirectory || 'Backups'}`,
259
+ ` Cek terakhir : ${stamp(status.lastCheck)}${status.lastCheck === null ? '' : status.lastCheckOk ? ' (berhasil)' : ' (gagal)'}`,
260
+ ` Config file : ${status.paths.configFile}`,
261
+ ` Credential file : ${status.paths.credentialFile}`,
262
+ );
263
+ if (status.enabled && !status.authenticated) lines.push('', 'Jalankan /drive login untuk menghubungkan akun Google.');
264
+ if (status.registered === false) lines.push('', 'Integrasi aktif tetapi tool belum terdaftar: jalankan /drive enable.');
265
+ return lines.join('\n');
266
+ }