kronk-cli 0.7.0 → 0.8.0

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/src/subagent.js CHANGED
@@ -3,6 +3,8 @@ import { config } from './config.js';
3
3
  import { c, elapsed } from './ui.js';
4
4
  import { startRun } from './tasklog.js';
5
5
  import { livePane } from './pane.js';
6
+ import { modelLimits as fetchModelLimits } from './client.js';
7
+ import { resolveModel } from './resolve.js';
6
8
 
7
9
  /**
8
10
  * Sub-agents: one delegated task, one throwaway context.
@@ -67,6 +69,18 @@ export const AGENTS = {
67
69
  const agentList = () => Object.entries(AGENTS)
68
70
  .map(([name, a]) => `"${name}" — ${a.use}`).join('; ');
69
71
 
72
+ /**
73
+ * The ids a task's `model` parameter may name, or null when nobody has said.
74
+ *
75
+ * Set once at boot, from `chatModels(await listModels())` in src/boot.js, by
76
+ * `setServedModels` below. A test that builds `runTask` by hand, as every
77
+ * test in test/subagent.test.js does, never calls that setter, so this stays
78
+ * null and the tool is offered exactly as it always was: no `model` property
79
+ * at all, because an enum with no members is not a real choice, it is a tool
80
+ * a model would call and then have no valid value to put in it.
81
+ */
82
+ let servedModels = null;
83
+
70
84
  /**
71
85
  * The instruction the delegating model actually reads.
72
86
  *
@@ -74,16 +88,14 @@ const agentList = () => Object.entries(AGENTS)
74
88
  * the one place a small model reliably looks, so the whole protocol lives
75
89
  * here — what it is for, that the sub-agent starts blank, and that a vague
76
90
  * prompt is the failure mode.
91
+ *
92
+ * `model` is built from `servedModels` rather than written once: the whole
93
+ * point of the enum is that the delegating model can only ever name a real
94
+ * id, so the property has to be rebuilt whenever the served list changes,
95
+ * and left off entirely when there is no list to build it from.
77
96
  */
78
- export const TASK_TOOL = def('task',
79
- 'Delegate one self-contained piece of work to a sub-agent that runs in its own context. '
80
- + 'Use it when getting the answer means reading or running a lot but the answer itself is '
81
- + 'small — surveys, searches across many files, reproducing a failure. What the sub-agent '
82
- + 'reads never enters this conversation; only its report does. The sub-agent cannot see this '
83
- + 'conversation and cannot ask you anything, so the prompt must carry everything it needs and '
84
- + 'must say exactly what to report back. It cannot delegate further. Prefer one well-scoped '
85
- + 'task over several vague ones.',
86
- {
97
+ function buildTaskTool(models) {
98
+ const properties = {
87
99
  agent: {
88
100
  type: 'string',
89
101
  enum: Object.keys(AGENTS),
@@ -94,8 +106,50 @@ export const TASK_TOOL = def('task',
94
106
  description: 'The whole task, written for someone who knows nothing about this '
95
107
  + 'conversation: what to do, where to look, and exactly what to report back.',
96
108
  },
97
- },
98
- ['agent', 'prompt']);
109
+ };
110
+ if (models?.length) {
111
+ properties.model = {
112
+ type: 'string',
113
+ enum: models,
114
+ description: 'Which model to run this task on, when the default is not the right size '
115
+ + 'for it. A survey that reads a lot and concludes little wants a small, fast model; '
116
+ + 'reproducing a failure or making a change wants a large one. Omit it to use the '
117
+ + "session's own default. Naming a model that is not already loaded costs a real "
118
+ + 'load — seconds, not instant — so only ask for a different one when the task actually '
119
+ + 'needs it.',
120
+ };
121
+ }
122
+ return def('task',
123
+ 'Delegate one self-contained piece of work to a sub-agent that runs in its own context. '
124
+ + 'Use it when getting the answer means reading or running a lot but the answer itself is '
125
+ + 'small — surveys, searches across many files, reproducing a failure. What the sub-agent '
126
+ + 'reads never enters this conversation; only its report does. The sub-agent cannot see this '
127
+ + 'conversation and cannot ask you anything, so the prompt must carry everything it needs and '
128
+ + 'must say exactly what to report back. It cannot delegate further. Prefer one well-scoped '
129
+ + 'task over several vague ones.',
130
+ properties,
131
+ ['agent', 'prompt']);
132
+ }
133
+
134
+ export let TASK_TOOL = buildTaskTool(servedModels);
135
+
136
+ /**
137
+ * Tell src/subagent.js what Kronk is actually serving, so the task tool's
138
+ * `model` parameter can only ever name something real.
139
+ *
140
+ * Called once at boot with `chatModels(ids)` from src/boot.js — never the
141
+ * raw list from `listModels()`, so an embedding model, or a bare id sitting
142
+ * next to its own /AGENT profile, can never appear as something a delegated
143
+ * task is allowed to ask for. What it does keep is every chat model that has
144
+ * no profile of its own; src/boot.js has the argument for why that matters
145
+ * more here than it does when one default is being chosen. `null` (or an
146
+ * empty list) puts the tool back to its no-`model` shape, which is also its
147
+ * starting shape — nothing to degrade to, because nothing was ever added.
148
+ */
149
+ export function setServedModels(ids) {
150
+ servedModels = Array.isArray(ids) && ids.length ? [...ids] : null;
151
+ TASK_TOOL = buildTaskTool(servedModels);
152
+ }
99
153
 
100
154
  /**
101
155
  * The delegation tool, when it is allowed at all.
@@ -134,6 +188,14 @@ const nest = (line) => String(line).split('\n')
134
188
  /** How much of the prompt fits on the opening line. */
135
189
  const GIST = 60;
136
190
 
191
+ /**
192
+ * The tail of a model id — enough of it to actually distinguish one on screen.
193
+ * A served id's own name is the second-to-last segment (`unsloth/Qwen3.6-…
194
+ * /AGENT`); the last segment alone is often just `AGENT` or `base`, which
195
+ * every profile of every model shares and which therefore says nothing.
196
+ */
197
+ const short = (id) => String(id).split('/').slice(-2).join('/');
198
+
137
199
  /**
138
200
  * The line that says a run has started.
139
201
  *
@@ -141,11 +203,18 @@ const GIST = 60;
141
203
  * two of them in a row are indistinguishable in scrollback. The gist is the
142
204
  * prompt's first line, cut: enough to recognise which task this was, never
143
205
  * enough to be worth reading instead of the report.
206
+ *
207
+ * `model` is named here too, always, not only when it differs from the
208
+ * session's own — residency is the real cost of letting a task pick one
209
+ * (src/subagent.js's module comment on `setServedModels`), and "which model
210
+ * just cost you a cold load" is a question this line should answer without
211
+ * making anyone go read /agents first.
144
212
  */
145
- export function openLine(agent, prompt) {
213
+ export function openLine(agent, prompt, model) {
146
214
  const first = String(prompt).split('\n')[0].trim();
147
215
  const gist = first.length > GIST ? `${first.slice(0, GIST - 1)}…` : first;
148
- return `${c.grey(' ┌')} ${c.bold(agent)} ${c.grey(`· ${gist}`)}`;
216
+ const tag = model ? c.grey(` · ${short(model)}`) : '';
217
+ return `${c.grey(' ┌')} ${c.bold(agent)}${tag} ${c.grey(`· ${gist}`)}`;
149
218
  }
150
219
 
151
220
  /**
@@ -196,6 +265,29 @@ function reportFrom(messages) {
196
265
  return text;
197
266
  }
198
267
 
268
+ /**
269
+ * One model's window and output cap, looked up once per distinct id and kept
270
+ * for the rest of the session.
271
+ *
272
+ * Used to be a single field, `config.subagentLimits`, resolved once at boot
273
+ * for the one id `KRONK_SUBAGENT_MODEL` named. Now that a task can name a
274
+ * different model on every delegation, "once at boot for one id" is not
275
+ * enough — but "once per delegation" is worse: a survey and a build fix
276
+ * delegated back to back on the same small model would ask Kronk for the
277
+ * same profile twice for no reason. A `Map` keyed by id is the middle
278
+ * ground — paid once per model actually used, not once per model that exists
279
+ * and not once per task that runs.
280
+ */
281
+ const limitsCache = new Map();
282
+
283
+ /** Test-only: the cache is a session-lifetime optimisation, not a session. */
284
+ export function resetLimitsCache() { limitsCache.clear(); }
285
+
286
+ async function limitsFor(id, fetchLimits) {
287
+ if (!limitsCache.has(id)) limitsCache.set(id, await fetchLimits(id));
288
+ return limitsCache.get(id);
289
+ }
290
+
199
291
  /**
200
292
  * Run one delegated task to completion and hand back only its report.
201
293
  *
@@ -209,6 +301,9 @@ export async function runTask(args, {
209
301
  // Injected the same way `run` and `out` are, and for the same reason: the
210
302
  // pane's behaviour is a screen behaviour, and a test has no screen.
211
303
  pane: makePane = livePane,
304
+ // Real Kronk by default; a test hands in a stub so resolving a named
305
+ // model's limits never touches a network nobody promised would be there.
306
+ modelLimits: fetchLimits = fetchModelLimits,
212
307
  }) {
213
308
  const agent = args?.agent ?? 'explore';
214
309
  const prompt = String(args?.prompt ?? '').trim();
@@ -222,14 +317,41 @@ export async function runTask(args, {
222
317
  { role: 'user', content: prompt },
223
318
  ];
224
319
 
320
+ // The model this task actually runs on: what it asked for, else the
321
+ // session's configured default for delegation, else the session's own.
322
+ let taskModel = config.subagentModel ?? model;
323
+ const wanted = args?.model ? String(args.model).trim() : '';
324
+ // A note for the tool result, not a return in itself — a task that asked
325
+ // for a model that turns out not to be served still runs, on the fallback
326
+ // above, because there is nothing the delegating model can do to fix an id
327
+ // that came straight out of the enum it was offered. Failing the whole
328
+ // delegation over it would only cost a retry with the same bad value; a
329
+ // note it can read and carry forward is strictly more useful.
330
+ let modelNote = null;
331
+ if (wanted) {
332
+ if (servedModels) {
333
+ const resolved = resolveModel(wanted, servedModels);
334
+ if (resolved) taskModel = resolved;
335
+ else modelNote = `note: "${wanted}" is not being served right now — ran on ${short(taskModel)} instead.`;
336
+ } else {
337
+ // Nothing to check the name against — a unit test that built `runTask`
338
+ // by hand, or a session where `setServedModels` was never called. The
339
+ // id is trusted exactly as `model` and `config.subagentModel` already
340
+ // are in that situation: a plain string, handed to `run` unvalidated.
341
+ taskModel = wanted;
342
+ }
343
+ }
344
+
225
345
  // A sub-agent on another model gets that model's window and output cap, not
226
346
  // the session's. Compacting a 32k sub-agent at 85% of the main model's 131k
227
- // means never compacting it at all.
228
- const own = config.subagentModel ? config.subagentLimits : null;
347
+ // means never compacting it at all. The session's own model needs no
348
+ // lookup — config.contextWindow and config.maxTokens already are its
349
+ // limits, kept current by applyLimits whenever the session switches models.
350
+ const limits = taskModel === model ? null : await limitsFor(taskModel, fetchLimits);
229
351
 
230
352
  // Recorded from here, not from the first step: a run that dies in its first
231
353
  // model call still leaves a file saying which task was asked for.
232
- const log = startRun({ agent, prompt, model: config.subagentModel ?? model });
354
+ const log = startRun({ agent, prompt, model: taskModel });
233
355
  const started = Date.now();
234
356
 
235
357
  // On a terminal the run is drawn in a box that erases itself; on a pipe, a
@@ -237,9 +359,10 @@ export async function runTask(args, {
237
359
  // an inert one and everything below takes exactly the path it took before
238
360
  // the pane existed. The transcript is written either way, which is what
239
361
  // makes erasing the box safe — see src/pane.js.
240
- const pane = makePane({ agent, maxSteps, signal });
362
+ const pane = makePane({ agent, model: taskModel, maxSteps, signal });
241
363
  if (pane.enabled) pane.open();
242
- else out(openLine(agent, prompt));
364
+ else out(openLine(agent, prompt, taskModel));
365
+ if (modelNote) out(c.yellow(` ${modelNote}`));
243
366
 
244
367
  let steps = 0;
245
368
  let report;
@@ -248,9 +371,9 @@ export async function runTask(args, {
248
371
  messages,
249
372
  // A smaller model for the grunt work is the whole reason this is a knob:
250
373
  // the survey is reading and grepping, the synthesis is not.
251
- model: config.subagentModel ?? model,
252
- window: own ? own.contextWindow : config.contextWindow,
253
- maxTokens: own?.maxTokens ?? config.maxTokens,
374
+ model: taskModel,
375
+ window: limits ? limits.configured : config.contextWindow,
376
+ maxTokens: limits?.maxTokens ?? config.maxTokens,
254
377
  signal,
255
378
  // Wrapped, not replaced: delegation is still not a way to get a command
256
379
  // past the user. The wrapper only wipes the box first, so the question is
@@ -284,6 +407,10 @@ export async function runTask(args, {
284
407
  });
285
408
 
286
409
  report = reportFrom(done);
410
+ // The delegating model reads the tool result, not the scrollback, so the
411
+ // note about a model it asked for and did not get belongs in both —
412
+ // printed above when the run started, and carried in what it gets back.
413
+ if (modelNote) report = `${modelNote}\n\n${report}`;
287
414
  // Before the report, so the box is gone by the time the answer lands in
288
415
  // the scrollback it is keeping clean.
289
416
  pane.close();
package/src/tasklog.js CHANGED
@@ -203,6 +203,11 @@ const gist = (prompt, width) => {
203
203
  * never finished says so rather than reporting a duration it does not have:
204
204
  * "still running" and "died three steps in" are the two states where the
205
205
  * transcript is worth the most, and both would otherwise render as a blank.
206
+ *
207
+ * The model is named on every line, not only the ones that used a different
208
+ * one: a task can now pick its own (src/subagent.js), and a run that took a
209
+ * minute longer than the others is otherwise a mystery — this is the list
210
+ * where that gets explained, without opening the run in full.
206
211
  */
207
212
  export function runLines() {
208
213
  if (!config.taskLog) {
@@ -216,7 +221,8 @@ export function runLines() {
216
221
  const how = { done: took(r.ms), stopped: 'stopped', running: 'running' }[r.state] ?? took(r.ms);
217
222
  const state = `${r.steps} step${r.steps === 1 ? '' : 's'} \u00b7 ${how}`.padEnd(20);
218
223
  const dot = { done: c.green('\u25cf'), stopped: c.yellow('\u25cf'), running: c.blue('\u25cf') }[r.state] ?? c.grey('\u25cf');
219
- return ` ${dot} ${c.bold(String(r.n).padStart(2))} ${r.agent.padEnd(7)} ${c.grey(state)} ${c.grey(gist(r.prompt, 44))}`;
224
+ const model = r.model ? `${r.model.split('/').slice(-2).join('/')} \u00b7 ` : '';
225
+ return ` ${dot} ${c.bold(String(r.n).padStart(2))} ${r.agent.padEnd(7)} ${c.grey(state)} ${c.grey(model)}${c.grey(gist(r.prompt, 40))}`;
220
226
  });
221
227
  lines.push(c.grey(`\n /tasks <n> for one in full \u00b7 ${sessionDir()}`));
222
228
  return lines;
package/src/ui.js CHANGED
@@ -24,12 +24,26 @@ export const c = {
24
24
  grey: wrap('grey'),
25
25
  };
26
26
 
27
- export const banner = (model, url) => `
28
- ${c.cyan(' ██ kronk-cli')} ${c.grey('· local agent, no network')}
29
- ${c.grey('model')} ${c.bold(model)}
30
- ${c.grey('server')} ${url}
31
- ${c.grey('/help for commands · Ctrl-C to interrupt · /exit to quit')}
32
- `;
27
+ /** `{current, latest, hint}` when a newer release exists; `{current, hint}` when only the route is known (see src/update.js). */
28
+ const fmtCliUpdate = (u) => (u.latest
29
+ ? `kronk-cli ${u.current} → ${u.latest} · run: ${u.hint}`
30
+ : `kronk-cli ${u.current} · ${u.hint}`);
31
+
32
+ /**
33
+ * `update` is optional and, when there is nothing to report, this renders
34
+ * exactly what it always has — a session with no news prints no extra line.
35
+ * See src/update.js for how `update.cli` is decided; this function only lays
36
+ * it out.
37
+ */
38
+ export const banner = (model, url, update) => [
39
+ '',
40
+ `${c.cyan(' ██ kronk-cli')} ${c.grey('· local agent, no network')}`,
41
+ ` ${c.grey('model')} ${c.bold(model)}`,
42
+ ` ${c.grey('server')} ${url}`,
43
+ ...(update?.cli ? [` ${c.grey('update')} ${fmtCliUpdate(update.cli)}`] : []),
44
+ ` ${c.grey('/help for commands · Ctrl-C to interrupt · /exit to quit')}`,
45
+ '',
46
+ ].join('\n');
33
47
 
34
48
  const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n));
35
49
 
package/src/update.js ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * "A newer kronk-cli is available" — asked of the package manager that put
3
+ * this copy on disk, never of the network directly. `test/egress.test.js`
4
+ * forbids `fetch` to anything but the configured Kronk host and forbids a raw
5
+ * `node:http` import; this module respects both by going through `npm`,
6
+ * `brew` and `git` subprocesses the same way `src/setup.js` goes through
7
+ * `kronk` — a manager knows the version graph for the thing it manages, and
8
+ * asking it is the whole point of the issue this module exists for.
9
+ *
10
+ * It is a fifth entry in the `child_process` allowlist that test enforces,
11
+ * alongside `context.js`, `mcp.js`, `setup.js` and `tools.js`. It earns its
12
+ * own entry rather than borrowing one of theirs: `setup.js`'s door is
13
+ * `runKronk`, which only ever spawns the `kronk` binary, and reusing it here
14
+ * would mean either widening what that door lets through or lying about what
15
+ * command is actually running. `context.js` spawns `git` too, but only ever
16
+ * `-C <the user's project>` to describe *their* repository for the system
17
+ * prompt — pointing it at kronk-cli's own install directory to ask a
18
+ * different question would overload what that module is for. A dedicated
19
+ * module keeps each spawner's job legible from its name, which is what the
20
+ * allowlist is trying to preserve in the first place.
21
+ *
22
+ * Never on the path that blocks the prompt. `updateStatus` reads a small
23
+ * cache file synchronously (cheap, local, not a subprocess) and returns
24
+ * immediately; when that cache is missing or a day or more old it also kicks
25
+ * off `refreshCliUpdate` for next time, deliberately unawaited. A stale line
26
+ * shown instantly beats a fresh one that made the REPL wait for `npm view` to
27
+ * come back over whatever network the machine has today.
28
+ */
29
+ import { execFile } from 'node:child_process';
30
+ import { promisify } from 'node:util';
31
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
32
+ import { dirname, join, sep } from 'node:path';
33
+ import { config } from './config.js';
34
+ import { VERSION } from './version.js';
35
+
36
+ const execFileAsync = promisify(execFile);
37
+
38
+ /**
39
+ * Run a manager and let go of it.
40
+ *
41
+ * `unref` is the whole reason this wrapper exists. `updateStatus` fires the
42
+ * refresh deliberately unawaited, and a live child keeps a handle on the event
43
+ * loop: without it, quitting the REPL in the first few seconds of a session
44
+ * prints "bye" and then sits there until `npm view` answers or the timeout
45
+ * kills it. A cache written for next time is never worth delaying this exit
46
+ * for — if the answer is lost because the process went away, the next session
47
+ * simply asks again.
48
+ */
49
+ function exec(cmd, argv) {
50
+ const p = execFileAsync(cmd, argv, { timeout: CHECK_TIMEOUT_MS });
51
+ p.child?.unref?.();
52
+ return p;
53
+ }
54
+
55
+ /** A cold `npm view` or `brew outdated` over a slow link still has to give up. */
56
+ const CHECK_TIMEOUT_MS = 4000;
57
+
58
+ /** "At most once a day" from the issue, in milliseconds. */
59
+ const DAY_MS = 24 * 60 * 60 * 1000;
60
+
61
+ /** What `packaging/install.sh` leaves behind. The only record that route has. */
62
+ const RECEIPT = '.install-receipt.json';
63
+
64
+ const HINTS = {
65
+ brew: 'brew upgrade kronk-cli',
66
+ npm: 'npm install -g kronk-cli@latest',
67
+ git: 'git pull',
68
+ };
69
+
70
+ // ---- install-route detection --------------------------------------------
71
+
72
+ /**
73
+ * How far up from the running file to look for a `.git` before giving up on
74
+ * calling this a checkout. `src/index.js` is one level under the repository
75
+ * root in every layout this project ships or is developed in; the extra
76
+ * headroom is for a checkout nested a few directories deeper than that.
77
+ */
78
+ const GIT_SEARCH_DEPTH = 6;
79
+
80
+ function findGitRoot(startDir) {
81
+ let dir = startDir;
82
+ for (let i = 0; i < GIT_SEARCH_DEPTH; i++) {
83
+ if (existsSync(join(dir, '.git'))) return dir;
84
+ const parent = dirname(dir);
85
+ if (parent === dir) return null;
86
+ dir = parent;
87
+ }
88
+ return null;
89
+ }
90
+
91
+ const toPosix = (p) => p.split(sep).join('/');
92
+
93
+ /**
94
+ * Where this running copy of kronk-cli came from, read off the path Node
95
+ * resolved for `src/index.js` rather than asked of any registry — the issue's
96
+ * own framing: "detect the route from where the running src/index.js
97
+ * resolves". Each package manager lays its files out in a shape distinctive
98
+ * enough to tell apart without guessing:
99
+ *
100
+ * - Homebrew nests a formula's version under `Cellar/<name>/<version>`.
101
+ * - npm's global install nests every package under `lib/node_modules`.
102
+ * - `packaging/install.sh` has exactly one job — unpack straight into
103
+ * `$PREFIX/lib/kronk-cli` — and that shape is otherwise unclaimed.
104
+ * - A git checkout is the one route that carries its own `.git` upward
105
+ * from wherever the file sits, so it is checked by content rather than by
106
+ * path shape.
107
+ *
108
+ * Path shape alone is only suggestive, so the routes that can produce
109
+ * *evidence* are asked first, and the install script's receipt is evidence of
110
+ * exactly the same rank as a `.git` directory. That ordering is not a detail:
111
+ * `install.sh` defaults to `$PREFIX=$HOME/.local`, a great many people keep
112
+ * `$HOME` itself under version control for their dotfiles, and walking up
113
+ * from `~/.local/lib/kronk-cli/src` reaches `~/.git` well inside
114
+ * `GIT_SEARCH_DEPTH`. Asking for the `.git` first would tell every one of
115
+ * those people to run `git pull` to upgrade a tarball install — precisely the
116
+ * failure the issue means by "the hint must match how it was installed, or it
117
+ * is worse than nothing". With no receipt there is nothing to go on but the
118
+ * path, and then a `.git` ancestor is the better guess and wins.
119
+ *
120
+ * Returns null when none of the above matches — a shape this function was
121
+ * never taught, which is the honest answer, not a guess dressed as one.
122
+ */
123
+ export function detectInstall(entryPath) {
124
+ const norm = toPosix(entryPath);
125
+ if (/\/Cellar\/kronk-cli\//.test(norm)) return { route: 'brew' };
126
+ if (/\/node_modules\/kronk-cli(\/|$)/.test(norm)) return { route: 'npm' };
127
+
128
+ const libDir = /\/lib\/kronk-cli\//.test(norm) ? dirname(dirname(entryPath)) : null;
129
+ if (libDir && existsSync(join(libDir, RECEIPT))) return { route: 'install-sh', dir: libDir };
130
+
131
+ const gitRoot = findGitRoot(dirname(entryPath));
132
+ if (gitRoot) return { route: 'git', dir: gitRoot };
133
+
134
+ if (libDir) return { route: 'install-sh', dir: libDir };
135
+ return null;
136
+ }
137
+
138
+ // ---- the cache ------------------------------------------------------------
139
+
140
+ function readCache(path) {
141
+ try {
142
+ const data = JSON.parse(readFileSync(path, 'utf8'));
143
+ return typeof data?.checkedAt === 'number' ? data : null;
144
+ } catch {
145
+ // Missing, unreadable, or not JSON — treated exactly like "never checked",
146
+ // which is what it functionally is. A corrupt cache must never crash the
147
+ // one thing on the startup path that is supposed to be purely cosmetic.
148
+ return null;
149
+ }
150
+ }
151
+
152
+ function writeCache(path, data) {
153
+ try {
154
+ mkdirSync(dirname(path), { recursive: true });
155
+ writeFileSync(path, JSON.stringify(data), 'utf8');
156
+ } catch {
157
+ // The cache is a convenience, not the check itself — losing this write
158
+ // only means the next session asks again instead of reading an answer.
159
+ }
160
+ }
161
+
162
+ // ---- semver, just enough of it --------------------------------------------
163
+
164
+ /** `a` and `b` as dotted numeric components; a leading `v` is not data. */
165
+ function isNewer(current, latest) {
166
+ if (!latest) return false;
167
+ const a = current.replace(/^v/, '').split('.').map(Number);
168
+ const b = latest.replace(/^v/, '').split('.').map(Number);
169
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
170
+ const x = a[i] ?? 0;
171
+ const y = b[i] ?? 0;
172
+ if (Number.isNaN(x) || Number.isNaN(y)) return false; // an unparseable
173
+ if (y > x) return true; // component is
174
+ if (y < x) return false; // "no opinion"
175
+ }
176
+ return false;
177
+ }
178
+
179
+ // ---- asking each manager ---------------------------------------------------
180
+
181
+ async function npmLatest() {
182
+ try {
183
+ const { stdout } = await exec('npm', ['view', 'kronk-cli', 'version']);
184
+ const v = stdout.trim();
185
+ return /^\d+\.\d+\.\d+/.test(v) ? v : null;
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ async function brewLatest() {
192
+ try {
193
+ const { stdout } = await exec('brew', ['outdated', '--json']);
194
+ const data = JSON.parse(stdout);
195
+ // Absent means "not outdated", which is not a failure — most days this
196
+ // is the answer, and it renders exactly like "nothing to report".
197
+ return data.formulae?.find((f) => f.name === 'kronk-cli')?.current_version ?? null;
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * `git fetch --tags` is the one network call this module makes through a
205
+ * manager rather than being blocked outright — the issue asks for it by
206
+ * name ("though the fetch is a network call made through git and should
207
+ * follow the same off-switch as the rest"), which `refreshCliUpdate`'s
208
+ * `config.updateCheck` guard already covers before this is ever called.
209
+ */
210
+ async function gitLatest(dir) {
211
+ try {
212
+ await exec('git', ['-C', dir, 'fetch', '--tags', '--quiet']);
213
+ const { stdout } = await exec('git', ['-C', dir, 'tag', '--sort=-v:refname']);
214
+ return stdout.split('\n').map((l) => l.trim()).find(Boolean) ?? null;
215
+ } catch {
216
+ return null;
217
+ }
218
+ }
219
+
220
+ async function latestForRoute(route, dir) {
221
+ if (route === 'brew') return brewLatest();
222
+ if (route === 'npm') return npmLatest();
223
+ if (route === 'git') return gitLatest(dir);
224
+ return null;
225
+ }
226
+
227
+ /**
228
+ * The install script leaves no record of where a copy came from unless it
229
+ * chose to write one — see `packaging/install.sh`'s receipt. Its presence is
230
+ * the only thing this route can honestly report: not a version comparison
231
+ * (there is no registry to ask), just a nudge to re-run the same script,
232
+ * exactly as the issue specifies: "without claiming to know whether a newer
233
+ * release exists". No cache, no subprocess — a receipt is either sitting
234
+ * next to this install or it is not.
235
+ */
236
+ function installScriptHint(install) {
237
+ if (!existsSync(join(install.dir, RECEIPT))) return null;
238
+ return { current: VERSION, hint: 're-run the install script' };
239
+ }
240
+
241
+ /**
242
+ * Ask the detected manager what the newest kronk-cli is, and cache the
243
+ * answer. Exported so a test can await it directly; `updateStatus` below
244
+ * fires it without awaiting, which is the shape the startup path actually
245
+ * uses.
246
+ */
247
+ export async function refreshCliUpdate({ entryPath = process.argv[1], install, cacheFile = config.updateCacheFile } = {}) {
248
+ const found = install ?? detectInstall(entryPath);
249
+ const cache = { checkedAt: Date.now(), cli: null };
250
+ if (found && found.route !== 'install-sh') {
251
+ // A git tag is conventionally `v0.8.1`; npm and Homebrew both answer in
252
+ // bare `0.8.1`. Stripped here, once, so the banner's arrow reads as one
253
+ // version pointing at another and not at a spelling difference.
254
+ const latest = (await latestForRoute(found.route, found.dir))?.replace(/^v/, '') ?? null;
255
+ if (isNewer(VERSION, latest)) {
256
+ cache.cli = { current: VERSION, latest, hint: HINTS[found.route] };
257
+ }
258
+ }
259
+ writeCache(cacheFile, cache);
260
+ return cache.cli;
261
+ }
262
+
263
+ /**
264
+ * What the startup banner should say about kronk-cli's own version, decided
265
+ * without ever making the REPL wait for it.
266
+ *
267
+ * `KRONK_UPDATE_CHECK=false` (or the matching config key) turns the whole
268
+ * thing off: no cache read, no subprocess, ever — for anyone who wants a
269
+ * program that runs nothing it was not asked to run.
270
+ */
271
+ export function updateStatus({
272
+ entryPath = process.argv[1], now = Date.now(), cacheFile = config.updateCacheFile,
273
+ } = {}) {
274
+ if (!config.updateCheck) return null;
275
+
276
+ const install = detectInstall(entryPath);
277
+ if (!install) return null;
278
+
279
+ if (install.route === 'install-sh') {
280
+ const hint = installScriptHint(install);
281
+ return hint ? { cli: hint } : null;
282
+ }
283
+
284
+ const cache = readCache(cacheFile);
285
+ const stale = !cache || now - cache.checkedAt >= DAY_MS;
286
+ if (stale) {
287
+ // Next session's answer, not this one's wait.
288
+ refreshCliUpdate({ entryPath, install, cacheFile }).catch(() => {});
289
+ }
290
+ return cache?.cli ? { cli: cache.cli } : null;
291
+ }
package/src/version.js ADDED
@@ -0,0 +1,35 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ /**
4
+ * The one place that reads this package's own version.
5
+ *
6
+ * Everything that needs to say what build this is — the `--version` flag, the
7
+ * MCP `clientInfo` handshake, the update check's idea of "current" — imports
8
+ * `VERSION` from here instead of carrying its own copy. `src/mcp.js` used to
9
+ * hardcode `'0.1.0'` and nothing ever noticed it had fallen six releases
10
+ * behind, because nothing read the real number. `package.json` is in the
11
+ * published `files` allowlist, so this works on every install route: npm,
12
+ * Homebrew, the install script, and a git checkout all ship the real file.
13
+ */
14
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
15
+
16
+ export const VERSION = pkg.version;
17
+
18
+ /**
19
+ * `kronk version 1.32.7\n` -> `1.32.7`. `runKronk(['--version'])` is the one
20
+ * door this program uses to ask the `kronk` binary anything, and this is the
21
+ * one place that makes sense of what comes back. Falls back to the first line
22
+ * rather than to null, on the theory that an unexpected format is still more
23
+ * informative on screen than nothing — but only the first line, and only so
24
+ * much of it: `runKronk` captures up to CAPTURE_CAP of whatever the binary
25
+ * decided to print, and `--version` is one line of output, not a place to
26
+ * empty a stack trace into.
27
+ */
28
+ const STRAY_MAX = 60;
29
+
30
+ export function parseKronkVersion(stdout) {
31
+ const m = String(stdout).match(/version\s+(\S+)/i);
32
+ if (m) return m[1];
33
+ const first = String(stdout).split('\n')[0].trim();
34
+ return first ? first.slice(0, STRAY_MAX) : null;
35
+ }