zelai-cli 1.1.0 → 1.2.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/bin/zelai.js CHANGED
@@ -5,16 +5,37 @@
5
5
  */
6
6
  'use strict';
7
7
 
8
- require('../src/cli').main(process.argv.slice(2)).catch((err) => {
9
- // Last-resort error handler so uncaught failures don't leak a stack trace.
10
- const chalk = (() => {
11
- try { return require('chalk'); } catch { return null; }
12
- })();
13
- const tag = chalk ? chalk.red.bold('✖ fatal') : '✖ fatal';
14
- const msg = err && err.message ? err.message : String(err);
8
+ // Track interactive mode — kalau true, error apapun jangan exit, cukup print.
9
+ global.__ZELAI_INTERACTIVE__ = false;
10
+
11
+ function safeStringify(err) {
12
+ if (!err) return 'unknown error';
13
+ if (err.message) return err.message;
14
+ try { return JSON.stringify(err); } catch { return String(err); }
15
+ }
16
+
17
+ function logErr(label, err) {
18
+ let chalk;
19
+ try { chalk = require('chalk'); } catch {}
20
+ const tag = chalk ? chalk.red.bold('✖ ' + label) : '✖ ' + label;
21
+ const msg = safeStringify(err);
15
22
  process.stderr.write(`${tag} ${msg}\n`);
16
23
  if (process.env.ZELAI_DEBUG && err && err.stack) {
17
24
  process.stderr.write(err.stack + '\n');
18
25
  }
19
- process.exit(1);
26
+ }
27
+
28
+ process.on('uncaughtException', (err) => {
29
+ logErr('uncaught', err);
30
+ if (!global.__ZELAI_INTERACTIVE__) process.exit(1);
31
+ });
32
+
33
+ process.on('unhandledRejection', (err) => {
34
+ logErr('rejection', err);
35
+ if (!global.__ZELAI_INTERACTIVE__) process.exit(1);
36
+ });
37
+
38
+ require('../src/cli').main(process.argv.slice(2)).catch((err) => {
39
+ logErr('fatal', err);
40
+ if (!global.__ZELAI_INTERACTIVE__) process.exit(1);
20
41
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zelai-cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Zelai — official CLI & SDK for the Zelapi Agent API. Chat with AI right from your terminal, Discord, or Telegram. Multi-turn sessions, slash commands, limit warnings, and a built-in Discord/Telegram connector.",
5
5
  "keywords": [
6
6
  "zelai",
package/src/cli.js CHANGED
@@ -11,6 +11,8 @@ const config = require('./config');
11
11
  const { makeClient, LimitExceededError } = require('./client');
12
12
  const commands = require('./commands');
13
13
  const session = require('./session');
14
+ const themeMod = require('./theme');
15
+ const versionMod = require('./version');
14
16
  const ui = require('./ui');
15
17
 
16
18
  const VERSION = require('../package.json').version;
@@ -105,6 +107,9 @@ async function main(argv) {
105
107
  if (args.flags.endpoint) cfg.endpoint = args.flags.endpoint;
106
108
  if (args.flags.system) cfg.system = args.flags.system;
107
109
 
110
+ // Apply theme yang udah dipilih user (atau default 'dark').
111
+ themeMod.setTheme(cfg.theme || 'dark');
112
+
108
113
  const sub = args._[0];
109
114
 
110
115
  if (sub === 'login') {
@@ -184,12 +189,18 @@ async function main(argv) {
184
189
  return;
185
190
  }
186
191
 
187
- // Kalau belum punya API key — jalanin login interaktif dulu.
192
+ // Kalau belum punya API key — jalanin login interaktif dulu, terus theme picker.
193
+ const isFirstLogin = !cfg.apiKey;
188
194
  if (!cfg.apiKey) {
189
195
  if (!args.flags.noBanner) console.log(ui.banner());
190
196
  console.log(`${ui.systemTag()} kayaknya kamu belum login. Yuk masukin API key dulu.`);
191
197
  await runLogin(cfg, null);
192
198
  cfg = config.load();
199
+ if (cfg.apiKey) {
200
+ await pickThemeInteractive();
201
+ cfg = config.load();
202
+ themeMod.setTheme(cfg.theme || 'dark');
203
+ }
193
204
  }
194
205
 
195
206
  // One-shot mode lewat -p.
@@ -227,7 +238,64 @@ async function runOneShot(fn) {
227
238
  await fn();
228
239
  } catch (err) {
229
240
  handleError(err);
230
- process.exit(1);
241
+ if (!global.__ZELAI_INTERACTIVE__) process.exit(1);
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Tanya user mau theme apa setelah login pertama.
247
+ * Pilihan: dark (default), night.
248
+ */
249
+ async function pickThemeInteractive() {
250
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
251
+ try {
252
+ console.log('');
253
+ console.log(' ' + ui.chalk.bold('🎨 Pilih warna terminal:'));
254
+ const themes = themeMod.list();
255
+ themes.forEach((name, i) => {
256
+ const lbl = themeMod.describe(name);
257
+ console.log(' ' + ui.chalk.yellow(`${i + 1}.`) + ' ' + ui.chalk.cyan.bold(name.padEnd(8)) + ui.chalk.gray('— ' + lbl));
258
+ });
259
+ const ans = await askMasked(rl, ` ${ui.systemTag()} pilih [${themes.join('/')}] (default: dark): `);
260
+ const choice = (ans || '').trim().toLowerCase();
261
+ let pick = 'dark';
262
+ if (themes.includes(choice)) pick = choice;
263
+ else if (/^\d+$/.test(choice)) {
264
+ const idx = parseInt(choice, 10) - 1;
265
+ if (themes[idx]) pick = themes[idx];
266
+ }
267
+ themeMod.setTheme(pick);
268
+ config.update({ theme: pick });
269
+ console.log(` ${ui.okTag()} theme ${ui.chalk.cyan(pick)} ${ui.chalk.gray('— ' + themeMod.describe(pick))}`);
270
+ console.log('');
271
+ } finally {
272
+ rl.close();
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Cek versi terbaru dari npm registry (best-effort, async + cached 24h).
278
+ * Kalau current behind → print warning vulnerable.
279
+ */
280
+ async function checkVersionWarning() {
281
+ const cur = VERSION;
282
+ const cfg = config.load();
283
+ const DAY = 24 * 60 * 60 * 1000;
284
+ let latest = cfg.latestKnownVersion;
285
+ const stale = !cfg.lastVersionCheck || (Date.now() - cfg.lastVersionCheck) > DAY;
286
+ if (stale) {
287
+ try {
288
+ const fetched = await versionMod.getLatest(3000);
289
+ if (fetched) {
290
+ latest = fetched;
291
+ config.update({ latestKnownVersion: fetched, lastVersionCheck: Date.now() });
292
+ }
293
+ } catch {
294
+ // ignored
295
+ }
296
+ }
297
+ if (latest && versionMod.isBehind(cur, latest)) {
298
+ console.log(ui.vulnerableWarning(cur, latest));
231
299
  }
232
300
  }
233
301
 
@@ -323,10 +391,16 @@ async function runPrompt(cfg, client, prompt, sessionId) {
323
391
  }
324
392
 
325
393
  async function runInteractive(cfg, args) {
394
+ // Mark interactive mode so global error guards in bin/zelai.js gak ngebunuh process.
395
+ global.__ZELAI_INTERACTIVE__ = true;
396
+
326
397
  if (!args.flags.noBanner) {
327
398
  console.log(ui.banner());
328
399
  }
329
400
 
401
+ // Version check warning (vulnerable kalo behind) — async, non-blocking.
402
+ checkVersionWarning().catch(() => {});
403
+
330
404
  const client = makeClient(cfg);
331
405
  const ctx = {
332
406
  config: cfg,
@@ -334,6 +408,7 @@ async function runInteractive(cfg, args) {
334
408
  history: [],
335
409
  sessionId: args.flags.session || cfg.lastSession || null,
336
410
  rl: null,
411
+ connectors: {},
337
412
  };
338
413
 
339
414
  // Auto-reload history dari file sesi sebelumnya kalau ada.
@@ -364,7 +439,10 @@ async function runInteractive(cfg, args) {
364
439
  };
365
440
 
366
441
  const promptFn = () => {
367
- rl.setPrompt(ui.userPrompt(ctx.config.model));
442
+ const modelLabel = ctx.config.modelDisabled ? 'auto' : ctx.config.model;
443
+ // Chat box: tulis baris atas dulu, terus prompt
444
+ console.log(ui.userBoxOpen(modelLabel, { model: modelLabel }));
445
+ rl.setPrompt(ui.userPrompt(modelLabel, { disabledHint: ctx.config.modelDisabled || ctx.config.systemDisabled }));
368
446
  rl.prompt();
369
447
  };
370
448
 
@@ -387,6 +465,7 @@ async function runInteractive(cfg, args) {
387
465
  return;
388
466
  }
389
467
 
468
+ // SEMUA branch wrapped — REPL never exit on error.
390
469
  if (line.startsWith('/')) {
391
470
  try {
392
471
  const handled = await commands.dispatch(ctx, line, helpers);
@@ -427,15 +506,17 @@ async function handleChat(ctx, content) {
427
506
  try {
428
507
  res = await ctx.client.sendAgent({
429
508
  messages,
430
- model: ctx.config.model,
509
+ // Kalau di-disable, kirim null biar payload gak include field-nya.
510
+ model: ctx.config.modelDisabled ? null : ctx.config.model,
431
511
  endpoint: ctx.config.endpoint,
432
- system: ctx.config.system,
512
+ system: ctx.config.systemDisabled ? null : ctx.config.system,
433
513
  sessionId: ctx.sessionId,
434
514
  });
435
515
  } catch (err) {
436
516
  spinner.stop();
437
517
  ctx.history.pop();
438
- throw err;
518
+ handleError(err);
519
+ return; // INTERACTIVE: gak exit, balik ke prompt
439
520
  }
440
521
  spinner.stop();
441
522
 
@@ -450,15 +531,18 @@ async function handleChat(ctx, content) {
450
531
  }
451
532
  ctx.sessionId = newId;
452
533
  session.append(newId, { role: 'user', content }, {
453
- model: ctx.config.model,
534
+ model: ctx.config.modelDisabled ? null : ctx.config.model,
454
535
  endpoint: ctx.config.endpoint,
455
- system: ctx.config.system,
536
+ system: ctx.config.systemDisabled ? null : ctx.config.system,
456
537
  });
457
538
  session.append(newId, { role: 'assistant', content: reply });
458
539
  config.update({ lastSession: newId });
459
540
 
460
- console.log(`${ui.assistantTag(ctx.config.model)}`);
461
- console.log(ui.renderMarkdown(reply));
541
+ // Render assistant bubble.
542
+ const modelLabel = ctx.config.modelDisabled ? 'auto' : ctx.config.model;
543
+ console.log(ui.assistantBoxOpen(modelLabel));
544
+ console.log(ui.gutterIndent(ui.renderMarkdown(reply)));
545
+ console.log(ui.assistantBoxClose());
462
546
  }
463
547
 
464
548
  async function warnQuotaSoft(client, apiKey) {
package/src/client.js CHANGED
@@ -90,6 +90,7 @@ function makeClient(config) {
90
90
  endpoint: endpoint || '/ai/claila',
91
91
  messages: msgs,
92
92
  };
93
+ // model & system di-omit dari payload kalau null/empty (lihat /model:disable, /system:disable).
93
94
  if (model) payload.model = model;
94
95
  if (system) payload.system = system;
95
96
 
package/src/commands.js CHANGED
@@ -1,6 +1,14 @@
1
1
  /**
2
2
  * Slash command registry.
3
- * Setiap handler dipanggil dengan (ctx, args, helpers) — ctx = session state dari cli.js.
3
+ *
4
+ * Parser support 2 syntax:
5
+ * /cmd arg1 arg2 — normal
6
+ * /cmd:arg1 arg2 arg3 — colon-form (e.g. /system:disable, /telegram:<token> <chatid>)
7
+ *
8
+ * Setiap handler dipanggil dengan (ctx, args, helpers).
9
+ * ctx : session state dari cli.js (config, history, sessionId, client, rl, …)
10
+ * args : string sisa setelah nama command
11
+ * helpers : { ask, …, spawnTelegram, spawnDiscord }
4
12
  */
5
13
  'use strict';
6
14
 
@@ -8,27 +16,38 @@ const ora = require('ora');
8
16
  const ui = require('./ui');
9
17
  const config = require('./config');
10
18
  const session = require('./session');
19
+ const versionMod = require('./version');
20
+ const themeMod = require('./theme');
11
21
  const { LimitExceededError } = require('./client');
12
22
 
13
23
  const HELP_ROWS = [
14
- ['/help', 'tampilin daftar command'],
15
- ['/status', 'cek status server (/api/status)'],
16
- ['/activity', 'tampilin activity logs akun kamu'],
17
- ['/usage', 'tampilin pemakaian / kuota API key'],
18
- ['/model', 'ganti model opus | sonnet | haiku'],
19
- ['/endpoint', 'ganti AI endpoint, mis: /ai/claila, /ai/spawn'],
20
- ['/system', 'set system prompt; kosong → tampilin current'],
21
- ['/new', 'mulai sesi baru (lupain history)'],
22
- ['/session', 'tampilin / set session ID secara manual'],
23
- ['/sessions', 'list semua sesi di folder ./session'],
24
- ['/load', 'load sesi tersimpan: /load <id|nomor>'],
25
- ['/forget', 'hapus sesi tersimpan: /forget <id|nomor|all>'],
26
- ['/save', 'paksa simpan sesi sekarang ke ./session'],
27
- ['/login', 'set / ganti API key (zel_…)'],
28
- ['/logout', 'hapus API key tersimpan'],
29
- ['/config', 'tampilin lokasi & isi config'],
30
- ['/clear', 'bersihin layar'],
31
- ['/exit', 'keluar (alias: /quit, /q)'],
24
+ ['/help', 'tampilin daftar command'],
25
+ ['/status', 'cek status server (/api/status)'],
26
+ ['/activity', 'tampilin activity logs akun kamu'],
27
+ ['/usage', 'tampilin pemakaian / kuota API key'],
28
+ ['/model', 'ganti model: /model opus|sonnet|haiku'],
29
+ ['/model:disable', 'matikan model di payload (server default)'],
30
+ ['/model:enable', 'nyalain lagi model di payload'],
31
+ ['/endpoint', 'ganti AI endpoint, mis: /ai/claila, /ai/spawn'],
32
+ ['/system', 'set system prompt; kosong tampilin current'],
33
+ ['/system:disable', 'matikan system prompt di payload'],
34
+ ['/system:enable', 'nyalain lagi system prompt di payload'],
35
+ ['/theme', 'ganti tema: /theme dark|night'],
36
+ ['/new', 'mulai sesi baru (lupain history)'],
37
+ ['/session', 'tampilin / set session ID secara manual'],
38
+ ['/sessions', 'list semua sesi di folder ./session'],
39
+ ['/load', 'load sesi tersimpan: /load <id|nomor>'],
40
+ ['/forget', 'hapus sesi tersimpan: /forget <id|nomor|all>'],
41
+ ['/save', 'paksa simpan sesi sekarang ke ./session'],
42
+ ['/telegram:<token>','konek bot Telegram: /telegram:<TOKEN> [chatid]'],
43
+ ['/discord:<token>', 'konek bot Discord: /discord:<TOKEN>'],
44
+ ['/disconnect', 'matiin semua connector aktif'],
45
+ ['/update', 'update zelai-cli ke versi terbaru lewat npm'],
46
+ ['/login', 'set / ganti API key (zel_…)'],
47
+ ['/logout', 'hapus API key tersimpan'],
48
+ ['/config', 'tampilin lokasi & isi config'],
49
+ ['/clear', 'bersihin layar'],
50
+ ['/exit', 'keluar (alias: /quit, /q)'],
32
51
  ];
33
52
 
34
53
  async function cmdHelp() {
@@ -36,7 +55,7 @@ async function cmdHelp() {
36
55
  console.log(ui.chalk.bold('Slash Commands'));
37
56
  console.log('');
38
57
  for (const [k, v] of HELP_ROWS) {
39
- console.log(' ' + ui.chalk.yellow(k.padEnd(11)) + ui.chalk.gray('· ') + ui.chalk.white(v));
58
+ console.log(' ' + ui.chalk.yellow(k.padEnd(20)) + ui.chalk.gray('· ') + ui.chalk.white(v));
40
59
  }
41
60
  console.log('');
42
61
  console.log(ui.chalk.gray(' Tip: ketik biasa tanpa "/" buat ngobrol sama AI.'));
@@ -49,14 +68,14 @@ async function cmdStatus(ctx) {
49
68
  const data = await ctx.client.getStatus();
50
69
  spinner.stop();
51
70
  console.log(ui.hr());
52
- console.log(ui.chalk.bold('Server Status') + ui.chalk.gray(' · ') + ui.chalk.cyan.underline('https://zelapioffciall.dpdns.org/api/status'));
71
+ console.log(ui.chalk.bold('Server Status') + ui.chalk.gray(' · ') + ui.theme.current().link('https://zelapioffciall.dpdns.org/api/status'));
53
72
  console.log('');
54
73
  if (typeof data === 'object' && data !== null) {
55
- const known = ['status', 'state', 'health', 'uptime', 'version', 'region', 'latency', 'node', 'env'];
74
+ const known = ['status', 'state', 'health', 'uptime', 'version', 'region', 'latency', 'node', 'env', 'runtime'];
56
75
  const root = data.data && typeof data.data === 'object' ? data.data : data;
57
76
  let printed = 0;
58
77
  for (const k of known) {
59
- if (root[k] !== undefined) {
78
+ if (root[k] !== undefined && typeof root[k] !== 'object') {
60
79
  const v = root[k];
61
80
  const value = (k === 'status' || k === 'state' || k === 'health')
62
81
  ? (/ok|up|online|healthy|stable/i.test(String(v)) ? ui.chalk.green.bold(String(v)) : ui.chalk.yellow(String(v)))
@@ -65,13 +84,18 @@ async function cmdStatus(ctx) {
65
84
  printed++;
66
85
  }
67
86
  }
68
- if (printed === 0) {
69
- console.log(ui.chalk.gray(ui.pretty(data)));
70
- } else {
87
+ if (Array.isArray(root.targets) || Array.isArray(data.targets)) {
88
+ const targets = root.targets || data.targets;
71
89
  console.log('');
72
- console.log(ui.chalk.gray(' raw:'));
73
- console.log(ui.chalk.gray(indent(ui.pretty(data), 2)));
90
+ console.log(' ' + ui.chalk.bold('targets:'));
91
+ for (const t of targets) {
92
+ const dot = t.online ? ui.chalk.green('●') : ui.chalk.red('●');
93
+ console.log(' ' + dot + ' ' + ui.chalk.white((t.name || t.id || '').padEnd(20)) +
94
+ ui.chalk.gray(' · ping ') + ui.chalk.cyan(String(t.ping ?? '—') + 'ms'));
95
+ }
96
+ printed++;
74
97
  }
98
+ if (printed === 0) console.log(ui.chalk.gray(ui.pretty(data)));
75
99
  } else {
76
100
  console.log(String(data));
77
101
  }
@@ -155,15 +179,26 @@ function maybeShowQuotaBar(flat) {
155
179
 
156
180
  async function cmdModel(ctx, args) {
157
181
  const next = (args || '').trim().toLowerCase();
182
+ if (next === 'disable') {
183
+ ctx.config = config.update({ modelDisabled: true });
184
+ console.log(`${ui.okTag()} model param dimatikan — payload bakal di-kirim tanpa field model.`);
185
+ return;
186
+ }
187
+ if (next === 'enable') {
188
+ ctx.config = config.update({ modelDisabled: false });
189
+ console.log(`${ui.okTag()} model param dinyalain lagi — sekarang pake ${ui.chalk.cyan(ctx.config.model)}.`);
190
+ return;
191
+ }
158
192
  if (!next) {
159
- console.log(`${ui.infoTag()} model sekarang: ${ui.chalk.cyan(ctx.config.model)} ${ui.chalk.gray(' pilihan: opus, sonnet, haiku')}`);
193
+ const status = ctx.config.modelDisabled ? ui.chalk.gray('(disabled)') : '';
194
+ console.log(`${ui.infoTag()} model: ${ui.chalk.cyan(ctx.config.model)} ${status} ${ui.chalk.gray('— pilihan: opus, sonnet, haiku · /model:disable buat matiin')}`);
160
195
  return;
161
196
  }
162
197
  if (!['opus', 'sonnet', 'haiku'].includes(next)) {
163
198
  console.log(`${ui.errorTag()} model gak dikenal. Pilih: opus | sonnet | haiku`);
164
199
  return;
165
200
  }
166
- ctx.config = config.update({ model: next });
201
+ ctx.config = config.update({ model: next, modelDisabled: false });
167
202
  console.log(`${ui.okTag()} model diganti ke ${ui.chalk.cyan(next)}`);
168
203
  }
169
204
 
@@ -183,15 +218,41 @@ async function cmdEndpoint(ctx, args) {
183
218
 
184
219
  async function cmdSystem(ctx, args) {
185
220
  const next = (args || '').trim();
221
+ if (next === 'disable') {
222
+ ctx.config = config.update({ systemDisabled: true });
223
+ console.log(`${ui.okTag()} system prompt dimatikan — payload bakal di-kirim tanpa field system.`);
224
+ return;
225
+ }
226
+ if (next === 'enable') {
227
+ ctx.config = config.update({ systemDisabled: false });
228
+ console.log(`${ui.okTag()} system prompt dinyalain lagi.`);
229
+ return;
230
+ }
186
231
  if (!next) {
187
- console.log(`${ui.infoTag()} system prompt saat ini:`);
232
+ const status = ctx.config.systemDisabled ? ui.chalk.gray('(disabled)') : '';
233
+ console.log(`${ui.infoTag()} system prompt ${status}:`);
188
234
  console.log(ui.chalk.gray(' ' + (ctx.config.system || '(kosong)')));
189
235
  return;
190
236
  }
191
- ctx.config = config.update({ system: next });
237
+ ctx.config = config.update({ system: next, systemDisabled: false });
192
238
  console.log(`${ui.okTag()} system prompt updated.`);
193
239
  }
194
240
 
241
+ async function cmdTheme(ctx, args) {
242
+ const next = (args || '').trim().toLowerCase();
243
+ if (!next) {
244
+ const cur = themeMod.current().name;
245
+ console.log(`${ui.infoTag()} theme sekarang: ${ui.chalk.cyan(cur)} ${ui.chalk.gray('— pilihan: ' + themeMod.list().join(', '))}`);
246
+ return;
247
+ }
248
+ if (!themeMod.setTheme(next)) {
249
+ console.log(`${ui.errorTag()} theme "${next}" gak dikenal. Pilih: ${themeMod.list().join(' | ')}`);
250
+ return;
251
+ }
252
+ ctx.config = config.update({ theme: next });
253
+ console.log(`${ui.okTag()} theme diganti ke ${ui.chalk.cyan(next)} ${ui.chalk.gray('— ' + themeMod.describe(next))}`);
254
+ }
255
+
195
256
  async function cmdNew(ctx) {
196
257
  ctx.history = [];
197
258
  ctx.sessionId = null;
@@ -249,7 +310,6 @@ function resolveSessionRef(ctx, ref) {
249
310
  if (!ref) return null;
250
311
  const trimmed = ref.trim();
251
312
  if (!trimmed) return null;
252
- // Number → from last listing
253
313
  if (/^\d+$/.test(trimmed)) {
254
314
  const idx = parseInt(trimmed, 10) - 1;
255
315
  const rows = ctx._lastSessionsList || session.list();
@@ -357,11 +417,12 @@ async function cmdConfig(ctx) {
357
417
  console.log(ui.kv('path', config.configPath()));
358
418
  console.log(ui.kv('sessionDir', session.sessionDir()));
359
419
  console.log(ui.kv('apiKey', ctx.config.apiKey ? maskKey(ctx.config.apiKey) : '(belum di-set)'));
360
- console.log(ui.kv('model', ctx.config.model));
420
+ console.log(ui.kv('theme', ctx.config.theme || 'dark'));
421
+ console.log(ui.kv('model', ctx.config.modelDisabled ? '(disabled)' : ctx.config.model));
361
422
  console.log(ui.kv('endpoint', ctx.config.endpoint));
362
423
  console.log(ui.kv('baseUrl', ctx.config.baseUrl));
363
424
  console.log(ui.kv('session', ctx.sessionId || '(belum ada)'));
364
- console.log(ui.kv('system', truncate(ctx.config.system || '', 60)));
425
+ console.log(ui.kv('system', ctx.config.systemDisabled ? '(disabled)' : truncate(ctx.config.system || '', 60)));
365
426
  console.log(ui.hr());
366
427
  }
367
428
 
@@ -371,10 +432,154 @@ async function cmdClear() {
371
432
 
372
433
  async function cmdExit(ctx) {
373
434
  console.log(`${ui.infoTag()} bye! 👋`);
435
+ if (ctx.connectors) {
436
+ for (const c of Object.values(ctx.connectors)) {
437
+ try { c.child && c.child.kill(); } catch {}
438
+ }
439
+ }
374
440
  if (ctx.rl) ctx.rl.close();
375
441
  process.exit(0);
376
442
  }
377
443
 
444
+ /* ----- /update ----- */
445
+
446
+ async function cmdUpdate(ctx) {
447
+ console.log(`${ui.infoTag()} ngecek versi terbaru di npm…`);
448
+ const latest = await versionMod.getLatest();
449
+ const cur = require('../package.json').version;
450
+ if (!latest) {
451
+ console.log(`${ui.warnTag()} gak bisa ngambil versi terbaru — coba cek koneksi.`);
452
+ return;
453
+ }
454
+ if (!versionMod.isBehind(cur, latest)) {
455
+ console.log(`${ui.okTag()} kamu udah pakai versi terbaru: ${ui.chalk.green.bold('v' + cur)}.`);
456
+ return;
457
+ }
458
+ console.log(`${ui.infoTag()} update ${ui.chalk.gray('v' + cur)} → ${ui.chalk.green.bold('v' + latest)}`);
459
+ console.log(ui.chalk.gray(' running: npm i -g zelai-cli@latest'));
460
+ console.log('');
461
+ const result = await versionMod.runUpdate((line) => {
462
+ if (line) console.log(ui.chalk.gray(' ' + line));
463
+ });
464
+ console.log('');
465
+ if (result.ok) {
466
+ console.log(`${ui.okTag()} update selesai. Restart ${ui.chalk.yellow('zelai')} buat pake versi baru.`);
467
+ } else {
468
+ console.log(`${ui.errorTag()} update gagal (exit ${result.code}). Coba manual: ${ui.chalk.yellow('npm i -g zelai-cli@latest')}`);
469
+ }
470
+ }
471
+
472
+ /* ----- connectors (in-process spawn) ----- */
473
+
474
+ async function cmdTelegram(ctx, args) {
475
+ // args = "<token> [chatid]" — sebelumnya colon-form udah di-flatten parser.
476
+ const parts = (args || '').trim().split(/\s+/).filter(Boolean);
477
+ const token = parts[0];
478
+ const chatId = parts[1];
479
+ if (!token || !token.includes(':')) {
480
+ console.log(`${ui.errorTag()} format: ${ui.chalk.yellow('/telegram:<BOT_TOKEN> [chat_id]')}`);
481
+ console.log(ui.chalk.gray(' contoh: /telegram:1234567:AAAA…token 987654321'));
482
+ return;
483
+ }
484
+ if (!ctx.config.apiKey) {
485
+ console.log(`${ui.errorTag()} login dulu (/login zel_…)`);
486
+ return;
487
+ }
488
+ if (!ctx.connectors) ctx.connectors = {};
489
+ if (ctx.connectors.telegram && !ctx.connectors.telegram.child.killed) {
490
+ console.log(`${ui.warnTag()} connector Telegram udah jalan — pakai /disconnect dulu.`);
491
+ return;
492
+ }
493
+
494
+ const { spawn } = require('child_process');
495
+ const path = require('path');
496
+ const env = {
497
+ ...process.env,
498
+ ZELAPI_KEY: ctx.config.apiKey,
499
+ TELEGRAM_TOKEN: token,
500
+ ZELAI_MODEL: ctx.config.modelDisabled ? '' : ctx.config.model,
501
+ ZELAI_ENDPOINT: ctx.config.endpoint,
502
+ ZELAI_SYSTEM: ctx.config.systemDisabled ? '' : ctx.config.system,
503
+ };
504
+ if (chatId) env.TELEGRAM_ALLOW = chatId;
505
+
506
+ const script = path.join(__dirname, '..', 'connector', 'telegram.js');
507
+ const child = spawn(process.execPath, [script], { env, stdio: ['ignore', 'pipe', 'pipe'] });
508
+ ctx.connectors.telegram = { child, token: maskKey(token), chatId };
509
+
510
+ console.log(`${ui.infoTag()} starting Telegram connector ${ui.chalk.gray('pid=' + child.pid)} ${ui.chalk.gray('token=' + maskKey(token))}${chatId ? ui.chalk.gray(' allow=' + chatId) : ''}`);
511
+
512
+ const prefix = ui.chalk.magenta('[telegram] ');
513
+ child.stdout.on('data', (b) => process.stdout.write(prefix + b.toString().replace(/\n(?!$)/g, '\n' + prefix)));
514
+ child.stderr.on('data', (b) => process.stdout.write(prefix + ui.chalk.red(b.toString()).replace(/\n(?!$)/g, '\n' + prefix)));
515
+ child.on('close', (code) => {
516
+ console.log(`${ui.infoTag()} Telegram connector exit (code ${code})`);
517
+ if (ctx.connectors) delete ctx.connectors.telegram;
518
+ });
519
+ }
520
+
521
+ async function cmdDiscord(ctx, args) {
522
+ const parts = (args || '').trim().split(/\s+/).filter(Boolean);
523
+ const token = parts[0];
524
+ if (!token) {
525
+ console.log(`${ui.errorTag()} format: ${ui.chalk.yellow('/discord:<BOT_TOKEN>')}`);
526
+ return;
527
+ }
528
+ if (!ctx.config.apiKey) {
529
+ console.log(`${ui.errorTag()} login dulu (/login zel_…)`);
530
+ return;
531
+ }
532
+ if (!ctx.connectors) ctx.connectors = {};
533
+ if (ctx.connectors.discord && !ctx.connectors.discord.child.killed) {
534
+ console.log(`${ui.warnTag()} connector Discord udah jalan — pakai /disconnect dulu.`);
535
+ return;
536
+ }
537
+
538
+ const { spawn } = require('child_process');
539
+ const path = require('path');
540
+ const env = {
541
+ ...process.env,
542
+ ZELAPI_KEY: ctx.config.apiKey,
543
+ DISCORD_TOKEN: token,
544
+ ZELAI_MODEL: ctx.config.modelDisabled ? '' : ctx.config.model,
545
+ ZELAI_ENDPOINT: ctx.config.endpoint,
546
+ ZELAI_SYSTEM: ctx.config.systemDisabled ? '' : ctx.config.system,
547
+ };
548
+ const script = path.join(__dirname, '..', 'connector', 'discord.js');
549
+ const child = spawn(process.execPath, [script], { env, stdio: ['ignore', 'pipe', 'pipe'] });
550
+ ctx.connectors.discord = { child, token: maskKey(token) };
551
+
552
+ console.log(`${ui.infoTag()} starting Discord connector ${ui.chalk.gray('pid=' + child.pid)} ${ui.chalk.gray('token=' + maskKey(token))}`);
553
+
554
+ const prefix = ui.chalk.cyan('[discord] ');
555
+ child.stdout.on('data', (b) => process.stdout.write(prefix + b.toString().replace(/\n(?!$)/g, '\n' + prefix)));
556
+ child.stderr.on('data', (b) => process.stdout.write(prefix + ui.chalk.red(b.toString()).replace(/\n(?!$)/g, '\n' + prefix)));
557
+ child.on('close', (code) => {
558
+ console.log(`${ui.infoTag()} Discord connector exit (code ${code})`);
559
+ if (ctx.connectors) delete ctx.connectors.discord;
560
+ });
561
+ }
562
+
563
+ async function cmdDisconnect(ctx, args) {
564
+ const target = (args || '').trim().toLowerCase();
565
+ if (!ctx.connectors || Object.keys(ctx.connectors).length === 0) {
566
+ console.log(`${ui.infoTag()} gak ada connector yang aktif.`);
567
+ return;
568
+ }
569
+ let killed = 0;
570
+ for (const [name, info] of Object.entries(ctx.connectors)) {
571
+ if (target && target !== name) continue;
572
+ try { info.child.kill(); killed++; } catch {}
573
+ }
574
+ if (killed === 0) {
575
+ console.log(`${ui.warnTag()} gak ada yang dimatiin ${target ? `(target "${target}")` : ''}.`);
576
+ } else {
577
+ console.log(`${ui.okTag()} ${killed} connector dimatiin.`);
578
+ }
579
+ }
580
+
581
+ /* ----- registry ----- */
582
+
378
583
  const REGISTRY = {
379
584
  '/help': cmdHelp,
380
585
  '/?': cmdHelp,
@@ -386,6 +591,7 @@ const REGISTRY = {
386
591
  '/model': cmdModel,
387
592
  '/endpoint': cmdEndpoint,
388
593
  '/system': cmdSystem,
594
+ '/theme': cmdTheme,
389
595
  '/new': cmdNew,
390
596
  '/reset': cmdNew,
391
597
  '/session': cmdSession,
@@ -394,6 +600,10 @@ const REGISTRY = {
394
600
  '/load': cmdLoad,
395
601
  '/forget': cmdForget,
396
602
  '/save': cmdSave,
603
+ '/telegram': cmdTelegram,
604
+ '/discord': cmdDiscord,
605
+ '/disconnect': cmdDisconnect,
606
+ '/update': cmdUpdate,
397
607
  '/login': cmdLogin,
398
608
  '/logout': cmdLogout,
399
609
  '/config': cmdConfig,
@@ -404,13 +614,28 @@ const REGISTRY = {
404
614
  '/q': cmdExit,
405
615
  };
406
616
 
617
+ /**
618
+ * Parse a slash command.
619
+ *
620
+ * /cmd → { name: '/cmd', args: '' }
621
+ * /cmd foo bar → { name: '/cmd', args: 'foo bar' }
622
+ * /cmd:foo → { name: '/cmd', args: 'foo' }
623
+ * /cmd:foo:bar → { name: '/cmd', args: 'foo:bar' }
624
+ * /cmd:foo:bar baz qux → { name: '/cmd', args: 'foo:bar baz qux' }
625
+ */
407
626
  function parse(line) {
408
627
  const trimmed = line.trim();
409
628
  if (!trimmed.startsWith('/')) return null;
629
+ // /word:rest... → split di colon pertama
630
+ const m = trimmed.match(/^\/([a-z?][a-z0-9?_-]*):(.*)$/i);
631
+ if (m) {
632
+ return { name: '/' + m[1].toLowerCase(), args: m[2].trim() };
633
+ }
634
+ // /cmd args
410
635
  const space = trimmed.indexOf(' ');
411
- const name = space === -1 ? trimmed : trimmed.slice(0, space);
636
+ const name = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase();
412
637
  const args = space === -1 ? '' : trimmed.slice(space + 1);
413
- return { name: name.toLowerCase(), args };
638
+ return { name, args };
414
639
  }
415
640
 
416
641
  async function dispatch(ctx, line, helpers) {
@@ -421,10 +646,16 @@ async function dispatch(ctx, line, helpers) {
421
646
  console.log(`${ui.errorTag()} command gak dikenal: ${parsed.name} — coba ${ui.chalk.yellow('/help')}`);
422
647
  return true;
423
648
  }
424
- await handler(ctx, parsed.args, helpers || {});
649
+ try {
650
+ await handler(ctx, parsed.args, helpers || {});
651
+ } catch (err) {
652
+ showError(err);
653
+ }
425
654
  return true;
426
655
  }
427
656
 
657
+ /* ----- shared helpers ----- */
658
+
428
659
  function renderListy(data, preferredKeys) {
429
660
  const items = Array.isArray(data)
430
661
  ? data
@@ -467,7 +698,7 @@ function showError(err) {
467
698
  console.log(ui.limitWarning(err.info || {}));
468
699
  return;
469
700
  }
470
- console.log(`${ui.errorTag()} ${err.message}`);
701
+ console.log(`${ui.errorTag()} ${err.message || err}`);
471
702
  }
472
703
 
473
704
  function formatVal(v) {
@@ -493,9 +724,4 @@ function maskKey(key) {
493
724
  return key.slice(0, 6) + '…' + key.slice(-4);
494
725
  }
495
726
 
496
- function indent(s, n) {
497
- const pad = ' '.repeat(n);
498
- return s.split('\n').map((l) => pad + l).join('\n');
499
- }
500
-
501
727
  module.exports = { dispatch, parse, REGISTRY };
package/src/config.js CHANGED
@@ -13,13 +13,20 @@ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
13
13
 
14
14
  const DEFAULTS = {
15
15
  apiKey: null,
16
- baseUrl: 'https://api.zelapioffciall.dpdns.org',
16
+ baseUrl: 'https://zelapioffciall.dpdns.org',
17
17
  statusUrl: 'https://zelapioffciall.dpdns.org/api/status',
18
18
  userBaseUrl: 'https://zelapioffciall.dpdns.org',
19
19
  model: 'haiku',
20
20
  endpoint: '/ai/claila',
21
21
  system: 'You are Zelai, a helpful and concise assistant. Reply in the same language as the user.',
22
+ // Toggle flag — kalau true, field gak dikirim ke payload meskipun ada value-nya.
23
+ modelDisabled: false,
24
+ systemDisabled: false,
22
25
  lastSession: null,
26
+ theme: 'dark',
27
+ // Soft cache buat version-check supaya gak hit registry setiap startup.
28
+ lastVersionCheck: 0,
29
+ latestKnownVersion: null,
23
30
  };
24
31
 
25
32
  function ensureDir() {
@@ -34,6 +41,10 @@ function load() {
34
41
  try {
35
42
  const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
36
43
  const parsed = JSON.parse(raw);
44
+ // Migrate baseUrl yang ngarah ke subdomain `api.` lama → root.
45
+ if (parsed.baseUrl === 'https://api.zelapioffciall.dpdns.org') {
46
+ parsed.baseUrl = 'https://zelapioffciall.dpdns.org';
47
+ }
37
48
  return { ...DEFAULTS, ...parsed };
38
49
  } catch {
39
50
  return { ...DEFAULTS };
package/src/theme.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Theme palette buat terminal — dipakai di seluruh UI helpers.
3
+ *
4
+ * Tiap theme nge-define palette warna untuk:
5
+ * primary, accent, dim, user, assistant, system, error, warn, ok, link
6
+ *
7
+ * Active theme dipilih lewat config (`theme: 'dark' | 'night'`).
8
+ */
9
+ 'use strict';
10
+
11
+ const chalk = require('chalk');
12
+
13
+ const THEMES = {
14
+ dark: {
15
+ name: 'dark',
16
+ label: 'Dark — magenta neon, classic Zelapi',
17
+ primary: chalk.magentaBright.bold,
18
+ accent: chalk.cyan,
19
+ dim: chalk.gray,
20
+ user: chalk.cyan.bold,
21
+ assistant: chalk.magentaBright.bold,
22
+ system: chalk.yellow.bold,
23
+ error: chalk.red.bold,
24
+ warn: chalk.yellow.bold,
25
+ ok: chalk.green.bold,
26
+ info: chalk.blueBright.bold,
27
+ link: chalk.cyan.underline,
28
+ box: chalk.magentaBright,
29
+ boxAccent: chalk.cyan,
30
+ },
31
+ night: {
32
+ name: 'night',
33
+ label: 'Night — soft blue & purple, easy on eyes',
34
+ primary: chalk.blueBright.bold,
35
+ accent: chalk.magenta,
36
+ dim: chalk.gray,
37
+ user: chalk.blueBright.bold,
38
+ assistant: chalk.magenta.bold,
39
+ system: chalk.cyan.bold,
40
+ error: chalk.red.bold,
41
+ warn: chalk.yellow,
42
+ ok: chalk.green,
43
+ info: chalk.cyan.bold,
44
+ link: chalk.blueBright.underline,
45
+ box: chalk.blue,
46
+ boxAccent: chalk.magenta,
47
+ },
48
+ };
49
+
50
+ let CURRENT = THEMES.dark;
51
+
52
+ function setTheme(name) {
53
+ const next = THEMES[name];
54
+ if (!next) return false;
55
+ CURRENT = next;
56
+ return true;
57
+ }
58
+
59
+ function current() {
60
+ return CURRENT;
61
+ }
62
+
63
+ function list() {
64
+ return Object.keys(THEMES);
65
+ }
66
+
67
+ function describe(name) {
68
+ return THEMES[name] ? THEMES[name].label : null;
69
+ }
70
+
71
+ module.exports = { setTheme, current, list, describe, THEMES };
package/src/ui.js CHANGED
@@ -1,10 +1,14 @@
1
1
  /**
2
- * UI helpers — banner, prompt, markdown renderer untuk terminal.
2
+ * UI helpers — banner, prompt, markdown renderer, chat box untuk terminal.
3
+ *
4
+ * Semua warna ngambil dari src/theme.js — jadi `dark`/`night` keubah secara
5
+ * konsisten di seluruh UI.
3
6
  */
4
7
  'use strict';
5
8
 
6
9
  const chalk = require('chalk');
7
10
  const { marked } = require('marked');
11
+ const theme = require('./theme');
8
12
 
9
13
  let TerminalRenderer;
10
14
  try {
@@ -32,29 +36,33 @@ if (TerminalRenderer) {
32
36
  }),
33
37
  });
34
38
  } catch {
35
- // ignored — falls back to plain text
39
+ // ignored — fall back to plain text
36
40
  }
37
41
  }
38
42
 
39
43
  const VERSION = require('../package.json').version;
40
44
 
45
+ function termWidth() {
46
+ return Math.min(process.stdout.columns || 80, 100);
47
+ }
48
+
41
49
  function banner() {
42
- const c = chalk.magentaBright;
43
- const accent = chalk.cyan;
44
- const dim = chalk.gray;
50
+ const t = theme.current();
51
+ const c = t.primary;
52
+ const accent = t.accent;
53
+ const dim = t.dim;
45
54
  const yellow = chalk.yellow;
46
55
 
47
- // ASCII ZELAI — block style. "by ZELAPI" subtitle on the side.
48
56
  const lines = [
49
57
  '',
50
- c.bold(' ███████╗███████╗██╗ █████╗ ██╗ '),
51
- c.bold(' ╚══███╔╝██╔════╝██║ ██╔══██╗██║ '),
52
- c.bold(' ███╔╝ █████╗ ██║ ███████║██║ ') + dim(' by ') + chalk.magenta.bold('ZELAPI'),
53
- c.bold(' ███╔╝ ██╔══╝ ██║ ██╔══██║██║ ') + dim(' v') + dim(VERSION),
54
- c.bold(' ███████╗███████╗███████╗██║ ██║██║ ') + dim(' Protocol V1'),
55
- c.bold(' ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ '),
58
+ c(' ███████╗███████╗██╗ █████╗ ██╗ '),
59
+ c(' ╚══███╔╝██╔════╝██║ ██╔══██╗██║ '),
60
+ c(' ███╔╝ █████╗ ██║ ███████║██║ ') + dim(' by ') + t.accent.bold('ZELAPI'),
61
+ c(' ███╔╝ ██╔══╝ ██║ ██╔══██║██║ ') + dim(' v') + dim(VERSION),
62
+ c(' ███████╗███████╗███████╗██║ ██║██║ ') + dim(' Protocol V1'),
63
+ c(' ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ '),
56
64
  '',
57
- ' ' + accent.bold('● ') + accent.bold('Zelai Agent CLI') + dim(' • ') + chalk.green('online') + dim(' • ') + accent.underline('zelapioffciall.dpdns.org'),
65
+ ' ' + accent.bold('● ') + accent.bold('Zelai Agent CLI') + dim(' • ') + chalk.green('online') + dim(' • ') + t.link('zelapioffciall.dpdns.org'),
58
66
  ' ' + dim('Ketik ') + yellow('/help') + dim(' buat lihat command. ') + yellow('/exit') + dim(' buat keluar. ') + yellow('Ctrl+D') + dim(' juga bisa.'),
59
67
  '',
60
68
  ];
@@ -62,9 +70,8 @@ function banner() {
62
70
  }
63
71
 
64
72
  function smallBanner() {
65
- const c = chalk.magentaBright;
66
- const dim = chalk.gray;
67
- return c.bold('zelai') + dim(' by ZELAPI · v' + VERSION);
73
+ const t = theme.current();
74
+ return t.primary('zelai') + t.dim(' by ZELAPI · v' + VERSION);
68
75
  }
69
76
 
70
77
  function renderMarkdown(text) {
@@ -77,42 +84,98 @@ function renderMarkdown(text) {
77
84
  }
78
85
  }
79
86
 
80
- function userPrompt(model) {
81
- const m = chalk.gray(`(${model})`);
82
- return `${chalk.cyan.bold('you')} ${m} ${chalk.gray('›')} `;
87
+ /* ----- chat box ----- */
88
+
89
+ /**
90
+ * Top of an input box. Render duluan sebelum readline prompt.
91
+ * ╭─[ you (haiku) ]──────────────────────
92
+ * │ »
93
+ */
94
+ function userBoxOpen(model, opts = {}) {
95
+ const t = theme.current();
96
+ const w = termWidth();
97
+ const label = ` you (${opts.model || model || 'haiku'}) `;
98
+ const filler = Math.max(w - label.length - 4, 0);
99
+ const top = t.boxAccent('╭─[') + t.user(label) + t.boxAccent(']' + '─'.repeat(filler));
100
+ return top;
101
+ }
102
+
103
+ function userPrompt(model, opts = {}) {
104
+ const t = theme.current();
105
+ const arrow = opts.disabledHint ? t.warn('»') : t.user('»');
106
+ return `${t.boxAccent('│')} ${arrow} `;
107
+ }
108
+
109
+ /**
110
+ * Open of an assistant bubble.
111
+ * ╭─[ zelai (haiku) ]──────────────────────
112
+ * │ markdown content here
113
+ * │ ...
114
+ * ╰──────────────────────────────────────
115
+ */
116
+ function assistantBoxOpen(model) {
117
+ const t = theme.current();
118
+ const w = termWidth();
119
+ const label = ` zelai (${model}) `;
120
+ const filler = Math.max(w - label.length - 4, 0);
121
+ return t.box('╭─[') + t.assistant(label) + t.box(']' + '─'.repeat(filler));
122
+ }
123
+
124
+ function assistantBoxClose() {
125
+ const t = theme.current();
126
+ return t.box('╰' + '─'.repeat(Math.max(termWidth() - 1, 0)));
127
+ }
128
+
129
+ /**
130
+ * Indent each non-empty line with the "│ " gutter.
131
+ */
132
+ function gutterIndent(text) {
133
+ const t = theme.current();
134
+ const g = t.box('│') + ' ';
135
+ if (!text) return g;
136
+ return text.split('\n').map((l) => g + l).join('\n');
83
137
  }
84
138
 
139
+ /* ----- tags / hints ----- */
140
+
85
141
  function assistantTag(model) {
86
- return chalk.magentaBright.bold('zelai') + chalk.gray(`(${model})`) + chalk.gray(' ›');
142
+ const t = theme.current();
143
+ return t.assistant('zelai') + t.dim(`(${model})`) + t.dim(' ›');
87
144
  }
88
145
 
89
146
  function systemTag() {
90
- return chalk.yellow.bold('system') + chalk.gray(' ›');
147
+ const t = theme.current();
148
+ return t.system('system') + t.dim(' ›');
91
149
  }
92
150
 
93
151
  function errorTag() {
94
- return chalk.red.bold('error') + chalk.gray(' ›');
152
+ const t = theme.current();
153
+ return t.error('error') + t.dim(' ›');
95
154
  }
96
155
 
97
156
  function warnTag() {
98
- return chalk.yellow.bold('⚠ warning') + chalk.gray(' ›');
157
+ const t = theme.current();
158
+ return t.warn('⚠ warning') + t.dim(' ›');
99
159
  }
100
160
 
101
161
  function infoTag() {
102
- return chalk.blueBright.bold('info') + chalk.gray(' ›');
162
+ const t = theme.current();
163
+ return t.info('info') + t.dim(' ›');
103
164
  }
104
165
 
105
166
  function okTag() {
106
- return chalk.green.bold('✓') + chalk.gray(' ›');
167
+ const t = theme.current();
168
+ return t.ok('✓') + t.dim(' ›');
107
169
  }
108
170
 
109
171
  function hr() {
110
- const w = Math.min(process.stdout.columns || 80, 100);
111
- return chalk.gray('─'.repeat(w));
172
+ const t = theme.current();
173
+ return t.dim('─'.repeat(termWidth()));
112
174
  }
113
175
 
114
176
  function kv(key, value) {
115
- return `${chalk.gray(key.padEnd(14))} ${chalk.white(value)}`;
177
+ const t = theme.current();
178
+ return `${t.dim(key.padEnd(14))} ${chalk.white(value)}`;
116
179
  }
117
180
 
118
181
  function pretty(obj) {
@@ -123,38 +186,68 @@ function pretty(obj) {
123
186
  }
124
187
  }
125
188
 
126
- /**
127
- * Render a clean "limit hit" box.
128
- */
189
+ /* ----- limit warning ----- */
190
+
129
191
  function limitWarning(info) {
130
- const dim = chalk.gray;
131
- const red = chalk.red.bold;
192
+ const t = theme.current();
193
+ const red = t.error;
132
194
  const top = red('┏━━ LIMIT EXCEEDED ' + '━'.repeat(40));
133
195
  const bot = red('┗' + '━'.repeat(58));
134
- const lines = [
135
- '',
136
- top,
137
- red('┃ ') + chalk.yellow('Kuota API kamu udah habis 🚫'),
138
- red('┃ '),
139
- ];
196
+ const lines = ['', top, red('┃ ') + chalk.yellow('Kuota API kamu udah habis 🚫'), red('┃ ')];
140
197
  if (info && typeof info === 'object') {
141
- if (info.used !== undefined) lines.push(red('┃ ') + dim('used : ') + chalk.white(String(info.used)));
142
- if (info.limit !== undefined) lines.push(red('┃ ') + dim('limit : ') + chalk.white(String(info.limit)));
143
- if (info.resetAt) lines.push(red('┃ ') + dim('reset : ') + chalk.white(String(info.resetAt)));
144
- if (info.plan) lines.push(red('┃ ') + dim('plan : ') + chalk.white(String(info.plan)));
198
+ if (info.used !== undefined) lines.push(red('┃ ') + t.dim('used : ') + chalk.white(String(info.used)));
199
+ if (info.limit !== undefined) lines.push(red('┃ ') + t.dim('limit : ') + chalk.white(String(info.limit)));
200
+ if (info.resetAt) lines.push(red('┃ ') + t.dim('reset : ') + chalk.white(String(info.resetAt)));
201
+ if (info.plan) lines.push(red('┃ ') + t.dim('plan : ') + chalk.white(String(info.plan)));
145
202
  }
146
203
  lines.push(red('┃ '));
147
- lines.push(red('┃ ') + dim('Upgrade plan di ') + chalk.cyan.underline('https://zelapioffciall.dpdns.org/pricing'));
204
+ lines.push(red('┃ ') + t.dim('Upgrade plan di ') + t.link('https://zelapioffciall.dpdns.org/pricing'));
148
205
  lines.push(bot);
149
206
  lines.push('');
150
207
  return lines.join('\n');
151
208
  }
152
209
 
210
+ /* ----- version vulnerable warning ----- */
211
+
212
+ function vulnerableWarning(current, latest) {
213
+ const t = theme.current();
214
+ const yellow = chalk.yellow.bold;
215
+ const dim = t.dim;
216
+ const top = yellow('┏━━ ⚠ UPDATE AVAILABLE ' + '━'.repeat(35));
217
+ const bot = yellow('┗' + '━'.repeat(58));
218
+ return [
219
+ '',
220
+ top,
221
+ yellow('┃ ') + chalk.white('Your version is too vulnerable to problems,'),
222
+ yellow('┃ ') + chalk.white('please update by typing ') + chalk.cyan.bold('/update'),
223
+ yellow('┃ '),
224
+ yellow('┃ ') + dim('current : ') + chalk.white(current),
225
+ yellow('┃ ') + dim('latest : ') + chalk.green.bold(latest),
226
+ bot,
227
+ '',
228
+ ].join('\n');
229
+ }
230
+
231
+ /* ----- generic box ----- */
232
+
233
+ function box(title, body) {
234
+ const t = theme.current();
235
+ const w = termWidth();
236
+ const label = ` ${title} `;
237
+ const filler = Math.max(w - label.length - 4, 0);
238
+ const top = t.boxAccent('╭─[') + t.user(label) + t.boxAccent(']' + '─'.repeat(filler));
239
+ const bot = t.boxAccent('╰' + '─'.repeat(Math.max(w - 1, 0)));
240
+ return [top, gutterIndent(body || ''), bot].join('\n');
241
+ }
242
+
153
243
  module.exports = {
154
244
  banner,
155
245
  smallBanner,
156
246
  renderMarkdown,
247
+ userBoxOpen,
157
248
  userPrompt,
249
+ assistantBoxOpen,
250
+ assistantBoxClose,
158
251
  assistantTag,
159
252
  systemTag,
160
253
  errorTag,
@@ -165,5 +258,10 @@ module.exports = {
165
258
  kv,
166
259
  pretty,
167
260
  limitWarning,
261
+ vulnerableWarning,
262
+ box,
263
+ gutterIndent,
264
+ termWidth,
168
265
  chalk,
266
+ theme,
169
267
  };
package/src/version.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Version check + /update helper.
3
+ *
4
+ * - getLatest() → ngambil versi terbaru dari npm registry (zelai-cli)
5
+ * - isBehind(a, b) → true kalau a (current) < b (latest)
6
+ * - runUpdate() → spawn `npm i -g zelai-cli@latest`, stream output ke stdout
7
+ */
8
+ 'use strict';
9
+
10
+ const axios = require('axios');
11
+ const { spawn } = require('child_process');
12
+
13
+ const REGISTRY = 'https://registry.npmjs.org';
14
+ const PKG = 'zelai-cli';
15
+
16
+ async function getLatest(timeoutMs = 4000) {
17
+ try {
18
+ const res = await axios.get(`${REGISTRY}/${PKG}/latest`, { timeout: timeoutMs });
19
+ if (res.status >= 200 && res.status < 300 && res.data && res.data.version) {
20
+ return String(res.data.version);
21
+ }
22
+ } catch {
23
+ // Diem aja — version check itu best effort.
24
+ }
25
+ return null;
26
+ }
27
+
28
+ function parseSemver(v) {
29
+ const m = String(v || '').replace(/^v/, '').match(/^(\d+)\.(\d+)\.(\d+)/);
30
+ if (!m) return null;
31
+ return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
32
+ }
33
+
34
+ function compareSemver(a, b) {
35
+ const av = parseSemver(a);
36
+ const bv = parseSemver(b);
37
+ if (!av || !bv) return 0;
38
+ for (let i = 0; i < 3; i++) {
39
+ if (av[i] < bv[i]) return -1;
40
+ if (av[i] > bv[i]) return 1;
41
+ }
42
+ return 0;
43
+ }
44
+
45
+ function isBehind(current, latest) {
46
+ if (!current || !latest) return false;
47
+ return compareSemver(current, latest) < 0;
48
+ }
49
+
50
+ /**
51
+ * Jalanin `npm i -g zelai-cli@latest`. Stream stdout/stderr live ke onLine,
52
+ * supaya CLI bisa render pesan progress (warnaan).
53
+ *
54
+ * Returns Promise<{ ok, code }>.
55
+ */
56
+ function runUpdate(onLine) {
57
+ return new Promise((resolve) => {
58
+ const args = ['i', '-g', `${PKG}@latest`];
59
+ const child = spawn('npm', args, { stdio: ['ignore', 'pipe', 'pipe'] });
60
+
61
+ let buf = '';
62
+ const onData = (chunk) => {
63
+ buf += chunk.toString('utf8');
64
+ let nl;
65
+ while ((nl = buf.indexOf('\n')) !== -1) {
66
+ const line = buf.slice(0, nl);
67
+ buf = buf.slice(nl + 1);
68
+ if (typeof onLine === 'function') onLine(line);
69
+ }
70
+ };
71
+ child.stdout.on('data', onData);
72
+ child.stderr.on('data', onData);
73
+
74
+ child.on('close', (code) => {
75
+ if (buf && typeof onLine === 'function') onLine(buf);
76
+ resolve({ ok: code === 0, code });
77
+ });
78
+ child.on('error', (err) => {
79
+ if (typeof onLine === 'function') onLine('error: ' + err.message);
80
+ resolve({ ok: false, code: -1 });
81
+ });
82
+ });
83
+ }
84
+
85
+ module.exports = { getLatest, isBehind, compareSemver, parseSemver, runUpdate, PKG };