klyro 1.0.5 → 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 (47) hide show
  1. package/dist/agent/custom-agents.d.ts +3 -0
  2. package/dist/agent/custom-agents.js +96 -0
  3. package/dist/agent/orchestrator.d.ts +22 -2
  4. package/dist/agent/orchestrator.js +30 -4
  5. package/dist/agent/runtime.d.ts +5 -0
  6. package/dist/agent/runtime.js +164 -50
  7. package/dist/checkpoints/store.d.ts +9 -0
  8. package/dist/checkpoints/store.js +20 -0
  9. package/dist/cli/completion.js +2 -2
  10. package/dist/cli/config.d.ts +4 -4
  11. package/dist/cli/eval.d.ts +15 -1
  12. package/dist/cli/eval.js +34 -2
  13. package/dist/cli/hooks.d.ts +54 -5
  14. package/dist/cli/hooks.js +85 -6
  15. package/dist/cli/init.d.ts +6 -0
  16. package/dist/cli/init.js +60 -0
  17. package/dist/cli/repl.js +146 -25
  18. package/dist/cli/run.d.ts +7 -1
  19. package/dist/cli/run.js +92 -50
  20. package/dist/cli/slash/custom.d.ts +25 -0
  21. package/dist/cli/slash/custom.js +166 -0
  22. package/dist/cli/slash/parser.d.ts +9 -1
  23. package/dist/cli/slash/parser.js +31 -9
  24. package/dist/context/memory.js +18 -1
  25. package/dist/eval/harness.d.ts +21 -3
  26. package/dist/eval/harness.js +31 -3
  27. package/dist/eval/judge.d.ts +32 -0
  28. package/dist/eval/judge.js +63 -0
  29. package/dist/eval/tasks.js +134 -0
  30. package/dist/index.js +150 -127
  31. package/dist/mcp/client.d.ts +15 -0
  32. package/dist/mcp/client.js +42 -2
  33. package/dist/mcp/config.d.ts +3 -1
  34. package/dist/mcp/config.js +19 -1
  35. package/dist/mcp/registry.d.ts +13 -0
  36. package/dist/mcp/registry.js +41 -1
  37. package/dist/mcp/remote.d.ts +29 -0
  38. package/dist/mcp/remote.js +153 -0
  39. package/dist/policy/approval.d.ts +15 -1
  40. package/dist/policy/approval.js +8 -0
  41. package/dist/providers/endpoints.d.ts +43 -0
  42. package/dist/providers/endpoints.js +104 -0
  43. package/dist/providers.js +13 -10
  44. package/dist/tui/app.js +140 -13
  45. package/dist/tui/app.test.js +24 -0
  46. package/dist/tui/approval.js +53 -1
  47. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -60,8 +60,10 @@ async function main() {
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,69 +500,19 @@ 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);
555
510
  const obs = await store.loadObservations(full);
556
511
  const out = file ?? `${full}.export.json`;
557
512
  await (await import('node:fs/promises')).writeFile(out, JSON.stringify({ record: rec, messages: msgs, observations: obs }, null, 2));
558
513
  process.stdout.write(`exported ${full} → ${out}\n`);
559
- });
560
- sessions.command('import <file>').description('Import session from file (restores record + messages + observations)').action(async (file) => {
514
+ }
515
+ async function sessionImport(file) {
561
516
  let data;
562
517
  try {
563
518
  data = JSON.parse(await (await import('node:fs/promises')).readFile(file, 'utf-8'));
@@ -604,8 +559,8 @@ async function main() {
604
559
  }
605
560
  }
606
561
  process.stdout.write(`imported → ${created.id} (${restored} messages restored)\n`);
607
- });
608
- sessions.command('fork <id>').description('Fork session with full context (9.4)').action(async (id) => {
562
+ }
563
+ async function sessionFork(id) {
609
564
  const { getDefaultSessionStore, matchSessionIds } = await import('./persistence/session.js');
610
565
  const store = getDefaultSessionStore();
611
566
  const matches = await matchSessionIds(store, id);
@@ -621,8 +576,8 @@ async function main() {
621
576
  const forked = await store.fork(full);
622
577
  const msgs = await store.loadMessages(forked.id);
623
578
  process.stdout.write(`forked ${full.slice(0, 8)} → ${forked.id.slice(0, 8)} (${msgs.length} messages carried over)\n`);
624
- });
625
- sessions.command('delete <id>').description('Delete a session and its artifacts').action(async (id) => {
579
+ }
580
+ async function sessionDelete(id) {
626
581
  const { getDefaultSessionStore, matchSessionIds } = await import('./persistence/session.js');
627
582
  const store = getDefaultSessionStore();
628
583
  const matches = await matchSessionIds(store, id);
@@ -637,7 +592,64 @@ async function main() {
637
592
  const full = matches[0].id;
638
593
  await store.delete(full);
639
594
  process.stdout.write(`deleted ${full.slice(0, 8)}\n`);
640
- });
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); });
641
653
  // 10.1 — MCP
642
654
  const mcp = program.command('mcp').description('MCP client/server (10.1)');
643
655
  mcp.command('list').description('List MCP servers').action(async () => {
@@ -654,10 +666,14 @@ async function main() {
654
666
  process.stdout.write(`${name} source=${source}${spec?.disabled ? ' disabled' : ''}\n`);
655
667
  }
656
668
  });
657
- mcp.command('add <name> <command> [args...]').description('Add a project MCP server to .mcp.json (stdio command)').action(async (name, command, args) => {
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) => {
658
670
  const { addProjectServer, projectMcpPath } = await import('./mcp/config.js');
659
671
  try {
660
- addProjectServer(process.cwd(), name, { command, ...(args && args.length > 0 ? { args } : {}) });
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);
661
677
  process.stdout.write(`added mcp server "${name}" → ${projectMcpPath(process.cwd())}\n`);
662
678
  }
663
679
  catch (err) {
@@ -687,18 +703,21 @@ async function main() {
687
703
  process.stderr.write(`klyro: mcp server not found: ${name}\n`);
688
704
  process.exit(2);
689
705
  }
690
- const { McpClient } = await import('./mcp/client.js');
691
- const client = new McpClient(name, spec);
692
- // 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).
693
708
  const timer = setTimeout(() => {
694
709
  process.stderr.write(`klyro: mcp probe ${name} timed out after 15s\n`);
695
710
  process.exit(2);
696
711
  }, 15_000);
697
712
  try {
698
- await client.connect();
713
+ const withConnect = client;
714
+ if (typeof withConnect.connect === 'function')
715
+ await withConnect.connect();
699
716
  const tools = await client.listTools();
717
+ const prompts = typeof client.promptsList === 'function' ? await client.promptsList().catch(() => []) : [];
700
718
  const names = tools.map((t) => t.name);
701
- 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`);
702
721
  process.exit(0);
703
722
  }
704
723
  catch (err) {
@@ -731,15 +750,16 @@ async function main() {
731
750
  return;
732
751
  }
733
752
  for (const h of hooks)
734
- 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`);
735
754
  });
736
- program.command('agents [name] [extra...]').description('List agents (10.2), show one, or run: agents run <name> <task...>').action(async (name, extra) => {
737
- 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());
738
758
  // `klyro agents run <name> <task...>`: one-shot run under a named agent.
739
759
  if (name === 'run') {
740
760
  const [agentName, ...taskParts] = extra ?? [];
741
- if (!agentName || !BUILTIN_AGENTS.some((a) => a.id === agentName)) {
742
- 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`);
743
763
  process.exit(2);
744
764
  }
745
765
  const task = (taskParts ?? []).join(' ').trim();
@@ -756,19 +776,20 @@ async function main() {
756
776
  process.exit(code);
757
777
  }
758
778
  if (!name) {
759
- for (const a of BUILTIN_AGENTS)
760
- 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`);
761
781
  return;
762
782
  }
763
- const def = BUILTIN_AGENTS.find((a) => a.id === name);
783
+ const def = ALL.find((a) => a.id === name);
764
784
  if (!def) {
765
- 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`);
766
786
  process.exit(2);
767
787
  }
768
788
  const tools = def.allowedTools ?? ['<inherited: all parent tools>'];
769
789
  const lines = [
770
790
  `agent: ${def.id}`,
771
791
  `description: ${def.description}`,
792
+ `source: ${def.source ?? 'builtin'}`,
772
793
  `tools (${tools.length}): ${tools.join(', ')}`,
773
794
  `readonly: ${def.readonly ?? false}`,
774
795
  `canSpawn: ${def.canSpawn ?? false}`,
@@ -776,6 +797,8 @@ async function main() {
776
797
  `maxSteps: ${def.maxSteps ?? '<default>'}`,
777
798
  `maxTokens: ${def.maxTokens ?? '<default>'}`,
778
799
  ];
800
+ if (def.prompt)
801
+ lines.push(`prompt: ${def.prompt.slice(0, 200)}${def.prompt.length > 200 ? '…' : ''}`);
779
802
  process.stdout.write(lines.join('\n') + '\n');
780
803
  });
781
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
@@ -7,9 +7,11 @@ export declare const McpServerPolicySchema: z.ZodObject<{
7
7
  }, z.core.$strip>;
8
8
  export type McpServerPolicy = z.infer<typeof McpServerPolicySchema>;
9
9
  export declare const McpServerSpecSchema: z.ZodObject<{
10
- command: z.ZodString;
10
+ command: z.ZodOptional<z.ZodString>;
11
11
  args: z.ZodOptional<z.ZodArray<z.ZodString>>;
12
12
  env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
13
+ url: z.ZodOptional<z.ZodString>;
14
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
13
15
  disabled: z.ZodOptional<z.ZodBoolean>;
14
16
  policy: z.ZodOptional<z.ZodObject<{
15
17
  allowTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -19,11 +19,23 @@ export const McpServerPolicySchema = z.object({
19
19
  timeoutMs: z.number().int().positive().optional(),
20
20
  });
21
21
  export const McpServerSpecSchema = z.object({
22
- command: z.string().min(1),
22
+ /** stdio transport: command to spawn. Required unless `url` is set. */
23
+ command: z.string().min(1).optional(),
23
24
  args: z.array(z.string()).optional(),
24
25
  env: z.record(z.string(), z.string()).optional(),
26
+ /** Remote transport: Streamable-HTTP JSON-RPC endpoint. Required unless `command` is set. */
27
+ url: z.string().url().optional(),
28
+ /** Extra HTTP headers for remote transport (`${env:VAR}` expanded). */
29
+ headers: z.record(z.string(), z.string()).optional(),
25
30
  disabled: z.boolean().optional(),
26
31
  policy: McpServerPolicySchema.optional(),
32
+ }).superRefine((s, ctx) => {
33
+ if (!s.command && !s.url) {
34
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'MCP server needs either "command" (stdio) or "url" (remote HTTP)' });
35
+ }
36
+ if (s.command && s.url) {
37
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'MCP server takes either "command" or "url", not both' });
38
+ }
27
39
  });
28
40
  /** Upper bound for per-server timeouts — larger values are clamped, not rejected. */
29
41
  export const MAX_MCP_TIMEOUT_MS = 600_000;
@@ -82,6 +94,12 @@ export function loadMcpServers(cwd) {
82
94
  env[k] = expandEnv(v);
83
95
  spec.env = env;
84
96
  }
97
+ if (spec.headers) {
98
+ const headers = {};
99
+ for (const [k, v] of Object.entries(spec.headers))
100
+ headers[k] = expandEnv(v);
101
+ spec.headers = headers;
102
+ }
85
103
  servers[name] = spec;
86
104
  sources[name] = label;
87
105
  }
@@ -30,6 +30,11 @@ export declare const MCP_SUCCESS_MAX_CHARS = 12000;
30
30
  * behind a plausible-looking name.
31
31
  */
32
32
  export declare function sanitizeMcpName(server: string, tool: string): string;
33
+ /**
34
+ * Construct the right client for a spec: remote HTTP when `url` is set,
35
+ * else stdio. Used by registration, probe, and prompt runs.
36
+ */
37
+ export declare function makeMcpClient(name: string, spec: McpServerSpec): McpClientLike;
33
38
  export declare function registerMcpServers(cfg: {
34
39
  servers: Record<string, McpServerSpec>;
35
40
  }, opts: RegisterMcpOpts): Promise<McpRegisterResult>;
@@ -52,3 +57,11 @@ export declare function loadAndRegisterMcp(opts: {
52
57
  source: 'global' | 'project';
53
58
  }) => Promise<boolean>;
54
59
  }): Promise<McpRegisterResult>;
60
+ /**
61
+ * Run one MCP prompt (`/mcp__<server>__<prompt>`) on demand: fresh stdio
62
+ * connection per invocation (no client lifecycle to manage), redacted
63
+ * output. Typing the prompt name is explicit consent, so project servers
64
+ * connect without the registration-time approval gate. Throws McpError /
65
+ * Error on unknown server, disabled server, or prompt failure.
66
+ */
67
+ export declare function runMcpPrompt(cwd: string, server: string, prompt: string, args: Record<string, string>): Promise<string>;