zelai-cli 1.1.0

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.
package/src/cli.js ADDED
@@ -0,0 +1,563 @@
1
+ /**
2
+ * Zelai CLI — main loop.
3
+ * Interactive REPL untuk Zelapi Agent API.
4
+ */
5
+ 'use strict';
6
+
7
+ const readline = require('readline');
8
+ const ora = require('ora');
9
+
10
+ const config = require('./config');
11
+ const { makeClient, LimitExceededError } = require('./client');
12
+ const commands = require('./commands');
13
+ const session = require('./session');
14
+ const ui = require('./ui');
15
+
16
+ const VERSION = require('../package.json').version;
17
+
18
+ function parseArgv(argv) {
19
+ const out = { _: [], flags: {} };
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const a = argv[i];
22
+ if (a === '--help' || a === '-h') out.flags.help = true;
23
+ else if (a === '--version' || a === '-v') out.flags.version = true;
24
+ else if (a === '--key' || a === '-k') out.flags.key = argv[++i];
25
+ else if (a === '--model' || a === '-m') out.flags.model = argv[++i];
26
+ else if (a === '--endpoint' || a === '-e') out.flags.endpoint = argv[++i];
27
+ else if (a === '--system' || a === '-s') out.flags.system = argv[++i];
28
+ else if (a === '--session') out.flags.session = argv[++i];
29
+ else if (a === '--no-banner') out.flags.noBanner = true;
30
+ else if (a === '--prompt' || a === '-p') out.flags.prompt = argv[++i];
31
+ else if (a === 'login') out._.push('login');
32
+ else if (a === 'status') out._.push('status');
33
+ else if (a === 'usage') out._.push('usage');
34
+ else if (a === 'activity' || a === 'logs') out._.push('activity');
35
+ else if (a === 'config') out._.push('config');
36
+ else if (a === 'sessions') out._.push('sessions');
37
+ else if (a === 'resume' || a === 'continue') {
38
+ out._.push('resume');
39
+ // optional positional arg: zelai resume <id|nomor>
40
+ if (argv[i + 1] && !argv[i + 1].startsWith('-')) {
41
+ out.flags.resumeRef = argv[++i];
42
+ }
43
+ }
44
+ else if (a.startsWith('-')) {
45
+ // unknown flag — abaikan biar friendly
46
+ } else out._.push(a);
47
+ }
48
+ return out;
49
+ }
50
+
51
+ function printHelp() {
52
+ const c = ui.chalk;
53
+ console.log(`
54
+ ${c.magentaBright.bold('zelai')} ${c.gray('v' + VERSION)} ${c.gray('—')} official CLI buat ${c.magenta.bold('Zelapi')} Agent API
55
+
56
+ ${c.bold('Usage:')}
57
+ ${c.cyan('zelai')} masuk ke chat interaktif
58
+ ${c.cyan('zelai -p')} ${c.gray('"<pesan>"')} kirim satu pesan terus exit (one-shot)
59
+ ${c.cyan('zelai login')} ${c.gray('[zel_…]')} set / ganti API key
60
+ ${c.cyan('zelai status')} cek status server
61
+ ${c.cyan('zelai usage')} tampilin pemakaian API key
62
+ ${c.cyan('zelai activity')} tampilin activity logs
63
+ ${c.cyan('zelai sessions')} list sesi tersimpan di ./session
64
+ ${c.cyan('zelai resume')} ${c.gray('[id|nomor]')} lanjutin sesi (default: paling baru)
65
+ ${c.cyan('zelai config')} tampilin konfigurasi tersimpan
66
+
67
+ ${c.bold('Options:')}
68
+ ${c.gray('-k, --key <key>')} pakai API key tertentu sekali jalan
69
+ ${c.gray('-m, --model <name>')} opus | sonnet | haiku
70
+ ${c.gray('-e, --endpoint <path>')} AI endpoint (default /ai/claila)
71
+ ${c.gray('-s, --system <prompt>')} custom system prompt
72
+ ${c.gray(' --session <id>')} lanjutin sesi lama
73
+ ${c.gray(' --no-banner')} sembunyiin banner
74
+ ${c.gray('-p, --prompt <msg>')} kirim satu pesan langsung
75
+ ${c.gray('-h, --help')} tampilin pesan ini
76
+ ${c.gray('-v, --version')} tampilin versi
77
+
78
+ ${c.bold('Inside chat:')}
79
+ ketik ${c.yellow('/help')} buat lihat semua slash command (${c.yellow('/status')}, ${c.yellow('/usage')}, ${c.yellow('/activity')}, ${c.yellow('/sessions')}, ${c.yellow('/load')}, …)
80
+
81
+ ${c.gray('Docs: https://zelapioffciall.dpdns.org')}
82
+ `);
83
+ }
84
+
85
+ async function main(argv) {
86
+ const args = parseArgv(argv);
87
+
88
+ if (args.flags.help) {
89
+ printHelp();
90
+ return;
91
+ }
92
+ if (args.flags.version) {
93
+ console.log(`zelai v${VERSION}`);
94
+ return;
95
+ }
96
+
97
+ // Make sure session folder is ready first thing — user-requested behavior.
98
+ session.ensureDir();
99
+
100
+ // Load config + apply flags / env overrides.
101
+ let cfg = config.load();
102
+ if (process.env.ZELAPI_KEY && !cfg.apiKey) cfg.apiKey = process.env.ZELAPI_KEY;
103
+ if (args.flags.key) cfg.apiKey = args.flags.key;
104
+ if (args.flags.model) cfg.model = args.flags.model;
105
+ if (args.flags.endpoint) cfg.endpoint = args.flags.endpoint;
106
+ if (args.flags.system) cfg.system = args.flags.system;
107
+
108
+ const sub = args._[0];
109
+
110
+ if (sub === 'login') {
111
+ await runLogin(cfg, args._[1]);
112
+ return;
113
+ }
114
+ if (sub === 'status') {
115
+ const client = makeClient(cfg);
116
+ await runOneShot(() => commandStatus(client));
117
+ return;
118
+ }
119
+ if (sub === 'usage') {
120
+ if (!cfg.apiKey) return errorExit('Belum login. Jalanin `zelai login` dulu.');
121
+ const client = makeClient(cfg);
122
+ await runOneShot(() => commandUsage(client, cfg.apiKey));
123
+ return;
124
+ }
125
+ if (sub === 'activity') {
126
+ if (!cfg.apiKey) return errorExit('Belum login. Jalanin `zelai login` dulu.');
127
+ const client = makeClient(cfg);
128
+ await runOneShot(() => commandActivity(client, cfg.apiKey));
129
+ return;
130
+ }
131
+ if (sub === 'sessions') {
132
+ listSessions();
133
+ return;
134
+ }
135
+ if (sub === 'resume') {
136
+ // Resolve sesi yang mau di-resume:
137
+ // zelai resume <id> → load id-nya
138
+ // zelai resume <nomor> → load index dari `zelai sessions` (1-based)
139
+ // zelai resume → load sesi yang paling baru
140
+ const ref = args.flags.resumeRef;
141
+ const rows = session.list();
142
+ let target = null;
143
+ if (!ref) {
144
+ target = rows[0]; // paling baru
145
+ } else if (/^\d+$/.test(ref)) {
146
+ target = rows[parseInt(ref, 10) - 1];
147
+ } else {
148
+ const rec = session.load(ref);
149
+ target = rec ? { id: rec.id, turns: (rec.history || []).length } : null;
150
+ }
151
+ if (!target) {
152
+ console.log(`${ui.errorTag()} sesi "${ref || '(none)'}" gak ketemu. Coba ${ui.chalk.cyan('zelai sessions')} dulu.`);
153
+ process.exit(1);
154
+ }
155
+ const rec = session.load(target.id);
156
+ if (rec) {
157
+ if (rec.model) cfg.model = rec.model;
158
+ if (rec.endpoint) cfg.endpoint = rec.endpoint;
159
+ if (rec.system) cfg.system = rec.system;
160
+ config.update({
161
+ lastSession: target.id,
162
+ model: cfg.model,
163
+ endpoint: cfg.endpoint,
164
+ system: cfg.system,
165
+ });
166
+ }
167
+ args.flags.session = target.id;
168
+ if (!args.flags.noBanner) console.log(ui.banner());
169
+ console.log(`${ui.okTag()} resume sesi ${ui.chalk.cyan(target.id)} ` +
170
+ ui.chalk.gray(`(${(rec && rec.history && rec.history.length) || 0} pesan dimuat)`));
171
+ if (!cfg.apiKey) {
172
+ console.log(`${ui.systemTag()} login dulu yuk biar bisa lanjut chatting.`);
173
+ await runLogin(cfg, null);
174
+ cfg = config.load();
175
+ }
176
+ // skip banner di runInteractive karena udah di-print
177
+ const interactiveArgs = { ...args, flags: { ...args.flags, noBanner: true } };
178
+ await runInteractive(cfg, interactiveArgs);
179
+ return;
180
+ }
181
+ if (sub === 'config') {
182
+ const ctx = { config: cfg, sessionId: args.flags.session || cfg.lastSession || null };
183
+ await commands.dispatch(ctx, '/config', {});
184
+ return;
185
+ }
186
+
187
+ // Kalau belum punya API key — jalanin login interaktif dulu.
188
+ if (!cfg.apiKey) {
189
+ if (!args.flags.noBanner) console.log(ui.banner());
190
+ console.log(`${ui.systemTag()} kayaknya kamu belum login. Yuk masukin API key dulu.`);
191
+ await runLogin(cfg, null);
192
+ cfg = config.load();
193
+ }
194
+
195
+ // One-shot mode lewat -p.
196
+ if (args.flags.prompt) {
197
+ const client = makeClient(cfg);
198
+ const sessionId = args.flags.session || null;
199
+ await runPrompt(cfg, client, args.flags.prompt, sessionId);
200
+ return;
201
+ }
202
+
203
+ await runInteractive(cfg, args);
204
+ }
205
+
206
+ async function runLogin(cfg, maybeKey) {
207
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
208
+ try {
209
+ let key = maybeKey;
210
+ if (!key) {
211
+ key = await askMasked(rl, `${ui.systemTag()} masukin API key (zel_…): `, { mask: true });
212
+ }
213
+ key = (key || '').trim();
214
+ if (!key) {
215
+ console.log(`${ui.errorTag()} API key kosong, dibatalin.`);
216
+ return;
217
+ }
218
+ config.update({ apiKey: key });
219
+ console.log(`${ui.okTag()} tersimpan di ${ui.chalk.gray(config.configPath())}`);
220
+ } finally {
221
+ rl.close();
222
+ }
223
+ }
224
+
225
+ async function runOneShot(fn) {
226
+ try {
227
+ await fn();
228
+ } catch (err) {
229
+ handleError(err);
230
+ process.exit(1);
231
+ }
232
+ }
233
+
234
+ async function commandStatus(client) {
235
+ const spinner = ora({ text: 'ngecek status…', color: 'cyan' }).start();
236
+ try {
237
+ const data = await client.getStatus();
238
+ spinner.stop();
239
+ console.log(ui.pretty(data));
240
+ } catch (err) {
241
+ spinner.stop();
242
+ throw err;
243
+ }
244
+ }
245
+
246
+ async function commandUsage(client, key) {
247
+ const spinner = ora({ text: 'narik usage…', color: 'cyan' }).start();
248
+ try {
249
+ const data = await client.getUsage(key);
250
+ spinner.stop();
251
+ console.log(ui.pretty(data));
252
+ } catch (err) {
253
+ spinner.stop();
254
+ throw err;
255
+ }
256
+ }
257
+
258
+ async function commandActivity(client, key) {
259
+ const spinner = ora({ text: 'narik activity logs…', color: 'cyan' }).start();
260
+ try {
261
+ const data = await client.getActivityLogs(key);
262
+ spinner.stop();
263
+ console.log(ui.pretty(data));
264
+ } catch (err) {
265
+ spinner.stop();
266
+ throw err;
267
+ }
268
+ }
269
+
270
+ function listSessions() {
271
+ const rows = session.list();
272
+ console.log(ui.hr());
273
+ console.log(ui.chalk.bold('Sessions tersimpan'));
274
+ console.log(ui.chalk.gray('dir: ' + session.sessionDir()));
275
+ if (rows.length === 0) {
276
+ console.log(ui.chalk.gray('(belum ada sesi)'));
277
+ console.log(ui.hr());
278
+ return;
279
+ }
280
+ rows.forEach((r, i) => {
281
+ const when = r.updatedAt ? new Date(r.updatedAt).toISOString().replace('T', ' ').slice(0, 19) : '—';
282
+ console.log(
283
+ ui.chalk.gray(`#${String(i + 1).padStart(2)} `) +
284
+ ui.chalk.cyan(r.id.padEnd(28)) +
285
+ ui.chalk.gray(' · ') +
286
+ ui.chalk.gray(`${String(r.turns).padStart(3)} turns`) +
287
+ ui.chalk.gray(' · ') +
288
+ ui.chalk.gray(when) +
289
+ (r.preview ? ui.chalk.gray(' — ' + r.preview) : '')
290
+ );
291
+ });
292
+ console.log(ui.hr());
293
+ }
294
+
295
+ async function runPrompt(cfg, client, prompt, sessionId) {
296
+ const spinner = ora({ text: ' mikir…', color: 'magenta' }).start();
297
+ try {
298
+ const res = await client.sendAgent({
299
+ messages: [{ role: 'user', content: prompt }],
300
+ model: cfg.model,
301
+ endpoint: cfg.endpoint,
302
+ system: cfg.system,
303
+ sessionId: sessionId || null,
304
+ });
305
+ spinner.stop();
306
+ console.log(ui.renderMarkdown(res.reply || '(gak ada balasan)'));
307
+
308
+ const sid = res.sessionId || sessionId || session.localId();
309
+ session.append(sid, { role: 'user', content: prompt }, {
310
+ model: cfg.model, endpoint: cfg.endpoint, system: cfg.system,
311
+ });
312
+ session.append(sid, { role: 'assistant', content: res.reply || '' });
313
+ if (sessionId && res.sessionId && sessionId !== res.sessionId) {
314
+ session.rename(sessionId, res.sessionId);
315
+ }
316
+ config.update({ lastSession: sid });
317
+ console.log(ui.chalk.gray(`\nsession: ${sid}`));
318
+ } catch (err) {
319
+ spinner.stop();
320
+ handleError(err);
321
+ process.exit(1);
322
+ }
323
+ }
324
+
325
+ async function runInteractive(cfg, args) {
326
+ if (!args.flags.noBanner) {
327
+ console.log(ui.banner());
328
+ }
329
+
330
+ const client = makeClient(cfg);
331
+ const ctx = {
332
+ config: cfg,
333
+ client,
334
+ history: [],
335
+ sessionId: args.flags.session || cfg.lastSession || null,
336
+ rl: null,
337
+ };
338
+
339
+ // Auto-reload history dari file sesi sebelumnya kalau ada.
340
+ if (ctx.sessionId) {
341
+ const rec = session.load(ctx.sessionId);
342
+ if (rec && Array.isArray(rec.history) && rec.history.length > 0) {
343
+ ctx.history = rec.history.map(({ role, content }) => ({ role, content }));
344
+ console.log(`${ui.infoTag()} lanjutin sesi ${ui.chalk.cyan(ctx.sessionId)} ` +
345
+ ui.chalk.gray(`(${rec.history.length} pesan dimuat dari ./session)`));
346
+ } else {
347
+ console.log(`${ui.infoTag()} pakai session id ${ui.chalk.cyan(ctx.sessionId)} ${ui.chalk.gray('(belum ada history lokal)')}`);
348
+ }
349
+ }
350
+
351
+ // Optional: warn early kalau quota deket habis (best-effort, gak nge-block).
352
+ warnQuotaSoft(client, cfg.apiKey).catch(() => {});
353
+
354
+ const rl = readline.createInterface({
355
+ input: process.stdin,
356
+ output: process.stdout,
357
+ historySize: 200,
358
+ terminal: true,
359
+ });
360
+ ctx.rl = rl;
361
+
362
+ const helpers = {
363
+ ask: (q, opts) => askMasked(rl, q, opts),
364
+ };
365
+
366
+ const promptFn = () => {
367
+ rl.setPrompt(ui.userPrompt(ctx.config.model));
368
+ rl.prompt();
369
+ };
370
+
371
+ rl.on('SIGINT', () => {
372
+ console.log(`\n${ui.infoTag()} pakai ${ui.chalk.yellow('/exit')} buat keluar, atau Ctrl+D.`);
373
+ promptFn();
374
+ });
375
+
376
+ rl.on('close', () => {
377
+ console.log(`\n${ui.infoTag()} bye! 👋`);
378
+ process.exit(0);
379
+ });
380
+
381
+ promptFn();
382
+
383
+ rl.on('line', async (raw) => {
384
+ const line = raw.replace(/\r$/, '').trim();
385
+ if (!line) {
386
+ promptFn();
387
+ return;
388
+ }
389
+
390
+ if (line.startsWith('/')) {
391
+ try {
392
+ const handled = await commands.dispatch(ctx, line, helpers);
393
+ if (!handled) {
394
+ await handleChat(ctx, line);
395
+ }
396
+ } catch (err) {
397
+ handleError(err);
398
+ }
399
+ promptFn();
400
+ return;
401
+ }
402
+
403
+ try {
404
+ await handleChat(ctx, line);
405
+ } catch (err) {
406
+ handleError(err);
407
+ }
408
+ promptFn();
409
+ });
410
+ }
411
+
412
+ async function handleChat(ctx, content) {
413
+ ctx.history.push({ role: 'user', content });
414
+
415
+ // Strategi: kalau ada session id, kirim cuma user message terbaru — server
416
+ // yang ngurus history. Kalau belum, sertain tail history terakhir biar AI
417
+ // tetap punya konteks.
418
+ let messages;
419
+ if (ctx.sessionId) {
420
+ messages = [{ role: 'user', content }];
421
+ } else {
422
+ messages = ctx.history.slice(-12);
423
+ }
424
+
425
+ const spinner = ora({ text: ' mikir…', color: 'magenta' }).start();
426
+ let res;
427
+ try {
428
+ res = await ctx.client.sendAgent({
429
+ messages,
430
+ model: ctx.config.model,
431
+ endpoint: ctx.config.endpoint,
432
+ system: ctx.config.system,
433
+ sessionId: ctx.sessionId,
434
+ });
435
+ } catch (err) {
436
+ spinner.stop();
437
+ ctx.history.pop();
438
+ throw err;
439
+ }
440
+ spinner.stop();
441
+
442
+ const reply = res.reply || '(gak ada balasan)';
443
+ ctx.history.push({ role: 'assistant', content: reply });
444
+
445
+ // Persist ke ./session/<id>.json
446
+ const previousId = ctx.sessionId;
447
+ const newId = res.sessionId || ctx.sessionId || session.localId();
448
+ if (previousId && res.sessionId && previousId !== res.sessionId) {
449
+ session.rename(previousId, res.sessionId);
450
+ }
451
+ ctx.sessionId = newId;
452
+ session.append(newId, { role: 'user', content }, {
453
+ model: ctx.config.model,
454
+ endpoint: ctx.config.endpoint,
455
+ system: ctx.config.system,
456
+ });
457
+ session.append(newId, { role: 'assistant', content: reply });
458
+ config.update({ lastSession: newId });
459
+
460
+ console.log(`${ui.assistantTag(ctx.config.model)}`);
461
+ console.log(ui.renderMarkdown(reply));
462
+ }
463
+
464
+ async function warnQuotaSoft(client, apiKey) {
465
+ if (!apiKey) return;
466
+ let usage;
467
+ try {
468
+ usage = await client.getUsage(apiKey);
469
+ } catch {
470
+ return; // soft check — diem aja kalau error
471
+ }
472
+ const info = extractQuotaInfo(usage);
473
+ if (!info) return;
474
+ if (info.exhausted) {
475
+ console.log(ui.limitWarning(info));
476
+ return;
477
+ }
478
+ if (info.used !== undefined && info.limit && info.limit > 0) {
479
+ const ratio = info.used / info.limit;
480
+ if (ratio >= 0.9) {
481
+ console.log(`${ui.warnTag()} pemakaian udah ${(ratio * 100).toFixed(0)}% (${info.used}/${info.limit}). Hati-hati ya.`);
482
+ }
483
+ }
484
+ }
485
+
486
+ function extractQuotaInfo(data) {
487
+ if (!data || typeof data !== 'object') return null;
488
+ const root = data.data && typeof data.data === 'object' ? data.data : data;
489
+ const used = num(root.used) ?? num(root.usage) ?? num(root.count) ?? num(root.requests);
490
+ const limit = num(root.limit) ?? num(root.quota) ?? num(root.max);
491
+ const plan = root.plan || root.tier;
492
+ const resetAt = root.reset || root.resetAt || root.resets_at;
493
+ const exhausted = root.exhausted === true
494
+ || (used !== undefined && limit !== undefined && used >= limit && limit > 0);
495
+ if (used === undefined && limit === undefined && !exhausted) return null;
496
+ return { used, limit, plan, resetAt, exhausted };
497
+ }
498
+
499
+ function num(v) {
500
+ if (v === undefined || v === null || v === '') return undefined;
501
+ const n = typeof v === 'number' ? v : parseFloat(v);
502
+ return Number.isFinite(n) ? n : undefined;
503
+ }
504
+
505
+ function handleError(err) {
506
+ if (err instanceof LimitExceededError) {
507
+ console.log(ui.limitWarning(err.info || {}));
508
+ return;
509
+ }
510
+ console.log(`${ui.errorTag()} ${err.message}`);
511
+ if (process.env.ZELAI_DEBUG && err.stack) {
512
+ console.log(ui.chalk.gray(err.stack));
513
+ }
514
+ }
515
+
516
+ /**
517
+ * readline `question` dengan optional masking buat API key.
518
+ */
519
+ function askMasked(rl, prompt, opts = {}) {
520
+ return new Promise((resolve) => {
521
+ if (!opts.mask) {
522
+ rl.question(prompt, (ans) => resolve(ans));
523
+ return;
524
+ }
525
+ const stdin = process.stdin;
526
+ const stdout = process.stdout;
527
+ stdout.write(prompt);
528
+ let buf = '';
529
+ const onData = (chunk) => {
530
+ const s = chunk.toString('utf8');
531
+ for (const ch of s) {
532
+ if (ch === '\n' || ch === '\r') {
533
+ stdin.removeListener('data', onData);
534
+ stdout.write('\n');
535
+ resolve(buf);
536
+ return;
537
+ }
538
+ if (ch === '') { // Ctrl+C
539
+ stdin.removeListener('data', onData);
540
+ stdout.write('\n');
541
+ process.exit(130);
542
+ }
543
+ if (ch === '' || ch === '\b') {
544
+ if (buf.length > 0) {
545
+ buf = buf.slice(0, -1);
546
+ stdout.write('\b \b');
547
+ }
548
+ continue;
549
+ }
550
+ buf += ch;
551
+ stdout.write('•');
552
+ }
553
+ };
554
+ stdin.on('data', onData);
555
+ });
556
+ }
557
+
558
+ function errorExit(msg) {
559
+ console.log(`${ui.errorTag()} ${msg}`);
560
+ process.exit(1);
561
+ }
562
+
563
+ module.exports = { main };