klyro 1.0.6 → 1.0.8
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/READ.md +1 -1
- package/README.md +35 -9
- package/dist/agent/orchestrator.d.ts +7 -1
- package/dist/agent/orchestrator.js +20 -9
- package/dist/agent/runtime.d.ts +10 -0
- package/dist/agent/runtime.js +70 -13
- package/dist/chat.d.ts +10 -0
- package/dist/chat.js +39 -7
- package/dist/checkpoints/store.d.ts +2 -0
- package/dist/checkpoints/store.js +12 -0
- package/dist/cli/auth.d.ts +10 -3
- package/dist/cli/auth.js +43 -5
- package/dist/cli/doctor.js +0 -1
- package/dist/cli/eval.js +22 -16
- package/dist/cli/hooks.d.ts +21 -1
- package/dist/cli/hooks.js +34 -2
- package/dist/cli/keychain.d.ts +10 -0
- package/dist/cli/keychain.js +86 -0
- package/dist/cli/repl.js +54 -17
- package/dist/cli/setup.js +3 -2
- package/dist/cli/slash/parser.d.ts +1 -1
- package/dist/cli/slash/parser.js +6 -3
- package/dist/cli/update.d.ts +3 -1
- package/dist/cli/update.js +16 -1
- package/dist/context/accounting.d.ts +6 -0
- package/dist/context/accounting.js +8 -2
- package/dist/context/compaction.d.ts +2 -1
- package/dist/context/compaction.js +39 -12
- package/dist/context/memory.d.ts +11 -0
- package/dist/context/memory.js +47 -9
- package/dist/eval/harness.d.ts +19 -2
- package/dist/eval/harness.js +72 -7
- package/dist/index.js +90 -4
- package/dist/mcp/auth.d.ts +85 -0
- package/dist/mcp/auth.js +249 -0
- package/dist/mcp/config.d.ts +28 -0
- package/dist/mcp/config.js +65 -0
- package/dist/mcp/registry.d.ts +13 -7
- package/dist/mcp/registry.js +90 -14
- package/dist/mcp/remote.d.ts +7 -0
- package/dist/mcp/remote.js +56 -2
- package/dist/mcp/sse.d.ts +42 -0
- package/dist/mcp/sse.js +310 -0
- package/dist/persistence/audit.d.ts +15 -3
- package/dist/persistence/audit.js +84 -13
- package/dist/persistence/store.d.ts +9 -0
- package/dist/persistence/store.js +17 -0
- package/dist/policy/engine.d.ts +8 -7
- package/dist/policy/engine.js +15 -6
- package/dist/providers.js +4 -4
- package/dist/tools/registry.js +4 -0
- package/dist/tools/shell/shell-exec.d.ts +13 -0
- package/dist/tools/shell/shell-exec.js +64 -2
- package/dist/tools/types.d.ts +4 -4
- package/dist/tools/web/web-fetch.d.ts +53 -0
- package/dist/tools/web/web-fetch.js +275 -0
- package/dist/tools/web/web-search.d.ts +53 -0
- package/dist/tools/web/web-search.js +121 -0
- package/dist/tui/app.js +35 -5
- package/dist/tui/app.test.js +3 -2
- package/dist/tui/approval.js +3 -1
- package/dist/tui/scroll-model.d.ts +2 -2
- package/dist/tui/scroll-model.js +9 -3
- package/dist/tui/tokens.d.ts +8 -11
- package/dist/tui/tokens.js +18 -11
- package/package.json +1 -1
package/READ.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Klyro — Complete Build Documentation
|
|
2
2
|
|
|
3
|
-
**For any coding agent:** This file is the single source of truth for what has been built till now (
|
|
3
|
+
**For any coding agent:** This file is the single source of truth for what has been built till now (current version: see `package.json` — v1.0.8; Levels 1-9 complete, Level 10 largely complete incl. MCP/hooks/sub-agents, 34 built-in tools incl. `web_fetch`/`web_search`). The §20 ledger below is the historical record (v0.1.39→v0.1.61); version/test-count numbers inside it are point-in-time, not current. After reading, you have the complete picture.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
package/README.md
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
# Klyro
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Autonomous AI coding harness — terminal-native agent (CLI + Ink TUI) for any OpenAI-compatible or Anthropic LLM endpoint.
|
|
4
4
|
|
|
5
5
|
## What works today
|
|
6
6
|
|
|
7
|
-
- Streams from `https://<host>/v1/chat/completions`
|
|
7
|
+
- Streams from `https://<host>/v1/chat/completions` (OpenAI-compatible) and Anthropic `/v1/messages`
|
|
8
8
|
- HTTPS-only (with localhost exemption for local LLMs)
|
|
9
|
-
- Per-request timeout
|
|
10
|
-
- Interactive REPL with multi-turn history
|
|
11
|
-
-
|
|
12
|
-
-
|
|
9
|
+
- Per-request timeout, retry with backoff, usage/cost accounting
|
|
10
|
+
- Interactive Ink TUI + REPL with multi-turn history, slash commands, approvals
|
|
11
|
+
- Autonomous loop: phases, budgets, stuck detection, verification + repair
|
|
12
|
+
- 34 built-in tools (fs/search/shell/git/verify/plan/web), policy engine, MCP client/server
|
|
13
|
+
- Session persistence (JSON), hash-chained audit log, checkpoints/undo, eval harness
|
|
14
|
+
- Strict TypeScript (`tsc`, noEmit typecheck, vitest)
|
|
13
15
|
|
|
14
16
|
## Quick start
|
|
15
17
|
|
|
@@ -55,6 +57,19 @@ node dist/index.js chat
|
|
|
55
57
|
| `KLYRO_WORKER=0` | Disable subprocess isolation for subagents |
|
|
56
58
|
| `KLYRO_SESSIONS_DIR`, `KLYRO_UPDATE_CACHE`, `KLYRO_CREDENTIALS_FILE` | Relocatable state (tests + power users) |
|
|
57
59
|
|
|
60
|
+
## New in recent releases
|
|
61
|
+
|
|
62
|
+
- `klyro run --bare` — deterministic runs: skips MCP, hooks, memory/KLYRO.md/context, persistence
|
|
63
|
+
- `klyro mcp trust <name>` / `mcp prompts [server]` / `mcp add <name> <https-url>` — remote MCP + prompt trust
|
|
64
|
+
- `klyro agents lint` — validate `.klyro/agents/*.md` (ids, tool names)
|
|
65
|
+
- `klyro init` — scan-seeded `KLYRO.md` + `.mcp.json` (never overwrites)
|
|
66
|
+
- `klyro update --apply` — opt-in self-apply of the verified update
|
|
67
|
+
- `klyro eval --judge-model <id>` — model-graded rubric scoring
|
|
68
|
+
- Hooks: `matcher` scoping, stdin JSON, `sessionStart`/`sessionEnd`/`stop` events, JSON verdicts
|
|
69
|
+
- Custom agents (`.klyro/agents/*.md`), custom commands (`.klyro/commands/*.md`), vim mode (`/vim`), `@`-file completion
|
|
70
|
+
- Credentials prefer the OS keychain (macOS Keychain, Linux libsecret), 0600 file fallback
|
|
71
|
+
- Headless JSON ends with exactly one stable `kind:result` envelope (parse the LAST line)
|
|
72
|
+
|
|
58
73
|
## Documentation
|
|
59
74
|
|
|
60
75
|
| Doc | Purpose |
|
|
@@ -69,9 +84,20 @@ node dist/index.js chat
|
|
|
69
84
|
|
|
70
85
|
```
|
|
71
86
|
src/
|
|
72
|
-
├── index.ts
|
|
73
|
-
├──
|
|
74
|
-
|
|
87
|
+
├── index.ts # commander entry — tui/run/chat/eval/session/mcp/agents/commit/audit/...
|
|
88
|
+
├── agent/ # runtime loop, orchestrator, adapters, worktree, tasks
|
|
89
|
+
├── cli/ # run/repl/config/doctor/hooks/eval/slash/...
|
|
90
|
+
├── tools/ # 34 built-ins: fs/search/shell/git/verify/plan/web (+ registry)
|
|
91
|
+
├── policy/ # engine, path-guard, approval, secret-redactor
|
|
92
|
+
├── context/ # project-map, repo-map, tokenizer, compaction, memory, trust
|
|
93
|
+
├── verification/ # registry, parsers, repair loop, baseline, scoped
|
|
94
|
+
├── mcp/ # client (stdio/SSE/HTTP), trust, serve, OAuth
|
|
95
|
+
├── persistence/ # JSON session store, hash-chained audit
|
|
96
|
+
├── checkpoints/ # snapshots, undo/rewind
|
|
97
|
+
├── events/ trace/ renderers/ # event bus, JSONL traces, terminal/JSON output
|
|
98
|
+
├── tui/ # Ink app (transcript, approval, diff, scroll, markdown)
|
|
99
|
+
├── eval/ # scripted harness, tasks, judge
|
|
100
|
+
└── chat.ts / repl.ts # legacy one-shot chat + legacy REPL
|
|
75
101
|
```
|
|
76
102
|
|
|
77
103
|
## License
|
|
@@ -53,7 +53,13 @@ export interface AgentDefinition {
|
|
|
53
53
|
}
|
|
54
54
|
/** Default agents a model can delegate to. */
|
|
55
55
|
export declare const BUILTIN_AGENTS: readonly AgentDefinition[];
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Layer specialist instructions into a child's system prompt (not the user
|
|
58
|
+
* task). Pure — unit-tested directly. No-op when the def has no prompt.
|
|
59
|
+
*/
|
|
60
|
+
export declare function layerSpecialistPrompt(base: RuntimeDeps['systemPrompt'], def: Pick<AgentDefinition, 'id' | 'prompt'>): RuntimeDeps['systemPrompt'];
|
|
61
|
+
/** Compact summary returned to the parent — the child's transcript stays separate. */
|
|
62
|
+
export interface ChildSummary {
|
|
57
63
|
taskId: string;
|
|
58
64
|
agentName: string;
|
|
59
65
|
status: TaskStatus;
|
|
@@ -33,7 +33,7 @@ export const BUILTIN_AGENTS = [
|
|
|
33
33
|
description: 'Read-only reconnaissance: map the repo, find symbols and tests.',
|
|
34
34
|
readonly: true,
|
|
35
35
|
canSpawn: false,
|
|
36
|
-
allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'search_files', 'repo_map', 'find_symbol', 'git_status', 'git_log', 'git_diff', 'recent_files', 'imports_of', 'importers_of'],
|
|
36
|
+
allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'search_files', 'repo_map', 'find_symbol', 'git_status', 'git_log', 'git_diff', 'recent_files', 'imports_of', 'importers_of', 'web_fetch', 'web_search'],
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
id: 'implementer',
|
|
@@ -68,9 +68,22 @@ export const BUILTIN_AGENTS = [
|
|
|
68
68
|
description: 'Read-only documentation lookup: find and summarise docs, READMEs, and code structure.',
|
|
69
69
|
readonly: true,
|
|
70
70
|
canSpawn: false,
|
|
71
|
-
allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'search_files', 'repo_map', 'recent_files'],
|
|
71
|
+
allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'search_files', 'repo_map', 'recent_files', 'web_fetch', 'web_search'],
|
|
72
72
|
},
|
|
73
73
|
];
|
|
74
|
+
/**
|
|
75
|
+
* Layer specialist instructions into a child's system prompt (not the user
|
|
76
|
+
* task). Pure — unit-tested directly. No-op when the def has no prompt.
|
|
77
|
+
*/
|
|
78
|
+
export function layerSpecialistPrompt(base, def) {
|
|
79
|
+
if (!def.prompt)
|
|
80
|
+
return base;
|
|
81
|
+
return (ctx) => {
|
|
82
|
+
const r = base(ctx);
|
|
83
|
+
const block = `\n\n<specialist id="${def.id}">\n${def.prompt}\n</specialist>`;
|
|
84
|
+
return typeof r === 'string' ? r + block : { ...r, system: r.system + block };
|
|
85
|
+
};
|
|
86
|
+
}
|
|
74
87
|
/**
|
|
75
88
|
* All agents: builtins plus custom `.klyro/agents/*.md` definitions.
|
|
76
89
|
* Custom ids win on clash (including overriding a builtin) — the override
|
|
@@ -368,7 +381,8 @@ export class AgentOrchestrator {
|
|
|
368
381
|
}
|
|
369
382
|
this.taskMeta.set(record.id, worktree ? { def, dropped: resolved.dropped, worktree, repoCwd } : { def, dropped: resolved.dropped });
|
|
370
383
|
const childRegistry = new ScopedRegistry(this.deps.registry, resolved.allowed);
|
|
371
|
-
const
|
|
384
|
+
const childPromptFn = layerSpecialistPrompt(this.deps.systemPrompt, def);
|
|
385
|
+
const childDeps = { ...this.deps, registry: childRegistry, systemPrompt: childPromptFn };
|
|
372
386
|
const childRef = {
|
|
373
387
|
taskId: record.id,
|
|
374
388
|
...(parent.taskId !== undefined ? { parentTaskId: parent.taskId } : {}),
|
|
@@ -380,11 +394,8 @@ export class AgentOrchestrator {
|
|
|
380
394
|
...(childModel !== undefined ? { model: childModel } : {}),
|
|
381
395
|
...(resolved.allowedPaths !== undefined ? { allowedPaths: resolved.allowedPaths } : {}),
|
|
382
396
|
};
|
|
383
|
-
// Specialist instructions from `.klyro/agents/*.md` (or programmatic
|
|
384
|
-
// defs) ride with the delegated task on both paths below.
|
|
385
|
-
const childTask = def.prompt ? `${def.prompt}\n\n---\n\n${input.task}` : input.task;
|
|
386
397
|
const childOptions = {
|
|
387
|
-
task:
|
|
398
|
+
task: input.task,
|
|
388
399
|
cwd: childCwd,
|
|
389
400
|
model: childModel ?? 'inherit', // model override must reach the adapter (see runtime)
|
|
390
401
|
maxSteps: def.maxSteps,
|
|
@@ -431,7 +442,7 @@ export class AgentOrchestrator {
|
|
|
431
442
|
let childOutcome;
|
|
432
443
|
try {
|
|
433
444
|
if (useProcessIsolation) {
|
|
434
|
-
const sysPrompt = resolveSystemPrompt(
|
|
445
|
+
const sysPrompt = resolveSystemPrompt(childPromptFn, { cwd: childCwd });
|
|
435
446
|
// Splice the volatile telemetry suffix into the stable prefix so the
|
|
436
447
|
// child's provider sees one system string. Telemetry is best-effort
|
|
437
448
|
// inside the child (it re-emits); the goal here is parity, not
|
|
@@ -439,7 +450,7 @@ export class AgentOrchestrator {
|
|
|
439
450
|
const systemPrompt = sysPrompt.suffix ? `${sysPrompt.system}\n${sysPrompt.suffix}` : sysPrompt.system;
|
|
440
451
|
const payload = {
|
|
441
452
|
cwd: childCwd,
|
|
442
|
-
task:
|
|
453
|
+
task: input.task,
|
|
443
454
|
// A concrete provider model must reach the child — 'inherit' only
|
|
444
455
|
// exists to defer resolution inside the parent's run().
|
|
445
456
|
model: (childModel ?? parent.model),
|
package/dist/agent/runtime.d.ts
CHANGED
|
@@ -131,6 +131,16 @@ export interface RunOptions {
|
|
|
131
131
|
store?: import('../persistence/store.js').SessionStore;
|
|
132
132
|
sessionId?: string;
|
|
133
133
|
};
|
|
134
|
+
/**
|
|
135
|
+
* Level 10 — tamper-evident audit. When an AuditLog is provided, the
|
|
136
|
+
* runtime writes policy decisions and tool completions into the chained
|
|
137
|
+
* audit stream (complements, does not replace, persistence). Defaults off
|
|
138
|
+
* so callers opt in; `klyro` CLI enables it when a sessions dir exists.
|
|
139
|
+
*/
|
|
140
|
+
audit?: {
|
|
141
|
+
log?: import('../persistence/audit.js').AuditLog;
|
|
142
|
+
sessionId?: string;
|
|
143
|
+
};
|
|
134
144
|
/**
|
|
135
145
|
* Orchestration context (P0). Present for any agent that is itself managed
|
|
136
146
|
* by an AgentOrchestrator — so a child knows who its parent is, how deep the
|
package/dist/agent/runtime.js
CHANGED
|
@@ -23,7 +23,7 @@ import { verify, diagnosticForModel } from '../verification/engine.js';
|
|
|
23
23
|
import { detectVerifyCommand } from '../verification/auto.js';
|
|
24
24
|
import { ensureBaseline, getBaseline } from '../verification/baseline.js';
|
|
25
25
|
import { compressTranscript, totalTokens, calibrateEstimate, transcriptCharLength } from '../context/tokenizer.js';
|
|
26
|
-
import { capForModel } from '../context/accounting.js';
|
|
26
|
+
import { capForModel, RESERVE_OUTPUT_TOKENS } from '../context/accounting.js';
|
|
27
27
|
import { shouldRemind, reminderForTodos } from '../context/memory.js';
|
|
28
28
|
import { ratesFor, isAnthropicModel } from '../providers/model-info.js';
|
|
29
29
|
import { classifyFailure, rerunOnce, gatherRepairContext, guardRepair } from '../verification/classify.js';
|
|
@@ -186,6 +186,16 @@ export async function run(opts, deps) {
|
|
|
186
186
|
bus.emit(ev);
|
|
187
187
|
tracer?.write(ev).catch(() => undefined);
|
|
188
188
|
};
|
|
189
|
+
// Light audit writer: mirrors policy decisions + tool results into the
|
|
190
|
+
// chained audit log. Best-effort — audit errors must not break the run.
|
|
191
|
+
const auditLog = opts.audit?.log;
|
|
192
|
+
const auditSessionId = opts.audit?.sessionId ?? opts.persist?.sessionId;
|
|
193
|
+
const writeAudit = (ev) => {
|
|
194
|
+
if (!auditLog || !auditSessionId)
|
|
195
|
+
return;
|
|
196
|
+
// fire-and-forget — the log serializes its own chain internally
|
|
197
|
+
void auditLog.write(ev).catch(() => undefined);
|
|
198
|
+
};
|
|
189
199
|
const closeTracer = async () => {
|
|
190
200
|
try {
|
|
191
201
|
await tracer?.close();
|
|
@@ -279,6 +289,10 @@ export async function run(opts, deps) {
|
|
|
279
289
|
const fileEditCounts = new Map();
|
|
280
290
|
let stuckTriggers = 0;
|
|
281
291
|
let stuckAbort = false;
|
|
292
|
+
// Steerable stop: a stop hook's `{"continue":true}` verdict carries one
|
|
293
|
+
// more turn. Consumed once at the completion point, max 3 per run.
|
|
294
|
+
let stopCont = null;
|
|
295
|
+
let stopContUsed = 0;
|
|
282
296
|
outer: while (steps < maxSteps) {
|
|
283
297
|
// 5.1 limits: max-cost, max-time
|
|
284
298
|
if (maxCost !== undefined) {
|
|
@@ -358,7 +372,7 @@ export async function run(opts, deps) {
|
|
|
358
372
|
const systemForBudget = telemetrySuffix ? `${stableSystem}\n\n${telemetrySuffix}` : stableSystem;
|
|
359
373
|
// Window-aware ceiling (was a hardcoded 120k that overflowed 8k local
|
|
360
374
|
// models): size the input budget to the model's context window.
|
|
361
|
-
const BUDGET = { total: capForModel(opts.model,
|
|
375
|
+
const BUDGET = { total: capForModel(opts.model, RESERVE_OUTPUT_TOKENS), reservedOutput: RESERVE_OUTPUT_TOKENS };
|
|
362
376
|
let reqMessages = transcript;
|
|
363
377
|
let reqSystem = stableSystem;
|
|
364
378
|
let reqSuffix = telemetrySuffix;
|
|
@@ -464,7 +478,7 @@ export async function run(opts, deps) {
|
|
|
464
478
|
telemetry.recordError('overflow_retry');
|
|
465
479
|
emitKlyro({ type: 'error', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', code: 'REQUEST_TOO_LARGE', message: 'context overflow — aggressively compacting transcript and retrying the request once' });
|
|
466
480
|
try {
|
|
467
|
-
const compacted = compressTranscript(reqSystem, transcript, { total: 30_000, reservedOutput:
|
|
481
|
+
const compacted = compressTranscript(reqSystem, transcript, { total: 30_000, reservedOutput: RESERVE_OUTPUT_TOKENS });
|
|
468
482
|
if (compacted.messages.length < transcript.length || compacted.dropped > 0) {
|
|
469
483
|
transcript.splice(0, transcript.length, ...compacted.messages);
|
|
470
484
|
}
|
|
@@ -472,7 +486,7 @@ export async function run(opts, deps) {
|
|
|
472
486
|
// Transcript already fits the aggressive budget — force it
|
|
473
487
|
// strictly smaller so the retry cannot repeat the overflow.
|
|
474
488
|
const halved = Math.max(4000, Math.floor(totalTokens(reqSystem, transcript) / 2));
|
|
475
|
-
const smaller = compressTranscript(reqSystem, transcript, { total: halved, reservedOutput:
|
|
489
|
+
const smaller = compressTranscript(reqSystem, transcript, { total: halved, reservedOutput: RESERVE_OUTPUT_TOKENS });
|
|
476
490
|
transcript.splice(0, transcript.length, ...smaller.messages);
|
|
477
491
|
}
|
|
478
492
|
tokenCache = { lastRef: null, lastSystem: undefined, lastCount: 0 };
|
|
@@ -603,6 +617,17 @@ export async function run(opts, deps) {
|
|
|
603
617
|
return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? withRepairTokens({ ok: false, attempts: verificationAttempts }) : undefined };
|
|
604
618
|
}
|
|
605
619
|
if (finalizedCalls.length === 0) {
|
|
620
|
+
// Steerable stop: a stop hook asked for one more turn instead of
|
|
621
|
+
// completing. Consumed once per verdict, max 3 per run.
|
|
622
|
+
if (stopCont !== null && stopContUsed < 3) {
|
|
623
|
+
stopContUsed++;
|
|
624
|
+
const contMsg = { role: 'user', content: [text(`[system note] ${stopCont}`)] };
|
|
625
|
+
stopCont = null;
|
|
626
|
+
transcript.push(contMsg);
|
|
627
|
+
await checkpoint(contMsg);
|
|
628
|
+
emit?.({ kind: 'step_end', step: steps });
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
606
631
|
if (hadInvalidTool) {
|
|
607
632
|
// The model attempted a tool call that failed validation; the error is
|
|
608
633
|
// already a tool_result in the transcript — loop so the model can
|
|
@@ -902,6 +927,7 @@ export async function run(opts, deps) {
|
|
|
902
927
|
else {
|
|
903
928
|
emitKlyro({ type: 'permission.decision', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, action: decision.action, reason: decision.reason });
|
|
904
929
|
}
|
|
930
|
+
writeAudit({ kind: 'policy_decision', sessionId: auditSessionId ?? 'ephemeral', callId: call.id, action: decision.action, ts: Date.now() });
|
|
905
931
|
if (decision.action === 'deny') {
|
|
906
932
|
const denyMsg = {
|
|
907
933
|
role: 'tool',
|
|
@@ -990,29 +1016,40 @@ export async function run(opts, deps) {
|
|
|
990
1016
|
// Hooks: matching preToolUse hooks run before execution. A non-zero
|
|
991
1017
|
// exit denies the tool with POLICY_DENIED — the real tool never runs.
|
|
992
1018
|
// Matchers scope hooks per tool; stdin carries the structured payload.
|
|
1019
|
+
// A structured JSON verdict wins over the exit code: deny blocks with
|
|
1020
|
+
// its message, allow+context attaches model-visible context.
|
|
1021
|
+
const hookContext = [];
|
|
993
1022
|
const matchingPre = hooksForEvent(runHooks, 'preToolUse', call.name);
|
|
994
1023
|
if (matchingPre.length > 0) {
|
|
995
1024
|
for (const hook of matchingPre) {
|
|
996
1025
|
let exitCode = -1;
|
|
997
1026
|
let detail = '';
|
|
1027
|
+
let verdict;
|
|
998
1028
|
try {
|
|
999
1029
|
const r = await runHook(hook, { toolName: call.name, input: call.input }, { event: 'preToolUse', tool: call.name, input: call.input, sessionId, cwd: opts.cwd });
|
|
1000
1030
|
exitCode = r.exitCode;
|
|
1001
1031
|
detail = (r.stderr || r.stdout || '').slice(0, 300);
|
|
1032
|
+
if (r.verdict)
|
|
1033
|
+
verdict = r.verdict;
|
|
1002
1034
|
}
|
|
1003
1035
|
catch (err) {
|
|
1004
1036
|
detail = String(err instanceof Error ? err.message : err).slice(0, 300);
|
|
1005
1037
|
}
|
|
1006
|
-
if (exitCode !== 0) {
|
|
1007
|
-
const reason = `hook ${hook.name} denied: ${detail || 'hook failed'}`;
|
|
1038
|
+
if (verdict?.decision === 'deny' || exitCode !== 0) {
|
|
1039
|
+
const reason = `hook ${hook.name} denied: ${verdict?.message || detail || 'hook failed'}`;
|
|
1008
1040
|
emit?.({ kind: 'policy_decision', id: call.id, name: call.name, action: 'deny', reason });
|
|
1009
1041
|
emitKlyro({ type: 'permission.decision', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, action: 'deny', reason });
|
|
1010
1042
|
const latencyMs = Date.now() - t0;
|
|
1011
1043
|
return {
|
|
1012
1044
|
obs: { ok: false, error: { code: 'POLICY_DENIED', message: reason } },
|
|
1013
1045
|
latencyMs,
|
|
1046
|
+
hookContext,
|
|
1014
1047
|
};
|
|
1015
1048
|
}
|
|
1049
|
+
// Allow verdicts may carry model-visible context (sliced at parse).
|
|
1050
|
+
if (verdict?.decision !== 'deny' && typeof verdict?.context === 'string' && verdict.context) {
|
|
1051
|
+
hookContext.push({ hook: hook.name, context: verdict.context });
|
|
1052
|
+
}
|
|
1016
1053
|
}
|
|
1017
1054
|
}
|
|
1018
1055
|
let obs;
|
|
@@ -1023,7 +1060,7 @@ export async function run(opts, deps) {
|
|
|
1023
1060
|
obs = { ok: false, error: { code: 'EXEC_CRASH', message: err instanceof Error ? err.message : String(err) } };
|
|
1024
1061
|
}
|
|
1025
1062
|
const latencyMs = Date.now() - t0;
|
|
1026
|
-
return { obs, latencyMs };
|
|
1063
|
+
return { obs, latencyMs, hookContext };
|
|
1027
1064
|
};
|
|
1028
1065
|
// 5.2 stuck termination (P0-3): the FIRST detection injects one
|
|
1029
1066
|
// "change approach" synthetic message for the next model turn; the SECOND
|
|
@@ -1044,7 +1081,7 @@ export async function run(opts, deps) {
|
|
|
1044
1081
|
};
|
|
1045
1082
|
// Commit phase: fold one execution result into the transcript, in original
|
|
1046
1083
|
// call order. The only writer — call sequentially, never concurrently.
|
|
1047
|
-
const commitResult = async (call, obs, latencyMs) => {
|
|
1084
|
+
const commitResult = async (call, obs, latencyMs, hookContext = []) => {
|
|
1048
1085
|
const output = obs.ok ? redactOutput(obs.value) : redactOutput({ error: obs.error });
|
|
1049
1086
|
const toolMsg = {
|
|
1050
1087
|
role: 'tool',
|
|
@@ -1052,6 +1089,16 @@ export async function run(opts, deps) {
|
|
|
1052
1089
|
};
|
|
1053
1090
|
transcript.push(toolMsg);
|
|
1054
1091
|
await checkpoint(toolMsg, { toolCallId: call.id, toolName: call.name, input: call.input, output, isError: !obs.ok });
|
|
1092
|
+
// Hook-injected context rides as its own user message right after the
|
|
1093
|
+
// tool result (uniform across output shapes — no result surgery).
|
|
1094
|
+
if (hookContext.length > 0) {
|
|
1095
|
+
const note = {
|
|
1096
|
+
role: 'user',
|
|
1097
|
+
content: [text(hookContext.map((h) => `[hook ${h.hook} context]\n${h.context}`).join('\n\n'))],
|
|
1098
|
+
};
|
|
1099
|
+
transcript.push(note);
|
|
1100
|
+
await checkpoint(note);
|
|
1101
|
+
}
|
|
1055
1102
|
if (obs.ok) {
|
|
1056
1103
|
telemetry.recordToolCall(call, latencyMs, false);
|
|
1057
1104
|
if (call.name === 'write_file' || call.name === 'edit_file' || call.name === 'multi_edit' || call.name === 'apply_patch') {
|
|
@@ -1074,6 +1121,8 @@ export async function run(opts, deps) {
|
|
|
1074
1121
|
}
|
|
1075
1122
|
emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, output, isError: !obs.ok, latencyMs });
|
|
1076
1123
|
emit?.({ kind: 'tool_result', id: call.id, name: call.name, output, isError: !obs.ok, latencyMs });
|
|
1124
|
+
// Light audit: every executed call completes into the chained log.
|
|
1125
|
+
writeAudit({ kind: 'tool_call_completed', sessionId: auditSessionId ?? 'ephemeral', callId: call.id, isError: !obs.ok, latencyMs, ts: Date.now() });
|
|
1077
1126
|
if (obs.ok) {
|
|
1078
1127
|
const fileChanged = inferFileChanged(call.name, call.input, obs.value);
|
|
1079
1128
|
if (fileChanged) {
|
|
@@ -1133,8 +1182,8 @@ export async function run(opts, deps) {
|
|
|
1133
1182
|
const runOne = async (call) => {
|
|
1134
1183
|
if (!(await gateCall(call)))
|
|
1135
1184
|
return;
|
|
1136
|
-
const { obs, latencyMs } = await execTool(call);
|
|
1137
|
-
await commitResult(call, obs, latencyMs);
|
|
1185
|
+
const { obs, latencyMs, hookContext } = await execTool(call);
|
|
1186
|
+
await commitResult(call, obs, latencyMs, hookContext);
|
|
1138
1187
|
};
|
|
1139
1188
|
// 3.5 — parallel when every call is concurrencySafe, sequential otherwise.
|
|
1140
1189
|
// Gate runs sequentially in both paths (approval UI is one-at-a-time).
|
|
@@ -1164,7 +1213,7 @@ export async function run(opts, deps) {
|
|
|
1164
1213
|
for (let i = 0; i < settled.length; i++) {
|
|
1165
1214
|
const s = settled[i];
|
|
1166
1215
|
if (s.status === 'fulfilled') {
|
|
1167
|
-
await commitResult(approved[i], s.value.obs, s.value.latencyMs);
|
|
1216
|
+
await commitResult(approved[i], s.value.obs, s.value.latencyMs, s.value.hookContext);
|
|
1168
1217
|
}
|
|
1169
1218
|
else {
|
|
1170
1219
|
await commitResult(approved[i], { ok: false, error: { code: 'EXEC_CRASH', message: String(s.reason) } }, 0);
|
|
@@ -1193,8 +1242,11 @@ export async function run(opts, deps) {
|
|
|
1193
1242
|
}
|
|
1194
1243
|
}
|
|
1195
1244
|
catch { /* ignore — completions are best-effort visibility */ }
|
|
1196
|
-
// stop hooks: run once per completed step (blocking
|
|
1197
|
-
//
|
|
1245
|
+
// stop hooks: run once per completed step (blocking). A structured
|
|
1246
|
+
// `{"continue": true, "message": ...}` verdict asks for one more turn
|
|
1247
|
+
// instead of completing — consumed at the completion point below,
|
|
1248
|
+
// bounded to 3 continuations per run so a hook can't loop forever.
|
|
1249
|
+
stopCont = null; // fresh verdict per step; stale ones never carry over
|
|
1198
1250
|
for (const hook of hooksForEvent(runHooks, 'stop')) {
|
|
1199
1251
|
try {
|
|
1200
1252
|
const r = await runHook(hook, { toolName: '', input: {} }, { event: 'stop', sessionId, cwd: opts.cwd, step: steps, status: 'open' });
|
|
@@ -1204,6 +1256,11 @@ export async function run(opts, deps) {
|
|
|
1204
1256
|
}
|
|
1205
1257
|
catch { /* ignore */ }
|
|
1206
1258
|
}
|
|
1259
|
+
else if (r.verdict?.cont === true && stopContUsed < 3) {
|
|
1260
|
+
stopCont = typeof r.verdict.message === 'string' && r.verdict.message
|
|
1261
|
+
? r.verdict.message
|
|
1262
|
+
: `stop hook ${hook.name} requested continuation`;
|
|
1263
|
+
}
|
|
1207
1264
|
}
|
|
1208
1265
|
catch { /* ignore — stop hooks never fail the turn */ }
|
|
1209
1266
|
}
|
package/dist/chat.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface ChatOptions {
|
|
|
20
20
|
}
|
|
21
21
|
/** Strip a trailing slash so we can append /chat/completions cleanly. */
|
|
22
22
|
export declare function normalizeBaseURL(url: string): string;
|
|
23
|
+
/** True for loopback hostnames (localhost / 127.0.0.0/8 / ::1). */
|
|
24
|
+
export declare function isLoopbackHost(host: string): boolean;
|
|
23
25
|
/**
|
|
24
26
|
* Validate that the base URL is HTTPS (or localhost over HTTP for local LLMs).
|
|
25
27
|
* Refuses to send the bearer token over a plaintext remote connection.
|
|
@@ -31,6 +33,14 @@ export declare function normalizeBaseURL(url: string): string;
|
|
|
31
33
|
export declare function assertSafeBaseURL(url: string, opts?: {
|
|
32
34
|
allowInsecure?: boolean;
|
|
33
35
|
}): void;
|
|
36
|
+
/**
|
|
37
|
+
* Remote MCP URL guard. Mirrors `assertSafeBaseURL`'s fail-closed posture but
|
|
38
|
+
* is named for MCP config/transports so callers do not have to import provider
|
|
39
|
+
* URL text.
|
|
40
|
+
*/
|
|
41
|
+
export declare function assertSafeRemoteURL(url: string, opts?: {
|
|
42
|
+
allowInsecure?: boolean;
|
|
43
|
+
}): void;
|
|
34
44
|
export declare function chat(prompt: string, system: string, modelOverride?: string, opts?: ChatOptions): Promise<void>;
|
|
35
45
|
/**
|
|
36
46
|
* Parse SSE frames and write text deltas to stdout. Handles
|
package/dist/chat.js
CHANGED
|
@@ -18,6 +18,17 @@ const MAX_ERROR_BODY_BYTES = 4_000;
|
|
|
18
18
|
export function normalizeBaseURL(url) {
|
|
19
19
|
return url.replace(/\/+$/, '');
|
|
20
20
|
}
|
|
21
|
+
/** True for loopback hostnames (localhost / 127.0.0.0/8 / ::1). */
|
|
22
|
+
export function isLoopbackHost(host) {
|
|
23
|
+
const h = host.toLowerCase().replace(/^\[|\]$/g, '');
|
|
24
|
+
if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0.0.0.0' || h === '::')
|
|
25
|
+
return true;
|
|
26
|
+
if (h.startsWith('127.')) {
|
|
27
|
+
const parts = h.split('.');
|
|
28
|
+
return parts.length === 4 && parts.every((p) => /^\d+$/.test(p) && Number(p) >= 0 && Number(p) <= 255);
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
21
32
|
/**
|
|
22
33
|
* Validate that the base URL is HTTPS (or localhost over HTTP for local LLMs).
|
|
23
34
|
* Refuses to send the bearer token over a plaintext remote connection.
|
|
@@ -41,14 +52,10 @@ export function assertSafeBaseURL(url, opts) {
|
|
|
41
52
|
if (opts?.allowInsecure === true || process.env.KLYRO_ALLOW_INSECURE === '1')
|
|
42
53
|
return;
|
|
43
54
|
const host = parsed.hostname.toLowerCase();
|
|
44
|
-
// Allow loopback
|
|
45
|
-
|
|
55
|
+
// Allow loopback without flag (shared helper); private LAN ranges keep
|
|
56
|
+
// the provider-path behavior below.
|
|
57
|
+
if (isLoopbackHost(host))
|
|
46
58
|
return;
|
|
47
|
-
if (host.startsWith('127.')) {
|
|
48
|
-
const parts = host.split('.');
|
|
49
|
-
if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p) && Number(p) >= 0 && Number(p) <= 255))
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
59
|
// Private ranges 10/8, 192.168/16, 172.16-31/12
|
|
53
60
|
if (/^10\.\d+\.\d+\.\d+$/.test(host))
|
|
54
61
|
return;
|
|
@@ -62,6 +69,31 @@ export function assertSafeBaseURL(url, opts) {
|
|
|
62
69
|
}
|
|
63
70
|
throw new Error(`Unsupported KLYRO_BASE_URL protocol: ${parsed.protocol}`);
|
|
64
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Remote MCP URL guard. Mirrors `assertSafeBaseURL`'s fail-closed posture but
|
|
74
|
+
* is named for MCP config/transports so callers do not have to import provider
|
|
75
|
+
* URL text.
|
|
76
|
+
*/
|
|
77
|
+
export function assertSafeRemoteURL(url, opts) {
|
|
78
|
+
let parsed;
|
|
79
|
+
try {
|
|
80
|
+
parsed = new URL(url);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new Error(`invalid remote MCP URL: ${url}`);
|
|
84
|
+
}
|
|
85
|
+
if (parsed.protocol === 'https:')
|
|
86
|
+
return;
|
|
87
|
+
if (parsed.protocol === 'http:') {
|
|
88
|
+
if (isLoopbackHost(parsed.hostname))
|
|
89
|
+
return;
|
|
90
|
+
if (opts?.allowInsecure === true || process.env.KLYRO_ALLOW_INSECURE === '1')
|
|
91
|
+
return;
|
|
92
|
+
throw new Error(`Refusing to connect to remote MCP server over plaintext HTTP to ${parsed.hostname}. ` +
|
|
93
|
+
`Use https:// or a loopback URL, or set KLYRO_ALLOW_INSECURE=1 for this terminal only (not recommended).`);
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`unsupported remote MCP URL protocol: ${parsed.protocol}`);
|
|
96
|
+
}
|
|
65
97
|
export async function chat(prompt, system, modelOverride, opts = {}) {
|
|
66
98
|
const baseURL = opts.baseURL ?? process.env.KLYRO_BASE_URL ?? 'https://api.openai.com/v1';
|
|
67
99
|
const apiKey = opts.apiKey ?? process.env.KLYRO_API_KEY;
|
|
@@ -21,6 +21,8 @@ export interface CheckpointInfo {
|
|
|
21
21
|
}
|
|
22
22
|
/** Numbered snapshot list for `/checkpoints` and the `/rewind` menu. */
|
|
23
23
|
export declare function listCheckpointInfo(cwd: string): Promise<CheckpointInfo[]>;
|
|
24
|
+
/** File paths a snapshot would restore (for `/rewind <n> preview`). */
|
|
25
|
+
export declare function snapshotFiles(cwd: string, id: string): Promise<string[]>;
|
|
24
26
|
export declare function diff(cwd: string, id?: string): Promise<string>;
|
|
25
27
|
export declare function undo(cwd: string, n?: number): Promise<void>;
|
|
26
28
|
export declare function rewind(cwd: string): Promise<void>;
|
|
@@ -174,6 +174,18 @@ export async function listCheckpointInfo(cwd) {
|
|
|
174
174
|
}
|
|
175
175
|
return out;
|
|
176
176
|
}
|
|
177
|
+
/** File paths a snapshot would restore (for `/rewind <n> preview`). */
|
|
178
|
+
export async function snapshotFiles(cwd, id) {
|
|
179
|
+
try {
|
|
180
|
+
const meta = JSON.parse(await fs.readFile(path.join(ckptDir(cwd), id, '.meta.json'), 'utf-8'));
|
|
181
|
+
const files = Array.isArray(meta.files) ? meta.files : [];
|
|
182
|
+
const missing = Array.isArray(meta.missing) ? meta.missing.map((f) => `${f} (deleted)`) : [];
|
|
183
|
+
return [...files, ...missing];
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
177
189
|
export async function diff(cwd, id) {
|
|
178
190
|
const ckpts = await listCheckpoints(cwd);
|
|
179
191
|
const target = id ?? ckpts[ckpts.length - 1];
|
package/dist/cli/auth.d.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 2.2 — klyro login / logout / aliases
|
|
3
|
-
* Stores masked key →
|
|
3
|
+
* Stores masked key → OS keychain when available, else
|
|
4
|
+
* ~/.klyro/credentials.json 0600 (with refuse-on-lax-perms reads).
|
|
4
5
|
*/
|
|
5
6
|
export declare function credPath(): string;
|
|
6
|
-
/** Persist one provider key
|
|
7
|
-
export declare function saveKey(provider: string, key: string): Promise<
|
|
7
|
+
/** Persist one provider key: OS keychain when available, else 0600 file. Never logs or returns the key. */
|
|
8
|
+
export declare function saveKey(provider: string, key: string): Promise<'keychain' | 'file'>;
|
|
9
|
+
/**
|
|
10
|
+
* Async key read: OS keychain first (when available), then the 0600 file
|
|
11
|
+
* (with refuse-on-lax-perms). Use in async paths (provider resolution,
|
|
12
|
+
* eval, setup); sync contexts keep `getStoredKey` (file only).
|
|
13
|
+
*/
|
|
14
|
+
export declare function getStoredKeyAsync(provider: string): Promise<string | undefined>;
|
|
8
15
|
/** Which providers have stored keys (names only — never values). */
|
|
9
16
|
export declare function storedProviders(): string[];
|
|
10
17
|
export declare const LOGIN_DEFAULTS: Record<string, {
|
package/dist/cli/auth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 2.2 — klyro login / logout / aliases
|
|
3
|
-
* Stores masked key →
|
|
3
|
+
* Stores masked key → OS keychain when available, else
|
|
4
|
+
* ~/.klyro/credentials.json 0600 (with refuse-on-lax-perms reads).
|
|
4
5
|
*/
|
|
5
6
|
import * as fs from 'node:fs/promises';
|
|
6
7
|
import * as fsSync from 'node:fs';
|
|
@@ -15,21 +16,44 @@ export function credPath() {
|
|
|
15
16
|
const home = os.homedir() || process.cwd();
|
|
16
17
|
return path.join(home, '.klyro', 'credentials.json');
|
|
17
18
|
}
|
|
18
|
-
/** Persist one provider key
|
|
19
|
+
/** Persist one provider key: OS keychain when available, else 0600 file. Never logs or returns the key. */
|
|
19
20
|
export async function saveKey(provider, key) {
|
|
21
|
+
const trimmed = key.trim();
|
|
22
|
+
try {
|
|
23
|
+
const { keychainSet } = await import('./keychain.js');
|
|
24
|
+
if (await keychainSet(provider, trimmed))
|
|
25
|
+
return 'keychain';
|
|
26
|
+
}
|
|
27
|
+
catch { /* fall through to file */ }
|
|
20
28
|
const creds = {};
|
|
21
29
|
try {
|
|
22
30
|
const raw = await fs.readFile(credPath(), 'utf-8');
|
|
23
31
|
Object.assign(creds, JSON.parse(raw));
|
|
24
32
|
}
|
|
25
33
|
catch { /* ignore */ }
|
|
26
|
-
creds[provider] =
|
|
34
|
+
creds[provider] = trimmed;
|
|
27
35
|
await fs.mkdir(path.dirname(credPath()), { recursive: true });
|
|
28
36
|
await fs.writeFile(credPath(), JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
29
37
|
try {
|
|
30
38
|
await fs.chmod(credPath(), 0o600);
|
|
31
39
|
}
|
|
32
40
|
catch { /* ignore on Windows */ }
|
|
41
|
+
return 'file';
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Async key read: OS keychain first (when available), then the 0600 file
|
|
45
|
+
* (with refuse-on-lax-perms). Use in async paths (provider resolution,
|
|
46
|
+
* eval, setup); sync contexts keep `getStoredKey` (file only).
|
|
47
|
+
*/
|
|
48
|
+
export async function getStoredKeyAsync(provider) {
|
|
49
|
+
try {
|
|
50
|
+
const { keychainGet } = await import('./keychain.js');
|
|
51
|
+
const v = await keychainGet(provider);
|
|
52
|
+
if (v)
|
|
53
|
+
return v;
|
|
54
|
+
}
|
|
55
|
+
catch { /* fall through to file */ }
|
|
56
|
+
return getStoredKey(provider);
|
|
33
57
|
}
|
|
34
58
|
/** Which providers have stored keys (names only — never values). */
|
|
35
59
|
export function storedProviders() {
|
|
@@ -78,8 +102,10 @@ export async function runLogin() {
|
|
|
78
102
|
const model = ((await rl.question(`Model [${defs.model}]: `)) || defs.model).trim();
|
|
79
103
|
const storeProvider = provider === 'local' ? 'openai' : provider;
|
|
80
104
|
if (key.trim()) {
|
|
81
|
-
await saveKey(storeProvider, key);
|
|
82
|
-
process.stdout.write(
|
|
105
|
+
const where = await saveKey(storeProvider, key);
|
|
106
|
+
process.stdout.write(where === 'keychain'
|
|
107
|
+
? `Saved ${storeProvider} key to the OS keychain\n`
|
|
108
|
+
: `Saved ${storeProvider} key to ${credPath()} (0600)\n`);
|
|
83
109
|
}
|
|
84
110
|
// Persist non-secret settings (merged with existing config, never clobbers).
|
|
85
111
|
const { loadConfig, saveConfig } = await import('./config.js');
|
|
@@ -106,6 +132,18 @@ export async function runLogin() {
|
|
|
106
132
|
}
|
|
107
133
|
}
|
|
108
134
|
export async function runLogout(provider) {
|
|
135
|
+
// Best-effort keychain removal first (a keychain-held key must not survive
|
|
136
|
+
// a file-only logout).
|
|
137
|
+
try {
|
|
138
|
+
const { keychainDelete } = await import('./keychain.js');
|
|
139
|
+
if (provider)
|
|
140
|
+
await keychainDelete(provider);
|
|
141
|
+
else {
|
|
142
|
+
await keychainDelete('openai');
|
|
143
|
+
await keychainDelete('anthropic');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch { /* ignore */ }
|
|
109
147
|
try {
|
|
110
148
|
const raw = await fs.readFile(credPath(), 'utf-8');
|
|
111
149
|
const creds = JSON.parse(raw);
|
package/dist/cli/doctor.js
CHANGED
|
@@ -198,7 +198,6 @@ export async function runDoctor(opts = {}) {
|
|
|
198
198
|
process.stdout.write('─'.repeat(40) + '\n');
|
|
199
199
|
for (const c of checks) {
|
|
200
200
|
const glyph = c.ok ? '✓' : '✗';
|
|
201
|
-
const color = c.ok ? '' : '';
|
|
202
201
|
process.stdout.write(`${glyph} ${c.name.padEnd(14)} ${c.detail}\n`);
|
|
203
202
|
}
|
|
204
203
|
process.stdout.write('─'.repeat(40) + '\n');
|