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.
Files changed (78) hide show
  1. package/README.md +13 -0
  2. package/dist/agent/custom-agents.d.ts +3 -0
  3. package/dist/agent/custom-agents.js +96 -0
  4. package/dist/agent/orchestrator.d.ts +26 -0
  5. package/dist/agent/orchestrator.js +41 -4
  6. package/dist/agent/runtime.d.ts +15 -0
  7. package/dist/agent/runtime.js +232 -61
  8. package/dist/chat.d.ts +10 -0
  9. package/dist/chat.js +39 -7
  10. package/dist/checkpoints/store.d.ts +11 -0
  11. package/dist/checkpoints/store.js +32 -0
  12. package/dist/cli/auth.d.ts +10 -3
  13. package/dist/cli/auth.js +43 -5
  14. package/dist/cli/completion.js +2 -2
  15. package/dist/cli/config.d.ts +4 -4
  16. package/dist/cli/doctor.js +0 -1
  17. package/dist/cli/eval.d.ts +15 -1
  18. package/dist/cli/eval.js +43 -5
  19. package/dist/cli/hooks.d.ts +74 -5
  20. package/dist/cli/hooks.js +118 -7
  21. package/dist/cli/init.d.ts +6 -0
  22. package/dist/cli/init.js +60 -0
  23. package/dist/cli/keychain.d.ts +10 -0
  24. package/dist/cli/keychain.js +86 -0
  25. package/dist/cli/repl.js +188 -30
  26. package/dist/cli/run.d.ts +7 -1
  27. package/dist/cli/run.js +92 -50
  28. package/dist/cli/setup.js +3 -2
  29. package/dist/cli/slash/custom.d.ts +25 -0
  30. package/dist/cli/slash/custom.js +166 -0
  31. package/dist/cli/slash/parser.d.ts +9 -1
  32. package/dist/cli/slash/parser.js +34 -9
  33. package/dist/cli/update.d.ts +3 -1
  34. package/dist/cli/update.js +16 -1
  35. package/dist/context/accounting.d.ts +6 -0
  36. package/dist/context/accounting.js +8 -2
  37. package/dist/context/compaction.d.ts +2 -1
  38. package/dist/context/compaction.js +39 -12
  39. package/dist/context/memory.d.ts +11 -0
  40. package/dist/context/memory.js +59 -4
  41. package/dist/eval/harness.d.ts +40 -5
  42. package/dist/eval/harness.js +103 -10
  43. package/dist/eval/judge.d.ts +32 -0
  44. package/dist/eval/judge.js +63 -0
  45. package/dist/eval/tasks.js +134 -0
  46. package/dist/index.js +239 -130
  47. package/dist/mcp/auth.d.ts +85 -0
  48. package/dist/mcp/auth.js +249 -0
  49. package/dist/mcp/client.d.ts +15 -0
  50. package/dist/mcp/client.js +42 -2
  51. package/dist/mcp/config.d.ts +31 -1
  52. package/dist/mcp/config.js +84 -1
  53. package/dist/mcp/registry.d.ts +19 -0
  54. package/dist/mcp/registry.js +118 -2
  55. package/dist/mcp/remote.d.ts +36 -0
  56. package/dist/mcp/remote.js +207 -0
  57. package/dist/mcp/sse.d.ts +42 -0
  58. package/dist/mcp/sse.js +310 -0
  59. package/dist/persistence/audit.d.ts +15 -3
  60. package/dist/persistence/audit.js +84 -13
  61. package/dist/persistence/store.d.ts +9 -0
  62. package/dist/persistence/store.js +17 -0
  63. package/dist/policy/approval.d.ts +15 -1
  64. package/dist/policy/approval.js +8 -0
  65. package/dist/policy/engine.js +9 -0
  66. package/dist/providers/endpoints.d.ts +43 -0
  67. package/dist/providers/endpoints.js +104 -0
  68. package/dist/providers.js +17 -14
  69. package/dist/tools/shell/shell-exec.d.ts +13 -0
  70. package/dist/tools/shell/shell-exec.js +64 -2
  71. package/dist/tui/app.js +172 -15
  72. package/dist/tui/app.test.js +27 -2
  73. package/dist/tui/approval.js +55 -1
  74. package/dist/tui/scroll-model.d.ts +2 -2
  75. package/dist/tui/scroll-model.js +9 -3
  76. package/dist/tui/tokens.d.ts +8 -11
  77. package/dist/tui/tokens.js +18 -11
  78. package/package.json +1 -1
@@ -1,10 +1,17 @@
1
1
  /**
2
2
  * 2.2 — klyro login / logout / aliases
3
- * Stores masked key → ~/.klyro/credentials.json 0600
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 (0600). Never logs or returns the key. */
7
- export declare function saveKey(provider: string, key: string): Promise<void>;
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 → ~/.klyro/credentials.json 0600
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 (0600). Never logs or returns the 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] = key.trim();
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(`Saved ${storeProvider} key to ${credPath()} (0600)\n`);
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);
@@ -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'],
@@ -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>;
@@ -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');
@@ -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): Promise<EvalResult>;
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
- // Simple harness for suite: just run check.sh via file fixtures
102
- const { runFileFixture, loadFileFixture: loadFF } = await import('../eval/harness.js');
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 r = await runFileFixture(fixture, { runs: opts.runs, parallel: opts.parallel });
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
  }
@@ -1,26 +1,52 @@
1
1
  /**
2
- * Hooks engine (10.2) — preToolUse / postToolUse shell hooks.
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, plus `KLYRO_TOOL_NAME`
14
- * and `KLYRO_TOOL_INPUT_JSON` for the hook's inspection.
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 runHook(hook: Hook, ctx: HookContext): Promise<HookResult>;
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) — preToolUse / postToolUse shell hooks.
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, plus `KLYRO_TOOL_NAME`
14
- * and `KLYRO_TOOL_INPUT_JSON` for the hook's inspection.
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: z.enum(['preToolUse', 'postToolUse']),
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: stdout.slice(0, MAX_HOOK_OUTPUT_CHARS),
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
+ }
@@ -0,0 +1,6 @@
1
+ export interface InitResult {
2
+ created: string[];
3
+ skipped: string[];
4
+ }
5
+ export declare function initProject(cwd: string): Promise<InitResult>;
6
+ export declare function nextStepsText(): string;