kronk-cli 0.7.0 → 0.8.1
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 +187 -5
- package/package.json +2 -1
- package/src/agent.js +7 -2
- package/src/argv.js +11 -1
- package/src/boot.js +35 -2
- package/src/config.js +21 -3
- package/src/index.js +87 -28
- package/src/mcp.js +4 -1
- package/src/pane.js +10 -2
- package/src/rescue.js +306 -0
- package/src/rescueStore.js +277 -0
- package/src/resolve.js +27 -0
- package/src/setup.js +16 -3
- package/src/subagent.js +148 -21
- package/src/tasklog.js +7 -1
- package/src/ui.js +20 -6
- package/src/update.js +291 -0
- package/src/version.js +35 -0
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
252
|
-
window:
|
|
253
|
-
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
${
|
|
30
|
-
${
|
|
31
|
-
|
|
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
|
+
}
|