luang-cli 0.2.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/commands.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // lib/commands.js — implementasi semua command luang-cli.
4
4
 
5
5
  const os = require('os');
6
+ const fs = require('fs');
6
7
  const readline = require('readline');
7
8
 
8
9
  const core = require('./core');
@@ -14,6 +15,8 @@ const {
14
15
  saveConfig,
15
16
  collectProviders,
16
17
  heartbeatProviders,
18
+ resolveWorkspace,
19
+ modelsForProvider,
17
20
  detectProviders,
18
21
  PROVIDER_TYPES,
19
22
  DISPLAY_NAMES,
@@ -94,6 +97,129 @@ function permLine(label, val) {
94
97
  return `${val ? '✅' : '❌'} ${label}`;
95
98
  }
96
99
 
100
+ // ── Telemetry (konsent ganda: lokal config + flag server) ────────────────────
101
+
102
+ // Label satu baris status konsent ganda. serverOn: true/false/null (tak tahu).
103
+ function telemetryConsentLabel(localOn, serverOn) {
104
+ const l = localOn ? 'lokal ✅' : 'lokal ❌';
105
+ const s = serverOn === true ? 'server ✅' : serverOn === false ? 'server ❌' : 'server ?';
106
+ const on = localOn && serverOn === true;
107
+ return `${l} · ${s} → efektif ${on ? '✅ DIKIRIM' : '❌ tidak dikirim'}`;
108
+ }
109
+
110
+ async function cmdTelemetry(rt, args) {
111
+ const sub = String(args.pos[1] || 'status').toLowerCase();
112
+ if (sub === 'on') {
113
+ rt.cfg.telemetry = true;
114
+ saveConfig(rt.cfg);
115
+ console.log('✅ Telemetry lokal: ON (config.json telemetry=true)');
116
+ console.log('⚡ Kirim hanya setelah flag server ikut ON (wajib dua-duanya).');
117
+ } else if (sub === 'off') {
118
+ rt.cfg.telemetry = false;
119
+ saveConfig(rt.cfg);
120
+ console.log('✅ Telemetry lokal: OFF');
121
+ console.log('🤐 Data run (telemetry.run) TIDAK akan dikirim dari mesin ini.');
122
+ } else if (sub !== 'status') {
123
+ return errExit('Pemakaian: luang-cli telemetry on|off|status');
124
+ }
125
+
126
+ // Flag server: agent_keys.telemetry_enabled (diubah dari dashboard).
127
+ let serverOn = null;
128
+ let note = null;
129
+ if (rt.key) {
130
+ try {
131
+ const me = await apiRequest(rt, 'GET', '/api/v1/agent/me');
132
+ serverOn = ((me && me.data) || {}).telemetry_enabled === true;
133
+ } catch (e) {
134
+ note = e.message;
135
+ }
136
+ } else {
137
+ note = 'belum login — flag server tidak bisa diperiksa (luang-cli login)';
138
+ }
139
+
140
+ console.log('');
141
+ console.log('📊 Telemetry — konsent ganda (opt-in, bukan opt-out)');
142
+ console.log(` Lokal : config.telemetry = ${rt.cfg.telemetry === true ? 'true ✅' : 'false ❌'} (${CONFIG_FILE})`);
143
+ if (serverOn === null) console.log(` Server : ? — ${note || 'gagal cek /api/v1/agent/me'}`);
144
+ else console.log(` Server : telemetry_enabled = ${serverOn ? 'true ✅' : 'false ❌'} (Agents → toggle Telemetry di dashboard)`);
145
+ console.log(` Efektif: ${telemetryConsentLabel(rt.cfg.telemetry === true, serverOn)}`);
146
+ console.log('');
147
+ if (rt.cfg.telemetry === true && serverOn !== true) {
148
+ console.log('ℹ️ Bebannya harus ON bersamaan: lokal di sini, server di dashboard.');
149
+ }
150
+ console.log('🔐 Kirim pakai header X-CLI-Key (bukan JWT) via frame WS telemetry.run.');
151
+ console.log(' Bila salah satu OFF → CLI tidak mengirim apa pun (dan server pun men-drop).');
152
+ return 0;
153
+ }
154
+
155
+ // ── Workspace folder (folder kerja provider; sinkron dashboard ↔ CLI) ──────
156
+ // Server adalah sumber kebenaran (dashboard mengatur); CLI menyimpan salinan
157
+ // lokal + MEMAKAI sebagai cwd saat --exec. Endpoint CLI: POST /agent/workspace
158
+ // (X-CLI-Key, milik key sendiri) — dashboard memakai PUT /keys/:id/workspace (JWT).
159
+ async function workspaceShow(rt, serverVal) {
160
+ const local = typeof rt.cfg.workspace === 'string' ? rt.cfg.workspace.trim() : '';
161
+ const server = typeof serverVal === 'string' ? serverVal.trim() : '';
162
+ const effective = server || local;
163
+ const resolved = resolveWorkspace(effective);
164
+ let exists = null;
165
+ if (resolved) {
166
+ try {
167
+ exists = fs.statSync(resolved).isDirectory();
168
+ } catch (e) {
169
+ exists = false;
170
+ }
171
+ }
172
+ console.log('');
173
+ console.log('📁 Workspace folder — folder kerja provider agent (opencode, claude, …)');
174
+ console.log(` Server : ${server ? server : '(belum diset — atur di dashboard Agents atau: luang-cli workspace <path>)'}`);
175
+ console.log(` Lokal : ${local ? local : '(mengikuti server)'}`);
176
+ if (resolved) {
177
+ console.log(` Efektif: ${resolved}${exists === true ? '' : ' ⚠️ tidak ada/bukan folder → exec pakai folder saat ini'}`);
178
+ } else {
179
+ console.log(` Efektif: folder saat ini (${process.cwd()})`);
180
+ }
181
+ console.log(' Atur : luang-cli workspace <path> · reset: luang-cli workspace --reset');
182
+ return 0;
183
+ }
184
+
185
+ async function cmdWorkspace(rt, args) {
186
+ const raw = String(args.pos[1] || '').trim();
187
+ if (!rt.key) return errExit('Belum login — jalankan `luang-cli login` atau set LUANG_KEY.');
188
+ const flagReset = args.flags && (args.flags.reset === true || args.flags.reset === 'true' || args.flags.reset === '');
189
+ const reset = raw === 'reset' || raw === '-' || raw === '--reset' || flagReset;
190
+ if ((!raw || raw === 'status' || raw === 'show') && !reset) {
191
+ let serverVal = null;
192
+ try {
193
+ const me = await apiRequest(rt, 'GET', '/api/v1/agent/me');
194
+ serverVal = me && me.data ? me.data.workspace : null;
195
+ } catch (e) {
196
+ console.log(` ⚠️ /me gagal: ${e.message} — tampilkan nilai lokal saja.`);
197
+ }
198
+ return workspaceShow(rt, serverVal);
199
+ }
200
+ const value = reset ? '' : raw;
201
+ if (!reset) {
202
+ const resolved = resolveWorkspace(value);
203
+ if (!resolved) return errExit(`Path tidak valid: ${value}`);
204
+ try {
205
+ if (!fs.statSync(resolved).isDirectory()) {
206
+ return errExit(`Bukan folder: ${resolved}`, 'Buat dulu foldernya, atau cek penulisannya.');
207
+ }
208
+ } catch (e) {
209
+ return errExit(`Folder tidak ada: ${resolved}`, 'Buat dulu foldernya, atau cek penulisannya.');
210
+ }
211
+ }
212
+ try {
213
+ await apiRequest(rt, 'POST', '/api/v1/agent/workspace', { body: { workspace: value } });
214
+ } catch (e) {
215
+ return errExit(`Gagal menyimpan ke server: ${e.message}`);
216
+ }
217
+ rt.cfg.workspace = value;
218
+ saveConfig(rt.cfg);
219
+ console.log(reset ? '📁 Workspace direset — exec memakai folder saat ini.' : `📁 Workspace disimpan: ${resolveWorkspace(value)} (server + lokal sinkron)`);
220
+ return 0;
221
+ }
222
+
97
223
  // ── login (device flow) ──────────────────────────────────────────────────────
98
224
 
99
225
  async function cmdLogin(rt, args) {
@@ -211,6 +337,12 @@ async function cmdWhoami(rt) {
211
337
  return authFailMessage(e);
212
338
  }
213
339
  const d = (me && me.data) || {};
340
+ // Sinkron default provider dari dashboard bila diset di sana.
341
+ const sdp = typeof d.default_provider === 'string' ? d.default_provider.trim() : '';
342
+ if (sdp && rt.cfg.default_provider !== sdp) {
343
+ rt.cfg.default_provider = sdp;
344
+ saveConfig(rt.cfg);
345
+ }
214
346
  console.log('👤 Sesi Agent');
215
347
  console.log(` Nama : ${d.name || '-'}`);
216
348
  console.log(` Status : ${d.status || '-'}`);
@@ -222,7 +354,11 @@ async function cmdWhoami(rt) {
222
354
  console.log(` ${permLine('Bisa update tugas (can_update_tasks)', !!d.can_update_tasks)}`);
223
355
  console.log(` ${permLine('Bisa buat tugas (can_create_tasks)', !!d.can_create_tasks)}`);
224
356
  console.log('');
357
+ console.log(`📊 Telemetry: ${telemetryConsentLabel(rt.cfg.telemetry === true, d.telemetry_enabled === true)}`);
358
+ console.log(' (ubah lokal: luang-cli telemetry on|off · flag server: dashboard Agents)');
359
+ console.log('');
225
360
  console.log(`🛠 Provider terdaftar (server): ${providersLabel(d.providers)}`);
361
+ console.log(`⭐ Default provider: ${rt.cfg.default_provider || '(belum di-set)'}${sdp ? ' (disetel dashboard)' : ''}`);
226
362
  return 0;
227
363
  }
228
364
 
@@ -260,22 +396,24 @@ async function cmdProvider(rt, args) {
260
396
  return 0;
261
397
  }
262
398
  printTable(
263
- ['Provider', 'Path', 'Exec Template'],
399
+ ['Provider', 'Path', 'Exec Template', 'Model'],
264
400
  list.map((p) => [
265
401
  (p.is_default ? '★ ' : '') + p.provider_type,
266
402
  p.path || '-',
267
403
  p.exec_template || '-',
404
+ p.model || '-',
268
405
  ])
269
406
  );
270
407
  console.log('');
271
408
  console.log('★ = default provider · deteksi: ~/.claude, ~/.codex, ~/.config/opencode/~/.opencode, ~/.gemini + which(1)');
409
+ console.log(' {model} di exec_template diisi dari kolom Model (atau env LUANG_MODEL) saat mode --exec.');
272
410
  await syncIfKey(rt);
273
411
  return 0;
274
412
  }
275
413
 
276
414
  if (sub === 'add') {
277
415
  const type = args.pos[2];
278
- if (!type) return errExit('Format: luang-cli provider add <type> [--path P] [--exec_template T]', `type: ${PROVIDER_TYPES.join(', ')}`);
416
+ if (!type) return errExit('Format: luang-cli provider add <type> [--path P] [--exec_template T] [--model M]', `type: ${PROVIDER_TYPES.join(', ')}`);
279
417
  if (!PROVIDER_TYPES.includes(type)) return errExit(`Tipe tidak dikenal: ${type}`, `Pilihan: ${PROVIDER_TYPES.join(', ')}`);
280
418
  const existing = collectProviders(rt.cfg).find((p) => p.provider_type === type) || {};
281
419
  const tPath = flags.path && flags.path !== true ? String(flags.path) : existing.path || null;
@@ -283,16 +421,22 @@ async function cmdProvider(rt, args) {
283
421
  flags.exec_template && flags.exec_template !== true
284
422
  ? String(flags.exec_template)
285
423
  : existing.exec_template || DEFAULT_TEMPLATES[type] || null;
424
+ let tModel = flags.model && flags.model !== true ? String(flags.model).trim() : existing.model || null;
425
+ if (tModel && !/^[\w.:+/-]+$/.test(tModel)) {
426
+ return errExit(`--model tidak valid: ${tModel}`, 'Karakter yang diizinkan: huruf, angka, `.` `_` `:` `+` `-` `/`');
427
+ }
286
428
  rt.cfg.providers[type] = {
287
429
  provider_type: type,
288
430
  display_name: DISPLAY_NAMES[type] || type,
289
431
  path: tPath,
290
432
  exec_template: tTpl,
433
+ ...(tModel ? { model: tModel } : {}),
291
434
  };
292
435
  saveConfig(rt.cfg);
293
436
  console.log(`➕ Provider ${type} tersimpan di config:`);
294
437
  console.log(` Path : ${tPath || '-'}`);
295
438
  console.log(` Exec Template : ${tTpl || '-'}`);
439
+ if (tModel) console.log(` Model : ${tModel}`);
296
440
  if (!tTpl || !tTpl.includes('{task}')) {
297
441
  console.log('⚠️ Exec template tanpa {task} — tugas tidak bisa dieksekusi otomatis.');
298
442
  }
@@ -334,4 +478,82 @@ async function cmdProvider(rt, args) {
334
478
  return errExit(`Sub-command provider tidak dikenal: ${sub}`, 'Gunakan: list | add | remove | default');
335
479
  }
336
480
 
337
- module.exports = { cmdLogin, cmdLogout, cmdWhoami, cmdProvider, errExit, authFailMessage, taskIdOf, statusInputNorm, normalizeTaskList, ask, providersLabel, syncIfKey, permLine };
481
+ // ── models (daftar model per provider) ───────────────────────────────────────
482
+
483
+ // Lebar kolom daftar model & maks baris wrap (spec: maks 6 baris + "…+n lagi").
484
+ const MODELS_LIST_WIDTH = 44;
485
+ const MODELS_MAX_LINES = 6;
486
+
487
+ function wrapModelList(models, width, maxLines) {
488
+ const rows = [];
489
+ let cur = '';
490
+ let cnt = 0;
491
+ for (const m of models) {
492
+ const cand = cur ? `${cur}, ${m}` : m;
493
+ if (cur && cand.length > width) {
494
+ rows.push({ text: cur, cnt });
495
+ cur = m;
496
+ cnt = 1;
497
+ } else {
498
+ cur = cand;
499
+ cnt += 1;
500
+ }
501
+ }
502
+ if (cur) rows.push({ text: cur, cnt });
503
+ if (rows.length <= maxLines) return { lines: rows.map((r) => r.text), remaining: 0 };
504
+ const keep = rows.slice(0, maxLines);
505
+ const shown = keep.reduce((a, r) => a + r.cnt, 0);
506
+ return { lines: keep.map((r) => r.text).concat([`…+${models.length - shown} lagi`]), remaining: models.length - shown };
507
+ }
508
+
509
+ async function cmdModels(rt, args) {
510
+ const arg = args.pos[1] && !String(args.pos[1]).startsWith('-') ? String(args.pos[1]) : null;
511
+ const allTypes = Object.keys(core.MODEL_REGISTRY);
512
+ if (arg && !allTypes.includes(arg)) {
513
+ return errExit(`Provider tidak dikenal: ${arg}`, `Pilihan: ${allTypes.join(', ')}`);
514
+ }
515
+ const types = arg ? [arg] : allTypes;
516
+ const detected = new Map(collectProviders(rt.cfg).map((p) => [p.provider_type, p]));
517
+
518
+ const result = {};
519
+ for (const t of types) {
520
+ const p = detected.get(t);
521
+ // refresh: true → deteksi dijalankan ulang (bypass cache) saat user minta.
522
+ result[t] = modelsForProvider(t, { path: p && p.path, refresh: true });
523
+ }
524
+
525
+ if (args.flags.json === true) {
526
+ console.log(JSON.stringify(result, null, 2));
527
+ return 0;
528
+ }
529
+
530
+ if (!types.length) {
531
+ console.log('📭 Tidak ada provider.');
532
+ return 0;
533
+ }
534
+
535
+ const wProv = Math.max('Provider'.length, ...types.map((t) => t.length));
536
+ const counts = types.map((t) => String(result[t].models.length));
537
+ const wCnt = Math.max('Jumlah'.length, ...counts.map((s) => s.length));
538
+ const pad = (s, w) => String(s).padEnd(w, ' ');
539
+ const padL = (s, w) => String(s).padStart(w, ' ');
540
+
541
+ console.log(`${pad('Provider', wProv)} ${padL('Jumlah', wCnt)} Models`);
542
+ console.log(`${'─'.repeat(wProv)} ${'─'.repeat(wCnt)} ${'─'.repeat(Math.min(MODELS_LIST_WIDTH, 20))}`);
543
+ types.forEach((t, i) => {
544
+ const r = result[t];
545
+ const w = wrapModelList(r.models, MODELS_LIST_WIDTH, MODELS_MAX_LINES);
546
+ const head = `${pad(t, wProv)} ${padL(r.models.length, wCnt)} ${w.lines[0] || '-'}`;
547
+ console.log(head);
548
+ for (const line of w.lines.slice(1)) {
549
+ console.log(`${' '.repeat(wProv)} ${' '.repeat(wCnt)} ${line}`);
550
+ }
551
+ });
552
+ console.log('');
553
+ console.log('Sumber: static = registry saran lokal · binary = perintah `opencode models` · config = ~/.config/opencode');
554
+ console.log('Daftar ini saran/datalist — model bisa diketik manual; `claude`/`codex`/`gemini` tidak pernah dieksekusi.');
555
+ console.log('JSON: luang-cli models [provider] --json');
556
+ return 0;
557
+ }
558
+
559
+ module.exports = { cmdLogin, cmdLogout, cmdWhoami, cmdTelemetry, cmdWorkspace, cmdProvider, cmdModels, errExit, authFailMessage, taskIdOf, statusInputNorm, normalizeTaskList, ask, providersLabel, syncIfKey, permLine, telemetryConsentLabel };
package/lib/core.js CHANGED
@@ -8,6 +8,25 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const { spawnSync } = require('child_process');
10
10
 
11
+ const { modelsForProvider, sanitizeModels, MODEL_REGISTRY } = require('./models');
12
+
13
+ /**
14
+ * Resolve workspace folder mentah (dari server / config / arg CLI) menjadi
15
+ * path absolut, atau null bila kosong. Aturan:
16
+ * - "" / null → null (pakai cwd proses)
17
+ * - "~" / "~/x" → home dir user
18
+ * - relatif → resolve terhadap cwd proses saat dipanggil
19
+ * - absolut → apa adanya (dinormalisasi)
20
+ * Tidak menyentuh filesystem (cek eksistensi di pemanggil agar bisa warning).
21
+ */
22
+ function resolveWorkspace(raw) {
23
+ const s = typeof raw === 'string' ? raw.trim() : '';
24
+ if (!s) return null;
25
+ if (s === '~' || s.startsWith('~/')) return path.normalize(path.join(os.homedir(), s.slice(s === '~' ? 1 : 2)));
26
+ if (path.isAbsolute(s)) return path.normalize(s);
27
+ return path.resolve(process.cwd(), s);
28
+ }
29
+
11
30
  // ── Lokasi file ──────────────────────────────────────────────────────────────
12
31
 
13
32
  const CONFIG_DIR = path.join(os.homedir(), '.luang-cli');
@@ -20,6 +39,18 @@ const DEFAULT_CONFIG = {
20
39
  key: null,
21
40
  default_provider: null,
22
41
  providers: {},
42
+ // Telemetry konsent (lihat docs/agent-realtime-ws.md):
43
+ // false = jangan kirim apa pun. Kirim hanya bila ini true DAN server flag
44
+ // telemetry_enabled=true (dari GET /agent/me / hello.ok WS).
45
+ telemetry: false,
46
+ // Opsional: jumlah token diisi manual saat exec (jika user tahu) — hanya
47
+ // ikut dikirim bila consent aktif; selain itu biarkan null.
48
+ tokens_in: null,
49
+ tokens_out: null,
50
+ // Mode auto agent (`luang-cli agent start`): true = eksekusi tugas To-Do
51
+ // tanpa konfirmasi y/N (agent berjalan di background / tanpa terminal).
52
+ // Ditulis oleh `agent start` (default true, `--no-auto` menulis false).
53
+ auto_exec: false,
23
54
  };
24
55
 
25
56
  // ── Config ───────────────────────────────────────────────────────────────────
@@ -390,6 +421,9 @@ function heartbeatProviders(list) {
390
421
  path: p.path || null,
391
422
  exec_template: p.exec_template || null,
392
423
  version: probeVersion(p.path),
424
+ // Kontrak baru (opsional): daftar model per provider — maks 50 item,
425
+ // tiap item ≤120 karakter (lihat lib/models.js).
426
+ models: modelsForProvider(p.provider_type, { path: p.path }).models,
393
427
  }));
394
428
  }
395
429
 
@@ -438,5 +472,9 @@ module.exports = {
438
472
  probeVersion,
439
473
  collectProviders,
440
474
  heartbeatProviders,
475
+ resolveWorkspace,
476
+ modelsForProvider,
477
+ sanitizeModels,
478
+ MODEL_REGISTRY,
441
479
  openBrowser,
442
480
  };