klyro 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +29 -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 +22 -2
  5. package/dist/agent/orchestrator.js +30 -4
  6. package/dist/agent/runtime.d.ts +5 -0
  7. package/dist/agent/runtime.js +174 -51
  8. package/dist/checkpoints/store.d.ts +9 -0
  9. package/dist/checkpoints/store.js +20 -0
  10. package/dist/cli/auth.js +11 -3
  11. package/dist/cli/completion.js +63 -10
  12. package/dist/cli/config.d.ts +4 -4
  13. package/dist/cli/doctor.js +13 -0
  14. package/dist/cli/eval.d.ts +15 -1
  15. package/dist/cli/eval.js +34 -2
  16. package/dist/cli/hooks.d.ts +54 -5
  17. package/dist/cli/hooks.js +85 -6
  18. package/dist/cli/init.d.ts +6 -0
  19. package/dist/cli/init.js +60 -0
  20. package/dist/cli/repl.js +261 -34
  21. package/dist/cli/run.d.ts +7 -1
  22. package/dist/cli/run.js +97 -41
  23. package/dist/cli/slash/custom.d.ts +25 -0
  24. package/dist/cli/slash/custom.js +166 -0
  25. package/dist/cli/slash/parser.d.ts +16 -2
  26. package/dist/cli/slash/parser.js +67 -18
  27. package/dist/cli/update.d.ts +8 -4
  28. package/dist/cli/update.js +50 -7
  29. package/dist/context/accounting.d.ts +8 -0
  30. package/dist/context/accounting.js +18 -1
  31. package/dist/context/compaction.d.ts +1 -0
  32. package/dist/context/compaction.js +2 -1
  33. package/dist/context/memory.js +18 -1
  34. package/dist/eval/harness.d.ts +21 -3
  35. package/dist/eval/harness.js +31 -3
  36. package/dist/eval/judge.d.ts +32 -0
  37. package/dist/eval/judge.js +63 -0
  38. package/dist/eval/tasks.js +134 -0
  39. package/dist/index.js +225 -132
  40. package/dist/mcp/client.d.ts +15 -0
  41. package/dist/mcp/client.js +42 -2
  42. package/dist/mcp/config.d.ts +10 -1
  43. package/dist/mcp/config.js +64 -1
  44. package/dist/mcp/registry.d.ts +13 -0
  45. package/dist/mcp/registry.js +47 -5
  46. package/dist/mcp/remote.d.ts +29 -0
  47. package/dist/mcp/remote.js +153 -0
  48. package/dist/mcp/serve.d.ts +23 -0
  49. package/dist/mcp/serve.js +111 -0
  50. package/dist/policy/approval.d.ts +15 -1
  51. package/dist/policy/approval.js +8 -0
  52. package/dist/policy/engine.d.ts +11 -1
  53. package/dist/policy/engine.js +14 -1
  54. package/dist/policy/secret-redactor.js +4 -1
  55. package/dist/providers/endpoints.d.ts +43 -0
  56. package/dist/providers/endpoints.js +104 -0
  57. package/dist/providers.js +13 -10
  58. package/dist/shared/error-map.d.ts +19 -0
  59. package/dist/shared/error-map.js +58 -0
  60. package/dist/tools/lsp/diagnostics.d.ts +35 -4
  61. package/dist/tools/lsp/diagnostics.js +88 -9
  62. package/dist/tools/normalize.d.ts +3 -0
  63. package/dist/tools/normalize.js +8 -5
  64. package/dist/tools/search/dependencies.d.ts +2 -2
  65. package/dist/tools/shell/background.d.ts +6 -0
  66. package/dist/tools/shell/background.js +17 -0
  67. package/dist/tools/symbols/find-symbol.d.ts +1 -1
  68. package/dist/tools/symbols/find-symbol.js +8 -6
  69. package/dist/tools/types.d.ts +8 -1
  70. package/dist/tui/app.d.ts +2 -0
  71. package/dist/tui/app.js +454 -53
  72. package/dist/tui/app.test.js +66 -3
  73. package/dist/tui/approval.js +53 -1
  74. package/dist/tui/markdown.js +9 -0
  75. package/dist/tui/mouse.d.ts +26 -1
  76. package/dist/tui/mouse.js +104 -6
  77. package/dist/tui/scroll-flow.test.js +3 -1
  78. package/dist/tui/tokens.d.ts +6 -6
  79. package/dist/tui/tokens.js +9 -6
  80. package/package.json +1 -1
@@ -154,6 +154,26 @@ export async function listCheckpoints(cwd) {
154
154
  return [];
155
155
  }
156
156
  }
157
+ /** Numbered snapshot list for `/checkpoints` and the `/rewind` menu. */
158
+ export async function listCheckpointInfo(cwd) {
159
+ const ids = await listCheckpoints(cwd);
160
+ const out = [];
161
+ for (let i = ids.length - 1; i >= 0; i--) {
162
+ const id = ids[i];
163
+ let ts = 0;
164
+ let files = 0;
165
+ try {
166
+ const meta = JSON.parse(await fs.readFile(path.join(ckptDir(cwd), id, '.meta.json'), 'utf-8'));
167
+ if (typeof meta.ts === 'number')
168
+ ts = meta.ts;
169
+ if (Array.isArray(meta.files))
170
+ files = meta.files.length;
171
+ }
172
+ catch { /* best-effort */ }
173
+ out.push({ index: ids.length - i, id, ts, files });
174
+ }
175
+ return out;
176
+ }
157
177
  export async function diff(cwd, id) {
158
178
  const ckpts = await listCheckpoints(cwd);
159
179
  const target = id ?? ckpts[ckpts.length - 1];
package/dist/cli/auth.js CHANGED
@@ -132,13 +132,21 @@ export async function runLogout(provider) {
132
132
  }
133
133
  export function getStoredKey(provider) {
134
134
  try {
135
- // Warn (don't refuse) when the credentials file is group/other-readable.
136
- // Refusing would lock out existing users with old umasks; warn instead.
135
+ // Refuse (don't just warn) when the credentials file is
136
+ // group/other-readable: a key other users can read is compromised by
137
+ // definition. Repair hint included. KLYRO_CREDENTIALS_INSECURE_OK=1
138
+ // preserves the old warn-and-continue behavior for exotic setups.
137
139
  if (process.platform !== 'win32') {
138
140
  try {
139
141
  const st = fsSync.statSync(credPath());
140
142
  if ((st.mode & 0o077) !== 0) {
141
- process.stderr.write(`warning: credentials file ${credPath()} is group/other-readable (mode ${(st.mode & 0o777).toString(8)}) — run chmod 600 on it\n`);
143
+ if (process.env.KLYRO_CREDENTIALS_INSECURE_OK === '1') {
144
+ process.stderr.write(`warning: credentials file ${credPath()} is group/other-readable (mode ${(st.mode & 0o777).toString(8)}) — run chmod 600 on it\n`);
145
+ }
146
+ else {
147
+ process.stderr.write(`klyro: refusing to use group/other-readable credentials file ${credPath()} (mode ${(st.mode & 0o777).toString(8)}) — run: chmod 600 ${credPath()} (or set KLYRO_CREDENTIALS_INSECURE_OK=1 to override)\n`);
148
+ return undefined;
149
+ }
142
150
  }
143
151
  }
144
152
  catch { /* missing file → no warning */ }
@@ -2,40 +2,93 @@
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'];
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
+ /** Second-level completion: global flags + per-command flags. */
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
+ 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', '--bare'],
10
+ chat: ['-s', '--system', '-m', '--model', '-t', '--timeout'],
11
+ eval: ['--output', '--suite', '--filter', '--runs', '--parallel', '--model'],
12
+ tui: ['-m', '--model', '--max-steps'],
13
+ doctor: ['--json'],
14
+ session: ['list', 'show', 'resume', 'fork', 'delete'],
15
+ sessions: ['export', 'import', 'fork', 'delete'],
16
+ mcp: ['list', 'add', 'remove', 'probe', 'serve'],
17
+ config: ['list', 'get', 'set', 'unset', 'path'],
18
+ commit: ['--dry-run', '--message', '--force-secret'],
19
+ completion: ['bash', 'zsh', 'fish', 'powershell'],
20
+ resume: ['-m', '--model', '--max-steps'],
21
+ };
22
+ function flagsFor(cmd) {
23
+ return [...GLOBAL_FLAGS, ...(COMMAND_FLAGS[cmd] ?? [])];
24
+ }
6
25
  function bashScript() {
7
- return `# klyro bash completion
26
+ const cmdCases = Object.entries(COMMAND_FLAGS)
27
+ .map(([c, fs]) => ` ${c}) opts="${fs.join(' ')}" ;;`)
28
+ .join('\n');
29
+ return `# klyro bash completion (commands + flags)
8
30
  _klyro_complete() {
9
31
  local cur="\${COMP_WORDS[COMP_CWORD]}"
32
+ local prev="\${COMP_WORDS[COMP_CWORD-1]}"
10
33
  local cmds="${COMMANDS.join(' ')}"
11
- COMPREPLY=( $(compgen -W "$cmds" -- "$cur") )
34
+ if [[ $COMP_CWORD -eq 1 ]]; then
35
+ COMPREPLY=( $(compgen -W "$cmds ${GLOBAL_FLAGS.join(' ')}" -- "$cur") )
36
+ return
37
+ fi
38
+ local first="\${COMP_WORDS[1]}"
39
+ local opts=""
40
+ case "$first" in
41
+ ${cmdCases}
42
+ *) opts="${GLOBAL_FLAGS.join(' ')}" ;;
43
+ esac
44
+ COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
12
45
  }
13
46
  complete -F _klyro_complete klyro
14
47
  complete -F _klyro_complete ky
15
48
  `;
16
49
  }
17
50
  function zshScript() {
51
+ const cmdCases = Object.entries(COMMAND_FLAGS)
52
+ .map(([c, fs]) => ` ${c}) _values 'flags' ${fs.map((f) => `'${f}'`).join(' ')} ;;`)
53
+ .join('\n');
18
54
  return `#compdef klyro ky
19
55
  _klyro() {
20
- local -a completions
21
- completions=(${COMMANDS.map((c) => `'${c}'`).join(' ')})
22
- _describe 'klyro commands' completions
56
+ if (( CURRENT == 2 )); then
57
+ _describe 'klyro commands' ${COMMANDS.map((c) => `'${c}'`).join(' ')} ${GLOBAL_FLAGS.map((f) => `'${f}'`).join(' ')}
58
+ return
59
+ fi
60
+ case "$words[2]" in
61
+ ${cmdCases}
62
+ *) _values 'flags' ${GLOBAL_FLAGS.map((f) => `'${f}'`).join(' ')} ;;
63
+ esac
23
64
  }
24
65
  compdef _klyro klyro ky
25
66
  `;
26
67
  }
27
68
  function fishScript() {
28
- return `# klyro fish completion
29
- ${COMMANDS.map((c) => `complete -c klyro -f -a ${c}`).join('\n')}
69
+ const flagLines = Object.entries(COMMAND_FLAGS)
70
+ .flatMap(([c, fs]) => fs.map((f) => `complete -c klyro -f -n "__fish_seen_subcommand_from ${c}" -a ${f}`))
71
+ .join('\n');
72
+ return `# klyro fish completion (commands + flags)
73
+ ${COMMANDS.map((c) => `complete -c klyro -f -n __fish_use_subcommand -a ${c}`).join('\n')}
74
+ ${flagLines}
30
75
  complete -c ky -f -a "${COMMANDS.join(' ')}"
31
76
  `;
32
77
  }
33
78
  function powershellScript() {
34
- return `# klyro powershell completion
79
+ return `# klyro powershell completion (commands + flags)
35
80
  Register-ArgumentCompleter -Native -CommandName klyro,ky -ScriptBlock {
36
81
  param($wordToComplete, $commandAst, $cursorPosition)
37
82
  $cmds = @(${COMMANDS.map((c) => `'${c}'`).join(', ')})
38
- $cmds | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
83
+ $flags = @(${GLOBAL_FLAGS.map((f) => `'${f}'`).join(', ')})
84
+ $tokens = $commandAst.ToString() -split '\\s+'
85
+ if ($tokens.Count -le 2) { $cands = $cmds + $flags } else {
86
+ switch ($tokens[1]) {
87
+ ${Object.entries(COMMAND_FLAGS).map(([c, fs]) => ` '${c}' { $cands = @(${fs.map((f) => `'${f}'`).join(', ')}) }`).join('\n')}
88
+ default { $cands = $flags }
89
+ }
90
+ }
91
+ $cands | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
39
92
  [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
40
93
  }
41
94
  }
@@ -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>;
@@ -108,6 +108,18 @@ function checkPlatform() {
108
108
  const ok = ['win32', 'linux', 'darwin'].includes(process.platform);
109
109
  return { name: 'Platform', ok, detail: `${process.platform} ${process.arch} ${ok ? '✓' : '✗ unsupported'}` };
110
110
  }
111
+ async function checkSandbox() {
112
+ try {
113
+ const { detectSandbox } = await import('../tools/shell/sandbox.js');
114
+ const st = detectSandbox();
115
+ if (st.active)
116
+ return { name: 'Sandbox', ok: true, detail: `${st.backend} ✓` };
117
+ return { name: 'Sandbox', ok: true, detail: `none — ${st.reason ?? 'policy+path guards only'}` };
118
+ }
119
+ catch {
120
+ return { name: 'Sandbox', ok: true, detail: 'none — policy+path guards only' };
121
+ }
122
+ }
111
123
  async function checkMcp(cwd) {
112
124
  try {
113
125
  const { loadMcpServers } = await import('../mcp/config.js');
@@ -167,6 +179,7 @@ export async function runDoctor(opts = {}) {
167
179
  checks.push(await checkGit());
168
180
  checks.push(await checkTools());
169
181
  checks.push(checkPlatform());
182
+ checks.push(await checkSandbox());
170
183
  const mcpCheck = await checkMcp(cwd);
171
184
  const trustCheck = await checkTrust();
172
185
  checks.push(mcpCheck);
@@ -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
@@ -131,10 +131,23 @@ export async function runEval(opts) {
131
131
  stderr.write('klyro eval: no scenarios in input\n');
132
132
  return 2;
133
133
  }
134
+ // Live judge adapter for `judge.rubric` (env endpoint + key; mock otherwise).
135
+ let judgeAdapter;
136
+ if (opts.judgeModel) {
137
+ const { httpChatAdapter } = await import('../agent/provider-adapter.js');
138
+ const { getStoredKey } = await import('./auth.js');
139
+ const baseUrl = process.env.KLYRO_BASE_URL;
140
+ const apiKey = process.env.KLYRO_API_KEY ?? getStoredKey('openai') ?? getStoredKey('anthropic');
141
+ if (!baseUrl || !apiKey) {
142
+ stderr.write('klyro eval: --judge-model needs KLYRO_BASE_URL and KLYRO_API_KEY (or a stored key)\n');
143
+ return 2;
144
+ }
145
+ judgeAdapter = httpChatAdapter({ baseURL: baseUrl, apiKey });
146
+ }
134
147
  const results = [];
135
148
  for (const sc of scenarios) {
136
149
  const start = Date.now();
137
- const r = await runScenario(sc);
150
+ const r = await runScenario(sc, judgeAdapter && opts.judgeModel ? { adapter: judgeAdapter, model: opts.judgeModel } : undefined);
138
151
  r.durationMs = Date.now() - start;
139
152
  results.push(r);
140
153
  if (opts.output === 'json') {
@@ -223,7 +236,7 @@ function tupleToEvent(tuple) {
223
236
  throw new Error(`scriptedAdapterFromSpec: unknown event kind: ${kind}`);
224
237
  }
225
238
  }
226
- export async function runScenario(sc) {
239
+ export async function runScenario(sc, judgeOpts) {
227
240
  const failures = [];
228
241
  const model = sc.model ?? 'mock';
229
242
  const adapter = scriptedAdapterFromSpec(sc.scripted_events);
@@ -265,6 +278,24 @@ export async function runScenario(sc) {
265
278
  if (exp.toolCallsAtMost !== undefined && result.toolCalls > exp.toolCallsAtMost) {
266
279
  failures.push(`toolCalls: expected <= ${exp.toolCallsAtMost}, got ${result.toolCalls}`);
267
280
  }
281
+ // Model-graded semantic check (opt-in: needs a live judge adapter).
282
+ let judge;
283
+ if (sc.judge && sc.judge.rubric.length > 0) {
284
+ if (judgeOpts) {
285
+ const { runJudge } = await import('../eval/judge.js');
286
+ const v = await runJudge(judgeOpts.adapter, judgeOpts.model, {
287
+ task: sc.task,
288
+ finalText: result.finalText,
289
+ toolCalls: result.toolCalls,
290
+ }, sc.judge.rubric);
291
+ judge = { pass: v.pass, notes: v.notes, skipped: v.skipped };
292
+ if (!v.pass)
293
+ failures.push(`judge: ${v.notes || 'rubric unmet'}`);
294
+ }
295
+ else {
296
+ judge = { pass: true, notes: 'no judge adapter — skipped', skipped: true };
297
+ }
298
+ }
268
299
  return {
269
300
  name: sc.name,
270
301
  passed: failures.length === 0,
@@ -274,5 +305,6 @@ export async function runScenario(sc) {
274
305
  toolCalls: result.toolCalls,
275
306
  text: result.finalText,
276
307
  durationMs: 0,
308
+ ...(judge ? { judge } : {}),
277
309
  };
278
310
  }
@@ -1,26 +1,46 @@
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.
9
16
  *
10
17
  * `loadHooks` never throws — a missing file is `[]`, an invalid file is
11
18
  * `[]` plus a one-time stderr warning per path. `runHook` spawns the
12
19
  * 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.
20
+ * posix), a default 30s timeout, a filtered env, `KLYRO_TOOL_NAME` /
21
+ * `KLYRO_TOOL_INPUT_JSON` for inspection, AND the full JSON payload on
22
+ * stdin (`{ event, tool, input, sessionId, ... }`).
15
23
  */
16
24
  import { z } from 'zod';
25
+ export declare const HookEventSchema: z.ZodEnum<{
26
+ stop: "stop";
27
+ preToolUse: "preToolUse";
28
+ postToolUse: "postToolUse";
29
+ sessionStart: "sessionStart";
30
+ sessionEnd: "sessionEnd";
31
+ }>;
32
+ export type HookEvent = z.infer<typeof HookEventSchema>;
17
33
  export declare const HookSchema: z.ZodObject<{
18
34
  name: z.ZodString;
19
35
  event: z.ZodEnum<{
36
+ stop: "stop";
20
37
  preToolUse: "preToolUse";
21
38
  postToolUse: "postToolUse";
39
+ sessionStart: "sessionStart";
40
+ sessionEnd: "sessionEnd";
22
41
  }>;
23
42
  command: z.ZodString;
43
+ matcher: z.ZodOptional<z.ZodString>;
24
44
  timeoutMs: z.ZodOptional<z.ZodNumber>;
25
45
  }, z.core.$strip>;
26
46
  export type Hook = z.infer<typeof HookSchema>;
@@ -40,8 +60,37 @@ export interface HookContext {
40
60
  toolName: string;
41
61
  input: unknown;
42
62
  }
63
+ /** Lifecycle payload delivered on stdin (and merged into env where small). */
64
+ export interface HookPayload {
65
+ event: HookEvent;
66
+ tool?: string;
67
+ input?: unknown;
68
+ sessionId?: string;
69
+ cwd?: string;
70
+ status?: string;
71
+ step?: number;
72
+ }
73
+ /**
74
+ * Select hooks for an event. Tool events honor `matcher` (regex against the
75
+ * tool name; invalid regex never matches); lifecycle events always match.
76
+ * Exported pure for unit tests.
77
+ */
78
+ export declare function hooksForEvent(hooks: Hook[], event: HookEvent, toolName?: string): Hook[];
43
79
  /**
44
80
  * Run one hook. Resolves (never rejects) with the exit code + sliced
45
81
  * output. `ok` is true only when the process exited 0.
82
+ *
83
+ * `stdinJson` (when given) is written to the child's stdin as JSON —
84
+ * the primary contract (mirrors CC's stdin-JSON hooks); the env vars are
85
+ * kept as a convenience for shell one-liners.
86
+ */
87
+ export declare function runHook(hook: Hook, ctx: HookContext, stdinJson?: unknown): Promise<HookResult>;
88
+ /**
89
+ * Run all `sessionEnd` hooks for a finished run (best-effort, sequential).
90
+ * Returns hook outputs for logging. Never throws.
46
91
  */
47
- export declare function runHook(hook: Hook, ctx: HookContext): Promise<HookResult>;
92
+ export declare function runSessionEndHooks(cwd: string, sessionId: string | undefined, status: string): Promise<Array<{
93
+ name: string;
94
+ ok: boolean;
95
+ output: string;
96
+ }>>;
package/dist/cli/hooks.js CHANGED
@@ -1,27 +1,38 @@
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.
9
16
  *
10
17
  * `loadHooks` never throws — a missing file is `[]`, an invalid file is
11
18
  * `[]` plus a one-time stderr warning per path. `runHook` spawns the
12
19
  * 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.
20
+ * posix), a default 30s timeout, a filtered env, `KLYRO_TOOL_NAME` /
21
+ * `KLYRO_TOOL_INPUT_JSON` for inspection, AND the full JSON payload on
22
+ * stdin (`{ event, tool, input, sessionId, ... }`).
15
23
  */
16
24
  import { spawn } from 'node:child_process';
17
25
  import * as fs from 'node:fs';
18
26
  import * as os from 'node:os';
19
27
  import * as path from 'node:path';
20
28
  import { z } from 'zod';
29
+ export const HookEventSchema = z.enum(['preToolUse', 'postToolUse', 'sessionStart', 'sessionEnd', 'stop']);
21
30
  export const HookSchema = z.object({
22
31
  name: z.string().min(1),
23
- event: z.enum(['preToolUse', 'postToolUse']),
32
+ event: HookEventSchema,
24
33
  command: z.string().min(1),
34
+ /** Optional regex matched against the tool name (tool events only). */
35
+ matcher: z.string().min(1).optional(),
25
36
  timeoutMs: z.number().int().positive().optional(),
26
37
  });
27
38
  const HooksFileSchema = z.object({
@@ -116,11 +127,36 @@ function hookEnv() {
116
127
  out.PATH = process.env.PATH;
117
128
  return out;
118
129
  }
130
+ /**
131
+ * Select hooks for an event. Tool events honor `matcher` (regex against the
132
+ * tool name; invalid regex never matches); lifecycle events always match.
133
+ * Exported pure for unit tests.
134
+ */
135
+ export function hooksForEvent(hooks, event, toolName) {
136
+ return hooks.filter((h) => {
137
+ if (h.event !== event)
138
+ return false;
139
+ if (h.matcher === undefined)
140
+ return true;
141
+ if (toolName === undefined)
142
+ return true; // lifecycle events have no tool
143
+ try {
144
+ return new RegExp(h.matcher).test(toolName);
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ });
150
+ }
119
151
  /**
120
152
  * Run one hook. Resolves (never rejects) with the exit code + sliced
121
153
  * output. `ok` is true only when the process exited 0.
154
+ *
155
+ * `stdinJson` (when given) is written to the child's stdin as JSON —
156
+ * the primary contract (mirrors CC's stdin-JSON hooks); the env vars are
157
+ * kept as a convenience for shell one-liners.
122
158
  */
123
- export function runHook(hook, ctx) {
159
+ export function runHook(hook, ctx, stdinJson) {
124
160
  const timeoutMs = hook.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
125
161
  return new Promise((resolve) => {
126
162
  let env;
@@ -139,6 +175,13 @@ export function runHook(hook, ctx) {
139
175
  catch {
140
176
  env.KLYRO_TOOL_INPUT_JSON = '{}';
141
177
  }
178
+ let payload = '';
179
+ try {
180
+ payload = JSON.stringify(stdinJson ?? { event: 'preToolUse', tool: ctx.toolName, input: ctx.input ?? {} });
181
+ }
182
+ catch {
183
+ payload = '{}';
184
+ }
142
185
  let child;
143
186
  try {
144
187
  child = spawn(hook.command, {
@@ -147,6 +190,7 @@ export function runHook(hook, ctx) {
147
190
  windowsHide: true,
148
191
  timeout: timeoutMs,
149
192
  env,
193
+ stdio: ['pipe', 'pipe', 'pipe'],
150
194
  });
151
195
  }
152
196
  catch (err) {
@@ -177,5 +221,40 @@ export function runHook(hook, ctx) {
177
221
  stderr: stderr.slice(0, MAX_HOOK_OUTPUT_CHARS),
178
222
  });
179
223
  });
224
+ // Deliver the stdin JSON contract, then close so the child never hangs
225
+ // waiting for EOF. Write errors (EPIPE on early exit) are ignored —
226
+ // the exit code below is what matters.
227
+ try {
228
+ if (child.stdin) {
229
+ child.stdin.on('error', () => undefined);
230
+ child.stdin.write(payload);
231
+ child.stdin.end();
232
+ }
233
+ }
234
+ catch { /* ignore */ }
180
235
  });
181
236
  }
237
+ /**
238
+ * Run all `sessionEnd` hooks for a finished run (best-effort, sequential).
239
+ * Returns hook outputs for logging. Never throws.
240
+ */
241
+ export async function runSessionEndHooks(cwd, sessionId, status) {
242
+ const out = [];
243
+ let hooks;
244
+ try {
245
+ hooks = hooksForEvent(loadHooks(cwd), 'sessionEnd');
246
+ }
247
+ catch {
248
+ return out;
249
+ }
250
+ for (const h of hooks) {
251
+ try {
252
+ const r = await runHook(h, { toolName: '', input: {} }, { event: 'sessionEnd', sessionId, cwd, status });
253
+ out.push({ name: h.name, ok: r.ok, output: (r.stdout || r.stderr || '').slice(0, 2000) });
254
+ }
255
+ catch {
256
+ out.push({ name: h.name, ok: false, output: '' });
257
+ }
258
+ }
259
+ return out;
260
+ }
@@ -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;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Project bootstrap shared by `klyro init` and the REPL `/init`:
3
+ * scan-seeded KLYRO.md (only when absent) + `.mcp.json` skeleton.
4
+ * Never overwrites user files; returns created vs skipped lists.
5
+ */
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ function captureStdout() {
9
+ const orig = process.stdout.write.bind(process.stdout);
10
+ let out = '';
11
+ process.stdout.write = ((c) => {
12
+ out += String(c);
13
+ return true;
14
+ });
15
+ return {
16
+ release: () => {
17
+ process.stdout.write = orig;
18
+ return out;
19
+ },
20
+ };
21
+ }
22
+ export async function initProject(cwd) {
23
+ const created = [];
24
+ const skipped = [];
25
+ const klyroMd = path.join(cwd, 'KLYRO.md');
26
+ if (fs.existsSync(klyroMd)) {
27
+ skipped.push('KLYRO.md (exists)');
28
+ }
29
+ else {
30
+ const { runScan } = await import('./scan.js');
31
+ const cap = captureStdout();
32
+ try {
33
+ await runScan({ cwd, json: false });
34
+ }
35
+ finally {
36
+ // release even if scan throws — partial output still seeds the draft
37
+ }
38
+ const out = cap.release();
39
+ fs.writeFileSync(klyroMd, `# KLYRO.md\n\nProject: ${cwd}\n\n## Stack\n\n${out.slice(0, 2000)}\n\n## Conventions\n\n- Prefer smallest change that solves the task.\n- Run verification after edits.\n`, 'utf-8');
40
+ created.push('KLYRO.md');
41
+ }
42
+ const mcpJson = path.join(cwd, '.mcp.json');
43
+ if (fs.existsSync(mcpJson)) {
44
+ skipped.push('.mcp.json (exists)');
45
+ }
46
+ else {
47
+ fs.writeFileSync(mcpJson, JSON.stringify({ mcpServers: {} }, null, 2) + '\n', 'utf-8');
48
+ created.push('.mcp.json');
49
+ }
50
+ return { created, skipped };
51
+ }
52
+ export function nextStepsText() {
53
+ return [
54
+ 'Next steps:',
55
+ ' klyro login # store provider key once (or set KLYRO_BASE_URL/_API_KEY/_MODEL)',
56
+ ' klyro doctor # verify toolchain, provider, sessions, sandbox',
57
+ ' klyro agents # list subagents (add yours in .klyro/agents/*.md)',
58
+ ' klyro mcp add <n> <cmd># attach a tool server (or https:// URL for remote)',
59
+ ].join('\n');
60
+ }