klyro 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/agent/custom-agents.d.ts +3 -0
- package/dist/agent/custom-agents.js +96 -0
- package/dist/agent/orchestrator.d.ts +26 -0
- package/dist/agent/orchestrator.js +41 -4
- package/dist/agent/runtime.d.ts +15 -0
- package/dist/agent/runtime.js +232 -61
- package/dist/chat.d.ts +10 -0
- package/dist/chat.js +39 -7
- package/dist/checkpoints/store.d.ts +11 -0
- package/dist/checkpoints/store.js +32 -0
- package/dist/cli/auth.d.ts +10 -3
- package/dist/cli/auth.js +43 -5
- package/dist/cli/completion.js +2 -2
- package/dist/cli/config.d.ts +4 -4
- package/dist/cli/doctor.js +0 -1
- package/dist/cli/eval.d.ts +15 -1
- package/dist/cli/eval.js +43 -5
- package/dist/cli/hooks.d.ts +74 -5
- package/dist/cli/hooks.js +118 -7
- package/dist/cli/init.d.ts +6 -0
- package/dist/cli/init.js +60 -0
- package/dist/cli/keychain.d.ts +10 -0
- package/dist/cli/keychain.js +86 -0
- package/dist/cli/repl.js +188 -30
- package/dist/cli/run.d.ts +7 -1
- package/dist/cli/run.js +92 -50
- package/dist/cli/setup.js +3 -2
- package/dist/cli/slash/custom.d.ts +25 -0
- package/dist/cli/slash/custom.js +166 -0
- package/dist/cli/slash/parser.d.ts +9 -1
- package/dist/cli/slash/parser.js +34 -9
- 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 +59 -4
- package/dist/eval/harness.d.ts +40 -5
- package/dist/eval/harness.js +103 -10
- package/dist/eval/judge.d.ts +32 -0
- package/dist/eval/judge.js +63 -0
- package/dist/eval/tasks.js +134 -0
- package/dist/index.js +239 -130
- package/dist/mcp/auth.d.ts +85 -0
- package/dist/mcp/auth.js +249 -0
- package/dist/mcp/client.d.ts +15 -0
- package/dist/mcp/client.js +42 -2
- package/dist/mcp/config.d.ts +31 -1
- package/dist/mcp/config.js +84 -1
- package/dist/mcp/registry.d.ts +19 -0
- package/dist/mcp/registry.js +118 -2
- package/dist/mcp/remote.d.ts +36 -0
- package/dist/mcp/remote.js +207 -0
- 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/approval.d.ts +15 -1
- package/dist/policy/approval.js +8 -0
- package/dist/policy/engine.js +9 -0
- package/dist/providers/endpoints.d.ts +43 -0
- package/dist/providers/endpoints.js +104 -0
- package/dist/providers.js +17 -14
- package/dist/tools/shell/shell-exec.d.ts +13 -0
- package/dist/tools/shell/shell-exec.js +64 -2
- package/dist/tui/app.js +172 -15
- package/dist/tui/app.test.js +27 -2
- package/dist/tui/approval.js +55 -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/cli/auth.d.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 2.2 — klyro login / logout / aliases
|
|
3
|
-
* Stores masked key →
|
|
3
|
+
* Stores masked key → OS keychain when available, else
|
|
4
|
+
* ~/.klyro/credentials.json 0600 (with refuse-on-lax-perms reads).
|
|
4
5
|
*/
|
|
5
6
|
export declare function credPath(): string;
|
|
6
|
-
/** Persist one provider key
|
|
7
|
-
export declare function saveKey(provider: string, key: string): Promise<
|
|
7
|
+
/** Persist one provider key: OS keychain when available, else 0600 file. Never logs or returns the key. */
|
|
8
|
+
export declare function saveKey(provider: string, key: string): Promise<'keychain' | 'file'>;
|
|
9
|
+
/**
|
|
10
|
+
* Async key read: OS keychain first (when available), then the 0600 file
|
|
11
|
+
* (with refuse-on-lax-perms). Use in async paths (provider resolution,
|
|
12
|
+
* eval, setup); sync contexts keep `getStoredKey` (file only).
|
|
13
|
+
*/
|
|
14
|
+
export declare function getStoredKeyAsync(provider: string): Promise<string | undefined>;
|
|
8
15
|
/** Which providers have stored keys (names only — never values). */
|
|
9
16
|
export declare function storedProviders(): string[];
|
|
10
17
|
export declare const LOGIN_DEFAULTS: Record<string, {
|
package/dist/cli/auth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 2.2 — klyro login / logout / aliases
|
|
3
|
-
* Stores masked key →
|
|
3
|
+
* Stores masked key → OS keychain when available, else
|
|
4
|
+
* ~/.klyro/credentials.json 0600 (with refuse-on-lax-perms reads).
|
|
4
5
|
*/
|
|
5
6
|
import * as fs from 'node:fs/promises';
|
|
6
7
|
import * as fsSync from 'node:fs';
|
|
@@ -15,21 +16,44 @@ export function credPath() {
|
|
|
15
16
|
const home = os.homedir() || process.cwd();
|
|
16
17
|
return path.join(home, '.klyro', 'credentials.json');
|
|
17
18
|
}
|
|
18
|
-
/** Persist one provider key
|
|
19
|
+
/** Persist one provider key: OS keychain when available, else 0600 file. Never logs or returns the key. */
|
|
19
20
|
export async function saveKey(provider, key) {
|
|
21
|
+
const trimmed = key.trim();
|
|
22
|
+
try {
|
|
23
|
+
const { keychainSet } = await import('./keychain.js');
|
|
24
|
+
if (await keychainSet(provider, trimmed))
|
|
25
|
+
return 'keychain';
|
|
26
|
+
}
|
|
27
|
+
catch { /* fall through to file */ }
|
|
20
28
|
const creds = {};
|
|
21
29
|
try {
|
|
22
30
|
const raw = await fs.readFile(credPath(), 'utf-8');
|
|
23
31
|
Object.assign(creds, JSON.parse(raw));
|
|
24
32
|
}
|
|
25
33
|
catch { /* ignore */ }
|
|
26
|
-
creds[provider] =
|
|
34
|
+
creds[provider] = trimmed;
|
|
27
35
|
await fs.mkdir(path.dirname(credPath()), { recursive: true });
|
|
28
36
|
await fs.writeFile(credPath(), JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
29
37
|
try {
|
|
30
38
|
await fs.chmod(credPath(), 0o600);
|
|
31
39
|
}
|
|
32
40
|
catch { /* ignore on Windows */ }
|
|
41
|
+
return 'file';
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Async key read: OS keychain first (when available), then the 0600 file
|
|
45
|
+
* (with refuse-on-lax-perms). Use in async paths (provider resolution,
|
|
46
|
+
* eval, setup); sync contexts keep `getStoredKey` (file only).
|
|
47
|
+
*/
|
|
48
|
+
export async function getStoredKeyAsync(provider) {
|
|
49
|
+
try {
|
|
50
|
+
const { keychainGet } = await import('./keychain.js');
|
|
51
|
+
const v = await keychainGet(provider);
|
|
52
|
+
if (v)
|
|
53
|
+
return v;
|
|
54
|
+
}
|
|
55
|
+
catch { /* fall through to file */ }
|
|
56
|
+
return getStoredKey(provider);
|
|
33
57
|
}
|
|
34
58
|
/** Which providers have stored keys (names only — never values). */
|
|
35
59
|
export function storedProviders() {
|
|
@@ -78,8 +102,10 @@ export async function runLogin() {
|
|
|
78
102
|
const model = ((await rl.question(`Model [${defs.model}]: `)) || defs.model).trim();
|
|
79
103
|
const storeProvider = provider === 'local' ? 'openai' : provider;
|
|
80
104
|
if (key.trim()) {
|
|
81
|
-
await saveKey(storeProvider, key);
|
|
82
|
-
process.stdout.write(
|
|
105
|
+
const where = await saveKey(storeProvider, key);
|
|
106
|
+
process.stdout.write(where === 'keychain'
|
|
107
|
+
? `Saved ${storeProvider} key to the OS keychain\n`
|
|
108
|
+
: `Saved ${storeProvider} key to ${credPath()} (0600)\n`);
|
|
83
109
|
}
|
|
84
110
|
// Persist non-secret settings (merged with existing config, never clobbers).
|
|
85
111
|
const { loadConfig, saveConfig } = await import('./config.js');
|
|
@@ -106,6 +132,18 @@ export async function runLogin() {
|
|
|
106
132
|
}
|
|
107
133
|
}
|
|
108
134
|
export async function runLogout(provider) {
|
|
135
|
+
// Best-effort keychain removal first (a keychain-held key must not survive
|
|
136
|
+
// a file-only logout).
|
|
137
|
+
try {
|
|
138
|
+
const { keychainDelete } = await import('./keychain.js');
|
|
139
|
+
if (provider)
|
|
140
|
+
await keychainDelete(provider);
|
|
141
|
+
else {
|
|
142
|
+
await keychainDelete('openai');
|
|
143
|
+
await keychainDelete('anthropic');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch { /* ignore */ }
|
|
109
147
|
try {
|
|
110
148
|
const raw = await fs.readFile(credPath(), 'utf-8');
|
|
111
149
|
const creds = JSON.parse(raw);
|
package/dist/cli/completion.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* 1.2 — klyro completion
|
|
3
3
|
* Generates shell completion scripts for bash/zsh/fish/powershell.
|
|
4
4
|
*/
|
|
5
|
-
const COMMANDS = ['tui', 'run', 'chat', 'config', 'doctor', 'completion', 'update', 'eval', 'session', 'resume', 'help', 'version', 'scan', 'project', 'mcp', 'hooks', 'agents', 'commit', 'audit', 'benchmark', 'sessions', 'login', 'logout'];
|
|
5
|
+
const COMMANDS = ['tui', 'run', 'chat', 'config', 'doctor', 'init', 'completion', 'update', 'eval', 'session', 'resume', 'help', 'version', 'scan', 'project', 'mcp', 'hooks', 'agents', 'commit', 'audit', 'benchmark', 'sessions', 'login', 'logout'];
|
|
6
6
|
/** Second-level completion: global flags + per-command flags. */
|
|
7
7
|
const GLOBAL_FLAGS = ['--cwd', '--config', '--debug', '--verbose', '--quiet', '--json', '--yes', '--no-color', '--print', '--output-format', '--no-stream', '--show-thinking', '--tui', '--chat', '--continue', '--resume', '--help', '--version'];
|
|
8
8
|
const COMMAND_FLAGS = {
|
|
9
|
-
run: ['-m', '--model', '--max-steps', '--max-tokens', '--temperature', '--timeout', '--base-url', '--api-key', '--output', '--provider', '--dry-run', '--resume', '--resume-session', '--verify', '--verify-command', '--verify-mode', '--max-repairs', '--persist', '--require-verify', '--agent', '--max-depth'],
|
|
9
|
+
run: ['-m', '--model', '--max-steps', '--max-tokens', '--temperature', '--timeout', '--base-url', '--api-key', '--output', '--provider', '--dry-run', '--resume', '--resume-session', '--verify', '--verify-command', '--verify-mode', '--max-repairs', '--persist', '--require-verify', '--agent', '--max-depth', '--bare'],
|
|
10
10
|
chat: ['-s', '--system', '-m', '--model', '-t', '--timeout'],
|
|
11
11
|
eval: ['--output', '--suite', '--filter', '--runs', '--parallel', '--model'],
|
|
12
12
|
tui: ['-m', '--model', '--max-steps'],
|
package/dist/cli/config.d.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { z } from 'zod';
|
|
|
7
7
|
export declare const ConfigSchema: z.ZodObject<{
|
|
8
8
|
model: z.ZodOptional<z.ZodString>;
|
|
9
9
|
provider: z.ZodOptional<z.ZodEnum<{
|
|
10
|
-
openai: "openai";
|
|
11
10
|
anthropic: "anthropic";
|
|
11
|
+
openai: "openai";
|
|
12
12
|
}>>;
|
|
13
13
|
baseUrl: z.ZodOptional<z.ZodString>;
|
|
14
14
|
apiKey: z.ZodOptional<z.ZodString>;
|
|
@@ -20,8 +20,8 @@ export declare const ConfigSchema: z.ZodObject<{
|
|
|
20
20
|
providers: z.ZodOptional<z.ZodObject<{
|
|
21
21
|
failover: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
22
22
|
provider: z.ZodEnum<{
|
|
23
|
-
openai: "openai";
|
|
24
23
|
anthropic: "anthropic";
|
|
24
|
+
openai: "openai";
|
|
25
25
|
}>;
|
|
26
26
|
baseURL: z.ZodOptional<z.ZodString>;
|
|
27
27
|
apiKey: z.ZodOptional<z.ZodString>;
|
|
@@ -32,8 +32,8 @@ export declare const ConfigSchema: z.ZodObject<{
|
|
|
32
32
|
export type KlyroConfig = z.infer<typeof ConfigSchema>;
|
|
33
33
|
export declare const FailoverEntrySchema: z.ZodObject<{
|
|
34
34
|
provider: z.ZodEnum<{
|
|
35
|
-
openai: "openai";
|
|
36
35
|
anthropic: "anthropic";
|
|
36
|
+
openai: "openai";
|
|
37
37
|
}>;
|
|
38
38
|
baseURL: z.ZodOptional<z.ZodString>;
|
|
39
39
|
apiKey: z.ZodOptional<z.ZodString>;
|
|
@@ -43,8 +43,8 @@ export type FailoverEntry = z.infer<typeof FailoverEntrySchema>;
|
|
|
43
43
|
export declare const FailoverConfigSchema: z.ZodObject<{
|
|
44
44
|
failover: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
45
45
|
provider: z.ZodEnum<{
|
|
46
|
-
openai: "openai";
|
|
47
46
|
anthropic: "anthropic";
|
|
47
|
+
openai: "openai";
|
|
48
48
|
}>;
|
|
49
49
|
baseURL: z.ZodOptional<z.ZodString>;
|
|
50
50
|
apiKey: z.ZodOptional<z.ZodString>;
|
package/dist/cli/doctor.js
CHANGED
|
@@ -198,7 +198,6 @@ export async function runDoctor(opts = {}) {
|
|
|
198
198
|
process.stdout.write('─'.repeat(40) + '\n');
|
|
199
199
|
for (const c of checks) {
|
|
200
200
|
const glyph = c.ok ? '✓' : '✗';
|
|
201
|
-
const color = c.ok ? '' : '';
|
|
202
201
|
process.stdout.write(`${glyph} ${c.name.padEnd(14)} ${c.detail}\n`);
|
|
203
202
|
}
|
|
204
203
|
process.stdout.write('─'.repeat(40) + '\n');
|
package/dist/cli/eval.d.ts
CHANGED
|
@@ -54,6 +54,10 @@ export interface EvalScenario {
|
|
|
54
54
|
};
|
|
55
55
|
/** Adapter events to script, as [eventKind, ...args] tuples. */
|
|
56
56
|
scripted_events?: Array<Array<unknown[]>>;
|
|
57
|
+
/** Semantic rubric graded by a model judge (needs --judge-model). */
|
|
58
|
+
judge?: {
|
|
59
|
+
rubric: string[];
|
|
60
|
+
};
|
|
57
61
|
expect?: {
|
|
58
62
|
status?: RunResult['status'];
|
|
59
63
|
textContains?: string;
|
|
@@ -70,6 +74,11 @@ export interface EvalResult {
|
|
|
70
74
|
toolCalls: number;
|
|
71
75
|
text: string;
|
|
72
76
|
durationMs: number;
|
|
77
|
+
judge?: {
|
|
78
|
+
pass: boolean;
|
|
79
|
+
notes: string;
|
|
80
|
+
skipped: boolean;
|
|
81
|
+
};
|
|
73
82
|
}
|
|
74
83
|
export interface RunEvalOptions {
|
|
75
84
|
inputPath: string;
|
|
@@ -79,7 +88,12 @@ export interface RunEvalOptions {
|
|
|
79
88
|
runs?: number;
|
|
80
89
|
parallel?: number;
|
|
81
90
|
model?: string;
|
|
91
|
+
/** Live model id for grading `judge.rubric` (env endpoint + key required). */
|
|
92
|
+
judgeModel?: string;
|
|
82
93
|
}
|
|
83
94
|
export declare function runEval(opts: RunEvalOptions): Promise<number>;
|
|
84
95
|
export declare function scriptedAdapterFromSpec(spec: Array<Array<unknown[]>> | undefined): ProviderAdapter;
|
|
85
|
-
export declare function runScenario(sc: EvalScenario
|
|
96
|
+
export declare function runScenario(sc: EvalScenario, judgeOpts?: {
|
|
97
|
+
adapter: ProviderAdapter;
|
|
98
|
+
model: string;
|
|
99
|
+
}): Promise<EvalResult>;
|
package/dist/cli/eval.js
CHANGED
|
@@ -47,6 +47,19 @@ import { builtinRegistry } from '../tools/registry.js';
|
|
|
47
47
|
import { builtinRules, DEFAULT_POLICY_CONFIG, PolicyEngine } from '../policy/engine.js';
|
|
48
48
|
import { DenyAllApprovalPrompt } from '../policy/approval.js';
|
|
49
49
|
export async function runEval(opts) {
|
|
50
|
+
// Live judge adapter (shared by suite + JSONL paths) for `judge.rubric`.
|
|
51
|
+
let judgeAdapter;
|
|
52
|
+
if (opts.judgeModel) {
|
|
53
|
+
const { httpChatAdapter } = await import('../agent/provider-adapter.js');
|
|
54
|
+
const { getStoredKeyAsync } = await import('./auth.js');
|
|
55
|
+
const baseUrl = process.env.KLYRO_BASE_URL;
|
|
56
|
+
const apiKey = process.env.KLYRO_API_KEY ?? (await getStoredKeyAsync('openai')) ?? (await getStoredKeyAsync('anthropic'));
|
|
57
|
+
if (!baseUrl || !apiKey) {
|
|
58
|
+
stderr.write('klyro eval: --judge-model needs KLYRO_BASE_URL and KLYRO_API_KEY (or a stored key)\n');
|
|
59
|
+
return 2;
|
|
60
|
+
}
|
|
61
|
+
judgeAdapter = httpChatAdapter({ baseURL: baseUrl, apiKey });
|
|
62
|
+
}
|
|
50
63
|
// 5.4 — suite mode: load from evals/fixtures
|
|
51
64
|
if (opts.suite) {
|
|
52
65
|
const { runHarness, loadFileFixture } = await import('../eval/harness.js');
|
|
@@ -98,12 +111,17 @@ export async function runEval(opts) {
|
|
|
98
111
|
stderr.write(`klyro eval: no fixtures for suite ${opts.suite}\n`);
|
|
99
112
|
return 2;
|
|
100
113
|
}
|
|
101
|
-
//
|
|
102
|
-
|
|
114
|
+
// Suite runner: agent-driven fixtures (script.json) execute the real
|
|
115
|
+
// runtime + tools before check.sh; static fixtures just run check.sh.
|
|
116
|
+
// --judge-model threads a live grader into both paths that declare rubrics.
|
|
117
|
+
const { runFileFixture, runAgentFixture, loadFileFixture: loadFF } = await import('../eval/harness.js');
|
|
103
118
|
const results = [];
|
|
104
119
|
for (const t of tasks) {
|
|
105
120
|
const fixture = await loadFF(path.join(fixturesDir, t.id));
|
|
106
|
-
const
|
|
121
|
+
const judgeOpts = judgeAdapter && opts.judgeModel ? { judgeAdapter, judgeModel: opts.judgeModel } : {};
|
|
122
|
+
const r = fixture.script
|
|
123
|
+
? await runAgentFixture(fixture, judgeOpts)
|
|
124
|
+
: await runFileFixture(fixture, { runs: opts.runs, parallel: opts.parallel });
|
|
107
125
|
results.push(r);
|
|
108
126
|
const tag = r.status === 'pass' ? 'PASS' : 'FAIL';
|
|
109
127
|
if (opts.output === 'json')
|
|
@@ -131,10 +149,11 @@ export async function runEval(opts) {
|
|
|
131
149
|
stderr.write('klyro eval: no scenarios in input\n');
|
|
132
150
|
return 2;
|
|
133
151
|
}
|
|
152
|
+
// Live judge adapter was built at the top (shared with suite mode).
|
|
134
153
|
const results = [];
|
|
135
154
|
for (const sc of scenarios) {
|
|
136
155
|
const start = Date.now();
|
|
137
|
-
const r = await runScenario(sc);
|
|
156
|
+
const r = await runScenario(sc, judgeAdapter && opts.judgeModel ? { adapter: judgeAdapter, model: opts.judgeModel } : undefined);
|
|
138
157
|
r.durationMs = Date.now() - start;
|
|
139
158
|
results.push(r);
|
|
140
159
|
if (opts.output === 'json') {
|
|
@@ -223,7 +242,7 @@ function tupleToEvent(tuple) {
|
|
|
223
242
|
throw new Error(`scriptedAdapterFromSpec: unknown event kind: ${kind}`);
|
|
224
243
|
}
|
|
225
244
|
}
|
|
226
|
-
export async function runScenario(sc) {
|
|
245
|
+
export async function runScenario(sc, judgeOpts) {
|
|
227
246
|
const failures = [];
|
|
228
247
|
const model = sc.model ?? 'mock';
|
|
229
248
|
const adapter = scriptedAdapterFromSpec(sc.scripted_events);
|
|
@@ -265,6 +284,24 @@ export async function runScenario(sc) {
|
|
|
265
284
|
if (exp.toolCallsAtMost !== undefined && result.toolCalls > exp.toolCallsAtMost) {
|
|
266
285
|
failures.push(`toolCalls: expected <= ${exp.toolCallsAtMost}, got ${result.toolCalls}`);
|
|
267
286
|
}
|
|
287
|
+
// Model-graded semantic check (opt-in: needs a live judge adapter).
|
|
288
|
+
let judge;
|
|
289
|
+
if (sc.judge && sc.judge.rubric.length > 0) {
|
|
290
|
+
if (judgeOpts) {
|
|
291
|
+
const { runJudge } = await import('../eval/judge.js');
|
|
292
|
+
const v = await runJudge(judgeOpts.adapter, judgeOpts.model, {
|
|
293
|
+
task: sc.task,
|
|
294
|
+
finalText: result.finalText,
|
|
295
|
+
toolCalls: result.toolCalls,
|
|
296
|
+
}, sc.judge.rubric);
|
|
297
|
+
judge = { pass: v.pass, notes: v.notes, skipped: v.skipped };
|
|
298
|
+
if (!v.pass)
|
|
299
|
+
failures.push(`judge: ${v.notes || 'rubric unmet'}`);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
judge = { pass: true, notes: 'no judge adapter — skipped', skipped: true };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
268
305
|
return {
|
|
269
306
|
name: sc.name,
|
|
270
307
|
passed: failures.length === 0,
|
|
@@ -274,5 +311,6 @@ export async function runScenario(sc) {
|
|
|
274
311
|
toolCalls: result.toolCalls,
|
|
275
312
|
text: result.finalText,
|
|
276
313
|
durationMs: 0,
|
|
314
|
+
...(judge ? { judge } : {}),
|
|
277
315
|
};
|
|
278
316
|
}
|
package/dist/cli/hooks.d.ts
CHANGED
|
@@ -1,26 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hooks engine (10.2) —
|
|
2
|
+
* Hooks engine (10.2, v2) — lifecycle + tool shell hooks.
|
|
3
|
+
*
|
|
4
|
+
* Events: `preToolUse` / `postToolUse` (per tool call), `sessionStart`
|
|
5
|
+
* (once per run, may abort it), `sessionEnd` (once per run, best-effort),
|
|
6
|
+
* `stop` (after each agent step).
|
|
3
7
|
*
|
|
4
8
|
* Config files:
|
|
5
9
|
* - project: `<cwd>/.klyro/hooks.json`
|
|
6
10
|
* - global: `~/.klyro/hooks.json` (merged; project wins on name clash)
|
|
7
11
|
*
|
|
8
|
-
* Schema: `{ hooks: Array<{ name, event, command, timeoutMs? }> }`.
|
|
12
|
+
* Schema: `{ hooks: Array<{ name, event, command, matcher?, timeoutMs? }> }`.
|
|
13
|
+
* `matcher` is a regex tested against the tool name — lifecycle events
|
|
14
|
+
* (`sessionStart`/`sessionEnd`/`stop`) always match; tool events without a
|
|
15
|
+
* matcher match every tool; invalid regex never matches.
|
|
16
|
+
*
|
|
17
|
+
* Verdict contract: stdout parsed as JSON yields `{decision, message,
|
|
18
|
+
* context, continue|cont}`. preToolUse `decision:"deny"` blocks with
|
|
19
|
+
* `message` (wins over exit code); `decision:"allow"` + `context` attaches
|
|
20
|
+
* model-visible context to the tool result. stop `continue:true` grants one
|
|
21
|
+
* more turn (max 3/run). Non-JSON stdout keeps pure exit-code semantics.
|
|
9
22
|
*
|
|
10
23
|
* `loadHooks` never throws — a missing file is `[]`, an invalid file is
|
|
11
24
|
* `[]` plus a one-time stderr warning per path. `runHook` spawns the
|
|
12
25
|
* command with `shell: true` (commands are strings, must work on win +
|
|
13
|
-
* posix), a default 30s timeout, a filtered env,
|
|
14
|
-
*
|
|
26
|
+
* posix), a default 30s timeout, a filtered env, `KLYRO_TOOL_NAME` /
|
|
27
|
+
* `KLYRO_TOOL_INPUT_JSON` for inspection, AND the full JSON payload on
|
|
28
|
+
* stdin (`{ event, tool, input, sessionId, ... }`).
|
|
15
29
|
*/
|
|
16
30
|
import { z } from 'zod';
|
|
31
|
+
export declare const HookEventSchema: z.ZodEnum<{
|
|
32
|
+
stop: "stop";
|
|
33
|
+
preToolUse: "preToolUse";
|
|
34
|
+
postToolUse: "postToolUse";
|
|
35
|
+
sessionStart: "sessionStart";
|
|
36
|
+
sessionEnd: "sessionEnd";
|
|
37
|
+
}>;
|
|
38
|
+
export type HookEvent = z.infer<typeof HookEventSchema>;
|
|
17
39
|
export declare const HookSchema: z.ZodObject<{
|
|
18
40
|
name: z.ZodString;
|
|
19
41
|
event: z.ZodEnum<{
|
|
42
|
+
stop: "stop";
|
|
20
43
|
preToolUse: "preToolUse";
|
|
21
44
|
postToolUse: "postToolUse";
|
|
45
|
+
sessionStart: "sessionStart";
|
|
46
|
+
sessionEnd: "sessionEnd";
|
|
22
47
|
}>;
|
|
23
48
|
command: z.ZodString;
|
|
49
|
+
matcher: z.ZodOptional<z.ZodString>;
|
|
24
50
|
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
25
51
|
}, z.core.$strip>;
|
|
26
52
|
export type Hook = z.infer<typeof HookSchema>;
|
|
@@ -29,6 +55,20 @@ export interface HookResult {
|
|
|
29
55
|
exitCode: number | null;
|
|
30
56
|
stdout: string;
|
|
31
57
|
stderr: string;
|
|
58
|
+
/**
|
|
59
|
+
* Structured verdict parsed from stdout when it is a JSON object
|
|
60
|
+
* (mirrors CC hook JSON output). Fields:
|
|
61
|
+
* - decision: 'allow' | 'deny' (preToolUse; deny wins over exit code)
|
|
62
|
+
* - message: denial reason / continuation note
|
|
63
|
+
* - context: extra model-visible context (preToolUse allow only)
|
|
64
|
+
* - cont: stop-hook request to continue the loop one more turn
|
|
65
|
+
*/
|
|
66
|
+
verdict?: {
|
|
67
|
+
decision?: string;
|
|
68
|
+
message?: string;
|
|
69
|
+
context?: string;
|
|
70
|
+
cont?: boolean;
|
|
71
|
+
};
|
|
32
72
|
}
|
|
33
73
|
export declare const DEFAULT_HOOK_TIMEOUT_MS = 30000;
|
|
34
74
|
/**
|
|
@@ -40,8 +80,37 @@ export interface HookContext {
|
|
|
40
80
|
toolName: string;
|
|
41
81
|
input: unknown;
|
|
42
82
|
}
|
|
83
|
+
/** Lifecycle payload delivered on stdin (and merged into env where small). */
|
|
84
|
+
export interface HookPayload {
|
|
85
|
+
event: HookEvent;
|
|
86
|
+
tool?: string;
|
|
87
|
+
input?: unknown;
|
|
88
|
+
sessionId?: string;
|
|
89
|
+
cwd?: string;
|
|
90
|
+
status?: string;
|
|
91
|
+
step?: number;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Select hooks for an event. Tool events honor `matcher` (regex against the
|
|
95
|
+
* tool name; invalid regex never matches); lifecycle events always match.
|
|
96
|
+
* Exported pure for unit tests.
|
|
97
|
+
*/
|
|
98
|
+
export declare function hooksForEvent(hooks: Hook[], event: HookEvent, toolName?: string): Hook[];
|
|
43
99
|
/**
|
|
44
100
|
* Run one hook. Resolves (never rejects) with the exit code + sliced
|
|
45
101
|
* output. `ok` is true only when the process exited 0.
|
|
102
|
+
*
|
|
103
|
+
* `stdinJson` (when given) is written to the child's stdin as JSON —
|
|
104
|
+
* the primary contract (mirrors CC's stdin-JSON hooks); the env vars are
|
|
105
|
+
* kept as a convenience for shell one-liners.
|
|
106
|
+
*/
|
|
107
|
+
export declare function runHook(hook: Hook, ctx: HookContext, stdinJson?: unknown): Promise<HookResult>;
|
|
108
|
+
/**
|
|
109
|
+
* Run all `sessionEnd` hooks for a finished run (best-effort, sequential).
|
|
110
|
+
* Returns hook outputs for logging. Never throws.
|
|
46
111
|
*/
|
|
47
|
-
export declare function
|
|
112
|
+
export declare function runSessionEndHooks(cwd: string, sessionId: string | undefined, status: string): Promise<Array<{
|
|
113
|
+
name: string;
|
|
114
|
+
ok: boolean;
|
|
115
|
+
output: string;
|
|
116
|
+
}>>;
|
package/dist/cli/hooks.js
CHANGED
|
@@ -1,27 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hooks engine (10.2) —
|
|
2
|
+
* Hooks engine (10.2, v2) — lifecycle + tool shell hooks.
|
|
3
|
+
*
|
|
4
|
+
* Events: `preToolUse` / `postToolUse` (per tool call), `sessionStart`
|
|
5
|
+
* (once per run, may abort it), `sessionEnd` (once per run, best-effort),
|
|
6
|
+
* `stop` (after each agent step).
|
|
3
7
|
*
|
|
4
8
|
* Config files:
|
|
5
9
|
* - project: `<cwd>/.klyro/hooks.json`
|
|
6
10
|
* - global: `~/.klyro/hooks.json` (merged; project wins on name clash)
|
|
7
11
|
*
|
|
8
|
-
* Schema: `{ hooks: Array<{ name, event, command, timeoutMs? }> }`.
|
|
12
|
+
* Schema: `{ hooks: Array<{ name, event, command, matcher?, timeoutMs? }> }`.
|
|
13
|
+
* `matcher` is a regex tested against the tool name — lifecycle events
|
|
14
|
+
* (`sessionStart`/`sessionEnd`/`stop`) always match; tool events without a
|
|
15
|
+
* matcher match every tool; invalid regex never matches.
|
|
16
|
+
*
|
|
17
|
+
* Verdict contract: stdout parsed as JSON yields `{decision, message,
|
|
18
|
+
* context, continue|cont}`. preToolUse `decision:"deny"` blocks with
|
|
19
|
+
* `message` (wins over exit code); `decision:"allow"` + `context` attaches
|
|
20
|
+
* model-visible context to the tool result. stop `continue:true` grants one
|
|
21
|
+
* more turn (max 3/run). Non-JSON stdout keeps pure exit-code semantics.
|
|
9
22
|
*
|
|
10
23
|
* `loadHooks` never throws — a missing file is `[]`, an invalid file is
|
|
11
24
|
* `[]` plus a one-time stderr warning per path. `runHook` spawns the
|
|
12
25
|
* command with `shell: true` (commands are strings, must work on win +
|
|
13
|
-
* posix), a default 30s timeout, a filtered env,
|
|
14
|
-
*
|
|
26
|
+
* posix), a default 30s timeout, a filtered env, `KLYRO_TOOL_NAME` /
|
|
27
|
+
* `KLYRO_TOOL_INPUT_JSON` for inspection, AND the full JSON payload on
|
|
28
|
+
* stdin (`{ event, tool, input, sessionId, ... }`).
|
|
15
29
|
*/
|
|
16
30
|
import { spawn } from 'node:child_process';
|
|
17
31
|
import * as fs from 'node:fs';
|
|
18
32
|
import * as os from 'node:os';
|
|
19
33
|
import * as path from 'node:path';
|
|
20
34
|
import { z } from 'zod';
|
|
35
|
+
export const HookEventSchema = z.enum(['preToolUse', 'postToolUse', 'sessionStart', 'sessionEnd', 'stop']);
|
|
21
36
|
export const HookSchema = z.object({
|
|
22
37
|
name: z.string().min(1),
|
|
23
|
-
event:
|
|
38
|
+
event: HookEventSchema,
|
|
24
39
|
command: z.string().min(1),
|
|
40
|
+
/** Optional regex matched against the tool name (tool events only). */
|
|
41
|
+
matcher: z.string().min(1).optional(),
|
|
25
42
|
timeoutMs: z.number().int().positive().optional(),
|
|
26
43
|
});
|
|
27
44
|
const HooksFileSchema = z.object({
|
|
@@ -116,11 +133,36 @@ function hookEnv() {
|
|
|
116
133
|
out.PATH = process.env.PATH;
|
|
117
134
|
return out;
|
|
118
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Select hooks for an event. Tool events honor `matcher` (regex against the
|
|
138
|
+
* tool name; invalid regex never matches); lifecycle events always match.
|
|
139
|
+
* Exported pure for unit tests.
|
|
140
|
+
*/
|
|
141
|
+
export function hooksForEvent(hooks, event, toolName) {
|
|
142
|
+
return hooks.filter((h) => {
|
|
143
|
+
if (h.event !== event)
|
|
144
|
+
return false;
|
|
145
|
+
if (h.matcher === undefined)
|
|
146
|
+
return true;
|
|
147
|
+
if (toolName === undefined)
|
|
148
|
+
return true; // lifecycle events have no tool
|
|
149
|
+
try {
|
|
150
|
+
return new RegExp(h.matcher).test(toolName);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
119
157
|
/**
|
|
120
158
|
* Run one hook. Resolves (never rejects) with the exit code + sliced
|
|
121
159
|
* output. `ok` is true only when the process exited 0.
|
|
160
|
+
*
|
|
161
|
+
* `stdinJson` (when given) is written to the child's stdin as JSON —
|
|
162
|
+
* the primary contract (mirrors CC's stdin-JSON hooks); the env vars are
|
|
163
|
+
* kept as a convenience for shell one-liners.
|
|
122
164
|
*/
|
|
123
|
-
export function runHook(hook, ctx) {
|
|
165
|
+
export function runHook(hook, ctx, stdinJson) {
|
|
124
166
|
const timeoutMs = hook.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
|
|
125
167
|
return new Promise((resolve) => {
|
|
126
168
|
let env;
|
|
@@ -139,6 +181,13 @@ export function runHook(hook, ctx) {
|
|
|
139
181
|
catch {
|
|
140
182
|
env.KLYRO_TOOL_INPUT_JSON = '{}';
|
|
141
183
|
}
|
|
184
|
+
let payload = '';
|
|
185
|
+
try {
|
|
186
|
+
payload = JSON.stringify(stdinJson ?? { event: 'preToolUse', tool: ctx.toolName, input: ctx.input ?? {} });
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
payload = '{}';
|
|
190
|
+
}
|
|
142
191
|
let child;
|
|
143
192
|
try {
|
|
144
193
|
child = spawn(hook.command, {
|
|
@@ -147,6 +196,7 @@ export function runHook(hook, ctx) {
|
|
|
147
196
|
windowsHide: true,
|
|
148
197
|
timeout: timeoutMs,
|
|
149
198
|
env,
|
|
199
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
150
200
|
});
|
|
151
201
|
}
|
|
152
202
|
catch (err) {
|
|
@@ -170,12 +220,73 @@ export function runHook(hook, ctx) {
|
|
|
170
220
|
});
|
|
171
221
|
child.on('close', (code) => {
|
|
172
222
|
const exitCode = typeof code === 'number' ? code : -1;
|
|
223
|
+
const out = stdout.slice(0, MAX_HOOK_OUTPUT_CHARS);
|
|
173
224
|
resolve({
|
|
174
225
|
ok: exitCode === 0,
|
|
175
226
|
exitCode,
|
|
176
|
-
stdout:
|
|
227
|
+
stdout: out,
|
|
177
228
|
stderr: stderr.slice(0, MAX_HOOK_OUTPUT_CHARS),
|
|
229
|
+
...parseVerdict(out),
|
|
178
230
|
});
|
|
179
231
|
});
|
|
232
|
+
// Deliver the stdin JSON contract, then close so the child never hangs
|
|
233
|
+
// waiting for EOF. Write errors (EPIPE on early exit) are ignored —
|
|
234
|
+
// the exit code below is what matters.
|
|
235
|
+
try {
|
|
236
|
+
if (child.stdin) {
|
|
237
|
+
child.stdin.on('error', () => undefined);
|
|
238
|
+
child.stdin.write(payload);
|
|
239
|
+
child.stdin.end();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch { /* ignore */ }
|
|
180
243
|
});
|
|
181
244
|
}
|
|
245
|
+
/** Parse a structured JSON verdict from hook stdout (best-effort). */
|
|
246
|
+
function parseVerdict(out) {
|
|
247
|
+
const trimmed = out.trim();
|
|
248
|
+
if (!trimmed.startsWith('{') || !trimmed.endsWith('}'))
|
|
249
|
+
return {};
|
|
250
|
+
try {
|
|
251
|
+
const p = JSON.parse(trimmed);
|
|
252
|
+
const verdict = {};
|
|
253
|
+
if (typeof p['decision'] === 'string')
|
|
254
|
+
verdict.decision = p['decision'];
|
|
255
|
+
if (typeof p['message'] === 'string')
|
|
256
|
+
verdict.message = p['message'].slice(0, 2000);
|
|
257
|
+
if (typeof p['context'] === 'string')
|
|
258
|
+
verdict.context = p['context'].slice(0, 2000);
|
|
259
|
+
if (typeof p['continue'] === 'boolean')
|
|
260
|
+
verdict.cont = p['continue'];
|
|
261
|
+
if (typeof p['cont'] === 'boolean' && verdict.cont === undefined)
|
|
262
|
+
verdict.cont = p['cont'];
|
|
263
|
+
return Object.keys(verdict).length > 0 ? { verdict } : {};
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return {};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Run all `sessionEnd` hooks for a finished run (best-effort, sequential).
|
|
271
|
+
* Returns hook outputs for logging. Never throws.
|
|
272
|
+
*/
|
|
273
|
+
export async function runSessionEndHooks(cwd, sessionId, status) {
|
|
274
|
+
const out = [];
|
|
275
|
+
let hooks;
|
|
276
|
+
try {
|
|
277
|
+
hooks = hooksForEvent(loadHooks(cwd), 'sessionEnd');
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
for (const h of hooks) {
|
|
283
|
+
try {
|
|
284
|
+
const r = await runHook(h, { toolName: '', input: {} }, { event: 'sessionEnd', sessionId, cwd, status });
|
|
285
|
+
out.push({ name: h.name, ok: r.ok, output: (r.stdout || r.stderr || '').slice(0, 2000) });
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
out.push({ name: h.name, ok: false, output: '' });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|