klyro 1.0.5 → 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 (78) hide show
  1. package/README.md +13 -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 +26 -0
  5. package/dist/agent/orchestrator.js +41 -4
  6. package/dist/agent/runtime.d.ts +15 -0
  7. package/dist/agent/runtime.js +232 -61
  8. package/dist/chat.d.ts +10 -0
  9. package/dist/chat.js +39 -7
  10. package/dist/checkpoints/store.d.ts +11 -0
  11. package/dist/checkpoints/store.js +32 -0
  12. package/dist/cli/auth.d.ts +10 -3
  13. package/dist/cli/auth.js +43 -5
  14. package/dist/cli/completion.js +2 -2
  15. package/dist/cli/config.d.ts +4 -4
  16. package/dist/cli/doctor.js +0 -1
  17. package/dist/cli/eval.d.ts +15 -1
  18. package/dist/cli/eval.js +43 -5
  19. package/dist/cli/hooks.d.ts +74 -5
  20. package/dist/cli/hooks.js +118 -7
  21. package/dist/cli/init.d.ts +6 -0
  22. package/dist/cli/init.js +60 -0
  23. package/dist/cli/keychain.d.ts +10 -0
  24. package/dist/cli/keychain.js +86 -0
  25. package/dist/cli/repl.js +188 -30
  26. package/dist/cli/run.d.ts +7 -1
  27. package/dist/cli/run.js +92 -50
  28. package/dist/cli/setup.js +3 -2
  29. package/dist/cli/slash/custom.d.ts +25 -0
  30. package/dist/cli/slash/custom.js +166 -0
  31. package/dist/cli/slash/parser.d.ts +9 -1
  32. package/dist/cli/slash/parser.js +34 -9
  33. package/dist/cli/update.d.ts +3 -1
  34. package/dist/cli/update.js +16 -1
  35. package/dist/context/accounting.d.ts +6 -0
  36. package/dist/context/accounting.js +8 -2
  37. package/dist/context/compaction.d.ts +2 -1
  38. package/dist/context/compaction.js +39 -12
  39. package/dist/context/memory.d.ts +11 -0
  40. package/dist/context/memory.js +59 -4
  41. package/dist/eval/harness.d.ts +40 -5
  42. package/dist/eval/harness.js +103 -10
  43. package/dist/eval/judge.d.ts +32 -0
  44. package/dist/eval/judge.js +63 -0
  45. package/dist/eval/tasks.js +134 -0
  46. package/dist/index.js +239 -130
  47. package/dist/mcp/auth.d.ts +85 -0
  48. package/dist/mcp/auth.js +249 -0
  49. package/dist/mcp/client.d.ts +15 -0
  50. package/dist/mcp/client.js +42 -2
  51. package/dist/mcp/config.d.ts +31 -1
  52. package/dist/mcp/config.js +84 -1
  53. package/dist/mcp/registry.d.ts +19 -0
  54. package/dist/mcp/registry.js +118 -2
  55. package/dist/mcp/remote.d.ts +36 -0
  56. package/dist/mcp/remote.js +207 -0
  57. package/dist/mcp/sse.d.ts +42 -0
  58. package/dist/mcp/sse.js +310 -0
  59. package/dist/persistence/audit.d.ts +15 -3
  60. package/dist/persistence/audit.js +84 -13
  61. package/dist/persistence/store.d.ts +9 -0
  62. package/dist/persistence/store.js +17 -0
  63. package/dist/policy/approval.d.ts +15 -1
  64. package/dist/policy/approval.js +8 -0
  65. package/dist/policy/engine.js +9 -0
  66. package/dist/providers/endpoints.d.ts +43 -0
  67. package/dist/providers/endpoints.js +104 -0
  68. package/dist/providers.js +17 -14
  69. package/dist/tools/shell/shell-exec.d.ts +13 -0
  70. package/dist/tools/shell/shell-exec.js +64 -2
  71. package/dist/tui/app.js +172 -15
  72. package/dist/tui/app.test.js +27 -2
  73. package/dist/tui/approval.js +55 -1
  74. package/dist/tui/scroll-model.d.ts +2 -2
  75. package/dist/tui/scroll-model.js +9 -3
  76. package/dist/tui/tokens.d.ts +8 -11
  77. package/dist/tui/tokens.js +18 -11
  78. package/package.json +1 -1
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Project bootstrap shared by `klyro init` and the REPL `/init`:
3
+ * scan-seeded KLYRO.md (only when absent) + `.mcp.json` skeleton.
4
+ * Never overwrites user files; returns created vs skipped lists.
5
+ */
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ function captureStdout() {
9
+ const orig = process.stdout.write.bind(process.stdout);
10
+ let out = '';
11
+ process.stdout.write = ((c) => {
12
+ out += String(c);
13
+ return true;
14
+ });
15
+ return {
16
+ release: () => {
17
+ process.stdout.write = orig;
18
+ return out;
19
+ },
20
+ };
21
+ }
22
+ export async function initProject(cwd) {
23
+ const created = [];
24
+ const skipped = [];
25
+ const klyroMd = path.join(cwd, 'KLYRO.md');
26
+ if (fs.existsSync(klyroMd)) {
27
+ skipped.push('KLYRO.md (exists)');
28
+ }
29
+ else {
30
+ const { runScan } = await import('./scan.js');
31
+ const cap = captureStdout();
32
+ try {
33
+ await runScan({ cwd, json: false });
34
+ }
35
+ finally {
36
+ // release even if scan throws — partial output still seeds the draft
37
+ }
38
+ const out = cap.release();
39
+ fs.writeFileSync(klyroMd, `# 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`, 'utf-8');
40
+ created.push('KLYRO.md');
41
+ }
42
+ const mcpJson = path.join(cwd, '.mcp.json');
43
+ if (fs.existsSync(mcpJson)) {
44
+ skipped.push('.mcp.json (exists)');
45
+ }
46
+ else {
47
+ fs.writeFileSync(mcpJson, JSON.stringify({ mcpServers: {} }, null, 2) + '\n', 'utf-8');
48
+ created.push('.mcp.json');
49
+ }
50
+ return { created, skipped };
51
+ }
52
+ export function nextStepsText() {
53
+ return [
54
+ 'Next steps:',
55
+ ' klyro login # store provider key once (or set KLYRO_BASE_URL/_API_KEY/_MODEL)',
56
+ ' klyro doctor # verify toolchain, provider, sessions, sandbox',
57
+ ' klyro agents # list subagents (add yours in .klyro/agents/*.md)',
58
+ ' klyro mcp add <n> <cmd># attach a tool server (or https:// URL for remote)',
59
+ ].join('\n');
60
+ }
@@ -0,0 +1,10 @@
1
+ export declare const KEYCHAIN_SERVICE = "klyro";
2
+ export type KeychainBackend = 'macos' | 'linux';
3
+ /** Which OS backend (if any) can store keys on this machine. */
4
+ export declare function keychainAvailable(): KeychainBackend | null;
5
+ /** Read a secret. Returns null when unavailable, missing, or denied. */
6
+ export declare function keychainGet(account: string): Promise<string | null>;
7
+ /** Store a secret. Returns false when unavailable or denied (caller falls back). */
8
+ export declare function keychainSet(account: string, secret: string): Promise<boolean>;
9
+ /** Delete a secret. Returns false when unavailable (caller falls back). */
10
+ export declare function keychainDelete(account: string): Promise<boolean>;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * OS keychain for provider API keys — no new dependencies, platform CLIs only.
3
+ *
4
+ * - macOS: `security` (Keychain Services; always present)
5
+ * - Linux: `secret-tool` (libsecret; present on most GNOME desktops)
6
+ * - Windows: unavailable (no dependency-free read path) → file fallback
7
+ *
8
+ * Every function is best-effort and never throws: missing CLIs, locked
9
+ * keychains, and headless sessions all degrade to `null`/`false`, and
10
+ * callers fall back to the 0600 credentials file. Service name is fixed
11
+ * (`klyro`), accounts are provider ids (`openai`, `anthropic`).
12
+ */
13
+ import { execFileSync, spawnSync } from 'node:child_process';
14
+ export const KEYCHAIN_SERVICE = 'klyro';
15
+ /** Which OS backend (if any) can store keys on this machine. */
16
+ export function keychainAvailable() {
17
+ if (process.platform === 'darwin')
18
+ return 'macos';
19
+ if (process.platform === 'linux') {
20
+ try {
21
+ const r = spawnSync('secret-tool', ['--version'], { stdio: 'ignore', timeout: 5000 });
22
+ if (r.error)
23
+ return null;
24
+ return typeof r.status === 'number' && r.status === 0 ? 'linux' : null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+ function run(args, input) {
33
+ try {
34
+ const res = execFileSync(args[0], args.slice(1), {
35
+ input: input ?? undefined,
36
+ encoding: 'utf-8',
37
+ stdio: ['pipe', 'pipe', 'ignore'],
38
+ timeout: 10_000,
39
+ });
40
+ return { ok: true, out: typeof res === 'string' ? res : '' };
41
+ }
42
+ catch {
43
+ return { ok: false, out: '' };
44
+ }
45
+ }
46
+ /** Read a secret. Returns null when unavailable, missing, or denied. */
47
+ export async function keychainGet(account) {
48
+ const backend = keychainAvailable();
49
+ if (!backend)
50
+ return null;
51
+ if (backend === 'macos') {
52
+ const r = run(['security', 'find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', account, '-w']);
53
+ const v = r.out.trim();
54
+ return r.ok && v ? v : null;
55
+ }
56
+ const r = run(['secret-tool', 'lookup', 'service', KEYCHAIN_SERVICE, 'account', account]);
57
+ const v = r.out.replace(/\n$/, '');
58
+ return r.ok && v ? v : null;
59
+ }
60
+ /** Store a secret. Returns false when unavailable or denied (caller falls back). */
61
+ export async function keychainSet(account, secret) {
62
+ if (!secret)
63
+ return false;
64
+ const backend = keychainAvailable();
65
+ if (!backend)
66
+ return false;
67
+ if (backend === 'macos') {
68
+ // -U updates an existing item instead of erroring on duplicates.
69
+ const r = run(['security', 'add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, '-a', account, '-w', secret]);
70
+ return r.ok;
71
+ }
72
+ const r = run(['secret-tool', 'store', '--label', `klyro ${account}`, 'service', KEYCHAIN_SERVICE, 'account', account], secret);
73
+ return r.ok;
74
+ }
75
+ /** Delete a secret. Returns false when unavailable (caller falls back). */
76
+ export async function keychainDelete(account) {
77
+ const backend = keychainAvailable();
78
+ if (!backend)
79
+ return false;
80
+ if (backend === 'macos') {
81
+ const r = run(['security', 'delete-generic-password', '-s', KEYCHAIN_SERVICE, '-a', account]);
82
+ return r.ok;
83
+ }
84
+ const r = run(['secret-tool', 'clear', 'service', KEYCHAIN_SERVICE, 'account', account]);
85
+ return r.ok;
86
+ }
package/dist/cli/repl.js CHANGED
@@ -293,6 +293,8 @@ export async function startRepl(opts = {}) {
293
293
  let isMounted = false;
294
294
  // Live vim input mode for the TUI (toggled by /vim, persisted to config).
295
295
  let vimLive = 'insert';
296
+ // Custom-command recursion guard (a command body may invoke /commands).
297
+ const customDepthRef = { current: 0 };
296
298
  let directHooks;
297
299
  // Plain-text mirror for exit replay (scroll.md §1.2: session survives in
298
300
  // native scrollback after the alt screen is torn down). Cap 300 lines.
@@ -844,6 +846,7 @@ export async function startRepl(opts = {}) {
844
846
  let activeCallId = null;
845
847
  let activeCallName = null;
846
848
  let activeCallArgs = '';
849
+ let runEndStatus = 'error';
847
850
  try {
848
851
  const result = await run({
849
852
  task: taskText,
@@ -976,6 +979,7 @@ export async function startRepl(opts = {}) {
976
979
  });
977
980
  if (result.finalText)
978
981
  lastAssistantText = result.finalText;
982
+ runEndStatus = result.status;
979
983
  if (result.verification) {
980
984
  const v = result.verification;
981
985
  queuedAppend({
@@ -1015,6 +1019,12 @@ export async function startRepl(opts = {}) {
1015
1019
  queuedAppend({ id: `err-${Date.now()}`, kind: 'error', message });
1016
1020
  queuedStatus({ status: 'error', errorMessage: message });
1017
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 */ }
1018
1028
  }
1019
1029
  async function handleSlash(cmd) {
1020
1030
  switch (cmd.kind) {
@@ -1095,6 +1105,22 @@ export async function startRepl(opts = {}) {
1095
1105
  });
1096
1106
  return;
1097
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
+ }
1122
+ return;
1123
+ }
1098
1124
  case 'jobs': {
1099
1125
  const { listJobs } = await import('../tools/shell/background.js');
1100
1126
  const jobs = listJobs();
@@ -1151,8 +1177,8 @@ export async function startRepl(opts = {}) {
1151
1177
  case 'undo': {
1152
1178
  try {
1153
1179
  const { undo } = await import('../checkpoints/store.js');
1154
- await undo(cwd);
1155
- 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' });
1156
1182
  }
1157
1183
  catch (err) {
1158
1184
  const msg = err instanceof Error ? err.message : String(err);
@@ -1161,10 +1187,87 @@ export async function startRepl(opts = {}) {
1161
1187
  return;
1162
1188
  }
1163
1189
  case 'rewind': {
1190
+ // Numbered rewind menu: bare /rewind lists snapshots (1 = latest),
1191
+ // /rewind <n> restores code, /rewind <n> summary adds a revert
1192
+ // report, /rewind <n> preview dry-runs, /rewind <n> full also
1193
+ // truncates the conversation after the snapshot.
1194
+ const { listCheckpointInfo, snapshotFiles, undo } = await import('../checkpoints/store.js');
1195
+ const infos = await listCheckpointInfo(cwd);
1196
+ if (infos.length === 0) {
1197
+ queuedAppend({ id: `rewind-${Date.now()}`, kind: 'text', text: 'No checkpoints yet — snapshots are taken after each file mutation.', role: 'assistant' });
1198
+ return;
1199
+ }
1200
+ const n = cmd.n ?? 1;
1201
+ const mode = cmd.mode;
1202
+ if (cmd.n === undefined && !mode) {
1203
+ const rows = infos.slice(0, 10).map((c) => {
1204
+ const age = c.ts > 0 ? ` (${Math.max(1, Math.round((Date.now() - c.ts) / 60000))}m ago)` : '';
1205
+ return ` ${c.index}. ${c.id.slice(0, 12)} · ${c.files} file(s)${age}`;
1206
+ });
1207
+ queuedAppend({ id: `rewind-${Date.now()}`, kind: 'text', text: `Checkpoints (1 = latest):\n${rows.join('\n')}\nrun /rewind <n> to restore, /rewind <n> preview to dry-run, /rewind <n> summary for a revert report, /rewind <n> full to also rewind the conversation`, role: 'assistant' });
1208
+ return;
1209
+ }
1210
+ if (n < 1 || n > infos.length) {
1211
+ queuedAppend({ id: `rewind-err-${Date.now()}`, kind: 'error', message: `rewind failed: only ${infos.length} checkpoint(s), got n=${n}` });
1212
+ return;
1213
+ }
1214
+ const target = infos[n - 1];
1215
+ if (mode === 'preview') {
1216
+ // Dry run: list what WOULD be restored + current dirty state.
1217
+ const files = await snapshotFiles(cwd, target.id);
1218
+ let dirty = '';
1219
+ try {
1220
+ const { execFileSync } = await import('node:child_process');
1221
+ dirty = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8', timeout: 5000 });
1222
+ }
1223
+ catch {
1224
+ dirty = '';
1225
+ }
1226
+ const listed = files.slice(0, 20).map((f) => ` ${f}`).join('\n') || ' (no files recorded)';
1227
+ const dirtyLines = dirty.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 10);
1228
+ queuedAppend({
1229
+ id: `rewind-${Date.now()}`,
1230
+ kind: 'text',
1231
+ text: `Preview: /rewind ${n} would restore checkpoint ${target.id.slice(0, 12)}:\n${listed}${files.length > 20 ? `\n … +${files.length - 20} more` : ''}${dirtyLines.length > 0 ? `\nCurrent dirty files that would be overwritten:\n${dirtyLines.map((f) => ` ${f}`).join('\n')}` : '\nWorking tree is clean.'}\nRun /rewind ${n} to apply.`,
1232
+ role: 'assistant',
1233
+ });
1234
+ return;
1235
+ }
1164
1236
  try {
1165
- const { rewind } = await import('../checkpoints/store.js');
1166
- await rewind(cwd);
1167
- queuedAppend({ id: `rewind-${Date.now()}`, kind: 'text', text: 'Rewound to last checkpoint', role: 'assistant' });
1237
+ // Capture the pre-restore dirty state so `summary` can report it.
1238
+ let before = '';
1239
+ if (mode === 'summary' || mode === 'full') {
1240
+ try {
1241
+ const { execFileSync } = await import('node:child_process');
1242
+ before = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8', timeout: 5000 });
1243
+ }
1244
+ catch {
1245
+ before = '';
1246
+ }
1247
+ }
1248
+ await undo(cwd, n);
1249
+ let text = `Rewound to checkpoint ${n} (${target.id.slice(0, 12)}, ${target.files} file(s))`;
1250
+ if (mode === 'summary' || mode === 'full') {
1251
+ const files = before.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 20);
1252
+ text += files.length > 0 ? `\nReverted working-tree changes:\n${files.map((f) => ` ${f}`).join('\n')}` : '\nWorking tree was clean before restore.';
1253
+ }
1254
+ if (mode === 'full') {
1255
+ // Conversation rewind: drop persisted messages/observations at
1256
+ // or after the snapshot, so resume continues from that point.
1257
+ if (tuiSessionId) {
1258
+ try {
1259
+ const removed = await tuiStore.truncateMessages(tuiSessionId, target.ts > 0 ? target.ts : Date.now());
1260
+ text += `\nConversation rewound: dropped ${removed.messages} message(s), ${removed.observations} observation(s).`;
1261
+ }
1262
+ catch (err) {
1263
+ text += `\nConversation rewind failed (code restored): ${err instanceof Error ? err.message : String(err)}`;
1264
+ }
1265
+ }
1266
+ else {
1267
+ text += '\nNo active session — code restored, conversation untouched.';
1268
+ }
1269
+ }
1270
+ queuedAppend({ id: `rewind-${Date.now()}`, kind: 'text', text, role: 'assistant' });
1168
1271
  }
1169
1272
  catch (err) {
1170
1273
  const msg = err instanceof Error ? err.message : String(err);
@@ -1172,6 +1275,27 @@ export async function startRepl(opts = {}) {
1172
1275
  }
1173
1276
  return;
1174
1277
  }
1278
+ case 'checkpoints': {
1279
+ try {
1280
+ const { listCheckpointInfo } = await import('../checkpoints/store.js');
1281
+ const infos = await listCheckpointInfo(cwd);
1282
+ if (infos.length === 0) {
1283
+ queuedAppend({ id: `ckpt-${Date.now()}`, kind: 'text', text: 'No checkpoints yet.', role: 'assistant' });
1284
+ }
1285
+ else {
1286
+ const rows = infos.slice(0, 15).map((c) => {
1287
+ const age = c.ts > 0 ? ` (${Math.max(1, Math.round((Date.now() - c.ts) / 60000))}m ago)` : '';
1288
+ return ` ${c.index}. ${c.id.slice(0, 12)} · ${c.files} file(s)${age}`;
1289
+ });
1290
+ queuedAppend({ id: `ckpt-${Date.now()}`, kind: 'text', text: `Checkpoints (1 = latest):\n${rows.join('\n')}`, role: 'assistant' });
1291
+ }
1292
+ }
1293
+ catch (err) {
1294
+ const msg = err instanceof Error ? err.message : String(err);
1295
+ queuedAppend({ id: `ckpt-err-${Date.now()}`, kind: 'error', message: `checkpoints failed: ${msg}` });
1296
+ }
1297
+ return;
1298
+ }
1175
1299
  case 'verify': {
1176
1300
  const { detectVerifiers, primaryVerifyCommand } = await import('../verification/registry.js');
1177
1301
  const { verify } = await import('../verification/engine.js');
@@ -1339,26 +1463,18 @@ export async function startRepl(opts = {}) {
1339
1463
  return;
1340
1464
  }
1341
1465
  case 'init': {
1342
- const { writeFileSync, existsSync } = await import('node:fs');
1343
- const { join } = await import('node:path');
1344
- const target = join(cwd, 'KLYRO.md');
1345
- if (existsSync(target)) {
1346
- queuedAppend({ id: `init-${Date.now()}`, kind: 'text', text: `KLYRO.md already exists at ${target}`, role: 'assistant' });
1466
+ try {
1467
+ const { initProject, nextStepsText } = await import('./init.js');
1468
+ const { created, skipped } = await initProject(cwd);
1469
+ const lines = [
1470
+ ...created.map((f) => `created ${f}`),
1471
+ ...skipped.map((f) => `kept ${f}`),
1472
+ nextStepsText(),
1473
+ ];
1474
+ queuedAppend({ id: `init-${Date.now()}`, kind: 'text', text: lines.join('\n'), role: 'assistant' });
1347
1475
  }
1348
- else {
1349
- try {
1350
- const { runScan } = await import('./scan.js');
1351
- let out = '';
1352
- const orig = process.stdout.write.bind(process.stdout);
1353
- process.stdout.write = ((c) => { out += String(c); return true; });
1354
- await runScan({ cwd, json: false });
1355
- process.stdout.write = orig;
1356
- 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`);
1357
- queuedAppend({ id: `init2-${Date.now()}`, kind: 'text', text: `created ${target}`, role: 'assistant' });
1358
- }
1359
- catch (err) {
1360
- queuedAppend({ id: `init-err-${Date.now()}`, kind: 'error', message: `init failed: ${err instanceof Error ? err.message : String(err)}` });
1361
- }
1476
+ catch (err) {
1477
+ queuedAppend({ id: `init-err-${Date.now()}`, kind: 'error', message: `init failed: ${err instanceof Error ? err.message : String(err)}` });
1362
1478
  }
1363
1479
  return;
1364
1480
  }
@@ -1633,12 +1749,12 @@ export async function startRepl(opts = {}) {
1633
1749
  return;
1634
1750
  }
1635
1751
  case 'auth': {
1636
- const { getStoredKey } = await import('./auth.js');
1637
- const rows = ['openai', 'anthropic'].map((p) => {
1638
- const hasFile = !!getStoredKey(p);
1752
+ const { getStoredKeyAsync } = await import('./auth.js');
1753
+ const rows = await Promise.all(['openai', 'anthropic'].map(async (p) => {
1754
+ const hasKeychain = !!(await getStoredKeyAsync(p));
1639
1755
  const hasEnv = !!(p === 'openai' ? process.env.OPENAI_API_KEY : process.env.ANTHROPIC_API_KEY) || !!process.env.KLYRO_API_KEY;
1640
- return ` ${p}: ${hasFile ? 'stored key (0600)' : hasEnv ? 'env key' : '—'}`;
1641
- });
1756
+ return ` ${p}: ${hasKeychain ? 'stored key (keychain/file)' : hasEnv ? 'env key' : '—'}`;
1757
+ }));
1642
1758
  queuedAppend({ id: `auth-${Date.now()}`, kind: 'text', text: `Auth:\n${rows.join('\n')}\ncurrent provider: ${currentProvider}\nmanage via /login /logout`, role: 'assistant' });
1643
1759
  return;
1644
1760
  }
@@ -2573,7 +2689,7 @@ export async function startRepl(opts = {}) {
2573
2689
  catch { /* persist best-effort */ }
2574
2690
  if (isMounted && directHooks)
2575
2691
  directHooks.setVimMode(next);
2576
- queuedAppend({ id: `vim-${Date.now()}`, kind: 'text', text: `vim mode: ${next}${next === 'normal' ? ' (h/l move · i/a insert · x delete · 0/$ ends · j/k scroll)' : ''}`, role: 'assistant' });
2692
+ 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' });
2577
2693
  return;
2578
2694
  }
2579
2695
  case 'theme':
@@ -2817,6 +2933,48 @@ export async function startRepl(opts = {}) {
2817
2933
  await handleSlash(parse(target + extra));
2818
2934
  return;
2819
2935
  }
2936
+ // Custom command files: .klyro/commands/*.md (project wins).
2937
+ // Body expands $1..$9/$@ and runs as a prompt (recursion-guarded).
2938
+ if (m) {
2939
+ const { loadCustomCommands, expandArgs } = await import('./slash/custom.js');
2940
+ const custom = loadCustomCommands(cwd).find((c) => c.name === m[1].toLowerCase());
2941
+ if (custom) {
2942
+ const depth = (customDepthRef.current ?? 0) + 1;
2943
+ if (depth > 3) {
2944
+ queuedAppend({ id: `cust-${Date.now()}`, kind: 'error', message: `custom command recursion limit: /${custom.name}` });
2945
+ return;
2946
+ }
2947
+ const argList = m[2] ? m[2].split(/\s+/) : [];
2948
+ const expanded = expandArgs(custom.body, argList);
2949
+ customDepthRef.current = depth;
2950
+ try {
2951
+ if (expanded.trimStart().startsWith('/'))
2952
+ await handleSlash(parse(expanded));
2953
+ else
2954
+ await runWithBridge(expanded);
2955
+ }
2956
+ finally {
2957
+ customDepthRef.current = depth - 1;
2958
+ }
2959
+ return;
2960
+ }
2961
+ }
2962
+ // MCP prompt-commands: /mcp__<server>__<prompt> [args...]
2963
+ if (m) {
2964
+ const pm = /^mcp__([A-Za-z0-9_-]{1,20})__([A-Za-z0-9_-]+)$/.exec(m[1].toLowerCase());
2965
+ if (pm) {
2966
+ const { runMcpPrompt } = await import('../mcp/registry.js');
2967
+ const tokens = m[2] ? m[2].split(/\s+/).filter(Boolean) : [];
2968
+ try {
2969
+ const text = await runMcpPrompt(cwd, pm[1], pm[2], tokens);
2970
+ await runWithBridge(text);
2971
+ }
2972
+ catch (err) {
2973
+ queuedAppend({ id: `mcp-p-${Date.now()}`, kind: 'error', message: `mcp prompt failed: ${err instanceof Error ? err.message : String(err)}` });
2974
+ }
2975
+ return;
2976
+ }
2977
+ }
2820
2978
  queuedAppend({
2821
2979
  id: `unk-${Date.now()}`,
2822
2980
  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[];