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/index.js CHANGED
@@ -56,12 +56,14 @@ async function main() {
56
56
  .option('--verbose', 'Verbose output')
57
57
  .option('--quiet', 'Suppress non-essential output')
58
58
  .option('--json', 'Force JSON output where supported')
59
- .option('--yes', 'Auto-approve prompts where possible')
59
+ .option('--yes', 'Auto-approve commit prompts (scope: `klyro commit` only)')
60
60
  .option('--no-color', 'Disable colored output')
61
61
  .option('-p, --print <prompt>', 'Headless one-shot prompt (alias for run, --output json for machine)')
62
62
  .option('--output-format <fmt>', 'Headless output format: text|json|stream-json (default text)')
63
- .option('--no-stream', 'Disable streaming (buffer full response)')
64
- .option('--show-thinking', 'Show thinking blocks');
63
+ .option('--temperature <n>', 'Sampling temperature 0-2 (headless -p / run)', parseTemperature)
64
+ .option('--max-tokens <n>', 'Max output tokens per step (headless -p / run)', (v) => parsePositiveInt('--max-tokens', v))
65
+ .option('--no-stream', 'Disable streaming (TUI only; headless json always streams events)')
66
+ .option('--show-thinking', 'Show thinking blocks (TUI only)');
65
67
  // Unknown commands / unknown options are usage errors (exit 2 per PRD).
66
68
  // Help and version still exit 0; anything else rethrows to the last-resort
67
69
  // handler (exit 1).
@@ -197,12 +199,30 @@ async function main() {
197
199
  const code = await runDoctor({ json: !!opts.json });
198
200
  process.exit(code);
199
201
  });
202
+ program
203
+ .command('init')
204
+ .description('Bootstrap this project: scan + KLYRO.md draft + .mcp.json skeleton (never overwrites)')
205
+ .action(async () => {
206
+ const { initProject, nextStepsText } = await import('./cli/init.js');
207
+ try {
208
+ const { created, skipped } = await initProject(process.cwd());
209
+ for (const f of created)
210
+ process.stdout.write(`created ${f}\n`);
211
+ for (const f of skipped)
212
+ process.stdout.write(`kept ${f}\n`);
213
+ process.stdout.write(nextStepsText() + '\n');
214
+ process.exit(0);
215
+ }
216
+ catch (err) {
217
+ process.stderr.write(`klyro: init failed: ${err instanceof Error ? err.message : String(err)}\n`);
218
+ process.exit(1);
219
+ }
220
+ });
200
221
  // Support `klyro "prompt"` positional headless (2.5)
201
222
  program.argument('[prompt]', 'Headless one-shot prompt (same as -p)');
202
223
  program
203
224
  .action(async (promptArg) => {
204
- const opts = program.opts();
205
- // 9.2 — --continue / --resume handling
225
+ const opts = program.opts(); // 9.2 — --continue / --resume handling
206
226
  if (opts.continue || typeof opts.resume === 'string') {
207
227
  const { getDefaultSessionStore } = await import('./persistence/session.js');
208
228
  const store = getDefaultSessionStore();
@@ -243,6 +263,7 @@ async function main() {
243
263
  cwd: process.cwd(),
244
264
  model: process.env.KLYRO_MODEL ?? 'gpt-4o-mini',
245
265
  maxTokens: opts.maxTokens,
266
+ temperature: opts.temperature,
246
267
  output: output,
247
268
  provider: process.env.KLYRO_PROVIDER,
248
269
  });
@@ -298,6 +319,7 @@ async function main() {
298
319
  .option('--require-verify', 'Fail with exit 8 if no verification passed after edits (6.5)')
299
320
  .option('--agent <name>', 'Run under an orchestrator context enabling spawn_agent/task_list/task_get (explorer|implementer|tester|reviewer)')
300
321
  .option('--max-depth <n>', 'Max spawn depth for child agents (default 1)', (v) => parsePositiveInt('--max-depth', v))
322
+ .option('--bare', 'Deterministic runs: skip MCP, hooks, memory/KLYRO.md/context, persistence')
301
323
  .action(async (prompt, opts) => {
302
324
  const model = opts.model ?? process.env.KLYRO_MODEL;
303
325
  if (!model) {
@@ -339,6 +361,7 @@ async function main() {
339
361
  requireVerify: !!opts.requireVerify,
340
362
  agent: opts.agent,
341
363
  maxDepth: opts.maxDepth,
364
+ bare: !!opts.bare,
342
365
  });
343
366
  process.exit(code);
344
367
  }
@@ -370,17 +393,18 @@ async function main() {
370
393
  .option('--runs <n>', 'Runs per fixture (default 1)', (v) => parsePositiveInt('--runs', v))
371
394
  .option('--parallel <n>', 'Parallelism (default 1)', (v) => parsePositiveInt('--parallel', v))
372
395
  .option('--model <id>', 'Model for eval')
396
+ .option('--judge-model <id>', 'Live model id for grading judge.rubric (needs endpoint + key)')
373
397
  .action(async (input, opts) => {
374
398
  const output = (opts.output ?? 'human');
375
399
  if (opts.suite) {
376
- const code = await runEval({ inputPath: input ?? '-', output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model });
400
+ const code = await runEval({ inputPath: input ?? '-', output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel });
377
401
  process.exit(code);
378
402
  }
379
403
  if (!input) {
380
404
  process.stderr.write('klyro eval: missing input (provide <input> or --suite)\n');
381
405
  process.exit(2);
382
406
  }
383
- const code = await runEval({ inputPath: input, output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model });
407
+ const code = await runEval({ inputPath: input, output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel });
384
408
  process.exit(code);
385
409
  });
386
410
  program
@@ -395,14 +419,11 @@ async function main() {
395
419
  process.stdout.write(out + '\n');
396
420
  process.exit(0);
397
421
  });
398
- // Level 9 — Session management
399
- const session = program.command('session').description('Session persistence (Level 9)');
400
- session
401
- .command('list')
402
- .description('List persisted sessions')
403
- .option('--status <s>', 'Filter by status: open|complete|verify_failed|aborted|max_steps')
404
- .option('--json', 'Output JSON')
405
- .action(async (opts) => {
422
+ // Level 9 — Session management.
423
+ // One namespace: `session` and `sessions` accept the SAME subcommands
424
+ // (list/show/resume/export/import/fork/delete). Handlers live here once
425
+ // and both command groups delegate to them.
426
+ async function sessionList(opts) {
406
427
  const { getDefaultSessionStore, formatSession } = await import('./persistence/session.js');
407
428
  const store = getDefaultSessionStore();
408
429
  const all = await store.list(opts.status ? { status: opts.status } : undefined);
@@ -419,25 +440,26 @@ async function main() {
419
440
  }
420
441
  }
421
442
  }
422
- });
423
- session
424
- .command('show <id>')
425
- .description('Show session transcript and observations')
426
- .option('--json', 'Output JSON')
427
- .action(async (id, opts) => {
443
+ }
444
+ async function resolveOrExit(id) {
428
445
  const { getDefaultSessionStore, resolveSessionId, matchSessionIds } = await import('./persistence/session.js');
429
446
  const store = getDefaultSessionStore();
430
447
  const full = await resolveSessionId(store, id);
431
- if (!full) {
432
- const matches = await matchSessionIds(store, id);
433
- if (matches.length > 1) {
434
- process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
435
- }
436
- else {
437
- process.stderr.write(`session not found: ${id}\n`);
438
- }
439
- process.exit(2);
448
+ if (full)
449
+ return full;
450
+ const matches = await matchSessionIds(store, id);
451
+ if (matches.length > 1) {
452
+ process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
453
+ }
454
+ else {
455
+ process.stderr.write(`session not found: ${id}\n`);
440
456
  }
457
+ process.exit(2);
458
+ }
459
+ async function sessionShow(id, opts) {
460
+ const { getDefaultSessionStore } = await import('./persistence/session.js');
461
+ const store = getDefaultSessionStore();
462
+ const full = await resolveOrExit(id);
441
463
  const rec = await store.get(full);
442
464
  const msgs = await store.loadMessages(full);
443
465
  const obs = await store.loadObservations(full);
@@ -453,28 +475,11 @@ async function main() {
453
475
  for (const o of obs)
454
476
  process.stdout.write(` ${o.toolName} -> ${o.isError ? 'ERR' : 'ok'} ${JSON.stringify(o.output).slice(0, 120)}\n`);
455
477
  }
456
- });
457
- session
458
- .command('resume <id>')
459
- .description('Resume a persisted session (requires KLYRO_MODEL etc.)')
460
- .option('-m, --model <id>', 'Model (default: from session or env)')
461
- .option('--max-steps <n>', 'Max steps (default 30)', (v) => parsePositiveInt('--max-steps', v))
462
- .option('--verify-command <cmd>', 'Override verification command')
463
- .option('--verify', 'Enable verification (default: enabled)')
464
- .action(async (id, opts) => {
465
- const { getDefaultSessionStore, resolveSessionId, matchSessionIds } = await import('./persistence/session.js');
478
+ }
479
+ async function sessionResume(id, opts) {
480
+ const { getDefaultSessionStore } = await import('./persistence/session.js');
466
481
  const store = getDefaultSessionStore();
467
- const full = await resolveSessionId(store, id);
468
- if (!full) {
469
- const matches = await matchSessionIds(store, id);
470
- if (matches.length > 1) {
471
- process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
472
- }
473
- else {
474
- process.stderr.write(`session not found: ${id}\n`);
475
- }
476
- process.exit(2);
477
- }
482
+ const full = await resolveOrExit(id);
478
483
  const rec = await store.get(full);
479
484
  if (!rec) {
480
485
  process.stderr.write(`session not found: ${id}\n`);
@@ -495,75 +500,67 @@ async function main() {
495
500
  verifyCommand: opts.verifyCommand,
496
501
  });
497
502
  process.exit(code);
498
- });
499
- // Alias: klyro resume <id> → klyro session resume <id>
500
- program
501
- .command('resume <id>')
502
- .description('Alias for `klyro session resume <id>`')
503
- .option('-m, --model <id>', 'Model')
504
- .option('--max-steps <n>', 'Max steps', (v) => parsePositiveInt('--max-steps', v))
505
- .action(async (id, opts) => {
506
- const { getDefaultSessionStore, resolveSessionId, matchSessionIds } = await import('./persistence/session.js');
507
- const store = getDefaultSessionStore();
508
- const full = await resolveSessionId(store, id);
509
- if (!full) {
510
- const matches = await matchSessionIds(store, id);
511
- if (matches.length > 1) {
512
- process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
513
- }
514
- else {
515
- process.stderr.write(`session not found: ${id}\n`);
516
- }
517
- process.exit(2);
518
- }
519
- const rec = await store.get(full);
520
- if (!rec) {
521
- process.stderr.write(`session not found: ${id}\n`);
522
- process.exit(2);
523
- }
524
- const model = opts.model ?? rec.config.model ?? process.env.KLYRO_MODEL;
525
- if (!model) {
526
- process.stderr.write('klyro: KLYRO_MODEL is not set (or pass --model)\n');
527
- process.exit(2);
528
- }
529
- const code = await runOnce({
530
- task: rec.task,
531
- cwd: rec.cwd,
532
- model,
533
- maxSteps: opts.maxSteps ?? rec.config.maxSteps,
534
- sessionId: full,
535
- });
536
- process.exit(code);
537
- });
538
- program.command('scan').description('Scan project (7.1) — languages, frameworks, commands, 300ms cached').option('--json', 'JSON output').action(async (opts) => { const { runScan } = await import('./cli/scan.js'); process.exit(await runScan({ cwd: process.cwd(), json: !!opts.json })); });
539
- program.command('project').description('Alias for scan').option('--json', 'JSON output').action(async (opts) => { const { runProject } = await import('./cli/scan.js'); process.exit(await runProject({ cwd: process.cwd(), json: !!opts.json })); });
540
- // 9.2 — Continue / resume top-level flags (also handled via session resume)
541
- program.option('-c, --continue', 'Continue most recent session in cwd (9.2)');
542
- program.option('-r, --resume [id]', 'Resume session by id or pick most recent');
543
- // 9.4 — Sessions extended: fork/rename/export/import/prune/history + locks
544
- const sessions = program.command('sessions').description('Alias for session');
545
- sessions.command('export <id> [file]').description('Export session to file (9.4)').action(async (id, file) => {
546
- const { getDefaultSessionStore, resolveSessionId } = await import('./persistence/session.js');
503
+ }
504
+ async function sessionExport(id, file) {
505
+ const { getDefaultSessionStore } = await import('./persistence/session.js');
547
506
  const store = getDefaultSessionStore();
548
- const full = await resolveSessionId(store, id);
549
- if (!full) {
550
- process.stderr.write(`session not found: ${id}\n`);
551
- process.exit(2);
552
- }
507
+ const full = await resolveOrExit(id);
553
508
  const rec = await store.get(full);
554
509
  const msgs = await store.loadMessages(full);
510
+ const obs = await store.loadObservations(full);
555
511
  const out = file ?? `${full}.export.json`;
556
- await (await import('node:fs/promises')).writeFile(out, JSON.stringify({ record: rec, messages: msgs }, null, 2));
512
+ await (await import('node:fs/promises')).writeFile(out, JSON.stringify({ record: rec, messages: msgs, observations: obs }, null, 2));
557
513
  process.stdout.write(`exported ${full} → ${out}\n`);
558
- });
559
- sessions.command('import <file>').description('Import session from file').action(async (file) => {
560
- const data = JSON.parse(await (await import('node:fs/promises')).readFile(file, 'utf-8'));
514
+ }
515
+ async function sessionImport(file) {
516
+ let data;
517
+ try {
518
+ data = JSON.parse(await (await import('node:fs/promises')).readFile(file, 'utf-8'));
519
+ }
520
+ catch (err) {
521
+ process.stderr.write(`klyro: cannot import ${file}: ${err instanceof Error ? err.message : String(err)}\n`);
522
+ process.exit(2);
523
+ }
524
+ const rec = data.record ?? {};
561
525
  const { getDefaultSessionStore } = await import('./persistence/session.js');
562
526
  const store = getDefaultSessionStore();
563
- const rec = await store.create({ cwd: data.record?.cwd ?? process.cwd(), task: data.record?.task ?? 'imported', config: data.record?.config ?? { model: 'imported', maxSteps: 30 } });
564
- process.stdout.write(`imported → ${rec.id}\n`);
565
- });
566
- sessions.command('fork <id>').description('Fork session with full context (9.4)').action(async (id) => {
527
+ const cfg = (rec.config && typeof rec.config === 'object' ? rec.config : { model: 'imported', maxSteps: 30 });
528
+ const created = await store.create({ cwd: typeof rec.cwd === 'string' ? rec.cwd : process.cwd(), task: typeof rec.task === 'string' ? rec.task : 'imported', config: cfg });
529
+ // Restore the transcript — previously this was silently dropped (lossy
530
+ // import). Messages/observations go through append* so at-rest redaction
531
+ // still applies. Malformed entries fail loudly instead of half-importing.
532
+ const d = data;
533
+ let restored = 0;
534
+ if (d.messages !== undefined) {
535
+ if (!Array.isArray(d.messages)) {
536
+ process.stderr.write(`klyro: import failed: "messages" is not an array in ${file}\n`);
537
+ process.exit(2);
538
+ }
539
+ for (const m of d.messages) {
540
+ if (!m || typeof m !== 'object' || typeof m.role !== 'string' || !('content' in m)) {
541
+ process.stderr.write(`klyro: import failed: malformed message entry in ${file}\n`);
542
+ process.exit(2);
543
+ }
544
+ await store.appendMessage(created.id, m);
545
+ restored++;
546
+ }
547
+ }
548
+ if (d.observations !== undefined) {
549
+ if (!Array.isArray(d.observations)) {
550
+ process.stderr.write(`klyro: import failed: "observations" is not an array in ${file}\n`);
551
+ process.exit(2);
552
+ }
553
+ for (const o of d.observations) {
554
+ if (!o || typeof o !== 'object') {
555
+ process.stderr.write(`klyro: import failed: malformed observation entry in ${file}\n`);
556
+ process.exit(2);
557
+ }
558
+ await store.appendObservation(created.id, o);
559
+ }
560
+ }
561
+ process.stdout.write(`imported → ${created.id} (${restored} messages restored)\n`);
562
+ }
563
+ async function sessionFork(id) {
567
564
  const { getDefaultSessionStore, matchSessionIds } = await import('./persistence/session.js');
568
565
  const store = getDefaultSessionStore();
569
566
  const matches = await matchSessionIds(store, id);
@@ -579,8 +576,8 @@ async function main() {
579
576
  const forked = await store.fork(full);
580
577
  const msgs = await store.loadMessages(forked.id);
581
578
  process.stdout.write(`forked ${full.slice(0, 8)} → ${forked.id.slice(0, 8)} (${msgs.length} messages carried over)\n`);
582
- });
583
- sessions.command('delete <id>').description('Delete a session and its artifacts').action(async (id) => {
579
+ }
580
+ async function sessionDelete(id) {
584
581
  const { getDefaultSessionStore, matchSessionIds } = await import('./persistence/session.js');
585
582
  const store = getDefaultSessionStore();
586
583
  const matches = await matchSessionIds(store, id);
@@ -595,7 +592,64 @@ async function main() {
595
592
  const full = matches[0].id;
596
593
  await store.delete(full);
597
594
  process.stdout.write(`deleted ${full.slice(0, 8)}\n`);
598
- });
595
+ }
596
+ const session = program.command('session').description('Session persistence (Level 9)');
597
+ session
598
+ .command('list')
599
+ .description('List persisted sessions')
600
+ .option('--status <s>', 'Filter by status: open|complete|verify_failed|aborted|max_steps')
601
+ .option('--json', 'Output JSON')
602
+ .action(async (opts) => { await sessionList(opts); });
603
+ session
604
+ .command('show <id>')
605
+ .description('Show session transcript and observations')
606
+ .option('--json', 'Output JSON')
607
+ .action(async (id, opts) => { await sessionShow(id, opts); });
608
+ session
609
+ .command('resume <id>')
610
+ .description('Resume a persisted session (requires KLYRO_MODEL etc.)')
611
+ .option('-m, --model <id>', 'Model (default: from session or env)')
612
+ .option('--max-steps <n>', 'Max steps (default 30)', (v) => parsePositiveInt('--max-steps', v))
613
+ .option('--verify-command <cmd>', 'Override verification command')
614
+ .option('--verify', 'Enable verification (default: enabled)')
615
+ .action(async (id, opts) => { await sessionResume(id, opts); });
616
+ session
617
+ .command('export <id> [file]')
618
+ .description('Export session to file (9.4)')
619
+ .action(async (id, file) => { await sessionExport(id, file); });
620
+ session
621
+ .command('import <file>')
622
+ .description('Import session from file (restores record + messages + observations)')
623
+ .action(async (file) => { await sessionImport(file); });
624
+ session
625
+ .command('fork <id>')
626
+ .description('Fork session with full context (9.4)')
627
+ .action(async (id) => { await sessionFork(id); });
628
+ session
629
+ .command('delete <id>')
630
+ .description('Delete a session and its artifacts')
631
+ .action(async (id) => { await sessionDelete(id); });
632
+ // Alias: klyro resume <id> → klyro session resume <id>
633
+ program
634
+ .command('resume <id>')
635
+ .description('Alias for `klyro session resume <id>`')
636
+ .option('-m, --model <id>', 'Model')
637
+ .option('--max-steps <n>', 'Max steps', (v) => parsePositiveInt('--max-steps', v))
638
+ .action(async (id, opts) => { await sessionResume(id, opts); });
639
+ program.command('scan').description('Scan project (7.1) — languages, frameworks, commands, 300ms cached').option('--json', 'JSON output').action(async (opts) => { const { runScan } = await import('./cli/scan.js'); process.exit(await runScan({ cwd: process.cwd(), json: !!opts.json })); });
640
+ program.command('project').description('Alias for scan').option('--json', 'JSON output').action(async (opts) => { const { runProject } = await import('./cli/scan.js'); process.exit(await runProject({ cwd: process.cwd(), json: !!opts.json })); });
641
+ // 9.2 — Continue / resume top-level flags (also handled via session resume)
642
+ program.option('-c, --continue', 'Continue most recent session in cwd (9.2)');
643
+ program.option('-r, --resume [id]', 'Resume session by id or pick most recent');
644
+ // 9.4 — same namespace as `session`: every subcommand works under both.
645
+ const sessions = program.command('sessions').description('Alias for session (same subcommands)');
646
+ sessions.command('list').description('List persisted sessions').option('--status <s>', 'Filter by status').option('--json', 'Output JSON').action(async (opts) => { await sessionList(opts); });
647
+ sessions.command('show <id>').description('Show session transcript and observations').option('--json', 'Output JSON').action(async (id, opts) => { await sessionShow(id, opts); });
648
+ sessions.command('resume <id>').description('Resume a persisted session').option('-m, --model <id>', 'Model').option('--max-steps <n>', 'Max steps', (v) => parsePositiveInt('--max-steps', v)).action(async (id, opts) => { await sessionResume(id, opts); });
649
+ sessions.command('export <id> [file]').description('Export session to file (9.4)').action(async (id, file) => { await sessionExport(id, file); });
650
+ sessions.command('import <file>').description('Import session from file (restores record + messages + observations)').action(async (file) => { await sessionImport(file); });
651
+ sessions.command('fork <id>').description('Fork session with full context (9.4)').action(async (id) => { await sessionFork(id); });
652
+ sessions.command('delete <id>').description('Delete a session and its artifacts').action(async (id) => { await sessionDelete(id); });
599
653
  // 10.1 — MCP
600
654
  const mcp = program.command('mcp').description('MCP client/server (10.1)');
601
655
  mcp.command('list').description('List MCP servers').action(async () => {
@@ -612,7 +666,35 @@ async function main() {
612
666
  process.stdout.write(`${name} source=${source}${spec?.disabled ? ' disabled' : ''}\n`);
613
667
  }
614
668
  });
615
- mcp.command('add <name> <url>').description('Add MCP server').action(async (name) => { process.stdout.write(`added mcp ${name} (stub)\n`); });
669
+ mcp.command('add <name> <command> [args...]').description('Add a project MCP server to .mcp.json (stdio command, or https:// URL for remote)').action(async (name, command, args) => {
670
+ const { addProjectServer, projectMcpPath } = await import('./mcp/config.js');
671
+ try {
672
+ // URL first arg → remote Streamable-HTTP server; else stdio command.
673
+ const spec = /^https?:\/\//i.test(command)
674
+ ? { url: command }
675
+ : { command, ...(args && args.length > 0 ? { args } : {}) };
676
+ addProjectServer(process.cwd(), name, spec);
677
+ process.stdout.write(`added mcp server "${name}" → ${projectMcpPath(process.cwd())}\n`);
678
+ }
679
+ catch (err) {
680
+ process.stderr.write(`klyro: mcp add failed: ${err instanceof Error ? err.message : String(err)}\n`);
681
+ process.exit(2);
682
+ }
683
+ });
684
+ mcp.command('remove <name>').description('Remove a project MCP server from .mcp.json').action(async (name) => {
685
+ const { removeProjectServer, projectMcpPath } = await import('./mcp/config.js');
686
+ try {
687
+ if (!removeProjectServer(process.cwd(), name)) {
688
+ process.stderr.write(`klyro: mcp server not found in ${projectMcpPath(process.cwd())}: ${name}\n`);
689
+ process.exit(2);
690
+ }
691
+ process.stdout.write(`removed mcp server "${name}"\n`);
692
+ }
693
+ catch (err) {
694
+ process.stderr.write(`klyro: mcp remove failed: ${err instanceof Error ? err.message : String(err)}\n`);
695
+ process.exit(2);
696
+ }
697
+ });
616
698
  mcp.command('probe <name>').description('Connect to an MCP server (15s timeout), list its tools, print count+names').action(async (name) => {
617
699
  const { loadMcpServers } = await import('./mcp/config.js');
618
700
  const cfg = loadMcpServers(process.cwd());
@@ -621,18 +703,21 @@ async function main() {
621
703
  process.stderr.write(`klyro: mcp server not found: ${name}\n`);
622
704
  process.exit(2);
623
705
  }
624
- const { McpClient } = await import('./mcp/client.js');
625
- const client = new McpClient(name, spec);
626
- // 15s overall probe budget (connect has its own internal timeout too).
706
+ const { makeMcpClient } = await import('./mcp/registry.js');
707
+ const client = makeMcpClient(name, spec); // 15s overall probe budget (connect has its own internal timeout too).
627
708
  const timer = setTimeout(() => {
628
709
  process.stderr.write(`klyro: mcp probe ${name} timed out after 15s\n`);
629
710
  process.exit(2);
630
711
  }, 15_000);
631
712
  try {
632
- await client.connect();
713
+ const withConnect = client;
714
+ if (typeof withConnect.connect === 'function')
715
+ await withConnect.connect();
633
716
  const tools = await client.listTools();
717
+ const prompts = typeof client.promptsList === 'function' ? await client.promptsList().catch(() => []) : [];
634
718
  const names = tools.map((t) => t.name);
635
- process.stdout.write(`${name}: ${tools.length} tool(s)${names.length > 0 ? `: ${names.join(', ')}` : ''}\n`);
719
+ const extra = prompts.length > 0 ? `, ${prompts.length} prompt(s): ${prompts.map((p) => p.name).join(', ')}` : '';
720
+ process.stdout.write(`${name}: ${tools.length} tool(s)${names.length > 0 ? `: ${names.join(', ')}` : ''}${extra}\n`);
636
721
  process.exit(0);
637
722
  }
638
723
  catch (err) {
@@ -647,7 +732,11 @@ async function main() {
647
732
  catch { /* ignore */ }
648
733
  }
649
734
  });
650
- mcp.command('serve').description('Serve as MCP server').action(async () => { process.stdout.write('klyro mcp serve — exposing tools (stub)\n'); });
735
+ mcp.command('serve').description('Serve builtin tools as an MCP server over stdio (policy-gated)').action(async () => {
736
+ const { serveStdio } = await import('./mcp/serve.js');
737
+ const code = await serveStdio(process.cwd());
738
+ process.exit(code);
739
+ });
651
740
  // 10.2 — Hooks: list configured preToolUse/postToolUse hooks.
652
741
  program.command('hooks [cmd]').description('Hooks (10.2): `klyro hooks` or `klyro hooks list` prints configured hooks').action(async (cmd) => {
653
742
  if (cmd && cmd !== 'list') {
@@ -661,15 +750,16 @@ async function main() {
661
750
  return;
662
751
  }
663
752
  for (const h of hooks)
664
- process.stdout.write(`${h.name} ${h.event} ${h.command}\n`);
753
+ process.stdout.write(`${h.name} ${h.event}${h.matcher ? ` (${h.matcher})` : ''} ${h.command}\n`);
665
754
  });
666
- program.command('agents [name] [extra...]').description('List agents (10.2), show one, or run: agents run <name> <task...>').action(async (name, extra) => {
667
- const { BUILTIN_AGENTS } = await import('./agent/orchestrator.js');
755
+ program.command('agents [name] [extra...]').description('List agents (builtins + .klyro/agents/*.md), show one, or run: agents run <name> <task...>').action(async (name, extra) => {
756
+ const { listAllAgents } = await import('./agent/orchestrator.js');
757
+ const ALL = listAllAgents(process.cwd());
668
758
  // `klyro agents run <name> <task...>`: one-shot run under a named agent.
669
759
  if (name === 'run') {
670
760
  const [agentName, ...taskParts] = extra ?? [];
671
- if (!agentName || !BUILTIN_AGENTS.some((a) => a.id === agentName)) {
672
- process.stderr.write(`klyro: unknown agent: ${agentName ?? '(missing)'} (known: ${BUILTIN_AGENTS.map((a) => a.id).join(', ')})\n`);
761
+ if (!agentName || !ALL.some((a) => a.id === agentName)) {
762
+ process.stderr.write(`klyro: unknown agent: ${agentName ?? '(missing)'} (known: ${ALL.map((a) => a.id).join(', ')})\n`);
673
763
  process.exit(2);
674
764
  }
675
765
  const task = (taskParts ?? []).join(' ').trim();
@@ -686,19 +776,20 @@ async function main() {
686
776
  process.exit(code);
687
777
  }
688
778
  if (!name) {
689
- for (const a of BUILTIN_AGENTS)
690
- process.stdout.write(`${a.id} — ${a.description}\n`);
779
+ for (const a of ALL)
780
+ process.stdout.write(`${a.id}${a.source && a.source !== 'builtin' ? ` (${a.source})` : ''} — ${a.description}\n`);
691
781
  return;
692
782
  }
693
- const def = BUILTIN_AGENTS.find((a) => a.id === name);
783
+ const def = ALL.find((a) => a.id === name);
694
784
  if (!def) {
695
- process.stderr.write(`klyro: unknown agent: ${name} (known: ${BUILTIN_AGENTS.map((a) => a.id).join(', ')})\n`);
785
+ process.stderr.write(`klyro: unknown agent: ${name} (known: ${ALL.map((a) => a.id).join(', ')})\n`);
696
786
  process.exit(2);
697
787
  }
698
788
  const tools = def.allowedTools ?? ['<inherited: all parent tools>'];
699
789
  const lines = [
700
790
  `agent: ${def.id}`,
701
791
  `description: ${def.description}`,
792
+ `source: ${def.source ?? 'builtin'}`,
702
793
  `tools (${tools.length}): ${tools.join(', ')}`,
703
794
  `readonly: ${def.readonly ?? false}`,
704
795
  `canSpawn: ${def.canSpawn ?? false}`,
@@ -706,6 +797,8 @@ async function main() {
706
797
  `maxSteps: ${def.maxSteps ?? '<default>'}`,
707
798
  `maxTokens: ${def.maxTokens ?? '<default>'}`,
708
799
  ];
800
+ if (def.prompt)
801
+ lines.push(`prompt: ${def.prompt.slice(0, 200)}${def.prompt.length > 200 ? '…' : ''}`);
709
802
  process.stdout.write(lines.join('\n') + '\n');
710
803
  });
711
804
  // 10.3 — Web / git workflows / SDK
@@ -9,6 +9,15 @@ export interface McpResource {
9
9
  name?: string;
10
10
  mimeType?: string;
11
11
  }
12
+ export interface McpPromptDef {
13
+ name: string;
14
+ description?: string;
15
+ arguments?: Array<{
16
+ name: string;
17
+ description?: string;
18
+ required?: boolean;
19
+ }>;
20
+ }
12
21
  export interface McpCallResult {
13
22
  /** Normalized text payload (content blocks joined). */
14
23
  text: string;
@@ -19,6 +28,8 @@ export interface McpClientLike {
19
28
  listTools(signal?: AbortSignal): Promise<McpToolDef[]>;
20
29
  callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpCallResult>;
21
30
  listResources(signal?: AbortSignal): Promise<McpResource[]>;
31
+ promptsList?(signal?: AbortSignal): Promise<McpPromptDef[]>;
32
+ promptsGet?(name: string, args?: Record<string, string>, signal?: AbortSignal): Promise<string>;
22
33
  close(): Promise<void>;
23
34
  }
24
35
  export declare class McpError extends Error {
@@ -43,6 +54,10 @@ export declare class McpClient implements McpClientLike {
43
54
  callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpCallResult>;
44
55
  listResources(signal?: AbortSignal): Promise<McpResource[]>;
45
56
  readResource(uri: string, signal?: AbortSignal): Promise<string>;
57
+ /** List prompts; servers without prompts support yield [] (not an error). */
58
+ promptsList(signal?: AbortSignal): Promise<McpPromptDef[]>;
59
+ /** Get a prompt's messages as text. Throws McpError when unsupported/unknown. */
60
+ promptsGet(name: string, args?: Record<string, string>, signal?: AbortSignal): Promise<string>;
46
61
  close(): Promise<void>;
47
62
  /** Test hook: number of in-flight JSON-RPC requests. */
48
63
  pendingCount(): number;
@@ -2,7 +2,8 @@
2
2
  * P1 — MCP stdio client (r-11-17.md §3.1).
3
3
  *
4
4
  * Minimal JSON-RPC 2.0 over newline-delimited stdio: `initialize`,
5
- * `tools/list`, `tools/call`, `resources/list`, `resources/read`, `ping`.
5
+ * `tools/list`, `tools/call`, `resources/list`, `resources/read`,
6
+ * `prompts/list`, `prompts/get`, `ping`.
6
7
  * Every call is bound to an `AbortSignal` and a per-call timeout; spawn
7
8
  * failures and server errors surface as typed `McpError`s — never throws
8
9
  * raw across the boundary.
@@ -38,15 +39,22 @@ export class McpClient {
38
39
  constructor(name, spec) {
39
40
  this.name = name;
40
41
  this.spec = spec;
42
+ // URL specs route to RemoteMcpClient via makeMcpClient — direct
43
+ // construction without a command is a programming error, fail fast.
44
+ if (!spec.command)
45
+ throw new McpError(`mcp server "${name}" has no command (use url for remote servers)`, 'INVALID_SPEC');
41
46
  }
42
47
  async connect() {
43
48
  if (this.child)
44
49
  return;
50
+ const command = this.spec.command;
51
+ if (!command)
52
+ throw new McpError(`mcp server "${this.name}" has no command (use url for remote servers)`, 'INVALID_SPEC');
45
53
  const timeoutMs = this.spec.policy?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
46
54
  await new Promise((resolve, reject) => {
47
55
  let child;
48
56
  try {
49
- child = spawn(this.spec.command, this.spec.args ?? [], {
57
+ child = spawn(command, this.spec.args ?? [], {
50
58
  stdio: ['pipe', 'pipe', 'pipe'],
51
59
  env: { ...process.env, ...(this.spec.env ?? {}) },
52
60
  shell: false,
@@ -136,6 +144,38 @@ export class McpClient {
136
144
  const contents = Array.isArray(res.contents) ? res.contents : [];
137
145
  return contents.map((c) => (typeof c.text === 'string' ? c.text : JSON.stringify(c))).join('\n');
138
146
  }
147
+ /** List prompts; servers without prompts support yield [] (not an error). */
148
+ async promptsList(signal) {
149
+ this.assertLive();
150
+ let res;
151
+ try {
152
+ res = (await this.request('prompts/list', {}, this.timeout(), signal));
153
+ }
154
+ catch (err) {
155
+ if (err instanceof McpError && err.code === 'SERVER_ERROR' && err.details?.code === -32601) {
156
+ return [];
157
+ }
158
+ throw err;
159
+ }
160
+ return Array.isArray(res.prompts) ? res.prompts : [];
161
+ }
162
+ /** Get a prompt's messages as text. Throws McpError when unsupported/unknown. */
163
+ async promptsGet(name, args, signal) {
164
+ this.assertLive();
165
+ const res = (await this.request('prompts/get', { name, arguments: args ?? {} }, this.timeout(), signal));
166
+ const messages = Array.isArray(res.messages) ? res.messages : [];
167
+ const parts = [];
168
+ if (typeof res.description === 'string' && res.description)
169
+ parts.push(res.description);
170
+ for (const m of messages) {
171
+ const c = m.content;
172
+ if (c && typeof c.text === 'string')
173
+ parts.push(c.text);
174
+ else if (c !== undefined)
175
+ parts.push(JSON.stringify(c));
176
+ }
177
+ return parts.join('\n\n');
178
+ }
139
179
  async close() {
140
180
  // Idempotent vs the exit handler: closed is set FIRST (the on('exit')
141
181
  // callback checks it before failAll), and a second close() with no child