@gilangranggaid/kilua-agent 1.0.4 → 1.0.7

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.
Files changed (3) hide show
  1. package/bin/kilua.js +584 -0
  2. package/bin/kilua.mjs +151 -46
  3. package/package.json +4 -4
package/bin/kilua.js ADDED
@@ -0,0 +1,584 @@
1
+ #!/usr/bin/env node
2
+ import readline from "node:readline";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { execSync } from "node:child_process";
7
+
8
+ // ponytail: autonomous single-file AI Coding Agent CLI with slash commands, local skills & test execution.
9
+ const KILUA_DIR = path.join(os.homedir(), ".kilua");
10
+ const SETTINGS_PATH = path.join(KILUA_DIR, "settings.json");
11
+ const SKILLS_DIR = path.join(KILUA_DIR, "skills");
12
+
13
+ function ensureKiluaDir() {
14
+ if (!fs.existsSync(KILUA_DIR)) fs.mkdirSync(KILUA_DIR, { recursive: true });
15
+ if (!fs.existsSync(SKILLS_DIR)) fs.mkdirSync(SKILLS_DIR, { recursive: true });
16
+ }
17
+
18
+ function loadSettings() {
19
+ ensureKiluaDir();
20
+ try { return JSON.parse(fs.readFileSync(SETTINGS_PATH, "utf8")); } catch { return {}; }
21
+ }
22
+
23
+ function saveSettings(settings) {
24
+ ensureKiluaDir();
25
+ fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
26
+ }
27
+
28
+ function listInstalledSkills() {
29
+ ensureKiluaDir();
30
+ const skills = [];
31
+ try {
32
+ const entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true });
33
+ for (const entry of entries) {
34
+ const full = path.join(SKILLS_DIR, entry.name);
35
+ if (entry.isDirectory()) {
36
+ const readme = path.join(full, "SKILL.md");
37
+ const doc = fs.existsSync(readme) ? fs.readFileSync(readme, "utf8").slice(0, 300) : "No description";
38
+ skills.push({ name: entry.name, desc: doc.split("\n")[0] || entry.name });
39
+ } else if (entry.name.endsWith(".md") || entry.name.endsWith(".txt")) {
40
+ const doc = fs.readFileSync(full, "utf8").slice(0, 300);
41
+ skills.push({ name: entry.name.replace(/\.[^.]+$/, ""), desc: doc.split("\n")[0] || entry.name });
42
+ }
43
+ }
44
+ } catch {}
45
+ return skills;
46
+ }
47
+
48
+ function getSkillsPrompt() {
49
+ const list = listInstalledSkills();
50
+ if (!list.length) return "";
51
+ let prompt = "\n\n=== INSTALLED SKILLS ===\n";
52
+ for (const s of list) {
53
+ prompt += `- ${s.name}: ${s.desc}\n`;
54
+ }
55
+ return prompt;
56
+ }
57
+
58
+ const TOOLS_OPENAI = [
59
+ {
60
+ type: "function",
61
+ function: {
62
+ name: "read_file",
63
+ description: "Membaca isi file lokal.",
64
+ parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] }
65
+ }
66
+ },
67
+ {
68
+ type: "function",
69
+ function: {
70
+ name: "write_file",
71
+ description: "Menulis atau membuat file lokal.",
72
+ parameters: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] }
73
+ }
74
+ },
75
+ {
76
+ type: "function",
77
+ function: {
78
+ name: "edit_file",
79
+ description: "Mengganti teks persis di file lokal.",
80
+ parameters: { type: "object", properties: { path: { type: "string" }, old_string: { type: "string" }, new_string: { type: "string" } }, required: ["path", "old_string", "new_string"] }
81
+ }
82
+ },
83
+ {
84
+ type: "function",
85
+ function: {
86
+ name: "list_dir",
87
+ description: "Melihat daftar file di folder.",
88
+ parameters: { type: "object", properties: { path: { type: "string" } } }
89
+ }
90
+ },
91
+ {
92
+ type: "function",
93
+ function: {
94
+ name: "run_cmd",
95
+ description: "Menjalankan perintah terminal lokal untuk instalasi dependencies, test, build, atau verifikasi project.",
96
+ parameters: { type: "object", properties: { command: { type: "string" } }, required: ["command"] }
97
+ }
98
+ }
99
+ ];
100
+
101
+ const TOOLS_ANTHROPIC = TOOLS_OPENAI.map(t => ({
102
+ name: t.function.name,
103
+ description: t.function.description,
104
+ input_schema: t.function.parameters
105
+ }));
106
+
107
+ function executeLocalTool(name, args) {
108
+ const cwd = process.cwd();
109
+ try {
110
+ if (name === "read_file") {
111
+ const full = path.resolve(cwd, args.path);
112
+ return fs.existsSync(full) ? fs.readFileSync(full, "utf8").slice(0, 40000) : `Error: ${args.path} not found.`;
113
+ }
114
+ if (name === "write_file") {
115
+ const full = path.resolve(cwd, args.path);
116
+ fs.mkdirSync(path.dirname(full), { recursive: true });
117
+ fs.writeFileSync(full, args.content, "utf8");
118
+ return `✓ Written: ${args.path}`;
119
+ }
120
+ if (name === "edit_file") {
121
+ const full = path.resolve(cwd, args.path);
122
+ if (!fs.existsSync(full)) return `Error: ${args.path} not found.`;
123
+ const src = fs.readFileSync(full, "utf8");
124
+ if (!src.includes(args.old_string)) return `Error: old_string not matched in ${args.path}.`;
125
+ fs.writeFileSync(full, src.replace(args.old_string, args.new_string), "utf8");
126
+ return `✓ Edited: ${args.path}`;
127
+ }
128
+ if (name === "list_dir") {
129
+ const target = path.resolve(cwd, args.path || ".");
130
+ return fs.readdirSync(target, { withFileTypes: true }).map(it => `${it.isDirectory() ? "[DIR]" : "[FILE]"} ${it.name}`).join("\n");
131
+ }
132
+ if (name === "run_cmd") {
133
+ const out = execSync(args.command, { cwd, encoding: "utf8", timeout: 120000, stdio: ["pipe", "pipe", "pipe"] });
134
+ return out.trim() || "(Done with exit code 0)";
135
+ }
136
+ } catch (err) {
137
+ return `Error: ${err.stderr || err.message}`;
138
+ }
139
+ return `Error: Unknown tool ${name}`;
140
+ }
141
+
142
+ function promptQuestion(rl, query) {
143
+ return new Promise(res => rl.question(query, res));
144
+ }
145
+
146
+ async function runInteractiveConfig() {
147
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
148
+
149
+ console.log("\x1b[35m=== KILUA CONFIG ===\x1b[0m\nPath: " + SETTINGS_PATH);
150
+ console.log("1) 9Router Local / Claude Code Proxy (Port 20128)");
151
+ console.log("2) TokenHarbor / 9Router / OpenRouter (OpenAI-compatible)");
152
+ console.log("3) Google Gemini (AI Studio Key)\n");
153
+
154
+ const choice = (await promptQuestion(rl, "Pilihan [1/2/3] (default 1): ")).trim() || "1";
155
+ let settings = {};
156
+
157
+ if (choice === "1") {
158
+ const defaultUrl = "http://127.0.0.1:20128/v1";
159
+ const defaultModel = "harobor";
160
+
161
+ const baseUrl = (await promptQuestion(rl, `Base URL (${defaultUrl}): `)).trim() || defaultUrl;
162
+ const key = (await promptQuestion(rl, `API Key/Token (kosongkan jika tidak perlu): `)).trim();
163
+ const model = (await promptQuestion(rl, `Model (${defaultModel}): `)).trim() || defaultModel;
164
+
165
+ settings = {
166
+ provider: "anthropic",
167
+ base_url: baseUrl.replace(/\/+$/, ""),
168
+ api_key: key,
169
+ model
170
+ };
171
+ } else if (choice === "3") {
172
+ const key = (await promptQuestion(rl, "Masukkan GEMINI_API_KEY: ")).trim();
173
+ const model = (await promptQuestion(rl, "Nama Model (gemini-3.5-flash-lite): ")).trim() || "gemini-3.5-flash-lite";
174
+ settings = {
175
+ provider: "openai",
176
+ base_url: "https://generativelanguage.googleapis.com/v1beta/openai",
177
+ api_key: key,
178
+ model
179
+ };
180
+ } else {
181
+ console.log("Contoh Base URL: https://tokenharbor.ai/v1");
182
+ const baseUrl = (await promptQuestion(rl, "Base URL (https://tokenharbor.ai/v1): ")).trim() || "https://tokenharbor.ai/v1";
183
+ const key = (await promptQuestion(rl, "API Key: ")).trim();
184
+ const model = (await promptQuestion(rl, "Model (deepseek-v4.1-flash:free): ")).trim() || "deepseek-v4.1-flash:free";
185
+ settings = {
186
+ provider: "openai",
187
+ api_key: key,
188
+ base_url: baseUrl.replace(/\/+$/, ""),
189
+ model
190
+ };
191
+ }
192
+
193
+ saveSettings(settings);
194
+ rl.close();
195
+ console.log("\x1b[32m✓ Pengaturan disimpan di " + SETTINGS_PATH + "\x1b[0m\n");
196
+ return settings;
197
+ }
198
+
199
+ // Stream reader universal untuk SSE (Anthropic & 9router)
200
+ async function readSseStream(res, onChunk) {
201
+ const reader = res.body.getReader();
202
+ const decoder = new TextDecoder("utf-8");
203
+ let buffer = "";
204
+
205
+ while (true) {
206
+ const { done, value } = await reader.read();
207
+ if (done) break;
208
+ buffer += decoder.decode(value, { stream: true });
209
+ const lines = buffer.split("\n");
210
+ buffer = lines.pop() || "";
211
+
212
+ for (const rawLine of lines) {
213
+ const line = rawLine.trim();
214
+ if (!line || line.startsWith("event:") || line.startsWith(":")) continue;
215
+ if (line.startsWith("data:")) {
216
+ const jsonStr = line.slice(5).trim();
217
+ if (!jsonStr || jsonStr === "[DONE]") continue;
218
+ try {
219
+ const parsed = JSON.parse(jsonStr);
220
+ onChunk(parsed);
221
+ } catch {}
222
+ }
223
+ }
224
+ }
225
+
226
+ if (buffer.trim().startsWith("data:")) {
227
+ try {
228
+ const parsed = JSON.parse(buffer.trim().slice(5).trim());
229
+ onChunk(parsed);
230
+ } catch {}
231
+ }
232
+ }
233
+
234
+ function buildSystemPrompt() {
235
+ const skillsInfo = getSkillsPrompt();
236
+ return `Kamu KILUA, AI Coding Agent otonom di terminal lokal. Folder aktif: ${process.cwd()}.
237
+ KEMAMPUAN TOOLS:
238
+ - read_file, write_file, edit_file, list_dir
239
+ - run_cmd: menjalankan perintah terminal lokal (npm, npx, git, node, python, build, test, curl, dsb).
240
+
241
+ ATURAN OTONOM KILUA (SELF-TESTING):
242
+ 1. Jika pengguna meminta membuat project/fitur/skrip, JANGAN langsung serahkan begitu saja. Kamu HARUS mencoba atau memvalidasinya sendiri terlebih dahulu menggunakan run_cmd (misal: jalankan skrip, test, build, atau cek sintaks).
243
+ 2. Jika ada error saat kamu mencoba, perbaiki langsung menggunakan edit_file/write_file sampai berhasil.
244
+ 3. Setelah berhasil dicoba dan dipastikan tidak error, barulah beri tahu pengguna hasil dan cara menjalankannya.
245
+ 4. Kamu dapat menginstall skill baru atau CLI menggunakan run_cmd (misalnya: npx skills add <nama-skill> atau npm i).${skillsInfo}
246
+
247
+ Respon ringkas, padat, dan bertindak langsung.`;
248
+ }
249
+
250
+ // Loop untuk Anthropic / 9router local (/v1/messages)
251
+ async function runAnthropicLoop(settings, convo) {
252
+ const SYSTEM = buildSystemPrompt();
253
+
254
+ let endpoint = settings.base_url.replace(/\/+$/, "");
255
+ if (!endpoint.endsWith("/messages")) endpoint += "/messages";
256
+
257
+ for (let step = 0; step < 16; step++) {
258
+ process.stdout.write("\r\x1b[2K\x1b[35m[KILUA Berpikir...]\x1b[0m ");
259
+ let res;
260
+ try {
261
+ res = await fetch(endpoint, {
262
+ method: "POST",
263
+ headers: {
264
+ "Content-Type": "application/json",
265
+ "x-api-key": settings.api_key,
266
+ Authorization: `Bearer ${settings.api_key}`,
267
+ "anthropic-version": "2023-06-01"
268
+ },
269
+ body: JSON.stringify({
270
+ model: settings.model,
271
+ max_tokens: 4096,
272
+ stream: true,
273
+ system: SYSTEM,
274
+ messages: convo,
275
+ tools: TOOLS_ANTHROPIC
276
+ })
277
+ });
278
+ } catch (e) {
279
+ console.error("\n[!] Connection error:", e.message);
280
+ return;
281
+ }
282
+
283
+ if (!res.ok) {
284
+ const errText = await res.text().catch(() => "");
285
+ console.error(`\n[!] Error (${res.status}):`, errText.slice(0, 200));
286
+ return;
287
+ }
288
+
289
+ let printedHeader = false;
290
+ let fullText = "";
291
+ const toolBlocks = [];
292
+
293
+ await readSseStream(res, (evt) => {
294
+ let deltaText = "";
295
+ if (typeof evt.delta?.text === "string") deltaText = evt.delta.text;
296
+ else if (typeof evt.delta?.content === "string") deltaText = evt.delta.content;
297
+ else if (evt.choices?.[0]?.delta?.content) deltaText = evt.choices[0].delta.content;
298
+ else if (evt.content_block?.text) deltaText = evt.content_block.text;
299
+
300
+ if (deltaText) {
301
+ if (!printedHeader) {
302
+ process.stdout.write("\r\x1b[2KKILUA > ");
303
+ printedHeader = true;
304
+ }
305
+ process.stdout.write(deltaText);
306
+ fullText += deltaText;
307
+ }
308
+
309
+ if (evt.type === "content_block_start" && evt.content_block?.type === "tool_use") {
310
+ toolBlocks[evt.index] = { ...evt.content_block, _raw_json: "" };
311
+ } else if (evt.delta?.type === "input_json_delta") {
312
+ const idx = evt.index;
313
+ if (toolBlocks[idx]) {
314
+ toolBlocks[idx]._raw_json = (toolBlocks[idx]._raw_json || "") + (evt.delta.partial_json || "");
315
+ }
316
+ } else if (evt.type === "content_block_stop") {
317
+ const idx = evt.index;
318
+ if (toolBlocks[idx]?._raw_json) {
319
+ try { toolBlocks[idx].input = JSON.parse(toolBlocks[idx]._raw_json); } catch {}
320
+ delete toolBlocks[idx]._raw_json;
321
+ }
322
+ }
323
+ });
324
+
325
+ if (printedHeader) console.log();
326
+ else process.stdout.write("\r\x1b[2K");
327
+
328
+ const activeTools = toolBlocks.filter(Boolean);
329
+ const assistantBlocks = [];
330
+ if (fullText) assistantBlocks.push({ type: "text", text: fullText });
331
+ for (const tb of activeTools) assistantBlocks.push(tb);
332
+
333
+ convo.push({ role: "assistant", content: assistantBlocks.length ? assistantBlocks : fullText });
334
+
335
+ if (!activeTools.length) break;
336
+
337
+ const toolResultContent = [];
338
+ for (const tu of activeTools) {
339
+ const name = tu.name;
340
+ const args = tu.input || {};
341
+ console.log(`\x1b[36m⚡ Tool: ${name}\x1b[0m(${JSON.stringify(args)})`);
342
+ const output = executeLocalTool(name, args);
343
+ console.log(`\x1b[90m↳ ${output.slice(0, 160).replace(/\n/g, " ")}${output.length > 160 ? "..." : ""}\x1b[0m`);
344
+ toolResultContent.push({
345
+ type: "tool_result",
346
+ tool_use_id: tu.id,
347
+ content: output
348
+ });
349
+ }
350
+ convo.push({ role: "user", content: toolResultContent });
351
+ }
352
+ }
353
+
354
+ // Loop untuk OpenAI / TokenHarbor / Gemini OpenAI protocol
355
+ async function runOpenAiLoop(settings, convo) {
356
+ const SYSTEM = buildSystemPrompt();
357
+
358
+ let endpoint = settings.base_url.replace(/\/+$/, "");
359
+ if (endpoint.includes("generativelanguage.googleapis.com") && !endpoint.includes("/openai")) {
360
+ endpoint += "/openai";
361
+ }
362
+ if (!endpoint.endsWith("/chat/completions")) {
363
+ endpoint += "/chat/completions";
364
+ }
365
+
366
+ for (let step = 0; step < 16; step++) {
367
+ process.stdout.write("\r\x1b[2K\x1b[35m[KILUA Berpikir...]\x1b[0m ");
368
+ let res;
369
+ try {
370
+ res = await fetch(endpoint, {
371
+ method: "POST",
372
+ headers: {
373
+ "Content-Type": "application/json",
374
+ Authorization: `Bearer ${settings.api_key}`,
375
+ "x-goog-api-key": settings.api_key
376
+ },
377
+ body: JSON.stringify({
378
+ model: settings.model,
379
+ stream: true,
380
+ messages: [{ role: "system", content: SYSTEM }, ...convo],
381
+ tools: TOOLS_OPENAI,
382
+ tool_choice: "auto"
383
+ })
384
+ });
385
+ } catch (e) {
386
+ console.error("\n[!] Connection error:", e.message);
387
+ return;
388
+ }
389
+
390
+ if (!res.ok) {
391
+ const errText = await res.text().catch(() => "");
392
+ console.error(`\n[!] Error (${res.status}):`, errText.slice(0, 200));
393
+ return;
394
+ }
395
+
396
+ let printedHeader = false;
397
+ let fullText = "";
398
+ const toolCallsMap = {};
399
+
400
+ await readSseStream(res, (evt) => {
401
+ const choice = evt.choices?.[0];
402
+ const delta = choice?.delta;
403
+ if (delta?.content) {
404
+ if (!printedHeader) {
405
+ process.stdout.write("\r\x1b[2KKILUA > ");
406
+ printedHeader = true;
407
+ }
408
+ process.stdout.write(delta.content);
409
+ fullText += delta.content;
410
+ }
411
+ if (delta?.tool_calls) {
412
+ for (const tc of delta.tool_calls) {
413
+ const idx = tc.index ?? 0;
414
+ if (!toolCallsMap[idx]) toolCallsMap[idx] = { id: tc.id, function: { name: tc.function?.name || "", arguments: "" } };
415
+ if (tc.function?.name) toolCallsMap[idx].function.name = tc.function.name;
416
+ if (tc.function?.arguments) toolCallsMap[idx].function.arguments += tc.function.arguments;
417
+ }
418
+ }
419
+ });
420
+
421
+ if (printedHeader) console.log();
422
+ else process.stdout.write("\r\x1b[2K");
423
+
424
+ const toolCalls = Object.values(toolCallsMap);
425
+ convo.push({ role: "assistant", content: fullText, tool_calls: toolCalls.length ? toolCalls : undefined });
426
+
427
+ if (!toolCalls.length) break;
428
+
429
+ for (const tc of toolCalls) {
430
+ const name = tc.function.name;
431
+ let args = {};
432
+ try { args = JSON.parse(tc.function.arguments || "{}"); } catch {}
433
+ console.log(`\x1b[36m⚡ Tool: ${name}\x1b[0m(${JSON.stringify(args)})`);
434
+ const output = executeLocalTool(name, args);
435
+ console.log(`\x1b[90m↳ ${output.slice(0, 160).replace(/\n/g, " ")}${output.length > 160 ? "..." : ""}\x1b[0m`);
436
+ convo.push({ role: "tool", tool_call_id: tc.id, name, content: output });
437
+ }
438
+ }
439
+ }
440
+
441
+ async function runAgent(settings, userPrompt, convo) {
442
+ const isAnthropic = settings.provider === "anthropic" || settings.base_url.includes(":20128") || settings.model === "harobor";
443
+ if (isAnthropic) {
444
+ convo.push({ role: "user", content: userPrompt });
445
+ await runAnthropicLoop(settings, convo);
446
+ } else {
447
+ convo.push({ role: "user", content: userPrompt });
448
+ await runOpenAiLoop(settings, convo);
449
+ }
450
+ }
451
+
452
+ function printHelp() {
453
+ console.log(`
454
+ \x1b[1m\x1b[35m=== PERINTAH SLASH KILUA (/) ===\x1b[0m
455
+ \x1b[36m/help\x1b[0m Lihat panduan perintah ini
456
+ \x1b[36m/models\x1b[0m Lihat dan ganti model AI saat ini
457
+ \x1b[36m/skills\x1b[0m Lihat daftar skills yang terpasang
458
+ \x1b[36m/install-skill\x1b[0m Pasang skill baru (misal: /install-skill vercel-labs/skills@find-skills)
459
+ \x1b[36m/clear\x1b[0m Bersihkan riwayat percakapan sesi ini
460
+ \x1b[36m/config\x1b[0m Ubah API Key, Provider, atau Base URL
461
+ \x1b[36m/exit\x1b[0m Keluar dari Kilua Agent
462
+ `);
463
+ }
464
+
465
+ async function handleSlashCommand(cmd, settings, convo, rl) {
466
+ const parts = cmd.trim().split(/\s+/);
467
+ const action = parts[0].toLowerCase();
468
+
469
+ if (action === "/help") {
470
+ printHelp();
471
+ return settings;
472
+ }
473
+
474
+ if (action === "/models" || action === "/model") {
475
+ console.log(`\n\x1b[33mModel aktif saat ini:\x1b[0m \x1b[32m${settings.model}\x1b[0m`);
476
+ console.log(`Rekomendasi Model:`);
477
+ console.log(`- TokenHarbor : deepseek-v4.1-flash:free`);
478
+ console.log(`- Gemini : gemini-3.5-flash-lite, gemini-2.5-flash`);
479
+ console.log(`- 9Router : harobor, claude-3-5-sonnet-20241022\n`);
480
+ const newModel = (await promptQuestion(rl, `Ganti model (Enter untuk tetap '${settings.model}'): `)).trim();
481
+ if (newModel) {
482
+ settings.model = newModel;
483
+ saveSettings(settings);
484
+ console.log(`\x1b[32m✓ Model diubah menjadi ${newModel}\x1b[0m\n`);
485
+ }
486
+ return settings;
487
+ }
488
+
489
+ if (action === "/skills" || action === "/skill") {
490
+ const list = listInstalledSkills();
491
+ console.log(`\n\x1b[35m=== SKILLS TERPASANG (${SKILLS_DIR}) ===\x1b[0m`);
492
+ if (!list.length) {
493
+ console.log(`(Belum ada skill tambahan terpasang)`);
494
+ console.log(`Ketik \x1b[36m/install-skill <nama-skill>\x1b[0m untuk menambah skill.`);
495
+ } else {
496
+ for (const s of list) {
497
+ console.log(`- \x1b[32m${s.name}\x1b[0m: ${s.desc}`);
498
+ }
499
+ }
500
+ console.log();
501
+ return settings;
502
+ }
503
+
504
+ if (action === "/install-skill") {
505
+ const target = parts.slice(1).join(" ").trim();
506
+ if (!target) {
507
+ console.log(`\x1b[33mContoh penggunaan: /install-skill vercel-labs/skills@find-skills\x1b[0m`);
508
+ return settings;
509
+ }
510
+ console.log(`\x1b[35m[Memasang skill ${target}...]\x1b[0m`);
511
+ try {
512
+ execSync(`npx skills add ${target}`, { cwd: SKILLS_DIR, stdio: "inherit" });
513
+ console.log(`\x1b[32m✓ Skill ${target} berhasil dipasang!\x1b[0m\n`);
514
+ } catch (e) {
515
+ console.error(`[!] Gagal memasang skill:`, e.message);
516
+ }
517
+ return settings;
518
+ }
519
+
520
+ if (action === "/clear") {
521
+ convo.length = 0;
522
+ console.clear();
523
+ console.log(`\x1b[32m✓ Riwayat percakapan dibersihkan.\x1b[0m\n`);
524
+ return settings;
525
+ }
526
+
527
+ if (action === "/config") {
528
+ rl.close();
529
+ const newSettings = await runInteractiveConfig();
530
+ return newSettings;
531
+ }
532
+
533
+ console.log(`Perintah tidak dikenal: ${action}. Ketik \x1b[36m/help\x1b[0m untuk daftar perintah.`);
534
+ return settings;
535
+ }
536
+
537
+ async function main() {
538
+ const args = process.argv.slice(2);
539
+ const oneShot = args.join(" ").trim();
540
+
541
+ if (args[0] === "config" || args[0] === "setup") {
542
+ await runInteractiveConfig();
543
+ process.exit(0);
544
+ }
545
+
546
+ let settings = loadSettings();
547
+
548
+ if (!settings.api_key) {
549
+ console.log("\x1b[33m[!] Belum ada konfigurasi AI key.\x1b[0m");
550
+ settings = await runInteractiveConfig();
551
+ }
552
+
553
+ const convo = [];
554
+ if (oneShot) {
555
+ await runAgent(settings, oneShot, convo);
556
+ process.exit(0);
557
+ }
558
+
559
+ console.log("\x1b[1m\x1b[35m=== KILUA AI CLI AGENT ===\x1b[0m");
560
+ console.log(`CWD : \x1b[33m${process.cwd()}\x1b[0m`);
561
+ console.log(`Provider: \x1b[32m${settings.base_url}\x1b[0m (${settings.model})`);
562
+ console.log(`Config : \x1b[90m${SETTINGS_PATH}\x1b[0m (ketik \x1b[36m/help\x1b[0m untuk perintah slash)\n`);
563
+
564
+ let rl = readline.createInterface({ input: process.stdin, output: process.stdout });
565
+ const ask = () => {
566
+ rl.question("\x1b[1mKAMU > \x1b[0m", async input => {
567
+ const q = input.trim();
568
+ if (!q) return ask();
569
+ if (q === "exit" || q === "quit" || q === "/exit") { rl.close(); process.exit(0); }
570
+ if (q.startsWith("/")) {
571
+ settings = await handleSlashCommand(q, settings, convo, rl);
572
+ if (q === "/config") {
573
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout });
574
+ }
575
+ return ask();
576
+ }
577
+ await runAgent(settings, q, convo);
578
+ ask();
579
+ });
580
+ };
581
+ ask();
582
+ }
583
+
584
+ main();
package/bin/kilua.mjs CHANGED
@@ -5,13 +5,14 @@ import path from "node:path";
5
5
  import os from "node:os";
6
6
  import { execSync } from "node:child_process";
7
7
 
8
- // ponytail: single-file agent CLI supporting real-time streaming for 9router, Anthropic & OpenAI proxies.
8
+ // ponytail: autonomous single-file AI Coding Agent CLI with slash commands, local skills & test execution.
9
9
  const KILUA_DIR = path.join(os.homedir(), ".kilua");
10
10
  const SETTINGS_PATH = path.join(KILUA_DIR, "settings.json");
11
- const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
11
+ const SKILLS_DIR = path.join(KILUA_DIR, "skills");
12
12
 
13
13
  function ensureKiluaDir() {
14
14
  if (!fs.existsSync(KILUA_DIR)) fs.mkdirSync(KILUA_DIR, { recursive: true });
15
+ if (!fs.existsSync(SKILLS_DIR)) fs.mkdirSync(SKILLS_DIR, { recursive: true });
15
16
  }
16
17
 
17
18
  function loadSettings() {
@@ -24,20 +25,34 @@ function saveSettings(settings) {
24
25
  fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
25
26
  }
26
27
 
27
- function readClaudeSettings() {
28
+ function listInstalledSkills() {
29
+ ensureKiluaDir();
30
+ const skills = [];
28
31
  try {
29
- const raw = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf8"));
30
- const env = raw.env || {};
31
- if (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_AUTH_TOKEN) {
32
- return {
33
- base_url: env.ANTHROPIC_BASE_URL || "http://127.0.0.1:20128/v1",
34
- api_key: env.ANTHROPIC_AUTH_TOKEN || "",
35
- model: env.ANTHROPIC_DEFAULT_OPUS_MODEL || env.ANTHROPIC_DEFAULT_SONNET_MODEL || "harobor",
36
- provider: "anthropic"
37
- };
32
+ const entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true });
33
+ for (const entry of entries) {
34
+ const full = path.join(SKILLS_DIR, entry.name);
35
+ if (entry.isDirectory()) {
36
+ const readme = path.join(full, "SKILL.md");
37
+ const doc = fs.existsSync(readme) ? fs.readFileSync(readme, "utf8").slice(0, 300) : "No description";
38
+ skills.push({ name: entry.name, desc: doc.split("\n")[0] || entry.name });
39
+ } else if (entry.name.endsWith(".md") || entry.name.endsWith(".txt")) {
40
+ const doc = fs.readFileSync(full, "utf8").slice(0, 300);
41
+ skills.push({ name: entry.name.replace(/\.[^.]+$/, ""), desc: doc.split("\n")[0] || entry.name });
42
+ }
38
43
  }
39
44
  } catch {}
40
- return null;
45
+ return skills;
46
+ }
47
+
48
+ function getSkillsPrompt() {
49
+ const list = listInstalledSkills();
50
+ if (!list.length) return "";
51
+ let prompt = "\n\n=== INSTALLED SKILLS ===\n";
52
+ for (const s of list) {
53
+ prompt += `- ${s.name}: ${s.desc}\n`;
54
+ }
55
+ return prompt;
41
56
  }
42
57
 
43
58
  const TOOLS_OPENAI = [
@@ -77,7 +92,7 @@ const TOOLS_OPENAI = [
77
92
  type: "function",
78
93
  function: {
79
94
  name: "run_cmd",
80
- description: "Menjalankan perintah terminal lokal.",
95
+ description: "Menjalankan perintah terminal lokal untuk instalasi dependencies, test, build, atau verifikasi project.",
81
96
  parameters: { type: "object", properties: { command: { type: "string" } }, required: ["command"] }
82
97
  }
83
98
  }
@@ -115,8 +130,8 @@ function executeLocalTool(name, args) {
115
130
  return fs.readdirSync(target, { withFileTypes: true }).map(it => `${it.isDirectory() ? "[DIR]" : "[FILE]"} ${it.name}`).join("\n");
116
131
  }
117
132
  if (name === "run_cmd") {
118
- const out = execSync(args.command, { cwd, encoding: "utf8", timeout: 60000, stdio: ["pipe", "pipe", "pipe"] });
119
- return out.trim() || "(Done)";
133
+ const out = execSync(args.command, { cwd, encoding: "utf8", timeout: 120000, stdio: ["pipe", "pipe", "pipe"] });
134
+ return out.trim() || "(Done with exit code 0)";
120
135
  }
121
136
  } catch (err) {
122
137
  return `Error: ${err.stderr || err.message}`;
@@ -130,7 +145,6 @@ function promptQuestion(rl, query) {
130
145
 
131
146
  async function runInteractiveConfig() {
132
147
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
133
- const claudeCfg = readClaudeSettings();
134
148
 
135
149
  console.log("\x1b[35m=== KILUA CONFIG ===\x1b[0m\nPath: " + SETTINGS_PATH);
136
150
  console.log("1) 9Router Local / Claude Code Proxy (Port 20128)");
@@ -141,12 +155,11 @@ async function runInteractiveConfig() {
141
155
  let settings = {};
142
156
 
143
157
  if (choice === "1") {
144
- const defaultUrl = claudeCfg?.base_url || "http://127.0.0.1:20128/v1";
145
- const defaultKey = claudeCfg?.api_key || "";
146
- const defaultModel = claudeCfg?.model || "harobor";
158
+ const defaultUrl = "http://127.0.0.1:20128/v1";
159
+ const defaultModel = "harobor";
147
160
 
148
161
  const baseUrl = (await promptQuestion(rl, `Base URL (${defaultUrl}): `)).trim() || defaultUrl;
149
- const key = (await promptQuestion(rl, `API Key/Token (${defaultKey ? defaultKey.slice(0, 10) + "..." : "masukkan token"}): `)).trim() || defaultKey;
162
+ const key = (await promptQuestion(rl, `API Key/Token (kosongkan jika tidak perlu): `)).trim();
150
163
  const model = (await promptQuestion(rl, `Model (${defaultModel}): `)).trim() || defaultModel;
151
164
 
152
165
  settings = {
@@ -157,7 +170,7 @@ async function runInteractiveConfig() {
157
170
  };
158
171
  } else if (choice === "3") {
159
172
  const key = (await promptQuestion(rl, "Masukkan GEMINI_API_KEY: ")).trim();
160
- const model = (await promptQuestion(rl, "Nama Model (gemini-2.5-flash): ")).trim() || "gemini-2.5-flash";
173
+ const model = (await promptQuestion(rl, "Nama Model (gemini-3.5-flash-lite): ")).trim() || "gemini-3.5-flash-lite";
161
174
  settings = {
162
175
  provider: "openai",
163
176
  base_url: "https://generativelanguage.googleapis.com/v1beta/openai",
@@ -218,14 +231,30 @@ async function readSseStream(res, onChunk) {
218
231
  }
219
232
  }
220
233
 
234
+ function buildSystemPrompt() {
235
+ const skillsInfo = getSkillsPrompt();
236
+ return `Kamu KILUA, AI Coding Agent otonom di terminal lokal. Folder aktif: ${process.cwd()}.
237
+ KEMAMPUAN TOOLS:
238
+ - read_file, write_file, edit_file, list_dir
239
+ - run_cmd: menjalankan perintah terminal lokal (npm, npx, git, node, python, build, test, curl, dsb).
240
+
241
+ ATURAN OTONOM KILUA (SELF-TESTING):
242
+ 1. Jika pengguna meminta membuat project/fitur/skrip, JANGAN langsung serahkan begitu saja. Kamu HARUS mencoba atau memvalidasinya sendiri terlebih dahulu menggunakan run_cmd (misal: jalankan skrip, test, build, atau cek sintaks).
243
+ 2. Jika ada error saat kamu mencoba, perbaiki langsung menggunakan edit_file/write_file sampai berhasil.
244
+ 3. Setelah berhasil dicoba dan dipastikan tidak error, barulah beri tahu pengguna hasil dan cara menjalankannya.
245
+ 4. Kamu dapat menginstall skill baru atau CLI menggunakan run_cmd (misalnya: npx skills add <nama-skill> atau npm i).${skillsInfo}
246
+
247
+ Respon ringkas, padat, dan bertindak langsung.`;
248
+ }
249
+
221
250
  // Loop untuk Anthropic / 9router local (/v1/messages)
222
251
  async function runAnthropicLoop(settings, convo) {
223
- const SYSTEM = `Kamu KILUA, AI Coding Agent di terminal lokal. Folder: ${process.cwd()}. Gunakan tools (read_file, write_file, edit_file, list_dir, run_cmd) untuk inspeksi dan eksekusi tugas coding mandiri. Respon ringkas to the point.`;
252
+ const SYSTEM = buildSystemPrompt();
224
253
 
225
254
  let endpoint = settings.base_url.replace(/\/+$/, "");
226
255
  if (!endpoint.endsWith("/messages")) endpoint += "/messages";
227
256
 
228
- for (let step = 0; step < 12; step++) {
257
+ for (let step = 0; step < 16; step++) {
229
258
  process.stdout.write("\r\x1b[2K\x1b[35m[KILUA Berpikir...]\x1b[0m ");
230
259
  let res;
231
260
  try {
@@ -262,7 +291,6 @@ async function runAnthropicLoop(settings, convo) {
262
291
  const toolBlocks = [];
263
292
 
264
293
  await readSseStream(res, (evt) => {
265
- // 1. Text deltas
266
294
  let deltaText = "";
267
295
  if (typeof evt.delta?.text === "string") deltaText = evt.delta.text;
268
296
  else if (typeof evt.delta?.content === "string") deltaText = evt.delta.content;
@@ -278,7 +306,6 @@ async function runAnthropicLoop(settings, convo) {
278
306
  fullText += deltaText;
279
307
  }
280
308
 
281
- // 2. Tool deltas
282
309
  if (evt.type === "content_block_start" && evt.content_block?.type === "tool_use") {
283
310
  toolBlocks[evt.index] = { ...evt.content_block, _raw_json: "" };
284
311
  } else if (evt.delta?.type === "input_json_delta") {
@@ -313,7 +340,7 @@ async function runAnthropicLoop(settings, convo) {
313
340
  const args = tu.input || {};
314
341
  console.log(`\x1b[36m⚡ Tool: ${name}\x1b[0m(${JSON.stringify(args)})`);
315
342
  const output = executeLocalTool(name, args);
316
- console.log(`\x1b[90m↳ ${output.slice(0, 140).replace(/\n/g, " ")}${output.length > 140 ? "..." : ""}\x1b[0m`);
343
+ console.log(`\x1b[90m↳ ${output.slice(0, 160).replace(/\n/g, " ")}${output.length > 160 ? "..." : ""}\x1b[0m`);
317
344
  toolResultContent.push({
318
345
  type: "tool_result",
319
346
  tool_use_id: tu.id,
@@ -326,7 +353,7 @@ async function runAnthropicLoop(settings, convo) {
326
353
 
327
354
  // Loop untuk OpenAI / TokenHarbor / Gemini OpenAI protocol
328
355
  async function runOpenAiLoop(settings, convo) {
329
- const SYSTEM = `Kamu KILUA, AI Coding Agent di terminal lokal. Folder: ${process.cwd()}. Gunakan tools (read_file, write_file, edit_file, list_dir, run_cmd) untuk inspeksi dan eksekusi tugas coding mandiri. Respon ringkas to the point.`;
356
+ const SYSTEM = buildSystemPrompt();
330
357
 
331
358
  let endpoint = settings.base_url.replace(/\/+$/, "");
332
359
  if (endpoint.includes("generativelanguage.googleapis.com") && !endpoint.includes("/openai")) {
@@ -336,7 +363,7 @@ async function runOpenAiLoop(settings, convo) {
336
363
  endpoint += "/chat/completions";
337
364
  }
338
365
 
339
- for (let step = 0; step < 12; step++) {
366
+ for (let step = 0; step < 16; step++) {
340
367
  process.stdout.write("\r\x1b[2K\x1b[35m[KILUA Berpikir...]\x1b[0m ");
341
368
  let res;
342
369
  try {
@@ -405,7 +432,7 @@ async function runOpenAiLoop(settings, convo) {
405
432
  try { args = JSON.parse(tc.function.arguments || "{}"); } catch {}
406
433
  console.log(`\x1b[36m⚡ Tool: ${name}\x1b[0m(${JSON.stringify(args)})`);
407
434
  const output = executeLocalTool(name, args);
408
- console.log(`\x1b[90m↳ ${output.slice(0, 140).replace(/\n/g, " ")}${output.length > 140 ? "..." : ""}\x1b[0m`);
435
+ console.log(`\x1b[90m↳ ${output.slice(0, 160).replace(/\n/g, " ")}${output.length > 160 ? "..." : ""}\x1b[0m`);
409
436
  convo.push({ role: "tool", tool_call_id: tc.id, name, content: output });
410
437
  }
411
438
  }
@@ -422,6 +449,91 @@ async function runAgent(settings, userPrompt, convo) {
422
449
  }
423
450
  }
424
451
 
452
+ function printHelp() {
453
+ console.log(`
454
+ \x1b[1m\x1b[35m=== PERINTAH SLASH KILUA (/) ===\x1b[0m
455
+ \x1b[36m/help\x1b[0m Lihat panduan perintah ini
456
+ \x1b[36m/models\x1b[0m Lihat dan ganti model AI saat ini
457
+ \x1b[36m/skills\x1b[0m Lihat daftar skills yang terpasang
458
+ \x1b[36m/install-skill\x1b[0m Pasang skill baru (misal: /install-skill vercel-labs/skills@find-skills)
459
+ \x1b[36m/clear\x1b[0m Bersihkan riwayat percakapan sesi ini
460
+ \x1b[36m/config\x1b[0m Ubah API Key, Provider, atau Base URL
461
+ \x1b[36m/exit\x1b[0m Keluar dari Kilua Agent
462
+ `);
463
+ }
464
+
465
+ async function handleSlashCommand(cmd, settings, convo, rl) {
466
+ const parts = cmd.trim().split(/\s+/);
467
+ const action = parts[0].toLowerCase();
468
+
469
+ if (action === "/help") {
470
+ printHelp();
471
+ return settings;
472
+ }
473
+
474
+ if (action === "/models" || action === "/model") {
475
+ console.log(`\n\x1b[33mModel aktif saat ini:\x1b[0m \x1b[32m${settings.model}\x1b[0m`);
476
+ console.log(`Rekomendasi Model:`);
477
+ console.log(`- TokenHarbor : deepseek-v4.1-flash:free`);
478
+ console.log(`- Gemini : gemini-3.5-flash-lite, gemini-2.5-flash`);
479
+ console.log(`- 9Router : harobor, claude-3-5-sonnet-20241022\n`);
480
+ const newModel = (await promptQuestion(rl, `Ganti model (Enter untuk tetap '${settings.model}'): `)).trim();
481
+ if (newModel) {
482
+ settings.model = newModel;
483
+ saveSettings(settings);
484
+ console.log(`\x1b[32m✓ Model diubah menjadi ${newModel}\x1b[0m\n`);
485
+ }
486
+ return settings;
487
+ }
488
+
489
+ if (action === "/skills" || action === "/skill") {
490
+ const list = listInstalledSkills();
491
+ console.log(`\n\x1b[35m=== SKILLS TERPASANG (${SKILLS_DIR}) ===\x1b[0m`);
492
+ if (!list.length) {
493
+ console.log(`(Belum ada skill tambahan terpasang)`);
494
+ console.log(`Ketik \x1b[36m/install-skill <nama-skill>\x1b[0m untuk menambah skill.`);
495
+ } else {
496
+ for (const s of list) {
497
+ console.log(`- \x1b[32m${s.name}\x1b[0m: ${s.desc}`);
498
+ }
499
+ }
500
+ console.log();
501
+ return settings;
502
+ }
503
+
504
+ if (action === "/install-skill") {
505
+ const target = parts.slice(1).join(" ").trim();
506
+ if (!target) {
507
+ console.log(`\x1b[33mContoh penggunaan: /install-skill vercel-labs/skills@find-skills\x1b[0m`);
508
+ return settings;
509
+ }
510
+ console.log(`\x1b[35m[Memasang skill ${target}...]\x1b[0m`);
511
+ try {
512
+ execSync(`npx skills add ${target}`, { cwd: SKILLS_DIR, stdio: "inherit" });
513
+ console.log(`\x1b[32m✓ Skill ${target} berhasil dipasang!\x1b[0m\n`);
514
+ } catch (e) {
515
+ console.error(`[!] Gagal memasang skill:`, e.message);
516
+ }
517
+ return settings;
518
+ }
519
+
520
+ if (action === "/clear") {
521
+ convo.length = 0;
522
+ console.clear();
523
+ console.log(`\x1b[32m✓ Riwayat percakapan dibersihkan.\x1b[0m\n`);
524
+ return settings;
525
+ }
526
+
527
+ if (action === "/config") {
528
+ rl.close();
529
+ const newSettings = await runInteractiveConfig();
530
+ return newSettings;
531
+ }
532
+
533
+ console.log(`Perintah tidak dikenal: ${action}. Ketik \x1b[36m/help\x1b[0m untuk daftar perintah.`);
534
+ return settings;
535
+ }
536
+
425
537
  async function main() {
426
538
  const args = process.argv.slice(2);
427
539
  const oneShot = args.join(" ").trim();
@@ -433,16 +545,9 @@ async function main() {
433
545
 
434
546
  let settings = loadSettings();
435
547
 
436
- // Auto-import dari Claude Code jika settings kilua belum ada
437
548
  if (!settings.api_key) {
438
- const claudeCfg = readClaudeSettings();
439
- if (claudeCfg) {
440
- settings = claudeCfg;
441
- saveSettings(settings);
442
- } else {
443
- console.log("\x1b[33m[!] Belum ada konfigurasi AI key.\x1b[0m");
444
- settings = await runInteractiveConfig();
445
- }
549
+ console.log("\x1b[33m[!] Belum ada konfigurasi AI key.\x1b[0m");
550
+ settings = await runInteractiveConfig();
446
551
  }
447
552
 
448
553
  const convo = [];
@@ -454,19 +559,19 @@ async function main() {
454
559
  console.log("\x1b[1m\x1b[35m=== KILUA AI CLI AGENT ===\x1b[0m");
455
560
  console.log(`CWD : \x1b[33m${process.cwd()}\x1b[0m`);
456
561
  console.log(`Provider: \x1b[32m${settings.base_url}\x1b[0m (${settings.model})`);
457
- console.log(`Config : \x1b[90m${SETTINGS_PATH}\x1b[0m (ketik 'config' untuk ganti)\n`);
562
+ console.log(`Config : \x1b[90m${SETTINGS_PATH}\x1b[0m (ketik \x1b[36m/help\x1b[0m untuk perintah slash)\n`);
458
563
 
459
564
  let rl = readline.createInterface({ input: process.stdin, output: process.stdout });
460
565
  const ask = () => {
461
566
  rl.question("\x1b[1mKAMU > \x1b[0m", async input => {
462
567
  const q = input.trim();
463
568
  if (!q) return ask();
464
- if (q === "exit" || q === "quit") { rl.close(); process.exit(0); }
465
- if (q === "config" || q === "/config") {
466
- rl.close();
467
- await runInteractiveConfig();
468
- settings = loadSettings();
469
- rl = readline.createInterface({ input: process.stdin, output: process.stdout });
569
+ if (q === "exit" || q === "quit" || q === "/exit") { rl.close(); process.exit(0); }
570
+ if (q.startsWith("/")) {
571
+ settings = await handleSlashCommand(q, settings, convo, rl);
572
+ if (q === "/config") {
573
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout });
574
+ }
470
575
  return ask();
471
576
  }
472
577
  await runAgent(settings, q, convo);
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@gilangranggaid/kilua-agent",
3
- "version": "1.0.4",
3
+ "version": "1.0.7",
4
4
  "description": "Autonomous AI Coding Agent for your terminal and VS Code",
5
5
  "type": "module",
6
- "main": "bin/kilua.mjs",
6
+ "main": "bin/kilua.js",
7
7
  "bin": {
8
- "kilua": "./bin/kilua.mjs",
9
- "kilua-agent": "./bin/kilua.mjs"
8
+ "kilua": "./bin/kilua.js",
9
+ "kilua-agent": "./bin/kilua.js"
10
10
  },
11
11
  "files": [
12
12
  "bin",