klyro 1.0.1 → 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 +13 -5
- package/dist/agent/anthropic-adapter.js +19 -2
- package/dist/agent/capabilities.js +7 -1
- package/dist/agent/orchestrator.d.ts +18 -1
- package/dist/agent/orchestrator.js +51 -4
- package/dist/agent/retry.js +52 -10
- package/dist/agent/runtime.d.ts +34 -0
- package/dist/agent/runtime.js +164 -14
- package/dist/agent/stream-budget.d.ts +36 -0
- package/dist/agent/stream-budget.js +121 -0
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +26 -0
- package/dist/cli/commit.d.ts +31 -0
- package/dist/cli/commit.js +142 -0
- package/dist/cli/config.d.ts +45 -0
- package/dist/cli/config.js +82 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +71 -6
- package/dist/cli/hooks.d.ts +47 -0
- package/dist/cli/hooks.js +181 -0
- package/dist/cli/repl.js +41 -1
- package/dist/cli/run.d.ts +6 -0
- package/dist/cli/run.js +76 -3
- package/dist/events/catalog.d.ts +9 -0
- package/dist/events/catalog.js +9 -0
- package/dist/index.js +89 -5
- package/dist/mcp/client.js +1 -1
- package/dist/mcp/registry.d.ts +0 -18
- package/dist/mcp/registry.js +49 -2
- package/dist/policy/engine.d.ts +16 -0
- package/dist/policy/engine.js +74 -1
- package/dist/policy/path-guard.d.ts +24 -0
- package/dist/policy/path-guard.js +46 -0
- package/dist/providers/model-info.d.ts +6 -0
- package/dist/providers/model-info.js +8 -0
- package/dist/tools/fs/apply-patch.js +6 -1
- package/dist/tools/fs/edit-file.js +4 -1
- package/dist/tools/fs/multi-edit.js +4 -1
- package/dist/tools/fs/write-file.js +16 -6
- package/dist/tools/plan/todo-write.js +1 -1
- package/dist/tools/shell/shell-exec.d.ts +28 -0
- package/dist/tools/shell/shell-exec.js +87 -1
- package/dist/trace/writer.d.ts +7 -0
- package/dist/trace/writer.js +7 -0
- package/dist/verification/classify.js +4 -3
- package/dist/verification/engine.d.ts +8 -0
- package/dist/verification/engine.js +25 -0
- package/dist/verification/registry.js +16 -5
- package/dist/verification/scoped.js +36 -5
- package/package.json +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate-limit scheduler for provider streams.
|
|
3
|
+
*
|
|
4
|
+
* A global in-flight counter + FIFO semaphore caps concurrent
|
|
5
|
+
* `ProviderAdapter.stream` calls at `MAX_CONCURRENT_STREAMS` (4). An
|
|
6
|
+
* adaptive cooldown collapses the cap to 1 while a recent 429 was
|
|
7
|
+
* observed (`noteRateLimited`), honouring an optional server-provided
|
|
8
|
+
* `Retry-After` delay, otherwise 60s.
|
|
9
|
+
*
|
|
10
|
+
* Consumed by `retryingAdapter` (see `./retry.js`), which acquires one
|
|
11
|
+
* slot around each `inner.stream` call and releases it in a `finally`.
|
|
12
|
+
* Aborted waiters are dequeued and rejected promptly.
|
|
13
|
+
*/
|
|
14
|
+
export declare const MAX_CONCURRENT_STREAMS = 4;
|
|
15
|
+
/** Fallback cooldown when no Retry-After delay was provided. */
|
|
16
|
+
export declare const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60000;
|
|
17
|
+
/**
|
|
18
|
+
* Record a 429 (or equivalent) rate-limit signal. Collapses the stream
|
|
19
|
+
* cap to 1 for `retryAfterMs` (or 60s when absent/invalid).
|
|
20
|
+
*/
|
|
21
|
+
export declare function noteRateLimited(retryAfterMs?: number): void;
|
|
22
|
+
/**
|
|
23
|
+
* Acquire a stream slot. Grants immediately when `inFlight` is under the
|
|
24
|
+
* effective cap, otherwise queues FIFO until a release (or the cooldown
|
|
25
|
+
* lifting) frees one. Abort-aware: an already-aborted signal rejects
|
|
26
|
+
* immediately; aborting while queued dequeues and rejects promptly.
|
|
27
|
+
*/
|
|
28
|
+
export declare function acquireStreamSlot(signal?: AbortSignal): Promise<() => void>;
|
|
29
|
+
/** Observable budget state (tests/diagnostics). */
|
|
30
|
+
export declare function streamBudgetState(): {
|
|
31
|
+
inFlight: number;
|
|
32
|
+
queued: number;
|
|
33
|
+
cap: number;
|
|
34
|
+
};
|
|
35
|
+
/** Reset the budget (counters, cooldown, queued waiters). Tests only. */
|
|
36
|
+
export declare function setStreamBudgetForTests(): void;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate-limit scheduler for provider streams.
|
|
3
|
+
*
|
|
4
|
+
* A global in-flight counter + FIFO semaphore caps concurrent
|
|
5
|
+
* `ProviderAdapter.stream` calls at `MAX_CONCURRENT_STREAMS` (4). An
|
|
6
|
+
* adaptive cooldown collapses the cap to 1 while a recent 429 was
|
|
7
|
+
* observed (`noteRateLimited`), honouring an optional server-provided
|
|
8
|
+
* `Retry-After` delay, otherwise 60s.
|
|
9
|
+
*
|
|
10
|
+
* Consumed by `retryingAdapter` (see `./retry.js`), which acquires one
|
|
11
|
+
* slot around each `inner.stream` call and releases it in a `finally`.
|
|
12
|
+
* Aborted waiters are dequeued and rejected promptly.
|
|
13
|
+
*/
|
|
14
|
+
export const MAX_CONCURRENT_STREAMS = 4;
|
|
15
|
+
/** Fallback cooldown when no Retry-After delay was provided. */
|
|
16
|
+
export const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60_000;
|
|
17
|
+
let inFlight = 0;
|
|
18
|
+
/** `Date.now()` timestamp until which the cap stays collapsed at 1. */
|
|
19
|
+
let recent429Until = 0;
|
|
20
|
+
const queue = [];
|
|
21
|
+
function abortError() {
|
|
22
|
+
const err = new Error('stream slot acquisition aborted');
|
|
23
|
+
err.name = 'AbortError';
|
|
24
|
+
return err;
|
|
25
|
+
}
|
|
26
|
+
function effectiveCap(now = Date.now()) {
|
|
27
|
+
return now < recent429Until ? 1 : MAX_CONCURRENT_STREAMS;
|
|
28
|
+
}
|
|
29
|
+
function makeRelease() {
|
|
30
|
+
let released = false;
|
|
31
|
+
return () => {
|
|
32
|
+
if (released)
|
|
33
|
+
return;
|
|
34
|
+
released = true;
|
|
35
|
+
inFlight = Math.max(0, inFlight - 1);
|
|
36
|
+
pump();
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Grant queued waiters while a slot is free under the current cap. */
|
|
40
|
+
function pump() {
|
|
41
|
+
while (queue.length > 0 && inFlight < effectiveCap()) {
|
|
42
|
+
const waiter = queue.shift();
|
|
43
|
+
if (!waiter)
|
|
44
|
+
break;
|
|
45
|
+
if (waiter.signal?.aborted) {
|
|
46
|
+
waiter.reject(abortError());
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (waiter.signal && waiter.onAbort) {
|
|
50
|
+
waiter.signal.removeEventListener('abort', waiter.onAbort);
|
|
51
|
+
}
|
|
52
|
+
inFlight += 1;
|
|
53
|
+
waiter.resolve(makeRelease());
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Record a 429 (or equivalent) rate-limit signal. Collapses the stream
|
|
58
|
+
* cap to 1 for `retryAfterMs` (or 60s when absent/invalid).
|
|
59
|
+
*/
|
|
60
|
+
export function noteRateLimited(retryAfterMs) {
|
|
61
|
+
const cooldown = typeof retryAfterMs === 'number' &&
|
|
62
|
+
Number.isFinite(retryAfterMs) &&
|
|
63
|
+
retryAfterMs >= 0
|
|
64
|
+
? retryAfterMs
|
|
65
|
+
: DEFAULT_RATE_LIMIT_COOLDOWN_MS;
|
|
66
|
+
recent429Until = Date.now() + cooldown;
|
|
67
|
+
// Wake queued waiters once the cooldown lifts even if no release
|
|
68
|
+
// happens in between (holders may outlive the cooldown). Unref'd so
|
|
69
|
+
// tests and short-lived processes never hang on this timer.
|
|
70
|
+
if (cooldown > 0 && cooldown < 3_600_000) {
|
|
71
|
+
const timer = setTimeout(pump, cooldown);
|
|
72
|
+
timer.unref?.();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Acquire a stream slot. Grants immediately when `inFlight` is under the
|
|
77
|
+
* effective cap, otherwise queues FIFO until a release (or the cooldown
|
|
78
|
+
* lifting) frees one. Abort-aware: an already-aborted signal rejects
|
|
79
|
+
* immediately; aborting while queued dequeues and rejects promptly.
|
|
80
|
+
*/
|
|
81
|
+
export function acquireStreamSlot(signal) {
|
|
82
|
+
if (signal?.aborted)
|
|
83
|
+
return Promise.reject(abortError());
|
|
84
|
+
if (inFlight < effectiveCap()) {
|
|
85
|
+
inFlight += 1;
|
|
86
|
+
return Promise.resolve(makeRelease());
|
|
87
|
+
}
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const waiter = { resolve, reject };
|
|
90
|
+
if (signal) {
|
|
91
|
+
waiter.signal = signal;
|
|
92
|
+
waiter.onAbort = () => {
|
|
93
|
+
const idx = queue.indexOf(waiter);
|
|
94
|
+
if (idx >= 0)
|
|
95
|
+
queue.splice(idx, 1);
|
|
96
|
+
reject(abortError());
|
|
97
|
+
};
|
|
98
|
+
signal.addEventListener('abort', waiter.onAbort, { once: true });
|
|
99
|
+
}
|
|
100
|
+
queue.push(waiter);
|
|
101
|
+
// Re-check: the cap may have widened (cooldown expiry) between the
|
|
102
|
+
// fast-path check and the push.
|
|
103
|
+
pump();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/** Observable budget state (tests/diagnostics). */
|
|
107
|
+
export function streamBudgetState() {
|
|
108
|
+
return { inFlight, queued: queue.length, cap: effectiveCap() };
|
|
109
|
+
}
|
|
110
|
+
/** Reset the budget (counters, cooldown, queued waiters). Tests only. */
|
|
111
|
+
export function setStreamBudgetForTests() {
|
|
112
|
+
inFlight = 0;
|
|
113
|
+
recent429Until = 0;
|
|
114
|
+
const pending = queue.splice(0, queue.length);
|
|
115
|
+
for (const waiter of pending) {
|
|
116
|
+
if (waiter.signal && waiter.onAbort) {
|
|
117
|
+
waiter.signal.removeEventListener('abort', waiter.onAbort);
|
|
118
|
+
}
|
|
119
|
+
waiter.reject(new Error('stream budget reset'));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 4.5 — Checkpoint snapshots: every mutation to checkpoints dir
|
|
3
|
+
*
|
|
4
|
+
* Snapshot bytes stay RAW (no redaction): checkpoint copies must be
|
|
5
|
+
* bit-identical to the working tree so undo() restores exact fidelity —
|
|
6
|
+
* redacting at snapshot time would corrupt restores (a redacted snapshot
|
|
7
|
+
* written back would permanently replace real code with [REDACTED]).
|
|
8
|
+
* Same-trust-domain rationale: snapshots never leave the project dir and
|
|
9
|
+
* are only read back by undo() into the same tree, so secret hygiene is
|
|
10
|
+
* enforced at the trace/persist boundaries (TraceWriter, SessionStore)
|
|
11
|
+
* instead of here.
|
|
3
12
|
*/
|
|
4
13
|
export declare function snapshot(cwd: string, files: string[]): Promise<string>;
|
|
5
14
|
export declare function listCheckpoints(cwd: string): Promise<string[]>;
|
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 4.5 — Checkpoint snapshots: every mutation to checkpoints dir
|
|
3
|
+
*
|
|
4
|
+
* Snapshot bytes stay RAW (no redaction): checkpoint copies must be
|
|
5
|
+
* bit-identical to the working tree so undo() restores exact fidelity —
|
|
6
|
+
* redacting at snapshot time would corrupt restores (a redacted snapshot
|
|
7
|
+
* written back would permanently replace real code with [REDACTED]).
|
|
8
|
+
* Same-trust-domain rationale: snapshots never leave the project dir and
|
|
9
|
+
* are only read back by undo() into the same tree, so secret hygiene is
|
|
10
|
+
* enforced at the trace/persist boundaries (TraceWriter, SessionStore)
|
|
11
|
+
* instead of here.
|
|
3
12
|
*/
|
|
4
13
|
import * as fs from 'node:fs/promises';
|
|
14
|
+
import * as fsSync from 'node:fs';
|
|
5
15
|
import * as path from 'node:path';
|
|
6
16
|
import * as crypto from 'node:crypto';
|
|
7
17
|
function ckptDir(cwd) {
|
|
8
18
|
return path.join(cwd, '.klyro', 'checkpoints');
|
|
9
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Best-effort permission lockdown (0600 files / 0700 dirs).
|
|
22
|
+
* Windows ACLs ignore POSIX mode bits — no-op by design.
|
|
23
|
+
*/
|
|
24
|
+
function lockDown(p, mode) {
|
|
25
|
+
if (process.platform === 'win32')
|
|
26
|
+
return;
|
|
27
|
+
try {
|
|
28
|
+
fsSync.chmodSync(p, mode);
|
|
29
|
+
}
|
|
30
|
+
catch { /* best-effort only */ }
|
|
31
|
+
}
|
|
10
32
|
/** Best-effort fsync of a just-written file (crash safety). */
|
|
11
33
|
async function fsyncFile(p) {
|
|
12
34
|
try {
|
|
@@ -35,9 +57,11 @@ function containedPath(cwd, base, rel) {
|
|
|
35
57
|
export async function snapshot(cwd, files) {
|
|
36
58
|
const dir = ckptDir(cwd);
|
|
37
59
|
await fs.mkdir(dir, { recursive: true });
|
|
60
|
+
lockDown(dir, 0o700);
|
|
38
61
|
const id = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
39
62
|
const dest = path.join(dir, id);
|
|
40
63
|
await fs.mkdir(dest, { recursive: true });
|
|
64
|
+
lockDown(dest, 0o700);
|
|
41
65
|
const missing = [];
|
|
42
66
|
const kept = [];
|
|
43
67
|
for (const f of files) {
|
|
@@ -52,6 +76,7 @@ export async function snapshot(cwd, files) {
|
|
|
52
76
|
continue;
|
|
53
77
|
await fs.mkdir(path.dirname(out), { recursive: true });
|
|
54
78
|
await fs.writeFile(out, data);
|
|
79
|
+
lockDown(out, 0o600);
|
|
55
80
|
await fsyncFile(out);
|
|
56
81
|
kept.push(rel);
|
|
57
82
|
}
|
|
@@ -65,6 +90,7 @@ export async function snapshot(cwd, files) {
|
|
|
65
90
|
// SessionStore.writeIndex atomic pattern).
|
|
66
91
|
const metaPath = path.join(dest, '.meta.json');
|
|
67
92
|
await fs.writeFile(metaPath, JSON.stringify({ id, files: kept, missing, ts: Date.now() }, null, 2));
|
|
93
|
+
lockDown(metaPath, 0o600);
|
|
68
94
|
await fsyncFile(metaPath);
|
|
69
95
|
// Best-effort last.diff for the repair guard (guardRepair reads it).
|
|
70
96
|
try {
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
export interface CommitOptions {
|
|
18
|
+
cwd: string;
|
|
19
|
+
yes?: boolean;
|
|
20
|
+
dryRun?: boolean;
|
|
21
|
+
message?: string;
|
|
22
|
+
forceSecret?: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** Porcelain XY: staged iff the index (first) column is set. Handles renames. */
|
|
25
|
+
export declare function stagedFilesFromPorcelain(porcelain: string): string[];
|
|
26
|
+
/** Type heuristic: test-only→test, docs-only→docs, lockfiles→chore, else feat. */
|
|
27
|
+
export declare function commitTypeFor(files: string[]): string;
|
|
28
|
+
/** Scope = most common top-level dir among staged files with a dir; '' if none. */
|
|
29
|
+
export declare function commitScopeFor(files: string[]): string;
|
|
30
|
+
export declare function buildCommitMessage(files: string[], summaryOpt?: string): string;
|
|
31
|
+
export declare function runCommit(opts: CommitOptions): Promise<number>;
|
|
@@ -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<{
|
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)
|
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');
|