klyro 1.0.4 → 1.0.6

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 (80) hide show
  1. package/README.md +29 -0
  2. package/dist/agent/custom-agents.d.ts +3 -0
  3. package/dist/agent/custom-agents.js +96 -0
  4. package/dist/agent/orchestrator.d.ts +22 -2
  5. package/dist/agent/orchestrator.js +30 -4
  6. package/dist/agent/runtime.d.ts +5 -0
  7. package/dist/agent/runtime.js +174 -51
  8. package/dist/checkpoints/store.d.ts +9 -0
  9. package/dist/checkpoints/store.js +20 -0
  10. package/dist/cli/auth.js +11 -3
  11. package/dist/cli/completion.js +63 -10
  12. package/dist/cli/config.d.ts +4 -4
  13. package/dist/cli/doctor.js +13 -0
  14. package/dist/cli/eval.d.ts +15 -1
  15. package/dist/cli/eval.js +34 -2
  16. package/dist/cli/hooks.d.ts +54 -5
  17. package/dist/cli/hooks.js +85 -6
  18. package/dist/cli/init.d.ts +6 -0
  19. package/dist/cli/init.js +60 -0
  20. package/dist/cli/repl.js +261 -34
  21. package/dist/cli/run.d.ts +7 -1
  22. package/dist/cli/run.js +97 -41
  23. package/dist/cli/slash/custom.d.ts +25 -0
  24. package/dist/cli/slash/custom.js +166 -0
  25. package/dist/cli/slash/parser.d.ts +16 -2
  26. package/dist/cli/slash/parser.js +67 -18
  27. package/dist/cli/update.d.ts +8 -4
  28. package/dist/cli/update.js +50 -7
  29. package/dist/context/accounting.d.ts +8 -0
  30. package/dist/context/accounting.js +18 -1
  31. package/dist/context/compaction.d.ts +1 -0
  32. package/dist/context/compaction.js +2 -1
  33. package/dist/context/memory.js +18 -1
  34. package/dist/eval/harness.d.ts +21 -3
  35. package/dist/eval/harness.js +31 -3
  36. package/dist/eval/judge.d.ts +32 -0
  37. package/dist/eval/judge.js +63 -0
  38. package/dist/eval/tasks.js +134 -0
  39. package/dist/index.js +225 -132
  40. package/dist/mcp/client.d.ts +15 -0
  41. package/dist/mcp/client.js +42 -2
  42. package/dist/mcp/config.d.ts +10 -1
  43. package/dist/mcp/config.js +64 -1
  44. package/dist/mcp/registry.d.ts +13 -0
  45. package/dist/mcp/registry.js +47 -5
  46. package/dist/mcp/remote.d.ts +29 -0
  47. package/dist/mcp/remote.js +153 -0
  48. package/dist/mcp/serve.d.ts +23 -0
  49. package/dist/mcp/serve.js +111 -0
  50. package/dist/policy/approval.d.ts +15 -1
  51. package/dist/policy/approval.js +8 -0
  52. package/dist/policy/engine.d.ts +11 -1
  53. package/dist/policy/engine.js +14 -1
  54. package/dist/policy/secret-redactor.js +4 -1
  55. package/dist/providers/endpoints.d.ts +43 -0
  56. package/dist/providers/endpoints.js +104 -0
  57. package/dist/providers.js +13 -10
  58. package/dist/shared/error-map.d.ts +19 -0
  59. package/dist/shared/error-map.js +58 -0
  60. package/dist/tools/lsp/diagnostics.d.ts +35 -4
  61. package/dist/tools/lsp/diagnostics.js +88 -9
  62. package/dist/tools/normalize.d.ts +3 -0
  63. package/dist/tools/normalize.js +8 -5
  64. package/dist/tools/search/dependencies.d.ts +2 -2
  65. package/dist/tools/shell/background.d.ts +6 -0
  66. package/dist/tools/shell/background.js +17 -0
  67. package/dist/tools/symbols/find-symbol.d.ts +1 -1
  68. package/dist/tools/symbols/find-symbol.js +8 -6
  69. package/dist/tools/types.d.ts +8 -1
  70. package/dist/tui/app.d.ts +2 -0
  71. package/dist/tui/app.js +454 -53
  72. package/dist/tui/app.test.js +66 -3
  73. package/dist/tui/approval.js +53 -1
  74. package/dist/tui/markdown.js +9 -0
  75. package/dist/tui/mouse.d.ts +26 -1
  76. package/dist/tui/mouse.js +104 -6
  77. package/dist/tui/scroll-flow.test.js +3 -1
  78. package/dist/tui/tokens.d.ts +6 -6
  79. package/dist/tui/tokens.js +9 -6
  80. package/package.json +1 -1
package/dist/cli/repl.js CHANGED
@@ -25,7 +25,7 @@ import { parseUnifiedDiff } from '../tui/diff-parser.js';
25
25
  import { parse } from './slash/parser.js';
26
26
  import { resolveProvider, providerHelp, lastProviderError } from '../providers.js';
27
27
  import { readVersion } from '../version.js';
28
- import { MouseFilter, MOUSE_ENABLE, MOUSE_DISABLE, createReadWrapper } from '../tui/mouse.js';
28
+ import { MouseFilter, MOUSE_ENABLE, MOUSE_DISABLE, PASTE_ENABLE, PASTE_DISABLE, PasteFilter, createReadWrapper } from '../tui/mouse.js';
29
29
  import { inferProviderFromBaseURL } from '../agent/registry.js';
30
30
  import { getDefaultSessionStore } from '../persistence/session.js';
31
31
  import { buildSystemPrompt, parseImageInput } from '../context/system-prompt.js';
@@ -291,6 +291,10 @@ export async function startRepl(opts = {}) {
291
291
  let lastStatus = null;
292
292
  const pendingQueue = [];
293
293
  let isMounted = false;
294
+ // Live vim input mode for the TUI (toggled by /vim, persisted to config).
295
+ let vimLive = 'insert';
296
+ // Custom-command recursion guard (a command body may invoke /commands).
297
+ const customDepthRef = { current: 0 };
294
298
  let directHooks;
295
299
  // Plain-text mirror for exit replay (scroll.md §1.2: session survives in
296
300
  // native scrollback after the alt screen is torn down). Cap 300 lines.
@@ -393,6 +397,7 @@ export async function startRepl(opts = {}) {
393
397
  // IMPORTANT: Ink 7 reads stdin via 'readable' + stdin.read() (paused mode),
394
398
  // never 'data' events — so the tap wraps read(), not emit().
395
399
  const mouseFilter = new MouseFilter();
400
+ const pasteFilter = new PasteFilter();
396
401
  const origStdinRead = process.stdin.read.bind(process.stdin);
397
402
  const origStdinEmit = process.stdin.emit.bind(process.stdin);
398
403
  let mouseTapInstalled = false;
@@ -405,18 +410,36 @@ export async function startRepl(opts = {}) {
405
410
  catch { /* ignore */ }
406
411
  }
407
412
  }
413
+ // Bracketed paste: bulk-insert into the input buffer instead of
414
+ // char-at-a-time typing (no autocomplete/history churn per char).
415
+ function dispatchPastes(pastes) {
416
+ for (const p of pastes) {
417
+ try {
418
+ if (isMounted && directHooks)
419
+ directHooks.pasteText(p);
420
+ }
421
+ catch { /* ignore */ }
422
+ }
423
+ }
408
424
  function installMouseTap() {
409
425
  if (!isAltScreen || mouseTapInstalled)
410
426
  return;
411
427
  mouseTapInstalled = true;
428
+ // Bracketed paste on: the terminal wraps pastes in ESC[200~ … ESC[201~.
429
+ try {
430
+ process.stdout.write(PASTE_ENABLE);
431
+ }
432
+ catch { /* ignore */ }
412
433
  const stdinAny = process.stdin;
413
434
  // Primary path: Ink's paused-mode read loop.
414
- stdinAny.read = createReadWrapper(origStdinRead, mouseFilter, (d) => dispatchWheels([d]));
435
+ stdinAny.read = createReadWrapper(origStdinRead, mouseFilter, (d) => dispatchWheels([d]), { onPaste: (p) => dispatchPastes([p]), pasteFilter });
415
436
  // Fallback path: flowing mode ('data' events), e.g. if any library
416
437
  // resumes the stream. Same split, same dispatch.
417
438
  stdinAny.emit = (...a) => {
418
439
  if (a[0] === 'data' && Buffer.isBuffer(a[1])) {
419
- const split = mouseFilter.push(a[1]);
440
+ const psplit = pasteFilter.push(a[1]);
441
+ dispatchPastes(psplit.pastes);
442
+ const split = mouseFilter.push(psplit.kept);
420
443
  dispatchWheels(split.wheels);
421
444
  if (split.kept.length === 0)
422
445
  return false;
@@ -430,6 +453,11 @@ export async function startRepl(opts = {}) {
430
453
  return;
431
454
  mouseTapInstalled = false;
432
455
  mouseFilter.reset();
456
+ pasteFilter.reset();
457
+ try {
458
+ process.stdout.write(PASTE_DISABLE);
459
+ }
460
+ catch { /* ignore */ }
433
461
  const stdinAny = process.stdin;
434
462
  stdinAny.read = origStdinRead;
435
463
  stdinAny.emit = origStdinEmit;
@@ -457,7 +485,9 @@ export async function startRepl(opts = {}) {
457
485
  if (!isAltScreen)
458
486
  return;
459
487
  const sink = (...args) => {
460
- const line = `[${new Date().toISOString()}] ${args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}`;
488
+ // Scrub secrets before the line reaches the ring buffer or disk —
489
+ // stray provider/tool logs must never persist credentials.
490
+ const line = redact(`[${new Date().toISOString()}] ${args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}`);
461
491
  consoleRing.push(line);
462
492
  if (consoleRing.length > 200)
463
493
  consoleRing.splice(0, consoleRing.length - 200);
@@ -700,6 +730,13 @@ export async function startRepl(opts = {}) {
700
730
  onMounted: (hooks) => {
701
731
  directHooks = hooks;
702
732
  isMounted = true;
733
+ // Apply a live vim mode toggled before mount.
734
+ if (vimLive !== 'insert') {
735
+ try {
736
+ hooks.setVimMode(vimLive);
737
+ }
738
+ catch { /* ignore */ }
739
+ }
703
740
  for (const ev of pendingQueue) {
704
741
  if (ev.kind === 'status')
705
742
  hooks.updateStatus(ev.patch);
@@ -809,6 +846,7 @@ export async function startRepl(opts = {}) {
809
846
  let activeCallId = null;
810
847
  let activeCallName = null;
811
848
  let activeCallArgs = '';
849
+ let runEndStatus = 'error';
812
850
  try {
813
851
  const result = await run({
814
852
  task: taskText,
@@ -941,6 +979,7 @@ export async function startRepl(opts = {}) {
941
979
  });
942
980
  if (result.finalText)
943
981
  lastAssistantText = result.finalText;
982
+ runEndStatus = result.status;
944
983
  if (result.verification) {
945
984
  const v = result.verification;
946
985
  queuedAppend({
@@ -980,6 +1019,12 @@ export async function startRepl(opts = {}) {
980
1019
  queuedAppend({ id: `err-${Date.now()}`, kind: 'error', message });
981
1020
  queuedStatus({ status: 'error', errorMessage: message });
982
1021
  }
1022
+ // sessionEnd hooks: best-effort end-of-run side effects. Never throws.
1023
+ try {
1024
+ const { runSessionEndHooks } = await import('./hooks.js');
1025
+ await runSessionEndHooks(cwd, sessionId, runEndStatus);
1026
+ }
1027
+ catch { /* ignore */ }
983
1028
  }
984
1029
  async function handleSlash(cmd) {
985
1030
  switch (cmd.kind) {
@@ -1050,7 +1095,30 @@ export async function startRepl(opts = {}) {
1050
1095
  return;
1051
1096
  }
1052
1097
  case 'memory': {
1053
- queuedAppend({ id: `mem-${Date.now()}`, kind: 'text', text: 'Memory: .klyro/memory/session-notes.md (stub) — use /memory to view', role: 'assistant' });
1098
+ const { loadMemory } = await import('../context/memory.js');
1099
+ const notes = (await loadMemory(cwd)).trim();
1100
+ queuedAppend({
1101
+ id: `mem-${Date.now()}`,
1102
+ kind: 'text',
1103
+ text: notes ? `Memory (.klyro/memory/session-notes.md):\n${notes.slice(0, 4000)}` : 'Memory is empty — the agent records durable notes here via memory_write.',
1104
+ role: 'assistant',
1105
+ });
1106
+ return;
1107
+ }
1108
+ case 'memory-append': {
1109
+ // Human write path: same redaction + atomicity as memory_write.
1110
+ if (!cmd.text) {
1111
+ queuedAppend({ id: `mem-${Date.now()}`, kind: 'text', text: 'usage: /memory append <text>', role: 'assistant' });
1112
+ return;
1113
+ }
1114
+ try {
1115
+ const { memoryWrite } = await import('../context/memory.js');
1116
+ const p = await memoryWrite(cwd, cmd.text);
1117
+ queuedAppend({ id: `mem-${Date.now()}`, kind: 'text', text: `Noted → ${p}`, role: 'assistant' });
1118
+ }
1119
+ catch (err) {
1120
+ queuedAppend({ id: `mem-err-${Date.now()}`, kind: 'error', message: `memory append failed: ${err instanceof Error ? err.message : String(err)}` });
1121
+ }
1054
1122
  return;
1055
1123
  }
1056
1124
  case 'jobs': {
@@ -1109,8 +1177,8 @@ export async function startRepl(opts = {}) {
1109
1177
  case 'undo': {
1110
1178
  try {
1111
1179
  const { undo } = await import('../checkpoints/store.js');
1112
- await undo(cwd);
1113
- queuedAppend({ id: `undo-${Date.now()}`, kind: 'text', text: 'Undone last checkpoint', role: 'assistant' });
1180
+ await undo(cwd, cmd.n ?? 1);
1181
+ queuedAppend({ id: `undo-${Date.now()}`, kind: 'text', text: `Undone ${cmd.n ?? 1} checkpoint(s)`, role: 'assistant' });
1114
1182
  }
1115
1183
  catch (err) {
1116
1184
  const msg = err instanceof Error ? err.message : String(err);
@@ -1119,10 +1187,47 @@ export async function startRepl(opts = {}) {
1119
1187
  return;
1120
1188
  }
1121
1189
  case 'rewind': {
1190
+ // Numbered rewind menu: bare /rewind lists snapshots (1 = latest),
1191
+ // /rewind <n> restores, /rewind <n> summary also reports reverted files.
1192
+ const { listCheckpointInfo, undo } = await import('../checkpoints/store.js');
1193
+ const infos = await listCheckpointInfo(cwd);
1194
+ if (infos.length === 0) {
1195
+ queuedAppend({ id: `rewind-${Date.now()}`, kind: 'text', text: 'No checkpoints yet — snapshots are taken after each file mutation.', role: 'assistant' });
1196
+ return;
1197
+ }
1198
+ const n = cmd.n ?? 1;
1199
+ if (cmd.n === undefined && !cmd.summary) {
1200
+ const rows = infos.slice(0, 10).map((c) => {
1201
+ const age = c.ts > 0 ? ` (${Math.max(1, Math.round((Date.now() - c.ts) / 60000))}m ago)` : '';
1202
+ return ` ${c.index}. ${c.id.slice(0, 12)} · ${c.files} file(s)${age}`;
1203
+ });
1204
+ queuedAppend({ id: `rewind-${Date.now()}`, kind: 'text', text: `Checkpoints (1 = latest):\n${rows.join('\n')}\nrun /rewind <n> to restore, /rewind <n> summary for a revert report`, role: 'assistant' });
1205
+ return;
1206
+ }
1207
+ if (n < 1 || n > infos.length) {
1208
+ queuedAppend({ id: `rewind-err-${Date.now()}`, kind: 'error', message: `rewind failed: only ${infos.length} checkpoint(s), got n=${n}` });
1209
+ return;
1210
+ }
1122
1211
  try {
1123
- const { rewind } = await import('../checkpoints/store.js');
1124
- await rewind(cwd);
1125
- queuedAppend({ id: `rewind-${Date.now()}`, kind: 'text', text: 'Rewound to last checkpoint', role: 'assistant' });
1212
+ // Capture the pre-restore dirty state so `summary` can report it.
1213
+ let before = '';
1214
+ if (cmd.summary) {
1215
+ try {
1216
+ const { execFileSync } = await import('node:child_process');
1217
+ before = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8', timeout: 5000 });
1218
+ }
1219
+ catch {
1220
+ before = '';
1221
+ }
1222
+ }
1223
+ await undo(cwd, n);
1224
+ const target = infos[n - 1];
1225
+ let text = `Rewound to checkpoint ${n} (${target.id.slice(0, 12)}, ${target.files} file(s))`;
1226
+ if (cmd.summary) {
1227
+ const files = before.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 20);
1228
+ text += files.length > 0 ? `\nReverted working-tree changes:\n${files.map((f) => ` ${f}`).join('\n')}` : '\nWorking tree was clean before restore.';
1229
+ }
1230
+ queuedAppend({ id: `rewind-${Date.now()}`, kind: 'text', text, role: 'assistant' });
1126
1231
  }
1127
1232
  catch (err) {
1128
1233
  const msg = err instanceof Error ? err.message : String(err);
@@ -1130,6 +1235,27 @@ export async function startRepl(opts = {}) {
1130
1235
  }
1131
1236
  return;
1132
1237
  }
1238
+ case 'checkpoints': {
1239
+ try {
1240
+ const { listCheckpointInfo } = await import('../checkpoints/store.js');
1241
+ const infos = await listCheckpointInfo(cwd);
1242
+ if (infos.length === 0) {
1243
+ queuedAppend({ id: `ckpt-${Date.now()}`, kind: 'text', text: 'No checkpoints yet.', role: 'assistant' });
1244
+ }
1245
+ else {
1246
+ const rows = infos.slice(0, 15).map((c) => {
1247
+ const age = c.ts > 0 ? ` (${Math.max(1, Math.round((Date.now() - c.ts) / 60000))}m ago)` : '';
1248
+ return ` ${c.index}. ${c.id.slice(0, 12)} · ${c.files} file(s)${age}`;
1249
+ });
1250
+ queuedAppend({ id: `ckpt-${Date.now()}`, kind: 'text', text: `Checkpoints (1 = latest):\n${rows.join('\n')}`, role: 'assistant' });
1251
+ }
1252
+ }
1253
+ catch (err) {
1254
+ const msg = err instanceof Error ? err.message : String(err);
1255
+ queuedAppend({ id: `ckpt-err-${Date.now()}`, kind: 'error', message: `checkpoints failed: ${msg}` });
1256
+ }
1257
+ return;
1258
+ }
1133
1259
  case 'verify': {
1134
1260
  const { detectVerifiers, primaryVerifyCommand } = await import('../verification/registry.js');
1135
1261
  const { verify } = await import('../verification/engine.js');
@@ -1297,26 +1423,18 @@ export async function startRepl(opts = {}) {
1297
1423
  return;
1298
1424
  }
1299
1425
  case 'init': {
1300
- const { writeFileSync, existsSync } = await import('node:fs');
1301
- const { join } = await import('node:path');
1302
- const target = join(cwd, 'KLYRO.md');
1303
- if (existsSync(target)) {
1304
- queuedAppend({ id: `init-${Date.now()}`, kind: 'text', text: `KLYRO.md already exists at ${target}`, role: 'assistant' });
1426
+ try {
1427
+ const { initProject, nextStepsText } = await import('./init.js');
1428
+ const { created, skipped } = await initProject(cwd);
1429
+ const lines = [
1430
+ ...created.map((f) => `created ${f}`),
1431
+ ...skipped.map((f) => `kept ${f}`),
1432
+ nextStepsText(),
1433
+ ];
1434
+ queuedAppend({ id: `init-${Date.now()}`, kind: 'text', text: lines.join('\n'), role: 'assistant' });
1305
1435
  }
1306
- else {
1307
- try {
1308
- const { runScan } = await import('./scan.js');
1309
- let out = '';
1310
- const orig = process.stdout.write.bind(process.stdout);
1311
- process.stdout.write = ((c) => { out += String(c); return true; });
1312
- await runScan({ cwd, json: false });
1313
- process.stdout.write = orig;
1314
- writeFileSync(target, `# KLYRO.md\n\nProject: ${cwd}\n\n## Stack\n\n${out.slice(0, 2000)}\n\n## Conventions\n\n- Prefer smallest change that solves the task.\n- Run verification after edits.\n`);
1315
- queuedAppend({ id: `init2-${Date.now()}`, kind: 'text', text: `created ${target}`, role: 'assistant' });
1316
- }
1317
- catch (err) {
1318
- queuedAppend({ id: `init-err-${Date.now()}`, kind: 'error', message: `init failed: ${err instanceof Error ? err.message : String(err)}` });
1319
- }
1436
+ catch (err) {
1437
+ queuedAppend({ id: `init-err-${Date.now()}`, kind: 'error', message: `init failed: ${err instanceof Error ? err.message : String(err)}` });
1320
1438
  }
1321
1439
  return;
1322
1440
  }
@@ -1613,8 +1731,13 @@ export async function startRepl(opts = {}) {
1613
1731
  case 'cancel': {
1614
1732
  ac.abort();
1615
1733
  ac = new AbortController();
1734
+ // Immediate abort cascade: background shells must not outlive the
1735
+ // cancelled run (the runtime abort path also kills them, but the
1736
+ // controller swap above means we do it here for immediacy).
1737
+ const { killAllJobs } = await import('../tools/shell/background.js');
1738
+ const killed = killAllJobs();
1616
1739
  queuedStatus({ status: 'aborted' });
1617
- queuedAppend({ id: `cancel-${Date.now()}`, kind: 'text', text: 'cancelled current operation', role: 'assistant' });
1740
+ queuedAppend({ id: `cancel-${Date.now()}`, kind: 'text', text: killed.length > 0 ? `cancelled current operation (${killed.length} background job(s) killed)` : 'cancelled current operation', role: 'assistant' });
1618
1741
  return;
1619
1742
  }
1620
1743
  case 'shell': {
@@ -2468,13 +2591,72 @@ export async function startRepl(opts = {}) {
2468
2591
  }
2469
2592
  return;
2470
2593
  }
2471
- case 'keymap':
2472
- case 'vim':
2594
+ case 'keymap': {
2595
+ // Real binding table (mirrors src/tui/app.tsx useInput + mouse.ts).
2596
+ // A stored `klyro.keymap` override is shown, not applied — the TUI
2597
+ // has one compiled keymap; the value is kept for future remapping.
2598
+ const { runConfig } = await import('./config.js');
2599
+ const capture = async (args) => {
2600
+ const orig = process.stdout.write.bind(process.stdout);
2601
+ let out = '';
2602
+ process.stdout.write = ((c) => { out += String(c); return true; });
2603
+ try {
2604
+ await runConfig(args);
2605
+ }
2606
+ finally {
2607
+ process.stdout.write = orig;
2608
+ }
2609
+ return out;
2610
+ };
2611
+ const stored = cmd.name?.trim();
2612
+ if (stored) {
2613
+ await capture(['set', 'klyro.keymap', stored]);
2614
+ queuedAppend({ id: `keymap2-${Date.now()}`, kind: 'text', text: `keymap note saved: ${stored} (display-only — the TUI uses its compiled keymap below)`, role: 'assistant' });
2615
+ return;
2616
+ }
2617
+ queuedAppend({
2618
+ id: `keymap-${Date.now()}`,
2619
+ kind: 'text',
2620
+ role: 'assistant',
2621
+ text: [
2622
+ 'Keymap (TUI compiled bindings):',
2623
+ ' Enter send · Shift+Enter newline · Tab complete slash · Esc drop queued / Esc×2 cancel run',
2624
+ ' Ctrl+C cancel (1st) / quit (2nd) · Ctrl+O expand last tool group · Ctrl+G jump bottom',
2625
+ ' PgUp/PgDn or Ctrl+U/Ctrl+D half-page · Ctrl+Home/End top/bottom · Home/End jump · Space jump to unread',
2626
+ ' Ctrl+B/F page · Shift/Ctrl+↑/↓ line · ↑/↓ history (with text) else scroll · wheel ±3 lines',
2627
+ ' Shift+drag selects · /vim toggles vim input mode · /keymap <note> saves a display note',
2628
+ ].join('\n'),
2629
+ });
2630
+ return;
2631
+ }
2632
+ case 'vim': {
2633
+ // Live vim input mode (not a config stub): toggles the TUI between
2634
+ // insert and normal mode via directHooks; persists the choice too.
2635
+ const want = cmd.state?.trim().toLowerCase();
2636
+ const next = want === 'on' || want === 'normal' ? 'normal' : want === 'off' || want === 'insert' ? 'insert' : vimLive === 'insert' ? 'normal' : 'insert';
2637
+ vimLive = next;
2638
+ try {
2639
+ const { runConfig } = await import('./config.js');
2640
+ const orig = process.stdout.write.bind(process.stdout);
2641
+ process.stdout.write = (() => true);
2642
+ try {
2643
+ await runConfig(['set', 'klyro.vim', next]);
2644
+ }
2645
+ finally {
2646
+ process.stdout.write = orig;
2647
+ }
2648
+ }
2649
+ catch { /* persist best-effort */ }
2650
+ if (isMounted && directHooks)
2651
+ directHooks.setVimMode(next);
2652
+ queuedAppend({ id: `vim-${Date.now()}`, kind: 'text', text: `vim mode: ${next}${next === 'normal' ? ' (h/l/0/$ move · 3h counts · w/b words · x/D/dd delete · i/a insert · j/k scroll)' : ''}`, role: 'assistant' });
2653
+ return;
2654
+ }
2473
2655
  case 'theme':
2474
2656
  case 'statusline':
2475
2657
  case 'output-style': {
2476
- const key = cmd.kind === 'keymap' ? 'klyro.keymap' : cmd.kind === 'vim' ? 'klyro.vim' : cmd.kind === 'theme' ? 'klyro.theme' : cmd.kind === 'statusline' ? 'klyro.statusline' : 'klyro.outputStyle';
2477
- const val = (cmd.kind === 'keymap' ? cmd.name : cmd.kind === 'vim' ? cmd.state : cmd.kind === 'theme' ? cmd.name : cmd.kind === 'statusline' ? cmd.format : cmd.style)?.trim();
2658
+ const key = cmd.kind === 'theme' ? 'klyro.theme' : cmd.kind === 'statusline' ? 'klyro.statusline' : 'klyro.outputStyle';
2659
+ const val = (cmd.kind === 'theme' ? cmd.name : cmd.kind === 'statusline' ? cmd.format : cmd.style)?.trim();
2478
2660
  const { runConfig } = await import('./config.js');
2479
2661
  const capture = async (args) => {
2480
2662
  const orig = process.stdout.write.bind(process.stdout);
@@ -2711,6 +2893,51 @@ export async function startRepl(opts = {}) {
2711
2893
  await handleSlash(parse(target + extra));
2712
2894
  return;
2713
2895
  }
2896
+ // Custom command files: .klyro/commands/*.md (project wins).
2897
+ // Body expands $1..$9/$@ and runs as a prompt (recursion-guarded).
2898
+ if (m) {
2899
+ const { loadCustomCommands, expandArgs } = await import('./slash/custom.js');
2900
+ const custom = loadCustomCommands(cwd).find((c) => c.name === m[1].toLowerCase());
2901
+ if (custom) {
2902
+ const depth = (customDepthRef.current ?? 0) + 1;
2903
+ if (depth > 3) {
2904
+ queuedAppend({ id: `cust-${Date.now()}`, kind: 'error', message: `custom command recursion limit: /${custom.name}` });
2905
+ return;
2906
+ }
2907
+ const argList = m[2] ? m[2].split(/\s+/) : [];
2908
+ const expanded = expandArgs(custom.body, argList);
2909
+ customDepthRef.current = depth;
2910
+ try {
2911
+ if (expanded.trimStart().startsWith('/'))
2912
+ await handleSlash(parse(expanded));
2913
+ else
2914
+ await runWithBridge(expanded);
2915
+ }
2916
+ finally {
2917
+ customDepthRef.current = depth - 1;
2918
+ }
2919
+ return;
2920
+ }
2921
+ }
2922
+ // MCP prompt-commands: /mcp__<server>__<prompt> [args...]
2923
+ if (m) {
2924
+ const pm = /^mcp__([A-Za-z0-9_-]{1,20})__([A-Za-z0-9_-]+)$/.exec(m[1].toLowerCase());
2925
+ if (pm) {
2926
+ const { runMcpPrompt } = await import('../mcp/registry.js');
2927
+ const argList = m[2] ? m[2].split(/\s+/) : [];
2928
+ const args = {};
2929
+ argList.forEach((a, i) => { args[`arg${i + 1}`] = a; });
2930
+ args['input'] = m[2] ?? '';
2931
+ try {
2932
+ const text = await runMcpPrompt(cwd, pm[1], pm[2], args);
2933
+ await runWithBridge(text);
2934
+ }
2935
+ catch (err) {
2936
+ queuedAppend({ id: `mcp-p-${Date.now()}`, kind: 'error', message: `mcp prompt failed: ${err instanceof Error ? err.message : String(err)}` });
2937
+ }
2938
+ return;
2939
+ }
2940
+ }
2714
2941
  queuedAppend({
2715
2942
  id: `unk-${Date.now()}`,
2716
2943
  kind: 'error',
package/dist/cli/run.d.ts CHANGED
@@ -66,6 +66,12 @@ export interface RunCliOptions {
66
66
  persist?: boolean;
67
67
  sessionId?: string;
68
68
  sessionsDir?: string;
69
+ /**
70
+ * Bare mode (`--bare`): skip MCP servers, all hooks, memory/KLYRO.md/L6
71
+ * context, and session persistence for fast deterministic runs. The model
72
+ * gets the base system prompt + tools only.
73
+ */
74
+ bare?: boolean;
69
75
  /** P1.4 — run the task under a named child-capable orchestrator context. */
70
76
  agent?: string;
71
77
  maxDepth?: number;
@@ -78,5 +84,5 @@ export interface RunCliOptions {
78
84
  export declare function shouldForceExit(lastSigintAt: number | undefined, now: number): boolean;
79
85
  export declare function runOnce(opts: RunCliOptions): Promise<number>;
80
86
  /** Wrap a system-prompt fn to inject Level-6 context (project map etc.) + KLYRO.md (4.4). */
81
- export declare function makeRunSystemPrompt(cwd: string, base: SystemPromptFn): Promise<SystemPromptFn>;
87
+ export declare function makeRunSystemPrompt(cwd: string, base: SystemPromptFn, bare?: boolean): Promise<SystemPromptFn>;
82
88
  export declare function loadTranscript(path: string): Message[];