klyro 1.0.6 → 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 (58) hide show
  1. package/README.md +13 -0
  2. package/dist/agent/orchestrator.d.ts +7 -1
  3. package/dist/agent/orchestrator.js +18 -7
  4. package/dist/agent/runtime.d.ts +10 -0
  5. package/dist/agent/runtime.js +70 -13
  6. package/dist/chat.d.ts +10 -0
  7. package/dist/chat.js +39 -7
  8. package/dist/checkpoints/store.d.ts +2 -0
  9. package/dist/checkpoints/store.js +12 -0
  10. package/dist/cli/auth.d.ts +10 -3
  11. package/dist/cli/auth.js +43 -5
  12. package/dist/cli/doctor.js +0 -1
  13. package/dist/cli/eval.js +22 -16
  14. package/dist/cli/hooks.d.ts +21 -1
  15. package/dist/cli/hooks.js +34 -2
  16. package/dist/cli/keychain.d.ts +10 -0
  17. package/dist/cli/keychain.js +86 -0
  18. package/dist/cli/repl.js +54 -17
  19. package/dist/cli/setup.js +3 -2
  20. package/dist/cli/slash/parser.d.ts +1 -1
  21. package/dist/cli/slash/parser.js +6 -3
  22. package/dist/cli/update.d.ts +3 -1
  23. package/dist/cli/update.js +16 -1
  24. package/dist/context/accounting.d.ts +6 -0
  25. package/dist/context/accounting.js +8 -2
  26. package/dist/context/compaction.d.ts +2 -1
  27. package/dist/context/compaction.js +39 -12
  28. package/dist/context/memory.d.ts +11 -0
  29. package/dist/context/memory.js +47 -9
  30. package/dist/eval/harness.d.ts +19 -2
  31. package/dist/eval/harness.js +72 -7
  32. package/dist/index.js +90 -4
  33. package/dist/mcp/auth.d.ts +85 -0
  34. package/dist/mcp/auth.js +249 -0
  35. package/dist/mcp/config.d.ts +28 -0
  36. package/dist/mcp/config.js +65 -0
  37. package/dist/mcp/registry.d.ts +13 -7
  38. package/dist/mcp/registry.js +90 -14
  39. package/dist/mcp/remote.d.ts +7 -0
  40. package/dist/mcp/remote.js +56 -2
  41. package/dist/mcp/sse.d.ts +42 -0
  42. package/dist/mcp/sse.js +310 -0
  43. package/dist/persistence/audit.d.ts +15 -3
  44. package/dist/persistence/audit.js +84 -13
  45. package/dist/persistence/store.d.ts +9 -0
  46. package/dist/persistence/store.js +17 -0
  47. package/dist/policy/engine.js +9 -0
  48. package/dist/providers.js +4 -4
  49. package/dist/tools/shell/shell-exec.d.ts +13 -0
  50. package/dist/tools/shell/shell-exec.js +64 -2
  51. package/dist/tui/app.js +35 -5
  52. package/dist/tui/app.test.js +3 -2
  53. package/dist/tui/approval.js +3 -1
  54. package/dist/tui/scroll-model.d.ts +2 -2
  55. package/dist/tui/scroll-model.js +9 -3
  56. package/dist/tui/tokens.d.ts +8 -11
  57. package/dist/tui/tokens.js +18 -11
  58. package/package.json +1 -1
@@ -10,17 +10,30 @@ import * as fs from 'node:fs/promises';
10
10
  import { readFileSync } from 'node:fs';
11
11
  import * as path from 'node:path';
12
12
  import { redact } from '../policy/secret-redactor.js';
13
+ import { resolveAndFollowSymlinks, assertNotSymlink } from '../policy/path-guard.js';
13
14
  /** Single-write ceiling — anything larger is rejected, not sliced. */
14
15
  export const MEMORY_WRITE_LIMIT_CHARS = 8000;
15
16
  /** Steady-state cap (~1k tokens ≈ 4k chars) kept via tail slice. */
16
17
  export const MEMORY_STEADY_STATE_CHARS = 4000;
18
+ /** Archive index filename (recall without an LLM pass). */
19
+ export const MEMORY_ARCHIVE_INDEX = 'archive-index.json';
20
+ /** Max retained archives. */
21
+ export const MEMORY_ARCHIVE_KEEP = 20;
17
22
  export async function memoryWrite(cwd, content) {
18
23
  if (content.length > MEMORY_WRITE_LIMIT_CHARS) {
19
24
  throw new Error(`memory budget exceeded: single write is ${content.length} chars (max ${MEMORY_WRITE_LIMIT_CHARS}) — split it into smaller notes`);
20
25
  }
26
+ // B4 — symlink/TOCTOU guard: the memory dir must stay inside cwd even if
27
+ // `.klyro` or `.klyro/memory` is (or becomes) a symlink. Resolve+follow up
28
+ // the existing parent chain first (defeats an escaping parent symlink), then
29
+ // mkdir, then a final realpath + lstat: a swapped-in final symlink is
30
+ // refused before any write lands outside cwd.
21
31
  const dir = path.join(cwd, '.klyro', 'memory');
32
+ await resolveAndFollowSymlinks(cwd, '.klyro'); // throws if `.klyro` escapes cwd
22
33
  await fs.mkdir(dir, { recursive: true });
23
- const p = path.join(dir, 'session-notes.md');
34
+ const { resolved } = await resolveAndFollowSymlinks(cwd, '.klyro/memory');
35
+ await assertNotSymlink(resolved);
36
+ const p = path.join(resolved, 'session-notes.md');
24
37
  const prev = await fs.readFile(p, 'utf-8').catch(() => '');
25
38
  // S4-at-rest: redact before appending — redact() only fires on secret
26
39
  // shapes (key/token/password with [:=-]), so normal prose survives.
@@ -28,21 +41,32 @@ export async function memoryWrite(cwd, content) {
28
41
  let next = full;
29
42
  if (full.length > MEMORY_STEADY_STATE_CHARS) {
30
43
  // Rotation, not silent loss: the dropped head is archived to a dated
31
- // file (pruned to the latest 20) instead of vanishing. Summarization
32
- // of archives is left to an explicit future pass.
44
+ // file (pruned to the latest 20) instead of vanishing, and the archive
45
+ // index is refreshed so the model knows what exists to re-read.
33
46
  const head = full.slice(0, full.length - MEMORY_STEADY_STATE_CHARS);
34
47
  next = full.slice(-MEMORY_STEADY_STATE_CHARS);
35
48
  try {
36
49
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
37
- await fs.writeFile(path.join(dir, `archive-${stamp}.md`), head, 'utf-8');
38
- const entries = (await fs.readdir(dir)).filter((e) => e.startsWith('archive-')).sort();
39
- for (const old of entries.slice(0, Math.max(0, entries.length - 20))) {
40
- await fs.unlink(path.join(dir, old)).catch(() => undefined);
50
+ const file = `archive-${stamp}.md`;
51
+ await fs.writeFile(path.join(resolved, file), head, 'utf-8');
52
+ const entries = (await fs.readdir(resolved)).filter((e) => e.startsWith('archive-') && e.endsWith('.md')).sort();
53
+ for (const old of entries.slice(0, Math.max(0, entries.length - MEMORY_ARCHIVE_KEEP))) {
54
+ await fs.unlink(path.join(resolved, old)).catch(() => undefined);
41
55
  }
56
+ const index = [];
57
+ for (const e of (await fs.readdir(resolved)).filter((e) => e.startsWith('archive-') && e.endsWith('.md')).sort()) {
58
+ try {
59
+ const content = await fs.readFile(path.join(resolved, e), 'utf-8');
60
+ const stat = await fs.stat(path.join(resolved, e));
61
+ index.push({ file: e, ts: Math.round(stat.mtimeMs), chars: content.length, preview: content.split('\n')[0]?.slice(0, 160) ?? '' });
62
+ }
63
+ catch { /* skip unreadable entries */ }
64
+ }
65
+ await fs.writeFile(path.join(resolved, MEMORY_ARCHIVE_INDEX), JSON.stringify(index, null, 2), 'utf-8');
42
66
  }
43
67
  catch { /* archive is best-effort; the live notes still persist */ }
44
68
  }
45
- const tmp = path.join(dir, `.session-notes.md.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
69
+ const tmp = path.join(resolved, `.session-notes.md.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
46
70
  await fs.writeFile(tmp, next, 'utf-8');
47
71
  try {
48
72
  const fh = await fs.open(tmp, 'r+');
@@ -83,7 +107,21 @@ export function loadMemorySync(cwd) {
83
107
  /** Wrap persisted notes into the injected prompt block; '' when empty. */
84
108
  export function memoryBlock(cwd) {
85
109
  const notes = loadMemorySync(cwd).trim();
86
- return notes ? `\n\n<memory>\n${notes.slice(0, 4000)}\n</memory>` : '';
110
+ if (!notes)
111
+ return '';
112
+ // Recall aid: tell the model which archives exist (with previews) so it
113
+ // can re-read them via read_file instead of losing rotated knowledge.
114
+ let archived = '';
115
+ try {
116
+ const raw = readFileSync(path.join(cwd, '.klyro', 'memory', MEMORY_ARCHIVE_INDEX), 'utf-8');
117
+ const entries = JSON.parse(raw);
118
+ if (Array.isArray(entries) && entries.length > 0) {
119
+ const lines = entries.slice(-5).map((e) => ` - .klyro/memory/${e.file} (${e.chars} chars): ${e.preview}`);
120
+ archived = `\nEarlier notes archived (read one with read_file for detail):\n${lines.join('\n')}`;
121
+ }
122
+ }
123
+ catch { /* no index — nothing archived yet */ }
124
+ return `\n\n<memory>\n${notes.slice(0, 4000)}${archived}\n</memory>`;
87
125
  }
88
126
  export function shouldRemind(turn, lastRemindTurn) {
89
127
  return turn - lastRemindTurn >= 20;
@@ -44,6 +44,7 @@ export interface TaskResult {
44
44
  export declare function runTask(t: ScriptedTask, opts?: {
45
45
  judgeAdapter?: ProviderAdapter;
46
46
  judgeModel?: string;
47
+ workDir?: string;
47
48
  }): Promise<TaskResult>;
48
49
  export interface HarnessSummary {
49
50
  total: number;
@@ -66,14 +67,30 @@ export interface FileFixture {
66
67
  checkSh: string;
67
68
  meta: Record<string, unknown>;
68
69
  repo?: string;
70
+ /**
71
+ * Optional canned agent turns (StreamEvent[][], same shape as
72
+ * ScriptedTask.script). When present the fixture is agent-driven: the
73
+ * runtime executes the script with real tools in a seeded tmp repo and
74
+ * check.sh then asserts the filesystem outcomes.
75
+ */
76
+ script?: StreamEvent[][];
69
77
  }
70
78
  export declare function loadFileFixture(dir: string): Promise<FileFixture>;
71
79
  export declare function runFileFixture(fixture: FileFixture, opts?: {
72
80
  runs?: number;
73
81
  parallel?: number;
74
82
  }): Promise<TaskResult>;
75
- /** Hook the harness up to a session + audit log so task runs are durable. */
76
- export declare function runHarnessWithPersistence(tasks: ScriptedTask[], opts: {
83
+ /**
84
+ * Agent-driven fixture: seed a tmp repo, run the fixture's canned script
85
+ * through the REAL runtime + tools, then assert outcomes with check.sh
86
+ * (plus optional meta judge rubric). This is what makes fixtures more than
87
+ * static `exit 0` stubs: the agent loop genuinely executes.
88
+ */
89
+ export declare function runAgentFixture(fixture: FileFixture, opts?: {
90
+ judgeAdapter?: ProviderAdapter;
91
+ judgeModel?: string;
92
+ }): Promise<TaskResult>;
93
+ /** Hook the harness up to a session + audit log so task runs are durable. */ export declare function runHarnessWithPersistence(tasks: ScriptedTask[], opts: {
77
94
  storeDir: string;
78
95
  auditPath: string;
79
96
  }): Promise<HarnessSummary>;
@@ -44,7 +44,11 @@ function scriptedAdapter(script) {
44
44
  }
45
45
  export async function runTask(t, opts = {}) {
46
46
  const start = Date.now();
47
- const cwd = path.join(os.tmpdir(), 'klyro-eval-' + t.id + '-' + Math.random().toString(36).slice(2));
47
+ // workDir lets callers seed a repo and inspect outcomes afterwards
48
+ // (agent-driven fixtures); otherwise an isolated tmp dir is used and
49
+ // removed.
50
+ const owned = !opts.workDir;
51
+ const cwd = opts.workDir ?? path.join(os.tmpdir(), 'klyro-eval-' + t.id + '-' + Math.random().toString(36).slice(2));
48
52
  await fs.mkdir(cwd, { recursive: true });
49
53
  const reg = new ToolRegistry()
50
54
  .register(readFileTool).register(writeFileTool).register(editFileTool)
@@ -110,10 +114,12 @@ export async function runTask(t, opts = {}) {
110
114
  };
111
115
  }
112
116
  finally {
113
- try {
114
- await fs.rm(cwd, { recursive: true, force: true });
117
+ if (owned) {
118
+ try {
119
+ await fs.rm(cwd, { recursive: true, force: true });
120
+ }
121
+ catch { }
115
122
  }
116
- catch { }
117
123
  }
118
124
  }
119
125
  export async function runHarness(tasks, opts = {}) {
@@ -158,7 +164,14 @@ export async function loadFileFixture(dir) {
158
164
  repo = await fs.readFile(path.join(dir, 'repo'), 'utf-8');
159
165
  }
160
166
  catch { /* ignore */ }
161
- return { dir, task: task.trim(), checkSh, meta, repo };
167
+ let script;
168
+ try {
169
+ const raw = JSON.parse(await fs.readFile(path.join(dir, 'script.json'), 'utf-8'));
170
+ if (Array.isArray(raw))
171
+ script = raw;
172
+ }
173
+ catch { /* no script → static fixture */ }
174
+ return { dir, task: task.trim(), checkSh, meta, ...(repo ? { repo } : {}), ...(script ? { script } : {}) };
162
175
  }
163
176
  export async function runFileFixture(fixture, opts = {}) {
164
177
  const start = Date.now();
@@ -196,8 +209,60 @@ export async function runFileFixture(fixture, opts = {}) {
196
209
  await fs.rm(tmp, { recursive: true, force: true }).catch(() => undefined);
197
210
  return result;
198
211
  }
199
- /** Hook the harness up to a session + audit log so task runs are durable. */
200
- export async function runHarnessWithPersistence(tasks, opts) {
212
+ /**
213
+ * Agent-driven fixture: seed a tmp repo, run the fixture's canned script
214
+ * through the REAL runtime + tools, then assert outcomes with check.sh
215
+ * (plus optional meta judge rubric). This is what makes fixtures more than
216
+ * static `exit 0` stubs: the agent loop genuinely executes.
217
+ */
218
+ export async function runAgentFixture(fixture, opts = {}) {
219
+ const start = Date.now();
220
+ const id = path.basename(fixture.dir);
221
+ if (!fixture.script) {
222
+ return { id, status: 'fail', details: 'agent fixture needs script.json', durationMs: Date.now() - start };
223
+ }
224
+ const tmp = path.join(os.tmpdir(), 'klyro-eval-agent-' + Math.random().toString(36).slice(2));
225
+ await fs.mkdir(tmp, { recursive: true });
226
+ try {
227
+ if (fixture.repo) {
228
+ const src = path.isAbsolute(fixture.repo) ? fixture.repo : path.join(fixture.dir, fixture.repo);
229
+ await fs.cp(src, tmp, { recursive: true }).catch(() => undefined);
230
+ }
231
+ const meta = fixture.meta;
232
+ const judgeRubric = meta['judge'] && typeof meta['judge'] === 'object'
233
+ ? meta['judge'].rubric
234
+ : undefined;
235
+ const r = await runTask({
236
+ id,
237
+ description: fixture.task.slice(0, 120),
238
+ task: fixture.task,
239
+ script: fixture.script,
240
+ expectStatus: (typeof meta['expectStatus'] === 'string' ? meta['expectStatus'] : 'complete'),
241
+ ...(typeof meta['expectToolCalls'] === 'number' ? { expectToolCalls: meta['expectToolCalls'] } : {}),
242
+ ...(Array.isArray(judgeRubric) && judgeRubric.every((s) => typeof s === 'string') ? { judge: { rubric: judgeRubric } } : {}),
243
+ }, { workDir: tmp, ...(opts.judgeAdapter ? { judgeAdapter: opts.judgeAdapter, judgeModel: opts.judgeModel } : {}) });
244
+ if (r.status === 'fail')
245
+ return { ...r, durationMs: Date.now() - start };
246
+ // Structural pass — now assert real filesystem outcomes.
247
+ const { spawn } = await import('node:child_process');
248
+ const out = await new Promise((resolve) => {
249
+ const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false });
250
+ let buf = '';
251
+ child.stdout?.on('data', (b) => { buf += b.toString(); });
252
+ child.stderr?.on('data', (b) => { buf += b.toString(); });
253
+ child.on('close', (code) => resolve(`exit=${code ?? -1} ${buf.slice(0, 500)}`));
254
+ child.on('error', (err) => resolve(`spawn-error: ${String(err)}`));
255
+ });
256
+ if (!out.startsWith('exit=0')) {
257
+ return { ...r, status: 'fail', details: `${r.details} check.sh: ${out}`.trim(), durationMs: Date.now() - start };
258
+ }
259
+ return { ...r, durationMs: Date.now() - start };
260
+ }
261
+ finally {
262
+ await fs.rm(tmp, { recursive: true, force: true }).catch(() => undefined);
263
+ }
264
+ }
265
+ /** Hook the harness up to a session + audit log so task runs are durable. */ export async function runHarnessWithPersistence(tasks, opts) {
201
266
  const store = new SessionStore(opts.storeDir);
202
267
  const audit = new AuditLog(opts.auditPath);
203
268
  const start = Date.now();
package/dist/index.js CHANGED
@@ -145,8 +145,9 @@ async function main() {
145
145
  program
146
146
  .command('update')
147
147
  .description('Check for klyro updates (cached 24h, KLYRO_NO_UPDATE_CHECK=1 to disable)')
148
- .action(async () => {
149
- const code = await runUpdate();
148
+ .option('--apply', 'Apply the update now via npm i -g (opt-in; default is notify-only)')
149
+ .action(async (opts) => {
150
+ const code = await runUpdate({ apply: !!opts.apply });
150
151
  process.exit(code);
151
152
  });
152
153
  program
@@ -645,7 +646,7 @@ async function main() {
645
646
  const sessions = program.command('sessions').description('Alias for session (same subcommands)');
646
647
  sessions.command('list').description('List persisted sessions').option('--status <s>', 'Filter by status').option('--json', 'Output JSON').action(async (opts) => { await sessionList(opts); });
647
648
  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('resume <id>').description('Resume a persisted session').option('-m, --model <id>', 'Model').option('--max-steps <n>', 'Max steps', (v) => parsePositiveInt('--max-steps', v)).option('--verify-command <cmd>', 'Override verification command').option('--verify', 'Enable verification (default: enabled)').action(async (id, opts) => { await sessionResume(id, opts); });
649
650
  sessions.command('export <id> [file]').description('Export session to file (9.4)').action(async (id, file) => { await sessionExport(id, file); });
650
651
  sessions.command('import <file>').description('Import session from file (restores record + messages + observations)').action(async (file) => { await sessionImport(file); });
651
652
  sessions.command('fork <id>').description('Fork session with full context (9.4)').action(async (id) => { await sessionFork(id); });
@@ -732,6 +733,58 @@ async function main() {
732
733
  catch { /* ignore */ }
733
734
  }
734
735
  });
736
+ mcp.command('trust <name>').description('Trust a project MCP server spec (records its hash, enables auto-connect)').action(async (name) => {
737
+ const { loadMcpServers } = await import('./mcp/config.js');
738
+ const { McpTrust, hashSpec } = await import('./mcp/trust.js');
739
+ const cfg = loadMcpServers(process.cwd());
740
+ const spec = cfg.servers[name];
741
+ if (!spec) {
742
+ process.stderr.write(`klyro: mcp server not found: ${name}\n`);
743
+ process.exit(2);
744
+ }
745
+ new McpTrust().approve(name, hashSpec(spec));
746
+ process.stdout.write(`trusted mcp server "${name}" (source=${cfg.sources[name] ?? 'unknown'})\n`);
747
+ });
748
+ mcp.command('prompts [server]').description('List MCP prompts as /mcp__<server>__<prompt> slash names').action(async (server) => {
749
+ const { loadMcpServers, enabledServers } = await import('./mcp/config.js');
750
+ const { makeMcpClient } = await import('./mcp/registry.js');
751
+ const cfg = loadMcpServers(process.cwd());
752
+ const targets = server ? [server] : Object.keys(enabledServers(cfg));
753
+ if (targets.length === 0) {
754
+ process.stdout.write('mcp prompts: none (no servers configured)\n');
755
+ return;
756
+ }
757
+ let shown = 0;
758
+ for (const name of targets) {
759
+ const spec = cfg.servers[name];
760
+ if (!spec) {
761
+ process.stderr.write(`klyro: mcp server not found: ${name}\n`);
762
+ continue;
763
+ }
764
+ const client = makeMcpClient(name, spec);
765
+ try {
766
+ const withConnect = client;
767
+ if (typeof withConnect.connect === 'function')
768
+ await withConnect.connect();
769
+ const prompts = typeof client.promptsList === 'function' ? await client.promptsList() : [];
770
+ for (const p of prompts) {
771
+ process.stdout.write(`/mcp__${name}__${p.name}${p.description ? ` — ${p.description}` : ''}\n`);
772
+ shown++;
773
+ }
774
+ }
775
+ catch (err) {
776
+ process.stderr.write(`klyro: mcp prompts ${name} failed: ${err instanceof Error ? err.message : String(err)}\n`);
777
+ }
778
+ finally {
779
+ try {
780
+ await client.close();
781
+ }
782
+ catch { /* ignore */ }
783
+ }
784
+ }
785
+ if (shown === 0)
786
+ process.stdout.write('mcp prompts: none exposed by configured servers\n');
787
+ });
735
788
  mcp.command('serve').description('Serve builtin tools as an MCP server over stdio (policy-gated)').action(async () => {
736
789
  const { serveStdio } = await import('./mcp/serve.js');
737
790
  const code = await serveStdio(process.cwd());
@@ -752,9 +805,42 @@ async function main() {
752
805
  for (const h of hooks)
753
806
  process.stdout.write(`${h.name} ${h.event}${h.matcher ? ` (${h.matcher})` : ''} ${h.command}\n`);
754
807
  });
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) => {
808
+ program.command('agents [name] [extra...]').description('List agents (builtins + .klyro/agents/*.md), show one, lint files, or run: agents run <name> <task...>').action(async (name, extra) => {
756
809
  const { listAllAgents } = await import('./agent/orchestrator.js');
757
810
  const ALL = listAllAgents(process.cwd());
811
+ // `klyro agents lint`: validate custom agent files (ids, tool names).
812
+ if (name === 'lint') {
813
+ const { loadCustomAgents } = await import('./agent/custom-agents.js');
814
+ const { builtinRegistry } = await import('./tools/registry.js');
815
+ const known = new Set(builtinRegistry().list().map((t) => t.name));
816
+ const customs = loadCustomAgents(process.cwd());
817
+ if (customs.length === 0) {
818
+ process.stdout.write('agents lint: no custom agents in .klyro/agents/ (or ~/.klyro/agents/)\n');
819
+ return;
820
+ }
821
+ let bad = 0;
822
+ for (const a of customs) {
823
+ const problems = [];
824
+ if (!a.description || a.description === `Custom agent ${a.id}`)
825
+ problems.push('missing description');
826
+ for (const t of a.allowedTools ?? []) {
827
+ if (!known.has(t))
828
+ problems.push(`unknown tool "${t}" (will be dropped at spawn)`);
829
+ }
830
+ if (!a.prompt && !(a.allowedTools ?? []).length)
831
+ problems.push('no prompt body and no tools — agent has no specialization');
832
+ if (problems.length > 0) {
833
+ bad++;
834
+ process.stdout.write(`${a.id} (${a.source ?? 'custom'}):\n${problems.map((p) => ` ! ${p}`).join('\n')}\n`);
835
+ }
836
+ else {
837
+ process.stdout.write(`${a.id} (${a.source ?? 'custom'}): ok\n`);
838
+ }
839
+ }
840
+ if (bad > 0)
841
+ process.exit(2);
842
+ return;
843
+ }
758
844
  // `klyro agents run <name> <task...>`: one-shot run under a named agent.
759
845
  if (name === 'run') {
760
846
  const [agentName, ...taskParts] = extra ?? [];
@@ -0,0 +1,85 @@
1
+ /** Shape of the `auth` block on an MCP server spec (see config.ts). */
2
+ export interface McpAuthSpec {
3
+ /** OAuth 2.0 client identifier registered with the authorization server. */
4
+ clientId: string;
5
+ /** Token endpoint; when absent, discovered from the server's metadata. */
6
+ tokenEndpoint?: string;
7
+ /**
8
+ * Authorization endpoint; when absent, discovered from the server's
9
+ * metadata. The MCP server URL is the OAuth resource server, so its
10
+ * `/.well-known/oauth-authorization-server` metadata is the default
11
+ * discovery source (per the MCP 2025-06-18 auth spec).
12
+ */
13
+ authorizationEndpoint?: string;
14
+ /** Custom redirect URI; defaults to a loopback listener on 127.0.0.1. */
15
+ redirectUri?: string;
16
+ /** Space-separated additional scopes to request. */
17
+ scopes?: string;
18
+ }
19
+ export declare class AuthError extends Error {
20
+ constructor(message: string);
21
+ }
22
+ export interface CachedToken {
23
+ access_token: string;
24
+ token_type: string;
25
+ /** Unix seconds when the token expires. 0 = unknown (treated as valid). */
26
+ expires_at: number;
27
+ refresh_token?: string;
28
+ scope?: string;
29
+ cachedAtMs: number;
30
+ }
31
+ export declare const TOKEN_STALE_MARGIN_S = 60;
32
+ export declare const TOKENS_FILE = "mcp-tokens.json";
33
+ /** Fully load the token cache as a plain object (missing/corrupt → {}). */
34
+ export declare function loadMcpTokens(): Record<string, CachedToken>;
35
+ /**
36
+ * Persist a token to the 0600 cache. Merges by key so concurrent server
37
+ * writes don't clobber each other. A symlinked cache path is refused (the
38
+ * write would land outside the klyro config dir or be swapped mid-write).
39
+ */
40
+ export declare function saveMcpToken(cacheKey: string, token: CachedToken): void;
41
+ /** Stable cache key for a server URL + clientId. */
42
+ export declare function mcpTokenCacheKey(url: string, clientId: string): string;
43
+ /** True when a cached token is still usable (with a clock-skew margin). */
44
+ export declare function tokenValid(t: CachedToken): boolean;
45
+ /** PKCE (RFC 7636 §4.1): cryptographically-random 43-128 char code verifier. */
46
+ export declare function generateCodeVerifier(): string;
47
+ /** PKCE §4.2: base64url SHA-256 challenge of the verifier. */
48
+ export declare function codeChallengeFromVerifier(verifier: string): string;
49
+ export interface AuthResolveResult {
50
+ /** Extra headers (e.g. `Authorization`) to attach to MCP requests. */
51
+ headers: Record<string, string>;
52
+ /** True when a cached token was used (no interactive flow needed). */
53
+ fromCache: boolean;
54
+ }
55
+ /**
56
+ * Resolve auth headers for a server: reuse a valid cached token, else run
57
+ * the interactive Authorization Code + PKCE flow. Returns only the headers;
58
+ * failures surface as `AuthError` so callers (the MCP probe/connect path)
59
+ * can turn them into a clear error instead of a hang.
60
+ */
61
+ export declare function resolveAuth(auth: McpAuthSpec, cacheKey: string): Promise<AuthResolveResult>;
62
+ interface TokenResponse {
63
+ access_token: string;
64
+ token_type?: string;
65
+ expires_in?: number;
66
+ refresh_token?: string;
67
+ scope?: string;
68
+ }
69
+ /**
70
+ * Run the Authorization Code flow with PKCE over a one-shot loopback
71
+ * redirect (RFC 8252). Best-effort and headless-friendly: on any failure it
72
+ * throws `AuthError` rather than leaving a stray listener. The token endpoint
73
+ * defaults to the OAuth metadata `token_endpoint` when the server advertises
74
+ * it, else the spec's `tokenEndpoint`.
75
+ */
76
+ export declare function runCodeFlow(auth: McpAuthSpec, serverUrl?: string): Promise<TokenResponse | null>;
77
+ /**
78
+ * Scrub credential-bearing headers from debug capture so `KLYRO_MCP_DEBUG`
79
+ * never writes a live access token to disk. Non-credential headers pass
80
+ * through untouched.
81
+ */
82
+ export declare function scrubAuthHeaders(headers: Record<string, string>): Record<string, string>;
83
+ /** Refreshes a stale token when a refresh_token is cached (best-effort). */
84
+ export declare function tryRefresh(auth: McpAuthSpec, cached: CachedToken): Promise<CachedToken | null>;
85
+ export {};