@ryuu-reinzz/ryuu-cli 0.5.0 → 0.5.2

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.
@@ -0,0 +1,430 @@
1
+ import { defineTool } from '../../src/tools/registry.js';
2
+ import { ValidationError } from '../../src/utils/errors.js';
3
+ import {
4
+ assertDestructiveAllowed,
5
+ contentPath,
6
+ limitItems,
7
+ pageQuery,
8
+ pickBranch,
9
+ pickCommit,
10
+ pickRepo,
11
+ refSegment,
12
+ repoBase,
13
+ text,
14
+ } from './tool-helpers.js';
15
+
16
+ const REPO_PROPS = {
17
+ owner: { type: 'string', description: 'Pemilik repository (username atau organisasi GitHub).' },
18
+ repo: { type: 'string', description: 'Nama repository tanpa owner.' },
19
+ };
20
+
21
+ export function createRepoTools(plugin) {
22
+ return [
23
+ defineTool({
24
+ name: 'github_list_repositories',
25
+ description:
26
+ 'Daftar repository yang dapat diakses akun terhubung (milik sendiri, collaborator, atau organisasi). Membutuhkan permission Metadata read; repository privat hanya muncul bila instalasi aplikasi mencakupnya.',
27
+ risk: 'read',
28
+ schema: {
29
+ type: 'object',
30
+ properties: {
31
+ visibility: { type: 'string', enum: ['all', 'public', 'private'], description: 'Filter visibilitas. Default all.' },
32
+ affiliation: {
33
+ type: 'string',
34
+ description: 'owner, collaborator, organization_member (boleh digabung dengan koma). Default semua.',
35
+ },
36
+ sort: { type: 'string', enum: ['updated', 'pushed', 'full_name', 'created'], description: 'Urutan hasil.' },
37
+ direction: { type: 'string', enum: ['asc', 'desc'], description: 'Arah urutan.' },
38
+ per_page: { type: 'integer', minimum: 1, maximum: 100, description: 'Jumlah per halaman (default 30).' },
39
+ page: { type: 'integer', minimum: 1, description: 'Halaman (default 1).' },
40
+ },
41
+ },
42
+ async handler(args) {
43
+ const api = await plugin.requireClient();
44
+ const query = {
45
+ ...pageQuery(args),
46
+ ...(args.visibility ? { visibility: args.visibility } : {}),
47
+ ...(args.affiliation ? { affiliation: args.affiliation } : {}),
48
+ ...(args.sort ? { sort: args.sort } : {}),
49
+ ...(args.direction ? { direction: args.direction } : {}),
50
+ };
51
+ const repos = await api.paginate('/user/repos', { query, perPage: query.per_page, maxPages: 2, maxItems: 100 });
52
+ return { ok: true, count: repos.length, repositories: limitItems(repos, 100).map(pickRepo) };
53
+ },
54
+ }),
55
+
56
+ defineTool({
57
+ name: 'github_get_repository',
58
+ description:
59
+ 'Ambil detail satu repository: default branch, visibilitas, bahasa, statistik, permission akun, dan status arsip.',
60
+ risk: 'read',
61
+ schema: { type: 'object', properties: REPO_PROPS, required: ['owner', 'repo'] },
62
+ async handler(args) {
63
+ const api = await plugin.requireClient();
64
+ const repo = await api.get(repoBase(args));
65
+ return { ok: true, repository: pickRepo(repo) };
66
+ },
67
+ }),
68
+
69
+ defineTool({
70
+ name: 'github_create_repository',
71
+ description:
72
+ 'Buat repository baru untuk akun yang terhubung. Membutuhkan permission Administration write atau scope public_repo/repo. Operasi ini mengubah data dan selalu meminta approval.',
73
+ risk: 'write',
74
+ schema: {
75
+ type: 'object',
76
+ properties: {
77
+ name: { type: 'string', description: 'Nama repository baru.' },
78
+ description: { type: 'string', description: 'Deskripsi singkat.' },
79
+ private: { type: 'boolean', description: 'Repository privat bila true.' },
80
+ auto_init: { type: 'boolean', description: 'Inisialisasi dengan README sehingga branch default langsung ada.' },
81
+ gitignore_template: { type: 'string', description: 'Template .gitignore, contoh: Node.' },
82
+ license_template: { type: 'string', description: 'Template lisensi, contoh: mit.' },
83
+ homepage: { type: 'string', description: 'URL homepage repository.' },
84
+ },
85
+ required: ['name'],
86
+ },
87
+ async handler(args) {
88
+ const api = await plugin.requireClient();
89
+ const name = String(args.name || '').trim();
90
+ if (!/^[A-Za-z0-9._-]{1,100}$/.test(name)) throw new Error('Nama repository tidak valid.');
91
+ const body = { name };
92
+ for (const key of ['description', 'auto_init', 'gitignore_template', 'license_template', 'homepage']) {
93
+ if (args[key] !== undefined) body[key] = args[key];
94
+ }
95
+ if (args.private !== undefined) body.private = Boolean(args.private);
96
+ const repo = await api.post('/user/repos', body);
97
+ return { ok: true, repository: pickRepo(repo) };
98
+ },
99
+ }),
100
+
101
+ defineTool({
102
+ name: 'github_update_repository',
103
+ description:
104
+ 'Ubah metadata repository (deskripsi, homepage, visibilitas, arsip, topik). Membutuhkan permission Administration write. Operasi ini selalu meminta approval.',
105
+ risk: 'write',
106
+ schema: {
107
+ type: 'object',
108
+ properties: {
109
+ ...REPO_PROPS,
110
+ name: { type: 'string', description: 'Nama baru repository.' },
111
+ description: { type: 'string', description: 'Deskripsi baru.' },
112
+ homepage: { type: 'string', description: 'URL homepage baru.' },
113
+ private: { type: 'boolean', description: 'Ubah visibilitas privat/publik.' },
114
+ archived: { type: 'boolean', description: 'Arsipkan atau aktifkan kembali repository.' },
115
+ has_issues: { type: 'boolean', description: 'Aktifkan/nonaktifkan Issues.' },
116
+ has_wiki: { type: 'boolean', description: 'Aktifkan/nonaktifkan Wiki.' },
117
+ default_branch: { type: 'string', description: 'Branch default baru (harus sudah ada).' },
118
+ },
119
+ required: ['owner', 'repo'],
120
+ },
121
+ async handler(args) {
122
+ const api = await plugin.requireClient();
123
+ const body = {};
124
+ for (const key of ['name', 'description', 'homepage', 'private', 'archived', 'has_issues', 'has_wiki', 'default_branch']) {
125
+ if (args[key] !== undefined) body[key] = args[key];
126
+ }
127
+ if (Object.keys(body).length === 0) throw new Error('Tidak ada perubahan yang diminta.');
128
+ const repo = await api.patch(repoBase(args), body);
129
+ return { ok: true, repository: pickRepo(repo) };
130
+ },
131
+ }),
132
+
133
+ defineTool({
134
+ name: 'github_delete_repository',
135
+ description:
136
+ 'Hapus repository secara permanen. Membutuhkan permission Administration write dan hak admin pada repository. Argumen confirm wajib berisi "owner/repo" persis sama; tanpa itu tool menolak. Operasi ini destruktif: selalu meminta approval Ryuu dan diblokir saat confirmDestructive=false. Jangan pernah dipanggil tanpa persetujuan eksplisit pengguna.',
137
+ risk: 'destructive',
138
+ requiresApproval: true,
139
+ schema: {
140
+ type: 'object',
141
+ properties: {
142
+ ...REPO_PROPS,
143
+ confirm: {
144
+ type: 'string',
145
+ description: 'Tulis ulang "owner/repo" dari repository yang dihapus sebagai konfirmasi eksplisit.',
146
+ },
147
+ },
148
+ required: ['owner', 'repo', 'confirm'],
149
+ },
150
+ async handler(args) {
151
+ await assertDestructiveAllowed(plugin, 'Penghapusan repository');
152
+ const base = repoBase(args);
153
+ const target = base.replace(/^\/repos\//, '');
154
+ const typed = String(args.confirm ?? '').trim();
155
+ if (typed.toLowerCase() !== target.toLowerCase()) {
156
+ throw new ValidationError(
157
+ `Konfirmasi tidak cocok. Untuk menghapus repository ini, argumen confirm harus berisi "${target}".`,
158
+ );
159
+ }
160
+ const api = await plugin.requireClient();
161
+ await api.delete(base);
162
+ return {
163
+ ok: true,
164
+ deleted: target,
165
+ note: 'Repository dihapus permanen. Tindakan ini tidak dapat dibatalkan.',
166
+ };
167
+ },
168
+ }),
169
+
170
+ defineTool({
171
+ name: 'github_list_branches',
172
+ description: 'Daftar branch pada repository beserta commit SHA terakhir dan status proteksi.',
173
+ risk: 'read',
174
+ schema: {
175
+ type: 'object',
176
+ properties: {
177
+ ...REPO_PROPS,
178
+ protected: { type: 'boolean', description: 'Filter hanya branch terproteksi (true) atau tidak (false).' },
179
+ per_page: { type: 'integer', minimum: 1, maximum: 100, description: 'Jumlah per halaman (default 30).' },
180
+ page: { type: 'integer', minimum: 1, description: 'Halaman (default 1).' },
181
+ },
182
+ required: ['owner', 'repo'],
183
+ },
184
+ async handler(args) {
185
+ const api = await plugin.requireClient();
186
+ const branches = await api.paginate(`${repoBase(args)}/branches`, {
187
+ query: {
188
+ ...pageQuery(args),
189
+ ...(args.protected !== undefined ? { protected: String(args.protected) } : {}),
190
+ },
191
+ maxPages: 2,
192
+ maxItems: 100,
193
+ });
194
+ return { ok: true, count: branches.length, branches: limitItems(branches, 100).map(pickBranch) };
195
+ },
196
+ }),
197
+
198
+ defineTool({
199
+ name: 'github_get_branch',
200
+ description: 'Ambil satu branch berdasarkan nama, termasuk SHA commit terakhir dan status proteksinya.',
201
+ risk: 'read',
202
+ schema: {
203
+ type: 'object',
204
+ properties: { ...REPO_PROPS, branch: { type: 'string', description: 'Nama branch, contoh: main.' } },
205
+ required: ['owner', 'repo', 'branch'],
206
+ },
207
+ async handler(args) {
208
+ const api = await plugin.requireClient();
209
+ const branch = await api.get(`${repoBase(args)}/branches/${refSegment(args.branch, 'branch')}`);
210
+ return { ok: true, branch: pickBranch(branch) };
211
+ },
212
+ }),
213
+
214
+ defineTool({
215
+ name: 'github_list_commits',
216
+ description:
217
+ 'Daftar commit pada branch atau path tertentu. Dapat difilter berdasarkan author dan rentang tanggal ISO 8601.',
218
+ risk: 'read',
219
+ schema: {
220
+ type: 'object',
221
+ properties: {
222
+ ...REPO_PROPS,
223
+ sha: { type: 'string', description: 'Branch atau SHA commit awal (default branch default repository).' },
224
+ path: { type: 'string', description: 'Hanya commit yang menyentuh path ini.' },
225
+ author: { type: 'string', description: 'Username GitHub atau email author.' },
226
+ since: { type: 'string', description: 'Batas awal ISO 8601, contoh 2026-01-01T00:00:00Z.' },
227
+ until: { type: 'string', description: 'Batas akhir ISO 8601.' },
228
+ per_page: { type: 'integer', minimum: 1, maximum: 100, description: 'Jumlah per halaman (default 30).' },
229
+ page: { type: 'integer', minimum: 1, description: 'Halaman (default 1).' },
230
+ },
231
+ required: ['owner', 'repo'],
232
+ },
233
+ async handler(args) {
234
+ const api = await plugin.requireClient();
235
+ const query = {
236
+ ...pageQuery(args),
237
+ ...(args.sha ? { sha: args.sha } : {}),
238
+ ...(args.path ? { path: args.path } : {}),
239
+ ...(args.author ? { author: args.author } : {}),
240
+ ...(args.since ? { since: args.since } : {}),
241
+ ...(args.until ? { until: args.until } : {}),
242
+ };
243
+ const commits = await api.paginate(`${repoBase(args)}/commits`, { query, maxPages: 2, maxItems: 100 });
244
+ return { ok: true, count: commits.length, commits: limitItems(commits, 100).map(pickCommit) };
245
+ },
246
+ }),
247
+
248
+ defineTool({
249
+ name: 'github_get_commit',
250
+ description: 'Ambil satu commit lengkap dengan pesan, author, parent, dan statistik perubahan file.',
251
+ risk: 'read',
252
+ schema: {
253
+ type: 'object',
254
+ properties: { ...REPO_PROPS, ref: { type: 'string', description: 'SHA commit, branch, atau tag.' } },
255
+ required: ['owner', 'repo', 'ref'],
256
+ },
257
+ async handler(args) {
258
+ const api = await plugin.requireClient();
259
+ const commit = await api.get(`${repoBase(args)}/commits/${refSegment(args.ref)}`);
260
+ return {
261
+ ok: true,
262
+ commit: pickCommit(commit),
263
+ files: limitItems(commit.files || [], 50).map((file) => ({
264
+ filename: file.filename,
265
+ status: file.status,
266
+ additions: file.additions,
267
+ deletions: file.deletions,
268
+ changes: file.changes,
269
+ })),
270
+ };
271
+ },
272
+ }),
273
+
274
+ defineTool({
275
+ name: 'github_get_file_contents',
276
+ description:
277
+ 'Baca isi file atau daftar isi folder pada repository. Untuk file teks, isi dikembalikan sudah didekode; file biner atau lebih dari 200 KB hanya mengembalikan metadata.',
278
+ risk: 'read',
279
+ schema: {
280
+ type: 'object',
281
+ properties: {
282
+ ...REPO_PROPS,
283
+ path: { type: 'string', description: 'Path file/folder relatif, contoh: src/index.js.' },
284
+ ref: { type: 'string', description: 'Branch, tag, atau SHA. Default branch default repository.' },
285
+ },
286
+ required: ['owner', 'repo', 'path'],
287
+ },
288
+ async handler(args) {
289
+ const api = await plugin.requireClient();
290
+ const data = await api.get(`${repoBase(args)}/contents/${contentPath(args.path)}`, args.ref ? { ref: args.ref } : null);
291
+ if (Array.isArray(data)) {
292
+ return {
293
+ ok: true,
294
+ type: 'directory',
295
+ count: data.length,
296
+ entries: limitItems(data, 100).map((entry) => ({
297
+ name: entry.name,
298
+ path: entry.path,
299
+ type: entry.type,
300
+ size: entry.size ?? null,
301
+ sha: entry.sha,
302
+ })),
303
+ };
304
+ }
305
+ const base = {
306
+ ok: true,
307
+ type: 'file',
308
+ name: data.name,
309
+ path: data.path,
310
+ sha: data.sha,
311
+ size: data.size,
312
+ html_url: data.html_url || null,
313
+ download_url: data.download_url || null,
314
+ };
315
+ if (data.encoding !== 'base64' || typeof data.content !== 'string') return base;
316
+ if (Number(data.size) > 200_000) {
317
+ return { ...base, truncated: true, note: 'File lebih dari 200 KB; isi tidak didekode.' };
318
+ }
319
+ const buffer = Buffer.from(data.content, 'base64');
320
+ if (buffer.includes(0)) {
321
+ return { ...base, binary: true, note: 'File tampak biner; isi tidak ditampilkan.' };
322
+ }
323
+ return { ...base, content: text(buffer.toString('utf8'), 100_000) };
324
+ },
325
+ }),
326
+
327
+ defineTool({
328
+ name: 'github_create_or_update_file',
329
+ description:
330
+ 'Buat file baru atau perbarui file yang ada pada satu commit. Untuk memperbarui file, sertakan SHA file saat ini (dari github_get_file_contents). Membutuhkan permission Contents write. Selalu meminta approval.',
331
+ risk: 'write',
332
+ schema: {
333
+ type: 'object',
334
+ properties: {
335
+ ...REPO_PROPS,
336
+ path: { type: 'string', description: 'Path file, contoh: docs/readme.md.' },
337
+ message: { type: 'string', description: 'Pesan commit.' },
338
+ content: { type: 'string', description: 'Isi file sebagai teks biasa (UTF-8).' },
339
+ sha: { type: 'string', description: 'SHA file yang sedang diperbarui; kosongkan untuk file baru.' },
340
+ branch: { type: 'string', description: 'Branch tujuan. Default branch default repository.' },
341
+ },
342
+ required: ['owner', 'repo', 'path', 'message', 'content'],
343
+ },
344
+ async handler(args) {
345
+ const api = await plugin.requireClient();
346
+ const body = {
347
+ message: String(args.message),
348
+ content: Buffer.from(String(args.content), 'utf8').toString('base64'),
349
+ ...(args.sha ? { sha: String(args.sha) } : {}),
350
+ ...(args.branch ? { branch: String(args.branch) } : {}),
351
+ };
352
+ const result = await api.put(`${repoBase(args)}/contents/${contentPath(args.path)}`, body);
353
+ return {
354
+ ok: true,
355
+ content: result?.content
356
+ ? { path: result.content.path, sha: result.content.sha, html_url: result.content.html_url }
357
+ : null,
358
+ commit: result?.commit ? { sha: result.commit.sha, message: text(result.commit.message, 500) } : null,
359
+ };
360
+ },
361
+ }),
362
+
363
+ defineTool({
364
+ name: 'github_delete_file',
365
+ description:
366
+ 'Hapus satu file dari repository dengan commit baru. Membutuhkan SHA file saat ini dan permission Contents write. Operasi destruktif: selalu meminta approval dan dapat diblokir oleh konfigurasi confirmDestructive.',
367
+ risk: 'destructive',
368
+ requiresApproval: true,
369
+ schema: {
370
+ type: 'object',
371
+ properties: {
372
+ ...REPO_PROPS,
373
+ path: { type: 'string', description: 'Path file yang dihapus.' },
374
+ message: { type: 'string', description: 'Pesan commit penghapusan.' },
375
+ sha: { type: 'string', description: 'SHA file saat ini (wajib).' },
376
+ branch: { type: 'string', description: 'Branch tujuan. Default branch default repository.' },
377
+ },
378
+ required: ['owner', 'repo', 'path', 'message', 'sha'],
379
+ },
380
+ async handler(args) {
381
+ await assertDestructiveAllowed(plugin, 'Penghapusan file');
382
+ const api = await plugin.requireClient();
383
+ const result = await api.delete(`${repoBase(args)}/contents/${contentPath(args.path)}`, {
384
+ message: String(args.message),
385
+ sha: String(args.sha),
386
+ ...(args.branch ? { branch: String(args.branch) } : {}),
387
+ });
388
+ return {
389
+ ok: true,
390
+ commit: result?.commit ? { sha: result.commit.sha, message: text(result.commit.message, 500) } : null,
391
+ };
392
+ },
393
+ }),
394
+
395
+ defineTool({
396
+ name: 'github_compare_commits',
397
+ description:
398
+ 'Bandingkan dua commit/branch (base...head) dan tampilkan status ahead/behind serta daftar file yang berubah.',
399
+ risk: 'read',
400
+ schema: {
401
+ type: 'object',
402
+ properties: {
403
+ ...REPO_PROPS,
404
+ base: { type: 'string', description: 'Branch/tag/SHA awal.' },
405
+ head: { type: 'string', description: 'Branch/tag/SHA akhir.' },
406
+ },
407
+ required: ['owner', 'repo', 'base', 'head'],
408
+ },
409
+ async handler(args) {
410
+ const api = await plugin.requireClient();
411
+ const ref = `${refSegment(args.base, 'base')}...${refSegment(args.head, 'head')}`;
412
+ const result = await api.get(`${repoBase(args)}/compare/${ref}`);
413
+ return {
414
+ ok: true,
415
+ status: result.status,
416
+ ahead_by: result.ahead_by,
417
+ behind_by: result.behind_by,
418
+ total_commits: result.total_commits,
419
+ commits: limitItems(result.commits || [], 50).map(pickCommit),
420
+ files: limitItems(result.files || [], 100).map((file) => ({
421
+ filename: file.filename,
422
+ status: file.status,
423
+ additions: file.additions,
424
+ deletions: file.deletions,
425
+ })),
426
+ };
427
+ },
428
+ }),
429
+ ];
430
+ }
@@ -0,0 +1,25 @@
1
+ import { createAccountTools } from './tools-account.js';
2
+ import { createRepoTools } from './tools-repos.js';
3
+ import { createIssueTools } from './tools-issues.js';
4
+ import { createPullTools } from './tools-pulls.js';
5
+ import { createReleaseTools } from './tools-releases.js';
6
+ import { createActionTools } from './tools-actions.js';
7
+
8
+ /**
9
+ * Seluruh tool AI plugin GitHub.
10
+ *
11
+ * Hanya tool yang benar-benar diimplementasikan yang didaftarkan. Setiap tool
12
+ * memakai kontrak registry Ryuu (name, description, schema, risk, handler),
13
+ * sehingga approval, validasi schema, dan rendering berjalan tanpa jalur
14
+ * khusus.
15
+ */
16
+ export function createGitHubTools(plugin) {
17
+ return [
18
+ ...createAccountTools(plugin),
19
+ ...createRepoTools(plugin),
20
+ ...createIssueTools(plugin),
21
+ ...createPullTools(plugin),
22
+ ...createReleaseTools(plugin),
23
+ ...createActionTools(plugin),
24
+ ];
25
+ }
package/plugins/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createGoogleDrivePlugin, googleDrivePluginDescriptor } from './google-drive/index.js';
2
+ import { createGitHubPlugin, githubPluginDescriptor } from './github/index.js';
2
3
 
3
4
  /**
4
5
  * Plugin host for Ryuu CLI.
@@ -12,6 +13,7 @@ import { createGoogleDrivePlugin, googleDrivePluginDescriptor } from './google-d
12
13
 
13
14
  const FACTORIES = [
14
15
  { descriptor: googleDrivePluginDescriptor, create: (options) => createGoogleDrivePlugin(options) },
16
+ { descriptor: githubPluginDescriptor, create: (options) => createGitHubPlugin(options) },
15
17
  ];
16
18
 
17
19
  /** Instantiate every known plugin. Failures are reported, not fatal. */
@@ -53,4 +55,4 @@ export function unregisterPluginTools(plugin, registry) {
53
55
  return names.length;
54
56
  }
55
57
 
56
- export { googleDrivePluginDescriptor };
58
+ export { googleDrivePluginDescriptor, githubPluginDescriptor };
@@ -30,6 +30,7 @@ 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
32
  import { createDriveCommand } from '../../plugins/google-drive/command.js';
33
+ import { createGitHubCommand } from '../../plugins/github/command.js';
33
34
  import { RyuuError } from '../utils/errors.js';
34
35
 
35
36
  export function parseCommandLine(line) {
@@ -92,9 +93,17 @@ export function createCommands(state) {
92
93
 
93
94
  const commands = new Map();
94
95
 
95
- // `details` is the optional tutorial shown by `/help <command>`.
96
- const define = (name, description, usage, handler, details = null) => {
97
- commands.set(name, { name, description, usage, handler, details });
96
+ // `details` is the optional tutorial shown by `/help <command>`; `meta` marks
97
+ // plugin commands so `/help` can list their subcommand usage separately.
98
+ const define = (name, description, usage, handler, details = null, meta = {}) => {
99
+ commands.set(name, { name, description, usage, handler, details, ...meta });
100
+ };
101
+
102
+ /** Usage line for `/help <command>` without printing the command name twice. */
103
+ const usageLine = (command) => {
104
+ const prefix = `/${command.name}`;
105
+ const usage = String(command.usage || '').trim();
106
+ return usage.startsWith(prefix) ? usage : `${prefix} ${usage}`.trim();
98
107
  };
99
108
 
100
109
  const stateDir = () => stateDirFor(config.workspace);
@@ -177,14 +186,23 @@ export function createCommands(state) {
177
186
  const command = commands.get(target);
178
187
  if (!command) return print(`Unknown command: /${target}. Run /help.`);
179
188
  const details = command.details ? `\n\n${command.details}` : '';
180
- return print(`/${command.name} ${command.usage}\n ${command.description}${details}`);
189
+ return print(`${usageLine(command)}\n ${command.description}${details}`);
181
190
  }
182
191
  const rows = [...commands.values()].map((command) => ` /${command.name.padEnd(12)} ${command.description}`);
183
192
  const flagRows = CLI_FLAGS.map(([flag, description]) => ` ${flag.padEnd(20)} ${description}`);
193
+ const pluginRows = [...commands.values()]
194
+ .filter((command) => command.plugin)
195
+ .flatMap((command) => [
196
+ ` ${usageLine(command)}`,
197
+ ...(command.examples || []).map((line) => ` ${line}`),
198
+ ]);
184
199
  print(
185
200
  [
186
201
  'Ryuu commands',
187
202
  ...rows,
203
+ ...(pluginRows.length
204
+ ? ['', 'Plugin commands (available while the plugin is loaded; details: /help <command>):', ...pluginRows]
205
+ : []),
188
206
  '',
189
207
  'CLI flags (set at startup, e.g. `ryuu --auto-approve "task"`, and listed in `ryuu --help`):',
190
208
  ...flagRows,
@@ -1120,6 +1138,40 @@ export function createCommands(state) {
1120
1138
  // Optional plugins (plugins/): each contributes one namespaced command so the
1121
1139
  // core parser stays untouched. `/drive` is only defined when the plugin loaded.
1122
1140
  for (const plugin of state.plugins || []) {
1141
+ if (plugin.name === 'github') {
1142
+ const handler = createGitHubCommand({
1143
+ plugin,
1144
+ registry,
1145
+ confirm: (question) => state.confirmLocal(question),
1146
+ openBrowser: true,
1147
+ showCode: state.showCode || null,
1148
+ });
1149
+ define(
1150
+ 'github',
1151
+ 'GitHub integration: login | logout | status | account | test | enable | disable | config',
1152
+ '/github [login [--device]|logout|status|account|test|enable|disable|config]',
1153
+ async (args = '') => handler(args, print),
1154
+ [
1155
+ ' Autentikasi memakai GitHub App lewat backend Ryuu, bukan token di CLI.',
1156
+ ' login membuka browser lalu menunggu persetujuan',
1157
+ ' login --device device flow untuk mesin headless',
1158
+ ' enable mendaftarkan tool GitHub untuk AI Agent',
1159
+ ' disable melepas tool tanpa menghapus akun/token',
1160
+ ].join('\n'),
1161
+ {
1162
+ plugin: true,
1163
+ examples: [
1164
+ 'login open the browser and wait for the GitHub approval',
1165
+ 'login --device headless device flow, no browser needed',
1166
+ 'status | account | test inspect connection, account and API access',
1167
+ 'enable | disable register or remove the GitHub AI tools',
1168
+ 'config show settings without any secret',
1169
+ ],
1170
+ },
1171
+ );
1172
+ continue;
1173
+ }
1174
+ if (plugin.name !== 'google-drive') continue;
1123
1175
  const handler = createDriveCommand({
1124
1176
  plugin,
1125
1177
  registry,
@@ -1132,6 +1184,21 @@ export function createCommands(state) {
1132
1184
  `Google Drive integration: login | logout | account | enable | disable | status | config | test`,
1133
1185
  '/drive [login|logout|account|enable|disable|status|config|test]',
1134
1186
  async (args = '') => handler(args, print),
1187
+ [
1188
+ ' Autentikasi memakai OAuth Google lewat backend Ryuu; CLI tidak menyimpan token Google.',
1189
+ ' login menghubungkan akun Google lewat browser',
1190
+ ' enable mendaftarkan tool Drive untuk AI Agent',
1191
+ ' disable melepas tool tanpa menghapus akun/token',
1192
+ ].join('\n'),
1193
+ {
1194
+ plugin: true,
1195
+ examples: [
1196
+ 'login | logout connect or disconnect the Google account',
1197
+ 'account | status | test inspect the connection and the granted scopes',
1198
+ 'enable | disable register or remove the Drive AI tools',
1199
+ 'config show settings without any secret',
1200
+ ],
1201
+ },
1135
1202
  );
1136
1203
  }
1137
1204