klyro 1.0.0 → 1.0.2
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 +49 -5
- package/dist/agent/anthropic-adapter.js +86 -17
- package/dist/agent/capabilities.d.ts +23 -0
- package/dist/agent/capabilities.js +53 -6
- package/dist/agent/child-worker.d.ts +104 -0
- package/dist/agent/child-worker.js +250 -0
- package/dist/agent/orchestrator.d.ts +124 -6
- package/dist/agent/orchestrator.js +425 -58
- package/dist/agent/provider-adapter.d.ts +8 -0
- package/dist/agent/provider-adapter.js +12 -3
- package/dist/agent/retry.d.ts +1 -1
- package/dist/agent/retry.js +54 -12
- package/dist/agent/runtime.d.ts +84 -8
- package/dist/agent/runtime.js +352 -39
- package/dist/agent/stream-budget.d.ts +36 -0
- package/dist/agent/stream-budget.js +121 -0
- package/dist/agent/worktree-manager.d.ts +74 -0
- package/dist/agent/worktree-manager.js +189 -0
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +56 -5
- package/dist/cli/auth.js +16 -1
- package/dist/cli/commit.d.ts +31 -0
- package/dist/cli/commit.js +142 -0
- package/dist/cli/config.d.ts +54 -3
- package/dist/cli/config.js +146 -3
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +71 -6
- package/dist/cli/eval.d.ts +6 -1
- package/dist/cli/eval.js +9 -0
- package/dist/cli/hooks.d.ts +47 -0
- package/dist/cli/hooks.js +181 -0
- package/dist/cli/repl.js +196 -29
- package/dist/cli/run.d.ts +13 -11
- package/dist/cli/run.js +144 -20
- package/dist/cli/update.d.ts +5 -0
- package/dist/cli/update.js +62 -10
- package/dist/context/import-graph.d.ts +2 -0
- package/dist/context/import-graph.js +31 -3
- package/dist/context/klyro-md.js +4 -1
- package/dist/context/memory.d.ts +8 -0
- package/dist/context/memory.js +50 -2
- package/dist/context/project-map.d.ts +6 -0
- package/dist/context/project-map.js +50 -2
- package/dist/context/repo-map.d.ts +2 -0
- package/dist/context/repo-map.js +31 -1
- package/dist/events/catalog.d.ts +37 -0
- package/dist/events/catalog.js +9 -0
- package/dist/index.js +177 -8
- package/dist/mcp/client.d.ts +6 -4
- package/dist/mcp/client.js +83 -14
- package/dist/mcp/config.d.ts +10 -0
- package/dist/mcp/config.js +18 -1
- package/dist/mcp/registry.d.ts +23 -19
- package/dist/mcp/registry.js +127 -8
- package/dist/mcp/schema.d.ts +11 -4
- package/dist/mcp/schema.js +27 -16
- package/dist/mcp/trust.d.ts +20 -0
- package/dist/mcp/trust.js +74 -0
- package/dist/persistence/audit.d.ts +28 -0
- package/dist/persistence/audit.js +101 -1
- package/dist/persistence/store.d.ts +26 -2
- package/dist/persistence/store.js +140 -13
- package/dist/policy/approval.d.ts +14 -0
- package/dist/policy/approval.js +44 -2
- package/dist/policy/engine.d.ts +17 -0
- package/dist/policy/engine.js +162 -9
- package/dist/policy/path-guard.d.ts +24 -0
- package/dist/policy/path-guard.js +46 -0
- package/dist/policy/secret-redactor.js +4 -0
- package/dist/providers/model-info.d.ts +23 -0
- package/dist/providers/model-info.js +43 -2
- package/dist/repl.d.ts +6 -0
- package/dist/repl.js +12 -7
- package/dist/tools/agent/spawn-agent.js +5 -5
- package/dist/tools/agent/task-apply.d.ts +4 -0
- package/dist/tools/agent/task-apply.js +44 -0
- package/dist/tools/agent/task-stop.d.ts +6 -0
- package/dist/tools/agent/task-stop.js +39 -0
- package/dist/tools/agent/task-wait.d.ts +17 -0
- package/dist/tools/agent/task-wait.js +79 -0
- package/dist/tools/fs/apply-patch.js +77 -1
- package/dist/tools/fs/edit-file.js +69 -1
- package/dist/tools/fs/multi-edit.d.ts +4 -0
- package/dist/tools/fs/multi-edit.js +70 -1
- package/dist/tools/fs/write-file.js +83 -6
- package/dist/tools/plan/todo-write.js +1 -1
- package/dist/tools/registry.js +6 -0
- package/dist/tools/shell/background.js +6 -3
- package/dist/tools/shell/sandbox.d.ts +51 -0
- package/dist/tools/shell/sandbox.js +143 -0
- package/dist/tools/shell/shell-exec.d.ts +29 -0
- package/dist/tools/shell/shell-exec.js +170 -12
- package/dist/tools/shell/worker-entry.d.ts +12 -0
- package/dist/tools/shell/worker-entry.js +43 -0
- package/dist/tools/types.d.ts +6 -0
- package/dist/tools/verify/run-verify.js +3 -1
- package/dist/trace/writer.d.ts +20 -0
- package/dist/trace/writer.js +62 -4
- package/dist/tui/app.js +1 -1
- package/dist/tui/approval.js +20 -21
- package/dist/util.d.ts +1 -0
- package/dist/util.js +1 -0
- package/dist/verification/baseline.js +17 -3
- package/dist/verification/classify.js +27 -15
- package/dist/verification/engine.d.ts +8 -0
- package/dist/verification/engine.js +28 -1
- package/dist/verification/registry.d.ts +2 -0
- package/dist/verification/registry.js +44 -0
- package/dist/verification/scoped.js +64 -11
- package/package.json +1 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `klyro commit` — conventional commit of already-staged changes.
|
|
3
|
+
*
|
|
4
|
+
* Steps:
|
|
5
|
+
* (a) `git status --porcelain` must show staged entries (index column set).
|
|
6
|
+
* Nothing staged → error 'nothing staged (git add first)', exit 2.
|
|
7
|
+
* (`--yes` never bypasses this — there is nothing to commit.)
|
|
8
|
+
* (b) Secret-scan the staged diff via `redact()`: if redaction shrinks or
|
|
9
|
+
* alters the diff, refuse and list the files (exit 2) unless
|
|
10
|
+
* `--force-secret` is passed.
|
|
11
|
+
* (c) Build a conventional message: type heuristic + top-dir scope +
|
|
12
|
+
* `--message` summary (or `update <n> files`).
|
|
13
|
+
* (d) `git commit -m` via execFileSync (no shell). Verification hooks always
|
|
14
|
+
* run — by construction this file contains no flag that skips them.
|
|
15
|
+
* (e) `--dry-run` prints the message + files and exits 0 without committing.
|
|
16
|
+
*/
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import { redact } from '../policy/secret-redactor.js';
|
|
19
|
+
function git(cwd, args) {
|
|
20
|
+
return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).toString();
|
|
21
|
+
}
|
|
22
|
+
/** Porcelain XY: staged iff the index (first) column is set. Handles renames. */
|
|
23
|
+
export function stagedFilesFromPorcelain(porcelain) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const line of porcelain.split('\n')) {
|
|
26
|
+
if (line.length < 4)
|
|
27
|
+
continue;
|
|
28
|
+
const indexStatus = line[0];
|
|
29
|
+
if (indexStatus === ' ' || indexStatus === '?' || indexStatus === '!')
|
|
30
|
+
continue;
|
|
31
|
+
let file = line.slice(3).trim();
|
|
32
|
+
// Rename/copy form: "old -> new" — the new path is what's committed.
|
|
33
|
+
const arrow = file.indexOf(' -> ');
|
|
34
|
+
if (arrow !== -1)
|
|
35
|
+
file = file.slice(arrow + 4);
|
|
36
|
+
// Quoted paths from core.quotePath — strip surrounding quotes.
|
|
37
|
+
if (file.startsWith('"') && file.endsWith('"'))
|
|
38
|
+
file = file.slice(1, -1);
|
|
39
|
+
if (file)
|
|
40
|
+
out.push(file);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
const LOCKFILES = new Set([
|
|
45
|
+
'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'Cargo.lock',
|
|
46
|
+
'Gemfile.lock', 'poetry.lock', 'composer.lock',
|
|
47
|
+
]);
|
|
48
|
+
function isTestPath(f) {
|
|
49
|
+
const l = f.toLowerCase();
|
|
50
|
+
return (l.includes('.test.') || l.includes('.spec.') ||
|
|
51
|
+
l.includes('__tests__') || /(^|\/)tests?\//.test(l) || /(^|\/)spec\//.test(l));
|
|
52
|
+
}
|
|
53
|
+
function isDocsPath(f) {
|
|
54
|
+
const l = f.toLowerCase();
|
|
55
|
+
return l.endsWith('.md') || l.startsWith('docs/') || l.includes('/docs/');
|
|
56
|
+
}
|
|
57
|
+
/** Type heuristic: test-only→test, docs-only→docs, lockfiles→chore, else feat. */
|
|
58
|
+
export function commitTypeFor(files) {
|
|
59
|
+
if (files.length > 0 && files.every((f) => LOCKFILES.has(f.split('/').pop() ?? f)))
|
|
60
|
+
return 'chore';
|
|
61
|
+
if (files.length > 0 && files.every(isTestPath))
|
|
62
|
+
return 'test';
|
|
63
|
+
if (files.length > 0 && files.every(isDocsPath))
|
|
64
|
+
return 'docs';
|
|
65
|
+
return 'feat';
|
|
66
|
+
}
|
|
67
|
+
/** Scope = most common top-level dir among staged files with a dir; '' if none. */
|
|
68
|
+
export function commitScopeFor(files) {
|
|
69
|
+
const counts = new Map();
|
|
70
|
+
for (const f of files) {
|
|
71
|
+
const slash = f.indexOf('/');
|
|
72
|
+
if (slash > 0) {
|
|
73
|
+
const top = f.slice(0, slash);
|
|
74
|
+
counts.set(top, (counts.get(top) ?? 0) + 1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
let best = '';
|
|
78
|
+
let bestN = 0;
|
|
79
|
+
for (const [dir, n] of counts) {
|
|
80
|
+
if (n > bestN) {
|
|
81
|
+
best = dir;
|
|
82
|
+
bestN = n;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return best;
|
|
86
|
+
}
|
|
87
|
+
export function buildCommitMessage(files, summaryOpt) {
|
|
88
|
+
const type = commitTypeFor(files);
|
|
89
|
+
const scope = commitScopeFor(files);
|
|
90
|
+
const summary = (summaryOpt ?? '').trim() || `update ${files.length} file${files.length === 1 ? '' : 's'}`;
|
|
91
|
+
return scope ? `${type}(${scope}): ${summary}` : `${type}: ${summary}`;
|
|
92
|
+
}
|
|
93
|
+
export async function runCommit(opts) {
|
|
94
|
+
void opts.yes; // accepted for uniformity; never bypasses the staged check
|
|
95
|
+
const cwd = opts.cwd;
|
|
96
|
+
let porcelain;
|
|
97
|
+
try {
|
|
98
|
+
porcelain = git(cwd, ['status', '--porcelain']);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
process.stderr.write(`klyro: commit: not a git repo or git unavailable (${err instanceof Error ? err.message.split('\n')[0] : String(err)})\n`);
|
|
102
|
+
return 2;
|
|
103
|
+
}
|
|
104
|
+
const staged = stagedFilesFromPorcelain(porcelain);
|
|
105
|
+
if (staged.length === 0) {
|
|
106
|
+
process.stderr.write('klyro: commit: nothing staged (git add first)\n');
|
|
107
|
+
return 2;
|
|
108
|
+
}
|
|
109
|
+
let namesOnly;
|
|
110
|
+
try {
|
|
111
|
+
namesOnly = git(cwd, ['diff', '--cached', '--name-only']).split('\n').map((s) => s.trim()).filter(Boolean);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
namesOnly = staged;
|
|
115
|
+
}
|
|
116
|
+
const files = namesOnly.length > 0 ? namesOnly : staged;
|
|
117
|
+
// (b) secret scan of the staged diff (read-only — redact() never writes).
|
|
118
|
+
try {
|
|
119
|
+
const diff = git(cwd, ['diff', '--cached', '--']);
|
|
120
|
+
if (redact(diff) !== diff && !opts.forceSecret) {
|
|
121
|
+
process.stderr.write(`klyro: commit: refused — possible secret in staged diff (files: ${files.join(', ')}). Review with \`git diff --cached\`, unstage the secret, or re-run with --force-secret.\n`);
|
|
122
|
+
return 2;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Could not read the diff — proceed; the commit itself is authoritative.
|
|
127
|
+
}
|
|
128
|
+
const message = buildCommitMessage(files, opts.message);
|
|
129
|
+
if (opts.dryRun) {
|
|
130
|
+
process.stdout.write(`${message}\nfiles:\n${files.map((f) => ` ${f}`).join('\n')}\n`);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
// NOTE: verification hooks must run — no skip flag is ever passed here.
|
|
135
|
+
execFileSync('git', ['commit', '-m', message], { cwd, stdio: 'inherit' });
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
process.stderr.write('klyro: commit: `git commit` failed (see output above)\n');
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
package/dist/cli/config.d.ts
CHANGED
|
@@ -17,8 +17,53 @@ export declare const ConfigSchema: z.ZodObject<{
|
|
|
17
17
|
api_key: z.ZodOptional<z.ZodString>;
|
|
18
18
|
'model.default': z.ZodOptional<z.ZodString>;
|
|
19
19
|
'model.small': z.ZodOptional<z.ZodString>;
|
|
20
|
+
providers: z.ZodOptional<z.ZodObject<{
|
|
21
|
+
failover: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
22
|
+
provider: z.ZodEnum<{
|
|
23
|
+
openai: "openai";
|
|
24
|
+
anthropic: "anthropic";
|
|
25
|
+
}>;
|
|
26
|
+
baseURL: z.ZodOptional<z.ZodString>;
|
|
27
|
+
apiKey: z.ZodOptional<z.ZodString>;
|
|
28
|
+
apiKeyEnv: z.ZodOptional<z.ZodString>;
|
|
29
|
+
}, z.core.$strip>>>;
|
|
30
|
+
}, z.core.$strip>>;
|
|
20
31
|
}, z.core.$loose>;
|
|
21
32
|
export type KlyroConfig = z.infer<typeof ConfigSchema>;
|
|
33
|
+
export declare const FailoverEntrySchema: z.ZodObject<{
|
|
34
|
+
provider: z.ZodEnum<{
|
|
35
|
+
openai: "openai";
|
|
36
|
+
anthropic: "anthropic";
|
|
37
|
+
}>;
|
|
38
|
+
baseURL: z.ZodOptional<z.ZodString>;
|
|
39
|
+
apiKey: z.ZodOptional<z.ZodString>;
|
|
40
|
+
apiKeyEnv: z.ZodOptional<z.ZodString>;
|
|
41
|
+
}, z.core.$strip>;
|
|
42
|
+
export type FailoverEntry = z.infer<typeof FailoverEntrySchema>;
|
|
43
|
+
export declare const FailoverConfigSchema: z.ZodObject<{
|
|
44
|
+
failover: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
45
|
+
provider: z.ZodEnum<{
|
|
46
|
+
openai: "openai";
|
|
47
|
+
anthropic: "anthropic";
|
|
48
|
+
}>;
|
|
49
|
+
baseURL: z.ZodOptional<z.ZodString>;
|
|
50
|
+
apiKey: z.ZodOptional<z.ZodString>;
|
|
51
|
+
apiKeyEnv: z.ZodOptional<z.ZodString>;
|
|
52
|
+
}, z.core.$strip>>>;
|
|
53
|
+
}, z.core.$strip>;
|
|
54
|
+
export interface ResolvedProviderEntry {
|
|
55
|
+
provider: 'openai' | 'anthropic';
|
|
56
|
+
baseURL?: string;
|
|
57
|
+
apiKey?: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Build the ordered provider chain: primary (existing precedence:
|
|
61
|
+
* flags > env > merged config > defaults) followed by `providers.failover`
|
|
62
|
+
* entries. `apiKeyEnv` names an env var resolved at build time; entries
|
|
63
|
+
* whose key is missing/empty are skipped (with a stderr reason) so a
|
|
64
|
+
* half-configured fallback can never become the active provider.
|
|
65
|
+
*/
|
|
66
|
+
export declare function resolveProviderChain(cwd?: string, flags?: Record<string, unknown>): Promise<ResolvedProviderEntry[]>;
|
|
22
67
|
export declare function getConfigDir(): string;
|
|
23
68
|
export declare function getConfigPath(): string;
|
|
24
69
|
export declare function getConfigSearchPaths(cwd?: string): Array<{
|
|
@@ -46,12 +91,18 @@ export interface PermissionRules {
|
|
|
46
91
|
* without re-prompting.
|
|
47
92
|
*/
|
|
48
93
|
export declare function loadPermissionRules(cwd?: string): Promise<PermissionRules>;
|
|
94
|
+
export declare function isValidAllowRule(rule: string): boolean;
|
|
49
95
|
/**
|
|
50
|
-
* Persist an "always allow" pattern
|
|
51
|
-
* (
|
|
96
|
+
* Persist an "always allow" pattern. Validates the rule against the strict
|
|
97
|
+
* grammar (throws `invalid rule` otherwise). When `cwd` is a project dir
|
|
98
|
+
* (exists + contains .git or .klyro), writes to <cwd>/.klyro/settings.json;
|
|
99
|
+
* otherwise writes to the home settings file. Returns whether it was
|
|
52
100
|
* added (false when already present) and the file written.
|
|
101
|
+
*
|
|
102
|
+
* NOTE for the repl integrator (sibling-owned repl.ts): pass the session
|
|
103
|
+
* cwd as the second arg to persist project-scoped allows.
|
|
53
104
|
*/
|
|
54
|
-
export declare function persistAllowRule(rule: string): Promise<{
|
|
105
|
+
export declare function persistAllowRule(rule: string, cwd?: string): Promise<{
|
|
55
106
|
added: boolean;
|
|
56
107
|
path: string;
|
|
57
108
|
}>;
|
package/dist/cli/config.js
CHANGED
|
@@ -24,8 +24,90 @@ export const ConfigSchema = z
|
|
|
24
24
|
// Global flags persisted
|
|
25
25
|
'model.default': z.string().optional(),
|
|
26
26
|
'model.small': z.string().optional(),
|
|
27
|
+
// Provider failover chain (L15): ordered fallbacks after the primary.
|
|
28
|
+
// Optional so existing configs keep validating; invalid entries exit 3.
|
|
29
|
+
providers: z
|
|
30
|
+
.object({
|
|
31
|
+
failover: z
|
|
32
|
+
.array(z.object({
|
|
33
|
+
provider: z.enum(['openai', 'anthropic']),
|
|
34
|
+
baseURL: z.string().optional(),
|
|
35
|
+
apiKey: z.string().optional(),
|
|
36
|
+
apiKeyEnv: z.string().optional(),
|
|
37
|
+
}))
|
|
38
|
+
.optional(),
|
|
39
|
+
})
|
|
40
|
+
.optional(),
|
|
27
41
|
})
|
|
28
42
|
.passthrough();
|
|
43
|
+
// --- Provider failover chain (L15 differentiator) ---
|
|
44
|
+
// `providers.failover` is an ordered list of fallback providers. Entries
|
|
45
|
+
// with unresolvable/missing API keys are skipped at chain-build time.
|
|
46
|
+
export const FailoverEntrySchema = z.object({
|
|
47
|
+
provider: z.enum(['openai', 'anthropic']),
|
|
48
|
+
baseURL: z.string().optional(),
|
|
49
|
+
apiKey: z.string().optional(),
|
|
50
|
+
apiKeyEnv: z.string().optional(),
|
|
51
|
+
});
|
|
52
|
+
export const FailoverConfigSchema = z.object({
|
|
53
|
+
failover: z.array(FailoverEntrySchema).optional(),
|
|
54
|
+
});
|
|
55
|
+
/**
|
|
56
|
+
* Build the ordered provider chain: primary (existing precedence:
|
|
57
|
+
* flags > env > merged config > defaults) followed by `providers.failover`
|
|
58
|
+
* entries. `apiKeyEnv` names an env var resolved at build time; entries
|
|
59
|
+
* whose key is missing/empty are skipped (with a stderr reason) so a
|
|
60
|
+
* half-configured fallback can never become the active provider.
|
|
61
|
+
*/
|
|
62
|
+
export async function resolveProviderChain(cwd = process.cwd(), flags = {}) {
|
|
63
|
+
const merged = await loadMergedConfig(cwd, flags);
|
|
64
|
+
const chain = [];
|
|
65
|
+
const skipped = [];
|
|
66
|
+
// Primary — mirror the precedence resolveProvider uses: explicit flags,
|
|
67
|
+
// then KLYRO_* env, then merged config.
|
|
68
|
+
const flagProvider = typeof flags['provider'] === 'string' ? flags['provider'] : undefined;
|
|
69
|
+
const flagBaseUrl = typeof flags['baseUrl'] === 'string' ? flags['baseUrl'] : typeof flags['baseURL'] === 'string' ? flags['baseURL'] : undefined;
|
|
70
|
+
const flagApiKey = typeof flags['apiKey'] === 'string' ? flags['apiKey'] : undefined;
|
|
71
|
+
const primaryProvider = (flagProvider ?? merged.provider ?? process.env.KLYRO_PROVIDER ?? 'openai');
|
|
72
|
+
const primaryBaseURL = flagBaseUrl ?? merged.baseUrl ?? merged.baseURL ?? process.env.KLYRO_BASE_URL;
|
|
73
|
+
const primaryApiKey = flagApiKey ?? merged.apiKey ?? merged.api_key ?? process.env.KLYRO_API_KEY;
|
|
74
|
+
if (primaryProvider === 'openai' || primaryProvider === 'anthropic') {
|
|
75
|
+
chain.push({
|
|
76
|
+
provider: primaryProvider,
|
|
77
|
+
...(primaryBaseURL ? { baseURL: primaryBaseURL } : {}),
|
|
78
|
+
...(primaryApiKey ? { apiKey: primaryApiKey } : {}),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// Failover list — validated leniently (invalid entries skipped, never fatal).
|
|
82
|
+
const providersRaw = merged.providers;
|
|
83
|
+
const parsed = FailoverConfigSchema.safeParse(providersRaw ?? {});
|
|
84
|
+
if (parsed.success && parsed.data.failover) {
|
|
85
|
+
for (const entry of parsed.data.failover) {
|
|
86
|
+
let key = entry.apiKey;
|
|
87
|
+
if (!key && entry.apiKeyEnv) {
|
|
88
|
+
const v = process.env[entry.apiKeyEnv];
|
|
89
|
+
if (v && v.length > 0)
|
|
90
|
+
key = v;
|
|
91
|
+
}
|
|
92
|
+
if (!key) {
|
|
93
|
+
skipped.push(`failover ${entry.provider}${entry.apiKeyEnv ? ` (env ${entry.apiKeyEnv} unset)` : ' (no apiKey)'}`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
chain.push({
|
|
97
|
+
provider: entry.provider,
|
|
98
|
+
...(entry.baseURL ? { baseURL: entry.baseURL } : {}),
|
|
99
|
+
apiKey: key,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const reason of skipped) {
|
|
104
|
+
try {
|
|
105
|
+
process.stderr.write(`klyro: skipping failover entry — ${reason}\n`);
|
|
106
|
+
}
|
|
107
|
+
catch { /* ignore */ }
|
|
108
|
+
}
|
|
109
|
+
return chain;
|
|
110
|
+
}
|
|
29
111
|
// --- Paths ---
|
|
30
112
|
export function getConfigDir() {
|
|
31
113
|
if (process.env.KLYRO_CONFIG_DIR)
|
|
@@ -338,11 +420,72 @@ export async function loadPermissionRules(cwd = process.cwd()) {
|
|
|
338
420
|
};
|
|
339
421
|
}
|
|
340
422
|
/**
|
|
341
|
-
*
|
|
342
|
-
*
|
|
423
|
+
* Strict allow-rule grammar: `tool` or `tool(glob)` where tool is
|
|
424
|
+
* lowercase alnum+underscore and glob contains no parens.
|
|
425
|
+
*/
|
|
426
|
+
const ALLOW_RULE_RE = /^[a-z0-9_]+\([^()]*\)$|^[a-z0-9_]+$/;
|
|
427
|
+
export function isValidAllowRule(rule) {
|
|
428
|
+
return ALLOW_RULE_RE.test(rule.trim());
|
|
429
|
+
}
|
|
430
|
+
function isProjectDir(cwd) {
|
|
431
|
+
try {
|
|
432
|
+
const st = fsSync.statSync(cwd);
|
|
433
|
+
if (!st.isDirectory())
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
return fsSync.existsSync(path.join(cwd, '.git')) || fsSync.existsSync(path.join(cwd, '.klyro'));
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Persist an "always allow" pattern. Validates the rule against the strict
|
|
443
|
+
* grammar (throws `invalid rule` otherwise). When `cwd` is a project dir
|
|
444
|
+
* (exists + contains .git or .klyro), writes to <cwd>/.klyro/settings.json;
|
|
445
|
+
* otherwise writes to the home settings file. Returns whether it was
|
|
343
446
|
* added (false when already present) and the file written.
|
|
447
|
+
*
|
|
448
|
+
* NOTE for the repl integrator (sibling-owned repl.ts): pass the session
|
|
449
|
+
* cwd as the second arg to persist project-scoped allows.
|
|
344
450
|
*/
|
|
345
|
-
export async function persistAllowRule(rule) {
|
|
451
|
+
export async function persistAllowRule(rule, cwd) {
|
|
452
|
+
if (!isValidAllowRule(rule)) {
|
|
453
|
+
throw new Error(`invalid rule: ${rule}`);
|
|
454
|
+
}
|
|
455
|
+
// Project-scoped persist when cwd is a project dir.
|
|
456
|
+
if (cwd && isProjectDir(cwd)) {
|
|
457
|
+
const projPath = path.join(cwd, '.klyro', 'settings.json');
|
|
458
|
+
let cfg = {};
|
|
459
|
+
try {
|
|
460
|
+
const raw = await fs.readFile(projPath, 'utf-8');
|
|
461
|
+
cfg = validateConfig(parseJsonc(raw, projPath), projPath);
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
const e = err;
|
|
465
|
+
if (e.code !== 'ENOENT')
|
|
466
|
+
throw err;
|
|
467
|
+
}
|
|
468
|
+
const allow = asStringArray(cfg.allow);
|
|
469
|
+
if (allow.includes(rule))
|
|
470
|
+
return { added: false, path: projPath };
|
|
471
|
+
cfg.allow = [...allow, rule];
|
|
472
|
+
await fs.mkdir(path.dirname(projPath), { recursive: true });
|
|
473
|
+
const tmp = `${projPath}.tmp-${process.pid}-${Date.now()}`;
|
|
474
|
+
const data = JSON.stringify(validateConfig(cfg, projPath), null, 2) + '\n';
|
|
475
|
+
await fs.writeFile(tmp, data, { mode: 0o600 });
|
|
476
|
+
try {
|
|
477
|
+
await fs.rename(tmp, projPath);
|
|
478
|
+
}
|
|
479
|
+
catch (err) {
|
|
480
|
+
await fs.unlink(tmp).catch(() => undefined);
|
|
481
|
+
throw err;
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
await fs.chmod(projPath, 0o600);
|
|
485
|
+
}
|
|
486
|
+
catch { /* ignore on Windows */ }
|
|
487
|
+
return { added: true, path: projPath };
|
|
488
|
+
}
|
|
346
489
|
const cfg = await loadConfig();
|
|
347
490
|
const allow = asStringArray(cfg.allow);
|
|
348
491
|
if (allow.includes(rule))
|
package/dist/cli/doctor.d.ts
CHANGED
package/dist/cli/doctor.js
CHANGED
|
@@ -10,7 +10,6 @@ import { spawn } from 'node:child_process';
|
|
|
10
10
|
import { getConfigPath, loadConfig } from './config.js';
|
|
11
11
|
import { resolveProvider, providerHelp } from '../providers.js';
|
|
12
12
|
import { getDefaultSessionsDir } from '../persistence/session.js';
|
|
13
|
-
import { builtinRegistry } from '../tools/registry.js';
|
|
14
13
|
function checkNode() {
|
|
15
14
|
const v = process.versions.node;
|
|
16
15
|
const major = Number(v.split('.')[0] ?? 0);
|
|
@@ -94,26 +93,92 @@ function checkGit() {
|
|
|
94
93
|
});
|
|
95
94
|
});
|
|
96
95
|
}
|
|
97
|
-
function checkTools() {
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
async function checkTools() {
|
|
97
|
+
try {
|
|
98
|
+
const { builtinRegistry } = await import('../tools/registry.js');
|
|
99
|
+
const reg = builtinRegistry();
|
|
100
|
+
return { name: 'Tools', ok: true, detail: `${reg.list().length} tools: ${reg.list().map((t) => t.name).join(', ')}` };
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
104
|
+
return { name: 'Tools', ok: false, detail: `registry unavailable: ${msg}` };
|
|
105
|
+
}
|
|
100
106
|
}
|
|
101
107
|
function checkPlatform() {
|
|
102
108
|
const ok = ['win32', 'linux', 'darwin'].includes(process.platform);
|
|
103
109
|
return { name: 'Platform', ok, detail: `${process.platform} ${process.arch} ${ok ? '✓' : '✗ unsupported'}` };
|
|
104
110
|
}
|
|
111
|
+
async function checkMcp(cwd) {
|
|
112
|
+
try {
|
|
113
|
+
const { loadMcpServers } = await import('../mcp/config.js');
|
|
114
|
+
const cfg = loadMcpServers(cwd);
|
|
115
|
+
const names = Object.keys(cfg.servers);
|
|
116
|
+
if (names.length === 0)
|
|
117
|
+
return { name: 'MCP servers', ok: true, detail: 'none' };
|
|
118
|
+
let project = 0;
|
|
119
|
+
for (const n of names) {
|
|
120
|
+
if (cfg.sources[n] === 'project')
|
|
121
|
+
project++;
|
|
122
|
+
}
|
|
123
|
+
return { name: 'MCP servers', ok: true, detail: `${names.length} servers (global ${names.length - project}/project ${project})` };
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return { name: 'MCP servers', ok: true, detail: 'none' };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function countTrustEntries(p) {
|
|
130
|
+
try {
|
|
131
|
+
const raw = fsSync.readFileSync(p, 'utf-8');
|
|
132
|
+
const parsed = JSON.parse(raw);
|
|
133
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
134
|
+
return Object.keys(parsed).length;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function checkTrust() {
|
|
143
|
+
try {
|
|
144
|
+
const [{ defaultTrustStorePath }] = [await import('../context/trust.js')];
|
|
145
|
+
const [{ defaultMcpTrustStorePath }] = [await import('../mcp/trust.js')];
|
|
146
|
+
const ctx = countTrustEntries(defaultTrustStorePath());
|
|
147
|
+
const mcp = countTrustEntries(defaultMcpTrustStorePath());
|
|
148
|
+
if (ctx === null && mcp === null)
|
|
149
|
+
return { name: 'Trust stores', ok: true, detail: 'none' };
|
|
150
|
+
return {
|
|
151
|
+
name: 'Trust stores',
|
|
152
|
+
ok: true,
|
|
153
|
+
detail: `context-trust ${ctx ?? 0} entries, mcp-trust ${mcp ?? 0} entries`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return { name: 'Trust stores', ok: true, detail: 'none' };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
105
160
|
export async function runDoctor(opts = {}) {
|
|
161
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
106
162
|
const checks = [];
|
|
107
163
|
checks.push(checkNode());
|
|
108
164
|
checks.push(await checkConfig());
|
|
109
165
|
checks.push(await checkProvider());
|
|
110
166
|
checks.push(await checkSessions());
|
|
111
167
|
checks.push(await checkGit());
|
|
112
|
-
checks.push(checkTools());
|
|
168
|
+
checks.push(await checkTools());
|
|
113
169
|
checks.push(checkPlatform());
|
|
170
|
+
const mcpCheck = await checkMcp(cwd);
|
|
171
|
+
const trustCheck = await checkTrust();
|
|
172
|
+
checks.push(mcpCheck);
|
|
173
|
+
checks.push(trustCheck);
|
|
114
174
|
const allOk = checks.every((c) => c.ok);
|
|
115
175
|
if (opts.json) {
|
|
116
|
-
process.stdout.write(JSON.stringify({
|
|
176
|
+
process.stdout.write(JSON.stringify({
|
|
177
|
+
ok: allOk,
|
|
178
|
+
checks,
|
|
179
|
+
mcp: { ok: mcpCheck.ok, detail: mcpCheck.detail },
|
|
180
|
+
trust: { ok: trustCheck.ok, detail: trustCheck.detail },
|
|
181
|
+
}, null, 2) + '\n');
|
|
117
182
|
return allOk ? 0 : 1;
|
|
118
183
|
}
|
|
119
184
|
process.stdout.write('\nklyro doctor\n');
|
package/dist/cli/eval.d.ts
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
* is set, emits one JSON object per line. Exit code 0 if all pass, 1
|
|
40
40
|
* otherwise.
|
|
41
41
|
*/
|
|
42
|
-
import { type RunResult } from '../agent/runtime.js';
|
|
42
|
+
import { type RunResult, type VerifyMode } from '../agent/runtime.js';
|
|
43
43
|
import type { ProviderAdapter } from '../agent/provider-adapter.js';
|
|
44
44
|
export interface EvalScenario {
|
|
45
45
|
name: string;
|
|
@@ -47,6 +47,11 @@ export interface EvalScenario {
|
|
|
47
47
|
model?: string;
|
|
48
48
|
maxSteps?: number;
|
|
49
49
|
maxTokens?: number;
|
|
50
|
+
/** Verification passthrough (default unchanged: strict pipeline when omitted). */
|
|
51
|
+
verify?: {
|
|
52
|
+
mode?: VerifyMode;
|
|
53
|
+
command?: string;
|
|
54
|
+
};
|
|
50
55
|
/** Adapter events to script, as [eventKind, ...args] tuples. */
|
|
51
56
|
scripted_events?: Array<Array<unknown[]>>;
|
|
52
57
|
expect?: {
|
package/dist/cli/eval.js
CHANGED
|
@@ -236,6 +236,15 @@ export async function runScenario(sc) {
|
|
|
236
236
|
maxSteps: sc.maxSteps,
|
|
237
237
|
maxTokens: sc.maxTokens,
|
|
238
238
|
nonInteractive: true,
|
|
239
|
+
...(sc.verify
|
|
240
|
+
? {
|
|
241
|
+
verify: {
|
|
242
|
+
enabled: true,
|
|
243
|
+
...(sc.verify.command !== undefined ? { command: sc.verify.command } : {}),
|
|
244
|
+
...(sc.verify.mode !== undefined ? { mode: sc.verify.mode } : {}),
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
: {}),
|
|
239
248
|
}, {
|
|
240
249
|
adapter,
|
|
241
250
|
registry,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hooks engine (10.2) — preToolUse / postToolUse shell hooks.
|
|
3
|
+
*
|
|
4
|
+
* Config files:
|
|
5
|
+
* - project: `<cwd>/.klyro/hooks.json`
|
|
6
|
+
* - global: `~/.klyro/hooks.json` (merged; project wins on name clash)
|
|
7
|
+
*
|
|
8
|
+
* Schema: `{ hooks: Array<{ name, event, command, timeoutMs? }> }`.
|
|
9
|
+
*
|
|
10
|
+
* `loadHooks` never throws — a missing file is `[]`, an invalid file is
|
|
11
|
+
* `[]` plus a one-time stderr warning per path. `runHook` spawns the
|
|
12
|
+
* command with `shell: true` (commands are strings, must work on win +
|
|
13
|
+
* posix), a default 30s timeout, a filtered env, plus `KLYRO_TOOL_NAME`
|
|
14
|
+
* and `KLYRO_TOOL_INPUT_JSON` for the hook's inspection.
|
|
15
|
+
*/
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
export declare const HookSchema: z.ZodObject<{
|
|
18
|
+
name: z.ZodString;
|
|
19
|
+
event: z.ZodEnum<{
|
|
20
|
+
preToolUse: "preToolUse";
|
|
21
|
+
postToolUse: "postToolUse";
|
|
22
|
+
}>;
|
|
23
|
+
command: z.ZodString;
|
|
24
|
+
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
25
|
+
}, z.core.$strip>;
|
|
26
|
+
export type Hook = z.infer<typeof HookSchema>;
|
|
27
|
+
export interface HookResult {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
exitCode: number | null;
|
|
30
|
+
stdout: string;
|
|
31
|
+
stderr: string;
|
|
32
|
+
}
|
|
33
|
+
export declare const DEFAULT_HOOK_TIMEOUT_MS = 30000;
|
|
34
|
+
/**
|
|
35
|
+
* Load hooks for a run. Global first, then project — a project hook with
|
|
36
|
+
* the same `name` replaces the global one. Never throws.
|
|
37
|
+
*/
|
|
38
|
+
export declare function loadHooks(cwd: string): Hook[];
|
|
39
|
+
export interface HookContext {
|
|
40
|
+
toolName: string;
|
|
41
|
+
input: unknown;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Run one hook. Resolves (never rejects) with the exit code + sliced
|
|
45
|
+
* output. `ok` is true only when the process exited 0.
|
|
46
|
+
*/
|
|
47
|
+
export declare function runHook(hook: Hook, ctx: HookContext): Promise<HookResult>;
|