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.
- package/dist/agent/anthropic-adapter.d.ts +1 -1
- package/dist/agent/anthropic-adapter.js +22 -20
- package/dist/agent/provider-adapter.js +30 -7
- package/dist/agent/registry.js +24 -7
- package/dist/agent/retry.js +54 -9
- package/dist/agent/runtime.d.ts +45 -1
- package/dist/agent/runtime.js +236 -23
- package/dist/chat.d.ts +3 -3
- package/dist/chat.js +137 -39
- package/dist/cli/auth.d.ts +9 -0
- package/dist/cli/auth.js +84 -0
- package/dist/cli/completion.d.ts +6 -0
- package/dist/cli/completion.js +66 -0
- package/dist/cli/config.d.ts +44 -0
- package/dist/cli/config.js +428 -0
- package/dist/cli/doctor.d.ts +8 -0
- package/dist/cli/doctor.js +136 -0
- package/dist/cli/errors.d.ts +5 -0
- package/dist/cli/errors.js +37 -0
- package/dist/cli/markdown.d.ts +9 -0
- package/dist/cli/markdown.js +77 -0
- package/dist/cli/repl.js +113 -5
- package/dist/cli/run.d.ts +9 -0
- package/dist/cli/run.js +97 -2
- package/dist/cli/slash/parser.d.ts +10 -0
- package/dist/cli/slash/parser.js +6 -1
- package/dist/cli/trace.d.ts +7 -0
- package/dist/cli/trace.js +63 -0
- package/dist/cli/update.d.ts +6 -0
- package/dist/cli/update.js +70 -0
- package/dist/context/system-prompt.d.ts +14 -0
- package/dist/context/system-prompt.js +47 -0
- package/dist/events/bus.d.ts +16 -0
- package/dist/events/bus.js +28 -0
- package/dist/events/catalog.d.ts +136 -0
- package/dist/events/catalog.js +5 -0
- package/dist/index.js +335 -22
- package/dist/persistence/session.d.ts +12 -0
- package/dist/persistence/session.js +45 -0
- package/dist/policy/engine.d.ts +11 -1
- package/dist/policy/engine.js +85 -2
- package/dist/providers/model-info.d.ts +18 -0
- package/dist/providers/model-info.js +18 -0
- package/dist/providers.js +3 -2
- package/dist/renderers/json.d.ts +11 -0
- package/dist/renderers/json.js +14 -0
- package/dist/renderers/terminal.d.ts +9 -0
- package/dist/renderers/terminal.js +41 -0
- package/dist/repl.js +52 -15
- package/dist/shared/errors.d.ts +18 -0
- package/dist/shared/errors.js +33 -0
- package/dist/shared/index.d.ts +2 -0
- package/dist/shared/index.js +2 -0
- package/dist/shared/types.d.ts +19 -0
- package/dist/shared/types.js +11 -0
- package/dist/tools/fs/read-file.d.ts +1 -31
- package/dist/tools/fs/read-file.js +45 -17
- package/dist/tools/fs/read-history.d.ts +3 -0
- package/dist/tools/fs/read-history.js +13 -0
- package/dist/tools/fs/write-file.d.ts +1 -4
- package/dist/tools/fs/write-file.js +35 -1
- package/dist/tools/normalize.d.ts +1 -1
- package/dist/tools/shell/shell-exec.d.ts +1 -26
- package/dist/tools/shell/shell-exec.js +121 -17
- package/dist/tools/types.d.ts +25 -0
- package/dist/trace/writer.d.ts +13 -0
- package/dist/trace/writer.js +53 -0
- package/dist/tui/app.d.ts +1 -15
- package/dist/tui/app.js +203 -22
- package/dist/tui/approval.js +25 -4
- package/dist/tui/approval.test.js +1 -1
- package/dist/tui/snapshot.test.js +2 -2
- package/dist/tui/status.js +5 -1
- package/dist/util/log.d.ts +12 -0
- package/dist/util/log.js +75 -0
- package/dist/verification/auto.d.ts +8 -0
- package/dist/verification/auto.js +52 -0
- 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
|
|
29
|
-
const
|
|
30
|
-
const pkg = JSON.parse(
|
|
31
|
-
|
|
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
|
-
|
|
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
|
|
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',
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/policy/engine.d.ts
CHANGED
|
@@ -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;
|
package/dist/policy/engine.js
CHANGED
|
@@ -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;
|