kritya 0.8.26-beta → 0.8.28-beta
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/README.md +36 -6
- package/dist/agent/skills.js +2 -1
- package/dist/commands/doctor.js +380 -0
- package/dist/config/config.js +2 -0
- package/dist/config/debug.js +3 -12
- package/dist/config/models.js +18 -0
- package/dist/index.js +56 -3
- package/dist/mcp/client.js +5 -4
- package/dist/net/urlSafety.js +3 -3
- package/dist/plugins/discover.js +2 -1
- package/dist/plugins/mcp.js +2 -1
- package/dist/repomap/repoMap.js +3 -3
- package/dist/stderr.js +47 -0
- package/dist/telemetry/metrics.js +11 -0
- package/dist/telemetry/tracer.js +2 -6
- package/dist/tools/glob.js +4 -4
- package/dist/tools/grep.js +4 -4
- package/dist/tools/ignore.js +2 -2
- package/dist/ui/ApiKeySetupPrompt.js +2 -2
- package/dist/ui/App.js +106 -86
- package/dist/ui/ElicitationPrompt.js +2 -2
- package/dist/ui/Markdown.js +5 -5
- package/dist/ui/McpTrustPrompt.js +1 -1
- package/dist/ui/PermissionPrompt.js +1 -1
- package/dist/ui/SelectList.js +1 -1
- package/dist/ui/Spinner.js +12 -8
- package/dist/ui/StatusLine.js +1 -1
- package/dist/ui/StreamViewport.js +29 -0
- package/dist/ui/TextInput.js +139 -0
- package/dist/ui/TranscriptItem.js +5 -4
- package/dist/ui/openInEditor.js +34 -0
- package/dist/ui/viewport.js +17 -50
- package/dist/undo/undo.js +11 -0
- package/dist/update/check.js +189 -0
- package/package.json +17 -15
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ review → fix` for building something new end-to-end
|
|
|
55
55
|
|
|
56
56
|
## Setup
|
|
57
57
|
|
|
58
|
-
Requires Node.js >=22.
|
|
58
|
+
Requires Node.js >=22.19.0.
|
|
59
59
|
|
|
60
60
|
1. Get an API key from your chosen provider (click any
|
|
61
61
|
model → "Get API Key").
|
|
@@ -132,6 +132,25 @@ kritya [directory] [options]
|
|
|
132
132
|
-v, --version version
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
+
Subcommands — these run instead of starting a session:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
kritya doctor [dir] [--json] [--offline]
|
|
139
|
+
check that this installation is set up correctly: the
|
|
140
|
+
Node version against the package's engines, the config
|
|
141
|
+
file, the active provider and whether its API key
|
|
142
|
+
resolves *and* the endpoint accepts it, workspace trust
|
|
143
|
+
and git state, MCP servers (including unset ${VAR}s),
|
|
144
|
+
sandbox availability, persistence settings, skills and
|
|
145
|
+
hooks, and whether a newer release exists
|
|
146
|
+
kritya audit ... inspect the local audit log (see docs/CONFIGURATION.md)
|
|
147
|
+
kritya skills ... list and validate discovered skills
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`kritya doctor` exits non-zero only when something is actually broken; the
|
|
151
|
+
warnings it prints are "this works, but not the way you may expect" — an
|
|
152
|
+
unavailable sandbox, an untrusted workspace, a lagging `latest` tag.
|
|
153
|
+
|
|
135
154
|
In-session commands (type `/` to see them with autocomplete; letters filter the list):
|
|
136
155
|
|
|
137
156
|
| Command | What it does |
|
|
@@ -170,7 +189,8 @@ In-session commands (type `/` to see them with autocomplete; letters filter the
|
|
|
170
189
|
Custom `/commands` you define (see [docs/CONFIGURATION.md](docs/CONFIGURATION.md#custom-slash-commands)) also appear here.
|
|
171
190
|
|
|
172
191
|
`Esc` cancels a running request. `↑/↓` recalls input history. `Ctrl+O` toggles
|
|
173
|
-
full tool output. `Ctrl+
|
|
192
|
+
full tool output. `Ctrl+E` opens the file the agent touched most recently in
|
|
193
|
+
`$EDITOR`. `Ctrl+K` is the kill switch (see below). `Ctrl+C` exits.
|
|
174
194
|
|
|
175
195
|
### More features
|
|
176
196
|
|
|
@@ -432,10 +452,20 @@ model config lives in `~/.kritya/config.json`.
|
|
|
432
452
|
kritya collects **no telemetry** and phones home to nothing of its own by
|
|
433
453
|
default. Network requests go only to the model provider you configure (and to
|
|
434
454
|
Tavily if you use web search). Sessions and config stay on your machine under
|
|
435
|
-
`~/.kritya/`.
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
455
|
+
`~/.kritya/`.
|
|
456
|
+
|
|
457
|
+
Two things leave the machine beyond the model provider, and both are opt-out:
|
|
458
|
+
|
|
459
|
+
- **The update check.** `kritya --version` and `kritya doctor` ask the public
|
|
460
|
+
npm registry for the latest published version, so they can tell you when
|
|
461
|
+
you're behind. The answer is cached in `~/.kritya/update-check.json` for 24
|
|
462
|
+
hours, so it is one request per day at most, and it only runs when stdout is
|
|
463
|
+
a TTY — a script or a pipe never triggers it and never sees the notice.
|
|
464
|
+
`KRITYA_NO_UPDATE_CHECK=1` turns it off entirely.
|
|
465
|
+
- **Tracing**, if you opt in via `KRITYA_OTEL_ENDPOINT` (see
|
|
466
|
+
[Audit log & telemetry](docs/CONFIGURATION.md#audit-log--telemetry)): spans
|
|
467
|
+
are sent to the OpenTelemetry Collector you point it at — nothing is
|
|
468
|
+
exported anywhere unless you configure that endpoint yourself.
|
|
439
469
|
|
|
440
470
|
## Development
|
|
441
471
|
|
package/dist/agent/skills.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { writeStderr } from "../stderr.js";
|
|
4
5
|
import { pluginsDir, pluginSkillsRoots, scanPlugins, userPluginsDir } from "../plugins/discover.js";
|
|
5
6
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
6
7
|
const KEY_VALUE_RE = /^([A-Za-z0-9_-]+):\s*(.*)$/;
|
|
@@ -73,7 +74,7 @@ function unquote(value) {
|
|
|
73
74
|
return value;
|
|
74
75
|
}
|
|
75
76
|
let warnSink = (message) => {
|
|
76
|
-
|
|
77
|
+
writeStderr(`kritya: ${message}\n`);
|
|
77
78
|
};
|
|
78
79
|
function warn(message) {
|
|
79
80
|
warnSink(message);
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { BUILTIN_PROVIDERS, CONFIG_DIR, CONFIG_FILE, legacyGlobalModel, loadConfig, privacyModeFor, resolveProvider, } from "../config/config.js";
|
|
6
|
+
import { DEFAULT_MODEL, contextWindowFor } from "../config/models.js";
|
|
7
|
+
import { retentionDaysFor } from "../config/retention.js";
|
|
8
|
+
import { assertJsonWithinLimits } from "../config/jsonSafety.js";
|
|
9
|
+
import { isGitRepo } from "../agent/worktree.js";
|
|
10
|
+
import { loadHooks } from "../hooks/hooks.js";
|
|
11
|
+
import { loadProjectMcpServers, missingVars } from "../mcp/servers.js";
|
|
12
|
+
import { scanSkillsDetailed, skillsDir, userSkillsDir } from "../agent/skills.js";
|
|
13
|
+
import { defaultSandboxMode, sandboxAvailable, sandboxUnavailableReason, } from "../shell/sandbox.js";
|
|
14
|
+
import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel } from "../provider/switchyardSidecar.js";
|
|
15
|
+
import { gatedContentHash, isTrusted } from "../trust/trust.js";
|
|
16
|
+
import { checkForUpdate, updateCheckDisabled } from "../update/check.js";
|
|
17
|
+
import { VERSION } from "../version.js";
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const pkg = require("../../package.json");
|
|
20
|
+
export const DOCTOR_USAGE = `kritya doctor — check that this installation is set up correctly
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
kritya doctor [dir] diagnose the setup for [dir] (default: current directory)
|
|
24
|
+
kritya doctor [dir] --json machine-readable output
|
|
25
|
+
kritya doctor [dir] --offline skip the network probes (provider, update check)
|
|
26
|
+
|
|
27
|
+
Exits non-zero if any check failed. Warnings alone do not fail it — most are
|
|
28
|
+
"this works, but not the way you may expect" (an unavailable sandbox, an
|
|
29
|
+
untrusted workspace).
|
|
30
|
+
|
|
31
|
+
Checks: Node version against the package's engines, config file validity, the
|
|
32
|
+
active provider and whether its key resolves and the endpoint answers, workspace
|
|
33
|
+
trust and git state, MCP servers, sandbox availability, persistence/privacy
|
|
34
|
+
settings, discovered skills and hooks, and whether a newer release exists.`;
|
|
35
|
+
const MARKS = { ok: "✓", warn: "!", fail: "✗" };
|
|
36
|
+
function ok(label) {
|
|
37
|
+
return { level: "ok", label };
|
|
38
|
+
}
|
|
39
|
+
function warn(label) {
|
|
40
|
+
return { level: "warn", label };
|
|
41
|
+
}
|
|
42
|
+
function fail(label) {
|
|
43
|
+
return { level: "fail", label };
|
|
44
|
+
}
|
|
45
|
+
/** A path shortened to `~/…` when it lives under the home directory. */
|
|
46
|
+
function tilde(p) {
|
|
47
|
+
const home = os.homedir();
|
|
48
|
+
return p === home || p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p;
|
|
49
|
+
}
|
|
50
|
+
function exists(p) {
|
|
51
|
+
try {
|
|
52
|
+
return fs.existsSync(p);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function runtimeSection() {
|
|
59
|
+
const checks = [];
|
|
60
|
+
const required = pkg.engines?.node;
|
|
61
|
+
const actual = process.versions.node;
|
|
62
|
+
const [major = 0, minor = 0] = actual.split(".").map(Number);
|
|
63
|
+
// engines.node is `>=22.19.0`. Parsed by hand because the only operators
|
|
64
|
+
// this package ever uses are `>=` and a bare version.
|
|
65
|
+
const minimum = required?.replace(/^>=\s*/, "");
|
|
66
|
+
const [reqMajor = 0, reqMinor = 0] = (minimum ?? "").split(".").map(Number);
|
|
67
|
+
const tooOld = Boolean(minimum) && (major < reqMajor || (major === reqMajor && minor < reqMinor));
|
|
68
|
+
checks.push(tooOld
|
|
69
|
+
? fail(`node ${actual} — this package requires >=${minimum}`)
|
|
70
|
+
: ok(`node ${actual}${minimum ? ` (requires >=${minimum})` : ""}`));
|
|
71
|
+
checks.push(ok(`${process.platform} ${process.arch}`));
|
|
72
|
+
checks.push(ok(`kritya ${VERSION}`));
|
|
73
|
+
return { title: "Runtime", checks };
|
|
74
|
+
}
|
|
75
|
+
async function updateSection(options) {
|
|
76
|
+
if (options.offline) {
|
|
77
|
+
return { title: "Releases", checks: [warn("update check skipped (--offline)")] };
|
|
78
|
+
}
|
|
79
|
+
if (updateCheckDisabled()) {
|
|
80
|
+
return {
|
|
81
|
+
title: "Releases",
|
|
82
|
+
checks: [warn(`update check disabled by ${"KRITYA_NO_UPDATE_CHECK"}`)],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const status = await checkForUpdate({ fetchImpl: options.fetchImpl, force: true });
|
|
86
|
+
const checks = [];
|
|
87
|
+
if (status.source === "unavailable") {
|
|
88
|
+
checks.push(warn(`could not reach the npm registry — ${status.reason}`));
|
|
89
|
+
return { title: "Releases", checks };
|
|
90
|
+
}
|
|
91
|
+
checks.push(ok(`installed ${status.current}`));
|
|
92
|
+
if (status.outdated) {
|
|
93
|
+
checks.push(warn(`newer version available: ${status.latest} — npm install -g kritya@${status.latest}`));
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
checks.push(ok(`newest published: ${status.latest}`));
|
|
97
|
+
}
|
|
98
|
+
return { title: "Releases", checks };
|
|
99
|
+
}
|
|
100
|
+
function configSection(options) {
|
|
101
|
+
const configDir = options.configDir ?? CONFIG_DIR;
|
|
102
|
+
const configFile = options.configDir ? path.join(options.configDir, "config.json") : CONFIG_FILE;
|
|
103
|
+
const checks = [];
|
|
104
|
+
if (!exists(configFile)) {
|
|
105
|
+
checks.push(warn(`${tilde(configFile)} not found — built-in defaults are in use`));
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
try {
|
|
109
|
+
const raw = fs.readFileSync(configFile, "utf8");
|
|
110
|
+
// The same two guards loadConfig applies. Reported separately because
|
|
111
|
+
// loadConfig swallows both and returns {} — which otherwise looks
|
|
112
|
+
// identical to "no config at all", the classic "my config isn't taking
|
|
113
|
+
// effect" report.
|
|
114
|
+
assertJsonWithinLimits(raw, "config.json");
|
|
115
|
+
JSON.parse(raw);
|
|
116
|
+
checks.push(ok(`${tilde(configFile)} parsed`));
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
checks.push(fail(`${tilde(configFile)} is unusable: ${err instanceof Error ? err.message : String(err)}`));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
checks.push(exists(configDir)
|
|
123
|
+
? ok(`${tilde(configDir)} exists`)
|
|
124
|
+
: warn(`${tilde(configDir)} not created yet`));
|
|
125
|
+
return { title: "Configuration", checks };
|
|
126
|
+
}
|
|
127
|
+
function providerSection(config, provider) {
|
|
128
|
+
const checks = [];
|
|
129
|
+
checks.push(ok(`active provider: ${provider.name}`));
|
|
130
|
+
checks.push(ok(`base URL: ${provider.baseUrl}`));
|
|
131
|
+
if (provider.apiKey) {
|
|
132
|
+
const source = provider.apiKeyEnv
|
|
133
|
+
? `${provider.apiKeyEnv} is set`
|
|
134
|
+
: "a literal key is configured";
|
|
135
|
+
checks.push(ok(`API key resolved (${source})`));
|
|
136
|
+
}
|
|
137
|
+
else if (provider.apiKeyEnv) {
|
|
138
|
+
checks.push(fail(`no API key — ${provider.apiKeyEnv} is unset and no literal key is configured. ` +
|
|
139
|
+
`Set it in the environment, in a .env file, or under providers.${provider.name}.apiKey ` +
|
|
140
|
+
`in ${tilde(CONFIG_FILE)}`));
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
checks.push(fail(`no API key resolved for provider "${provider.name}"`));
|
|
144
|
+
}
|
|
145
|
+
const model = resolveEffectiveModel(provider.name, [config.providers?.[provider.name]?.model, legacyGlobalModel(config, provider.name)], provider.name === "switchyard" ? SWITCHYARD_ROUTE_ID : DEFAULT_MODEL);
|
|
146
|
+
checks.push(ok(`model: ${model} (context ${contextWindowFor(model, config).toLocaleString("en-US")})`));
|
|
147
|
+
// A pinned provider name with no matching entry is a silent misconfiguration:
|
|
148
|
+
// resolveProvider falls back to NVIDIA's URL, so requests go somewhere the
|
|
149
|
+
// user never intended. Built-ins count as known even with no config entry.
|
|
150
|
+
const known = Boolean(BUILTIN_PROVIDERS[provider.name] ?? config.providers?.[provider.name]);
|
|
151
|
+
if (!known) {
|
|
152
|
+
checks.push(warn(`provider "${provider.name}" is not a built-in and has no config.providers entry — ` +
|
|
153
|
+
`falling back to the default base URL`));
|
|
154
|
+
}
|
|
155
|
+
return { title: "Provider", checks };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Ask the provider's `/models` endpoint whether the key works. A 401/403 here
|
|
159
|
+
* is the single most useful thing doctor can report — it is the difference
|
|
160
|
+
* between "your setup is wrong" and "the model is having a bad day".
|
|
161
|
+
*
|
|
162
|
+
* Exported so each response class can be tested without a real provider.
|
|
163
|
+
*/
|
|
164
|
+
export async function probeProvider(provider, fetchImpl, timeoutMs = 5_000) {
|
|
165
|
+
const url = `${provider.baseUrl.replace(/\/+$/, "")}/models`;
|
|
166
|
+
try {
|
|
167
|
+
const response = await (fetchImpl ?? fetch)(url, {
|
|
168
|
+
headers: provider.apiKey ? { authorization: `Bearer ${provider.apiKey}` } : {},
|
|
169
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
170
|
+
});
|
|
171
|
+
if (response.ok)
|
|
172
|
+
return ok(`endpoint reachable — GET ${url} returned ${response.status}`);
|
|
173
|
+
if (response.status === 401 || response.status === 403) {
|
|
174
|
+
return fail(`the endpoint rejected the API key — GET ${url} returned ${response.status}. ` +
|
|
175
|
+
`Check that the key is valid, not expired, and belongs to this provider.`);
|
|
176
|
+
}
|
|
177
|
+
if (response.status === 404) {
|
|
178
|
+
return warn(`endpoint reachable, but GET ${url} returned 404 — not every provider ` +
|
|
179
|
+
`implements /models, so this is not necessarily a problem`);
|
|
180
|
+
}
|
|
181
|
+
return warn(`GET ${url} returned ${response.status}`);
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
return fail(`could not reach ${url} — ${err instanceof Error ? err.message : String(err)}. ` +
|
|
185
|
+
`Check your network, proxy, and the provider's baseUrl.`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function workspaceSection(workspace, config) {
|
|
189
|
+
const checks = [];
|
|
190
|
+
const krityaDir = path.join(workspace, ".kritya");
|
|
191
|
+
checks.push(ok(tilde(workspace)));
|
|
192
|
+
checks.push(isGitRepo(workspace)
|
|
193
|
+
? ok("git repository")
|
|
194
|
+
: warn("not a git repository — /commit and isolated write subagents are unavailable"));
|
|
195
|
+
// Trust gates the workspace's own settings.json allow rules, hooks, .env and
|
|
196
|
+
// custom commands. Worth stating explicitly: an untrusted workspace looks
|
|
197
|
+
// like "my hooks just don't run".
|
|
198
|
+
const hash = gatedContentHash(workspace);
|
|
199
|
+
if (!hash) {
|
|
200
|
+
checks.push(ok("no trust-gated content present"));
|
|
201
|
+
}
|
|
202
|
+
else if (isTrusted(workspace, hash)) {
|
|
203
|
+
checks.push(ok("workspace trust-gated content is trusted"));
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
checks.push(warn("workspace is NOT trusted — .kritya/settings.json allow rules, hooks, .env and " +
|
|
207
|
+
".kritya/commands/*.md will be ignored until you accept the prompt"));
|
|
208
|
+
}
|
|
209
|
+
for (const [rel, note] of [
|
|
210
|
+
["settings.json", "permission rules"],
|
|
211
|
+
[".mcp.json", "project MCP servers"],
|
|
212
|
+
[".env", "workspace environment"],
|
|
213
|
+
]) {
|
|
214
|
+
const file = rel === ".mcp.json" ? path.join(workspace, rel) : path.join(krityaDir, rel);
|
|
215
|
+
if (exists(file))
|
|
216
|
+
checks.push(ok(`${rel} present (${note})`));
|
|
217
|
+
}
|
|
218
|
+
const commandsDir = path.join(krityaDir, "commands");
|
|
219
|
+
if (exists(commandsDir)) {
|
|
220
|
+
const count = fs.readdirSync(commandsDir).filter((f) => f.endsWith(".md")).length;
|
|
221
|
+
checks.push(ok(`${count} custom command(s) in .kritya/commands`));
|
|
222
|
+
}
|
|
223
|
+
// Surfaced here rather than only on use: a configured MCP server whose env
|
|
224
|
+
// var is missing fails at load with a warning that is easy to miss.
|
|
225
|
+
const servers = {
|
|
226
|
+
...loadProjectMcpServers(workspace),
|
|
227
|
+
...config.mcpServers,
|
|
228
|
+
};
|
|
229
|
+
const names = Object.keys(servers);
|
|
230
|
+
if (names.length === 0) {
|
|
231
|
+
checks.push(ok("no MCP servers configured"));
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
checks.push(ok(`${names.length} MCP server(s) configured: ${names.join(", ")}`));
|
|
235
|
+
for (const name of names) {
|
|
236
|
+
const missing = missingVars(servers[name]).filter((v) => process.env[v] === undefined);
|
|
237
|
+
if (missing.length > 0) {
|
|
238
|
+
checks.push(warn(`MCP server "${name}" references unset variable(s): ${missing.join(", ")}`));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { title: "Workspace", checks };
|
|
243
|
+
}
|
|
244
|
+
function safetySection(config) {
|
|
245
|
+
const checks = [];
|
|
246
|
+
const mode = config.sandboxExec ?? defaultSandboxMode();
|
|
247
|
+
const available = sandboxAvailable();
|
|
248
|
+
if (mode === "off") {
|
|
249
|
+
checks.push(warn('sandboxExec is "off" — shell commands run unconfined'));
|
|
250
|
+
}
|
|
251
|
+
else if (available) {
|
|
252
|
+
checks.push(ok(`sandboxExec "${mode}" and a sandbox backend is available`));
|
|
253
|
+
}
|
|
254
|
+
else if (mode === "strict") {
|
|
255
|
+
// "strict" fails closed — the command is refused rather than run
|
|
256
|
+
// unprotected, so this is not a "falls back with a warning" situation.
|
|
257
|
+
checks.push(warn(`sandboxExec is "strict" but ${sandboxUnavailableReason()} — commands that would be ` +
|
|
258
|
+
`sandboxed are refused outright rather than run unprotected`));
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
checks.push(warn(`sandboxExec is "${mode}" but ${sandboxUnavailableReason()} — sandboxed commands ` +
|
|
262
|
+
`fall back to unsandboxed execution with a warning`));
|
|
263
|
+
}
|
|
264
|
+
const privacy = privacyModeFor(config);
|
|
265
|
+
checks.push(privacy
|
|
266
|
+
? warn("privacy mode is ON — transcripts, audit logs and telemetry are not written")
|
|
267
|
+
: ok("privacy mode is off"));
|
|
268
|
+
const audit = config.audit ?? "on";
|
|
269
|
+
const otel = config.otel ?? "off";
|
|
270
|
+
const retention = retentionDaysFor(config);
|
|
271
|
+
checks.push(ok(`audit ${audit}, telemetry ${otel}`));
|
|
272
|
+
checks.push(retention > 0
|
|
273
|
+
? ok(`retention ${retention} day(s)`)
|
|
274
|
+
: warn(`retention disabled (${retention}) — transcripts and logs are kept forever`));
|
|
275
|
+
return { title: "Safety & persistence", checks };
|
|
276
|
+
}
|
|
277
|
+
function extensionsSection(workspace, config) {
|
|
278
|
+
const checks = [];
|
|
279
|
+
const hash = gatedContentHash(workspace);
|
|
280
|
+
const trusted = !hash || isTrusted(workspace, hash);
|
|
281
|
+
const { loaded, skipped } = scanSkillsDetailed([skillsDir(workspace), userSkillsDir()]);
|
|
282
|
+
checks.push(skipped.length === 0
|
|
283
|
+
? ok(`${loaded.length} skill(s) discovered`)
|
|
284
|
+
: warn(`${loaded.length} skill(s) discovered, ${skipped.length} skipped — ` +
|
|
285
|
+
skipped.map((s) => `${s.name} (${s.reason})`).join("; ")));
|
|
286
|
+
const hooks = loadHooks(workspace, trusted);
|
|
287
|
+
// HooksConfig is keyed by event, each holding an array — sum across events
|
|
288
|
+
// rather than looking for a `hooks` key that does not exist.
|
|
289
|
+
const hookCount = Object.values(hooks).reduce((total, list) => total + (Array.isArray(list) ? list.length : 0), 0);
|
|
290
|
+
checks.push(hookCount > 0 ? ok(`${hookCount} hook(s) configured`) : ok("no hooks configured"));
|
|
291
|
+
checks.push(ok(`KRITYA.md ${exists(path.join(workspace, "KRITYA.md")) ? "present" : "not present"}`));
|
|
292
|
+
checks.push(ok(`config.mcpServers: ${Object.keys(config.mcpServers ?? {}).length}`));
|
|
293
|
+
return { title: "Skills & hooks", checks };
|
|
294
|
+
}
|
|
295
|
+
/** Every diagnostic, in display order. Exported so it can be tested directly. */
|
|
296
|
+
export async function collectDiagnostics(options = {}) {
|
|
297
|
+
const workspace = path.resolve(options.workspace ?? ".");
|
|
298
|
+
const config = options.config ?? loadConfig();
|
|
299
|
+
const configSec = configSection(options);
|
|
300
|
+
const sections = [runtimeSection(), await updateSection(options), configSec];
|
|
301
|
+
if (!exists(workspace) || !fs.statSync(workspace).isDirectory()) {
|
|
302
|
+
sections.push({
|
|
303
|
+
title: "Workspace",
|
|
304
|
+
checks: [fail(`${workspace} is not a directory`)],
|
|
305
|
+
});
|
|
306
|
+
return sections;
|
|
307
|
+
}
|
|
308
|
+
const resolved = resolveProvider(config);
|
|
309
|
+
const provider = providerSection(config, resolved);
|
|
310
|
+
if (options.offline) {
|
|
311
|
+
provider.checks.push(warn("provider endpoint not probed (--offline)"));
|
|
312
|
+
}
|
|
313
|
+
else if (resolved.name === "switchyard") {
|
|
314
|
+
// Its baseUrl is a placeholder; the real client is a local sidecar that
|
|
315
|
+
// only exists for the lifetime of a session, so there is nothing to probe.
|
|
316
|
+
provider.checks.push(warn("endpoint not probed — switchyard runs a local sidecar, started on launch"));
|
|
317
|
+
}
|
|
318
|
+
else if (resolved.apiKey) {
|
|
319
|
+
provider.checks.push(await probeProvider(resolved, options.fetchImpl));
|
|
320
|
+
}
|
|
321
|
+
sections.push(provider);
|
|
322
|
+
sections.push(workspaceSection(workspace, config));
|
|
323
|
+
sections.push(safetySection(config));
|
|
324
|
+
sections.push(extensionsSection(workspace, config));
|
|
325
|
+
return sections;
|
|
326
|
+
}
|
|
327
|
+
export function summarize(sections, workspace) {
|
|
328
|
+
const checks = sections.flatMap((s) => s.checks);
|
|
329
|
+
return {
|
|
330
|
+
version: VERSION,
|
|
331
|
+
workspace,
|
|
332
|
+
sections,
|
|
333
|
+
failures: checks.filter((c) => c.level === "fail").length,
|
|
334
|
+
warnings: checks.filter((c) => c.level === "warn").length,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
/** Exported for tests: the human-readable rendering, given the collected checks. */
|
|
338
|
+
export function renderReport(report) {
|
|
339
|
+
const lines = [
|
|
340
|
+
`kritya doctor — ${report.version}`,
|
|
341
|
+
`workspace: ${tilde(report.workspace)}`,
|
|
342
|
+
"",
|
|
343
|
+
];
|
|
344
|
+
for (const section of report.sections) {
|
|
345
|
+
lines.push(section.title);
|
|
346
|
+
for (const check of section.checks)
|
|
347
|
+
lines.push(` ${MARKS[check.level]} ${check.label}`);
|
|
348
|
+
lines.push("");
|
|
349
|
+
}
|
|
350
|
+
if (report.failures > 0) {
|
|
351
|
+
lines.push(`${report.failures} problem(s) found${report.warnings ? `, ${report.warnings} warning(s)` : ""}.`);
|
|
352
|
+
}
|
|
353
|
+
else if (report.warnings > 0) {
|
|
354
|
+
lines.push(`No problems found (${report.warnings} warning(s) — see ! above).`);
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
lines.push("No problems found.");
|
|
358
|
+
}
|
|
359
|
+
return lines.join("\n");
|
|
360
|
+
}
|
|
361
|
+
/** Handles `kritya doctor ...`. Resolves to the process exit code. */
|
|
362
|
+
export async function runDoctorCli(argv, options = {}) {
|
|
363
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
364
|
+
console.log(DOCTOR_USAGE);
|
|
365
|
+
return 0;
|
|
366
|
+
}
|
|
367
|
+
const json = argv.includes("--json");
|
|
368
|
+
const offline = argv.includes("--offline");
|
|
369
|
+
const dirArg = argv.find((a) => !a.startsWith("-"));
|
|
370
|
+
const workspace = path.resolve(options.workspace ?? dirArg ?? ".");
|
|
371
|
+
const sections = await collectDiagnostics({ ...options, workspace, offline });
|
|
372
|
+
const report = summarize(sections, workspace);
|
|
373
|
+
if (json) {
|
|
374
|
+
console.log(JSON.stringify(report, null, 2));
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
console.log(renderReport(report));
|
|
378
|
+
}
|
|
379
|
+
return report.failures > 0 ? 1 : 0;
|
|
380
|
+
}
|
package/dist/config/config.js
CHANGED
|
@@ -161,6 +161,8 @@ export function resolveProvider(config, override) {
|
|
|
161
161
|
apiKey = merged.apiKey;
|
|
162
162
|
if (!apiKey && name === "nvidia")
|
|
163
163
|
apiKey = process.env.NVIDIA_API_KEY || config.apiKey;
|
|
164
|
+
if (apiKey)
|
|
165
|
+
apiKey = apiKey.trim();
|
|
164
166
|
return {
|
|
165
167
|
name,
|
|
166
168
|
baseUrl,
|
package/dist/config/debug.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { writeStderr } from "../stderr.js";
|
|
1
2
|
/**
|
|
2
3
|
* Most best-effort persistence (session/audit/telemetry writes, config and
|
|
3
4
|
* trust-file reads, hook loading, …) deliberately swallows its own errors —
|
|
@@ -10,12 +11,7 @@ export function debugLog(context, err) {
|
|
|
10
11
|
if ((process.env.KRITYA_DEBUG ?? "").toLowerCase() !== "1" && process.env.KRITYA_DEBUG !== "true")
|
|
11
12
|
return;
|
|
12
13
|
const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
13
|
-
|
|
14
|
-
process.stderr.write(`[kritya debug] ${context}: ${message}\n`);
|
|
15
|
-
}
|
|
16
|
-
catch {
|
|
17
|
-
// stderr itself failing isn't something debug logging can do anything about
|
|
18
|
-
}
|
|
14
|
+
writeStderr(`[kritya debug] ${context}: ${message}\n`);
|
|
19
15
|
}
|
|
20
16
|
/**
|
|
21
17
|
* For best-effort persistence that a user actually needs to know failed
|
|
@@ -39,12 +35,7 @@ export function warnUser(context, err) {
|
|
|
39
35
|
}
|
|
40
36
|
warnedContexts.add(context);
|
|
41
37
|
const message = err instanceof Error ? err.message : String(err);
|
|
42
|
-
|
|
43
|
-
process.stderr.write(`[kritya] warning: ${context} failed: ${message}\n`);
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
// stderr itself failing isn't something this can do anything about
|
|
47
|
-
}
|
|
38
|
+
writeStderr(`[kritya] warning: ${context} failed: ${message}\n`);
|
|
48
39
|
debugLog(context, err);
|
|
49
40
|
}
|
|
50
41
|
/** Contexts currently warning via warnPersistenceFailure, in the order first seen. */
|
package/dist/config/models.js
CHANGED
|
@@ -45,6 +45,24 @@ export const CURATED_MODELS = [
|
|
|
45
45
|
note: "strong agentic tool use",
|
|
46
46
|
contextWindow: 256_000,
|
|
47
47
|
},
|
|
48
|
+
{
|
|
49
|
+
id: "z-ai/glm-5.3",
|
|
50
|
+
label: "GLM 5.3",
|
|
51
|
+
note: "strong agentic tool use",
|
|
52
|
+
contextWindow: 1_048_576,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "z-ai/glm-5.3-flash",
|
|
56
|
+
label: "GLM 5.3 Flash",
|
|
57
|
+
note: "fast + cheap",
|
|
58
|
+
contextWindow: 1_048_576,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: "deepseek-ai/deepseek-v4.1-flash",
|
|
62
|
+
label: "DeepSeek V4.1 Flash",
|
|
63
|
+
note: "fast + cheap",
|
|
64
|
+
contextWindow: 1_048_576,
|
|
65
|
+
},
|
|
48
66
|
];
|
|
49
67
|
export const DEFAULT_MODEL = CURATED_MODELS[0].id;
|
|
50
68
|
/**
|
package/dist/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import { AuditLog } from "./audit/audit.js";
|
|
|
16
16
|
import { runAuditCli } from "./audit/cli.js";
|
|
17
17
|
import { runSkillsCli } from "./agent/skillsCli.js";
|
|
18
18
|
import { createTracer, cleanupOldTelemetry } from "./telemetry/tracer.js";
|
|
19
|
-
import { createMeter } from "./telemetry/metrics.js";
|
|
19
|
+
import { createMeter, RENDER_BOUNDS_MS } from "./telemetry/metrics.js";
|
|
20
20
|
import { retentionDaysFor } from "./config/retention.js";
|
|
21
21
|
import { backgroundManager } from "./shell/background.js";
|
|
22
22
|
import { defaultSandboxMode, sandboxAvailable, sandboxUnavailableReason } from "./shell/sandbox.js";
|
|
@@ -42,6 +42,7 @@ import { runHeadless } from "./headless.js";
|
|
|
42
42
|
import { installCrashHandlers } from "./crash.js";
|
|
43
43
|
import { createWorktree, commitWorktree, worktreeDiffStat, removeWorktree, isGitRepo, } from "./agent/worktree.js";
|
|
44
44
|
import { VERSION } from "./version.js";
|
|
45
|
+
import { updateNoticeForUser } from "./update/check.js";
|
|
45
46
|
const USAGE = `kritya — a lean, provider-agnostic terminal coding agent
|
|
46
47
|
|
|
47
48
|
Usage: kritya [directory] [options]
|
|
@@ -75,6 +76,11 @@ Inspect the local audit log:
|
|
|
75
76
|
List and validate skills:
|
|
76
77
|
kritya skills [dir] [--json] [--validate]
|
|
77
78
|
|
|
79
|
+
Check the installation:
|
|
80
|
+
kritya doctor [dir] [--json] [--offline]
|
|
81
|
+
Diagnoses the Node version, config, provider and its API key, workspace
|
|
82
|
+
trust, MCP servers, sandbox, and whether a newer release exists.
|
|
83
|
+
|
|
78
84
|
Setup:
|
|
79
85
|
1. Get an API key at https://build.nvidia.com (free credits available)
|
|
80
86
|
2. export NVIDIA_API_KEY=nvapi-... (Linux/macOS)
|
|
@@ -153,14 +159,38 @@ if (process.argv[2] === "audit") {
|
|
|
153
159
|
if (process.argv[2] === "skills") {
|
|
154
160
|
process.exit(runSkillsCli(process.argv.slice(3)));
|
|
155
161
|
}
|
|
162
|
+
// `kritya doctor` is the third standalone subcommand. It is async (it probes
|
|
163
|
+
// the provider and the registry), so unlike the two above it has to be awaited
|
|
164
|
+
// here — without that, top-level execution would fall through to parseArgs and
|
|
165
|
+
// start a session after the diagnosis had already run. Imported dynamically so
|
|
166
|
+
// `--version`, `--help` and a normal launch don't pay to load its module graph.
|
|
167
|
+
if (process.argv[2] === "doctor") {
|
|
168
|
+
const { runDoctorCli } = await import("./commands/doctor.js");
|
|
169
|
+
process.exit(await runDoctorCli(process.argv.slice(3)));
|
|
170
|
+
}
|
|
156
171
|
const args = parseArgs(process.argv.slice(2));
|
|
157
172
|
if (args.help) {
|
|
158
173
|
console.log(USAGE);
|
|
159
174
|
process.exit(0);
|
|
160
175
|
}
|
|
161
176
|
if (args.version) {
|
|
177
|
+
// The version itself goes to stdout and is printed before anything else, so
|
|
178
|
+
// `kritya --version` stays exactly one parseable line. The staleness notice
|
|
179
|
+
// is a courtesy, not part of that contract, so it is TTY-only (a script or a
|
|
180
|
+
// pipe never pays for it and never sees it), cached for a day, bounded, and
|
|
181
|
+
// written to stderr.
|
|
162
182
|
console.log(VERSION);
|
|
163
|
-
process.
|
|
183
|
+
if (process.stdout.isTTY) {
|
|
184
|
+
void updateNoticeForUser()
|
|
185
|
+
.then((notice) => {
|
|
186
|
+
if (notice)
|
|
187
|
+
console.error(notice);
|
|
188
|
+
})
|
|
189
|
+
.finally(() => process.exit(0));
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
164
194
|
}
|
|
165
195
|
const workspace = path.resolve(args.dir);
|
|
166
196
|
if (!fs.existsSync(workspace) || !fs.statSync(workspace).isDirectory()) {
|
|
@@ -415,6 +445,17 @@ async function main() {
|
|
|
415
445
|
sessionMeter.flushAndWait(),
|
|
416
446
|
new Promise((resolve) => setTimeout(resolve, 2000).unref()),
|
|
417
447
|
]);
|
|
448
|
+
// Hand the terminal back before leaving. Ink hides the cursor for the
|
|
449
|
+
// whole time it is rendering and only shows it again from its own
|
|
450
|
+
// teardown — which the process.exit() below skips, leaving the shell's
|
|
451
|
+
// prompt invisible until the user runs `reset`. unmount() is that
|
|
452
|
+
// teardown; it also finishes drawing the frame we are standing on.
|
|
453
|
+
try {
|
|
454
|
+
ui.instance?.unmount();
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
// Whatever brought us here may have been the UI itself. Exit anyway.
|
|
458
|
+
}
|
|
418
459
|
cleanup();
|
|
419
460
|
process.exit(code);
|
|
420
461
|
})();
|
|
@@ -709,12 +750,24 @@ async function main() {
|
|
|
709
750
|
// compaction — subagents (read-only or write) never do, even though they
|
|
710
751
|
// run the same Agent class and can also trigger auto-compaction.
|
|
711
752
|
agent.autoMemory = true;
|
|
753
|
+
// How long Ink takes to turn the React tree into a frame, per frame. This is
|
|
754
|
+
// the part of drawing a screen that grows with the size of the transcript, so
|
|
755
|
+
// it is the number that says whether the UI is getting sluggish — the
|
|
756
|
+
// terminal write it excludes is bounded by the terminal, not by us.
|
|
757
|
+
// `renderTime` excludes Ink's own throttling, so it is the raw cost of the
|
|
758
|
+
// frame rather than the interval between frames.
|
|
759
|
+
const renderHistogram = sessionMeter.histogram("kritya.render.duration_ms", RENDER_BOUNDS_MS);
|
|
712
760
|
ui.instance = render(_jsx(App, { agent: agent, workspace: workspace, modelRef: modelRef, providerRef: providerRef, config: config, resumedCount: initialHistory.length, initialTasks: initialTasks, undoStack: undoStack, uiBridge: uiBridge, resumeSessions: resumeSessions.length ? resumeSessions : undefined, customCommands: loadCustomCommands(workspace, trustWorkspace, plugins), mcpToolCount: mcpTools.length, onSwitchClient: (newClient) => {
|
|
713
761
|
client = newClient;
|
|
714
762
|
}, onRequestPermissionReady: (fn) => {
|
|
715
763
|
permissionRef.current = fn;
|
|
716
764
|
}, onRequestElicitationReady: (fn) => {
|
|
717
765
|
elicitationRef.current = fn;
|
|
718
|
-
}, privacyMode: privacyMode, firstLaunch: firstLaunch })
|
|
766
|
+
}, privacyMode: privacyMode, firstLaunch: firstLaunch }), {
|
|
767
|
+
onRender: ({ renderTime }) => renderHistogram.record(renderTime),
|
|
768
|
+
// Repaint only the lines that changed instead of the whole frame:
|
|
769
|
+
// less flicker and less CPU while streaming. Ink 8 native.
|
|
770
|
+
incrementalRendering: true,
|
|
771
|
+
});
|
|
719
772
|
markBannerSeen(workspace);
|
|
720
773
|
}
|