klyro 1.0.16 → 1.0.18

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 (92) hide show
  1. package/dist/agent/anthropic-adapter.js +26 -1
  2. package/dist/agent/provider-adapter.d.ts +7 -0
  3. package/dist/agent/provider-adapter.js +26 -1
  4. package/dist/agent/runtime.d.ts +14 -0
  5. package/dist/agent/runtime.js +59 -22
  6. package/dist/chat.js +16 -56
  7. package/dist/checkpoints/store.d.ts +15 -1
  8. package/dist/checkpoints/store.js +7 -2
  9. package/dist/cli/args.d.ts +7 -0
  10. package/dist/cli/args.js +10 -0
  11. package/dist/cli/completion.js +0 -3
  12. package/dist/cli/doctor.d.ts +13 -0
  13. package/dist/cli/doctor.js +133 -1
  14. package/dist/cli/errors.js +1 -1
  15. package/dist/cli/eval.d.ts +32 -0
  16. package/dist/cli/eval.js +83 -6
  17. package/dist/cli/markdown.js +1 -1
  18. package/dist/cli/repl.js +13 -24
  19. package/dist/cli/run.d.ts +37 -0
  20. package/dist/cli/run.js +81 -9
  21. package/dist/cli/update.js +7 -1
  22. package/dist/context/accounting.js +1 -1
  23. package/dist/context/klyro-md.d.ts +11 -0
  24. package/dist/context/klyro-md.js +59 -4
  25. package/dist/context/level7.d.ts +4 -0
  26. package/dist/context/level7.js +20 -1
  27. package/dist/context/project-map.js +14 -13
  28. package/dist/eval/baseline.d.ts +47 -0
  29. package/dist/eval/baseline.js +55 -0
  30. package/dist/eval/harness.d.ts +14 -2
  31. package/dist/eval/harness.js +42 -4
  32. package/dist/index.js +57 -3
  33. package/dist/persistence/store.js +2 -2
  34. package/dist/policy/approval.js +3 -0
  35. package/dist/policy/path-guard.d.ts +9 -4
  36. package/dist/policy/path-guard.js +47 -23
  37. package/dist/providers/endpoints.js +8 -0
  38. package/dist/providers/model-info.d.ts +1 -0
  39. package/dist/providers/model-info.js +19 -0
  40. package/dist/providers.js +6 -2
  41. package/dist/renderers/terminal.d.ts +7 -0
  42. package/dist/renderers/terminal.js +52 -0
  43. package/dist/repl.d.ts +9 -0
  44. package/dist/repl.js +24 -5
  45. package/dist/shared/index.d.ts +1 -0
  46. package/dist/shared/index.js +1 -0
  47. package/dist/shared/proxy.d.ts +37 -0
  48. package/dist/shared/proxy.js +360 -0
  49. package/dist/tools/agent/task-list.d.ts +1 -1
  50. package/dist/tools/fs/apply-patch.js +4 -4
  51. package/dist/tools/fs/edit-file.js +2 -4
  52. package/dist/tools/fs/list-dir.js +1 -1
  53. package/dist/tools/fs/multi-edit.js +2 -2
  54. package/dist/tools/fs/read-file.js +1 -1
  55. package/dist/tools/fs/write-file.js +2 -2
  56. package/dist/tools/git/git-blame.d.ts +5 -0
  57. package/dist/tools/git/git-blame.js +22 -0
  58. package/dist/tools/git/git-diff.js +3 -20
  59. package/dist/tools/git/git-log.js +5 -19
  60. package/dist/tools/git/git-show.d.ts +5 -0
  61. package/dist/tools/git/git-show.js +22 -0
  62. package/dist/tools/git/git-status.js +4 -21
  63. package/dist/tools/git/run-git.d.ts +5 -0
  64. package/dist/tools/git/run-git.js +18 -0
  65. package/dist/tools/lsp/diagnostics.js +2 -3
  66. package/dist/tools/plan/todo-write.d.ts +1 -1
  67. package/dist/tools/registry.js +4 -0
  68. package/dist/tools/search/dependencies.d.ts +4 -4
  69. package/dist/tools/search/dependencies.js +1 -1
  70. package/dist/tools/search/glob.js +9 -4
  71. package/dist/tools/search/grep.js +11 -4
  72. package/dist/tools/search/ignore.d.ts +8 -0
  73. package/dist/tools/search/ignore.js +49 -0
  74. package/dist/tools/search/recent-files.js +1 -1
  75. package/dist/tools/search/search-files.js +11 -4
  76. package/dist/tools/web/web-fetch.js +2 -1
  77. package/dist/tools/web/web-search.js +3 -1
  78. package/dist/tui/app.js +117 -10
  79. package/dist/tui/app.test.js +65 -4
  80. package/dist/tui/measure.js +1 -0
  81. package/dist/tui/mouse.d.ts +7 -5
  82. package/dist/tui/mouse.js +9 -6
  83. package/dist/tui/scroll-flow.test.js +0 -1
  84. package/dist/tui/transcript-commands.d.ts +14 -0
  85. package/dist/tui/transcript-commands.js +31 -0
  86. package/dist/util/log.d.ts +19 -10
  87. package/dist/util/log.js +87 -52
  88. package/dist/verification/classify.d.ts +1 -1
  89. package/dist/verification/classify.js +2 -2
  90. package/dist/verification/engine.js +0 -6
  91. package/dist/verification/scoped.js +6 -4
  92. package/package.json +6 -1
@@ -122,7 +122,7 @@ export async function runTask(t, opts = {}) {
122
122
  try {
123
123
  await fs.rm(cwd, { recursive: true, force: true });
124
124
  }
125
- catch { }
125
+ catch { /* ignore — best-effort tmp cleanup */ }
126
126
  }
127
127
  }
128
128
  }
@@ -177,10 +177,46 @@ export async function loadFileFixture(dir) {
177
177
  catch { /* no script → static fixture */ }
178
178
  return { dir, task: task.trim(), checkSh, meta, ...(repo ? { repo } : {}), ...(script ? { script } : {}) };
179
179
  }
180
- export async function runFileFixture(fixture, opts = {}) {
180
+ /**
181
+ * 5.4b — sanitized env for `check.sh`: PATH + home/locale + temp vars
182
+ * (+ Windows shell vars so the shell can spawn). Everything else —
183
+ * secrets, `KLYRO_*` config, caller sentinels — is dropped. Exported for tests.
184
+ */
185
+ const FIXTURE_ENV_ALLOW = new Set([
186
+ 'PATH', 'PATHEXT', 'HOME', 'LANG', 'LC_ALL', 'TMPDIR', 'TEMP', 'TMP',
187
+ 'SYSTEMROOT', 'SYSTEMDRIVE', 'COMSPEC',
188
+ ]);
189
+ export function sanitizedFixtureEnv(base = process.env) {
190
+ const out = {};
191
+ for (const k of FIXTURE_ENV_ALLOW) {
192
+ const v = base[k];
193
+ if (v !== undefined)
194
+ out[k] = v;
195
+ }
196
+ return out;
197
+ }
198
+ /**
199
+ * 5.4b — guard: fixture workdirs must stay inside `os.tmpdir()` (the
200
+ * harness relies on that isolation). Throws otherwise unless the caller
201
+ * explicitly passes `allowWorkdirOutsideTmp`. Exported for tests.
202
+ */
203
+ export function assertTmpWorkdir(dir, opts = {}) {
204
+ if (opts.allowWorkdirOutsideTmp)
205
+ return;
206
+ const root = path.resolve(os.tmpdir());
207
+ const resolved = path.resolve(dir);
208
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
209
+ throw new Error(`fixture workdir escapes os.tmpdir(): ${resolved} (pass allowWorkdirOutsideTmp to override)`);
210
+ }
211
+ }
212
+ export async function runFileFixture(fixture, _opts = {}) {
181
213
  const start = Date.now();
182
214
  const tmp = path.join(os.tmpdir(), 'klyro-eval-file-' + Math.random().toString(36).slice(2));
215
+ assertTmpWorkdir(tmp, _opts);
183
216
  await fs.mkdir(tmp, { recursive: true });
217
+ // Restrictive creation (umask-style): the tmp workdir is owner-only.
218
+ // Best-effort — chmod is a no-op for ACLs on Windows but harmless.
219
+ await fs.chmod(tmp, 0o700).catch(() => undefined);
184
220
  // Copy repo if exists
185
221
  if (fixture.repo) {
186
222
  const src = path.isAbsolute(fixture.repo) ? fixture.repo : path.join(fixture.dir, fixture.repo);
@@ -192,7 +228,7 @@ export async function runFileFixture(fixture, opts = {}) {
192
228
  // Run check.sh via shell
193
229
  const { spawn } = await import('node:child_process');
194
230
  const result = await new Promise((resolve) => {
195
- const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false });
231
+ const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false, env: sanitizedFixtureEnv() });
196
232
  const sink = cappedOutput(MAX_CHECK_BYTES);
197
233
  child.stdout?.on('data', (b) => sink.push(b));
198
234
  child.stderr?.on('data', (b) => sink.push(b));
@@ -226,7 +262,9 @@ export async function runAgentFixture(fixture, opts = {}) {
226
262
  return { id, status: 'fail', details: 'agent fixture needs script.json', durationMs: Date.now() - start };
227
263
  }
228
264
  const tmp = path.join(os.tmpdir(), 'klyro-eval-agent-' + Math.random().toString(36).slice(2));
265
+ assertTmpWorkdir(tmp, opts);
229
266
  await fs.mkdir(tmp, { recursive: true });
267
+ await fs.chmod(tmp, 0o700).catch(() => undefined);
230
268
  try {
231
269
  if (fixture.repo) {
232
270
  const src = path.isAbsolute(fixture.repo) ? fixture.repo : path.join(fixture.dir, fixture.repo);
@@ -250,7 +288,7 @@ export async function runAgentFixture(fixture, opts = {}) {
250
288
  // Structural pass — now assert real filesystem outcomes.
251
289
  const { spawn } = await import('node:child_process');
252
290
  const out = await new Promise((resolve) => {
253
- const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false });
291
+ const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false, env: sanitizedFixtureEnv() });
254
292
  const sink = cappedOutput(MAX_CHECK_BYTES);
255
293
  child.stdout?.on('data', (b) => sink.push(b));
256
294
  child.stderr?.on('data', (b) => sink.push(b));
package/dist/index.js CHANGED
@@ -145,6 +145,27 @@ async function main() {
145
145
  const code = await runUpdate({ apply: !!opts.apply });
146
146
  process.exit(code);
147
147
  });
148
+ program
149
+ .command('version')
150
+ .description('Print the version number')
151
+ .action(() => {
152
+ process.stdout.write(`${VERSION}\n`);
153
+ });
154
+ program
155
+ .command('help [command]')
156
+ .description('Print help (same as --help)')
157
+ .action(async (cmd) => {
158
+ if (cmd) {
159
+ const target = program.commands.find((c) => c.name() === cmd);
160
+ if (!target) {
161
+ process.stderr.write(`klyro: unknown command: ${cmd}\n`);
162
+ process.exit(2);
163
+ }
164
+ target.outputHelp();
165
+ return;
166
+ }
167
+ program.outputHelp();
168
+ });
148
169
  program
149
170
  .command('login')
150
171
  .description('Login and store API key (masked, 0600) — provider auto-select')
@@ -191,7 +212,12 @@ async function main() {
191
212
  .command('doctor')
192
213
  .description('Run diagnostics (node, config, provider, sessions, git, tools)')
193
214
  .option('--json', 'Output JSON')
215
+ .option('--keys', 'Interactive key probe: show raw bytes your terminal sends (arrows, paste, wheel)')
194
216
  .action(async (opts) => {
217
+ if (opts.keys) {
218
+ const { runKeysProbe } = await import('./cli/doctor.js');
219
+ process.exit(await runKeysProbe());
220
+ }
195
221
  const code = await runDoctor({ json: !!opts.json });
196
222
  process.exit(code);
197
223
  });
@@ -297,8 +323,16 @@ async function main() {
297
323
  .description('Run a one-shot autonomous task. Streams text to stdout; tool calls to stderr.')
298
324
  .option('-m, --model <id>', 'Model id (default: env KLYRO_MODEL)')
299
325
  .option('--max-steps <n>', 'Max agent steps (default 30)', (v) => parsePositiveInt('--max-steps', v))
326
+ .option('--max-turns <n>', 'Alias for --max-steps (smaller wins when both are set)', (v) => parsePositiveInt('--max-turns', v))
300
327
  .option('--max-tokens <n>', 'Max output tokens per step', (v) => parsePositiveInt('--max-tokens', v))
301
328
  .option('--temperature <n>', 'Sampling temperature (0-2)', parseTemperature)
329
+ .option('--reasoning-effort <level>', 'Reasoning effort for reasoning models: low|medium|high')
330
+ .option('--system-prompt <text>', 'Replace the assembled system prompt wholesale')
331
+ .option('--append-system-prompt <text>', 'Extra instructions appended after the system prompt')
332
+ .option('--permission-mode <mode>', 'Policy mode: default|accept-edits|plan|auto')
333
+ .option('--allowed-tools <list>', 'Comma-separated tool names pre-approved for this run')
334
+ .option('--disallowed-tools <list>', 'Comma-separated tool names denied for this run (deny always wins)')
335
+ .option('--add-dir <path>', 'Extra sandbox directory (repeatable/comma-separated), like /add-dir')
302
336
  .option('--timeout <ms>', 'Request timeout in ms (default: env KLYRO_TIMEOUT_MS or 60000)', (v) => parsePositiveInt('--timeout', v))
303
337
  .option('--base-url <url>', 'Override KLYRO_BASE_URL')
304
338
  .option('--api-key <key>', 'Override KLYRO_API_KEY')
@@ -316,6 +350,7 @@ async function main() {
316
350
  .option('--agent <name>', 'Run under an orchestrator context enabling spawn_agent/task_list/task_get (explorer|implementer|tester|reviewer)')
317
351
  .option('--max-depth <n>', 'Max spawn depth for child agents (default 1)', (v) => parsePositiveInt('--max-depth', v))
318
352
  .option('--bare', 'Deterministic runs: skip MCP, hooks, memory/KLYRO.md/context, persistence')
353
+ .option('--auto-answer <text>', 'Headless answer for ask_user prompts (sets KLYRO_AUTO_ANSWER)')
319
354
  .action(async (prompt, opts) => {
320
355
  const model = opts.model ?? process.env.KLYRO_MODEL;
321
356
  if (!model) {
@@ -332,6 +367,16 @@ async function main() {
332
367
  process.stderr.write(`klyro: invalid --verify-mode: ${opts.verifyMode} (expected strict|advisory|off)\n`);
333
368
  process.exit(2);
334
369
  }
370
+ const reasoningEffort = opts.reasoningEffort;
371
+ if (reasoningEffort !== undefined && reasoningEffort !== 'low' && reasoningEffort !== 'medium' && reasoningEffort !== 'high') {
372
+ process.stderr.write(`klyro: invalid --reasoning-effort: ${opts.reasoningEffort} (expected low|medium|high)\n`);
373
+ process.exit(2);
374
+ }
375
+ const permissionMode = opts.permissionMode;
376
+ if (permissionMode !== undefined && permissionMode !== 'default' && permissionMode !== 'accept-edits' && permissionMode !== 'plan' && permissionMode !== 'auto') {
377
+ process.stderr.write(`klyro: invalid --permission-mode: ${opts.permissionMode} (expected default|accept-edits|plan|auto)\n`);
378
+ process.exit(2);
379
+ }
335
380
  // Provider validation is handled inside runOnce (single source of truth)
336
381
  try {
337
382
  const code = await runOnce({
@@ -339,8 +384,16 @@ async function main() {
339
384
  cwd: process.cwd(),
340
385
  model,
341
386
  maxSteps: opts.maxSteps,
387
+ maxTurns: opts.maxTurns,
342
388
  maxTokens: opts.maxTokens,
343
389
  temperature: opts.temperature,
390
+ reasoningEffort,
391
+ systemPromptText: opts.systemPrompt,
392
+ appendSystemPrompt: opts.appendSystemPrompt,
393
+ permissionMode,
394
+ allowedTools: opts.allowedTools,
395
+ disallowedTools: opts.disallowedTools,
396
+ addDir: opts.addDir,
344
397
  timeoutMs: opts.timeout,
345
398
  baseUrl: opts.baseUrl,
346
399
  apiKey: opts.apiKey,
@@ -358,6 +411,7 @@ async function main() {
358
411
  agent: opts.agent,
359
412
  maxDepth: opts.maxDepth,
360
413
  bare: !!opts.bare,
414
+ autoAnswer: opts.autoAnswer,
361
415
  });
362
416
  process.exit(code);
363
417
  }
@@ -391,17 +445,18 @@ async function main() {
391
445
  .option('--model <id>', 'Model for eval')
392
446
  .option('--judge-model <id>', 'Live model id for grading judge.rubric (needs endpoint + key)')
393
447
  .option('--cwd <path>', 'Shared scenario workdir (default: isolated tmp per scenario)')
448
+ .option('--auto-answer <text>', 'Headless answer for ask_user prompts (sets KLYRO_AUTO_ANSWER)')
394
449
  .action(async (input, opts) => {
395
450
  const output = (opts.output ?? 'human');
396
451
  if (opts.suite) {
397
- const code = await runEval({ inputPath: input ?? '-', output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel });
452
+ const code = await runEval({ inputPath: input ?? '-', output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel, cwd: opts.cwd, autoAnswer: opts.autoAnswer });
398
453
  process.exit(code);
399
454
  }
400
455
  if (!input) {
401
456
  process.stderr.write('klyro eval: missing input (provide <input> or --suite)\n');
402
457
  process.exit(2);
403
458
  }
404
- const code = await runEval({ inputPath: input, output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel, cwd: opts.cwd });
459
+ const code = await runEval({ inputPath: input, output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel, cwd: opts.cwd, autoAnswer: opts.autoAnswer });
405
460
  process.exit(code);
406
461
  });
407
462
  program
@@ -729,7 +784,6 @@ async function main() {
729
784
  }
730
785
  main().catch((err) => {
731
786
  // Last-resort: anything that escaped the command handlers lands here.
732
- // eslint-disable-next-line no-console
733
787
  console.error(`klyro: ${err instanceof Error ? err.message : String(err)}`);
734
788
  process.exit(1);
735
789
  });
@@ -180,11 +180,11 @@ export class SessionStore {
180
180
  try {
181
181
  pIdx = JSON.parse(await fs.readFile(pp, 'utf-8'));
182
182
  }
183
- catch { }
183
+ catch { /* missing index — start fresh */ }
184
184
  pIdx[id] = record;
185
185
  await this.writePerProjectIndex(pp, pIdx);
186
186
  }
187
- catch { }
187
+ catch { /* best-effort — the primary index above already persisted */ }
188
188
  return record;
189
189
  });
190
190
  }
@@ -16,8 +16,11 @@ import { patternForCall } from './patterns.js';
16
16
  */
17
17
  export function sanitizeForPrompt(s) {
18
18
  const noAnsi = s
19
+ // eslint-disable-next-line no-control-regex -- intentional: stripping CSI + OSC sequences
19
20
  .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
21
+ // eslint-disable-next-line no-control-regex -- intentional: stripping OSC sequences
20
22
  .replace(/\x1b\][^\x07]*\x07/g, '');
23
+ // eslint-disable-next-line no-control-regex -- intentional: stripping C0 controls
21
24
  const clean = noAnsi.replace(/[\x00-\x08\x0b-\x0d\x0e-\x1f\x7f]/g, '');
22
25
  const LIMIT = 500;
23
26
  if (clean.length > LIMIT) {
@@ -21,13 +21,18 @@ export declare class PathGuardError extends Error {
21
21
  /**
22
22
  * Resolve a (possibly relative) path against cwd and assert it stays inside.
23
23
  * Does NOT follow symlinks — call `resolveAndFollowSymlinks` for that.
24
+ * `extraRoots` (--add-dir / agent sandbox extensions, relative entries
25
+ * resolve against cwd) are tried in order when the path escapes cwd.
24
26
  */
25
- export declare function resolveWithinCwd(cwd: string, requested: string): PathGuardResult;
27
+ export declare function resolveWithinCwd(cwd: string, requested: string, extraRoots?: readonly string[]): PathGuardResult;
26
28
  /**
27
- * Resolve, follow symlinks, and assert the target stays inside cwd.
28
- * Use this for read_file / write_file to defeat symlink-based escapes.
29
+ * Resolve, follow symlinks, and assert the target stays inside cwd (or one
30
+ * of `extraRoots`). Use this for read_file / write_file to defeat
31
+ * symlink-based escapes. Symlink checks run against the winning root, so a
32
+ * link inside an added dir pointing outside it is refused exactly like a
33
+ * cwd escape.
29
34
  */
30
- export declare function resolveAndFollowSymlinks(cwd: string, requested: string): Promise<PathGuardResult>;
35
+ export declare function resolveAndFollowSymlinks(cwd: string, requested: string, extraRoots?: readonly string[]): Promise<PathGuardResult>;
31
36
  /**
32
37
  * Symlink-swap guard: refuse when the FINAL path itself is a symlink.
33
38
  *
@@ -33,31 +33,55 @@ function normalizeForCompare(p) {
33
33
  /**
34
34
  * Resolve a (possibly relative) path against cwd and assert it stays inside.
35
35
  * Does NOT follow symlinks — call `resolveAndFollowSymlinks` for that.
36
+ * `extraRoots` (--add-dir / agent sandbox extensions, relative entries
37
+ * resolve against cwd) are tried in order when the path escapes cwd.
36
38
  */
37
- export function resolveWithinCwd(cwd, requested) {
39
+ export function resolveWithinCwd(cwd, requested, extraRoots) {
40
+ return resolveWithinRoots(cwd, requested, extraRoots);
41
+ }
42
+ function tryContain(root, requested) {
43
+ const resolved = path.isAbsolute(requested) ? path.resolve(requested) : path.resolve(root, requested);
44
+ // Use relative to test containment. path.relative throws on different
45
+ // drives on Windows; normalize to handle that.
46
+ const rel = path.relative(root, resolved);
47
+ if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)))
48
+ return { resolved };
49
+ // Different drive on Windows: relative starts with a drive letter
50
+ if (isWindows() && /^[A-Za-z]:[\\/]/.test(rel))
51
+ return { drive: true };
52
+ return null;
53
+ }
54
+ function resolveWithinRoots(cwd, requested, extraRoots) {
55
+ const roots = extraRoots ?? [];
38
56
  if (!requested || requested.length === 0) {
39
57
  throw new PathGuardError(TOOL_ERROR_CODES.INVALID_INPUT, 'Path is empty');
40
58
  }
41
59
  const absCwd = path.resolve(cwd);
42
- const resolved = path.isAbsolute(requested) ? path.resolve(requested) : path.resolve(absCwd, requested);
43
- // Use relative to test containment. path.relative throws on different
44
- // drives on Windows; normalize to handle that.
45
- const rel = path.relative(absCwd, resolved);
46
- if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) {
47
- return { resolved };
60
+ const first = tryContain(absCwd, requested);
61
+ if (first && !('drive' in first))
62
+ return { resolved: first.resolved, root: absCwd };
63
+ for (const extra of roots) {
64
+ const root = path.isAbsolute(extra) ? path.resolve(extra) : path.resolve(absCwd, extra);
65
+ if (root === absCwd)
66
+ continue;
67
+ const hit = tryContain(root, requested);
68
+ if (hit && !('drive' in hit))
69
+ return { resolved: hit.resolved, root };
48
70
  }
49
- // Different drive on Windows: relative starts with a drive letter
50
- if (isWindows() && /^[A-Za-z]:[\\/]/.test(rel)) {
71
+ if (first && 'drive' in first) {
51
72
  throw new PathGuardError(TOOL_ERROR_CODES.PATH_ESCAPE, `Path escapes cwd (different drive): ${requested}`);
52
73
  }
53
74
  throw new PathGuardError(TOOL_ERROR_CODES.PATH_ESCAPE, `Path escapes cwd: ${requested}`);
54
75
  }
55
76
  /**
56
- * Resolve, follow symlinks, and assert the target stays inside cwd.
57
- * Use this for read_file / write_file to defeat symlink-based escapes.
77
+ * Resolve, follow symlinks, and assert the target stays inside cwd (or one
78
+ * of `extraRoots`). Use this for read_file / write_file to defeat
79
+ * symlink-based escapes. Symlink checks run against the winning root, so a
80
+ * link inside an added dir pointing outside it is refused exactly like a
81
+ * cwd escape.
58
82
  */
59
- export async function resolveAndFollowSymlinks(cwd, requested) {
60
- const { resolved } = resolveWithinCwd(cwd, requested);
83
+ export async function resolveAndFollowSymlinks(cwd, requested, extraRoots) {
84
+ const { resolved, root } = resolveWithinRoots(cwd, requested, extraRoots);
61
85
  let real;
62
86
  let realParent;
63
87
  try {
@@ -77,21 +101,21 @@ export async function resolveAndFollowSymlinks(cwd, requested) {
77
101
  return { resolved };
78
102
  }
79
103
  }
80
- const absCwd = await fs.realpath(cwd).catch(() => path.resolve(cwd));
104
+ const absRoot = await fs.realpath(root).catch(() => path.resolve(root));
81
105
  const cmpReal = normalizeForCompare(real);
82
- const cmpCwd = normalizeForCompare(absCwd);
83
- if (cmpReal !== cmpCwd && !cmpReal.startsWith(cmpCwd + path.sep)) {
106
+ const cmpRoot = normalizeForCompare(absRoot);
107
+ if (cmpReal !== cmpRoot && !cmpReal.startsWith(cmpRoot + path.sep)) {
84
108
  throw new PathGuardError(TOOL_ERROR_CODES.PATH_ESCAPE, `Symlink target escapes cwd: ${requested} -> ${real}`);
85
109
  }
86
110
  // Defense-in-depth: ensure the parent directory of the resolved target
87
- // is also inside cwd. This catches the case where the requested path
88
- // walks through a symlinked parent (e.g. `<cwd>/evil-link/../escape`)
89
- // and `real` happens to land back inside cwd but the parent was outside.
90
- // Skip when the target itself is cwd (realParent would be the parent of
91
- // cwd, which is necessarily outside).
92
- if (real !== absCwd) {
111
+ // is also inside the winning root. This catches the case where the
112
+ // requested path walks through a symlinked parent (e.g. `<root>/evil-link/../escape`)
113
+ // and `real` happens to land back inside the root but the parent was outside.
114
+ // Skip when the target itself is the root (realParent would be the parent of
115
+ // the root, which is necessarily outside).
116
+ if (real !== absRoot) {
93
117
  const cmpRealParent = normalizeForCompare(realParent);
94
- if (cmpRealParent !== cmpCwd && !cmpRealParent.startsWith(cmpCwd + path.sep)) {
118
+ if (cmpRealParent !== cmpRoot && !cmpRealParent.startsWith(cmpRoot + path.sep)) {
95
119
  throw new PathGuardError(TOOL_ERROR_CODES.PATH_ESCAPE, `Path escapes cwd via parent symlink: ${requested} -> ${realParent}`);
96
120
  }
97
121
  }
@@ -47,6 +47,14 @@ const BUILTINS = [
47
47
  defaultModel: 'local-model',
48
48
  probePath: '/models',
49
49
  },
50
+ {
51
+ id: 'openrouter',
52
+ label: 'OpenRouter',
53
+ kind: 'openai-compatible',
54
+ baseURL: 'https://openrouter.ai/api/v1',
55
+ defaultModel: 'openai/gpt-4o-mini',
56
+ apiKeyEnv: ['KLYRO_API_KEY', 'OPENROUTER_API_KEY'],
57
+ },
50
58
  ];
51
59
  const custom = new Map();
52
60
  function validateEndpoint(def) {
@@ -15,6 +15,7 @@ export interface ModelInfo {
15
15
  }
16
16
  export declare const MODEL_REGISTRY: Record<string, ModelInfo>;
17
17
  export declare function getModelInfo(id: string): ModelInfo;
18
+ export declare function resolveModelAlias(id: string): string;
18
19
  /**
19
20
  * Single-source per-1K-token rate table, derived from MODEL_REGISTRY so
20
21
  * prices live in exactly one place. Local/self-hosted entries are $0.
@@ -12,6 +12,25 @@ export const MODEL_REGISTRY = {
12
12
  export function getModelInfo(id) {
13
13
  return MODEL_REGISTRY[id] ?? { id, contextWindow: 100000, maxOutput: 4096, inputPricePer1k: 0, outputPricePer1k: 0, capabilities: { tools: true } };
14
14
  }
15
+ /**
16
+ * CLI model aliases (2.2): short names resolving against the registry so
17
+ * they never point at retired generations. Exact registry hits and unknown
18
+ * ids pass through untouched (the provider reports those errors).
19
+ */
20
+ const MODEL_ALIASES = {
21
+ sonnet: 'claude-3-5-sonnet-20240620',
22
+ // No opus generation is pinned in the registry; closest capable Claude.
23
+ opus: 'claude-3-5-sonnet-20240620',
24
+ haiku: 'claude-3-haiku-20240307',
25
+ gpt: 'gpt-4o-mini',
26
+ local: 'local-model',
27
+ };
28
+ export function resolveModelAlias(id) {
29
+ if (!id || MODEL_REGISTRY[id])
30
+ return id;
31
+ const hit = MODEL_ALIASES[id.toLowerCase()];
32
+ return hit ?? id;
33
+ }
15
34
  /**
16
35
  * Single-source per-1K-token rate table, derived from MODEL_REGISTRY so
17
36
  * prices live in exactly one place. Local/self-hosted entries are $0.
package/dist/providers.js CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { assertSafeBaseURL, normalizeBaseURL } from './chat.js';
15
15
  import { localProbeEndpoints } from './providers/endpoints.js';
16
+ import { resolveModelAlias } from './providers/model-info.js';
16
17
  /**
17
18
  * Local probe candidates, derived from the single provider registry
18
19
  * (`providers/endpoints.ts`) — adding a local server there automatically
@@ -49,7 +50,9 @@ export async function resolveProvider() {
49
50
  };
50
51
  const envBaseURL = process.env.KLYRO_BASE_URL;
51
52
  const envKey = process.env.KLYRO_API_KEY;
52
- const envModel = process.env.KLYRO_MODEL;
53
+ // Model aliases (2.2: sonnet/opus/haiku/gpt/local) resolve against the
54
+ // registry here so every consumer gets canonical ids.
55
+ const envModel = process.env.KLYRO_MODEL ? resolveModelAlias(process.env.KLYRO_MODEL) : undefined;
53
56
  // Persisted config loads first: it carries the allowInsecure opt-in that
54
57
  // also governs env URLs, and supplies model/key fallbacks everywhere.
55
58
  let cfg = {};
@@ -61,7 +64,8 @@ export async function resolveProvider() {
61
64
  const allowInsecure = cfg.allowInsecure === true;
62
65
  const cfgBase = (cfg.baseUrl ?? cfg.baseURL);
63
66
  const cfgProvider = cfg.provider;
64
- const cfgModel = (cfg.model ?? cfg['model.default']);
67
+ const cfgModelRaw = (cfg.model ?? cfg['model.default']);
68
+ const cfgModel = cfgModelRaw ? resolveModelAlias(cfgModelRaw) : undefined;
65
69
  const cfgKey = (cfg.apiKey ?? cfg.api_key);
66
70
  // An explicitly configured endpoint that fails policy is a hard error —
67
71
  // fail loud with the reason instead of silently guessing another endpoint
@@ -7,3 +7,10 @@ export declare class TerminalRenderer {
7
7
  handle(ev: KlyroEvent): void;
8
8
  renderMarkdown(text: string): void;
9
9
  }
10
+ /**
11
+ * Drain-aware stdout write shared by legacy streaming paths (3.1: all
12
+ * human output flows through the terminal renderer module). Sanitizes
13
+ * first, then waits for drain. Returns false if stdout closed (e.g. piped
14
+ * to `head`) or the abort signal fires — callers must stop producing.
15
+ */
16
+ export declare function writeStdoutDrained(chunk: string, signal?: AbortSignal): Promise<boolean>;
@@ -40,3 +40,55 @@ export class TerminalRenderer {
40
40
  process.stdout.write(sanitizeTerminalText(out));
41
41
  }
42
42
  }
43
+ /**
44
+ * Drain-aware stdout write shared by legacy streaming paths (3.1: all
45
+ * human output flows through the terminal renderer module). Sanitizes
46
+ * first, then waits for drain. Returns false if stdout closed (e.g. piped
47
+ * to `head`) or the abort signal fires — callers must stop producing.
48
+ */
49
+ export async function writeStdoutDrained(chunk, signal) {
50
+ const clean = sanitizeTerminalText(chunk);
51
+ return new Promise((resolve) => {
52
+ if (signal?.aborted) {
53
+ resolve(false);
54
+ return;
55
+ }
56
+ if (!process.stdout.write(clean)) {
57
+ let settled = false;
58
+ const cleanup = () => {
59
+ process.stdout.off('drain', onDrain);
60
+ process.stdout.off('error', onError);
61
+ if (signal)
62
+ signal.removeEventListener('abort', onAbort);
63
+ };
64
+ const onDrain = () => {
65
+ if (settled)
66
+ return;
67
+ settled = true;
68
+ cleanup();
69
+ resolve(true);
70
+ };
71
+ const onError = () => {
72
+ if (settled)
73
+ return;
74
+ settled = true;
75
+ cleanup();
76
+ resolve(false);
77
+ };
78
+ const onAbort = () => {
79
+ if (settled)
80
+ return;
81
+ settled = true;
82
+ cleanup();
83
+ resolve(false);
84
+ };
85
+ process.stdout.once('drain', onDrain);
86
+ process.stdout.once('error', onError);
87
+ if (signal)
88
+ signal.addEventListener('abort', onAbort, { once: true });
89
+ }
90
+ else {
91
+ resolve(true);
92
+ }
93
+ });
94
+ }
package/dist/repl.d.ts CHANGED
@@ -17,6 +17,15 @@ export interface Turn {
17
17
  role: 'user' | 'assistant';
18
18
  content: string;
19
19
  }
20
+ /**
21
+ * Trailing-backslash continuation for the legacy REPL (1.4): a line ending
22
+ * in an odd run of backslashes joins the next line (one backslash removed,
23
+ * newline kept); anything else completes the prompt. Exported for tests.
24
+ */
25
+ export declare function splitContinuedLine(pending: string, line: string): {
26
+ pending: string;
27
+ complete?: string;
28
+ };
20
29
  export declare function repl(system: string): Promise<void>;
21
30
  /** Drop oldest turns to stay under the size cap. Keep user/assistant pairs together. */
22
31
  export declare function trimHistory(history: Turn[]): void;
package/dist/repl.js CHANGED
@@ -22,6 +22,17 @@ import { estimateTokens } from './context/tokenizer.js';
22
22
  const MAX_HISTORY_TURNS = 40;
23
23
  /** Approximate char budget for the user side of history. */
24
24
  const MAX_HISTORY_CHARS = 80_000;
25
+ /**
26
+ * Trailing-backslash continuation for the legacy REPL (1.4): a line ending
27
+ * in an odd run of backslashes joins the next line (one backslash removed,
28
+ * newline kept); anything else completes the prompt. Exported for tests.
29
+ */
30
+ export function splitContinuedLine(pending, line) {
31
+ const m = /\\+$/.exec(line);
32
+ if (m && m[0].length % 2 === 1)
33
+ return { pending: pending + line.slice(0, -1) + '\n' };
34
+ return { pending: '', complete: pending + line };
35
+ }
25
36
  export async function repl(system) {
26
37
  const provider = await resolveProvider();
27
38
  if (!provider) {
@@ -33,7 +44,7 @@ export async function repl(system) {
33
44
  console.error(' ollama serve # then KLYRO_BASE_URL=http://localhost:11434/v1 KLYRO_MODEL=llama3.2');
34
45
  exit(2);
35
46
  }
36
- const { baseURL, apiKey, model, source } = provider;
47
+ const { baseURL, apiKey, model } = provider;
37
48
  // Legacy REPL — prefer `klyro tui`; history truncation is approximate.
38
49
  process.stderr.write('klyro: legacy REPL — prefer `klyro tui`; history truncation is approximate.\n');
39
50
  const history = [];
@@ -62,26 +73,34 @@ export async function repl(system) {
62
73
  });
63
74
  if (isTTY)
64
75
  console.log(`klyro REPL — type :quit to exit, :clear to reset history ${providerHelp(provider)}`);
76
+ // Trailing-backslash continuation (1.4): a line ending in an odd run of
77
+ // backslashes joins the next line (one backslash removed, newline kept).
78
+ let continued = '';
65
79
  try {
66
80
  while (true) {
67
81
  // In TTY mode, readline handles the prompt and echoes the user's typing.
68
82
  // In non-TTY mode, we write the prompt ourselves.
83
+ const tag = continued ? '... ' : (isTTY ? '> ' : '');
69
84
  if (!isTTY)
70
- process.stdout.write('> ');
85
+ process.stdout.write(tag);
71
86
  let line;
72
87
  try {
73
88
  // Pass the prompt only when readline is in charge (TTY). In non-TTY
74
89
  // mode the prompt is suppressed (output: undefined) so passing '' is
75
90
  // equivalent and avoids doubling.
76
- line = await rl.question(isTTY ? '> ' : '');
91
+ line = await rl.question(isTTY ? tag : '');
77
92
  }
78
- catch (err) {
93
+ catch {
79
94
  // EOF on stdin (Ctrl-D) or close — exit cleanly.
80
95
  break;
81
96
  }
82
97
  if (!line)
83
98
  break; // empty line on EOF
84
- const prompt = line.trim();
99
+ const step = splitContinuedLine(continued, line);
100
+ continued = step.pending;
101
+ if (step.complete === undefined)
102
+ continue;
103
+ const prompt = step.complete.trim();
85
104
  if (!prompt)
86
105
  continue;
87
106
  if (prompt === ':quit' || prompt === ':exit')
@@ -1,4 +1,5 @@
1
1
  export * from './errors.js';
2
2
  export * from './types.js';
3
3
  export * from './sanitize.js';
4
+ export * from './proxy.js';
4
5
  export * from './json.js';
@@ -1,4 +1,5 @@
1
1
  export * from './errors.js';
2
2
  export * from './types.js';
3
3
  export * from './sanitize.js';
4
+ export * from './proxy.js';
4
5
  export * from './json.js';