kritya 0.8.27-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 +33 -4
- package/dist/commands/doctor.js +380 -0
- package/dist/index.js +31 -1
- package/dist/update/check.js +189 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -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 |
|
|
@@ -433,10 +452,20 @@ model config lives in `~/.kritya/config.json`.
|
|
|
433
452
|
kritya collects **no telemetry** and phones home to nothing of its own by
|
|
434
453
|
default. Network requests go only to the model provider you configure (and to
|
|
435
454
|
Tavily if you use web search). Sessions and config stay on your machine under
|
|
436
|
-
`~/.kritya/`.
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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.
|
|
440
469
|
|
|
441
470
|
## Development
|
|
442
471
|
|
|
@@ -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/index.js
CHANGED
|
@@ -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()) {
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CONFIG_DIR } from "../config/config.js";
|
|
4
|
+
import { debugLog } from "../config/debug.js";
|
|
5
|
+
import { writeFileAtomicSync } from "../atomicWrite.js";
|
|
6
|
+
import { VERSION } from "../version.js";
|
|
7
|
+
/**
|
|
8
|
+
* "Is a newer kritya out?" — a courtesy notice, so every path here is
|
|
9
|
+
* best-effort by design: it never throws, never blocks for long, and never
|
|
10
|
+
* runs when it would be in the way (see `updateCheckDisabled` and the TTY gate
|
|
11
|
+
* at the call sites). A version check that can break `kritya --version` or
|
|
12
|
+
* stall startup would be a worse bug than the staleness it reports.
|
|
13
|
+
*/
|
|
14
|
+
export const UPDATE_CHECK_DISABLED_ENV = "KRITYA_NO_UPDATE_CHECK";
|
|
15
|
+
const CACHE_FILE = path.join(CONFIG_DIR, "update-check.json");
|
|
16
|
+
/** How long a cached answer stays good. One registry hit per day, per machine. */
|
|
17
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
/** Kept short: this is a nicety, and it sits in front of a user's shell prompt. */
|
|
19
|
+
const REQUEST_TIMEOUT_MS = 2_000;
|
|
20
|
+
const REGISTRY_URL = "https://registry.npmjs.org/kritya";
|
|
21
|
+
/** npm's abbreviated packument — a fraction of the full document's size. */
|
|
22
|
+
const PACKUMENT_ACCEPT = "application/vnd.npm.install-v1+json";
|
|
23
|
+
export function updateCheckDisabled() {
|
|
24
|
+
const value = process.env[UPDATE_CHECK_DISABLED_ENV];
|
|
25
|
+
return value !== undefined && /^(1|true|yes|on)$/i.test(value.trim());
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Semver precedence: -1 if a < b, 0 if equal, 1 if a > b.
|
|
29
|
+
*
|
|
30
|
+
* Hand-rolled rather than pulled from a dependency because the only shapes
|
|
31
|
+
* this ever sees are our own release tags (`0.8.27-beta`), and a full semver
|
|
32
|
+
* implementation would be more code than it saves. Follows §11 where it
|
|
33
|
+
* matters: a release outranks any prerelease of the same core, and numeric
|
|
34
|
+
* prerelease identifiers rank below alphanumeric ones.
|
|
35
|
+
*/
|
|
36
|
+
export function compareVersions(a, b) {
|
|
37
|
+
const parse = (value) => {
|
|
38
|
+
const [core, ...rest] = value.trim().replace(/^v/, "").split("-");
|
|
39
|
+
return { nums: core.split(".").map((n) => Number(n) || 0), pre: rest.join("-") };
|
|
40
|
+
};
|
|
41
|
+
const left = parse(a);
|
|
42
|
+
const right = parse(b);
|
|
43
|
+
for (let i = 0; i < 3; i += 1) {
|
|
44
|
+
const x = left.nums[i] ?? 0;
|
|
45
|
+
const y = right.nums[i] ?? 0;
|
|
46
|
+
if (x !== y)
|
|
47
|
+
return x < y ? -1 : 1;
|
|
48
|
+
}
|
|
49
|
+
if (left.pre === right.pre)
|
|
50
|
+
return 0;
|
|
51
|
+
if (!left.pre)
|
|
52
|
+
return 1;
|
|
53
|
+
if (!right.pre)
|
|
54
|
+
return -1;
|
|
55
|
+
const li = left.pre.split(".");
|
|
56
|
+
const ri = right.pre.split(".");
|
|
57
|
+
for (let i = 0; i < Math.max(li.length, ri.length); i += 1) {
|
|
58
|
+
const x = li[i];
|
|
59
|
+
const y = ri[i];
|
|
60
|
+
if (x === undefined)
|
|
61
|
+
return -1;
|
|
62
|
+
if (y === undefined)
|
|
63
|
+
return 1;
|
|
64
|
+
const xNum = /^\d+$/.test(x);
|
|
65
|
+
const yNum = /^\d+$/.test(y);
|
|
66
|
+
if (xNum && yNum) {
|
|
67
|
+
const dx = Number(x);
|
|
68
|
+
const dy = Number(y);
|
|
69
|
+
if (dx !== dy)
|
|
70
|
+
return dx < dy ? -1 : 1;
|
|
71
|
+
}
|
|
72
|
+
else if (xNum !== yNum) {
|
|
73
|
+
return xNum ? -1 : 1;
|
|
74
|
+
}
|
|
75
|
+
else if (x !== y) {
|
|
76
|
+
return x < y ? -1 : 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The newest version across a packument's dist-tags.
|
|
83
|
+
*
|
|
84
|
+
* Deliberately the maximum of *all* tags rather than a single channel: this
|
|
85
|
+
* package currently only publishes prereleases under `beta`, while `latest`
|
|
86
|
+
* lags behind by design (it is moved by hand — see publish.yml). Reading only
|
|
87
|
+
* `latest` would mean the notice never fires for the people actually running
|
|
88
|
+
* betas.
|
|
89
|
+
*/
|
|
90
|
+
export function newestVersion(distTags) {
|
|
91
|
+
let newest = null;
|
|
92
|
+
for (const version of Object.values(distTags ?? {})) {
|
|
93
|
+
if (typeof version !== "string" || !version)
|
|
94
|
+
continue;
|
|
95
|
+
if (!newest || compareVersions(version, newest) > 0)
|
|
96
|
+
newest = version;
|
|
97
|
+
}
|
|
98
|
+
return newest;
|
|
99
|
+
}
|
|
100
|
+
export function isOutdated(current, latest) {
|
|
101
|
+
return latest !== null && compareVersions(latest, current) > 0;
|
|
102
|
+
}
|
|
103
|
+
/** The user-facing notice, or null when there's nothing worth saying. */
|
|
104
|
+
export function updateNotice(current, latest) {
|
|
105
|
+
if (!isOutdated(current, latest))
|
|
106
|
+
return null;
|
|
107
|
+
return (`A newer version of kritya is available: ${current} → ${latest}\n` +
|
|
108
|
+
` npm install -g kritya@${latest}`);
|
|
109
|
+
}
|
|
110
|
+
function readCache(file) {
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
113
|
+
if (typeof parsed.checkedAt !== "number" || typeof parsed.latest !== "string")
|
|
114
|
+
return null;
|
|
115
|
+
return { checkedAt: parsed.checkedAt, latest: parsed.latest };
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
// Missing on first run, or corrupt — either way, just check the registry.
|
|
119
|
+
debugLog(`readUpdateCache(${file})`, err);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function writeCache(file, entry) {
|
|
124
|
+
try {
|
|
125
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
126
|
+
writeFileAtomicSync(file, `${JSON.stringify(entry, null, 2)}\n`, { mode: 0o600 });
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
// Not being able to remember the answer only costs one extra request.
|
|
130
|
+
debugLog(`writeUpdateCache(${file})`, err);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Resolve whether a newer version exists. Never rejects.
|
|
135
|
+
*
|
|
136
|
+
* A cached answer younger than the TTL is returned as-is; otherwise the
|
|
137
|
+
* registry is asked with a hard 2s timeout and the result is cached. Any
|
|
138
|
+
* failure resolves to `source: "unavailable"` — being offline is not an error
|
|
139
|
+
* worth showing anyone.
|
|
140
|
+
*/
|
|
141
|
+
export async function checkForUpdate(options = {}) {
|
|
142
|
+
const current = VERSION;
|
|
143
|
+
if (updateCheckDisabled()) {
|
|
144
|
+
return { current, latest: null, outdated: false, source: "disabled" };
|
|
145
|
+
}
|
|
146
|
+
const now = options.now ?? Date.now();
|
|
147
|
+
const cacheFile = options.cacheFile ?? CACHE_FILE;
|
|
148
|
+
if (!options.force) {
|
|
149
|
+
const cached = readCache(cacheFile);
|
|
150
|
+
if (cached && now - cached.checkedAt < CACHE_TTL_MS) {
|
|
151
|
+
return {
|
|
152
|
+
current,
|
|
153
|
+
latest: cached.latest,
|
|
154
|
+
outdated: isOutdated(current, cached.latest),
|
|
155
|
+
source: "cache",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
161
|
+
const response = await fetchImpl(REGISTRY_URL, {
|
|
162
|
+
headers: { accept: PACKUMENT_ACCEPT },
|
|
163
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
164
|
+
});
|
|
165
|
+
if (!response.ok)
|
|
166
|
+
throw new Error(`registry returned HTTP ${response.status}`);
|
|
167
|
+
const doc = (await response.json());
|
|
168
|
+
const latest = newestVersion(doc["dist-tags"]);
|
|
169
|
+
if (!latest)
|
|
170
|
+
throw new Error("registry response contained no dist-tags");
|
|
171
|
+
writeCache(cacheFile, { checkedAt: now, latest });
|
|
172
|
+
return { current, latest, outdated: isOutdated(current, latest), source: "registry" };
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
debugLog(`checkForUpdate(${REGISTRY_URL})`, err);
|
|
176
|
+
return {
|
|
177
|
+
current,
|
|
178
|
+
latest: null,
|
|
179
|
+
outdated: false,
|
|
180
|
+
source: "unavailable",
|
|
181
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** The notice to print for a user, or null. Never throws. */
|
|
186
|
+
export async function updateNoticeForUser(options = {}) {
|
|
187
|
+
const status = await checkForUpdate(options);
|
|
188
|
+
return updateNotice(status.current, status.latest);
|
|
189
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kritya",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.28-beta",
|
|
4
4
|
"description": "Kritya — a lean, provider-agnostic terminal coding agent (NVIDIA, OpenAI, OpenRouter, Groq, Ollama, and more)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -37,9 +37,12 @@
|
|
|
37
37
|
"format:check": "prettier --check .",
|
|
38
38
|
"check:audit": "node scripts/check-audit.mjs",
|
|
39
39
|
"check:install-scripts": "node scripts/check-install-scripts.mjs",
|
|
40
|
+
"check:package": "node scripts/check-package.mjs",
|
|
41
|
+
"check:release": "node scripts/check-release.mjs",
|
|
42
|
+
"check:smoke": "node scripts/smoke-install.mjs",
|
|
40
43
|
"check:typescript-setup": "node scripts/check-typescript-setup.mjs",
|
|
41
44
|
"link": "npm run build && npm link",
|
|
42
|
-
"prepublishOnly": "npm run build",
|
|
45
|
+
"prepublishOnly": "npm run build && npm run check:package && npm run check:smoke",
|
|
43
46
|
"prepare": "husky",
|
|
44
47
|
"electron:dev": "npm run build && electron .",
|
|
45
48
|
"electron:build": "npm run build && electron-builder"
|