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/README.md +150 -21
- package/bin/luang-cli.js +118 -19
- package/lib/agent.js +869 -81
- package/lib/commands.js +225 -3
- package/lib/core.js +38 -0
- package/lib/models.js +343 -0
- package/lib/ws.js +552 -0
- package/package.json +4 -1
- package/test/bin/opencode +10 -0
- package/test/http-stub.js +77 -0
- package/test/run-e2e.js +806 -0
- package/test/ws-stub.js +202 -0
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
|
+
};
|