klyro 1.0.1 → 1.0.3
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 +44 -4
- package/dist/agent/orchestrator.js +102 -8
- package/dist/agent/retry.js +52 -10
- package/dist/agent/runtime.d.ts +34 -0
- package/dist/agent/runtime.js +169 -15
- 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/markdown.js +29 -1
- package/dist/cli/repl.js +41 -1
- package/dist/cli/run.d.ts +6 -0
- package/dist/cli/run.js +76 -3
- package/dist/context/tokenizer.d.ts +18 -0
- package/dist/context/tokenizer.js +50 -1
- package/dist/events/bus.d.ts +5 -0
- package/dist/events/bus.js +10 -0
- 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/sandbox.d.ts +4 -3
- package/dist/tools/shell/sandbox.js +23 -3
- 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/tui/app.js +20 -1
- package/dist/tui/app.test.js +18 -12
- package/dist/tui/approval.test.js +20 -3
- package/dist/tui/markdown.d.ts +13 -0
- package/dist/tui/markdown.js +169 -2
- package/dist/tui/scroll-flow.test.js +3 -1
- 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,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');
|
|
@@ -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>;
|
|
@@ -0,0 +1,181 @@
|
|
|
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 { spawn } from 'node:child_process';
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as os from 'node:os';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
export const HookSchema = z.object({
|
|
22
|
+
name: z.string().min(1),
|
|
23
|
+
event: z.enum(['preToolUse', 'postToolUse']),
|
|
24
|
+
command: z.string().min(1),
|
|
25
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
26
|
+
});
|
|
27
|
+
const HooksFileSchema = z.object({
|
|
28
|
+
hooks: z.array(HookSchema),
|
|
29
|
+
});
|
|
30
|
+
export const DEFAULT_HOOK_TIMEOUT_MS = 30_000;
|
|
31
|
+
const MAX_HOOK_OUTPUT_CHARS = 4000;
|
|
32
|
+
/** Paths already warned about (invalid JSON/schema) — warn once per path. */
|
|
33
|
+
const warnedPaths = new Set();
|
|
34
|
+
function warnOnce(filePath, detail) {
|
|
35
|
+
if (warnedPaths.has(filePath))
|
|
36
|
+
return;
|
|
37
|
+
warnedPaths.add(filePath);
|
|
38
|
+
try {
|
|
39
|
+
process.stderr.write(`klyro: hooks: ignoring invalid file ${filePath}: ${detail}\n`);
|
|
40
|
+
}
|
|
41
|
+
catch { /* ignore */ }
|
|
42
|
+
}
|
|
43
|
+
/** Read + validate one hooks file. Missing → []. Invalid → [] + warn once. */
|
|
44
|
+
function readHooksFile(filePath) {
|
|
45
|
+
let raw;
|
|
46
|
+
try {
|
|
47
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return []; // missing/unreadable — silent, zero-cost fast path stays silent
|
|
51
|
+
}
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(raw);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
warnOnce(filePath, err instanceof Error ? err.message : String(err));
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
const checked = HooksFileSchema.safeParse(parsed);
|
|
61
|
+
if (!checked.success) {
|
|
62
|
+
const first = checked.error.issues[0];
|
|
63
|
+
warnOnce(filePath, first ? `${first.path.join('.') || 'hooks'}: ${first.message}` : 'schema mismatch');
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
return checked.data.hooks;
|
|
67
|
+
}
|
|
68
|
+
function globalHooksPath() {
|
|
69
|
+
try {
|
|
70
|
+
return path.join(os.homedir() || process.cwd(), '.klyro', 'hooks.json');
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return path.join(process.cwd(), '.klyro', 'hooks.json');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Load hooks for a run. Global first, then project — a project hook with
|
|
78
|
+
* the same `name` replaces the global one. Never throws.
|
|
79
|
+
*/
|
|
80
|
+
export function loadHooks(cwd) {
|
|
81
|
+
let out = [];
|
|
82
|
+
try {
|
|
83
|
+
const byName = new Map();
|
|
84
|
+
for (const h of readHooksFile(globalHooksPath()))
|
|
85
|
+
byName.set(h.name, h);
|
|
86
|
+
const projectFile = path.join(cwd, '.klyro', 'hooks.json');
|
|
87
|
+
for (const h of readHooksFile(projectFile))
|
|
88
|
+
byName.set(h.name, h);
|
|
89
|
+
out = [...byName.values()];
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Minimal filtered env for hook children (mirrors the shell_exec policy:
|
|
98
|
+
* secrets stripped, only safe prefixes pass). Kept local so the hooks
|
|
99
|
+
* engine never depends on the tool layer at import time.
|
|
100
|
+
*/
|
|
101
|
+
function hookEnv() {
|
|
102
|
+
const out = {};
|
|
103
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
104
|
+
if (k.startsWith('KLYRO_') && k.includes('API_KEY'))
|
|
105
|
+
continue;
|
|
106
|
+
if (k.includes('SECRET') || k.includes('TOKEN') || k === 'ANTHROPIC_API_KEY' || k === 'OPENAI_API_KEY')
|
|
107
|
+
continue;
|
|
108
|
+
if (k.startsWith('PATH') || k.startsWith('HOME') || k.startsWith('USER') ||
|
|
109
|
+
k.startsWith('SHELL') || k.startsWith('TERM') || k.startsWith('LANG') ||
|
|
110
|
+
k.startsWith('NODE_') || k.startsWith('NPM_') || k.startsWith('PNPM_') ||
|
|
111
|
+
k.startsWith('YARN_') || k === 'PWD' || k === 'TMPDIR' || k === 'TEMP' ||
|
|
112
|
+
k === 'SystemRoot' || k === 'windir' || k === 'PATHEXT' || k === 'OS') {
|
|
113
|
+
out[k] = v;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
out.PATH = process.env.PATH;
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Run one hook. Resolves (never rejects) with the exit code + sliced
|
|
121
|
+
* output. `ok` is true only when the process exited 0.
|
|
122
|
+
*/
|
|
123
|
+
export function runHook(hook, ctx) {
|
|
124
|
+
const timeoutMs = hook.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
let env;
|
|
127
|
+
try {
|
|
128
|
+
// Filtered env (same policy as shell_exec): secrets stripped, only
|
|
129
|
+
// safe prefixes pass — plus the hook's own inspection vars below.
|
|
130
|
+
env = hookEnv();
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
env = { PATH: process.env.PATH };
|
|
134
|
+
}
|
|
135
|
+
env.KLYRO_TOOL_NAME = ctx.toolName;
|
|
136
|
+
try {
|
|
137
|
+
env.KLYRO_TOOL_INPUT_JSON = JSON.stringify(ctx.input ?? {}).slice(0, 16_000);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
env.KLYRO_TOOL_INPUT_JSON = '{}';
|
|
141
|
+
}
|
|
142
|
+
let child;
|
|
143
|
+
try {
|
|
144
|
+
child = spawn(hook.command, {
|
|
145
|
+
cwd: process.cwd(),
|
|
146
|
+
shell: true,
|
|
147
|
+
windowsHide: true,
|
|
148
|
+
timeout: timeoutMs,
|
|
149
|
+
env,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
resolve({ ok: false, exitCode: -1, stdout: '', stderr: String(err instanceof Error ? err.message : err).slice(0, MAX_HOOK_OUTPUT_CHARS) });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
let stdout = '';
|
|
157
|
+
let stderr = '';
|
|
158
|
+
child.stdout?.on('data', (d) => {
|
|
159
|
+
stdout += d.toString();
|
|
160
|
+
if (stdout.length > MAX_HOOK_OUTPUT_CHARS * 2)
|
|
161
|
+
stdout = stdout.slice(0, MAX_HOOK_OUTPUT_CHARS * 2);
|
|
162
|
+
});
|
|
163
|
+
child.stderr?.on('data', (d) => {
|
|
164
|
+
stderr += d.toString();
|
|
165
|
+
if (stderr.length > MAX_HOOK_OUTPUT_CHARS * 2)
|
|
166
|
+
stderr = stderr.slice(0, MAX_HOOK_OUTPUT_CHARS * 2);
|
|
167
|
+
});
|
|
168
|
+
child.on('error', (err) => {
|
|
169
|
+
resolve({ ok: false, exitCode: -1, stdout: stdout.slice(0, MAX_HOOK_OUTPUT_CHARS), stderr: String(err.message ?? err).slice(0, MAX_HOOK_OUTPUT_CHARS) });
|
|
170
|
+
});
|
|
171
|
+
child.on('close', (code) => {
|
|
172
|
+
const exitCode = typeof code === 'number' ? code : -1;
|
|
173
|
+
resolve({
|
|
174
|
+
ok: exitCode === 0,
|
|
175
|
+
exitCode,
|
|
176
|
+
stdout: stdout.slice(0, MAX_HOOK_OUTPUT_CHARS),
|
|
177
|
+
stderr: stderr.slice(0, MAX_HOOK_OUTPUT_CHARS),
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
package/dist/cli/markdown.js
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
* 1.5 — incremental markdown renderer (headers, code w/ highlight, lists, tables)
|
|
3
3
|
* Minimal: handles headers, code blocks, lists, tables, width-aware wrap, plain-text for non-TTY.
|
|
4
4
|
*/
|
|
5
|
+
import { highlightCodeLine } from '../tui/markdown.js';
|
|
6
|
+
/** ANSI color code per MdPart color name (R4 shared highlighter). */
|
|
7
|
+
const ANSI = {
|
|
8
|
+
red: '\x1b[31m',
|
|
9
|
+
green: '\x1b[32m',
|
|
10
|
+
yellow: '\x1b[33m',
|
|
11
|
+
blue: '\x1b[34m',
|
|
12
|
+
magenta: '\x1b[35m',
|
|
13
|
+
cyan: '\x1b[36m',
|
|
14
|
+
gray: '\x1b[90m',
|
|
15
|
+
};
|
|
16
|
+
const RESET = '\x1b[0m';
|
|
17
|
+
function renderHighlightedCode(line, lang) {
|
|
18
|
+
const parts = highlightCodeLine(line, lang);
|
|
19
|
+
let out = '';
|
|
20
|
+
for (const p of parts) {
|
|
21
|
+
let s = p.text;
|
|
22
|
+
if (p.dim)
|
|
23
|
+
s = `\x1b[2m${s}\x1b[22m`;
|
|
24
|
+
if (p.color && ANSI[p.color])
|
|
25
|
+
s = `${ANSI[p.color]}${s}${RESET}`;
|
|
26
|
+
out += s;
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
5
30
|
export function renderMarkdown(md, opts = {}) {
|
|
6
31
|
const width = opts.width ?? (process.stdout.columns || 80);
|
|
7
32
|
const isTTY = opts.isTTY ?? !!process.stdout.isTTY;
|
|
@@ -12,14 +37,17 @@ export function renderMarkdown(md, opts = {}) {
|
|
|
12
37
|
let out = '';
|
|
13
38
|
const lines = md.split('\n');
|
|
14
39
|
let inCodeBlock = false;
|
|
40
|
+
let fenceLang = '';
|
|
15
41
|
for (let line of lines) {
|
|
16
42
|
if (line.startsWith('```')) {
|
|
43
|
+
if (!inCodeBlock)
|
|
44
|
+
fenceLang = line.replace(/^```/, '').trim().toLowerCase();
|
|
17
45
|
inCodeBlock = !inCodeBlock;
|
|
18
46
|
out += (inCodeBlock ? '┌ code ──\n' : '└──────\n');
|
|
19
47
|
continue;
|
|
20
48
|
}
|
|
21
49
|
if (inCodeBlock) {
|
|
22
|
-
out += '│ ' + line + '\n';
|
|
50
|
+
out += '│ ' + renderHighlightedCode(line, fenceLang) + '\n';
|
|
23
51
|
continue;
|
|
24
52
|
}
|
|
25
53
|
if (line.startsWith('# ')) {
|