kiosapi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js ADDED
@@ -0,0 +1,202 @@
1
+ import { loadConfig } from './config.js';
2
+ import { dim } from './ui.js';
3
+ /** Turn an upstream HTTP status into an actionable Indonesian message. */
4
+ function humanizeError(status) {
5
+ if (status === 401)
6
+ return 'Otorisasi ditolak. Jalankan: kiosapi masuk';
7
+ if (status === 402)
8
+ return 'Saldo habis. Isi saldo: kiosapi isi <jumlah> (atau kiosapi.id/dashboard/topup).';
9
+ if (status === 429)
10
+ return 'Terlalu banyak permintaan / kuota gratis habis. Coba lagi nanti.';
11
+ if (status === 404)
12
+ return 'Model tidak ditemukan. Lihat daftar: kiosapi model';
13
+ return `Permintaan gagal (HTTP ${status}).`;
14
+ }
15
+ /** GET /v1/models — public, no auth needed. */
16
+ export async function fetchModels() {
17
+ const { baseUrl } = loadConfig();
18
+ const res = await fetch(`${baseUrl}/v1/models`);
19
+ if (!res.ok)
20
+ throw new Error(`Gagal mengambil daftar model (HTTP ${res.status}).`);
21
+ const body = (await res.json());
22
+ return body.data ?? [];
23
+ }
24
+ /** Pick the model to use: explicit flag → configured default → first free text model. */
25
+ export async function resolveModel(flag) {
26
+ if (flag)
27
+ return flag;
28
+ const { defaultModel } = loadConfig();
29
+ if (defaultModel)
30
+ return defaultModel;
31
+ const models = await fetchModels();
32
+ const text = models.filter((m) => (m.kind ?? 'text') === 'text');
33
+ const pick = text.find((m) => m.tier === 'free') ?? text[0] ?? models[0];
34
+ if (!pick)
35
+ throw new Error('Tidak ada model tersedia di server.');
36
+ process.stderr.write(`${dim(`(model otomatis: ${pick.id} — atur tetap dengan: kiosapi setel model <id>)`)}\n`);
37
+ return pick.id;
38
+ }
39
+ /** Authenticated request to the gateway with a humanized error on failure. */
40
+ async function authedFetch(path, init) {
41
+ const { baseUrl, apiKey } = loadConfig();
42
+ if (!apiKey)
43
+ throw new Error('Belum masuk. Jalankan: kiosapi masuk');
44
+ const res = await fetch(`${baseUrl}${path}`, {
45
+ ...init,
46
+ headers: {
47
+ Authorization: `Bearer ${apiKey}`,
48
+ 'Content-Type': 'application/json',
49
+ ...init?.headers,
50
+ },
51
+ });
52
+ if (!res.ok)
53
+ throw new Error(humanizeError(res.status));
54
+ return res;
55
+ }
56
+ /** GET /v1/saldo — own balance summary. */
57
+ export async function fetchSaldo() {
58
+ return (await authedFetch('/v1/saldo')).json();
59
+ }
60
+ /** GET /v1/pemakaian — own usage summary over the last N days. */
61
+ export async function fetchPemakaian(days) {
62
+ return (await authedFetch(`/v1/pemakaian?hari=${days}`)).json();
63
+ }
64
+ /** POST /v1/topup — create a hosted-checkout invoice; returns its URL. */
65
+ export async function createTopup(amount) {
66
+ const res = await authedFetch('/v1/topup', { method: 'POST', body: JSON.stringify({ amount }) });
67
+ return res.json();
68
+ }
69
+ /** POST /v1/chat/completions (non-stream) with tools — returns the assistant message (for the agent). */
70
+ export async function chatComplete(model, messages, tools) {
71
+ const { baseUrl, apiKey } = loadConfig();
72
+ if (!apiKey)
73
+ throw new Error('Belum masuk. Jalankan: kiosapi masuk');
74
+ const res = await fetch(`${baseUrl}/v1/chat/completions`, {
75
+ method: 'POST',
76
+ headers: {
77
+ Authorization: `Bearer ${apiKey}`,
78
+ 'Content-Type': 'application/json',
79
+ 'X-Title': 'kiosapi-cli',
80
+ },
81
+ body: JSON.stringify({
82
+ model,
83
+ messages,
84
+ tools,
85
+ tool_choice: tools && tools.length > 0 ? 'auto' : undefined,
86
+ stream: false,
87
+ }),
88
+ });
89
+ if (!res.ok)
90
+ throw new Error(humanizeError(res.status));
91
+ const body = (await res.json());
92
+ const msg = body.choices?.[0]?.message;
93
+ if (!msg)
94
+ throw new Error('Respons model kosong.');
95
+ return { content: msg.content ?? null, tool_calls: msg.tool_calls };
96
+ }
97
+ /** Parse an OpenAI SSE stream, yielding content deltas. */
98
+ async function* consumeSSE(res) {
99
+ if (!res.body)
100
+ throw new Error('Respons kosong dari server.');
101
+ const reader = res.body.getReader();
102
+ const decoder = new TextDecoder();
103
+ let buffer = '';
104
+ while (true) {
105
+ const { done, value } = await reader.read();
106
+ if (done)
107
+ break;
108
+ buffer += decoder.decode(value, { stream: true });
109
+ const lines = buffer.split('\n');
110
+ buffer = lines.pop() ?? '';
111
+ for (const line of lines) {
112
+ const trimmed = line.trim();
113
+ if (!trimmed.startsWith('data:'))
114
+ continue;
115
+ const payload = trimmed.slice(5).trim();
116
+ if (payload === '[DONE]')
117
+ return;
118
+ try {
119
+ const evt = JSON.parse(payload);
120
+ const piece = evt.choices?.[0]?.delta?.content;
121
+ if (piece)
122
+ yield piece;
123
+ }
124
+ catch {
125
+ // ignore keep-alive / non-JSON lines
126
+ }
127
+ }
128
+ }
129
+ }
130
+ /** POST /v1/chat/completions (stream) — yields content chunks as they arrive. */
131
+ export async function* streamChat(model, messages) {
132
+ const res = await authedFetch('/v1/chat/completions', {
133
+ method: 'POST',
134
+ headers: { 'X-Title': 'kiosapi-cli' },
135
+ body: JSON.stringify({ model, messages, stream: true }),
136
+ });
137
+ yield* consumeSSE(res);
138
+ }
139
+ /** Stream a vision question over a local image (sent as a data URL). */
140
+ export async function* streamVision(model, dataUrl, question) {
141
+ const messages = [
142
+ {
143
+ role: 'user',
144
+ content: [
145
+ { type: 'text', text: question },
146
+ { type: 'image_url', image_url: { url: dataUrl } },
147
+ ],
148
+ },
149
+ ];
150
+ const res = await authedFetch('/v1/chat/completions', {
151
+ method: 'POST',
152
+ headers: { 'X-Title': 'kiosapi-cli' },
153
+ body: JSON.stringify({ model, messages, stream: true }),
154
+ });
155
+ yield* consumeSSE(res);
156
+ }
157
+ /** Pick the first model of a given media kind (or honor an explicit flag). */
158
+ export async function resolveMediaModel(flag, kind) {
159
+ if (flag)
160
+ return flag;
161
+ const models = await fetchModels();
162
+ const m = models.find((x) => x.kind === kind);
163
+ if (!m)
164
+ throw new Error(`Tidak ada model ${kind} tersedia. Lihat: kiosapi model`);
165
+ return m.id;
166
+ }
167
+ /** POST /v1/images/generations — returns the first image (base64) + cost. */
168
+ export async function generateImage(model, prompt, opts = {}) {
169
+ const res = await authedFetch('/v1/images/generations', {
170
+ method: 'POST',
171
+ body: JSON.stringify({ model, prompt, ...opts }),
172
+ });
173
+ const body = (await res.json());
174
+ const first = body.data?.[0];
175
+ if (!first)
176
+ throw new Error('Tidak ada gambar yang dihasilkan.');
177
+ return { b64: first.b64_json, mime: first.mime, cost: body.kiosapi?.cost_rupiah ?? 0 };
178
+ }
179
+ /** POST /v1/videos/generations — submit an async job. */
180
+ export async function submitVideo(model, prompt, opts = {}) {
181
+ const res = await authedFetch('/v1/videos/generations', {
182
+ method: 'POST',
183
+ body: JSON.stringify({ model, prompt, ...opts }),
184
+ });
185
+ const body = (await res.json());
186
+ if (!body.job_id)
187
+ throw new Error('Gagal memulai job video.');
188
+ return { jobId: body.job_id, estimate: body.estimate_rupiah ?? 0 };
189
+ }
190
+ /** GET /v1/jobs/:id — poll an async job. */
191
+ export async function pollJob(id) {
192
+ const res = await authedFetch(`/v1/jobs/${id}`);
193
+ return (await res.json());
194
+ }
195
+ /** Fetch bytes from an authenticated absolute URL (e.g. a job's video_url). */
196
+ export async function fetchBytesAuthed(url) {
197
+ const { apiKey } = loadConfig();
198
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
199
+ if (!res.ok)
200
+ throw new Error(humanizeError(res.status));
201
+ return new Uint8Array(await res.arrayBuffer());
202
+ }
@@ -0,0 +1,432 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+ import { extname } from 'node:path';
4
+ import { createInterface } from 'node:readline/promises';
5
+ import { parseArgs } from 'node:util';
6
+ import { runAgent } from './agent/run.js';
7
+ import { runTeam } from './agent/team.js';
8
+ import { createTopup, fetchBytesAuthed, fetchModels, fetchPemakaian, fetchSaldo, generateImage, pollJob, resolveMediaModel, resolveModel, streamChat, streamVision, submitVideo, } from './api.js';
9
+ import { clearKey, fileConfig, loadConfig, saveConfig } from './config.js';
10
+ import { bold, cyan, dim, green, idn, promptHidden, readStdin, red, rupiah, sleep, thinking, yellow, } from './ui.js';
11
+ const IMAGE_MIME = {
12
+ '.png': 'image/png',
13
+ '.jpg': 'image/jpeg',
14
+ '.jpeg': 'image/jpeg',
15
+ '.webp': 'image/webp',
16
+ '.gif': 'image/gif',
17
+ };
18
+ /** masuk — store the API key. */
19
+ export async function cmdMasuk() {
20
+ console.log(dim('Buat API key di https://kiosapi.id/dashboard/keys, lalu tempel di sini.'));
21
+ const key = (await promptHidden('API key: ')).trim();
22
+ if (!key)
23
+ throw new Error('Dibatalkan.');
24
+ if (!key.startsWith('kios_live_')) {
25
+ console.error(yellow('Peringatan: API key Kiosapi biasanya diawali "kios_live_".'));
26
+ }
27
+ saveConfig({ apiKey: key });
28
+ console.log(green('✓ Tersimpan di ~/.kiosapi/config.json'));
29
+ console.log(dim('Coba: kiosapi tanya "halo"'));
30
+ }
31
+ /** keluar — forget the stored key. */
32
+ export function cmdKeluar() {
33
+ clearKey();
34
+ console.log(green('✓ Keluar — API key dihapus dari config.'));
35
+ }
36
+ /** periksa — connectivity + settings overview. */
37
+ export async function cmdPeriksa() {
38
+ const cfg = loadConfig();
39
+ console.log(bold('Periksa Kiosapi CLI'));
40
+ console.log(` Base URL : ${cfg.baseUrl}`);
41
+ console.log(` API key : ${cfg.apiKey ? green('terpasang') : yellow('belum ada → kiosapi masuk')}`);
42
+ console.log(` Model default: ${cfg.defaultModel ?? dim('(otomatis)')}`);
43
+ process.stdout.write(' Konektivitas : ');
44
+ try {
45
+ const res = await fetch(`${cfg.baseUrl}/v1/models`);
46
+ console.log(res.ok ? green(`OK (${res.status})`) : red(`gagal (${res.status})`));
47
+ }
48
+ catch {
49
+ console.log(red('tidak terhubung'));
50
+ }
51
+ }
52
+ /** model — list available models (optionally filtered). */
53
+ export async function cmdModel(args) {
54
+ const { values } = parseArgs({
55
+ args,
56
+ options: { cari: { type: 'string', short: 'c' } },
57
+ allowPositionals: true,
58
+ });
59
+ const q = (values.cari ?? '').toLowerCase();
60
+ const models = await fetchModels();
61
+ const rows = q
62
+ ? models.filter((m) => m.id.toLowerCase().includes(q) || (m.provider ?? '').toLowerCase().includes(q))
63
+ : models;
64
+ if (rows.length === 0) {
65
+ console.log(dim('Tidak ada model yang cocok.'));
66
+ return;
67
+ }
68
+ const width = Math.min(42, Math.max(...rows.map((m) => m.id.length)));
69
+ for (const m of rows) {
70
+ const tier = (m.tier ?? '').padEnd(5);
71
+ const kind = (m.kind ?? 'text').padEnd(5);
72
+ console.log(`${m.id.padEnd(width)} ${dim(tier)} ${kind} ${dim(m.provider ?? '')}`);
73
+ }
74
+ console.log(dim(`\n${rows.length} model.`));
75
+ }
76
+ /** tanya — one-shot streaming question (also reads piped stdin as context). */
77
+ export async function cmdTanya(args) {
78
+ const { values, positionals } = parseArgs({
79
+ args,
80
+ options: { model: { type: 'string', short: 'm' } },
81
+ allowPositionals: true,
82
+ });
83
+ let text = positionals.join(' ').trim();
84
+ const piped = await readStdin();
85
+ if (piped)
86
+ text = text ? `${text}\n\n${piped}` : piped;
87
+ if (!text)
88
+ throw new Error('Beri pertanyaan. Contoh: kiosapi tanya "halo"');
89
+ const model = await resolveModel(values.model);
90
+ const stop = thinking();
91
+ let first = true;
92
+ for await (const piece of streamChat(model, [{ role: 'user', content: text }])) {
93
+ if (first) {
94
+ stop();
95
+ first = false;
96
+ }
97
+ process.stdout.write(piece);
98
+ }
99
+ if (first)
100
+ stop();
101
+ process.stdout.write('\n');
102
+ }
103
+ /** ngobrol — interactive REPL with conversation history. */
104
+ export async function cmdNgobrol(args) {
105
+ const { values } = parseArgs({
106
+ args,
107
+ options: { model: { type: 'string', short: 'm' } },
108
+ allowPositionals: false,
109
+ });
110
+ let model = await resolveModel(values.model);
111
+ const history = [];
112
+ console.log(dim(`Mode ngobrol — model ${model}. Perintah: /model <id>, /bersih, /keluar.`));
113
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
114
+ try {
115
+ while (true) {
116
+ const input = (await rl.question(cyan('› '))).trim();
117
+ if (!input)
118
+ continue;
119
+ if (input === '/keluar' || input === '/exit')
120
+ break;
121
+ if (input === '/bersih') {
122
+ history.length = 0;
123
+ console.log(dim('Riwayat dibersihkan.'));
124
+ continue;
125
+ }
126
+ if (input.startsWith('/model')) {
127
+ const id = input.split(/\s+/)[1];
128
+ if (id) {
129
+ model = id;
130
+ console.log(dim(`Model: ${model}`));
131
+ }
132
+ else {
133
+ console.log(dim(`Model saat ini: ${model}`));
134
+ }
135
+ continue;
136
+ }
137
+ history.push({ role: 'user', content: input });
138
+ let answer = '';
139
+ const stop = thinking();
140
+ let first = true;
141
+ try {
142
+ for await (const piece of streamChat(model, history)) {
143
+ if (first) {
144
+ stop();
145
+ first = false;
146
+ }
147
+ process.stdout.write(piece);
148
+ answer += piece;
149
+ }
150
+ if (first)
151
+ stop();
152
+ process.stdout.write('\n');
153
+ history.push({ role: 'assistant', content: answer });
154
+ }
155
+ catch (err) {
156
+ if (first)
157
+ stop();
158
+ history.pop();
159
+ console.error(red(err instanceof Error ? err.message : String(err)));
160
+ }
161
+ }
162
+ }
163
+ finally {
164
+ rl.close();
165
+ }
166
+ }
167
+ /** setel — read/write persisted settings. */
168
+ export function cmdSetel(args) {
169
+ const [key, ...rest] = args;
170
+ if (!key) {
171
+ console.log(JSON.stringify(fileConfig(), null, 2));
172
+ return;
173
+ }
174
+ const value = rest.join(' ').trim();
175
+ if (!value)
176
+ throw new Error('Beri nilai. Contoh: kiosapi setel model deepseek/deepseek-v3');
177
+ if (key === 'model' || key === 'model-default')
178
+ saveConfig({ defaultModel: value });
179
+ else if (key === 'base-url' || key === 'url')
180
+ saveConfig({ baseUrl: value });
181
+ else
182
+ throw new Error(`Kunci tidak dikenal: ${key} (gunakan: model | base-url)`);
183
+ console.log(green(`✓ ${key} = ${value}`));
184
+ }
185
+ /** Shared driver for the agent modes (rencana/edit/buat). */
186
+ async function runAgentCommand(args, mode) {
187
+ const { values, positionals } = parseArgs({
188
+ args,
189
+ options: { model: { type: 'string', short: 'm' }, otomatis: { type: 'boolean' } },
190
+ allowPositionals: true,
191
+ });
192
+ const task = positionals.join(' ').trim();
193
+ if (!task)
194
+ throw new Error('Beri tugas. Contoh: kiosapi buat "bikin REST API Express + tes"');
195
+ const model = await resolveModel(values.model);
196
+ await runAgent(task, mode, { model, otomatis: Boolean(values.otomatis) });
197
+ }
198
+ /** rencana — read-only: explore the codebase and produce a plan. */
199
+ export function cmdRencana(args) {
200
+ return runAgentCommand(args, 'rencana');
201
+ }
202
+ /** edit — make the requested code changes (write/edit, no shell). */
203
+ export function cmdEdit(args) {
204
+ return runAgentCommand(args, 'edit');
205
+ }
206
+ /** buat — autonomous agent: build/modify the project (write + run). */
207
+ export function cmdBuat(args) {
208
+ return runAgentCommand(args, 'buat');
209
+ }
210
+ /** gambar — generate an image and save it to a file. */
211
+ export async function cmdGambar(args) {
212
+ const { values, positionals } = parseArgs({
213
+ args,
214
+ options: {
215
+ model: { type: 'string', short: 'm' },
216
+ opsi: { type: 'string' },
217
+ rasio: { type: 'string' },
218
+ keluar: { type: 'string', short: 'o' },
219
+ },
220
+ allowPositionals: true,
221
+ });
222
+ const prompt = positionals.join(' ').trim();
223
+ if (!prompt)
224
+ throw new Error('Beri prompt. Contoh: kiosapi gambar "kucing astronot"');
225
+ const model = await resolveMediaModel(values.model, 'image');
226
+ console.log(dim(`Membuat gambar (${model})…`));
227
+ const img = await generateImage(model, prompt, {
228
+ option: values.opsi,
229
+ aspect_ratio: values.rasio,
230
+ });
231
+ const ext = img.mime.includes('jpeg') ? 'jpg' : img.mime.includes('webp') ? 'webp' : 'png';
232
+ const file = values.keluar ?? `kiosapi-${Date.now()}.${ext}`;
233
+ writeFileSync(file, Buffer.from(img.b64, 'base64'));
234
+ console.log(`${green(`✓ ${file}`)} ${dim(`(${rupiah(img.cost)})`)}`);
235
+ }
236
+ /** video — generate a video (async job), poll, and save it. */
237
+ export async function cmdVideo(args) {
238
+ const { values, positionals } = parseArgs({
239
+ args,
240
+ options: {
241
+ model: { type: 'string', short: 'm' },
242
+ opsi: { type: 'string' },
243
+ rasio: { type: 'string' },
244
+ detik: { type: 'string' },
245
+ keluar: { type: 'string', short: 'o' },
246
+ },
247
+ allowPositionals: true,
248
+ });
249
+ const prompt = positionals.join(' ').trim();
250
+ if (!prompt)
251
+ throw new Error('Beri prompt. Contoh: kiosapi video "ombak pantai senja"');
252
+ const model = await resolveMediaModel(values.model, 'video');
253
+ const duration = values.detik ? Number(values.detik) : undefined;
254
+ console.log(dim(`Memulai job video (${model})…`));
255
+ const { jobId, estimate } = await submitVideo(model, prompt, {
256
+ option: values.opsi,
257
+ aspect_ratio: values.rasio,
258
+ duration_seconds: duration,
259
+ });
260
+ console.log(dim(`Job ${jobId} — estimasi ${rupiah(estimate)}. Memproses`));
261
+ for (let i = 0; i < 120; i++) {
262
+ await sleep(5000);
263
+ const st = await pollJob(jobId);
264
+ if (st.status === 'succeeded' && st.video_url) {
265
+ const bytes = await fetchBytesAuthed(st.video_url);
266
+ const file = values.keluar ?? `kiosapi-${Date.now()}.mp4`;
267
+ writeFileSync(file, bytes);
268
+ const cost = st.cost_rupiah != null ? ` (${rupiah(st.cost_rupiah)})` : '';
269
+ console.log(`\n${green(`✓ ${file}`)}${dim(cost)}`);
270
+ return;
271
+ }
272
+ if (st.status === 'failed')
273
+ throw new Error(`\n${st.error ?? 'Video gagal dibuat.'}`);
274
+ process.stdout.write('.');
275
+ }
276
+ throw new Error('\nTimeout menunggu video. Cek lagi nanti dengan job id.');
277
+ }
278
+ /** lihat — ask a vision model about a local image. */
279
+ export async function cmdLihat(args) {
280
+ const { values, positionals } = parseArgs({
281
+ args,
282
+ options: { model: { type: 'string', short: 'm' } },
283
+ allowPositionals: true,
284
+ });
285
+ const file = positionals[0];
286
+ if (!file)
287
+ throw new Error('Beri file gambar. Contoh: kiosapi lihat foto.png "apa ini?"');
288
+ const question = positionals.slice(1).join(' ').trim() || 'Jelaskan gambar ini.';
289
+ const mime = IMAGE_MIME[extname(file).toLowerCase()] ?? 'image/png';
290
+ const dataUrl = `data:${mime};base64,${readFileSync(file).toString('base64')}`;
291
+ const model = await resolveModel(values.model);
292
+ const stop = thinking();
293
+ let first = true;
294
+ for await (const piece of streamVision(model, dataUrl, question)) {
295
+ if (first) {
296
+ stop();
297
+ first = false;
298
+ }
299
+ process.stdout.write(piece);
300
+ }
301
+ if (first)
302
+ stop();
303
+ process.stdout.write('\n');
304
+ }
305
+ /** tim — multi-agent pipeline (perencana → pengkode → peninjau). */
306
+ export async function cmdTim(args) {
307
+ const { values, positionals } = parseArgs({
308
+ args,
309
+ options: { model: { type: 'string', short: 'm' }, otomatis: { type: 'boolean' } },
310
+ allowPositionals: true,
311
+ });
312
+ const task = positionals.join(' ').trim();
313
+ if (!task)
314
+ throw new Error('Beri tugas. Contoh: kiosapi tim "bikin endpoint /health + tes"');
315
+ const model = await resolveModel(values.model);
316
+ await runTeam(task, { model, otomatis: Boolean(values.otomatis) });
317
+ }
318
+ /** saldo — show own balance, bonus tokens, and month-to-date spend. */
319
+ export async function cmdSaldo() {
320
+ const s = await fetchSaldo();
321
+ console.log(`${bold('Saldo')} : ${green(rupiah(s.balance_rupiah))}`);
322
+ console.log(`Bonus token : ${idn(s.bonus_tokens)}`);
323
+ const cap = s.monthly_cap_rupiah != null ? ` / batas ${rupiah(s.monthly_cap_rupiah)}` : '';
324
+ console.log(`Bulan ini : ${rupiah(s.month_spend_rupiah)}${dim(cap)}`);
325
+ }
326
+ /** pakai — usage summary over the last N days (default 30). */
327
+ export async function cmdPakai(args) {
328
+ const { values } = parseArgs({
329
+ args,
330
+ options: { hari: { type: 'string' } },
331
+ allowPositionals: true,
332
+ });
333
+ const days = Math.max(1, Number(values.hari ?? '30') || 30);
334
+ const p = await fetchPemakaian(days);
335
+ console.log(bold(`Pemakaian ${p.hari} hari terakhir`));
336
+ console.log(` Request : ${idn(p.requests)}`);
337
+ console.log(` Token : ${idn(p.tokens_input + p.tokens_output)} ${dim(`(${idn(p.tokens_input)} in / ${idn(p.tokens_output)} out)`)}`);
338
+ console.log(` Biaya : ${rupiah(p.cost_rupiah)}`);
339
+ if (p.top_model.length > 0) {
340
+ console.log(dim(' Model teratas:'));
341
+ for (const m of p.top_model) {
342
+ console.log(` ${m.model} ${dim(`${idn(m.requests)}×`)} ${rupiah(m.cost_rupiah)}`);
343
+ }
344
+ }
345
+ }
346
+ /** isi — create a top-up invoice and print the checkout URL. */
347
+ export async function cmdIsi(args) {
348
+ const amount = Number(args[0]);
349
+ if (!Number.isFinite(amount) || amount < 10000) {
350
+ throw new Error('Jumlah minimal Rp10.000. Contoh: kiosapi isi 50000');
351
+ }
352
+ const { invoice_url } = await createTopup(Math.floor(amount));
353
+ console.log(green(`✓ Tagihan dibuat untuk ${rupiah(amount)}.`));
354
+ console.log(`Bayar di: ${cyan(invoice_url)}`);
355
+ }
356
+ /** Editor/IDE tools that can't be launched from a terminal — we print paste-config instead. */
357
+ const EDITOR_TOOLS = new Set(['cursor', 'cline', 'continue', 'vscode', 'windsurf']);
358
+ /**
359
+ * sambung — use Kiosapi inside other OpenAI-compatible agent tools. Either launches a CLI agent
360
+ * (aider, opencode, …) with the Kiosapi env injected, prints paste-config for editor extensions, or
361
+ * emits eval-safe shell exports. Only vendor-NEUTRAL tools — first-party vendor agents (Claude Code,
362
+ * Codex CLI) are intentionally excluded for ToS compliance (see docs/cli-plan.md §13).
363
+ */
364
+ export function cmdSambung(args) {
365
+ const sep = args.indexOf('--');
366
+ const head = sep === -1 ? args : args.slice(0, sep);
367
+ const forwarded = sep === -1 ? [] : args.slice(sep + 1);
368
+ const tool = head[0];
369
+ const { baseUrl, apiKey } = loadConfig();
370
+ const openaiBase = `${baseUrl}/v1`;
371
+ if (tool === 'env') {
372
+ if (!apiKey)
373
+ throw new Error('Belum masuk. Jalankan: kiosapi masuk');
374
+ console.log(`export OPENAI_API_KEY='${apiKey}'`);
375
+ console.log(`export OPENAI_BASE_URL='${openaiBase}'`);
376
+ console.log(`export OPENAI_API_BASE='${openaiBase}'`);
377
+ return;
378
+ }
379
+ if (!tool) {
380
+ printSambungHelp(openaiBase);
381
+ return;
382
+ }
383
+ if (!apiKey)
384
+ throw new Error('Belum masuk. Jalankan: kiosapi masuk');
385
+ if (EDITOR_TOOLS.has(tool)) {
386
+ console.log(`${bold(`Setel ${tool} memakai Kiosapi (provider OpenAI-compatible):`)}
387
+ Base URL / API base : ${openaiBase}
388
+ API key : key Kiosapi-mu (kios_live_…)
389
+ Model : id model Kiosapi, mis. deepseek/deepseek-v3 (lihat: kiosapi model)
390
+
391
+ ${dim(`Tempel ke setelan "custom/OpenAI-compatible provider" di ${tool}. Jangan bagikan API key.`)}`);
392
+ return;
393
+ }
394
+ // Launch a neutral CLI agent with Kiosapi env injected (stdio inherited so it runs interactively).
395
+ const res = spawnSync(tool, forwarded, {
396
+ stdio: 'inherit',
397
+ shell: process.platform === 'win32',
398
+ env: {
399
+ ...process.env,
400
+ OPENAI_API_KEY: apiKey,
401
+ OPENAI_BASE_URL: openaiBase,
402
+ OPENAI_API_BASE: openaiBase,
403
+ },
404
+ });
405
+ if (res.error) {
406
+ if (res.error.code === 'ENOENT') {
407
+ throw new Error(`"${tool}" tidak ditemukan di PATH. Pasang dulu lalu coba lagi.\n aider : pipx install aider-chat\n opencode : npm i -g opencode-ai`);
408
+ }
409
+ throw res.error;
410
+ }
411
+ process.exitCode = res.status ?? 0;
412
+ }
413
+ function printSambungHelp(openaiBase) {
414
+ console.log(`${bold('kiosapi sambung')} — pakai Kiosapi di tool agen lain (OpenAI-compatible)
415
+
416
+ Base URL : ${openaiBase}
417
+ API key : key Kiosapi-mu (kios_live_…)
418
+
419
+ ${bold('Luncurkan agen CLI dengan env Kiosapi:')}
420
+ kiosapi sambung aider -- --model openai/deepseek/deepseek-v3
421
+ kiosapi sambung opencode
422
+
423
+ ${bold('Tool editor (tampilkan cara setel):')}
424
+ kiosapi sambung cursor
425
+ kiosapi sambung cline
426
+ kiosapi sambung continue
427
+
428
+ ${bold('Shell export (tool apa pun):')}
429
+ eval "$(kiosapi sambung env)"
430
+
431
+ ${dim('Hanya tool netral OpenAI-compatible. Agen first-party vendor sengaja tidak disertakan (kepatuhan ToS).')}`);
432
+ }
package/dist/config.js ADDED
@@ -0,0 +1,49 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ const DEFAULT_BASE_URL = 'https://api.kiosapi.id';
5
+ const DIR = join(homedir(), '.kiosapi');
6
+ export const CONFIG_PATH = join(DIR, 'config.json');
7
+ /** Read only the on-disk config (ignores env overrides) — used before writing. */
8
+ function readFile() {
9
+ if (!existsSync(CONFIG_PATH))
10
+ return {};
11
+ try {
12
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
13
+ }
14
+ catch {
15
+ return {}; // corrupt file → treat as empty
16
+ }
17
+ }
18
+ /** Effective config: env vars override the file (handy for CI / one-off use). */
19
+ export function loadConfig() {
20
+ const file = readFile();
21
+ return {
22
+ apiKey: process.env.KIOSAPI_API_KEY ?? file.apiKey,
23
+ baseUrl: process.env.KIOSAPI_BASE_URL ?? file.baseUrl ?? DEFAULT_BASE_URL,
24
+ defaultModel: file.defaultModel,
25
+ };
26
+ }
27
+ /** Merge a patch into the on-disk config and persist it (chmod 600 where supported). */
28
+ export function saveConfig(patch) {
29
+ const next = { ...readFile(), ...patch };
30
+ mkdirSync(DIR, { recursive: true });
31
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`);
32
+ try {
33
+ chmodSync(CONFIG_PATH, 0o600);
34
+ }
35
+ catch {
36
+ // not supported on Windows — ignore
37
+ }
38
+ }
39
+ /** Forget the stored API key (keeps other settings). */
40
+ export function clearKey() {
41
+ const file = readFile();
42
+ file.apiKey = undefined;
43
+ mkdirSync(DIR, { recursive: true });
44
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(file, null, 2)}\n`);
45
+ }
46
+ /** The raw on-disk config (for `setel` with no args). */
47
+ export function fileConfig() {
48
+ return readFile();
49
+ }