@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,78 @@
1
+ import { GitHubPluginError, GITHUB_CODES } from './errors.js';
2
+
3
+ /**
4
+ * Klien GitHub API sisi plugin.
5
+ *
6
+ * CLI tidak memegang access token, jadi setiap panggilan diteruskan ke backend
7
+ * (Integration Gateway) yang memasang token pengguna. Antarmuka sengaja dibuat
8
+ * mirip REST GitHub agar tools mudah dibaca.
9
+ */
10
+ export class GitHubApi {
11
+ constructor({ backend, logger = null } = {}) {
12
+ if (!backend) throw new Error('GitHubApi membutuhkan backend client.');
13
+ this.backend = backend;
14
+ this.logger = logger;
15
+ }
16
+
17
+ async request(method, path, { query = null, body = undefined } = {}) {
18
+ const { data } = await this.backend.proxy(method, path, { query, body });
19
+ return data;
20
+ }
21
+
22
+ get(path, query = null) {
23
+ return this.request('GET', path, { query });
24
+ }
25
+
26
+ post(path, body = undefined, query = null) {
27
+ return this.request('POST', path, { body, query });
28
+ }
29
+
30
+ patch(path, body = undefined) {
31
+ return this.request('PATCH', path, { body });
32
+ }
33
+
34
+ put(path, body = undefined) {
35
+ return this.request('PUT', path, { body });
36
+ }
37
+
38
+ delete(path, body = undefined) {
39
+ return this.request('DELETE', path, { body });
40
+ }
41
+
42
+ /**
43
+ * Ambil beberapa halaman berturut-turut. Backend meneruskan header `Link`
44
+ * GitHub lewat `x-github-link`, sehingga CLI tahu kapan harus berhenti.
45
+ */
46
+ async paginate(path, { query = {}, perPage = 30, maxPages = 5, maxItems = 100 } = {}) {
47
+ const items = [];
48
+ let page = 1;
49
+ let pages = 0;
50
+ while (items.length < maxItems && pages < maxPages) {
51
+ pages += 1;
52
+ const { data, nextPage } = await this.backend.proxy('GET', path, {
53
+ query: { ...query, per_page: perPage, page },
54
+ });
55
+ if (!Array.isArray(data)) break;
56
+ items.push(...data.slice(0, Math.max(0, maxItems - items.length)));
57
+ if (!nextPage) break;
58
+ page += 1;
59
+ }
60
+ return items;
61
+ }
62
+
63
+ /** Cek kuota GitHub. */
64
+ async rateLimit() {
65
+ return this.get('/rate_limit');
66
+ }
67
+ }
68
+
69
+ /** Bungkus error jaringan/HTTP agar pesan ke pengguna konsisten. */
70
+ export function describeApiError(error) {
71
+ if (error instanceof GitHubPluginError) return error;
72
+ const status = Number(error?.status || 0);
73
+ return new GitHubPluginError(error?.message || 'Permintaan GitHub gagal.', {
74
+ code: status === 429 ? GITHUB_CODES.RATE_LIMIT : GITHUB_CODES.API,
75
+ status,
76
+ retryable: status === 429 || status >= 500,
77
+ });
78
+ }
@@ -0,0 +1,45 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ /**
4
+ * Pembantu autentikasi sisi CLI. Tidak ada kredensial di sini: OAuth,
5
+ * penukaran kode, refresh, dan revoke seluruhnya dikerjakan backend.
6
+ */
7
+
8
+ /** Buka browser default; `false` bila tidak ada opener yang tersedia. */
9
+ export function openBrowser(url) {
10
+ const target = String(url);
11
+ const attempts =
12
+ process.platform === 'darwin'
13
+ ? [['open', [target]]]
14
+ : process.platform === 'win32'
15
+ ? [['cmd', ['/c', 'start', '', target]]]
16
+ : [
17
+ ['xdg-open', [target]],
18
+ ['sensible-browser', [target]],
19
+ ['gio', ['open', target]],
20
+ ];
21
+ for (const [command, args] of attempts) {
22
+ try {
23
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
24
+ child.on('error', () => {});
25
+ child.unref();
26
+ return true;
27
+ } catch {
28
+ // Coba opener berikutnya.
29
+ }
30
+ }
31
+ return false;
32
+ }
33
+
34
+ /** Pesan kegagalan login yang ramah dan tidak membocorkan apa pun. */
35
+ export function describeLoginFailure(error) {
36
+ const status = Number(error?.status || 0);
37
+ const code = String(error?.code || '');
38
+ if (code === 'GITHUB_LOGIN_TIMEOUT' || code === 'GITHUB_SESSION') return error.message;
39
+ if (status === 401) return 'Backend menolak sesi login. Jalankan /github login lagi.';
40
+ if (status === 403) return 'Otorisasi ditolak atau dibatasi oleh GitHub.';
41
+ if (status === 410 || code === 'GITHUB_SESSION') return 'Sesi login sudah kedaluwarsa. Jalankan /github login lagi.';
42
+ if (status >= 500) return 'Backend integrasi GitHub sedang bermasalah. Coba lagi nanti.';
43
+ if (status === 0) return 'Tidak dapat menghubungi backend integrasi GitHub. Periksa koneksi internet.';
44
+ return error?.message || 'Login GitHub gagal.';
45
+ }
@@ -0,0 +1,191 @@
1
+ import { GitHubPluginError, GITHUB_CODES } from './errors.js';
2
+ import { GITHUB_BACKEND_URL } from './config.js';
3
+
4
+ /**
5
+ * Klien backend integrasi GitHub.
6
+ *
7
+ * Backend adalah satu-satunya pemegang Client Secret, Private Key, dan token
8
+ * pengguna. CLI hanya memegang `cliToken` (per akun, diterbitkan setelah OAuth
9
+ * berhasil) yang dikirim sebagai Bearer pada endpoint terautentikasi.
10
+ */
11
+ export const GITHUB_API_PREFIX = '/integrations/github';
12
+
13
+ const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
14
+
15
+ export class GitHubBackendClient {
16
+ constructor({
17
+ baseUrl = GITHUB_BACKEND_URL,
18
+ getToken = () => null,
19
+ fetchImpl = null,
20
+ logger = null,
21
+ timeoutMs = 20_000,
22
+ } = {}) {
23
+ this.baseUrl = String(baseUrl || GITHUB_BACKEND_URL).replace(/\/+$/, '');
24
+ this.getToken = getToken;
25
+ this.fetchImpl = fetchImpl || fetch;
26
+ this.logger = logger;
27
+ this.timeoutMs = timeoutMs;
28
+ }
29
+
30
+ endpoint(path) {
31
+ return `${this.baseUrl}${GITHUB_API_PREFIX}${path}`;
32
+ }
33
+
34
+ async request(path, { method = 'GET', body = undefined, auth = true, headers = {}, timeoutMs = null } = {}) {
35
+ const token = auth ? this.getToken() : null;
36
+ if (auth && !token) {
37
+ throw new GitHubPluginError('Belum login ke GitHub. Jalankan /github login.', {
38
+ code: GITHUB_CODES.UNAUTHORIZED,
39
+ status: 401,
40
+ reconnect: true,
41
+ });
42
+ }
43
+ const controller = new AbortController();
44
+ const timer = setTimeout(() => controller.abort(), timeoutMs || this.timeoutMs);
45
+ timer.unref?.();
46
+ let response;
47
+ try {
48
+ response = await this.fetchImpl(this.endpoint(path), {
49
+ method,
50
+ headers: {
51
+ accept: 'application/json',
52
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
53
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
54
+ ...headers,
55
+ },
56
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
57
+ signal: controller.signal,
58
+ });
59
+ } catch (error) {
60
+ throw new GitHubPluginError(
61
+ typeof error?.code === 'string' && error.code.startsWith('E')
62
+ ? 'Tidak dapat menghubungi backend integrasi GitHub (masalah jaringan).'
63
+ : 'Backend integrasi GitHub tidak merespons. Coba lagi.',
64
+ {
65
+ code: GITHUB_CODES.BACKEND_UNREACHABLE,
66
+ status: 0,
67
+ retryable: true,
68
+ details: error instanceof Error ? error.message : null,
69
+ },
70
+ );
71
+ } finally {
72
+ clearTimeout(timer);
73
+ }
74
+
75
+ const text = await response.text();
76
+ let parsed = null;
77
+ try {
78
+ parsed = text ? JSON.parse(text) : null;
79
+ } catch {
80
+ parsed = null;
81
+ }
82
+ const result = parsed && typeof parsed === 'object' && 'result' in parsed ? parsed.result : parsed;
83
+ if (!response.ok || parsed?.success === false) {
84
+ const message =
85
+ (result && typeof result === 'object' && (result.message || result.error)) ||
86
+ (typeof result === 'string' ? result : '') ||
87
+ `Backend menjawab HTTP ${response.status}`;
88
+ throw new GitHubPluginError(message, {
89
+ code:
90
+ (result && typeof result === 'object' && result.code) ||
91
+ (response.status === 429 ? GITHUB_CODES.RATE_LIMIT : GITHUB_CODES.BACKEND),
92
+ status: response.status,
93
+ retryable: RETRYABLE_STATUS.has(response.status),
94
+ reconnect: Boolean(result && typeof result === 'object' && result.reconnect),
95
+ details: result && typeof result === 'object' ? result.details || result.missing || null : null,
96
+ });
97
+ }
98
+ return { result: result ?? {}, headers: response.headers, status: response.status };
99
+ }
100
+
101
+ async startSession({ label = null, loginHint = null } = {}) {
102
+ const { result } = await this.request('/connect', {
103
+ method: 'POST',
104
+ auth: false,
105
+ body: { ...(label ? { label } : {}), ...(loginHint ? { loginHint } : {}) },
106
+ });
107
+ if (!result.sessionId || !result.pairingSecret || !result.url) {
108
+ throw new GitHubPluginError('Backend tidak mengembalikan sesi login yang valid.', {
109
+ code: GITHUB_CODES.SESSION,
110
+ status: 502,
111
+ });
112
+ }
113
+ return result;
114
+ }
115
+
116
+ async pollSession({ sessionId, pairingSecret }) {
117
+ const { result } = await this.request(`/session/${encodeURIComponent(sessionId)}`, {
118
+ auth: false,
119
+ headers: { 'x-ryuu-pairing': pairingSecret },
120
+ timeoutMs: 15_000,
121
+ });
122
+ return result;
123
+ }
124
+
125
+ async startDevice() {
126
+ const { result } = await this.request('/device/start', { method: 'POST', auth: false });
127
+ if (!result.sessionId || !result.pairingSecret || !result.userCode) {
128
+ throw new GitHubPluginError('Backend tidak mengembalikan kode device flow yang valid.', {
129
+ code: GITHUB_CODES.SESSION,
130
+ status: 502,
131
+ });
132
+ }
133
+ return result;
134
+ }
135
+
136
+ async pollDevice({ sessionId, pairingSecret }) {
137
+ const { result } = await this.request('/device/poll', {
138
+ method: 'POST',
139
+ auth: false,
140
+ body: { sessionId, pairingSecret },
141
+ timeoutMs: 20_000,
142
+ });
143
+ return result;
144
+ }
145
+
146
+ async status() {
147
+ return (await this.request('/status')).result;
148
+ }
149
+
150
+ async account() {
151
+ return (await this.request('/account')).result;
152
+ }
153
+
154
+ async test() {
155
+ return (await this.request('/test', { method: 'POST' })).result;
156
+ }
157
+
158
+ async refresh() {
159
+ return (await this.request('/refresh', { method: 'POST' })).result;
160
+ }
161
+
162
+ async disconnect() {
163
+ return (await this.request('/disconnect', { method: 'POST' })).result;
164
+ }
165
+
166
+ async setEnabled(enabled) {
167
+ return (await this.request(enabled ? '/enable' : '/disable', { method: 'POST' })).result;
168
+ }
169
+
170
+ /**
171
+ * Proxy ke GitHub REST. Backend memasang token pengguna; CLI tidak pernah
172
+ * melihat token tersebut. `nextPage` berasal dari header Link yang diteruskan
173
+ * backend sebagai `x-github-link`.
174
+ */
175
+ async proxy(method, apiPath, { query = null, body = undefined } = {}) {
176
+ const search = new URLSearchParams();
177
+ for (const [key, value] of Object.entries(query || {})) {
178
+ if (value === undefined || value === null || value === '') continue;
179
+ if (Array.isArray(value)) {
180
+ for (const item of value) search.append(key, String(item));
181
+ } else {
182
+ search.set(key, String(value));
183
+ }
184
+ }
185
+ const suffix = search.toString() ? `?${search.toString()}` : '';
186
+ const path = `/api${String(apiPath || '').startsWith('/') ? apiPath : `/${apiPath}`}${suffix}`;
187
+ const response = await this.request(path, { method, body: body !== undefined ? body : undefined });
188
+ const link = response.headers?.get?.('x-github-link') || '';
189
+ return { data: response.result, nextPage: /rel="next"/.test(link) };
190
+ }
191
+ }
@@ -0,0 +1,307 @@
1
+ import { syncPluginTools, unregisterPluginTools } from '../index.js';
2
+
3
+ /**
4
+ * Handler `/github`.
5
+ *
6
+ * Command memakai parser Ryuu yang sudah ada (`define` di cli/commands.js);
7
+ * modul ini hanya memformat keluaran dan memanggil manager. Tidak ada token
8
+ * yang pernah dicetak.
9
+ */
10
+
11
+ const USAGE = [
12
+ 'GitHub Integration',
13
+ '',
14
+ 'Autentikasi:',
15
+ ' /github login login lewat browser (utama)',
16
+ ' /github login --device login device flow (headless)',
17
+ ' /github logout putuskan akun GitHub',
18
+ '',
19
+ 'Status:',
20
+ ' /github status',
21
+ ' /github account',
22
+ ' /github test',
23
+ '',
24
+ 'Integrasi:',
25
+ ' /github enable',
26
+ ' /github disable',
27
+ '',
28
+ 'Konfigurasi:',
29
+ ' /github config',
30
+ ' /github config set <key> <value>',
31
+ ].join('\n');
32
+
33
+ function tokenize(args) {
34
+ const out = [];
35
+ let current = '';
36
+ let quote = null;
37
+ for (const char of String(args ?? '')) {
38
+ if (quote) {
39
+ if (char === quote) quote = null;
40
+ else current += char;
41
+ continue;
42
+ }
43
+ if (char === '"' || char === "'") {
44
+ quote = char;
45
+ continue;
46
+ }
47
+ if (/\s/.test(char)) {
48
+ if (current) out.push(current);
49
+ current = '';
50
+ continue;
51
+ }
52
+ current += char;
53
+ }
54
+ if (current) out.push(current);
55
+ return out;
56
+ }
57
+
58
+ function stamp(value) {
59
+ if (!value) return 'belum pernah';
60
+ const date = new Date(value);
61
+ if (Number.isNaN(date.getTime())) return String(value);
62
+ return date.toISOString().replace('T', ' ').slice(0, 19);
63
+ }
64
+
65
+ function redirectNote(plugin) {
66
+ return [
67
+ ` Backend : ${plugin.pathsInfo.configFile.replace(/\/config\.json$/, '')}`,
68
+ ' Client Secret / Private Key / Webhook Secret: hanya di backend, tidak ada di CLI.',
69
+ ].join('\n');
70
+ }
71
+
72
+ export function createGitHubCommand({ plugin, registry, confirm = null, openBrowser = true, showCode = null }) {
73
+ return async function githubCommand(args, print) {
74
+ const parts = tokenize(args);
75
+ const sub = (parts[0] || '').toLowerCase();
76
+ const rest = parts.slice(1);
77
+
78
+ if (!sub || sub === 'help' || sub === '?') {
79
+ print(USAGE);
80
+ return;
81
+ }
82
+
83
+ if (sub === 'login') {
84
+ const device = rest.includes('--device');
85
+ const noBrowser = rest.includes('--no-browser');
86
+ try {
87
+ await plugin.login({
88
+ print,
89
+ showCode,
90
+ device,
91
+ openBrowser: openBrowser && !noBrowser,
92
+ confirmReplace: confirm ? (question) => confirm(question) : null,
93
+ });
94
+ // Setelah login, plugin otomatis enabled; daftarkan tool agar sesi ini
95
+ // langsung bisa memakainya tanpa restart.
96
+ if (await plugin.isEnabled()) await syncPluginTools(plugin, registry);
97
+ } catch (error) {
98
+ print(`Login GitHub gagal: ${error.message}`);
99
+ }
100
+ return;
101
+ }
102
+
103
+ if (sub === 'logout') {
104
+ // Sesi yang kedaluwarsa tetap punya kredensial lokal yang harus dihapus,
105
+ // jadi pemeriksaannya memakai `authInfo().connected`, bukan isAuthenticated.
106
+ const info = await plugin.authInfo().catch(() => ({ connected: false }));
107
+ if (!info.connected) {
108
+ print('Belum ada akun GitHub yang terhubung. Tidak ada yang perlu diputus.');
109
+ return;
110
+ }
111
+ if (confirm && !(await confirm('Putuskan koneksi GitHub dan hapus kredensial lokal? [y/N] '))) {
112
+ print('Logout dibatalkan. Kredensial tidak diubah.');
113
+ return;
114
+ }
115
+ unregisterPluginTools(plugin, registry);
116
+ await plugin.logout({ print });
117
+ return;
118
+ }
119
+
120
+ if (sub === 'enable') {
121
+ try {
122
+ const result = await plugin.enable();
123
+ const count = await syncPluginTools(plugin, registry);
124
+ print(`Integrasi GitHub diaktifkan. ${count} tool terdaftar untuk AI Agent:`);
125
+ print(` ${result.tools.join(', ')}`);
126
+ print('Operasi yang mengubah data tetap meminta approval Ryuu.');
127
+ } catch (error) {
128
+ print(error.message);
129
+ if (error.code === 'GITHUB_UNAUTHORIZED') print('Jalankan /github login untuk menghubungkan akun GitHub.');
130
+ }
131
+ return;
132
+ }
133
+
134
+ if (sub === 'disable') {
135
+ const removed = unregisterPluginTools(plugin, registry);
136
+ await plugin.disable();
137
+ print(
138
+ `Integrasi GitHub dinonaktifkan (${removed} tool dilepas dari AI Agent). ` +
139
+ 'Akun dan token tetap tersimpan; instalasi GitHub App tidak diputus.',
140
+ );
141
+ return;
142
+ }
143
+
144
+ if (sub === 'status') {
145
+ print(await formatStatus(plugin, registry));
146
+ return;
147
+ }
148
+
149
+ if (sub === 'account') {
150
+ try {
151
+ const account = await plugin.account();
152
+ print(
153
+ [
154
+ 'AKUN GITHUB',
155
+ ` Username : @${account.login || '(tidak tersedia)'}`,
156
+ ` Nama : ${account.name || '(tidak tersedia)'}`,
157
+ ` Tipe akun : ${account.type || '(tidak tersedia)'}`,
158
+ ` Profil : ${account.htmlUrl || '(tidak tersedia)'}`,
159
+ ` Avatar : ${account.avatarUrl || '(tidak tersedia)'}`,
160
+ ` Scope : ${account.scope || '(tidak tercatat)'}`,
161
+ ` Status : ${account.connected ? 'terhubung' : 'perlu login ulang'}`,
162
+ ` Integrasi : ${(await plugin.isEnabled()) ? 'aktif' : 'nonaktif'}`,
163
+ ` Instalasi : ${(account.installations || []).length} terlihat`,
164
+ ].join('\n'),
165
+ );
166
+ for (const installation of account.installations || []) {
167
+ print(` · ${installation.account || '?'} (${installation.accountType || '?'}) · id ${installation.id}`);
168
+ }
169
+ } catch (error) {
170
+ print(`Tidak dapat membaca akun GitHub: ${error.message}`);
171
+ if (error.reconnect) print('Jalankan /github login untuk menghubungkan ulang.');
172
+ }
173
+ return;
174
+ }
175
+
176
+ if (sub === 'test') {
177
+ print('Menjalankan pemeriksaan GitHub…');
178
+ const result = await plugin.test();
179
+ const rows = (result.report || []).map(
180
+ (row) => ` ${row.ok ? '[✓]' : '[!]'} ${String(row.label).padEnd(26)} ${row.detail || ''}`,
181
+ );
182
+ print(
183
+ [
184
+ 'DIAGNOSTIK GITHUB',
185
+ ...rows,
186
+ '',
187
+ result.ok ? 'Semua pemeriksaan berhasil.' : 'Ada pemeriksaan yang gagal.',
188
+ 'Tidak ada repository yang dibuat, diubah, atau dihapus oleh test ini.',
189
+ ].join('\n'),
190
+ );
191
+ return;
192
+ }
193
+
194
+ if (sub === 'config') {
195
+ const action = (rest[0] || 'show').toLowerCase();
196
+ if (action === 'show') {
197
+ let view;
198
+ try {
199
+ view = await plugin.configView();
200
+ } catch (error) {
201
+ print(`Tidak dapat membaca konfigurasi GitHub: ${error.message}`);
202
+ return;
203
+ }
204
+ const width = Math.max(...Object.keys(view.lines).map((key) => key.length));
205
+ print(
206
+ [
207
+ 'KONFIGURASI GITHUB (NON-RAHASIA)',
208
+ ...Object.entries(view.lines).map(([key, value]) => ` ${key.padEnd(width)} ${value}`),
209
+ '',
210
+ redirectNote(plugin),
211
+ '',
212
+ 'Ubah dengan: /github config set <key> <value>',
213
+ 'Key: enabled, autoRefresh, confirmDestructive, defaultRepo',
214
+ ].join('\n'),
215
+ );
216
+ return;
217
+ }
218
+ if (action === 'set') {
219
+ const key = rest[1];
220
+ const value = rest.slice(2).join(' ');
221
+ if (!key || rest.length < 3) {
222
+ print('Usage: /github config set <key> <value>');
223
+ return;
224
+ }
225
+ try {
226
+ const parsed = await plugin.setConfig(key, value);
227
+ print(`Konfigurasi diperbarui: ${key} = ${parsed === null ? '(kosong)' : String(parsed)}`);
228
+ if (key === 'enabled') {
229
+ if (parsed === true) {
230
+ const count = await syncPluginTools(plugin, registry);
231
+ print(`${count} tool GitHub ${count > 0 ? 'terdaftar' : 'belum dapat didaftarkan (pastikan sudah login)'}.`);
232
+ } else {
233
+ const removed = unregisterPluginTools(plugin, registry);
234
+ print(`${removed} tool GitHub dilepas dari AI Agent.`);
235
+ }
236
+ }
237
+ if (key === 'confirmDestructive' && parsed === false) {
238
+ print('Peringatan: penghapusan file dan merge pull request akan diblokir sampai opsi ini diaktifkan lagi.');
239
+ }
240
+ } catch (error) {
241
+ print(error.message);
242
+ }
243
+ return;
244
+ }
245
+ print('Usage: /github config [show] | /github config set <key> <value>');
246
+ return;
247
+ }
248
+
249
+ print(`Subcommand tidak dikenal: /github ${sub}`);
250
+ print(USAGE);
251
+ };
252
+ }
253
+
254
+ export async function formatStatus(plugin, registry) {
255
+ const status = await plugin.status();
256
+ const registered = registry?.plugins?.get?.(plugin.name);
257
+ const remote = status.remote;
258
+ const lines = [
259
+ 'STATUS GITHUB',
260
+ ` Integrasi : ${status.enabled ? 'aktif' : 'nonaktif'}`,
261
+ ` Autentikasi : ${status.authenticated ? 'terhubung' : 'belum login'}`,
262
+ ];
263
+ if (status.configError) lines.push(` Konfigurasi : error — ${status.configError}`);
264
+ if (status.authError) lines.push(` Kredensial : error — ${status.authError}`);
265
+ if (status.sessionExpired) lines.push(' Sesi lokal : kedaluwarsa — jalankan /github login');
266
+
267
+ if (status.authenticated) {
268
+ const account = remote?.github || status.auth;
269
+ lines.push(` Akun : @${account.login || status.auth.login || '(tidak tersedia)'}`);
270
+ if (account.name || status.auth.name) lines.push(` Nama : ${account.name || status.auth.name}`);
271
+ if (remote?.token) {
272
+ lines.push(
273
+ ` Access token : ${
274
+ remote.token.accessExpiresAt
275
+ ? `berlaku sampai ${stamp(remote.token.accessExpiresAt)}`
276
+ : 'tanpa masa berlaku (expiration off)'
277
+ }`,
278
+ );
279
+ lines.push(
280
+ ` Refresh token : ${remote.token.hasRefreshToken ? 'tersedia di backend' : 'tidak tersedia'}`,
281
+ );
282
+ if (remote.token.needsReconnect) lines.push(' Perlu login ulang: ya (otorisasi dicabut/kedaluwarsa)');
283
+ }
284
+ lines.push(
285
+ ` Instalasi : ${Array.isArray(remote?.installations) ? remote.installations.length : (status.auth.installations || 0)} terlihat`,
286
+ );
287
+ lines.push(` Sesi lokal : ${status.auth.cliTokenExpiresAt ? `berlaku sampai ${stamp(status.auth.cliTokenExpiresAt)}` : 'tanpa masa berlaku tercatat'}`);
288
+ }
289
+
290
+ lines.push(
291
+ ` Backend : ${remote ? 'terhubung (respons aplikasi)' : status.remoteError ? `gagal — ${status.remoteError}` : status.settings?.backend || 'tidak diketahui'}`,
292
+ ` Tool aktif : ${registered ? registered.toolNames.length : 0} dari ${status.toolCount}`,
293
+ ` Cek terakhir : ${stamp(status.lastCheck)}${status.lastCheck === null ? '' : status.lastCheckOk ? ' (berhasil)' : ' (gagal)'}`,
294
+ ` Config file : ${status.paths.configFile}`,
295
+ ` Credential file : ${status.paths.credentialFile}`,
296
+ ' Rahasia : Client Secret, Private Key, dan token GitHub tidak disimpan di CLI',
297
+ );
298
+
299
+ if (status.enabled && !status.authenticated) lines.push('', 'Jalankan /github login untuk menghubungkan akun GitHub.');
300
+ if (status.enabled && status.authenticated && !registered) {
301
+ lines.push('', 'Integrasi aktif tetapi tool belum terdaftar: jalankan /github enable.');
302
+ }
303
+ if (remote?.config && !remote.config.ok) {
304
+ lines.push('', `Konfigurasi backend belum lengkap: ${(remote.config.missing || []).join(', ') || 'tidak diketahui'}.`);
305
+ }
306
+ return lines.join('\n');
307
+ }