kritya 0.8.27-beta → 0.8.29-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 CHANGED
@@ -13,8 +13,9 @@ are triaged.
13
13
 
14
14
  Open source, [MIT licensed](LICENSE).
15
15
 
16
- A lean, interctive coding agent for your terminal. Provider-agnostic —
17
- it works with any OpenAI-compatible endpoint: [build.nvidia.com](https://build.nvidia.com)
16
+ Superintelligence at your command line
17
+
18
+ It works with any OpenAI-compatible endpoint: [build.nvidia.com](https://build.nvidia.com)
18
19
  (Qwen3 Coder, Kimi K3, DeepSeek, GLM, Nemotron, ...) by default, plus OpenAI,
19
20
  OpenRouter, Groq, DeepSeek, Mistral, Together, and local models via Ollama.
20
21
 
@@ -132,6 +133,25 @@ kritya [directory] [options]
132
133
  -v, --version version
133
134
  ```
134
135
 
136
+ Subcommands — these run instead of starting a session:
137
+
138
+ ```
139
+ kritya doctor [dir] [--json] [--offline]
140
+ check that this installation is set up correctly: the
141
+ Node version against the package's engines, the config
142
+ file, the active provider and whether its API key
143
+ resolves *and* the endpoint accepts it, workspace trust
144
+ and git state, MCP servers (including unset ${VAR}s),
145
+ sandbox availability, persistence settings, skills and
146
+ hooks, and whether a newer release exists
147
+ kritya audit ... inspect the local audit log (see docs/CONFIGURATION.md)
148
+ kritya skills ... list and validate discovered skills
149
+ ```
150
+
151
+ `kritya doctor` exits non-zero only when something is actually broken; the
152
+ warnings it prints are "this works, but not the way you may expect" — an
153
+ unavailable sandbox, an untrusted workspace, a lagging `latest` tag.
154
+
135
155
  In-session commands (type `/` to see them with autocomplete; letters filter the list):
136
156
 
137
157
  | Command | What it does |
@@ -433,10 +453,20 @@ model config lives in `~/.kritya/config.json`.
433
453
  kritya collects **no telemetry** and phones home to nothing of its own by
434
454
  default. Network requests go only to the model provider you configure (and to
435
455
  Tavily if you use web search). Sessions and config stay on your machine under
436
- `~/.kritya/`. The one opt-in exception is `KRITYA_OTEL_ENDPOINT` (see
437
- [Audit log & telemetry](docs/CONFIGURATION.md#audit-log--telemetry)): if you explicitly set it, tracing/metrics spans are sent
438
- to the OpenTelemetry Collector you point it at — nothing is exported anywhere
439
- unless you configure that endpoint yourself.
456
+ `~/.kritya/`.
457
+
458
+ Two things leave the machine beyond the model provider, and both are opt-out:
459
+
460
+ - **The update check.** `kritya --version` and `kritya doctor` ask the public
461
+ npm registry for the latest published version, so they can tell you when
462
+ you're behind. The answer is cached in `~/.kritya/update-check.json` for 24
463
+ hours, so it is one request per day at most, and it only runs when stdout is
464
+ a TTY — a script or a pipe never triggers it and never sees the notice.
465
+ `KRITYA_NO_UPDATE_CHECK=1` turns it off entirely.
466
+ - **Tracing**, if you opt in via `KRITYA_OTEL_ENDPOINT` (see
467
+ [Audit log & telemetry](docs/CONFIGURATION.md#audit-log--telemetry)): spans
468
+ are sent to the OpenTelemetry Collector you point it at — nothing is
469
+ exported anywhere unless you configure that endpoint yourself.
440
470
 
441
471
  ## Development
442
472
 
@@ -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.exit(0);
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()) {
package/dist/ui/Banner.js CHANGED
@@ -50,7 +50,9 @@ function rowColor(row) {
50
50
  return `#${[r, g, b].map((c) => c.toString(16).padStart(2, "0")).join("")}`;
51
51
  }
52
52
  const BETA_LABEL = "beta";
53
- export function Banner({ subtitle, compact }) {
53
+ /** Product tagline shown under the banner on launch. */
54
+ export const TAGLINE = "Superintelligence at your command line";
55
+ export function Banner({ tagline, subtitle, compact, }) {
54
56
  if (compact) {
55
57
  return (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: subtitle }) }));
56
58
  }
@@ -70,5 +72,5 @@ export function Banner({ subtitle, compact }) {
70
72
  const indent = leftPad + aStart + Math.max(0, aWidth - BETA_LABEL.length);
71
73
  return " ".repeat(indent) + BETA_LABEL;
72
74
  })();
73
- return (_jsxs(Box, { flexDirection: "column", marginTop: 2, marginBottom: 1, children: [lines ? (lines.map((line, i) => (_jsx(Text, { color: rowColor(i), children: center(line) }, i)))) : (_jsx(Text, { bold: true, color: "cyan", children: center("KRITYA") })), betaLine ? _jsx(Text, { dimColor: true, children: betaLine }) : null, _jsx(Box, { marginTop: 2, children: subtitle ? _jsx(Text, { dimColor: true, children: center(subtitle) }) : null }), _jsx(Text, { dimColor: true, children: center("AI can make mistakes. Verify important output.") })] }));
75
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 2, marginBottom: 1, children: [lines ? (lines.map((line, i) => (_jsx(Text, { color: rowColor(i), children: center(line) }, i)))) : (_jsx(Text, { bold: true, color: "cyan", children: center("KRITYA") })), betaLine ? _jsx(Text, { dimColor: true, children: betaLine }) : null, tagline ? _jsx(Text, { children: center(tagline) }) : null, _jsx(Box, { marginTop: 2, children: subtitle ? _jsx(Text, { dimColor: true, children: center(subtitle) }) : null }), _jsx(Text, { dimColor: true, children: center("AI can make mistakes. Verify important output.") })] }));
74
76
  }
@@ -10,5 +10,5 @@ export function TranscriptItem({ item, verbose, contentWidth }) {
10
10
  return (_jsxs(Box, { marginBottom: item.kind === "tool" ? 0 : 1, flexDirection: "column", children: [item.kind === "user" && (_jsxs(Text, { children: [_jsxs(Text, { bold: true, color: "green", children: ["\u276F", " "] }), item.text] })), item.kind === "assistant" && _jsx(Markdown, { text: item.text, width: contentWidth }), item.kind === "tool" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [item.error ? _jsx(Text, { color: "red", children: "\u2717" }) : _jsx(Text, { color: "green", children: "\u2713" }), " ", item.summary, item.resultSummary && !verbose ? ` — ${item.resultSummary}` : ""] }), item.output && item.output.trim() && (item.resultSummary === undefined || verbose) && (_jsx(Text, { dimColor: true, children: toolOutputPreview(item.output, verbose, Math.max(20, contentWidth - TOOL_INDENT), item.error)
11
11
  .split("\n")
12
12
  .map((l) => ` ${l}`)
13
- .join("\n") }))] })), item.kind === "info" && _jsx(Text, { dimColor: true, children: item.text }), item.kind === "banner" && _jsx(Banner, { subtitle: item.subtitle, compact: item.compact }), item.kind === "summary" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "\u2713 Completed" }), item.files.length > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Changed:" }), item.files.map((f) => (_jsxs(Text, { children: [" ", f] }, f)))] })) : (_jsx(Text, { dimColor: true, children: "No files changed." })), _jsxs(Text, { dimColor: true, children: ["Next: ", item.nextStep] })] }))] }));
13
+ .join("\n") }))] })), item.kind === "info" && _jsx(Text, { dimColor: true, children: item.text }), item.kind === "banner" && (_jsx(Banner, { tagline: item.tagline, subtitle: item.subtitle, compact: item.compact })), item.kind === "summary" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "\u2713 Completed" }), item.files.length > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Changed:" }), item.files.map((f) => (_jsxs(Text, { children: [" ", f] }, f)))] })) : (_jsx(Text, { dimColor: true, children: "No files changed." })), _jsxs(Text, { dimColor: true, children: ["Next: ", item.nextStep] })] }))] }));
14
14
  }
@@ -7,6 +7,7 @@ import { ProviderClient, RetryExhaustedError, friendlyProviderErrorHint, } from
7
7
  import { createSwitchyardClient } from "../provider/switchyardClient.js";
8
8
  import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel } from "../provider/switchyardSidecar.js";
9
9
  import { KillSwitchError } from "../agent/killSwitch.js";
10
+ import { TAGLINE } from "./Banner.js";
10
11
  import { defaultSandboxMode, sandboxAvailable } from "../shell/sandbox.js";
11
12
  import { loadProjectState, nextPhase, PHASE_COMMAND, } from "../agent/workflow.js";
12
13
  import { tavilySearch } from "../tools/webSearch.js";
@@ -51,6 +52,7 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
51
52
  {
52
53
  id: nextId.current++,
53
54
  kind: "banner",
55
+ tagline: TAGLINE,
54
56
  subtitle: `${path.basename(workspace)} · ${modelRef.current} · type a request, or /help for commands`,
55
57
  compact: !firstLaunch,
56
58
  },
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.27-beta",
4
- "description": "Kritya — a lean, provider-agnostic terminal coding agent (NVIDIA, OpenAI, OpenRouter, Groq, Ollama, and more)",
3
+ "version": "0.8.29-beta",
4
+ "description": "Kritya — Superintelligence at your command line",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "provenance": true
@@ -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"
@@ -61,7 +64,7 @@
61
64
  "ink": "^8.0.0",
62
65
  "jszip": "^3.10.1",
63
66
  "mammoth": "^1.12.2",
64
- "openai": "^7.10.0",
67
+ "openai": "^7.28.0",
65
68
  "pdf-lib": "^1.17.1",
66
69
  "pdfjs-dist": "^6.3.289",
67
70
  "pptxgenjs": "^4.0.1",
@@ -75,6 +78,7 @@
75
78
  "@eslint/js": "^10.0.1",
76
79
  "@types/node": "^26.4.1",
77
80
  "@types/react": "^19.2.18",
81
+ "@typescript/native": "npm:typescript@^7.0.2",
78
82
  "c8": "^12.0.0",
79
83
  "electron": "^44.2.0",
80
84
  "electron-builder": "^26.15.3",
@@ -86,8 +90,7 @@
86
90
  "prettier": "^3.9.0",
87
91
  "tsx": "^4.23.13",
88
92
  "typescript": "npm:@typescript/typescript6@^6.0.2",
89
- "@typescript/native": "npm:typescript@^7.0.2",
90
- "typescript-eslint": "^8.69.0"
93
+ "typescript-eslint": "^8.71.1"
91
94
  },
92
95
  "overrides": {
93
96
  "brace-expansion": "^5.0.12",