kaizen-agent 0.3.0 → 0.5.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/cli/install.ts CHANGED
@@ -1,15 +1,208 @@
1
1
  #!/usr/bin/env bun
2
- import { symlinkSync, mkdirSync, readdirSync, lstatSync, readlinkSync, unlinkSync, existsSync } from "node:fs";
2
+ import {
3
+ symlinkSync, mkdirSync, readdirSync, lstatSync, readlinkSync, unlinkSync,
4
+ existsSync, copyFileSync, readFileSync, appendFileSync, rmSync, writeFileSync,
5
+ } from "node:fs";
3
6
  import { join, dirname, basename } from "node:path";
4
7
  import { homedir } from "node:os";
5
8
 
6
9
  const home = homedir();
7
10
  const REPO_URL = "https://github.com/hfadhlullah/kaizen.git";
11
+ const args = new Set(Bun.argv.slice(2));
12
+ const upgrade = args.has("upgrade") || args.has("--upgrade");
13
+ const wantSettings = args.has("settings") || args.has("config");
14
+ const check = args.has("--check");
15
+ const force = args.has("--force");
16
+ const verbose = args.has("--verbose");
17
+ const interactive = process.stdin.isTTY && !check && !upgrade && !args.has("--yes");
18
+
19
+ const c = {
20
+ dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
21
+ bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
22
+ cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
23
+ green: (s: string) => `\x1b[32m${s}\x1b[0m`,
24
+ };
25
+
26
+ // Where each agent reads skills from. The skill folders follow the open Agent
27
+ // Skills standard, so every one of these can run the workflow; agents/ and
28
+ // commands/ are Claude Code's own formats and go nowhere else.
29
+ const AGENTS = [
30
+ { name: "Claude Code", dir: ".claude", full: true },
31
+ { name: "Codex", dir: ".codex", full: false },
32
+ { name: "Antigravity", dir: ".agents", full: false },
33
+ { name: "OpenCode", dir: ".opencode", full: false },
34
+ { name: "Cursor", dir: ".cursor", full: false },
35
+ { name: "Gemini CLI", dir: ".gemini", full: false },
36
+ ];
37
+
38
+ // Install into an agent that is actually present. Creating ~/.opencode for someone
39
+ // who does not use OpenCode leaves litter that looks like configuration. Claude Code
40
+ // is always a target: it is the one this is installed from.
41
+ const found = (r: string) => AGENTS.filter((a) => a.dir === ".claude" || existsSync(join(r, a.dir)));
42
+
43
+ if (wantSettings) {
44
+ const { settings } = await import("./settings.ts");
45
+ await settings(await resolveRepoQuietly());
46
+ process.exit(0);
47
+ }
48
+
49
+ // Bare `kaizen` on a machine where it is already installed is not an install --
50
+ // show what the project is doing instead, and offer the things you would have
51
+ // opened a terminal for.
52
+ if (interactive && !upgrade && Bun.argv.length === 2) {
53
+ const repoDir = await resolveRepoQuietly();
54
+ const here = found(home);
55
+ const linked = here.every((a) =>
56
+ ["kaizen", "kaizen-help"].every((n) => existsSync(join(home, a.dir, "skills", n))));
57
+ if (linked) {
58
+ const { dashboard } = await import("./dashboard.ts");
59
+ await dashboard(repoDir, here.map((a) => a.name), async (action) => {
60
+ if (action === "settings") {
61
+ const { settings } = await import("./settings.ts");
62
+ await settings(repoDir, false);
63
+ } else if (action === "upgrade") {
64
+ return await runUpgrade(repoDir);
65
+ } else if (action === "init") {
66
+ const out = await Bun.$`bun run ${join(repoDir, "cli/install.ts")} --yes`.text();
67
+ return clean(out);
68
+ }
69
+ });
70
+ process.exit(0);
71
+ }
72
+ }
73
+
74
+ if (upgrade) await clearBunxCache();
75
+ if (!check && !upgrade) await welcome();
76
+
77
+ async function welcome() {
78
+ const rgb = (r: number, g: number, b: number, s: string) =>
79
+ `\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`;
80
+
81
+ // Fuji: snow on the peak, deepening blue down the slope.
82
+ const WORD = [
83
+ "██╗ ██╗ █████╗ ██╗███████╗███████╗███╗ ██╗",
84
+ "██║ ██╔╝██╔══██╗██║╚══███╔╝██╔════╝████╗ ██║",
85
+ "█████╔╝ ███████║██║ ███╔╝ █████╗ ██╔██╗ ██║",
86
+ "██╔═██╗ ██╔══██║██║ ███╔╝ ██╔══╝ ██║╚██╗██║",
87
+ "██║ ██╗██║ ██║██║███████╗███████╗██║ ╚████║",
88
+ "╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝╚══════╝╚═╝ ╚═══╝",
89
+ ];
90
+ const W = 69, C = 34, SNOW = 3;
91
+ // One constant slope, three columns per row, all the way down. The rows behind
92
+ // the wordmark keep the same rate, so the edge that goes in above the letters
93
+ // comes out below them on the same line -- vary it and the cone reads broken.
94
+ const H = [1, 4, 7, 10, 13, 16, 19, 22, 25, 28, 31, 34];
95
+
96
+ // Anything that would wrap is worse than the smaller thing that fits: the
97
+ // mountain needs the full width, the wordmark needs 47, below that use words.
98
+ const cols = process.stdout.columns || 80;
99
+ const wide = cols >= W + 4;
100
+ if (cols < 49) {
101
+ console.log(`\n ${c.bold("kaizen")}`);
102
+ } else {
103
+
104
+ const cell: string[][] = H.map(() => Array(W).fill(" "));
105
+ const isWord: boolean[][] = H.map(() => Array(W).fill(false));
106
+
107
+ if (wide) {
108
+ for (const [i, h] of H.entries()) {
109
+ const l = C - h, r = C + h;
110
+ if (i < SNOW) { // solid cap
111
+ cell[i]![l] = "▄"; cell[i]![r] = "▄";
112
+ for (let x = l + 1; x < r; x++) cell[i]![x] = "█";
113
+ } else { // slope, drawn as two edges
114
+ // Below the wordmark's top row the letters own the middle, so draw an
115
+ // edge only where all three of its characters clear them.
116
+ const band = i >= H.length - WORD.length;
117
+ const gap = Math.floor((W - 45) / 2) - 2;
118
+ if (!band || l + 2 < gap) {
119
+ cell[i]![l] = "▄"; cell[i]![l + 1] = "█"; cell[i]![l + 2] = "▀";
120
+ cell[i]![r - 2] = "▀"; cell[i]![r - 1] = "█"; cell[i]![r] = "▄";
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ const top = H.length - WORD.length, left = Math.floor((W - 45) / 2);
127
+ for (let r = top; r < H.length; r++) // air around the letters
128
+ for (let x = left - 2; x < left + 47; x++) if (x >= 0 && x < W) cell[r]![x] = " ";
129
+ for (const [r, line] of WORD.entries())
130
+ for (const [x, ch] of [...line].entries())
131
+ if (ch !== " ") { cell[top + r]![left + x] = ch; isWord[top + r]![left + x] = true; }
132
+
133
+ console.log();
134
+ for (const [i, row] of cell.entries()) {
135
+ if (!wide && i < top) continue; // no mountain to draw, no blank rows
136
+ const slope = Math.max(0, i - SNOW) / (H.length - SNOW);
137
+ const mountain: [number, number, number] = i < SNOW
138
+ ? [255 - i * 8, 255 - i * 5, 255]
139
+ : [Math.round(150 - slope * 105), Math.round(190 - slope * 110), Math.round(245 - slope * 80)];
140
+
141
+ // With no mountain there is nothing to sit under, so drop its left margin.
142
+ const off = wide ? 0 : left;
143
+ const chars = row.slice(off), flags = isWord[i]!.slice(off);
144
+
145
+ let out = " ", run = "", runWord = flags[0]!;
146
+ const flush = () => {
147
+ if (!run) return;
148
+ const t = (i - top) / (WORD.length - 1);
149
+ const col: [number, number, number] = runWord
150
+ ? [Math.round(214 - t * 150), Math.round(232 - t * 130), Math.round(255 - t * 45)]
151
+ : mountain;
152
+ out += rgb(col[0], col[1], col[2], run);
153
+ run = "";
154
+ };
155
+ for (const [x, ch] of chars.entries()) {
156
+ if (flags[x] !== runWord) { flush(); runWord = flags[x]!; }
157
+ run += ch;
158
+ }
159
+ flush();
160
+ console.log(out.replace(/\s+$/, ""));
161
+ }
162
+ if (wide) console.log(" " + rgb(38, 66, 130, "▀".repeat(W)));
163
+ }
164
+
165
+ console.log(`
166
+ ${c.bold("改善")} ${c.dim("kaizen — continuous improvement")}
167
+
168
+ ${c.dim("A staged workflow for AI agents — code, writing, research, operations:")}
169
+ ${c.dim("plan -> you approve -> build -> independent review -> bounded fix loop")}
170
+ `);
171
+
172
+ if (interactive) await anyKey();
173
+ }
174
+
175
+ // A pause before anything touches the filesystem, and the place to back out.
176
+ async function anyKey() {
177
+ const { stdin, stdout } = process;
178
+ stdout.write(` ${c.dim("press")} ${c.cyan("enter")} ${c.dim("to install, or")} ${c.cyan("ctrl-c")} ${c.dim("to leave")}`);
179
+ stdin.setRawMode(true);
180
+ stdin.resume();
181
+ await new Promise<void>((resolve) => {
182
+ const onData = (chunk: Buffer) => {
183
+ if (chunk.toString().includes("\x03")) { stdout.write("\n\n nothing installed\n"); process.exit(130); }
184
+ stdin.off("data", onData);
185
+ resolve();
186
+ };
187
+ stdin.on("data", onData);
188
+ });
189
+ stdin.setRawMode(false);
190
+ stdin.pause();
191
+ stdout.write("\r\x1b[2K");
192
+ }
8
193
 
9
- // Links point back at the clone, so the clone has to be somewhere permanent. Run
10
- // from a git checkout and that checkout is it; run through `bunx`, and the package
11
- // lives in a temp cache that gets evicted, so clone to KAIZEN_HOME first.
12
194
  const repo = await resolveRepo();
195
+ const root = await chooseRoot();
196
+ const targets = found(root);
197
+
198
+ // ---------------------------------------------------------------- repo
199
+
200
+ // Settings only needs the clone's default config, not a pull.
201
+ async function resolveRepoQuietly() {
202
+ const here = dirname(import.meta.dir);
203
+ if (existsSync(join(here, ".git"))) return here;
204
+ return process.env.KAIZEN_HOME ?? join(home, "kaizen");
205
+ }
13
206
 
14
207
  async function resolveRepo() {
15
208
  const here = dirname(import.meta.dir);
@@ -17,29 +210,39 @@ async function resolveRepo() {
17
210
 
18
211
  const dest = process.env.KAIZEN_HOME ?? join(home, "kaizen");
19
212
  if (existsSync(join(dest, ".git"))) {
20
- // A pull can refuse for reasons that have nothing to do with linking — local
21
- // edits, a detached head, no network. Linking the checkout that is already
22
- // there still works, so say what happened and carry on rather than failing
23
- // the install over it.
213
+ // A pull can refuse for reasons that have nothing to do with linking -- local
214
+ // edits, a detached head, no network. Linking the checkout already on disk
215
+ // still works, so say what happened and carry on.
24
216
  try {
25
217
  await Bun.$`git -C ${dest} pull --ff-only`.quiet();
26
- console.log(`update ${dest}`);
218
+ step(`updated ${tilde(dest)}`);
27
219
  } catch {
28
- console.log(`skip ${dest} — could not pull, linking what is there`);
220
+ step(`kept ${tilde(dest)} ${c.dim("(could not pull)")}`);
29
221
  }
30
222
  return dest;
31
223
  }
32
- console.log(`clone ${REPO_URL} -> ${dest}`);
224
+ step(`cloning into ${tilde(dest)}`);
33
225
  await Bun.$`git clone --quiet ${REPO_URL} ${dest}`;
34
226
  return dest;
35
227
  }
36
228
 
37
- // Each entry: source path in the repo -> directory it is linked into.
38
- const links = [
39
- ...["kaizen", "kaizen-help"].map((n) => [join(repo, "skills", n), "skills"] as const),
40
- ...dirEntries("agents").map((f) => [f, "agents"] as const),
41
- ...dirEntries("commands").map((f) => [f, "commands"] as const),
42
- ];
229
+ async function chooseRoot() {
230
+ if (args.has("--project")) return process.cwd();
231
+ if (args.has("--global") || !interactive) return home;
232
+ // A project install only means something in a project. Standing in the home
233
+ // directory it is the global install under another name, and outside a
234
+ // repository there is nothing for it to belong to.
235
+ const cwd = process.cwd();
236
+ if (cwd === home || !existsSync(join(cwd, ".git"))) return home;
237
+
238
+ const names = (r: string) => found(r).map((a) => a.name).join(", ");
239
+ return select("Install kaizen for", [
240
+ { label: "Globally", hint: `every project \u2014 ${names(home)}`, value: home },
241
+ { label: "This project only", hint: `${basename(cwd)}/ \u2014 ${names(cwd)}`, value: cwd },
242
+ ]);
243
+ }
244
+
245
+ // ---------------------------------------------------------------- linking
43
246
 
44
247
  function dirEntries(sub: string) {
45
248
  return readdirSync(join(repo, sub))
@@ -47,6 +250,14 @@ function dirEntries(sub: string) {
47
250
  .map((f) => join(repo, sub, f));
48
251
  }
49
252
 
253
+ const links = targets.flatMap((a) => [
254
+ ...["kaizen", "kaizen-help"].map((n) => [join(repo, "skills", n), join(a.dir, "skills")] as const),
255
+ ...(a.full ? [
256
+ ...dirEntries("agents").map((f) => [f, join(a.dir, "agents")] as const),
257
+ ...dirEntries("commands").map((f) => [f, join(a.dir, "commands")] as const),
258
+ ] : []),
259
+ ]);
260
+
50
261
  function linkState(src: string, dest: string) {
51
262
  try {
52
263
  const st = lstatSync(dest);
@@ -57,62 +268,247 @@ function linkState(src: string, dest: string) {
57
268
  }
58
269
  }
59
270
 
60
- const args = new Set(Bun.argv.slice(2));
61
- const check = args.has("--check");
62
- const force = args.has("--force");
63
- const base = join(await chooseRoot(), ".claude");
271
+ let blocked = 0, wrote = 0, already = 0;
272
+ for (const [src, sub] of links) {
273
+ const dir = join(root, sub);
274
+ const dest = join(dir, basename(src));
275
+ const state = linkState(src, dest);
276
+ const name = `${sub}/${basename(src)}`;
64
277
 
65
- async function chooseRoot() {
66
- if (args.has("--project")) return process.cwd();
67
- if (args.has("--global") || check) return home;
278
+ if (check) {
279
+ if (state !== "linked") blocked++;
280
+ console.log(`${state === "linked" ? "ok " : "MISS"} ${name}${state === "linked" ? "" : ` (${state})`}`);
281
+ continue;
282
+ }
283
+ if (state === "linked") { already++; if (verbose) console.log(c.dim(` ok ${name}`)); continue; }
284
+ if (state === "occupied" && !force) {
285
+ console.log(` ${c.bold("skip")} ${name} — real file present, use --force`);
286
+ blocked++;
287
+ continue;
288
+ }
289
+ mkdirSync(dir, { recursive: true });
290
+ if (state !== "missing") unlinkSync(dest);
291
+ symlinkSync(src, dest);
292
+ wrote++;
293
+ if (verbose) console.log(c.dim(` link ${name}`));
294
+ }
68
295
 
69
- // Only ask when there is someone to answer. Piped, scripted, or CI runs take the
70
- // global install silently rather than hanging on a prompt nobody sees.
71
- if (!process.stdin.isTTY) return home;
296
+ if (check) {
297
+ console.log(blocked ? `\n${blocked} link(s) missing — run: bunx kaizen-agent` : "\nall linked");
298
+ process.exit(blocked ? 1 : 0);
299
+ }
72
300
 
73
- const choice = await select("Install where?", [
74
- { label: "Every project", hint: join(home, ".claude"), value: home },
75
- { label: "This one only", hint: join(process.cwd(), ".claude"), value: process.cwd() },
76
- ]);
77
- return choice;
301
+ const into = targets.map((a) => a.name).join(", ");
302
+ step(
303
+ wrote && already ? `linked ${wrote} new, ${already} already in place — ${into}`
304
+ : wrote ? `linked ${wrote} files into ${into}`
305
+ : `already installed for ${into} ${c.dim("(nothing to change)")}`,
306
+ );
307
+ if (targets.length === 1) {
308
+ console.log(` ${c.dim("Codex, Antigravity, OpenCode, Cursor and Gemini CLI get it too,")}`);
309
+ console.log(` ${c.dim("if their directory exists when you install.")}`);
310
+ }
311
+
312
+ // ---------------------------------------------------------------- repo setup
313
+
314
+ if (!args.has("--project")) installLauncher();
315
+
316
+ // bunx runs from a temp copy and leaves nothing behind, so there is no `kaizen` to
317
+ // type afterwards. A launcher pointed at the clone gives one, and keeps working
318
+ // after the clone updates.
319
+ function installLauncher() {
320
+ const binDir = join(home, ".local", "bin");
321
+ const bin = join(binDir, "kaizen");
322
+ const script = `#!/bin/sh\n# kaizen launcher -- installed by kaizen-agent\nexec bun run ${join(repo, "cli/install.ts")} "$@"\n`;
323
+ try {
324
+ if (existsSync(bin) && readFileSync(bin, "utf8") === script) return;
325
+ mkdirSync(binDir, { recursive: true });
326
+ writeFileSync(bin, script, { mode: 0o755 });
327
+ const onPath = (process.env.PATH ?? "").split(":").includes(binDir);
328
+ step(onPath
329
+ ? `${c.cyan("kaizen upgrade")} is now on your PATH`
330
+ : `wrote ${tilde(bin)} ${c.dim("— add ~/.local/bin to PATH to use it")}`);
331
+ } catch {
332
+ /* a read-only home is not worth failing the install over */
333
+ }
334
+ }
335
+
336
+ const inGitRepo = existsSync(join(process.cwd(), ".git"));
337
+ const kaizenDir = join(process.cwd(), ".kaizen");
338
+
339
+ if (inGitRepo && !existsSync(kaizenDir)) {
340
+ const now = interactive
341
+ ? await select(`Set up ${basename(process.cwd())}/ for kaizen now`, [
342
+ { label: "Yes", hint: "writes .kaizen/ and a CLAUDE.md line so it runs by default", value: true },
343
+ { label: "Not now", hint: "the first /kaizen run will do it", value: false },
344
+ ])
345
+ : false;
346
+ if (now) initRepo();
347
+ } else if (existsSync(kaizenDir)) {
348
+ step(`${basename(process.cwd())}/.kaizen already set up`);
349
+ }
350
+
351
+ function initRepo() {
352
+ mkdirSync(kaizenDir, { recursive: true });
353
+ for (const f of readdirSync(join(repo, "skills/kaizen")).filter((f) => f.startsWith("spec"))) {
354
+ copyFileSync(join(repo, "skills/kaizen", f), join(kaizenDir, f));
355
+ }
356
+ copyFileSync(join(repo, "skills/kaizen/config.default.yml"), join(kaizenDir, "config.yml"));
357
+
358
+ // Run state is local; the workflow itself is shared, so those files stay tracked.
359
+ const rules = ".kaizen/*\n!.kaizen/spec*.md\n!.kaizen/config.yml\n";
360
+ const gi = join(process.cwd(), ".gitignore");
361
+ const current = existsSync(gi) ? readFileSync(gi, "utf8") : "";
362
+ if (!current.includes(".kaizen/*")) {
363
+ appendFileSync(gi, (current && !current.endsWith("\n") ? "\n" : "") + "\n# kaizen run state\n" + rules);
364
+ }
365
+ step("wrote .kaizen/ and the gitignore entry");
366
+ writePointer();
367
+ }
368
+
369
+ // Claude Code loads a skill by matching its description, so an ordinary request
370
+ // ("add a discount cap") does not reach kaizen unless the project says it should.
371
+ // One line in the agent instructions is what makes it the default here.
372
+ function writePointer() {
373
+ const block = `\n## Kaizen workflow\n\nNon-trivial work in this repository follows the staged workflow in\n\`.kaizen/spec.md\`: plan, human approval, build, independent review, bounded fix\nloop. Invoke the \`kaizen\` skill for it — do not carry the stages out inline.\n\nNon-trivial means work worth a plan: a change across more than one file, anything\nwith a migration or a rollback, or a document someone else will act on. Answering a\nquestion, reading code, or a one-line fix is not, and should not start a run.\n\nRun state lives in \`.kaizen/runs/<id>/\`. Read \`state.json\` first and continue from\nthe stage it names; never advance a run whose \`awaiting\` field is non-null without a\nrecorded human decision.\n`;
374
+
375
+ for (const name of ["CLAUDE.md", "AGENTS.md"]) {
376
+ const f = join(process.cwd(), name);
377
+ const current = existsSync(f) ? readFileSync(f, "utf8") : "";
378
+ if (current.includes("## Kaizen workflow")) continue;
379
+ if (!current && name === "AGENTS.md") continue; // do not create one that was never there
380
+ const text = current ? (current.endsWith("\n") ? "" : "\n") + block : block.slice(1);
381
+ appendFileSync(f, text);
382
+ step(`${existsSync(f) && current ? "added to" : "wrote"} ${name} ${c.dim("— kaizen now runs without being asked for")}`);
383
+ }
384
+ }
385
+
386
+ // ---------------------------------------------------------------- done
387
+
388
+ if (upgrade) {
389
+ let version = "";
390
+ try { version = JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).version; } catch {}
391
+ console.log(`
392
+ ${c.bold("Up to date")}${version ? c.dim(` — kaizen ${version}`) : ""}
393
+ ${c.dim("Restart Claude Code to pick up any new slash commands.")}
394
+ `);
395
+ } else {
396
+ console.log(`
397
+ ${c.bold("Next")}
398
+ 1 restart Claude Code ${c.dim("— skills load live, slash commands only at session start")}
399
+ 2 ${c.cyan("/kaizen <what you want done>")} ${c.dim("in any project — it sets itself up on first run")}
400
+
401
+ ${c.dim("/kaizen-help lists every command. kaizen upgrade updates all of this.")}
402
+ `);
403
+ }
404
+ if (blocked) process.exit(1);
405
+
406
+ // ---------------------------------------------------------------- helpers
407
+
408
+ function step(msg: string) { console.log(` ${c.green("+")} ${msg}`); }
409
+
410
+ // Runs the upgrade and reports what it actually did, version to version, so the
411
+ // dashboard can show a result rather than the user wondering whether it worked.
412
+ async function runUpgrade(repoDir: string) {
413
+ const before = versionOf(repoDir);
414
+ const out = await Bun.$`bun run ${join(repoDir, "cli/install.ts")} upgrade`.text().catch(() => "");
415
+ const after = versionOf(repoDir);
416
+ const latest = await npmLatest();
417
+
418
+ const lines = [
419
+ before === after
420
+ ? `${c.bold("Already up to date")} ${c.dim(after)}`
421
+ : `${c.bold("Upgraded")} ${c.dim(before)} → ${c.green(after)}`,
422
+ "",
423
+ // Only the step lines: the captured run's own heading and restart notice
424
+ // repeat what this panel already says above them.
425
+ ...clean(out)
426
+ .filter((l) => l.trim().startsWith("+") || l.trim().startsWith("skip"))
427
+ .map((l) => c.dim(l.trim())),
428
+ ];
429
+ if (latest && latest !== after) {
430
+ lines.push("", c.dim(`npm publishes ${latest}; this install follows the git clone.`));
431
+ }
432
+ lines.push("", c.dim("Restart your agent to pick up new slash commands."));
433
+ return lines;
78
434
  }
79
435
 
80
- // Arrow-key picker. Raw mode delivers keys unbuffered, one escape sequence at a
81
- // time; the alternative is a dependency for forty lines of escape codes.
436
+ function versionOf(dir: string) {
437
+ try { return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version as string; }
438
+ catch { return "?"; }
439
+ }
440
+
441
+ async function npmLatest() {
442
+ try {
443
+ const r = await fetch("https://registry.npmjs.org/kaizen-agent/latest", {
444
+ signal: AbortSignal.timeout(4000),
445
+ });
446
+ return ((await r.json()) as { version?: string }).version ?? null;
447
+ } catch { return null; } // offline is not an upgrade failure
448
+ }
449
+
450
+ // Captured output still carries its own colour codes, which stop any test on the
451
+ // text from matching and would nest inside this panel's own styling.
452
+ function clean(out: string) {
453
+ return out.split("\n").map((l) => l.replace(/\x1b\[[0-9;]*m/g, "").replace(/\r$/, "").trimEnd());
454
+ }
455
+
456
+ // bunx keeps an extracted copy per package under the temp directory and reuses it
457
+ // without re-resolving, so an upgrade that only pulls the clone still leaves the
458
+ // next `bunx kaizen-agent` running whatever was cached the first time.
459
+ async function clearBunxCache() {
460
+ const tmp = process.env.TMPDIR ?? "/tmp";
461
+ let removed = 0;
462
+ try {
463
+ for (const name of readdirSync(tmp)) {
464
+ if (/^bunx-.*kaizen-agent/.test(name)) {
465
+ rmSync(join(tmp, name), { recursive: true, force: true });
466
+ removed++;
467
+ }
468
+ }
469
+ } catch { /* an unreadable temp directory is not worth failing an upgrade over */ }
470
+ step(removed ? `cleared ${removed} cached copy of the installer` : "no installer cache to clear");
471
+ }
472
+ function tilde(p: string) { return p.startsWith(home) ? "~" + p.slice(home.length) : p; }
473
+
474
+ // Arrow-key picker. Raw mode delivers keys unbuffered; the alternative is a
475
+ // dependency for two options and forty lines of escape codes.
82
476
  async function select<T>(question: string, options: { label: string; hint: string; value: T }[]) {
83
477
  const { stdin, stdout } = process;
84
478
  let active = 0;
85
479
 
86
480
  const draw = (first: boolean) => {
87
- if (!first) stdout.write(`\x1b[${options.length}A`); // back up over the list
481
+ if (!first) stdout.write(`\x1b[${options.length}A`);
88
482
  for (const [i, o] of options.entries()) {
89
483
  const on = i === active;
90
484
  stdout.write(
91
- `\x1b[2K ${on ? "\x1b[36m>\x1b[0m" : " "} ${on ? "\x1b[1m" : "\x1b[2m"}${o.label}\x1b[0m` +
92
- ` \x1b[2m${o.hint}\x1b[0m\n`,
485
+ `\x1b[2K ${on ? c.cyan(">") : " "} ${on ? c.bold(o.label) : c.dim(o.label)}` +
486
+ ` ${c.dim(o.hint)}\n`,
93
487
  );
94
488
  }
95
489
  };
96
490
 
97
- stdout.write(`\n\x1b[1m${question}\x1b[0m \x1b[2m(arrows, enter)\x1b[0m\n`);
98
- stdout.write("\x1b[?25l"); // hide cursor
491
+ stdout.write(`\n ${c.bold(question)} ${c.dim("(arrows, enter)")}\n`);
492
+ stdout.write("\x1b[?25l");
99
493
  draw(true);
100
494
  stdin.setRawMode(true);
101
495
  stdin.resume();
102
496
 
103
- try {
104
- outer: for await (const chunk of stdin) {
497
+ // Read via events rather than `for await`: breaking out of an async iterator
498
+ // destroys stdin, and a second prompt would then have no stream to read.
499
+ await new Promise<void>((resolve) => {
500
+ const onData = (chunk: Buffer) => {
105
501
  // A chunk can hold more than one keypress -- an arrow and the enter behind
106
502
  // it arrive together when input is piped -- so walk it rather than
107
503
  // comparing the whole thing to one key.
108
504
  const keys = chunk.toString();
109
505
  for (let i = 0; i < keys.length; i++) {
110
506
  const rest = keys.slice(i);
111
- if (rest.startsWith("\x03")) { // ctrl-c
112
- stdout.write("\ncancelled\n");
113
- process.exit(130);
507
+ if (rest.startsWith("\x03")) { stdout.write("\ncancelled\n"); process.exit(130); }
508
+ if (rest.startsWith("\r") || rest.startsWith("\n")) {
509
+ stdin.off("data", onData);
510
+ return resolve();
114
511
  }
115
- if (rest.startsWith("\r") || rest.startsWith("\n")) break outer;
116
512
  if (rest.startsWith("\x1b[A") || rest.startsWith("\x1b[B")) {
117
513
  active = rest[2] === "A"
118
514
  ? (active - 1 + options.length) % options.length
@@ -123,42 +519,12 @@ async function select<T>(question: string, options: { label: string; hint: strin
123
519
  else continue;
124
520
  draw(false);
125
521
  }
126
- }
127
- } finally {
128
- stdin.setRawMode(false);
129
- stdin.pause();
130
- stdout.write("\x1b[?25h\n"); // show cursor again
131
- }
132
- return options[active]!.value;
133
- }
522
+ };
523
+ stdin.on("data", onData);
524
+ });
134
525
 
135
- let bad = 0;
136
- for (const [src, sub] of links) {
137
- const dir = join(base, sub);
138
- const dest = join(dir, basename(src));
139
- const state = linkState(src, dest);
140
-
141
- if (check) {
142
- if (state !== "linked") bad++;
143
- console.log(`${state === "linked" ? "ok " : "MISS"} ${sub}/${basename(src)}${state === "linked" ? "" : ` (${state})`}`);
144
- continue;
145
- }
146
- if (state === "linked") { console.log(`ok ${sub}/${basename(src)}`); continue; }
147
- if (state === "occupied" && !force) {
148
- console.log(`skip ${sub}/${basename(src)} — real file present, use --force`);
149
- bad++;
150
- continue;
151
- }
152
- mkdirSync(dir, { recursive: true });
153
- if (state !== "missing") unlinkSync(dest);
154
- symlinkSync(src, dest);
155
- console.log(`link ${sub}/${basename(src)}`);
156
- }
157
-
158
- if (check) {
159
- console.log(bad ? `\n${bad} link(s) missing — run: bun run ${repo}/cli/install.ts` : "\nall linked");
160
- process.exit(bad ? 1 : 0);
526
+ stdin.setRawMode(false);
527
+ stdin.pause();
528
+ stdout.write("\x1b[?25h");
529
+ return options[active]!.value;
161
530
  }
162
- console.log(`\nInstalled into ${base}. Restart Claude Code: skills load live, slash commands only on session start.`);
163
- console.log(`Then, in a repository you want to use it on: /kaizen-init`);
164
- if (bad) process.exit(1);