kiosapi 0.1.7 → 0.1.9

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.
@@ -1,5 +1,5 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
3
3
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
4
  /**
5
5
  * Local tools the agent can run, all confined to the current working directory and screened by an
@@ -24,14 +24,14 @@ function ignorePatterns() {
24
24
  return list;
25
25
  }
26
26
  /** A path is protected if any segment matches an ignore pattern, or it's a dotenv-style secret. */
27
- function isProtected(rel, patterns) {
27
+ export function isProtected(rel, patterns) {
28
28
  const segments = rel.split(/[\\/]/);
29
29
  if (segments.some((s) => s === '.env' || s.startsWith('.env.')))
30
30
  return true;
31
31
  return patterns.some((p) => segments.includes(p) || rel === p || rel.startsWith(`${p}/`));
32
32
  }
33
33
  /** Resolve a user/model-supplied path, rejecting anything outside the working directory or protected. */
34
- function safePath(p) {
34
+ export function safePath(p) {
35
35
  const abs = resolve(ROOT, p);
36
36
  const rel = relative(ROOT, abs);
37
37
  if (rel.startsWith('..') || isAbsolute(rel)) {
@@ -132,18 +132,189 @@ export function editFile(path, cari, ganti) {
132
132
  writeFileSync(abs, text.replace(cari, ganti));
133
133
  return `OK: ${path} diedit.`;
134
134
  }
135
- /** jalankan — run a shell command in the working dir, returning combined output (capped). */
136
- export function jalankan(perintah) {
135
+ // ---------------------------------------------------------------------------
136
+ // Glob expansion helpers for @pattern/* mentions
137
+ // ---------------------------------------------------------------------------
138
+ function globToRegex(pattern) {
139
+ const re = pattern
140
+ .replace(/\\/g, '/')
141
+ .replace(/[.+^${}()|[\]]/g, '\\$&') // escape regex chars, not * ?
142
+ .replace(/\*\*\//g, '(?:.+/)?') // **/ → optional dir prefix
143
+ .replace(/\*\*/g, '.+') // ** → any path (fallback)
144
+ .replace(/\*/g, '[^/]+') // * → single segment
145
+ .replace(/\?/g, '[^/]'); // ? → single char
146
+ return new RegExp(`^${re}$`);
147
+ }
148
+ const MAX_GLOB_FILES = 30;
149
+ const MAX_GLOB_CHARS = 150_000;
150
+ function globFiles(pattern) {
151
+ const patterns = ignorePatterns();
152
+ const re = globToRegex(pattern);
153
+ const hits = [];
154
+ const walk = (dir) => {
155
+ if (hits.length >= MAX_GLOB_FILES)
156
+ return;
157
+ for (const name of readdirSync(dir)) {
158
+ const abs = join(dir, name);
159
+ const rel = relative(ROOT, abs).replace(/\\/g, '/');
160
+ if (isProtected(rel, patterns))
161
+ continue;
162
+ const st = statSync(abs);
163
+ if (st.isDirectory()) {
164
+ walk(abs);
165
+ }
166
+ else if (re.test(rel)) {
167
+ hits.push(rel);
168
+ }
169
+ }
170
+ };
171
+ try {
172
+ walk(ROOT);
173
+ }
174
+ catch {
175
+ // ignore unreadable dirs
176
+ }
177
+ return hits.sort();
178
+ }
179
+ /** @git:subcommand shortcuts available in user messages. */
180
+ const GIT_CMDS = {
181
+ diff: ['git', 'diff', 'HEAD'],
182
+ status: ['git', 'status', '--short'],
183
+ log: ['git', 'log', '--oneline', '-10'],
184
+ staged: ['git', 'diff', '--staged'],
185
+ branch: ['git', 'branch', '-a'],
186
+ stash: ['git', 'stash', 'list'],
187
+ };
188
+ /**
189
+ * Expand @mentions in a user message:
190
+ * @git:diff / @git:status / @git:log → inject git output in <git:X> tags
191
+ * @src/**\/*.ts → inject all matched files in <file> tags
192
+ * @src/app.ts → inject single file in <file> tags
193
+ * Unresolvable tokens are left as-is.
194
+ */
195
+ export function expandAtMentions(text) {
196
+ return text.replace(/@([\w./\-:*?]+)/g, (match, p) => {
197
+ if (p.startsWith('git:')) {
198
+ const sub = p.slice(4);
199
+ const gitArgs = GIT_CMDS[sub];
200
+ if (!gitArgs) {
201
+ process.stderr.write(` [@${p}: tidak dikenal. Tersedia: ${Object.keys(GIT_CMDS)
202
+ .map((k) => `@git:${k}`)
203
+ .join(', ')}]\n`);
204
+ return match;
205
+ }
206
+ const res = spawnSync(gitArgs[0], gitArgs.slice(1), {
207
+ cwd: ROOT,
208
+ encoding: 'utf8',
209
+ timeout: 10_000,
210
+ });
211
+ const out = res.error
212
+ ? `Error: ${res.error.message}`
213
+ : (res.stdout ?? '').trim() || (res.status !== 0 ? (res.stderr ?? '').trim() : '(kosong)');
214
+ process.stderr.write(` [@${p}: injected]\n`);
215
+ return `\n<git:${sub}>\n${out}\n</git:${sub}>\n`;
216
+ }
217
+ // Glob pattern: @src/**/*.ts or @*.json etc.
218
+ if (p.includes('*') || p.includes('?')) {
219
+ const files = globFiles(p);
220
+ if (files.length === 0) {
221
+ process.stderr.write(` [@${p}: tidak ada file yang cocok]\n`);
222
+ return match;
223
+ }
224
+ const parts = [];
225
+ let total = 0;
226
+ for (const f of files) {
227
+ try {
228
+ const content = bacaFile(f);
229
+ total += content.length;
230
+ if (total > MAX_GLOB_CHARS) {
231
+ process.stderr.write(` [@${p}: limit chars tercapai, berhenti di ${parts.length} file]\n`);
232
+ break;
233
+ }
234
+ parts.push(`\n<file path="${f}">\n${content}\n</file>`);
235
+ }
236
+ catch {
237
+ // skip unreadable
238
+ }
239
+ }
240
+ process.stderr.write(` [@${p}: ${parts.length} file diinjeksi]\n`);
241
+ return parts.join('\n');
242
+ }
243
+ // Only expand if the token looks like a file path (contains / or .)
244
+ if (!p.includes('/') && !p.includes('.'))
245
+ return match;
246
+ try {
247
+ const content = bacaFile(p);
248
+ process.stderr.write(` [@${p}: injected]\n`);
249
+ return `\n<file path="${p}">\n${content}\n</file>\n`;
250
+ }
251
+ catch {
252
+ return match;
253
+ }
254
+ });
255
+ }
256
+ /** hapus_file — delete a single file. */
257
+ export function hapusFile(path) {
258
+ const { abs } = safePath(path);
259
+ if (!existsSync(abs))
260
+ return `Error: file tidak ada: ${path}`;
261
+ unlinkSync(abs);
262
+ return `OK: ${path} dihapus.`;
263
+ }
264
+ /** pindah_file — rename or move a file/directory (creates parent dirs if needed). */
265
+ export function pindahFile(dari, ke) {
266
+ const { abs: absFrom } = safePath(dari);
267
+ const { abs: absTo } = safePath(ke);
268
+ if (!existsSync(absFrom))
269
+ return `Error: path tidak ada: ${dari}`;
270
+ mkdirSync(dirname(absTo), { recursive: true });
271
+ renameSync(absFrom, absTo);
272
+ return `OK: ${dari} → ${ke}`;
273
+ }
274
+ /**
275
+ * jalankan — run a shell command, streaming output live to stdout so long installs
276
+ * (npm install, cargo build, etc.) don't leave the terminal silent for 30+ seconds.
277
+ * Output is also captured (capped at MAX_OUTPUT) and returned for the model.
278
+ */
279
+ export async function jalankan(perintah) {
137
280
  if (!perintah.trim())
138
281
  return 'Error: perintah kosong.';
139
- const res = spawnSync(perintah, {
140
- cwd: ROOT,
141
- shell: true,
142
- encoding: 'utf8',
143
- timeout: 120_000,
144
- maxBuffer: 10 * 1024 * 1024,
282
+ return new Promise((resolve) => {
283
+ let output = '';
284
+ let limitHit = false;
285
+ process.stdout.write('\n');
286
+ const proc = spawn(perintah, { cwd: ROOT, shell: true });
287
+ const onData = (chunk) => {
288
+ const text = chunk.toString();
289
+ output += text;
290
+ if (!limitHit) {
291
+ if (output.length > MAX_OUTPUT) {
292
+ limitHit = true;
293
+ process.stdout.write('\n ··· output dipotong ···\n');
294
+ }
295
+ else {
296
+ process.stdout.write(text);
297
+ }
298
+ }
299
+ };
300
+ const timer = setTimeout(() => {
301
+ proc.kill();
302
+ process.stdout.write('\n');
303
+ resolve('exit=null\nError: timeout (120 detik).');
304
+ }, 120_000);
305
+ proc.stdout?.on('data', onData);
306
+ proc.stderr?.on('data', onData);
307
+ proc.on('close', (code) => {
308
+ clearTimeout(timer);
309
+ process.stdout.write('\n');
310
+ const trimmed = output.trim();
311
+ const capped = trimmed.length > MAX_OUTPUT ? `${trimmed.slice(0, MAX_OUTPUT)}\n…[dipotong]` : trimmed;
312
+ resolve(`exit=${code ?? 'null'}\n${capped || '(tanpa output)'}`);
313
+ });
314
+ proc.on('error', (err) => {
315
+ clearTimeout(timer);
316
+ process.stdout.write('\n');
317
+ resolve(`exit=null\nError: ${err.message}`);
318
+ });
145
319
  });
146
- const out = `${res.stdout ?? ''}${res.stderr ?? ''}`.trim();
147
- const capped = out.length > MAX_OUTPUT ? `${out.slice(0, MAX_OUTPUT)}\n…[dipotong]` : out;
148
- return `exit=${res.status ?? 'null'}\n${capped || '(tanpa output)'}`;
149
320
  }
package/dist/api.js CHANGED
@@ -139,6 +139,8 @@ export async function chatComplete(model, messages, tools, onText) {
139
139
  let buffer = '';
140
140
  let content = '';
141
141
  const calls = new Map();
142
+ let promptTokens = 0;
143
+ let completionTokens = 0;
142
144
  while (true) {
143
145
  const { done, value } = await reader.read();
144
146
  if (done)
@@ -154,7 +156,13 @@ export async function chatComplete(model, messages, tools, onText) {
154
156
  if (payload === '[DONE]')
155
157
  continue;
156
158
  try {
157
- const delta = JSON.parse(payload).choices?.[0]?.delta;
159
+ const chunk = JSON.parse(payload);
160
+ // Capture usage when the gateway includes it (typically in the final chunk)
161
+ if (chunk.usage) {
162
+ promptTokens = chunk.usage.prompt_tokens ?? promptTokens;
163
+ completionTokens = chunk.usage.completion_tokens ?? completionTokens;
164
+ }
165
+ const delta = chunk.choices?.[0]?.delta;
158
166
  if (!delta)
159
167
  continue;
160
168
  if (delta.content) {
@@ -184,7 +192,12 @@ export async function chatComplete(model, messages, tools, onText) {
184
192
  type: 'function',
185
193
  function: { name: c.name, arguments: c.args },
186
194
  }));
187
- return { content: content || null, tool_calls: tool_calls.length > 0 ? tool_calls : undefined };
195
+ const usage = promptTokens + completionTokens > 0 ? { promptTokens, completionTokens } : undefined;
196
+ return {
197
+ content: content || null,
198
+ tool_calls: tool_calls.length > 0 ? tool_calls : undefined,
199
+ usage,
200
+ };
188
201
  }
189
202
  /** Parse an OpenAI SSE stream, yielding content deltas. */
190
203
  async function* consumeSSE(res) {
package/dist/commands.js CHANGED
@@ -3,10 +3,11 @@ import { readFileSync, writeFileSync } from 'node:fs';
3
3
  import { extname } from 'node:path';
4
4
  import { createInterface } from 'node:readline/promises';
5
5
  import { parseArgs } from 'node:util';
6
- import { runAgent } from './agent/run.js';
7
- import { runTeam } from './agent/team.js';
6
+ import { loadCheckpoint, runAgent } from './agent/run.js';
7
+ import { runCustomTeam, runTeam } from './agent/team.js';
8
+ import { expandAtMentions } from './agent/tools.js';
8
9
  import { createTopup, fetchBytesAuthed, fetchLatestVersion, fetchModels, fetchPemakaian, fetchSaldo, generateImage, modelSupportsTools, pollJob, resolveMediaModel, resolveModel, streamChat, streamVision, submitVideo, } from './api.js';
9
- import { clearKey, fileConfig, loadConfig, saveConfig } from './config.js';
10
+ import { clearKey, fileConfig, initProjectConfig, listTeamConfigs, loadConfig, loadProjectConfig, loadTeamConfig, saveConfig, saveTeamConfig, } from './config.js';
10
11
  import { VERSION } from './help.js';
11
12
  import { bold, cyan, dim, green, idn, prompt, promptHidden, readStdin, red, rupiah, sleep, thinking, yellow, } from './ui.js';
12
13
  const IMAGE_MIME = {
@@ -27,7 +28,15 @@ export async function cmdMasuk() {
27
28
  }
28
29
  saveConfig({ apiKey: key });
29
30
  console.log(green('✓ Tersimpan di ~/.kiosapi/config.json'));
30
- console.log(dim('Coba: kiosapi tanya "halo"'));
31
+ process.stdout.write(' Memverifikasi saldo… ');
32
+ try {
33
+ const s = await fetchSaldo();
34
+ console.log(green(`Saldo: ${rupiah(s.balance_rupiah)}`));
35
+ }
36
+ catch {
37
+ console.log(dim('(tidak bisa cek saldo — periksa koneksi atau coba: kiosapi saldo)'));
38
+ }
39
+ console.log(dim('Siap! Coba: kiosapi tanya "halo"'));
31
40
  }
32
41
  /** keluar — forget the stored key. */
33
42
  export function cmdKeluar() {
@@ -49,6 +58,16 @@ export async function cmdPeriksa() {
49
58
  catch {
50
59
  console.log(red('tidak terhubung'));
51
60
  }
61
+ if (cfg.apiKey) {
62
+ process.stdout.write(' API key saldo: ');
63
+ try {
64
+ const s = await fetchSaldo();
65
+ console.log(green(`valid — saldo ${rupiah(s.balance_rupiah)}`));
66
+ }
67
+ catch {
68
+ console.log(red('invalid atau expired — coba: kiosapi masuk'));
69
+ }
70
+ }
52
71
  const latest = await fetchLatestVersion();
53
72
  const upd = latest && isNewerVersion(latest, VERSION)
54
73
  ? yellow(` → versi ${latest} tersedia (kiosapi perbarui)`)
@@ -56,6 +75,19 @@ export async function cmdPeriksa() {
56
75
  ? green(' ✓ terbaru')
57
76
  : '';
58
77
  console.log(` Versi : ${VERSION}${upd}`);
78
+ // Tim tersimpan
79
+ const teams = listTeamConfigs();
80
+ console.log(` Tim tersimpan: ${teams.length > 0 ? teams.map((t) => cyan(t)).join(', ') : dim('(belum ada — lihat: kiosapi tim --buat)')}`);
81
+ // Checkpoint
82
+ const cp = loadCheckpoint();
83
+ if (cp) {
84
+ const when = new Date(cp.savedAt).toLocaleString('id-ID');
85
+ const turns = cp.messages.filter((m) => m.role === 'user').length;
86
+ console.log(` Checkpoint : ${yellow(`${cp.mode} · ${turns} giliran · ${when}`)} ${dim('(kiosapi lanjut)')}`);
87
+ }
88
+ else {
89
+ console.log(` Checkpoint : ${dim('(tidak ada)')}`);
90
+ }
59
91
  }
60
92
  /** Compare two dotted versions; true if `latest` is strictly newer than `current`. */
61
93
  function isNewerVersion(latest, current) {
@@ -105,7 +137,7 @@ export async function maybeNotifyUpdate() {
105
137
  saveConfig({ updateCheckedAt: now, latestVersion: latest });
106
138
  }
107
139
  if (latest && isNewerVersion(latest, VERSION)) {
108
- console.log(yellow(`Versi baru kiosapi ${latest} tersedia (sekarang ${VERSION}). Jalankan: kiosapi perbarui`));
140
+ console.log(yellow(`Versi baru kiosapi ${latest} tersedia (sekarang ${VERSION}). Jalankan: kiosapi perbarui (atau /perbarui dalam sesi)`));
109
141
  }
110
142
  }
111
143
  /** model — list available models. `--cari q` filters; `--tools` shows only tool-capable models. */
@@ -152,6 +184,7 @@ export async function cmdTanya(args) {
152
184
  text = text ? `${text}\n\n${piped}` : piped;
153
185
  if (!text)
154
186
  throw new Error('Beri pertanyaan. Contoh: kiosapi tanya "halo"');
187
+ text = expandAtMentions(text);
155
188
  const model = await resolveModel(values.model);
156
189
  const stop = thinking();
157
190
  let first = true;
@@ -290,7 +323,11 @@ async function runAgentCommand(args, mode) {
290
323
  options: { model: { type: 'string', short: 'm' }, otomatis: { type: 'boolean' } },
291
324
  allowPositionals: true,
292
325
  });
293
- const task = positionals.join(' ').trim();
326
+ let task = positionals.join(' ').trim();
327
+ // Accept piped stdin as additional context (e.g. `npm test 2>&1 | kiosapi buat "fix errors"`)
328
+ const piped = await readStdin();
329
+ if (piped)
330
+ task = task ? `${task}\n\n${piped}` : piped;
294
331
  if (!task)
295
332
  throw new Error('Beri tugas. Contoh: kiosapi buat "bikin REST API Express + tes"');
296
333
  const model = await resolveModel(values.model);
@@ -404,9 +441,151 @@ export async function cmdLihat(args) {
404
441
  stop();
405
442
  process.stdout.write('\n');
406
443
  }
444
+ /** Interactive wizard to create a custom TeamConfig from scratch. */
445
+ async function timBuatWizard(nama) {
446
+ if (!nama.trim())
447
+ throw new Error('Beri nama tim. Contoh: kiosapi tim --buat backend-team');
448
+ const DEFAULT_MODEL = 'deepseek/deepseek-v3';
449
+ console.log(bold(`Membuat tim "${nama}".`));
450
+ console.log(dim('Tambahkan peran satu per satu. Ketik Enter kosong untuk nama peran = selesai.\n'));
451
+ const peran = {};
452
+ const alurInput = [];
453
+ let idx = 1;
454
+ while (true) {
455
+ const namaPr = (await prompt(`Peran ${idx} — nama (kosong = selesai): `)).trim();
456
+ if (!namaPr)
457
+ break;
458
+ const modelPr = (await prompt(` Model (Enter = ${DEFAULT_MODEL}): `)).trim() || DEFAULT_MODEL;
459
+ const modeRaw = (await prompt(' Mode [rencana/edit/buat] (Enter = buat): '))
460
+ .trim()
461
+ .toLowerCase();
462
+ const modePr = modeRaw === 'rencana' || modeRaw === 'edit' ? modeRaw : 'buat';
463
+ const briefPr = (await prompt(' Brief/instruksi peran ini (Enter = skip): ')).trim();
464
+ peran[namaPr] = { model: modelPr, mode: modePr, brief: briefPr || undefined };
465
+ alurInput.push(namaPr);
466
+ console.log(dim(` ✓ Peran "${namaPr}" ditambahkan.\n`));
467
+ idx++;
468
+ }
469
+ if (alurInput.length === 0) {
470
+ console.log(yellow('Tidak ada peran — tim tidak disimpan.'));
471
+ return;
472
+ }
473
+ const alurRaw = (await prompt(`Urutan alur (Enter = ${alurInput.join(',')}): `)).trim();
474
+ const alur = alurRaw
475
+ ? alurRaw
476
+ .split(',')
477
+ .map((s) => s.trim())
478
+ .filter(Boolean)
479
+ : alurInput;
480
+ const config = { nama, peran, alur };
481
+ saveTeamConfig(config);
482
+ console.log(green(`\n✓ Tim "${nama}" tersimpan (${alur.length} peran: ${alur.join(' → ')}).`));
483
+ console.log(dim(` Edit : ~/.kiosapi/tim/${nama}.json`));
484
+ console.log(dim(` Jalankan: kiosapi tim --pakai ${nama} "tugas"`));
485
+ }
486
+ const ALL_COMMANDS = [
487
+ 'masuk',
488
+ 'keluar',
489
+ 'periksa',
490
+ 'perbarui',
491
+ 'model',
492
+ 'tanya',
493
+ 'ngobrol',
494
+ 'setel',
495
+ 'sambung',
496
+ 'saldo',
497
+ 'pakai',
498
+ 'isi',
499
+ 'rencana',
500
+ 'edit',
501
+ 'buat',
502
+ 'tim',
503
+ 'gambar',
504
+ 'video',
505
+ 'lihat',
506
+ 'lanjut',
507
+ 'init',
508
+ 'completion',
509
+ // English aliases
510
+ 'login',
511
+ 'logout',
512
+ 'doctor',
513
+ 'update',
514
+ 'models',
515
+ 'ask',
516
+ 'chat',
517
+ 'config',
518
+ 'connect',
519
+ 'balance',
520
+ 'usage',
521
+ 'topup',
522
+ 'plan',
523
+ 'team',
524
+ 'image',
525
+ 'vision',
526
+ 'resume',
527
+ ];
528
+ /** completion — output shell completion script for the given shell. */
529
+ export function cmdCompletion(args) {
530
+ const shell = args[0]?.toLowerCase();
531
+ const cmds = ALL_COMMANDS.join(' ');
532
+ if (shell === 'bash') {
533
+ console.log(`# Bash completion for kiosapi
534
+ # Add to ~/.bashrc: source <(kiosapi completion bash)
535
+ _kiosapi_completion() {
536
+ local cur="\${COMP_WORDS[COMP_CWORD]}"
537
+ COMPREPLY=( $(compgen -W "${cmds}" -- "$cur") )
538
+ }
539
+ complete -F _kiosapi_completion kiosapi`);
540
+ return;
541
+ }
542
+ if (shell === 'zsh') {
543
+ console.log(`# Zsh completion for kiosapi
544
+ # Add to ~/.zshrc: source <(kiosapi completion zsh)
545
+ _kiosapi() {
546
+ local -a commands
547
+ commands=(${ALL_COMMANDS.map((c) => `'${c}'`).join(' ')})
548
+ _describe 'command' commands
549
+ }
550
+ compdef _kiosapi kiosapi`);
551
+ return;
552
+ }
553
+ if (shell === 'fish') {
554
+ console.log(`# Fish completion for kiosapi
555
+ # Save to ~/.config/fish/completions/kiosapi.fish
556
+ complete -c kiosapi -f -a "${cmds}"`);
557
+ return;
558
+ }
559
+ if (shell === 'pwsh' || shell === 'powershell') {
560
+ console.log(`# PowerShell completion for kiosapi
561
+ # Add to $PROFILE:
562
+ Register-ArgumentCompleter -Native -CommandName kiosapi -ScriptBlock {
563
+ param($wordToComplete, $commandAst, $cursorPosition)
564
+ @(${ALL_COMMANDS.map((c) => `'${c}'`).join(',')}) |
565
+ Where-Object { $_ -like "$wordToComplete*" } |
566
+ ForEach-Object {
567
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
568
+ }
569
+ }`);
570
+ return;
571
+ }
572
+ console.log(`${bold('kiosapi completion')} — pasang shell completion
573
+
574
+ kiosapi completion bash → Bash
575
+ kiosapi completion zsh → Zsh
576
+ kiosapi completion fish → Fish
577
+ kiosapi completion pwsh → PowerShell
578
+
579
+ ${bold('Cara pasang:')}
580
+ ${dim('Bash:')} echo 'source <(kiosapi completion bash)' >> ~/.bashrc
581
+ ${dim('Zsh:')} echo 'source <(kiosapi completion zsh)' >> ~/.zshrc
582
+ ${dim('Fish:')} kiosapi completion fish > ~/.config/fish/completions/kiosapi.fish
583
+ ${dim('Pwsh:')} echo '. <(kiosapi completion pwsh | Out-String)' >> $PROFILE`);
584
+ }
407
585
  /**
408
- * tim — multi-agent pipeline (perencana → pengkode → peninjau). Each role can use a different model
409
- * via --perencana/--pengkode/--peninjau; roles without an override fall back to -m / the default.
586
+ * tim — multi-agent pipeline. Default: perencana → pengkode → peninjau.
587
+ * --pakai <nama> loads a saved custom team config (~/.kiosapi/tim/<nama>.json).
588
+ * --daftar lists all saved teams.
410
589
  */
411
590
  export async function cmdTim(args) {
412
591
  const { values, positionals } = parseArgs({
@@ -417,9 +596,44 @@ export async function cmdTim(args) {
417
596
  perencana: { type: 'string' },
418
597
  pengkode: { type: 'string' },
419
598
  peninjau: { type: 'string' },
599
+ pakai: { type: 'string' },
600
+ daftar: { type: 'boolean' },
601
+ buat: { type: 'string' },
420
602
  },
421
603
  allowPositionals: true,
422
604
  });
605
+ if (values.daftar) {
606
+ const teams = listTeamConfigs();
607
+ if (teams.length === 0) {
608
+ console.log(dim('Belum ada tim tersimpan.'));
609
+ console.log(dim('Buat baru: kiosapi tim --buat <nama>'));
610
+ console.log(dim('Atau dari sesi interaktif: /simpan-tim <nama>'));
611
+ }
612
+ else {
613
+ console.log(bold('Tim tersimpan:'));
614
+ for (const t of teams) {
615
+ const cfg = loadTeamConfig(t);
616
+ const alur = cfg?.alur.join(' → ') ?? '?';
617
+ console.log(` ${cyan(t)} ${dim(`(${alur})`)}`);
618
+ }
619
+ console.log(dim(`\n${teams.length} tim. Jalankan: kiosapi tim --pakai <nama> "tugas"`));
620
+ }
621
+ return;
622
+ }
623
+ if (values.buat) {
624
+ await timBuatWizard(values.buat);
625
+ return;
626
+ }
627
+ if (values.pakai) {
628
+ const config = loadTeamConfig(values.pakai);
629
+ if (!config)
630
+ throw new Error(`Tim "${values.pakai}" tidak ditemukan. Lihat: kiosapi tim --daftar`);
631
+ const task = positionals.join(' ').trim();
632
+ if (!task)
633
+ throw new Error(`Beri tugas. Contoh: kiosapi tim --pakai ${values.pakai} "tambah endpoint login"`);
634
+ await runCustomTeam(task, { config, otomatis: Boolean(values.otomatis) });
635
+ return;
636
+ }
423
637
  const task = positionals.join(' ').trim();
424
638
  if (!task)
425
639
  throw new Error('Beri tugas. Contoh: kiosapi tim "bikin endpoint /health + tes"');
@@ -470,6 +684,38 @@ export async function cmdIsi(args) {
470
684
  console.log(green(`✓ Tagihan dibuat untuk ${rupiah(amount)}.`));
471
685
  console.log(`Bayar di: ${cyan(invoice_url)}`);
472
686
  }
687
+ /**
688
+ * init — scaffold a kiosapi.json project config in the current directory.
689
+ * If one already exists, prints it and exits so the user can edit it manually.
690
+ */
691
+ export async function cmdInit() {
692
+ const existing = loadProjectConfig();
693
+ if (existing) {
694
+ console.log(yellow('kiosapi.json sudah ada (mungkin di direktori induk). Edit langsung untuk mengubah:'));
695
+ console.log(JSON.stringify(existing, null, 2));
696
+ return;
697
+ }
698
+ console.log(bold('Buat kiosapi.json — config per-project'));
699
+ console.log(dim('Kosongkan untuk pakai default global.\n'));
700
+ const chosenModel = await pickModel(undefined, fileConfig().defaultModel);
701
+ const modeRaw = (await prompt('Mode default [rencana/edit/buat] (Enter = buat): '))
702
+ .trim()
703
+ .toLowerCase();
704
+ const mode = modeRaw === 'rencana' || modeRaw === 'edit' ? modeRaw : 'buat';
705
+ const otoRaw = (await prompt('Auto-approve semua tool secara default? (y/t, Enter = t): '))
706
+ .trim()
707
+ .toLowerCase();
708
+ const config = {
709
+ ...(chosenModel ? { model: chosenModel } : {}),
710
+ mode,
711
+ otomatis: otoRaw === 'y' || otoRaw === 'ya',
712
+ };
713
+ const p = initProjectConfig(config);
714
+ console.log(green(`\n✓ ${p}`));
715
+ console.log(JSON.stringify(config, null, 2));
716
+ console.log(dim('\nTambahkan ke .gitignore jika config ini bersifat personal.'));
717
+ console.log(dim('Edit langsung untuk mengubah, atau hapus untuk kembali ke default global.'));
718
+ }
473
719
  /** Editor/IDE tools that can't be launched from a terminal — we print paste-config instead. */
474
720
  const EDITOR_TOOLS = new Set(['cursor', 'cline', 'continue', 'vscode', 'windsurf']);
475
721
  /**
package/dist/config.js CHANGED
@@ -1,9 +1,11 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { join } from 'node:path';
3
+ import { dirname, join } from 'node:path';
4
4
  const DEFAULT_BASE_URL = 'https://api.kiosapi.id';
5
- const DIR = join(homedir(), '.kiosapi');
5
+ export const DIR = join(homedir(), '.kiosapi');
6
6
  export const CONFIG_PATH = join(DIR, 'config.json');
7
+ export const CHECKPOINT_PATH = join(DIR, 'checkpoint.json');
8
+ export const TIM_DIR = join(DIR, 'tim');
7
9
  /** Read only the on-disk config (ignores env overrides) — used before writing. */
8
10
  function readFile() {
9
11
  if (!existsSync(CONFIG_PATH))
@@ -47,3 +49,59 @@ export function clearKey() {
47
49
  export function fileConfig() {
48
50
  return readFile();
49
51
  }
52
+ /** Persist a team config to ~/.kiosapi/tim/<nama>.json. */
53
+ export function saveTeamConfig(config) {
54
+ mkdirSync(TIM_DIR, { recursive: true });
55
+ writeFileSync(join(TIM_DIR, `${config.nama}.json`), `${JSON.stringify(config, null, 2)}\n`);
56
+ }
57
+ /** Load a team config by name; null if not found or invalid. */
58
+ export function loadTeamConfig(nama) {
59
+ const p = join(TIM_DIR, `${nama}.json`);
60
+ if (!existsSync(p))
61
+ return null;
62
+ try {
63
+ return JSON.parse(readFileSync(p, 'utf8'));
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ const PROJECT_CONFIG_NAME = 'kiosapi.json';
70
+ /**
71
+ * Walk up from cwd looking for kiosapi.json. Returns the first one found, or null.
72
+ * Walking up lets a project root config apply to all sub-directories.
73
+ */
74
+ export function loadProjectConfig() {
75
+ let dir = process.cwd();
76
+ while (true) {
77
+ const p = join(dir, PROJECT_CONFIG_NAME);
78
+ if (existsSync(p)) {
79
+ try {
80
+ return JSON.parse(readFileSync(p, 'utf8'));
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ const parent = dirname(dir);
87
+ if (parent === dir)
88
+ break; // filesystem root
89
+ dir = parent;
90
+ }
91
+ return null;
92
+ }
93
+ /** Write a kiosapi.json into the current directory. Returns the full path written. */
94
+ export function initProjectConfig(config) {
95
+ const p = join(process.cwd(), PROJECT_CONFIG_NAME);
96
+ writeFileSync(p, `${JSON.stringify(config, null, 2)}\n`);
97
+ return p;
98
+ }
99
+ /** List all saved team names. */
100
+ export function listTeamConfigs() {
101
+ if (!existsSync(TIM_DIR))
102
+ return [];
103
+ return readdirSync(TIM_DIR)
104
+ .filter((f) => f.endsWith('.json'))
105
+ .map((f) => f.slice(0, -5))
106
+ .sort();
107
+ }