luang-cli 0.3.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/README.md +77 -5
- package/bin/luang-cli.js +72 -3
- package/lib/agent.js +386 -22
- package/lib/commands.js +157 -1
- package/lib/core.js +30 -0
- package/lib/models.js +343 -0
- package/package.json +2 -2
- package/test/bin/opencode +5 -0
- package/test/http-stub.js +39 -1
- package/test/run-e2e.js +349 -1
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,
|
|
@@ -149,6 +152,74 @@ async function cmdTelemetry(rt, args) {
|
|
|
149
152
|
return 0;
|
|
150
153
|
}
|
|
151
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
|
+
|
|
152
223
|
// ── login (device flow) ──────────────────────────────────────────────────────
|
|
153
224
|
|
|
154
225
|
async function cmdLogin(rt, args) {
|
|
@@ -266,6 +337,12 @@ async function cmdWhoami(rt) {
|
|
|
266
337
|
return authFailMessage(e);
|
|
267
338
|
}
|
|
268
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
|
+
}
|
|
269
346
|
console.log('👤 Sesi Agent');
|
|
270
347
|
console.log(` Nama : ${d.name || '-'}`);
|
|
271
348
|
console.log(` Status : ${d.status || '-'}`);
|
|
@@ -281,6 +358,7 @@ async function cmdWhoami(rt) {
|
|
|
281
358
|
console.log(' (ubah lokal: luang-cli telemetry on|off · flag server: dashboard Agents)');
|
|
282
359
|
console.log('');
|
|
283
360
|
console.log(`🛠 Provider terdaftar (server): ${providersLabel(d.providers)}`);
|
|
361
|
+
console.log(`⭐ Default provider: ${rt.cfg.default_provider || '(belum di-set)'}${sdp ? ' (disetel dashboard)' : ''}`);
|
|
284
362
|
return 0;
|
|
285
363
|
}
|
|
286
364
|
|
|
@@ -400,4 +478,82 @@ async function cmdProvider(rt, args) {
|
|
|
400
478
|
return errExit(`Sub-command provider tidak dikenal: ${sub}`, 'Gunakan: list | add | remove | default');
|
|
401
479
|
}
|
|
402
480
|
|
|
403
|
-
|
|
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');
|
|
@@ -28,6 +47,10 @@ const DEFAULT_CONFIG = {
|
|
|
28
47
|
// ikut dikirim bila consent aktif; selain itu biarkan null.
|
|
29
48
|
tokens_in: null,
|
|
30
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,
|
|
31
54
|
};
|
|
32
55
|
|
|
33
56
|
// ── Config ───────────────────────────────────────────────────────────────────
|
|
@@ -398,6 +421,9 @@ function heartbeatProviders(list) {
|
|
|
398
421
|
path: p.path || null,
|
|
399
422
|
exec_template: p.exec_template || null,
|
|
400
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,
|
|
401
427
|
}));
|
|
402
428
|
}
|
|
403
429
|
|
|
@@ -446,5 +472,9 @@ module.exports = {
|
|
|
446
472
|
probeVersion,
|
|
447
473
|
collectProviders,
|
|
448
474
|
heartbeatProviders,
|
|
475
|
+
resolveWorkspace,
|
|
476
|
+
modelsForProvider,
|
|
477
|
+
sanitizeModels,
|
|
478
|
+
MODEL_REGISTRY,
|
|
449
479
|
openBrowser,
|
|
450
480
|
};
|
package/lib/models.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// lib/models.js — daftar model AI per provider untuk `luang-cli models`
|
|
4
|
+
// dan untuk sinkronisasi ke server (field `models` pada payload heartbeat
|
|
5
|
+
// POST /api/v1/agent/heartbeat dan pada `providers` frame hello WS —
|
|
6
|
+
// kontrak baru; backend menerima field opsional ini).
|
|
7
|
+
//
|
|
8
|
+
// Urutan sumber data:
|
|
9
|
+
// 1. Dinamis (hanya whitelist AMAN): opencode → coba perintah
|
|
10
|
+
// `opencode models`, gagal/ kosong → parse config opencode di
|
|
11
|
+
// ~/.config/opencode/ atau ~/.opencode, gagal juga → statis.
|
|
12
|
+
// 2. Registry statis = daftar saran per tipe provider (claude_code, codex,
|
|
13
|
+
// opencode, gemini_cli, custom). Ditulis apa adanya meski versi lama —
|
|
14
|
+
// ini datalist/saran, user tetap bebas mengetik model manual.
|
|
15
|
+
//
|
|
16
|
+
// KEBIJAKAN KETAT: binary `claude`, `gemini`, dan `codex` TIDAK PERNAH
|
|
17
|
+
// dieksekusi di sini — apa pun alasannya. Cukup registry statis / parse file.
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { spawnSync } = require('child_process');
|
|
23
|
+
|
|
24
|
+
// Batas payload ke server (kontrak heartbeat/hello).
|
|
25
|
+
const MAX_MODELS = 50;
|
|
26
|
+
const MAX_MODEL_LEN = 120;
|
|
27
|
+
const BIN_TIMEOUT_MS = 3000;
|
|
28
|
+
const CACHE_TTL_MS = 10 * 60 * 1000; // deteksi dinamis tidak dijalankan tiap heartbeat
|
|
29
|
+
|
|
30
|
+
// ── Registry statis (saran / datalist per tipe provider) ─────────────────────
|
|
31
|
+
|
|
32
|
+
const MODEL_REGISTRY = {
|
|
33
|
+
claude_code: [
|
|
34
|
+
'claude-opus-4-1',
|
|
35
|
+
'claude-sonnet-4-5',
|
|
36
|
+
'claude-haiku-4-5',
|
|
37
|
+
'claude-opus-4-1-20250805',
|
|
38
|
+
'claude-sonnet-4-5-20250929',
|
|
39
|
+
'claude-haiku-4-5-20251001',
|
|
40
|
+
'claude-sonnet-4-20250514',
|
|
41
|
+
'claude-opus-4-20250514',
|
|
42
|
+
'claude-3-7-sonnet-latest',
|
|
43
|
+
'claude-3-7-sonnet-20250219',
|
|
44
|
+
'claude-3-5-sonnet-latest',
|
|
45
|
+
'claude-3-5-sonnet-20241022',
|
|
46
|
+
'claude-3-5-haiku-latest',
|
|
47
|
+
'claude-3-5-haiku-20241022',
|
|
48
|
+
'claude-3-opus-latest',
|
|
49
|
+
'claude-3-sonnet-20240229',
|
|
50
|
+
],
|
|
51
|
+
codex: [
|
|
52
|
+
'gpt-5-codex',
|
|
53
|
+
'gpt-5.1-codex',
|
|
54
|
+
'gpt-5-codex-spark',
|
|
55
|
+
'gpt-5-mini',
|
|
56
|
+
'gpt-5-nano',
|
|
57
|
+
'gpt-4.1',
|
|
58
|
+
'gpt-4.1-mini',
|
|
59
|
+
'gpt-4.1-nano',
|
|
60
|
+
'o3',
|
|
61
|
+
'o3-mini',
|
|
62
|
+
'o4-mini',
|
|
63
|
+
'codex-mini-latest',
|
|
64
|
+
],
|
|
65
|
+
opencode: [
|
|
66
|
+
'anthropic/claude-sonnet-4-5',
|
|
67
|
+
'anthropic/claude-opus-4-1',
|
|
68
|
+
'anthropic/claude-haiku-4-5',
|
|
69
|
+
'openai/gpt-5-codex',
|
|
70
|
+
'openai/gpt-5.1-codex',
|
|
71
|
+
'openai/o4-mini',
|
|
72
|
+
'google/gemini-2.5-pro',
|
|
73
|
+
'google/gemini-2.5-flash',
|
|
74
|
+
'opencode/grok-code-fast-1',
|
|
75
|
+
'qwen/qwen3-coder',
|
|
76
|
+
'deepseek/deepseek-v3.1',
|
|
77
|
+
'mistral/codestral-latest',
|
|
78
|
+
],
|
|
79
|
+
gemini_cli: [
|
|
80
|
+
'gemini-2.5-pro',
|
|
81
|
+
'gemini-2.5-flash',
|
|
82
|
+
'gemini-2.5-flash-lite',
|
|
83
|
+
'gemini-2.0-flash',
|
|
84
|
+
'gemini-2.0-flash-lite',
|
|
85
|
+
'gemini-1.5-pro',
|
|
86
|
+
'gemini-1.5-flash',
|
|
87
|
+
'gemini-1.0-pro',
|
|
88
|
+
'gemma-3-27b-it',
|
|
89
|
+
'gemma-3-12b-it',
|
|
90
|
+
],
|
|
91
|
+
custom: [], // provider custom: tanpa saran — user ketik manual
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Whitelist binary yang BOLEH dijalankan untuk deteksi dinamis.
|
|
95
|
+
// Kebijakan ketat: claude / gemini / codex tidak pernah ada di daftar ini.
|
|
96
|
+
const DYNAMIC_OK = new Set(['opencode']);
|
|
97
|
+
|
|
98
|
+
const MODEL_TOKEN_RE = /^[\w.:+/-]+$/;
|
|
99
|
+
const PROVIDER_SLASH_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
100
|
+
// Kata umum yang bukan model (dipakai saat parse longgar).
|
|
101
|
+
const NON_MODEL_WORDS = new Set([
|
|
102
|
+
'provider', 'providers', 'model', 'models', 'name', 'names', 'id', 'ids',
|
|
103
|
+
'type', 'types', 'label', 'description', 'path', 'version', 'default',
|
|
104
|
+
'baseurl', 'base_url', 'apikey', 'api_key',
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
// ── Util ─────────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
// Batas wire: maks 50 item, tiap item ≤120 karakter, unik, terpotong rapi.
|
|
110
|
+
function sanitizeModels(list) {
|
|
111
|
+
const out = [];
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
for (const m of Array.isArray(list) ? list : []) {
|
|
114
|
+
if (out.length >= MAX_MODELS) break;
|
|
115
|
+
const s = String(m === null || m === undefined ? '' : m).trim().slice(0, MAX_MODEL_LEN);
|
|
116
|
+
if (!s || seen.has(s)) continue;
|
|
117
|
+
seen.add(s);
|
|
118
|
+
out.push(s);
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function makeCollector() {
|
|
124
|
+
const list = [];
|
|
125
|
+
const seen = new Set();
|
|
126
|
+
const add = (v) => {
|
|
127
|
+
if (typeof v !== 'string') return;
|
|
128
|
+
const s = v.trim().slice(0, MAX_MODEL_LEN);
|
|
129
|
+
if (!s || seen.has(s)) return;
|
|
130
|
+
if (!MODEL_TOKEN_RE.test(s)) return;
|
|
131
|
+
if (NON_MODEL_WORDS.has(s.toLowerCase())) return;
|
|
132
|
+
seen.add(s);
|
|
133
|
+
list.push(s);
|
|
134
|
+
};
|
|
135
|
+
return { list, add };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function whichBinLocal(bin) {
|
|
139
|
+
try {
|
|
140
|
+
const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', [bin], {
|
|
141
|
+
encoding: 'utf8',
|
|
142
|
+
timeout: BIN_TIMEOUT_MS,
|
|
143
|
+
});
|
|
144
|
+
if (r.status === 0 && r.stdout) {
|
|
145
|
+
const first = String(r.stdout).split(/\r?\n/)[0].trim();
|
|
146
|
+
if (first) return first;
|
|
147
|
+
}
|
|
148
|
+
} catch (e) {
|
|
149
|
+
// abaikan
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Kebijakan ketat: hanya binary yang NAMANYA mengandung "opencode" yang boleh
|
|
155
|
+
// dijalankan — pengaman ekstra agar path config yang aneh tidak pernah
|
|
156
|
+
// mengeksekusi claude/gemini/codex.
|
|
157
|
+
function resolveOpencodeBin(configuredPath) {
|
|
158
|
+
if (typeof configuredPath === 'string' && configuredPath.trim()) {
|
|
159
|
+
const p = configuredPath.trim();
|
|
160
|
+
if (path.basename(p).toLowerCase().includes('opencode') && fs.existsSync(p)) return p;
|
|
161
|
+
}
|
|
162
|
+
return whichBinLocal('opencode');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Parse output / config ────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
// Walker JSON longgar: kunci "models"/"model_ids"/"modelIds", field
|
|
168
|
+
// id/name/model, array berisi string, dan kunci berbentuk provider/model.
|
|
169
|
+
function walkForModels(node, add, depth) {
|
|
170
|
+
if (depth > 8 || node === null || node === undefined) return;
|
|
171
|
+
if (Array.isArray(node)) {
|
|
172
|
+
for (const v of node) {
|
|
173
|
+
if (typeof v === 'string') add(v);
|
|
174
|
+
else walkForModels(v, add, depth + 1);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (typeof node !== 'object') return;
|
|
179
|
+
for (const [k, v] of Object.entries(node)) {
|
|
180
|
+
if (k === 'models' || k === 'model_ids' || k === 'modelIds') {
|
|
181
|
+
if (typeof v === 'string') add(v);
|
|
182
|
+
else if (Array.isArray(v)) {
|
|
183
|
+
for (const x of v) {
|
|
184
|
+
if (typeof x === 'string') add(x);
|
|
185
|
+
else if (x && typeof x === 'object') {
|
|
186
|
+
add(x.id || x.name || x.model);
|
|
187
|
+
walkForModels(x, add, depth + 1);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} else if (v && typeof v === 'object') {
|
|
191
|
+
for (const [mk, mv] of Object.entries(v)) {
|
|
192
|
+
add(mk);
|
|
193
|
+
if (typeof mv === 'string') add(mv);
|
|
194
|
+
else walkForModels(mv, add, depth + 1);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (typeof v === 'string') {
|
|
200
|
+
if (k === 'id' || k === 'name' || k === 'model') add(v);
|
|
201
|
+
if (PROVIDER_SLASH_RE.test(k)) add(k);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (PROVIDER_SLASH_RE.test(k)) add(k);
|
|
205
|
+
walkForModels(v, add, depth + 1);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Ambil id model dari output perintah: JSON bila bisa, kalau tidak baris teks
|
|
210
|
+
// (token pertama yang mengandung angka "/" — sengaja konservatif agar header
|
|
211
|
+
// tabel/teks bebas tidak dianggap model).
|
|
212
|
+
function extractModelIds(text) {
|
|
213
|
+
const col = makeCollector();
|
|
214
|
+
const raw = String(text || '').trim();
|
|
215
|
+
if (!raw) return col.list;
|
|
216
|
+
let parsed = null;
|
|
217
|
+
try {
|
|
218
|
+
parsed = JSON.parse(raw);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
parsed = null;
|
|
221
|
+
}
|
|
222
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
223
|
+
walkForModels(parsed, col.add, 0);
|
|
224
|
+
} else {
|
|
225
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
226
|
+
const tok = line.trim().split(/\s+/)[0];
|
|
227
|
+
if (tok && /[0-9/]/.test(tok)) col.add(tok);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return col.list;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function stripJsonComments(s) {
|
|
234
|
+
return String(s).replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:"'\\])\/\/.*$/gm, '$1');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Parse config opencode (bila ada): ~/.config/opencode/opencode[.jsonc] → ~/.opencode.
|
|
238
|
+
function opencodeConfigModels() {
|
|
239
|
+
const home = os.homedir();
|
|
240
|
+
const candidates = [
|
|
241
|
+
path.join(home, '.config', 'opencode', 'opencode.json'),
|
|
242
|
+
path.join(home, '.config', 'opencode', 'opencode.jsonc'),
|
|
243
|
+
path.join(home, '.opencode', 'opencode.json'),
|
|
244
|
+
];
|
|
245
|
+
for (const p of candidates) {
|
|
246
|
+
try {
|
|
247
|
+
if (!fs.existsSync(p)) continue;
|
|
248
|
+
const txt = fs.readFileSync(p, 'utf8');
|
|
249
|
+
let obj = null;
|
|
250
|
+
try {
|
|
251
|
+
obj = JSON.parse(txt);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
try {
|
|
254
|
+
obj = JSON.parse(stripJsonComments(txt));
|
|
255
|
+
} catch (e2) {
|
|
256
|
+
obj = null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (obj && typeof obj === 'object') {
|
|
260
|
+
const col = makeCollector();
|
|
261
|
+
walkForModels(obj, col.add, 0);
|
|
262
|
+
if (col.list.length) return col.list;
|
|
263
|
+
}
|
|
264
|
+
} catch (e) {
|
|
265
|
+
// file tidak terbaca → coba kandidat berikutnya
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function runOpencodeModels(bin) {
|
|
272
|
+
try {
|
|
273
|
+
const r = spawnSync(bin, ['models'], {
|
|
274
|
+
encoding: 'utf8',
|
|
275
|
+
timeout: BIN_TIMEOUT_MS,
|
|
276
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
277
|
+
windowsHide: true,
|
|
278
|
+
});
|
|
279
|
+
if (r.error) return '';
|
|
280
|
+
return String(r.stdout || r.stderr || '');
|
|
281
|
+
} catch (e) {
|
|
282
|
+
return '';
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── API publik ───────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
const _cache = new Map(); // type → { at, result }
|
|
289
|
+
|
|
290
|
+
function staticResult(type) {
|
|
291
|
+
const list = Array.isArray(MODEL_REGISTRY[type]) ? MODEL_REGISTRY[type] : [];
|
|
292
|
+
return { provider: String(type), source: 'static', models: sanitizeModels(list) };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Daftar model satu provider.
|
|
297
|
+
* @param {string} type tipe provider (claude_code|codex|opencode|gemini_cli|custom|…)
|
|
298
|
+
* @param {{path?:string, refresh?:boolean}} opts path = path provider dari config;
|
|
299
|
+
* refresh = lewati cache (dipakai command `luang-cli models`).
|
|
300
|
+
* @returns {{provider:string, source:'static'|'binary'|'config', models:string[]}}
|
|
301
|
+
*/
|
|
302
|
+
function modelsForProvider(type, opts = {}) {
|
|
303
|
+
const t = String(type || '');
|
|
304
|
+
if (!opts.refresh) {
|
|
305
|
+
const hit = _cache.get(t);
|
|
306
|
+
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.result;
|
|
307
|
+
}
|
|
308
|
+
let result;
|
|
309
|
+
if (DYNAMIC_OK.has(t)) {
|
|
310
|
+
const bin = resolveOpencodeBin(opts.path);
|
|
311
|
+
const dynamic = bin ? extractModelIds(runOpencodeModels(bin)) : [];
|
|
312
|
+
if (dynamic.length) {
|
|
313
|
+
result = { provider: t, source: 'binary', models: sanitizeModels(dynamic) };
|
|
314
|
+
} else {
|
|
315
|
+
const fromCfg = sanitizeModels(opencodeConfigModels());
|
|
316
|
+
result = fromCfg.length
|
|
317
|
+
? { provider: t, source: 'config', models: fromCfg }
|
|
318
|
+
: staticResult(t);
|
|
319
|
+
}
|
|
320
|
+
} else {
|
|
321
|
+
result = staticResult(t);
|
|
322
|
+
}
|
|
323
|
+
_cache.set(t, { at: Date.now(), result });
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Semua tipe provider dalam registry (atau daftar tipe tertentu). */
|
|
328
|
+
function listAllModels(types, opts = {}) {
|
|
329
|
+
const keys = Array.isArray(types) && types.length ? types : Object.keys(MODEL_REGISTRY);
|
|
330
|
+
const out = {};
|
|
331
|
+
for (const t of keys) out[t] = modelsForProvider(t, opts);
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
module.exports = {
|
|
336
|
+
MODEL_REGISTRY,
|
|
337
|
+
MAX_MODELS,
|
|
338
|
+
MAX_MODEL_LEN,
|
|
339
|
+
sanitizeModels,
|
|
340
|
+
extractModelIds,
|
|
341
|
+
modelsForProvider,
|
|
342
|
+
listAllModels,
|
|
343
|
+
};
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "luang-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "CLI Agent Orchestration Luang — login device flow, sinkronisasi provider AI, daemon tugas, dan manajemen task dari terminal. Zero-dependency, Node >= 18.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
7
7
|
"luang-cli": "bin/luang-cli.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"test": "node test/e2e
|
|
10
|
+
"test": "node test/run-e2e.js"
|
|
11
11
|
},
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": ">=18"
|
package/test/bin/opencode
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Stub provider untuk E2E offline luang-cli.
|
|
3
3
|
# Ganti binary `opencode` di PATH uji — TIDAK memanggil opencode/claude/gemini asli.
|
|
4
|
+
if [ "$1" = "models" ]; then
|
|
5
|
+
# Dipakai skenario `luang-cli models` (deteksi dinamis opencode).
|
|
6
|
+
printf '%s\n' '["stub-model-a","stub-model-b","stub-model-c"]'
|
|
7
|
+
exit 0
|
|
8
|
+
fi
|
|
4
9
|
printf 'PROVIDER-RUN %s\n' "$*"
|
|
5
10
|
exit 0
|
package/test/http-stub.js
CHANGED
|
@@ -9,11 +9,19 @@
|
|
|
9
9
|
const http = require('http');
|
|
10
10
|
|
|
11
11
|
let telemetryEnabled = false;
|
|
12
|
+
let lastHeartbeat = null;
|
|
13
|
+
let workspace = '';
|
|
12
14
|
|
|
13
15
|
process.on('message', (msg) => {
|
|
14
16
|
if (msg && typeof msg === 'object' && 'telemetry_enabled' in msg) {
|
|
15
17
|
telemetryEnabled = msg.telemetry_enabled === true;
|
|
16
18
|
}
|
|
19
|
+
if (msg && typeof msg === 'object' && 'workspace' in msg) {
|
|
20
|
+
workspace = String(msg.workspace || '');
|
|
21
|
+
}
|
|
22
|
+
if (msg && msg.cmd === 'get_heartbeat') {
|
|
23
|
+
if (typeof process.send === 'function') process.send({ cmd: 'heartbeat', body: lastHeartbeat });
|
|
24
|
+
}
|
|
17
25
|
if (msg && msg.cmd === 'exit') {
|
|
18
26
|
process.exit(0);
|
|
19
27
|
}
|
|
@@ -24,10 +32,40 @@ const server = http.createServer((req, res) => {
|
|
|
24
32
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
25
33
|
res.end(JSON.stringify({
|
|
26
34
|
success: true,
|
|
27
|
-
data: { name: 'Sesi Uji', key_id: 'k3', telemetry_enabled: telemetryEnabled, can_update_tasks: true },
|
|
35
|
+
data: { name: 'Sesi Uji', key_id: 'k3', telemetry_enabled: telemetryEnabled, workspace, can_update_tasks: true },
|
|
28
36
|
}));
|
|
29
37
|
return;
|
|
30
38
|
}
|
|
39
|
+
if (req.url.startsWith('/api/v1/agent/workspace') && req.method === 'POST') {
|
|
40
|
+
let raw = '';
|
|
41
|
+
req.on('data', (d) => { raw += d; });
|
|
42
|
+
req.on('end', () => {
|
|
43
|
+
try {
|
|
44
|
+
const b = JSON.parse(raw);
|
|
45
|
+
workspace = typeof b.workspace === 'string' ? b.workspace : workspace;
|
|
46
|
+
} catch (e) { /* abaikan */ }
|
|
47
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
48
|
+
res.end(JSON.stringify({ success: true, message: 'Workspace disimpan', data: { workspace } }));
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (req.url.startsWith('/api/v1/agent/heartbeat') && req.method === 'POST') {
|
|
53
|
+
let raw = '';
|
|
54
|
+
req.on('data', (d) => { raw += d; });
|
|
55
|
+
req.on('end', () => {
|
|
56
|
+
try {
|
|
57
|
+
lastHeartbeat = JSON.parse(raw);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
lastHeartbeat = null;
|
|
60
|
+
}
|
|
61
|
+
// Kirim payload ke induk supaya E2E bisa memeriksa kontrak `providers[].models`.
|
|
62
|
+
if (typeof process.send === 'function') process.send({ cmd: 'heartbeat', body: lastHeartbeat });
|
|
63
|
+
const n = (lastHeartbeat && Array.isArray(lastHeartbeat.providers) ? lastHeartbeat.providers : []).length;
|
|
64
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
65
|
+
res.end(JSON.stringify({ success: true, data: { providers_count: n } }));
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
31
69
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
32
70
|
res.end(JSON.stringify({ success: false, message: 'not found' }));
|
|
33
71
|
});
|