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.
- package/README.md +13 -0
- package/dist/agent/orchestrator.d.ts +7 -1
- package/dist/agent/orchestrator.js +18 -7
- package/dist/agent/runtime.d.ts +10 -0
- package/dist/agent/runtime.js +70 -13
- package/dist/chat.d.ts +10 -0
- package/dist/chat.js +39 -7
- package/dist/checkpoints/store.d.ts +2 -0
- package/dist/checkpoints/store.js +12 -0
- package/dist/cli/auth.d.ts +10 -3
- package/dist/cli/auth.js +43 -5
- package/dist/cli/doctor.js +0 -1
- package/dist/cli/eval.js +22 -16
- package/dist/cli/hooks.d.ts +21 -1
- package/dist/cli/hooks.js +34 -2
- package/dist/cli/keychain.d.ts +10 -0
- package/dist/cli/keychain.js +86 -0
- package/dist/cli/repl.js +54 -17
- package/dist/cli/setup.js +3 -2
- package/dist/cli/slash/parser.d.ts +1 -1
- package/dist/cli/slash/parser.js +6 -3
- package/dist/cli/update.d.ts +3 -1
- package/dist/cli/update.js +16 -1
- package/dist/context/accounting.d.ts +6 -0
- package/dist/context/accounting.js +8 -2
- package/dist/context/compaction.d.ts +2 -1
- package/dist/context/compaction.js +39 -12
- package/dist/context/memory.d.ts +11 -0
- package/dist/context/memory.js +47 -9
- package/dist/eval/harness.d.ts +19 -2
- package/dist/eval/harness.js +72 -7
- package/dist/index.js +90 -4
- package/dist/mcp/auth.d.ts +85 -0
- package/dist/mcp/auth.js +249 -0
- package/dist/mcp/config.d.ts +28 -0
- package/dist/mcp/config.js +65 -0
- package/dist/mcp/registry.d.ts +13 -7
- package/dist/mcp/registry.js +90 -14
- package/dist/mcp/remote.d.ts +7 -0
- package/dist/mcp/remote.js +56 -2
- package/dist/mcp/sse.d.ts +42 -0
- package/dist/mcp/sse.js +310 -0
- package/dist/persistence/audit.d.ts +15 -3
- package/dist/persistence/audit.js +84 -13
- package/dist/persistence/store.d.ts +9 -0
- package/dist/persistence/store.js +17 -0
- package/dist/policy/engine.js +9 -0
- package/dist/providers.js +4 -4
- package/dist/tools/shell/shell-exec.d.ts +13 -0
- package/dist/tools/shell/shell-exec.js +64 -2
- package/dist/tui/app.js +35 -5
- package/dist/tui/app.test.js +3 -2
- package/dist/tui/approval.js +3 -1
- package/dist/tui/scroll-model.d.ts +2 -2
- package/dist/tui/scroll-model.js +9 -3
- package/dist/tui/tokens.d.ts +8 -11
- package/dist/tui/tokens.js +18 -11
- package/package.json +1 -1
package/dist/context/memory.js
CHANGED
|
@@ -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
|
|
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
|
|
32
|
-
//
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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(
|
|
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
|
-
|
|
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;
|
package/dist/eval/harness.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
76
|
-
|
|
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>;
|
package/dist/eval/harness.js
CHANGED
|
@@ -44,7 +44,11 @@ function scriptedAdapter(script) {
|
|
|
44
44
|
}
|
|
45
45
|
export async function runTask(t, opts = {}) {
|
|
46
46
|
const start = Date.now();
|
|
47
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
200
|
-
|
|
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
|
-
.
|
|
149
|
-
|
|
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 {};
|