klyro 0.1.4 → 0.1.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 (78) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +1 -1
  2. package/dist/agent/anthropic-adapter.js +22 -20
  3. package/dist/agent/provider-adapter.js +30 -7
  4. package/dist/agent/registry.js +24 -7
  5. package/dist/agent/retry.js +54 -9
  6. package/dist/agent/runtime.d.ts +45 -1
  7. package/dist/agent/runtime.js +236 -23
  8. package/dist/chat.d.ts +3 -3
  9. package/dist/chat.js +137 -39
  10. package/dist/cli/auth.d.ts +9 -0
  11. package/dist/cli/auth.js +84 -0
  12. package/dist/cli/completion.d.ts +6 -0
  13. package/dist/cli/completion.js +66 -0
  14. package/dist/cli/config.d.ts +44 -0
  15. package/dist/cli/config.js +428 -0
  16. package/dist/cli/doctor.d.ts +8 -0
  17. package/dist/cli/doctor.js +136 -0
  18. package/dist/cli/errors.d.ts +5 -0
  19. package/dist/cli/errors.js +37 -0
  20. package/dist/cli/markdown.d.ts +9 -0
  21. package/dist/cli/markdown.js +77 -0
  22. package/dist/cli/repl.js +113 -5
  23. package/dist/cli/run.d.ts +9 -0
  24. package/dist/cli/run.js +97 -2
  25. package/dist/cli/slash/parser.d.ts +10 -0
  26. package/dist/cli/slash/parser.js +6 -1
  27. package/dist/cli/trace.d.ts +7 -0
  28. package/dist/cli/trace.js +63 -0
  29. package/dist/cli/update.d.ts +6 -0
  30. package/dist/cli/update.js +70 -0
  31. package/dist/context/system-prompt.d.ts +14 -0
  32. package/dist/context/system-prompt.js +47 -0
  33. package/dist/events/bus.d.ts +16 -0
  34. package/dist/events/bus.js +28 -0
  35. package/dist/events/catalog.d.ts +136 -0
  36. package/dist/events/catalog.js +5 -0
  37. package/dist/index.js +335 -22
  38. package/dist/persistence/session.d.ts +12 -0
  39. package/dist/persistence/session.js +45 -0
  40. package/dist/policy/engine.d.ts +11 -1
  41. package/dist/policy/engine.js +85 -2
  42. package/dist/providers/model-info.d.ts +18 -0
  43. package/dist/providers/model-info.js +18 -0
  44. package/dist/providers.js +3 -2
  45. package/dist/renderers/json.d.ts +11 -0
  46. package/dist/renderers/json.js +14 -0
  47. package/dist/renderers/terminal.d.ts +9 -0
  48. package/dist/renderers/terminal.js +41 -0
  49. package/dist/repl.js +52 -15
  50. package/dist/shared/errors.d.ts +18 -0
  51. package/dist/shared/errors.js +33 -0
  52. package/dist/shared/index.d.ts +2 -0
  53. package/dist/shared/index.js +2 -0
  54. package/dist/shared/types.d.ts +19 -0
  55. package/dist/shared/types.js +11 -0
  56. package/dist/tools/fs/read-file.d.ts +1 -31
  57. package/dist/tools/fs/read-file.js +45 -17
  58. package/dist/tools/fs/read-history.d.ts +3 -0
  59. package/dist/tools/fs/read-history.js +13 -0
  60. package/dist/tools/fs/write-file.d.ts +1 -4
  61. package/dist/tools/fs/write-file.js +35 -1
  62. package/dist/tools/normalize.d.ts +1 -1
  63. package/dist/tools/shell/shell-exec.d.ts +1 -26
  64. package/dist/tools/shell/shell-exec.js +121 -17
  65. package/dist/tools/types.d.ts +25 -0
  66. package/dist/trace/writer.d.ts +13 -0
  67. package/dist/trace/writer.js +53 -0
  68. package/dist/tui/app.d.ts +1 -15
  69. package/dist/tui/app.js +203 -22
  70. package/dist/tui/approval.js +25 -4
  71. package/dist/tui/approval.test.js +1 -1
  72. package/dist/tui/snapshot.test.js +2 -2
  73. package/dist/tui/status.js +5 -1
  74. package/dist/util/log.d.ts +12 -0
  75. package/dist/util/log.js +75 -0
  76. package/dist/verification/auto.d.ts +8 -0
  77. package/dist/verification/auto.js +52 -0
  78. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * Provider: OpenAI-compatible /v1/chat/completions endpoint.
12
12
  * Configure via env: KLYRO_BASE_URL, KLYRO_API_KEY, KLYRO_MODEL.
13
13
  */
14
- import { Command } from 'commander';
14
+ import { Command, InvalidArgumentError } from 'commander';
15
15
  import { readFileSync } from 'node:fs';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { dirname, resolve } from 'node:path';
@@ -20,25 +20,64 @@ import { repl } from './repl.js';
20
20
  import { startRepl } from './cli/repl.js';
21
21
  import { runOnce } from './cli/run.js';
22
22
  import { runEval } from './cli/eval.js';
23
+ import { runConfig } from './cli/config.js';
24
+ import { runDoctor } from './cli/doctor.js';
25
+ import { runCompletion } from './cli/completion.js';
26
+ import { runUpdate } from './cli/update.js';
27
+ import { runLogin, runLogout } from './cli/auth.js';
23
28
  // Read version from package.json so `klyro --version` always matches the
24
29
  // published version. Walks up from this file (src/index.ts) to find the
25
30
  // nearest package.json.
26
31
  function readVersion() {
32
+ const here = dirname(fileURLToPath(import.meta.url));
33
+ const candidates = [
34
+ resolve(here, '..', 'package.json'),
35
+ resolve(here, '../..', 'package.json'),
36
+ resolve(here, '../../package.json'),
37
+ ];
38
+ for (const pkgPath of candidates) {
39
+ try {
40
+ const raw = readFileSync(pkgPath, 'utf8');
41
+ const pkg = JSON.parse(raw);
42
+ if (typeof pkg.version === 'string' && pkg.version.length > 0)
43
+ return pkg.version;
44
+ }
45
+ catch (err) {
46
+ const e = err;
47
+ // Only ignore missing file; surface JSON parse errors
48
+ if (e.code === 'ENOENT')
49
+ continue;
50
+ // Malformed JSON — warn but don't crash version output
51
+ process.stderr.write(`klyro: warning: malformed ${pkgPath}: ${e.message}\n`);
52
+ continue;
53
+ }
54
+ }
55
+ // Fallback: try require-style resolution
27
56
  try {
28
- const here = dirname(fileURLToPath(import.meta.url));
29
- const pkgPath = resolve(here, '..', 'package.json');
30
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
31
- return pkg.version ?? '0.0.0';
57
+ const pkgPath = resolve(process.cwd(), 'package.json');
58
+ const raw = readFileSync(pkgPath, 'utf8');
59
+ const pkg = JSON.parse(raw);
60
+ if (pkg.name === 'klyro' && typeof pkg.version === 'string')
61
+ return pkg.version;
32
62
  }
33
63
  catch {
34
- return '0.0.0';
64
+ // ignore
35
65
  }
66
+ return '0.0.0';
36
67
  }
37
68
  const VERSION = readVersion();
38
69
  function parsePositiveInt(name, v) {
39
70
  const n = Number(v);
40
- if (!Number.isFinite(n) || n <= 0)
41
- throw new Error(`invalid ${name}: ${v}`);
71
+ if (!Number.isFinite(n) || n <= 0) {
72
+ throw new InvalidArgumentError(`invalid ${name}: ${v}`);
73
+ }
74
+ return n;
75
+ }
76
+ function parseTemperature(v) {
77
+ const n = Number(v);
78
+ if (!Number.isFinite(n) || n < 0 || n > 2) {
79
+ throw new InvalidArgumentError(`invalid --temperature: ${v} (expected 0-2)`);
80
+ }
42
81
  return n;
43
82
  }
44
83
  async function main() {
@@ -48,7 +87,57 @@ async function main() {
48
87
  .description('Klyro — autonomous coding harness')
49
88
  .version(VERSION, '-V, --version', 'Print the version number')
50
89
  .helpOption('-h, --help', 'Print this help message')
51
- .showHelpAfterError();
90
+ .showHelpAfterError()
91
+ .showSuggestionAfterError(true);
92
+ // Global flags — 1.2 + 2.5 headless
93
+ program
94
+ .option('--cwd <path>', 'Change working directory')
95
+ .option('--config <path>', 'Override config file path (sets KLYRO_CONFIG)')
96
+ .option('--debug', 'Enable debug logging')
97
+ .option('--verbose', 'Verbose output')
98
+ .option('--quiet', 'Suppress non-essential output')
99
+ .option('--json', 'Force JSON output where supported')
100
+ .option('--yes', 'Auto-approve prompts where possible')
101
+ .option('--no-color', 'Disable colored output')
102
+ .option('-p, --print <prompt>', 'Headless one-shot prompt (alias for run, --output json for machine)')
103
+ .option('--output-format <fmt>', 'Headless output format: text|json|stream-json (default text)')
104
+ .option('--no-stream', 'Disable streaming (buffer full response)')
105
+ .option('--show-thinking', 'Show thinking blocks');
106
+ // Handle global flags before any command runs
107
+ program.hook('preAction', (thisCommand) => {
108
+ const opts = thisCommand.optsWithGlobals();
109
+ if (opts.cwd) {
110
+ try {
111
+ process.chdir(opts.cwd);
112
+ }
113
+ catch (err) {
114
+ process.stderr.write(`klyro: --cwd: cannot chdir to ${opts.cwd}: ${err instanceof Error ? err.message : String(err)}\n`);
115
+ process.exit(2);
116
+ }
117
+ }
118
+ if (opts.config)
119
+ process.env.KLYRO_CONFIG = opts.config;
120
+ if (opts.debug)
121
+ process.env.KLYRO_LOG_LEVEL = 'debug';
122
+ if (opts.verbose)
123
+ process.env.KLYRO_LOG_LEVEL = 'debug';
124
+ if (opts.quiet)
125
+ process.env.KLYRO_QUIET = '1';
126
+ if (opts.json)
127
+ process.env.KLYRO_JSON = '1';
128
+ if (opts.yes)
129
+ process.env.KLYRO_YES = '1';
130
+ if (opts.color === false || process.env.NO_COLOR === '1') {
131
+ process.env.FORCE_COLOR = '0';
132
+ process.env.NO_COLOR = '1';
133
+ }
134
+ else if (process.env.FORCE_COLOR === undefined && process.env.NO_COLOR === undefined) {
135
+ // Respect NO_COLOR/FORCE_COLOR if already set, otherwise honor terminal
136
+ if (process.env.TERM === 'dumb' || process.env.CI === '1') {
137
+ // leave as is
138
+ }
139
+ }
140
+ });
52
141
  // Top-level TUI overrides — single definition; commander auto-creates --no-tui negation
53
142
  // Note: -m/--model and --max-steps are defined only on the `tui` subcommand to avoid
54
143
  // CommanderError "option already exists" (parent options are inherited by subcommands).
@@ -66,10 +155,107 @@ async function main() {
66
155
  process.exit(code);
67
156
  });
68
157
  program
158
+ .command('completion <shell>')
159
+ .description('Generate shell completion script (bash|zsh|fish|powershell)')
160
+ .action(async (shell) => {
161
+ const code = await runCompletion(shell);
162
+ process.exit(code);
163
+ });
164
+ program
165
+ .command('update')
166
+ .description('Check for klyro updates (cached 24h, KLYRO_NO_UPDATE_CHECK=1 to disable)')
69
167
  .action(async () => {
168
+ const code = await runUpdate();
169
+ process.exit(code);
170
+ });
171
+ program
172
+ .command('login')
173
+ .description('Login and store API key (masked, 0600) — provider auto-select')
174
+ .action(async () => {
175
+ const code = await runLogin();
176
+ process.exit(code);
177
+ });
178
+ program
179
+ .command('logout [provider]')
180
+ .description('Remove stored credentials')
181
+ .action(async (provider) => {
182
+ const code = await runLogout(provider);
183
+ process.exit(code);
184
+ });
185
+ // LEVEL 1 — config & doctor (graduation requires klyro config / klyro doctor)
186
+ program
187
+ .command('config [command] [key] [value]')
188
+ .description('Manage klyro config file (~/.klyro/config.json). Commands: list, get, set, unset, path')
189
+ .allowUnknownOption(false)
190
+ .action(async (command, key, value) => {
191
+ const args = [];
192
+ if (command)
193
+ args.push(command);
194
+ if (key)
195
+ args.push(key);
196
+ if (value !== undefined)
197
+ args.push(value);
198
+ // Also capture remaining raw args for `set a b c` style values
199
+ // commander splits `set x y z` as command=set key=x value=y, but z is lost —
200
+ // so re-parse from raw argv for set
201
+ const raw = process.argv.slice(2);
202
+ const idx = raw.indexOf('config');
203
+ if (idx !== -1 && command === 'set' && key) {
204
+ // everything after `config set <key>` is the value
205
+ const after = raw.slice(idx + 3);
206
+ const fullValue = after.join(' ');
207
+ const code = await runConfig(['set', key, fullValue]);
208
+ process.exit(code);
209
+ }
210
+ const code = await runConfig(args);
211
+ process.exit(code);
212
+ });
213
+ program
214
+ .command('doctor')
215
+ .description('Run diagnostics (node, config, provider, sessions, git, tools)')
216
+ .option('--json', 'Output JSON')
217
+ .action(async (opts) => {
218
+ const code = await runDoctor({ json: !!opts.json });
219
+ process.exit(code);
220
+ });
221
+ // Support `klyro "prompt"` positional headless (2.5)
222
+ program.argument('[prompt]', 'Headless one-shot prompt (same as -p)');
223
+ program
224
+ .action(async (promptArg) => {
70
225
  const opts = program.opts();
226
+ // Headless via -p / --print or positional prompt
227
+ const headlessPrompt = opts.print ?? promptArg;
228
+ if (headlessPrompt) {
229
+ // Handle stdin piping: if stdin has data, append
230
+ let stdinText = '';
231
+ if (!process.stdin.isTTY) {
232
+ try {
233
+ const chunks = [];
234
+ for await (const chunk of process.stdin)
235
+ chunks.push(chunk);
236
+ stdinText = Buffer.concat(chunks).toString('utf-8').trim();
237
+ }
238
+ catch { /* ignore */ }
239
+ }
240
+ const fullPrompt = stdinText ? `${headlessPrompt}\n\n${stdinText}` : headlessPrompt;
241
+ const outputFormat = opts.outputFormat ?? (opts.json ? 'json' : 'text');
242
+ const output = outputFormat === 'json' ? 'json' : outputFormat === 'stream-json' ? 'json' : 'human';
243
+ const code = await runOnce({
244
+ task: fullPrompt,
245
+ cwd: process.cwd(),
246
+ model: process.env.KLYRO_MODEL ?? 'gpt-4o-mini',
247
+ maxTokens: opts.maxTokens,
248
+ output: output,
249
+ provider: process.env.KLYRO_PROVIDER,
250
+ });
251
+ process.exit(code);
252
+ }
71
253
  const forceTui = opts.tui === true;
72
254
  const forceLegacy = opts.chat === true || opts.tui === false;
255
+ if (forceTui && forceLegacy) {
256
+ process.stderr.write('klyro: --tui and --no-tui/--chat are mutually exclusive\n');
257
+ process.exit(2);
258
+ }
73
259
  if (forceTui) {
74
260
  const code = await startRepl({ forceTty: true });
75
261
  process.exit(code);
@@ -82,7 +268,11 @@ async function main() {
82
268
  const code = await startRepl();
83
269
  process.exit(code);
84
270
  }
85
- // Non-TTY without explicit flag: explain UI requires TTY
271
+ // Non-TTY without explicit flag: explain UI requires TTY, but avoid infinite loop on empty pipe
272
+ if (process.stdin.readableEnded || process.stdin.destroyed) {
273
+ process.stderr.write('klyro: no TTY and no input pipe — nothing to do. Try `klyro --help` or `klyro run \"<task>\"`\n');
274
+ process.exit(2);
275
+ }
86
276
  process.stderr.write('klyro: no TTY detected — starting legacy REPL (pipe mode)\n');
87
277
  process.stderr.write(' Tip: run `klyro tui` or `klyro --tui` to force the Ink UI, or `klyro --help` for options.\n');
88
278
  await repl('You are a helpful assistant.');
@@ -93,12 +283,7 @@ async function main() {
93
283
  .option('-m, --model <id>', 'Model id (default: env KLYRO_MODEL)')
94
284
  .option('--max-steps <n>', 'Max agent steps (default 30)', (v) => parsePositiveInt('--max-steps', v))
95
285
  .option('--max-tokens <n>', 'Max output tokens per step', (v) => parsePositiveInt('--max-tokens', v))
96
- .option('--temperature <n>', 'Sampling temperature', (v) => {
97
- const n = Number(v);
98
- if (!Number.isFinite(n) || n < 0 || n > 2)
99
- throw new Error(`invalid --temperature: ${v}`);
100
- return n;
101
- })
286
+ .option('--temperature <n>', 'Sampling temperature (0-2)', parseTemperature)
102
287
  .option('--timeout <ms>', 'Request timeout in ms (default: env KLYRO_TIMEOUT_MS or 60000)', (v) => parsePositiveInt('--timeout', v))
103
288
  .option('--base-url <url>', 'Override KLYRO_BASE_URL')
104
289
  .option('--api-key <key>', 'Override KLYRO_API_KEY')
@@ -106,6 +291,11 @@ async function main() {
106
291
  .option('--provider <name>', 'Provider: openai (default) or anthropic')
107
292
  .option('--dry-run', 'Print the prompt assembly (system, tools, task) and exit without calling the model')
108
293
  .option('--resume <file>', 'Resume from a saved transcript JSON file (must have a "transcript" field)')
294
+ .option('--resume-session <id>', 'Resume from a persisted session (Level 9) by id prefix')
295
+ .option('--verify', 'Enable verification after edits (Level 8, default: enabled)')
296
+ .option('--verify-command <cmd>', 'Custom verification command (default: auto-detected)')
297
+ .option('--max-repairs <n>', 'Max autonomous repair attempts (default 3)', (v) => parsePositiveInt('--max-repairs', v))
298
+ .option('--persist', 'Enable session persistence (Level 9, default: enabled)')
109
299
  .action(async (prompt, opts) => {
110
300
  const model = opts.model ?? process.env.KLYRO_MODEL;
111
301
  if (!model) {
@@ -117,11 +307,7 @@ async function main() {
117
307
  process.stderr.write(`klyro: invalid --output: ${output} (expected human|json|silent)\n`);
118
308
  process.exit(2);
119
309
  }
120
- const provider = (opts.provider ?? 'openai');
121
- if (provider !== 'openai' && provider !== 'anthropic') {
122
- process.stderr.write(`klyro: invalid --provider: ${provider} (expected openai|anthropic)\n`);
123
- process.exit(2);
124
- }
310
+ // Provider validation is handled inside runOnce (single source of truth)
125
311
  try {
126
312
  const code = await runOnce({
127
313
  task: prompt,
@@ -134,9 +320,14 @@ async function main() {
134
320
  baseUrl: opts.baseUrl,
135
321
  apiKey: opts.apiKey,
136
322
  output,
137
- provider,
323
+ provider: opts.provider,
138
324
  dryRun: !!opts.dryRun,
139
325
  resumePath: opts.resume,
326
+ sessionId: opts.resumeSession,
327
+ verify: opts.verify,
328
+ verifyCommand: opts.verifyCommand,
329
+ maxRepairAttempts: opts.maxRepairs,
330
+ persist: opts.persist,
140
331
  });
141
332
  process.exit(code);
142
333
  }
@@ -168,6 +359,128 @@ async function main() {
168
359
  const code = await runEval({ inputPath: input, output });
169
360
  process.exit(code);
170
361
  });
362
+ // Level 9 — Session management
363
+ const session = program.command('session').description('Session persistence (Level 9)');
364
+ session
365
+ .command('list')
366
+ .description('List persisted sessions')
367
+ .option('--status <s>', 'Filter by status: open|complete|verify_failed|aborted|max_steps')
368
+ .option('--json', 'Output JSON')
369
+ .action(async (opts) => {
370
+ const { getDefaultSessionStore, formatSession } = await import('./persistence/session.js');
371
+ const store = getDefaultSessionStore();
372
+ const all = await store.list(opts.status ? { status: opts.status } : undefined);
373
+ if (opts.json) {
374
+ process.stdout.write(JSON.stringify(all, null, 2) + '\n');
375
+ }
376
+ else {
377
+ if (all.length === 0) {
378
+ process.stdout.write('No sessions\n');
379
+ }
380
+ else {
381
+ for (const r of all.sort((a, b) => b.updatedAt - a.updatedAt)) {
382
+ process.stdout.write(formatSession(r) + '\n');
383
+ }
384
+ }
385
+ }
386
+ });
387
+ session
388
+ .command('show <id>')
389
+ .description('Show session transcript and observations')
390
+ .option('--json', 'Output JSON')
391
+ .action(async (id, opts) => {
392
+ const { getDefaultSessionStore, resolveSessionId } = await import('./persistence/session.js');
393
+ const store = getDefaultSessionStore();
394
+ const full = await resolveSessionId(store, id);
395
+ if (!full) {
396
+ process.stderr.write(`session not found: ${id}\n`);
397
+ process.exit(2);
398
+ }
399
+ const rec = await store.get(full);
400
+ const msgs = await store.loadMessages(full);
401
+ const obs = await store.loadObservations(full);
402
+ if (opts.json) {
403
+ process.stdout.write(JSON.stringify({ record: rec, messages: msgs, observations: obs }, null, 2) + '\n');
404
+ }
405
+ else {
406
+ process.stdout.write(`Session ${rec?.id}\n task: ${rec?.task}\n status: ${rec?.status}\n cwd: ${rec?.cwd}\n created: ${new Date(rec?.createdAt ?? 0).toISOString()}\n`);
407
+ process.stdout.write(`\nMessages (${msgs.length}):\n`);
408
+ for (const m of msgs)
409
+ process.stdout.write(` [${m.role}] ${JSON.stringify(m.content).slice(0, 200)}\n`);
410
+ process.stdout.write(`\nObservations (${obs.length}):\n`);
411
+ for (const o of obs)
412
+ process.stdout.write(` ${o.toolName} -> ${o.isError ? 'ERR' : 'ok'} ${JSON.stringify(o.output).slice(0, 120)}\n`);
413
+ }
414
+ });
415
+ session
416
+ .command('resume <id>')
417
+ .description('Resume a persisted session (requires KLYRO_MODEL etc.)')
418
+ .option('-m, --model <id>', 'Model (default: from session or env)')
419
+ .option('--max-steps <n>', 'Max steps (default 30)', (v) => parsePositiveInt('--max-steps', v))
420
+ .option('--verify-command <cmd>', 'Override verification command')
421
+ .option('--verify', 'Enable verification (default: enabled)')
422
+ .action(async (id, opts) => {
423
+ const { getDefaultSessionStore, resolveSessionId } = await import('./persistence/session.js');
424
+ const store = getDefaultSessionStore();
425
+ const full = await resolveSessionId(store, id);
426
+ if (!full) {
427
+ process.stderr.write(`session not found: ${id}\n`);
428
+ process.exit(2);
429
+ }
430
+ const rec = await store.get(full);
431
+ if (!rec) {
432
+ process.stderr.write(`session not found: ${id}\n`);
433
+ process.exit(2);
434
+ }
435
+ const model = opts.model ?? rec.config.model ?? process.env.KLYRO_MODEL;
436
+ if (!model) {
437
+ process.stderr.write('klyro: KLYRO_MODEL is not set (or pass --model)\n');
438
+ process.exit(2);
439
+ }
440
+ const code = await runOnce({
441
+ task: rec.task,
442
+ cwd: rec.cwd,
443
+ model,
444
+ maxSteps: opts.maxSteps ?? rec.config.maxSteps,
445
+ sessionId: full,
446
+ verify: opts.verify,
447
+ verifyCommand: opts.verifyCommand,
448
+ });
449
+ process.exit(code);
450
+ });
451
+ // Alias: klyro resume <id> → klyro session resume <id>
452
+ program
453
+ .command('resume <id>')
454
+ .description('Alias for `klyro session resume <id>`')
455
+ .option('-m, --model <id>', 'Model')
456
+ .option('--max-steps <n>', 'Max steps', (v) => parsePositiveInt('--max-steps', v))
457
+ .action(async (id, opts) => {
458
+ const { getDefaultSessionStore, resolveSessionId } = await import('./persistence/session.js');
459
+ const store = getDefaultSessionStore();
460
+ const full = await resolveSessionId(store, id);
461
+ if (!full) {
462
+ process.stderr.write(`session not found: ${id}\n`);
463
+ process.exit(2);
464
+ }
465
+ const rec = await store.get(full);
466
+ if (!rec) {
467
+ process.stderr.write(`session not found: ${id}\n`);
468
+ process.exit(2);
469
+ }
470
+ const model = opts.model ?? rec.config.model ?? process.env.KLYRO_MODEL;
471
+ if (!model) {
472
+ process.stderr.write('klyro: KLYRO_MODEL is not set (or pass --model)\n');
473
+ process.exit(2);
474
+ }
475
+ const code = await runOnce({
476
+ task: rec.task,
477
+ cwd: rec.cwd,
478
+ model,
479
+ maxSteps: opts.maxSteps ?? rec.config.maxSteps,
480
+ sessionId: full,
481
+ });
482
+ process.exit(code);
483
+ });
171
484
  await program.parseAsync(process.argv);
172
485
  }
173
486
  main().catch((err) => {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Level 9 — Session helpers (default store location, lifecycle).
3
+ */
4
+ import { SessionStore, type SessionRecord } from './store.js';
5
+ export declare function getDefaultSessionsDir(): string;
6
+ export declare function getDefaultSessionStore(): SessionStore;
7
+ /** Create a session store at a custom dir (for tests). */
8
+ export declare function createSessionStore(dir: string): SessionStore;
9
+ /** Format a session for human output. */
10
+ export declare function formatSession(rec: SessionRecord): string;
11
+ /** Resolve a short id prefix to full id (like git). */
12
+ export declare function resolveSessionId(store: SessionStore, prefix: string): Promise<string | null>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Level 9 — Session helpers (default store location, lifecycle).
3
+ */
4
+ import * as os from 'node:os';
5
+ import * as path from 'node:path';
6
+ import { SessionStore } from './store.js';
7
+ let _cachedStore;
8
+ export function getDefaultSessionsDir() {
9
+ if (process.env.KLYRO_SESSIONS_DIR)
10
+ return process.env.KLYRO_SESSIONS_DIR;
11
+ const home = os.homedir();
12
+ if (home)
13
+ return path.join(home, '.klyro', 'sessions');
14
+ return path.join(process.cwd(), '.klyro', 'sessions');
15
+ }
16
+ export function getDefaultSessionStore() {
17
+ if (_cachedStore)
18
+ return _cachedStore;
19
+ _cachedStore = new SessionStore(getDefaultSessionsDir());
20
+ return _cachedStore;
21
+ }
22
+ /** Create a session store at a custom dir (for tests). */
23
+ export function createSessionStore(dir) {
24
+ return new SessionStore(dir);
25
+ }
26
+ /** Format a session for human output. */
27
+ export function formatSession(rec) {
28
+ const d = new Date(rec.createdAt).toISOString().slice(0, 19).replace('T', ' ');
29
+ return `${rec.id.slice(0, 8)} ${rec.status.padEnd(12)} ${d} ${rec.cwd} "${rec.task.slice(0, 60)}"`;
30
+ }
31
+ /** Resolve a short id prefix to full id (like git). */
32
+ export async function resolveSessionId(store, prefix) {
33
+ if (prefix.length >= 32) {
34
+ const rec = await store.get(prefix);
35
+ return rec ? rec.id : null;
36
+ }
37
+ const all = await store.list();
38
+ const matches = all.filter((r) => r.id.startsWith(prefix));
39
+ if (matches.length === 1)
40
+ return matches[0].id;
41
+ if (matches.length === 0)
42
+ return null;
43
+ // ambiguous — return null and let caller report
44
+ return null;
45
+ }
@@ -30,13 +30,22 @@ export interface PolicyContext {
30
30
  /** Per-tool config. */
31
31
  config: PolicyConfig;
32
32
  }
33
+ export type PermissionMode = 'default' | 'accept-edits' | 'plan' | 'auto';
33
34
  export interface PolicyConfig {
35
+ /** Permission mode */
36
+ mode?: PermissionMode;
34
37
  /** Shell commands that are pre-approved (prefix match). */
35
38
  shellAllow: string[];
36
39
  /** Hard-deny shell commands (prefix or full match). */
37
40
  shellDeny: string[];
38
41
  /** File size limit in MiB for reads without confirmation. */
39
42
  readSizeAskMiB: number;
43
+ /** Glob rules: e.g. "write_file", "write_file(.env)", "shell_exec(npm *)" */
44
+ allow?: string[];
45
+ deny?: string[];
46
+ ask?: string[];
47
+ /** Additional allowed directories (sandbox) */
48
+ additionalDirs?: string[];
40
49
  }
41
50
  export interface PolicyRule {
42
51
  name: string;
@@ -57,6 +66,7 @@ export declare class PolicyEngine {
57
66
  transcript?: unknown;
58
67
  nonInteractive: boolean;
59
68
  }): Promise<Decision>;
69
+ private evaluateGlobRules;
60
70
  }
61
71
  /** Builtin set of rules. Order matters: first match wins. */
62
72
  export declare function builtinRules(): PolicyRule[];
@@ -64,7 +74,7 @@ export declare function builtinRules(): PolicyRule[];
64
74
  export declare const shellDenyRule: PolicyRule;
65
75
  /** Allowlist for shell — exact or prefix. Anything not in the list asks. */
66
76
  export declare const shellAllowRule: PolicyRule;
67
- /** Writes are auto-allowed if path resolves inside cwd (path-guard throws otherwise). */
77
+ /** Writes are auto-allowed if path resolves inside cwd or --add-dir (path-guard throws otherwise). */
68
78
  export declare const writeFileCwdRule: PolicyRule;
69
79
  /** Reads > 5 MiB require confirmation (avoid filling the context window). */
70
80
  export declare const readFileSizeRule: PolicyRule;
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { safe } from '../tools/normalize.js';
10
10
  export const DEFAULT_POLICY_CONFIG = {
11
+ mode: 'default',
11
12
  shellAllow: [
12
13
  'git',
13
14
  'ls',
@@ -36,6 +37,10 @@ export const DEFAULT_POLICY_CONFIG = {
36
37
  'bcdedit',
37
38
  ],
38
39
  readSizeAskMiB: 5,
40
+ allow: [],
41
+ deny: [],
42
+ ask: [],
43
+ additionalDirs: [],
39
44
  };
40
45
  /**
41
46
  * Compose multiple rules. The first rule to return a Decision wins.
@@ -55,6 +60,37 @@ export class PolicyEngine {
55
60
  nonInteractive: base.nonInteractive,
56
61
  config: this.config,
57
62
  };
63
+ // 3.4 — Permission modes
64
+ if (ctx.config.mode === 'auto') {
65
+ // auto / --yolo: allow everything (except hard deny already handled)
66
+ }
67
+ else if (ctx.config.mode === 'plan') {
68
+ // plan mode: block all writes (edit)
69
+ if (call.name === 'write_file' || call.name === 'edit_file') {
70
+ return { action: 'deny', reason: 'plan mode: writes blocked — use /permissions to allow or switch mode' };
71
+ }
72
+ }
73
+ else if (ctx.config.mode === 'accept-edits') {
74
+ // accept-edits: auto-allow edit tools
75
+ if (call.name === 'write_file' || call.name === 'edit_file') {
76
+ // fall through to allow, but still check .env below
77
+ }
78
+ }
79
+ // 3.4 — Glob rules allow/deny/ask with precedence deny → allow → ask
80
+ const globDecision = this.evaluateGlobRules(call);
81
+ if (globDecision)
82
+ return globDecision;
83
+ // 3.4 — .env guard: deny writes to .env files unless explicitly allowed
84
+ if ((call.name === 'write_file' || call.name === 'edit_file') && typeof call.input.path === 'string') {
85
+ const p = String(call.input.path);
86
+ if (/(^|\/)\.env(\.|$)/.test(p) || p.endsWith('.env')) {
87
+ // Check if explicitly allowed via allow list
88
+ const allowed = (ctx.config.allow ?? []).some((r) => r.includes('.env'));
89
+ if (!allowed) {
90
+ return { action: 'deny', reason: 'write to .env denied by policy — add to allow list or use --yolo' };
91
+ }
92
+ }
93
+ }
58
94
  for (const rule of this.rules) {
59
95
  const d = rule.evaluate(call, ctx);
60
96
  if (d)
@@ -62,6 +98,24 @@ export class PolicyEngine {
62
98
  }
63
99
  return { action: 'allow' };
64
100
  }
101
+ evaluateGlobRules(call) {
102
+ const check = (list, action) => {
103
+ if (!list || list.length === 0)
104
+ return null;
105
+ for (const rule of list) {
106
+ if (matchesGlobRule(call, rule)) {
107
+ if (action === 'allow')
108
+ return { action: 'allow' };
109
+ if (action === 'deny')
110
+ return { action: 'deny', reason: `denied by rule: ${rule}` };
111
+ return { action: 'ask', reason: `requires approval per rule: ${rule}` };
112
+ }
113
+ }
114
+ return null;
115
+ };
116
+ // Precedence: deny → allow → ask
117
+ return check(this.config.deny, 'deny') ?? check(this.config.allow, 'allow') ?? check(this.config.ask, 'ask') ?? null;
118
+ }
65
119
  }
66
120
  /** Builtin set of rules. Order matters: first match wins. */
67
121
  export function builtinRules() {
@@ -75,6 +129,26 @@ export function builtinRules() {
75
129
  function asString(v) {
76
130
  return typeof v === 'string' ? v : '';
77
131
  }
132
+ function matchesGlobRule(call, rule) {
133
+ // Rule grammar: tool or tool(glob). e.g. "write_file", "write_file(.env)", "shell_exec(npm *)"
134
+ const m = /^([a-z_]+)(?:\((.*)\))?$/.exec(rule.trim());
135
+ if (!m)
136
+ return false;
137
+ const tool = m[1];
138
+ const glob = m[2];
139
+ if (tool !== call.name)
140
+ return false;
141
+ if (glob === undefined)
142
+ return true; // any input for that tool
143
+ // For shell, match command; for file tools, match path
144
+ const target = typeof call.input.command === 'string' ? String(call.input.command)
145
+ : typeof call.input.path === 'string' ? String(call.input.path)
146
+ : JSON.stringify(call.input);
147
+ // Simple glob: * matches any, ? matches single, otherwise substring
148
+ const regexStr = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
149
+ const re = new RegExp(`^${regexStr}$`, 'i');
150
+ return re.test(target);
151
+ }
78
152
  function startsWithAny(haystack, needles) {
79
153
  const h = haystack.trim().toLowerCase();
80
154
  return needles.some((n) => {
@@ -120,16 +194,25 @@ export const shellAllowRule = {
120
194
  return { action: 'ask', reason: `shell command not in allowlist: ${cmd.slice(0, 60)}` };
121
195
  },
122
196
  };
123
- /** Writes are auto-allowed if path resolves inside cwd (path-guard throws otherwise). */
197
+ /** Writes are auto-allowed if path resolves inside cwd or --add-dir (path-guard throws otherwise). */
124
198
  export const writeFileCwdRule = {
125
199
  name: 'write-file-cwd',
126
- evaluate(call) {
200
+ evaluate(call, ctx) {
127
201
  if (call.name !== 'write_file' && call.name !== 'edit_file')
128
202
  return null;
129
203
  const p = asString(call.input.path);
130
204
  if (!p)
131
205
  return null;
132
206
  if (p.includes('..') || path.isAbsolute(p)) {
207
+ // Check if absolute is inside additionalDirs
208
+ if (path.isAbsolute(p)) {
209
+ const allowed = (ctx.config.additionalDirs ?? []).some((dir) => {
210
+ const rel = path.relative(path.resolve(dir), path.resolve(p));
211
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
212
+ });
213
+ if (allowed)
214
+ return null;
215
+ }
133
216
  return { action: 'deny', reason: 'path traversal or absolute path not allowed' };
134
217
  }
135
218
  return null;