@ryuu-reinzz/ryuu-cli 0.5.0 → 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.
@@ -0,0 +1,392 @@
1
+ import { defineTool } from '../../src/tools/registry.js';
2
+ import {
3
+ assertDestructiveAllowed,
4
+ contentPath,
5
+ limitItems,
6
+ pageQuery,
7
+ pickBranch,
8
+ pickCommit,
9
+ pickRepo,
10
+ refSegment,
11
+ repoBase,
12
+ text,
13
+ } from './tool-helpers.js';
14
+
15
+ const REPO_PROPS = {
16
+ owner: { type: 'string', description: 'Pemilik repository (username atau organisasi GitHub).' },
17
+ repo: { type: 'string', description: 'Nama repository tanpa owner.' },
18
+ };
19
+
20
+ export function createRepoTools(plugin) {
21
+ return [
22
+ defineTool({
23
+ name: 'github_list_repositories',
24
+ description:
25
+ 'Daftar repository yang dapat diakses akun terhubung (milik sendiri, collaborator, atau organisasi). Membutuhkan permission Metadata read; repository privat hanya muncul bila instalasi aplikasi mencakupnya.',
26
+ risk: 'read',
27
+ schema: {
28
+ type: 'object',
29
+ properties: {
30
+ visibility: { type: 'string', enum: ['all', 'public', 'private'], description: 'Filter visibilitas. Default all.' },
31
+ affiliation: {
32
+ type: 'string',
33
+ description: 'owner, collaborator, organization_member (boleh digabung dengan koma). Default semua.',
34
+ },
35
+ sort: { type: 'string', enum: ['updated', 'pushed', 'full_name', 'created'], description: 'Urutan hasil.' },
36
+ direction: { type: 'string', enum: ['asc', 'desc'], description: 'Arah urutan.' },
37
+ per_page: { type: 'integer', minimum: 1, maximum: 100, description: 'Jumlah per halaman (default 30).' },
38
+ page: { type: 'integer', minimum: 1, description: 'Halaman (default 1).' },
39
+ },
40
+ },
41
+ async handler(args) {
42
+ const api = await plugin.requireClient();
43
+ const query = {
44
+ ...pageQuery(args),
45
+ ...(args.visibility ? { visibility: args.visibility } : {}),
46
+ ...(args.affiliation ? { affiliation: args.affiliation } : {}),
47
+ ...(args.sort ? { sort: args.sort } : {}),
48
+ ...(args.direction ? { direction: args.direction } : {}),
49
+ };
50
+ const repos = await api.paginate('/user/repos', { query, perPage: query.per_page, maxPages: 2, maxItems: 100 });
51
+ return { ok: true, count: repos.length, repositories: limitItems(repos, 100).map(pickRepo) };
52
+ },
53
+ }),
54
+
55
+ defineTool({
56
+ name: 'github_get_repository',
57
+ description:
58
+ 'Ambil detail satu repository: default branch, visibilitas, bahasa, statistik, permission akun, dan status arsip.',
59
+ risk: 'read',
60
+ schema: { type: 'object', properties: REPO_PROPS, required: ['owner', 'repo'] },
61
+ async handler(args) {
62
+ const api = await plugin.requireClient();
63
+ const repo = await api.get(repoBase(args));
64
+ return { ok: true, repository: pickRepo(repo) };
65
+ },
66
+ }),
67
+
68
+ defineTool({
69
+ name: 'github_create_repository',
70
+ description:
71
+ 'Buat repository baru untuk akun yang terhubung. Membutuhkan permission Administration write atau scope public_repo/repo. Operasi ini mengubah data dan selalu meminta approval.',
72
+ risk: 'write',
73
+ schema: {
74
+ type: 'object',
75
+ properties: {
76
+ name: { type: 'string', description: 'Nama repository baru.' },
77
+ description: { type: 'string', description: 'Deskripsi singkat.' },
78
+ private: { type: 'boolean', description: 'Repository privat bila true.' },
79
+ auto_init: { type: 'boolean', description: 'Inisialisasi dengan README sehingga branch default langsung ada.' },
80
+ gitignore_template: { type: 'string', description: 'Template .gitignore, contoh: Node.' },
81
+ license_template: { type: 'string', description: 'Template lisensi, contoh: mit.' },
82
+ homepage: { type: 'string', description: 'URL homepage repository.' },
83
+ },
84
+ required: ['name'],
85
+ },
86
+ async handler(args) {
87
+ const api = await plugin.requireClient();
88
+ const name = String(args.name || '').trim();
89
+ if (!/^[A-Za-z0-9._-]{1,100}$/.test(name)) throw new Error('Nama repository tidak valid.');
90
+ const body = { name };
91
+ for (const key of ['description', 'auto_init', 'gitignore_template', 'license_template', 'homepage']) {
92
+ if (args[key] !== undefined) body[key] = args[key];
93
+ }
94
+ if (args.private !== undefined) body.private = Boolean(args.private);
95
+ const repo = await api.post('/user/repos', body);
96
+ return { ok: true, repository: pickRepo(repo) };
97
+ },
98
+ }),
99
+
100
+ defineTool({
101
+ name: 'github_update_repository',
102
+ description:
103
+ 'Ubah metadata repository (deskripsi, homepage, visibilitas, arsip, topik). Membutuhkan permission Administration write. Operasi ini selalu meminta approval.',
104
+ risk: 'write',
105
+ schema: {
106
+ type: 'object',
107
+ properties: {
108
+ ...REPO_PROPS,
109
+ name: { type: 'string', description: 'Nama baru repository.' },
110
+ description: { type: 'string', description: 'Deskripsi baru.' },
111
+ homepage: { type: 'string', description: 'URL homepage baru.' },
112
+ private: { type: 'boolean', description: 'Ubah visibilitas privat/publik.' },
113
+ archived: { type: 'boolean', description: 'Arsipkan atau aktifkan kembali repository.' },
114
+ has_issues: { type: 'boolean', description: 'Aktifkan/nonaktifkan Issues.' },
115
+ has_wiki: { type: 'boolean', description: 'Aktifkan/nonaktifkan Wiki.' },
116
+ default_branch: { type: 'string', description: 'Branch default baru (harus sudah ada).' },
117
+ },
118
+ required: ['owner', 'repo'],
119
+ },
120
+ async handler(args) {
121
+ const api = await plugin.requireClient();
122
+ const body = {};
123
+ for (const key of ['name', 'description', 'homepage', 'private', 'archived', 'has_issues', 'has_wiki', 'default_branch']) {
124
+ if (args[key] !== undefined) body[key] = args[key];
125
+ }
126
+ if (Object.keys(body).length === 0) throw new Error('Tidak ada perubahan yang diminta.');
127
+ const repo = await api.patch(repoBase(args), body);
128
+ return { ok: true, repository: pickRepo(repo) };
129
+ },
130
+ }),
131
+
132
+ defineTool({
133
+ name: 'github_list_branches',
134
+ description: 'Daftar branch pada repository beserta commit SHA terakhir dan status proteksi.',
135
+ risk: 'read',
136
+ schema: {
137
+ type: 'object',
138
+ properties: {
139
+ ...REPO_PROPS,
140
+ protected: { type: 'boolean', description: 'Filter hanya branch terproteksi (true) atau tidak (false).' },
141
+ per_page: { type: 'integer', minimum: 1, maximum: 100, description: 'Jumlah per halaman (default 30).' },
142
+ page: { type: 'integer', minimum: 1, description: 'Halaman (default 1).' },
143
+ },
144
+ required: ['owner', 'repo'],
145
+ },
146
+ async handler(args) {
147
+ const api = await plugin.requireClient();
148
+ const branches = await api.paginate(`${repoBase(args)}/branches`, {
149
+ query: {
150
+ ...pageQuery(args),
151
+ ...(args.protected !== undefined ? { protected: String(args.protected) } : {}),
152
+ },
153
+ maxPages: 2,
154
+ maxItems: 100,
155
+ });
156
+ return { ok: true, count: branches.length, branches: limitItems(branches, 100).map(pickBranch) };
157
+ },
158
+ }),
159
+
160
+ defineTool({
161
+ name: 'github_get_branch',
162
+ description: 'Ambil satu branch berdasarkan nama, termasuk SHA commit terakhir dan status proteksinya.',
163
+ risk: 'read',
164
+ schema: {
165
+ type: 'object',
166
+ properties: { ...REPO_PROPS, branch: { type: 'string', description: 'Nama branch, contoh: main.' } },
167
+ required: ['owner', 'repo', 'branch'],
168
+ },
169
+ async handler(args) {
170
+ const api = await plugin.requireClient();
171
+ const branch = await api.get(`${repoBase(args)}/branches/${refSegment(args.branch, 'branch')}`);
172
+ return { ok: true, branch: pickBranch(branch) };
173
+ },
174
+ }),
175
+
176
+ defineTool({
177
+ name: 'github_list_commits',
178
+ description:
179
+ 'Daftar commit pada branch atau path tertentu. Dapat difilter berdasarkan author dan rentang tanggal ISO 8601.',
180
+ risk: 'read',
181
+ schema: {
182
+ type: 'object',
183
+ properties: {
184
+ ...REPO_PROPS,
185
+ sha: { type: 'string', description: 'Branch atau SHA commit awal (default branch default repository).' },
186
+ path: { type: 'string', description: 'Hanya commit yang menyentuh path ini.' },
187
+ author: { type: 'string', description: 'Username GitHub atau email author.' },
188
+ since: { type: 'string', description: 'Batas awal ISO 8601, contoh 2026-01-01T00:00:00Z.' },
189
+ until: { type: 'string', description: 'Batas akhir ISO 8601.' },
190
+ per_page: { type: 'integer', minimum: 1, maximum: 100, description: 'Jumlah per halaman (default 30).' },
191
+ page: { type: 'integer', minimum: 1, description: 'Halaman (default 1).' },
192
+ },
193
+ required: ['owner', 'repo'],
194
+ },
195
+ async handler(args) {
196
+ const api = await plugin.requireClient();
197
+ const query = {
198
+ ...pageQuery(args),
199
+ ...(args.sha ? { sha: args.sha } : {}),
200
+ ...(args.path ? { path: args.path } : {}),
201
+ ...(args.author ? { author: args.author } : {}),
202
+ ...(args.since ? { since: args.since } : {}),
203
+ ...(args.until ? { until: args.until } : {}),
204
+ };
205
+ const commits = await api.paginate(`${repoBase(args)}/commits`, { query, maxPages: 2, maxItems: 100 });
206
+ return { ok: true, count: commits.length, commits: limitItems(commits, 100).map(pickCommit) };
207
+ },
208
+ }),
209
+
210
+ defineTool({
211
+ name: 'github_get_commit',
212
+ description: 'Ambil satu commit lengkap dengan pesan, author, parent, dan statistik perubahan file.',
213
+ risk: 'read',
214
+ schema: {
215
+ type: 'object',
216
+ properties: { ...REPO_PROPS, ref: { type: 'string', description: 'SHA commit, branch, atau tag.' } },
217
+ required: ['owner', 'repo', 'ref'],
218
+ },
219
+ async handler(args) {
220
+ const api = await plugin.requireClient();
221
+ const commit = await api.get(`${repoBase(args)}/commits/${refSegment(args.ref)}`);
222
+ return {
223
+ ok: true,
224
+ commit: pickCommit(commit),
225
+ files: limitItems(commit.files || [], 50).map((file) => ({
226
+ filename: file.filename,
227
+ status: file.status,
228
+ additions: file.additions,
229
+ deletions: file.deletions,
230
+ changes: file.changes,
231
+ })),
232
+ };
233
+ },
234
+ }),
235
+
236
+ defineTool({
237
+ name: 'github_get_file_contents',
238
+ description:
239
+ '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.',
240
+ risk: 'read',
241
+ schema: {
242
+ type: 'object',
243
+ properties: {
244
+ ...REPO_PROPS,
245
+ path: { type: 'string', description: 'Path file/folder relatif, contoh: src/index.js.' },
246
+ ref: { type: 'string', description: 'Branch, tag, atau SHA. Default branch default repository.' },
247
+ },
248
+ required: ['owner', 'repo', 'path'],
249
+ },
250
+ async handler(args) {
251
+ const api = await plugin.requireClient();
252
+ const data = await api.get(`${repoBase(args)}/contents/${contentPath(args.path)}`, args.ref ? { ref: args.ref } : null);
253
+ if (Array.isArray(data)) {
254
+ return {
255
+ ok: true,
256
+ type: 'directory',
257
+ count: data.length,
258
+ entries: limitItems(data, 100).map((entry) => ({
259
+ name: entry.name,
260
+ path: entry.path,
261
+ type: entry.type,
262
+ size: entry.size ?? null,
263
+ sha: entry.sha,
264
+ })),
265
+ };
266
+ }
267
+ const base = {
268
+ ok: true,
269
+ type: 'file',
270
+ name: data.name,
271
+ path: data.path,
272
+ sha: data.sha,
273
+ size: data.size,
274
+ html_url: data.html_url || null,
275
+ download_url: data.download_url || null,
276
+ };
277
+ if (data.encoding !== 'base64' || typeof data.content !== 'string') return base;
278
+ if (Number(data.size) > 200_000) {
279
+ return { ...base, truncated: true, note: 'File lebih dari 200 KB; isi tidak didekode.' };
280
+ }
281
+ const buffer = Buffer.from(data.content, 'base64');
282
+ if (buffer.includes(0)) {
283
+ return { ...base, binary: true, note: 'File tampak biner; isi tidak ditampilkan.' };
284
+ }
285
+ return { ...base, content: text(buffer.toString('utf8'), 100_000) };
286
+ },
287
+ }),
288
+
289
+ defineTool({
290
+ name: 'github_create_or_update_file',
291
+ description:
292
+ '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.',
293
+ risk: 'write',
294
+ schema: {
295
+ type: 'object',
296
+ properties: {
297
+ ...REPO_PROPS,
298
+ path: { type: 'string', description: 'Path file, contoh: docs/readme.md.' },
299
+ message: { type: 'string', description: 'Pesan commit.' },
300
+ content: { type: 'string', description: 'Isi file sebagai teks biasa (UTF-8).' },
301
+ sha: { type: 'string', description: 'SHA file yang sedang diperbarui; kosongkan untuk file baru.' },
302
+ branch: { type: 'string', description: 'Branch tujuan. Default branch default repository.' },
303
+ },
304
+ required: ['owner', 'repo', 'path', 'message', 'content'],
305
+ },
306
+ async handler(args) {
307
+ const api = await plugin.requireClient();
308
+ const body = {
309
+ message: String(args.message),
310
+ content: Buffer.from(String(args.content), 'utf8').toString('base64'),
311
+ ...(args.sha ? { sha: String(args.sha) } : {}),
312
+ ...(args.branch ? { branch: String(args.branch) } : {}),
313
+ };
314
+ const result = await api.put(`${repoBase(args)}/contents/${contentPath(args.path)}`, body);
315
+ return {
316
+ ok: true,
317
+ content: result?.content
318
+ ? { path: result.content.path, sha: result.content.sha, html_url: result.content.html_url }
319
+ : null,
320
+ commit: result?.commit ? { sha: result.commit.sha, message: text(result.commit.message, 500) } : null,
321
+ };
322
+ },
323
+ }),
324
+
325
+ defineTool({
326
+ name: 'github_delete_file',
327
+ description:
328
+ '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.',
329
+ risk: 'destructive',
330
+ requiresApproval: true,
331
+ schema: {
332
+ type: 'object',
333
+ properties: {
334
+ ...REPO_PROPS,
335
+ path: { type: 'string', description: 'Path file yang dihapus.' },
336
+ message: { type: 'string', description: 'Pesan commit penghapusan.' },
337
+ sha: { type: 'string', description: 'SHA file saat ini (wajib).' },
338
+ branch: { type: 'string', description: 'Branch tujuan. Default branch default repository.' },
339
+ },
340
+ required: ['owner', 'repo', 'path', 'message', 'sha'],
341
+ },
342
+ async handler(args) {
343
+ await assertDestructiveAllowed(plugin, 'Penghapusan file');
344
+ const api = await plugin.requireClient();
345
+ const result = await api.delete(`${repoBase(args)}/contents/${contentPath(args.path)}`, {
346
+ message: String(args.message),
347
+ sha: String(args.sha),
348
+ ...(args.branch ? { branch: String(args.branch) } : {}),
349
+ });
350
+ return {
351
+ ok: true,
352
+ commit: result?.commit ? { sha: result.commit.sha, message: text(result.commit.message, 500) } : null,
353
+ };
354
+ },
355
+ }),
356
+
357
+ defineTool({
358
+ name: 'github_compare_commits',
359
+ description:
360
+ 'Bandingkan dua commit/branch (base...head) dan tampilkan status ahead/behind serta daftar file yang berubah.',
361
+ risk: 'read',
362
+ schema: {
363
+ type: 'object',
364
+ properties: {
365
+ ...REPO_PROPS,
366
+ base: { type: 'string', description: 'Branch/tag/SHA awal.' },
367
+ head: { type: 'string', description: 'Branch/tag/SHA akhir.' },
368
+ },
369
+ required: ['owner', 'repo', 'base', 'head'],
370
+ },
371
+ async handler(args) {
372
+ const api = await plugin.requireClient();
373
+ const ref = `${refSegment(args.base, 'base')}...${refSegment(args.head, 'head')}`;
374
+ const result = await api.get(`${repoBase(args)}/compare/${ref}`);
375
+ return {
376
+ ok: true,
377
+ status: result.status,
378
+ ahead_by: result.ahead_by,
379
+ behind_by: result.behind_by,
380
+ total_commits: result.total_commits,
381
+ commits: limitItems(result.commits || [], 50).map(pickCommit),
382
+ files: limitItems(result.files || [], 100).map((file) => ({
383
+ filename: file.filename,
384
+ status: file.status,
385
+ additions: file.additions,
386
+ deletions: file.deletions,
387
+ })),
388
+ };
389
+ },
390
+ }),
391
+ ];
392
+ }
@@ -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) {
@@ -1120,6 +1121,30 @@ export function createCommands(state) {
1120
1121
  // Optional plugins (plugins/): each contributes one namespaced command so the
1121
1122
  // core parser stays untouched. `/drive` is only defined when the plugin loaded.
1122
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;
1123
1148
  const handler = createDriveCommand({
1124
1149
  plugin,
1125
1150
  registry,