kaizen-agent 1.7.6 → 1.7.7

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.
Files changed (3) hide show
  1. package/cli/install.ts +950 -947
  2. package/cli/web.ts +6 -2
  3. package/package.json +1 -1
package/cli/install.ts CHANGED
@@ -1,947 +1,950 @@
1
- #!/usr/bin/env bun
2
- import {
3
- symlinkSync, mkdirSync, readdirSync, lstatSync, readlinkSync, unlinkSync,
4
- existsSync, copyFileSync, readFileSync, appendFileSync, rmSync, writeFileSync, renameSync,
5
- statSync, cpSync,
6
- } from "node:fs";
7
- import { join, dirname, basename } from "node:path";
8
- import { homedir, tmpdir } from "node:os";
9
- import { applyPreset, detectPreset, PRESETS, type PresetName } from "./settings.ts";
10
-
11
- const home = homedir();
12
- const REPO_URL = "https://github.com/hfadhlullah/kaizen.git";
13
- const args = new Set(Bun.argv.slice(2));
14
- const upgrade = args.has("upgrade") || args.has("--upgrade");
15
- const wantSettings = args.has("settings") || args.has("config");
16
- const wantWeb = args.has("web");
17
- const check = args.has("--check");
18
- const uninstall = args.has("uninstall") || args.has("--uninstall");
19
- const force = args.has("--force");
20
- const verbose = args.has("--verbose");
21
- const interactive = process.stdin.isTTY && !check && !upgrade && !args.has("--yes");
22
-
23
- function parsePresetArg(): PresetName | null {
24
- for (const a of Bun.argv.slice(2)) {
25
- if (a.startsWith("--preset=")) {
26
- const v = a.split("=")[1]?.toLowerCase();
27
- if (v === "low" || v === "medium" || v === "ultra") return v as PresetName;
28
- }
29
- }
30
- const idx = Bun.argv.indexOf("--preset");
31
- if (idx !== -1 && Bun.argv[idx + 1]) {
32
- const v = Bun.argv[idx + 1]?.toLowerCase();
33
- if (v === "low" || v === "medium" || v === "ultra") return v as PresetName;
34
- }
35
- return null;
36
- }
37
- const presetArg = parsePresetArg();
38
-
39
- const c = {
40
- dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
41
- bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
42
- cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
43
- green: (s: string) => `\x1b[32m${s}\x1b[0m`,
44
- };
45
-
46
- // Where each agent reads skills from. The skill folders follow the open Agent
47
- // Skills standard, so every one of these can run the workflow; agents/ and
48
- // commands/ are Claude Code's own formats and go nowhere else.
49
- const AGENTS = [
50
- { name: "Claude Code", dir: ".claude", cmd: "claude", full: true,
51
- docs: "https://docs.claude.com/en/docs/claude-code" },
52
- { name: "Codex", dir: ".codex", cmd: "codex", full: false,
53
- docs: "https://github.com/openai/codex" },
54
- { name: "Antigravity", dir: ".agents", cmd: "agy", full: false,
55
- docs: "https://antigravity.google" },
56
- { name: "OpenCode", dir: ".opencode", cmd: "opencode", full: false,
57
- docs: "https://opencode.ai" },
58
- { name: "Cursor", dir: ".cursor", cmd: "cursor", full: false,
59
- docs: "https://cursor.com" },
60
- { name: "Gemini CLI", dir: ".gemini", cmd: "gemini", full: false,
61
- docs: "https://github.com/google-gemini/gemini-cli" },
62
- ];
63
-
64
- // An agent counts as present if it has a directory or a command. The directory
65
- // alone misses someone who installed the agent and has not run it yet; the command
66
- // alone misses one that is not on this shell's PATH.
67
- const onPath = new Set(
68
- AGENTS.filter((a) => Bun.which(a.cmd)).map((a) => a.dir),
69
- );
70
- const found = (r: string) =>
71
- AGENTS.filter((a) => existsSync(join(r, a.dir)) || onPath.has(a.dir));
72
-
73
- if (args.has("--version") || args.has("-v") || args.has("version")) {
74
- console.log(versionOf(dirname(import.meta.dir)));
75
- process.exit(0);
76
- }
77
-
78
- if (uninstall) { await removeEverything(); process.exit(0); }
79
-
80
- // Everything kaizen put on this machine, in one place, so uninstalling needs no
81
- // clone to read: the links are known by name, the rest by path. State (~/.kaizen
82
- // and any project's .kaizen) is left alone unless --purge says otherwise, because
83
- // it holds runs and decisions this program did not write.
84
- async function removeEverything() {
85
- let purge = args.has("--purge") || args.has("--all");
86
- // Deleting someone's runs is not a thing to do on a typo, so ask unless the
87
- // answer already arrived as a flag.
88
- if (interactive && !purge) {
89
- const choice = await select("Uninstall kaizen", [
90
- { label: "Keep my runs and settings", hint: "removes the app, keeps ~/.kaizen and each project's .kaizen/", value: "keep" },
91
- { label: "Remove everything", hint: "also deletes runs, backlog, memory and settings", value: "all" },
92
- { label: "Cancel", hint: "change nothing", value: "cancel" },
93
- ]);
94
- if (choice === "cancel") { console.log(`\n ${c.dim("Nothing removed.")}\n`); return; }
95
- purge = choice === "all";
96
- }
97
- const gone: string[] = [];
98
- const stuck: string[] = [];
99
- const drop = (p: string) => {
100
- if (!existsSync(p) && !isLink(p)) return;
101
- // Windows refuses to delete a file another process holds open, and a silent
102
- // failure here would read as a clean uninstall that removed nothing.
103
- try { rmSync(p, { recursive: true, force: true }); gone.push(tilde(p)); }
104
- catch { stuck.push(tilde(p)); }
105
- };
106
-
107
- // A --project install puts links in the project folder, and on Windows those
108
- // projects live on other drives entirely. Every project kaizen knows about is
109
- // checked, plus whatever a drive-wide scan turns up.
110
- const { knownProjects, searchRoots, findProjects } = await import("./state.ts");
111
- step("looking for project installs");
112
- const roots = [home, process.cwd(), ...knownProjects(),
113
- ...searchRoots([process.cwd()]).flatMap((r) => findProjects(r))]
114
- .filter((d, i, all) => all.indexOf(d) === i);
115
-
116
- for (const root of roots) {
117
- for (const a of AGENTS) {
118
- for (const n of ["kaizen", "kaizen-help"]) drop(join(root, a.dir, "skills", n));
119
- for (const sub of ["agents", "commands"]) {
120
- const dir = join(root, a.dir, sub);
121
- try {
122
- for (const f of readdirSync(dir)) {
123
- if (f.startsWith("kaizen-") && f.endsWith(".md")) drop(join(dir, f));
124
- }
125
- } catch { /* no such directory */ }
126
- }
127
- }
128
- }
129
-
130
- drop(process.env.KAIZEN_HOME ?? join(home, "kaizen"));
131
- drop(join(process.env.BUN_INSTALL ?? join(home, ".bun"), "bin", "kaizen.cmd"));
132
- drop(join(home, ".local", "bin", "kaizen"));
133
- await clearBunxCache();
134
-
135
- if (purge) {
136
- drop(join(home, ".kaizen"));
137
- drop(join(process.cwd(), ".kaizen"));
138
- }
139
-
140
- console.log(`\n ${c.bold("Uninstalled")}`);
141
- for (const p of gone) console.log(` ${c.dim("removed " + p)}`);
142
- if (!gone.length && !stuck.length) console.log(` ${c.dim("nothing left to remove")}`);
143
- if (stuck.length) {
144
- console.log(`\n ${c.bold("could not remove")} ${c.dim("— close any dashboard or agent still using these, then run it again")}`);
145
- for (const p of stuck) console.log(` ${c.dim(p)}`);
146
- }
147
- console.log(purge
148
- ? `\n ${c.dim("Runs and settings deleted too.")}\n`
149
- : `\n ${c.dim("Runs and settings kept in")} ${c.cyan(tilde(join(home, ".kaizen")))}${c.dim(" — add --purge to delete them.")}\n`);
150
- console.log(` ${c.dim("The CLAUDE.md / AGENTS.md line in each project is left for you to remove.")}\n`);
151
- }
152
-
153
- // A dangling symlink fails existsSync, and a stale link is exactly what wants
154
- // removing here.
155
- function isLink(p: string) {
156
- try { return lstatSync(p).isSymbolicLink(); } catch { return false; }
157
- }
158
-
159
- // Nothing to install into. kaizen is a workflow an agent runs; on its own it does
160
- // nothing, so send them to the agent first rather than laying out files no reader
161
- // will ever load.
162
- if (!found(home).length && !args.has("--force")) {
163
- await noAgent();
164
- process.exit(0);
165
- }
166
-
167
- async function noAgent() {
168
- console.log(` ${c.bold("No AI coding agent found on this machine.")}\n`);
169
- console.log(` ${c.dim("kaizen is a workflow that an agent runs — it plans, you approve, it builds,")}`);
170
- console.log(` ${c.dim("and a second agent reviews. So an agent has to be installed first.")}\n`);
171
-
172
- if (!interactive) {
173
- for (const a of AGENTS) console.log(` ${a.name.padEnd(14)} ${a.docs}`);
174
- console.log(`\n ${c.dim("Install one, then run this again.")}`);
175
- return;
176
- }
177
-
178
- const pick = await select("Which one would you like to install?", [
179
- ...AGENTS.map((a) => ({ label: a.name, hint: a.docs, value: a as (typeof AGENTS)[number] | null })),
180
- { label: "None of these — just show me the list", hint: "", value: null },
181
- ]);
182
-
183
- if (!pick) {
184
- for (const a of AGENTS) console.log(` ${a.name.padEnd(14)} ${a.docs}`);
185
- console.log(`\n ${c.dim("Install one, then run this again.")}`);
186
- return;
187
- }
188
-
189
- console.log(` ${c.bold(pick.name)}`);
190
- console.log(` ${c.cyan(pick.docs)}\n`);
191
- const opened = await openUrl(pick.docs);
192
- console.log(opened
193
- ? ` ${c.dim("Opened in your browser. Install it, then run")} ${c.cyan("kaizen")} ${c.dim("again.")}`
194
- : ` ${c.dim("Open that page to install it, then run")} ${c.cyan("kaizen")} ${c.dim("again.")}`);
195
- }
196
-
197
- // Best effort: a headless box or a locked-down desktop has no opener, and the URL
198
- // is printed either way.
199
- async function openUrl(url: string) {
200
- const opener = process.platform === "darwin" ? "open"
201
- : process.platform === "win32" ? "start" : "xdg-open";
202
- if (!Bun.which(opener)) return false;
203
- try { await Bun.$`${opener} ${url}`.quiet(); return true; } catch { return false; }
204
- }
205
-
206
- if (wantSettings) {
207
- const { settings } = await import("./settings.ts");
208
- await settings(await resolveRepoQuietly());
209
- process.exit(0);
210
- }
211
-
212
- if (wantWeb) {
213
- if (args.has("--shortcut")) { const { shortcut } = await import("./web.ts"); console.log(`\n kaizen web shortcut ${await shortcut()}\n`); process.exit(0); }
214
- const at = Bun.argv.indexOf("--port");
215
- const port = at !== -1 ? Number(Bun.argv[at + 1]) || undefined : undefined;
216
- const url = `http://127.0.0.1:${port ?? 7420}/`;
217
- const pidFile = join(home, ".kaizen", "web.pid");
218
- const alive = async () => { try { await fetch(url); return true; } catch { return false; } };
219
-
220
- if (args.has("--stop")) {
221
- // The pid file only knows a --daemon; a board started from the shortcut or a
222
- // terminal has none, so also ask the OS what listens on the port.
223
- const recorded = existsSync(pidFile) ? Number(readFileSync(pidFile, "utf8")) : NaN;
224
- const { pidOnPort } = await import("./state.ts");
225
- const listening = await pidOnPort(port ?? 7420);
226
- const pids = [...new Set([recorded, listening].filter((p): p is number => Number.isFinite(p) && p > 0))];
227
- if (!pids.length) { console.log(" nothing running on the kaizen web port"); process.exit(0); }
228
- for (const pid of pids) {
229
- try { process.kill(pid, "SIGTERM"); console.log(` stopped kaizen web (pid ${pid})`); }
230
- catch { console.log(` pid ${pid} already gone`); }
231
- }
232
- rmSync(pidFile, { force: true });
233
- process.exit(0);
234
- }
235
-
236
- if (args.has("--daemon")) {
237
- // The daemon is this same command, detached, with the browser left to us:
238
- // ignored stdio is what lets it outlive the terminal.
239
- if (await alive()) {
240
- console.log(` kaizen web already running at ${url}`);
241
- } else {
242
- const argv = Bun.argv.slice(2).filter((a) => a !== "--daemon" && a !== "--no-open");
243
- const child = Bun.spawn([process.execPath, Bun.main, ...argv, "--no-open"], {
244
- detached: true, stdio: ["ignore", "ignore", "ignore"], windowsHide: true,
245
- });
246
- child.unref();
247
- mkdirSync(dirname(pidFile), { recursive: true });
248
- writeFileSync(pidFile, String(child.pid));
249
- for (let i = 0; i < 30 && !(await alive()); i++) await Bun.sleep(100);
250
- if (!(await alive())) { console.log(` kaizen web did not come up on ${url}`); process.exit(1); }
251
- console.log(`\n kaizen web ${url} (pid ${child.pid}, detached)`);
252
- console.log(` kaizen web --stop to stop it\n`);
253
- }
254
- if (!args.has("--no-open")) await openUrl(url);
255
- process.exit(0);
256
- }
257
-
258
- const { web } = await import("./web.ts");
259
- await web(await resolveRepoQuietly(), { port, open: !args.has("--no-open") });
260
- process.exit(0);
261
- }
262
-
263
- // Bare `kaizen` on a machine where it is already installed is not an install --
264
- // show what the project is doing instead, and offer the things you would have
265
- // opened a terminal for.
266
- if (interactive && !upgrade && Bun.argv.length === 2) {
267
- const repoDir = await resolveRepoQuietly();
268
- // `bunx kaizen-agent@1.4.0` on a machine that already has an older copy is a
269
- // request to install that version, not to open the dashboard the old one links
270
- // to -- opening it would hand the upgrade back to the very installer being
271
- // replaced. Only skip to the dashboard when the two are the same version.
272
- const running = dirname(import.meta.dir);
273
- const stale = running !== repoDir && versionOf(running) !== versionOf(repoDir);
274
- if (!stale) {
275
- const here = found(home);
276
- const linked = here.length > 0 && here.every((a) =>
277
- ["kaizen", "kaizen-help"].every((n) => existsSync(join(home, a.dir, "skills", n))));
278
- if (linked) {
279
- const { dashboard } = await import("./dashboard.ts");
280
- await dashboard(repoDir, here.map((a) => a.name), async (action) => {
281
- if (action === "settings") {
282
- const { settings } = await import("./settings.ts");
283
- await settings(repoDir, false);
284
- } else if (action === "upgrade") {
285
- return await runUpgrade(repoDir);
286
- } else if (action === "init") {
287
- const out = await Bun.$`bun run ${join(repoDir, "cli/install.ts")} --yes`.text();
288
- return clean(out);
289
- }
290
- });
291
- process.exit(0);
292
- }
293
- }
294
- }
295
-
296
- if (upgrade) await clearBunxCache();
297
-
298
- // An install made without git is a copy, not a clone, so there is nothing to pull:
299
- // upgrading itself would copy ~/kaizen over ~/kaizen and report "already up to
300
- // date" forever. The new version comes from npm, and the installer inside it does
301
- // the copying and relinking, so hand the job over and stop.
302
- if (upgrade && !existsSync(join(dirname(import.meta.dir), ".git"))) {
303
- const latest = await npmLatest();
304
- const now = versionOf(dirname(import.meta.dir));
305
- // A copy can only upgrade through npm. When the version lookup fails, bun still
306
- // resolves "latest" its own way, so ask for that rather than falling through --
307
- // which would copy this install over itself and call it up to date.
308
- if (latest !== now) {
309
- const want = latest ?? "latest";
310
- step(`fetching kaizen ${want} from npm`);
311
- // --force so a stale package manifest cannot resolve the version away, and the
312
- // failure is printed rather than swallowed: a silent catch here looked exactly
313
- // like a successful upgrade that changed nothing.
314
- const r = await Bun.$`${process.execPath} x --force kaizen-agent@${want} --yes`
315
- .quiet().nothrow();
316
- const out = clean(r.stdout.toString() + r.stderr.toString()).filter((l) => l.trim());
317
- for (const l of out) console.log(l.startsWith(" ") ? l : ` ${l}`);
318
- if (r.exitCode !== 0) {
319
- console.log(`\n ${c.bold(`could not fetch kaizen ${want}`)}`);
320
- console.log(` ${c.dim("try:")} ${c.cyan(`bunx kaizen-agent@${want}`)}\n`);
321
- process.exit(1);
322
- }
323
- const after = versionOf(process.env.KAIZEN_HOME ?? join(home, "kaizen"));
324
- if (after === now) console.log(`\n ${c.bold(`kaizen ${now} is the latest on npm`)}\n`);
325
- process.exit(0);
326
- }
327
- }
328
- if (!check && !upgrade) await welcome();
329
-
330
- async function welcome() {
331
- const rgb = (r: number, g: number, b: number, s: string) =>
332
- `\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`;
333
-
334
- // Fuji: snow on the peak, deepening blue down the slope.
335
- const WORD = [
336
- "██╗ ██╗ █████╗ ██╗███████╗███████╗███╗ ██╗",
337
- "██║ ██╔╝██╔══██╗██║╚══███╔╝██╔════╝████╗ ██║",
338
- "█████╔╝ ███████║██║ ███╔╝ █████╗ ██╔██╗ ██║",
339
- "██╔═██╗ ██╔══██║██║ ███╔╝ ██╔══╝ ██║╚██╗██║",
340
- "██║ ██╗██║ ██║██║███████╗███████╗██║ ╚████║",
341
- "╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝╚══════╝╚═╝ ╚═══╝",
342
- ];
343
- const W = 69, C = 34, SNOW = 3;
344
- // One constant slope, three columns per row, all the way down. The rows behind
345
- // the wordmark keep the same rate, so the edge that goes in above the letters
346
- // comes out below them on the same line -- vary it and the cone reads broken.
347
- const H = [1, 4, 7, 10, 13, 16, 19, 22, 25, 28, 31, 34];
348
-
349
- // Anything that would wrap is worse than the smaller thing that fits: the
350
- // mountain needs the full width, the wordmark needs 47, below that use words.
351
- const cols = process.stdout.columns || 80;
352
- const wide = cols >= W + 4;
353
- if (cols < 49) {
354
- console.log(`\n ${c.bold("kaizen")}`);
355
- } else {
356
-
357
- const cell: string[][] = H.map(() => Array(W).fill(" "));
358
- const isWord: boolean[][] = H.map(() => Array(W).fill(false));
359
-
360
- if (wide) {
361
- for (const [i, h] of H.entries()) {
362
- const l = C - h, r = C + h;
363
- if (i < SNOW) { // solid cap
364
- cell[i]![l] = "▄"; cell[i]![r] = "▄";
365
- for (let x = l + 1; x < r; x++) cell[i]![x] = "█";
366
- } else { // slope, drawn as two edges
367
- // Below the wordmark's top row the letters own the middle, so draw an
368
- // edge only where all three of its characters clear them.
369
- const band = i >= H.length - WORD.length;
370
- const gap = Math.floor((W - 45) / 2) - 2;
371
- if (!band || l + 2 < gap) {
372
- cell[i]![l] = "▄"; cell[i]![l + 1] = "█"; cell[i]![l + 2] = "▀";
373
- cell[i]![r - 2] = "▀"; cell[i]![r - 1] = "█"; cell[i]![r] = "▄";
374
- }
375
- }
376
- }
377
- }
378
-
379
- const top = H.length - WORD.length, left = Math.floor((W - 45) / 2);
380
- for (let r = top; r < H.length; r++) // air around the letters
381
- for (let x = left - 2; x < left + 47; x++) if (x >= 0 && x < W) cell[r]![x] = " ";
382
- for (const [r, line] of WORD.entries())
383
- for (const [x, ch] of [...line].entries())
384
- if (ch !== " ") { cell[top + r]![left + x] = ch; isWord[top + r]![left + x] = true; }
385
-
386
- console.log();
387
- for (const [i, row] of cell.entries()) {
388
- if (!wide && i < top) continue; // no mountain to draw, no blank rows
389
- const slope = Math.max(0, i - SNOW) / (H.length - SNOW);
390
- const mountain: [number, number, number] = i < SNOW
391
- ? [255 - i * 8, 255 - i * 5, 255]
392
- : [Math.round(150 - slope * 105), Math.round(190 - slope * 110), Math.round(245 - slope * 80)];
393
-
394
- // With no mountain there is nothing to sit under, so drop its left margin.
395
- const off = wide ? 0 : left;
396
- const chars = row.slice(off), flags = isWord[i]!.slice(off);
397
-
398
- let out = " ", run = "", runWord = flags[0]!;
399
- const flush = () => {
400
- if (!run) return;
401
- const t = (i - top) / (WORD.length - 1);
402
- const col: [number, number, number] = runWord
403
- ? [Math.round(214 - t * 150), Math.round(232 - t * 130), Math.round(255 - t * 45)]
404
- : mountain;
405
- out += rgb(col[0], col[1], col[2], run);
406
- run = "";
407
- };
408
- for (const [x, ch] of chars.entries()) {
409
- if (flags[x] !== runWord) { flush(); runWord = flags[x]!; }
410
- run += ch;
411
- }
412
- flush();
413
- console.log(out.replace(/\s+$/, ""));
414
- }
415
- if (wide) console.log(" " + rgb(38, 66, 130, "▀".repeat(W)));
416
- }
417
-
418
- console.log(`
419
- ${c.bold("改善")} ${c.dim("kaizen — continuous improvement")}
420
-
421
- ${c.dim("A staged workflow for AI agents — code, writing, research, operations:")}
422
- ${c.dim("plan -> you approve -> build -> independent review -> bounded fix loop")}
423
- `);
424
-
425
- if (interactive) await anyKey();
426
- }
427
-
428
- // A pause before anything touches the filesystem, and the place to back out.
429
- async function anyKey() {
430
- const { stdin, stdout } = process;
431
- stdout.write(` ${c.dim("press")} ${c.cyan("enter")} ${c.dim("to install, or")} ${c.cyan("ctrl-c")} ${c.dim("to leave")}`);
432
- stdin.setRawMode(true);
433
- stdin.resume();
434
- await new Promise<void>((resolve) => {
435
- const onData = (chunk: Buffer) => {
436
- if (chunk.toString().includes("\x03")) { stdout.write("\n\n nothing installed\n"); process.exit(130); }
437
- stdin.off("data", onData);
438
- resolve();
439
- };
440
- stdin.on("data", onData);
441
- });
442
- stdin.setRawMode(false);
443
- stdin.pause();
444
- stdout.write("\r\x1b[2K");
445
- }
446
-
447
- const repo = await resolveRepo();
448
- const root = await chooseRoot();
449
- const presetChoice = await choosePreset();
450
- const targets = found(root);
451
-
452
- // null means "leave the config alone": an upgrade or a --yes run never asked, and
453
- // answering "medium" for the user overwrote whatever preset they had chosen.
454
- async function choosePreset(): Promise<PresetName | null> {
455
- if (presetArg) return presetArg;
456
- if (!interactive) return null;
457
-
458
- return select("Choose workflow preset", [
459
- {
460
- label: "Medium (Recommended)",
461
- hint: "balanced — subagents, plan approval, auto-fix high/critical",
462
- value: "medium",
463
- },
464
- {
465
- label: "Low",
466
- hint: "inline & manual — lite runner, inline builder, manual approvals, no auto-fix",
467
- value: "low",
468
- },
469
- {
470
- label: "Ultra",
471
- hint: "autonomous — subagents, auto mode, auto-fix all findings (up to 4 rounds)",
472
- value: "ultra",
473
- },
474
- ]);
475
- }
476
-
477
- // ---------------------------------------------------------------- repo
478
-
479
- // Settings only needs the clone's default config, not a pull.
480
- async function resolveRepoQuietly() {
481
- const here = dirname(import.meta.dir);
482
- if (existsSync(join(here, ".git"))) return here;
483
- return process.env.KAIZEN_HOME ?? join(home, "kaizen");
484
- }
485
-
486
- async function resolveRepo() {
487
- const here = dirname(import.meta.dir);
488
- const dest = process.env.KAIZEN_HOME ?? join(home, "kaizen");
489
- // The launcher runs the clone itself, so an upgrade from inside it must pull
490
- // that clone -- returning it untouched was "Up to date" at whatever was there.
491
- for (const dir of [here, dest]) {
492
- if (!existsSync(join(dir, ".git"))) continue;
493
- if (!upgrade && dir === here) return dir;
494
- // A pull can refuse for reasons that have nothing to do with linking -- local
495
- // edits, a detached head, no network. Linking the checkout already on disk
496
- // still works, so say what happened and carry on.
497
- try {
498
- await Bun.$`git -C ${dir} pull --ff-only`.quiet();
499
- step(`updated ${tilde(dir)}`);
500
- } catch {
501
- step(`kept ${tilde(dir)} ${c.dim("(could not pull)")}`);
502
- }
503
- return dir;
504
- }
505
- // A copy made before git was installed can never be pulled, so it keeps upgrading
506
- // through npm and its caches. Once git is here, replace it with a real clone --
507
- // into a sibling first, so a clone that fails leaves the working copy alone.
508
- if (Bun.which("git") && existsSync(join(dest, "cli", "install.ts"))) {
509
- const fresh = `${dest}.new`;
510
- try {
511
- rmSync(fresh, { recursive: true, force: true });
512
- await Bun.$`git clone --quiet ${REPO_URL} ${fresh}`;
513
- rmSync(dest, { recursive: true, force: true });
514
- renameSync(fresh, dest);
515
- step(`re-cloned ${tilde(dest)} ${c.dim("(git is available now)")}`);
516
- return dest;
517
- } catch {
518
- rmSync(fresh, { recursive: true, force: true });
519
- // Fall through to the copy below: an unreachable network is not a reason to
520
- // leave the install untouched.
521
- }
522
- }
523
-
524
- // Cloning needs an empty directory, and a copy made on a machine that had no git
525
- // is anything but: installing git later must not turn every run into a fatal.
526
- // An existing directory is updated in place whichever way it was made.
527
- if (Bun.which("git") && !existsSync(dest)) {
528
- step(`cloning into ${tilde(dest)}`);
529
- await Bun.$`git clone --quiet ${REPO_URL} ${dest}`;
530
- return dest;
531
- }
532
- // The bunx download already holds everything a clone would, so copy it in place.
533
- // Upgrades then come from re-running bunx rather than a pull.
534
- step(`copying into ${tilde(dest)} ${c.dim(existsSync(dest) ? "(over the copy already there)" : "(no git)")}`);
535
- cpSync(here, dest, {
536
- recursive: true, dereference: true,
537
- filter: (src) => basename(src) !== "node_modules",
538
- });
539
- return dest;
540
- }
541
-
542
- async function chooseRoot() {
543
- if (args.has("--project")) return process.cwd();
544
- if (args.has("--global") || !interactive) return home;
545
- // A project install only means something in a project. Standing in the home
546
- // directory it is the global install under another name; any other folder is
547
- // a project worth installing into, git repository or not.
548
- const cwd = process.cwd();
549
- if (cwd === home) return home;
550
-
551
- const names = (r: string) => found(r).map((a) => a.name).join(", ");
552
- return select("Install kaizen for", [
553
- { label: "Globally", hint: `every project \u2014 ${names(home)}`, value: home },
554
- { label: "This project only", hint: `${basename(cwd)}/ \u2014 ${names(cwd)}`, value: cwd },
555
- ]);
556
- }
557
-
558
- // ---------------------------------------------------------------- linking
559
-
560
- function dirEntries(sub: string) {
561
- return readdirSync(join(repo, sub))
562
- .filter((f) => f.startsWith("kaizen-") && f.endsWith(".md"))
563
- .map((f) => join(repo, sub, f));
564
- }
565
-
566
- const links = targets.flatMap((a) => [
567
- ...["kaizen", "kaizen-help"].map((n) => [join(repo, "skills", n), join(a.dir, "skills")] as const),
568
- ...(a.full ? [
569
- ...dirEntries("agents").map((f) => [f, join(a.dir, "agents")] as const),
570
- ...dirEntries("commands").map((f) => [f, join(a.dir, "commands")] as const),
571
- ] : []),
572
- ]);
573
-
574
- const isWindows = process.platform === "win32";
575
-
576
- // Windows only hands out file symlinks to an admin or a Developer Mode box, so
577
- // there the link is a directory junction (allowed for everyone) or, for a plain
578
- // file, a copy that is refreshed whenever the source moves ahead.
579
- function makeLink(src: string, dest: string) {
580
- if (!isWindows) { symlinkSync(src, dest); return; }
581
- if (statSync(src).isDirectory()) { symlinkSync(src, dest, "junction"); return; }
582
- copyFileSync(src, dest);
583
- }
584
-
585
- function sameFile(a: string, b: string) {
586
- try { return readFileSync(a).equals(readFileSync(b)); } catch { return false; }
587
- }
588
-
589
- function linkState(src: string, dest: string) {
590
- try {
591
- const st = lstatSync(dest);
592
- if (!st.isSymbolicLink()) {
593
- // A copied link on Windows is a real file; it is ours as long as it still
594
- // matches the source, and stale once the source changes.
595
- if (isWindows && st.isFile()) return sameFile(src, dest) ? "linked" : "stale";
596
- return "occupied";
597
- }
598
- return readlinkSync(dest) === src ? "linked" : "stale";
599
- } catch {
600
- return "missing";
601
- }
602
- }
603
-
604
- let blocked = 0, wrote = 0, already = 0;
605
- for (const [src, sub] of links) {
606
- const dir = join(root, sub);
607
- const dest = join(dir, basename(src));
608
- const state = linkState(src, dest);
609
- const name = `${sub}/${basename(src)}`;
610
-
611
- if (check) {
612
- if (state !== "linked") blocked++;
613
- console.log(`${state === "linked" ? "ok " : "MISS"} ${name}${state === "linked" ? "" : ` (${state})`}`);
614
- continue;
615
- }
616
- if (state === "linked") { already++; if (verbose) console.log(c.dim(` ok ${name}`)); continue; }
617
- if (state === "occupied" && !force) {
618
- console.log(` ${c.bold("skip")} ${name} — real file present, use --force`);
619
- blocked++;
620
- continue;
621
- }
622
- mkdirSync(dir, { recursive: true });
623
- if (state !== "missing") rmSync(dest, { recursive: true, force: true });
624
- makeLink(src, dest);
625
- wrote++;
626
- if (verbose) console.log(c.dim(` link ${name}`));
627
- }
628
-
629
- if (check) {
630
- console.log(blocked ? `\n${blocked} link(s) missing — run: bunx kaizen-agent` : "\nall linked");
631
- process.exit(blocked ? 1 : 0);
632
- }
633
-
634
- const into = targets.map((a) => a.name).join(", ");
635
- step(
636
- wrote && already ? `linked ${wrote} new, ${already} already in place — ${into}`
637
- : wrote ? `linked ${wrote} files into ${into}`
638
- : `already installed for ${into} ${c.dim("(nothing to change)")}`,
639
- );
640
- if (targets.length === 1) {
641
- console.log(` ${c.dim("Codex, Antigravity, OpenCode, Cursor and Gemini CLI get it too,")}`);
642
- console.log(` ${c.dim("if their directory exists when you install.")}`);
643
- }
644
-
645
- // ---------------------------------------------------------------- repo setup
646
-
647
- if (!args.has("--project")) installLauncher();
648
-
649
- // bunx runs from a temp copy and leaves nothing behind, so there is no `kaizen` to
650
- // type afterwards. A launcher pointed at the clone gives one, and keeps working
651
- // after the clone updates.
652
- function installLauncher() {
653
- const entry = join(repo, "cli/install.ts");
654
- // Windows cannot run a shell script and has no ~/.local/bin on PATH. Bun's own
655
- // bin directory is already there — bun put it there when it installed itself —
656
- // so a .cmd beside bun is the launcher that works without touching PATH.
657
- const win = process.platform === "win32";
658
- const binDir = win
659
- ? join(process.env.BUN_INSTALL ?? join(home, ".bun"), "bin")
660
- : join(home, ".local", "bin");
661
- const bin = join(binDir, win ? "kaizen.cmd" : "kaizen");
662
- const script = win
663
- ? `@echo off\r\nrem kaizen launcher -- installed by kaizen-agent\r\nbun run "${entry}" %*\r\n`
664
- : `#!/bin/sh\n# kaizen launcher -- installed by kaizen-agent\nexec bun run ${entry} "$@"\n`;
665
- try {
666
- if (existsSync(bin) && readFileSync(bin, "utf8") === script) return;
667
- mkdirSync(binDir, { recursive: true });
668
- writeFileSync(bin, script, { mode: 0o755 });
669
- const onPath = (process.env.PATH ?? "").split(win ? ";" : ":")
670
- .some((p) => p.replace(/[\\/]+$/, "").toLowerCase() === binDir.replace(/[\\/]+$/, "").toLowerCase());
671
- step(onPath
672
- ? `${c.cyan("kaizen upgrade")} is now on your PATH`
673
- : `wrote ${tilde(bin)} ${c.dim(`— add ${tilde(binDir)} to PATH to use it`)}`);
674
- } catch {
675
- /* a read-only home is not worth failing the install over */
676
- }
677
- }
678
-
679
- const inProject = process.cwd() !== home;
680
- const kaizenDir = join(process.cwd(), ".kaizen");
681
- const globalKaizen = join(home, ".kaizen");
682
- const globalConfig = join(globalKaizen, "config.yml");
683
-
684
- // Always global: a project keeps no config of its own, so ~/.kaizen/config.yml is
685
- // what every project follows.
686
- {
687
- mkdirSync(globalKaizen, { recursive: true });
688
- const defPath = join(repo, "skills/kaizen/config.default.yml");
689
- const def = readFileSync(defPath, "utf8");
690
- const existing = existsSync(globalConfig) ? readFileSync(globalConfig, "utf8") : null;
691
- if (presetChoice) {
692
- writeFileSync(globalConfig, applyPreset(existing ?? def, presetChoice, def));
693
- step(`configured ${c.bold(presetChoice)} preset in ${tilde(globalConfig)}`);
694
- } else if (!existing) {
695
- writeFileSync(globalConfig, def);
696
- step(`wrote ${tilde(globalConfig)}`);
697
- } else {
698
- step(`kept your settings in ${tilde(globalConfig)} ${c.dim(`(${detectPreset(existing, def)})`)}`);
699
- }
700
- }
701
-
702
- // A .kaizen/ in a parent already owns this folder's state, and setting up a child
703
- // would shadow it, hiding the parent's runs and leaving the child out of the root
704
- // .gitignore. Deliberately not shared with cli/dashboard.ts locate(): that one
705
- // resolves which state directory to read and so falls back to a global ~/.kaizen,
706
- // while this one decides whether a folder is already owned and must stop *before*
707
- // $HOME — a global ~/.kaizen would otherwise suppress the setup offer for every
708
- // non-repo folder under the home directory. Do not re-sync the two walks.
709
- function kaizenAbove() {
710
- let dir = process.cwd();
711
- for (;;) {
712
- if (dir === home) return false;
713
- if (existsSync(join(dir, ".kaizen"))) return true;
714
- if (existsSync(join(dir, ".git"))) return false;
715
- const up = dirname(dir);
716
- if (up === dir) return false;
717
- dir = up;
718
- }
719
- }
720
-
721
- if (inProject && !kaizenAbove()) {
722
- const now = root === process.cwd()
723
- ? true
724
- : interactive
725
- ? await select(`Set up ${basename(process.cwd())}/ for kaizen now`, [
726
- // "Not now" leads: the offer now appears in any folder outside home, so the
727
- // safe answer is the one under the cursor.
728
- { label: "Not now", hint: "the first /kaizen run will do it", value: false },
729
- { label: "Yes", hint: "writes .kaizen/ and a CLAUDE.md line so it runs by default", value: true },
730
- ])
731
- : false;
732
- if (now) initProject();
733
- } else if (existsSync(kaizenDir)) {
734
- step(`${basename(process.cwd())}/.kaizen already set up`);
735
- }
736
-
737
- function initProject() {
738
- mkdirSync(kaizenDir, { recursive: true });
739
- for (const f of readdirSync(join(repo, "skills/kaizen")).filter((f) => f.startsWith("spec"))) {
740
- copyFileSync(join(repo, "skills/kaizen", f), join(kaizenDir, f));
741
- }
742
- // No config.yml here on purpose: a project without one follows
743
- // ~/.kaizen/config.yml, so a setting changed once applies everywhere. Write one
744
- // only to make this project deliberately different.
745
-
746
- // Run state is local; the workflow itself is shared, so those files stay tracked.
747
- // Nothing to ignore where there is no git, so a plain folder gets no .gitignore.
748
- let ignored = false;
749
- if (existsSync(join(process.cwd(), ".git"))) {
750
- const rules = ".kaizen/*\n!.kaizen/spec*.md\n!.kaizen/config.yml\n";
751
- const gi = join(process.cwd(), ".gitignore");
752
- const current = existsSync(gi) ? readFileSync(gi, "utf8") : "";
753
- if (!current.includes(".kaizen/*")) {
754
- appendFileSync(gi, (current && !current.endsWith("\n") ? "\n" : "") + "\n# kaizen run state\n" + rules);
755
- ignored = true;
756
- }
757
- }
758
- step(ignored ? "wrote .kaizen/ and the gitignore entry" : "wrote .kaizen/");
759
- writePointer();
760
- }
761
-
762
- // Claude Code loads a skill by matching its description, so an ordinary request
763
- // ("add a discount cap") does not reach kaizen unless the project says it should.
764
- // One line in the agent instructions is what makes it the default here.
765
- function writePointer() {
766
- 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\nA run costs several times what the same change costs in one session, so spend it\nwhere being wrong is expensive: money, data loss, a migration or anything else hard\nto undo, work a customer sees, or a part of the system nobody has read in a year.\n\nWork where being wrong is cheap and obvious does not start a run. Answering a\nquestion, reading code, a one-line fix, a rename the compiler checks — do those\ndirectly. When it is genuinely unclear, ask rather than assuming the expensive path.\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`;
767
-
768
- for (const name of ["CLAUDE.md", "AGENTS.md"]) {
769
- const f = join(process.cwd(), name);
770
- const current = existsSync(f) ? readFileSync(f, "utf8") : "";
771
- if (current.includes("## Kaizen workflow")) continue;
772
- if (!current && name === "AGENTS.md") continue; // do not create one that was never there
773
- const text = current ? (current.endsWith("\n") ? "" : "\n") + block : block.slice(1);
774
- appendFileSync(f, text);
775
- step(`${existsSync(f) && current ? "added to" : "wrote"} ${name} ${c.dim("— kaizen now runs without being asked for")}`);
776
- }
777
- }
778
-
779
- // ---------------------------------------------------------------- done
780
-
781
- if (upgrade) {
782
- let version = "";
783
- try { version = JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).version; } catch {}
784
- console.log(`
785
- ${c.bold("Up to date")}${version ? c.dim(` — kaizen ${version}`) : ""}
786
- ${c.dim("Restart Claude Code to pick up any new slash commands.")}
787
- `);
788
- } else {
789
- console.log(`
790
- ${c.bold("Next")}
791
- 1 restart Claude Code ${c.dim("— skills load live, slash commands only at session start")}
792
- 2 ${c.cyan("/kaizen <what you want done>")} ${c.dim("in any project — it sets itself up on first run")}
793
-
794
- ${c.dim("/kaizen-help lists every command. kaizen upgrade updates all of this.")}
795
- `);
796
- }
797
- if (blocked) process.exit(1);
798
-
799
- // ---------------------------------------------------------------- helpers
800
-
801
- function step(msg: string) { console.log(` ${c.green("+")} ${msg}`); }
802
-
803
- // Runs the upgrade and reports what it actually did, version to version, so the
804
- // dashboard can show a result rather than the user wondering whether it worked.
805
- async function runUpgrade(repoDir: string) {
806
- const before = versionOf(repoDir);
807
- const out = await Bun.$`bun run ${join(repoDir, "cli/install.ts")} upgrade`.text().catch(() => "");
808
- const after = versionOf(repoDir);
809
- const latest = await npmLatest();
810
-
811
- const lines = [
812
- before === after
813
- ? `${c.bold("Already up to date")} ${c.dim(after)}`
814
- : `${c.bold("Upgraded")} ${c.dim(before)} → ${c.green(after)}`,
815
- "",
816
- // Only the step lines: the captured run's own heading and restart notice
817
- // repeat what this panel already says above them.
818
- ...clean(out)
819
- .filter((l) => l.trim().startsWith("+") || l.trim().startsWith("skip"))
820
- .map((l) => c.dim(l.trim())),
821
- ];
822
- if (latest && latest !== after) {
823
- lines.push("", c.dim(existsSync(join(repoDir, ".git"))
824
- ? `npm publishes ${latest}; this install follows the git clone.`
825
- : `npm publishes ${latest} — run: bunx kaizen-agent@${latest}`));
826
- }
827
- lines.push("", c.dim("Restart your agent to pick up new slash commands."));
828
- return lines;
829
- }
830
-
831
- function versionOf(dir: string) {
832
- try { return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version as string; }
833
- catch { return "?"; }
834
- }
835
-
836
- async function npmLatest() {
837
- try {
838
- const r = await fetch("https://registry.npmjs.org/kaizen-agent/latest", {
839
- signal: AbortSignal.timeout(10000),
840
- });
841
- return ((await r.json()) as { version?: string }).version ?? null;
842
- } catch { return null; } // offline is not an upgrade failure
843
- }
844
-
845
- // Captured output still carries its own colour codes, which stop any test on the
846
- // text from matching and would nest inside this panel's own styling.
847
- function clean(out: string) {
848
- return out.split("\n").map((l) => l.replace(/\x1b\[[0-9;]*m/g, "").replace(/\r$/, "").trimEnd());
849
- }
850
-
851
- // bunx keeps an extracted copy per package under the temp directory and reuses it
852
- // without re-resolving, so an upgrade that only pulls the clone still leaves the
853
- // next `bunx kaizen-agent` running whatever was cached the first time.
854
- async function clearBunxCache() {
855
- const tmp = process.env.TMPDIR ?? tmpdir();
856
- let removed = 0;
857
- try {
858
- for (const name of readdirSync(tmp)) {
859
- if (/^bunx-.*kaizen-agent/.test(name)) {
860
- rmSync(join(tmp, name), { recursive: true, force: true });
861
- removed++;
862
- }
863
- }
864
- } catch { /* an unreadable temp directory is not worth failing an upgrade over */ }
865
-
866
- // bun caches the package manifest too, which is what answers "no version
867
- // matching 1.3.7 found (but package exists)" hours after it was published.
868
- // Only kaizen's own entries go: another package's cache is not ours to clear.
869
- // Where that cache lives depends on the platform and on how bun was installed,
870
- // so try the lot; a directory that is not there reads as an empty one.
871
- const caches = [
872
- process.env.BUN_INSTALL_CACHE_DIR,
873
- join(process.env.BUN_INSTALL ?? join(home, ".bun"), "install", "cache"),
874
- join(process.env.XDG_CACHE_HOME ?? join(home, ".cache"), "bun"),
875
- process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, "bun", "install", "cache") : null,
876
- ].filter((d): d is string => Boolean(d));
877
- for (const dir of caches) {
878
- try {
879
- for (const name of readdirSync(dir)) {
880
- if (!name.includes("kaizen-agent")) continue;
881
- rmSync(join(dir, name), { recursive: true, force: true });
882
- removed++;
883
- }
884
- } catch { /* no cache directory is the same as an empty one */ }
885
- }
886
-
887
- step(removed ? `cleared ${removed} cached copy of the installer` : "no installer cache to clear");
888
- }
889
- function tilde(p: string) { return p.startsWith(home) ? "~" + p.slice(home.length) : p; }
890
-
891
- // Arrow-key picker. Raw mode delivers keys unbuffered; the alternative is a
892
- // dependency for two options and forty lines of escape codes.
893
- async function select<T>(question: string, options: { label: string; hint: string; value: T }[]) {
894
- const { stdin, stdout } = process;
895
- let active = 0;
896
-
897
- const draw = (first: boolean) => {
898
- if (!first) stdout.write(`\x1b[${options.length}A`);
899
- for (const [i, o] of options.entries()) {
900
- const on = i === active;
901
- stdout.write(
902
- `\x1b[2K ${on ? c.cyan(">") : " "} ${on ? c.bold(o.label) : c.dim(o.label)}` +
903
- ` ${c.dim(o.hint)}\n`,
904
- );
905
- }
906
- };
907
-
908
- stdout.write(`\n ${c.bold(question)} ${c.dim("(arrows, enter)")}\n`);
909
- stdout.write("\x1b[?25l");
910
- draw(true);
911
- stdin.setRawMode(true);
912
- stdin.resume();
913
-
914
- // Read via events rather than `for await`: breaking out of an async iterator
915
- // destroys stdin, and a second prompt would then have no stream to read.
916
- await new Promise<void>((resolve) => {
917
- const onData = (chunk: Buffer) => {
918
- // A chunk can hold more than one keypress -- an arrow and the enter behind
919
- // it arrive together when input is piped -- so walk it rather than
920
- // comparing the whole thing to one key.
921
- const keys = chunk.toString();
922
- for (let i = 0; i < keys.length; i++) {
923
- const rest = keys.slice(i);
924
- if (rest.startsWith("\x03")) { stdout.write("\ncancelled\n"); process.exit(130); }
925
- if (rest.startsWith("\r") || rest.startsWith("\n")) {
926
- stdin.off("data", onData);
927
- return resolve();
928
- }
929
- if (rest.startsWith("\x1b[A") || rest.startsWith("\x1b[B")) {
930
- active = rest[2] === "A"
931
- ? (active - 1 + options.length) % options.length
932
- : (active + 1) % options.length;
933
- i += 2;
934
- } else if (rest.startsWith("k")) active = (active - 1 + options.length) % options.length;
935
- else if (rest.startsWith("j")) active = (active + 1) % options.length;
936
- else continue;
937
- draw(false);
938
- }
939
- };
940
- stdin.on("data", onData);
941
- });
942
-
943
- stdin.setRawMode(false);
944
- stdin.pause();
945
- stdout.write("\x1b[?25h");
946
- return options[active]!.value;
947
- }
1
+ #!/usr/bin/env bun
2
+ import {
3
+ symlinkSync, mkdirSync, readdirSync, lstatSync, readlinkSync, unlinkSync,
4
+ existsSync, copyFileSync, readFileSync, appendFileSync, rmSync, writeFileSync, renameSync,
5
+ statSync, cpSync,
6
+ } from "node:fs";
7
+ import { join, dirname, basename } from "node:path";
8
+ import { homedir, tmpdir } from "node:os";
9
+ import { applyPreset, detectPreset, PRESETS, type PresetName } from "./settings.ts";
10
+
11
+ const home = homedir();
12
+ const REPO_URL = "https://github.com/hfadhlullah/kaizen.git";
13
+ const args = new Set(Bun.argv.slice(2));
14
+ const upgrade = args.has("upgrade") || args.has("--upgrade");
15
+ const wantSettings = args.has("settings") || args.has("config");
16
+ const wantWeb = args.has("web");
17
+ const check = args.has("--check");
18
+ const uninstall = args.has("uninstall") || args.has("--uninstall");
19
+ const force = args.has("--force");
20
+ const verbose = args.has("--verbose");
21
+ const interactive = process.stdin.isTTY && !check && !upgrade && !args.has("--yes");
22
+
23
+ function parsePresetArg(): PresetName | null {
24
+ for (const a of Bun.argv.slice(2)) {
25
+ if (a.startsWith("--preset=")) {
26
+ const v = a.split("=")[1]?.toLowerCase();
27
+ if (v === "low" || v === "medium" || v === "ultra") return v as PresetName;
28
+ }
29
+ }
30
+ const idx = Bun.argv.indexOf("--preset");
31
+ if (idx !== -1 && Bun.argv[idx + 1]) {
32
+ const v = Bun.argv[idx + 1]?.toLowerCase();
33
+ if (v === "low" || v === "medium" || v === "ultra") return v as PresetName;
34
+ }
35
+ return null;
36
+ }
37
+ const presetArg = parsePresetArg();
38
+
39
+ const c = {
40
+ dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
41
+ bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
42
+ cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
43
+ green: (s: string) => `\x1b[32m${s}\x1b[0m`,
44
+ };
45
+
46
+ // Where each agent reads skills from. The skill folders follow the open Agent
47
+ // Skills standard, so every one of these can run the workflow; agents/ and
48
+ // commands/ are Claude Code's own formats and go nowhere else.
49
+ const AGENTS = [
50
+ { name: "Claude Code", dir: ".claude", cmd: "claude", full: true,
51
+ docs: "https://docs.claude.com/en/docs/claude-code" },
52
+ { name: "Codex", dir: ".codex", cmd: "codex", full: false,
53
+ docs: "https://github.com/openai/codex" },
54
+ { name: "Antigravity", dir: ".agents", cmd: "agy", full: false,
55
+ docs: "https://antigravity.google" },
56
+ { name: "OpenCode", dir: ".opencode", cmd: "opencode", full: false,
57
+ docs: "https://opencode.ai" },
58
+ { name: "Cursor", dir: ".cursor", cmd: "cursor", full: false,
59
+ docs: "https://cursor.com" },
60
+ { name: "Gemini CLI", dir: ".gemini", cmd: "gemini", full: false,
61
+ docs: "https://github.com/google-gemini/gemini-cli" },
62
+ ];
63
+
64
+ // An agent counts as present if it has a directory or a command. The directory
65
+ // alone misses someone who installed the agent and has not run it yet; the command
66
+ // alone misses one that is not on this shell's PATH.
67
+ const onPath = new Set(
68
+ AGENTS.filter((a) => Bun.which(a.cmd)).map((a) => a.dir),
69
+ );
70
+ const found = (r: string) =>
71
+ AGENTS.filter((a) => existsSync(join(r, a.dir)) || onPath.has(a.dir));
72
+
73
+ if (args.has("--version") || args.has("-v") || args.has("version")) {
74
+ console.log(versionOf(dirname(import.meta.dir)));
75
+ process.exit(0);
76
+ }
77
+
78
+ if (uninstall) { await removeEverything(); process.exit(0); }
79
+
80
+ // Everything kaizen put on this machine, in one place, so uninstalling needs no
81
+ // clone to read: the links are known by name, the rest by path. State (~/.kaizen
82
+ // and any project's .kaizen) is left alone unless --purge says otherwise, because
83
+ // it holds runs and decisions this program did not write.
84
+ async function removeEverything() {
85
+ let purge = args.has("--purge") || args.has("--all");
86
+ // Deleting someone's runs is not a thing to do on a typo, so ask unless the
87
+ // answer already arrived as a flag.
88
+ if (interactive && !purge) {
89
+ const choice = await select("Uninstall kaizen", [
90
+ { label: "Keep my runs and settings", hint: "removes the app, keeps ~/.kaizen and each project's .kaizen/", value: "keep" },
91
+ { label: "Remove everything", hint: "also deletes runs, backlog, memory and settings", value: "all" },
92
+ { label: "Cancel", hint: "change nothing", value: "cancel" },
93
+ ]);
94
+ if (choice === "cancel") { console.log(`\n ${c.dim("Nothing removed.")}\n`); return; }
95
+ purge = choice === "all";
96
+ }
97
+ const gone: string[] = [];
98
+ const stuck: string[] = [];
99
+ const drop = (p: string) => {
100
+ if (!existsSync(p) && !isLink(p)) return;
101
+ // Windows refuses to delete a file another process holds open, and a silent
102
+ // failure here would read as a clean uninstall that removed nothing.
103
+ try { rmSync(p, { recursive: true, force: true }); gone.push(tilde(p)); }
104
+ catch { stuck.push(tilde(p)); }
105
+ };
106
+
107
+ // A --project install puts links in the project folder, and on Windows those
108
+ // projects live on other drives entirely. Every project kaizen knows about is
109
+ // checked, plus whatever a drive-wide scan turns up.
110
+ const { knownProjects, searchRoots, findProjects } = await import("./state.ts");
111
+ step("looking for project installs");
112
+ const roots = [home, process.cwd(), ...knownProjects(),
113
+ ...searchRoots([process.cwd()]).flatMap((r) => findProjects(r))]
114
+ .filter((d, i, all) => all.indexOf(d) === i);
115
+
116
+ for (const root of roots) {
117
+ for (const a of AGENTS) {
118
+ for (const n of ["kaizen", "kaizen-help"]) drop(join(root, a.dir, "skills", n));
119
+ for (const sub of ["agents", "commands"]) {
120
+ const dir = join(root, a.dir, sub);
121
+ try {
122
+ for (const f of readdirSync(dir)) {
123
+ if (f.startsWith("kaizen-") && f.endsWith(".md")) drop(join(dir, f));
124
+ }
125
+ } catch { /* no such directory */ }
126
+ }
127
+ }
128
+ }
129
+
130
+ drop(process.env.KAIZEN_HOME ?? join(home, "kaizen"));
131
+ drop(join(process.env.BUN_INSTALL ?? join(home, ".bun"), "bin", "kaizen.cmd"));
132
+ drop(join(home, ".local", "bin", "kaizen"));
133
+ await clearBunxCache();
134
+
135
+ if (purge) {
136
+ drop(join(home, ".kaizen"));
137
+ drop(join(process.cwd(), ".kaizen"));
138
+ }
139
+
140
+ console.log(`\n ${c.bold("Uninstalled")}`);
141
+ for (const p of gone) console.log(` ${c.dim("removed " + p)}`);
142
+ if (!gone.length && !stuck.length) console.log(` ${c.dim("nothing left to remove")}`);
143
+ if (stuck.length) {
144
+ console.log(`\n ${c.bold("could not remove")} ${c.dim("— close any dashboard or agent still using these, then run it again")}`);
145
+ for (const p of stuck) console.log(` ${c.dim(p)}`);
146
+ }
147
+ console.log(purge
148
+ ? `\n ${c.dim("Runs and settings deleted too.")}\n`
149
+ : `\n ${c.dim("Runs and settings kept in")} ${c.cyan(tilde(join(home, ".kaizen")))}${c.dim(" — add --purge to delete them.")}\n`);
150
+ console.log(` ${c.dim("The CLAUDE.md / AGENTS.md line in each project is left for you to remove.")}\n`);
151
+ }
152
+
153
+ // A dangling symlink fails existsSync, and a stale link is exactly what wants
154
+ // removing here.
155
+ function isLink(p: string) {
156
+ try { return lstatSync(p).isSymbolicLink(); } catch { return false; }
157
+ }
158
+
159
+ // Nothing to install into. kaizen is a workflow an agent runs; on its own it does
160
+ // nothing, so send them to the agent first rather than laying out files no reader
161
+ // will ever load.
162
+ if (!found(home).length && !args.has("--force")) {
163
+ await noAgent();
164
+ process.exit(0);
165
+ }
166
+
167
+ async function noAgent() {
168
+ console.log(` ${c.bold("No AI coding agent found on this machine.")}\n`);
169
+ console.log(` ${c.dim("kaizen is a workflow that an agent runs — it plans, you approve, it builds,")}`);
170
+ console.log(` ${c.dim("and a second agent reviews. So an agent has to be installed first.")}\n`);
171
+
172
+ if (!interactive) {
173
+ for (const a of AGENTS) console.log(` ${a.name.padEnd(14)} ${a.docs}`);
174
+ console.log(`\n ${c.dim("Install one, then run this again.")}`);
175
+ return;
176
+ }
177
+
178
+ const pick = await select("Which one would you like to install?", [
179
+ ...AGENTS.map((a) => ({ label: a.name, hint: a.docs, value: a as (typeof AGENTS)[number] | null })),
180
+ { label: "None of these — just show me the list", hint: "", value: null },
181
+ ]);
182
+
183
+ if (!pick) {
184
+ for (const a of AGENTS) console.log(` ${a.name.padEnd(14)} ${a.docs}`);
185
+ console.log(`\n ${c.dim("Install one, then run this again.")}`);
186
+ return;
187
+ }
188
+
189
+ console.log(` ${c.bold(pick.name)}`);
190
+ console.log(` ${c.cyan(pick.docs)}\n`);
191
+ const opened = await openUrl(pick.docs);
192
+ console.log(opened
193
+ ? ` ${c.dim("Opened in your browser. Install it, then run")} ${c.cyan("kaizen")} ${c.dim("again.")}`
194
+ : ` ${c.dim("Open that page to install it, then run")} ${c.cyan("kaizen")} ${c.dim("again.")}`);
195
+ }
196
+
197
+ // Best effort: a headless box or a locked-down desktop has no opener, and the URL
198
+ // is printed either way.
199
+ async function openUrl(url: string) {
200
+ // `start` is a cmd builtin, not a program, so it has to go through cmd.
201
+ if (process.platform === "win32") {
202
+ try { await Bun.$`cmd /c start "" ${url}`.quiet(); return true; } catch { return false; }
203
+ }
204
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
205
+ if (!Bun.which(opener)) return false;
206
+ try { await Bun.$`${opener} ${url}`.quiet(); return true; } catch { return false; }
207
+ }
208
+
209
+ if (wantSettings) {
210
+ const { settings } = await import("./settings.ts");
211
+ await settings(await resolveRepoQuietly());
212
+ process.exit(0);
213
+ }
214
+
215
+ if (wantWeb) {
216
+ if (args.has("--shortcut")) { const { shortcut } = await import("./web.ts"); console.log(`\n kaizen web shortcut ${await shortcut()}\n`); process.exit(0); }
217
+ const at = Bun.argv.indexOf("--port");
218
+ const port = at !== -1 ? Number(Bun.argv[at + 1]) || undefined : undefined;
219
+ const url = `http://127.0.0.1:${port ?? 7420}/`;
220
+ const pidFile = join(home, ".kaizen", "web.pid");
221
+ const alive = async () => { try { await fetch(url); return true; } catch { return false; } };
222
+
223
+ if (args.has("--stop")) {
224
+ // The pid file only knows a --daemon; a board started from the shortcut or a
225
+ // terminal has none, so also ask the OS what listens on the port.
226
+ const recorded = existsSync(pidFile) ? Number(readFileSync(pidFile, "utf8")) : NaN;
227
+ const { pidOnPort } = await import("./state.ts");
228
+ const listening = await pidOnPort(port ?? 7420);
229
+ const pids = [...new Set([recorded, listening].filter((p): p is number => Number.isFinite(p) && p > 0))];
230
+ if (!pids.length) { console.log(" nothing running on the kaizen web port"); process.exit(0); }
231
+ for (const pid of pids) {
232
+ try { process.kill(pid, "SIGTERM"); console.log(` stopped kaizen web (pid ${pid})`); }
233
+ catch { console.log(` pid ${pid} already gone`); }
234
+ }
235
+ rmSync(pidFile, { force: true });
236
+ process.exit(0);
237
+ }
238
+
239
+ if (args.has("--daemon")) {
240
+ // The daemon is this same command, detached, with the browser left to us:
241
+ // ignored stdio is what lets it outlive the terminal.
242
+ if (await alive()) {
243
+ console.log(` kaizen web already running at ${url}`);
244
+ } else {
245
+ const argv = Bun.argv.slice(2).filter((a) => a !== "--daemon" && a !== "--no-open");
246
+ const child = Bun.spawn([process.execPath, Bun.main, ...argv, "--no-open"], {
247
+ detached: true, stdio: ["ignore", "ignore", "ignore"], windowsHide: true,
248
+ });
249
+ child.unref();
250
+ mkdirSync(dirname(pidFile), { recursive: true });
251
+ writeFileSync(pidFile, String(child.pid));
252
+ for (let i = 0; i < 30 && !(await alive()); i++) await Bun.sleep(100);
253
+ if (!(await alive())) { console.log(` kaizen web did not come up on ${url}`); process.exit(1); }
254
+ console.log(`\n kaizen web ${url} (pid ${child.pid}, detached)`);
255
+ console.log(` kaizen web --stop to stop it\n`);
256
+ }
257
+ if (!args.has("--no-open")) await (await import("./web.ts")).openApp(url);
258
+ process.exit(0);
259
+ }
260
+
261
+ const { web } = await import("./web.ts");
262
+ await web(await resolveRepoQuietly(), { port, open: !args.has("--no-open") });
263
+ process.exit(0);
264
+ }
265
+
266
+ // Bare `kaizen` on a machine where it is already installed is not an install --
267
+ // show what the project is doing instead, and offer the things you would have
268
+ // opened a terminal for.
269
+ if (interactive && !upgrade && Bun.argv.length === 2) {
270
+ const repoDir = await resolveRepoQuietly();
271
+ // `bunx kaizen-agent@1.4.0` on a machine that already has an older copy is a
272
+ // request to install that version, not to open the dashboard the old one links
273
+ // to -- opening it would hand the upgrade back to the very installer being
274
+ // replaced. Only skip to the dashboard when the two are the same version.
275
+ const running = dirname(import.meta.dir);
276
+ const stale = running !== repoDir && versionOf(running) !== versionOf(repoDir);
277
+ if (!stale) {
278
+ const here = found(home);
279
+ const linked = here.length > 0 && here.every((a) =>
280
+ ["kaizen", "kaizen-help"].every((n) => existsSync(join(home, a.dir, "skills", n))));
281
+ if (linked) {
282
+ const { dashboard } = await import("./dashboard.ts");
283
+ await dashboard(repoDir, here.map((a) => a.name), async (action) => {
284
+ if (action === "settings") {
285
+ const { settings } = await import("./settings.ts");
286
+ await settings(repoDir, false);
287
+ } else if (action === "upgrade") {
288
+ return await runUpgrade(repoDir);
289
+ } else if (action === "init") {
290
+ const out = await Bun.$`bun run ${join(repoDir, "cli/install.ts")} --yes`.text();
291
+ return clean(out);
292
+ }
293
+ });
294
+ process.exit(0);
295
+ }
296
+ }
297
+ }
298
+
299
+ if (upgrade) await clearBunxCache();
300
+
301
+ // An install made without git is a copy, not a clone, so there is nothing to pull:
302
+ // upgrading itself would copy ~/kaizen over ~/kaizen and report "already up to
303
+ // date" forever. The new version comes from npm, and the installer inside it does
304
+ // the copying and relinking, so hand the job over and stop.
305
+ if (upgrade && !existsSync(join(dirname(import.meta.dir), ".git"))) {
306
+ const latest = await npmLatest();
307
+ const now = versionOf(dirname(import.meta.dir));
308
+ // A copy can only upgrade through npm. When the version lookup fails, bun still
309
+ // resolves "latest" its own way, so ask for that rather than falling through --
310
+ // which would copy this install over itself and call it up to date.
311
+ if (latest !== now) {
312
+ const want = latest ?? "latest";
313
+ step(`fetching kaizen ${want} from npm`);
314
+ // --force so a stale package manifest cannot resolve the version away, and the
315
+ // failure is printed rather than swallowed: a silent catch here looked exactly
316
+ // like a successful upgrade that changed nothing.
317
+ const r = await Bun.$`${process.execPath} x --force kaizen-agent@${want} --yes`
318
+ .quiet().nothrow();
319
+ const out = clean(r.stdout.toString() + r.stderr.toString()).filter((l) => l.trim());
320
+ for (const l of out) console.log(l.startsWith(" ") ? l : ` ${l}`);
321
+ if (r.exitCode !== 0) {
322
+ console.log(`\n ${c.bold(`could not fetch kaizen ${want}`)}`);
323
+ console.log(` ${c.dim("try:")} ${c.cyan(`bunx kaizen-agent@${want}`)}\n`);
324
+ process.exit(1);
325
+ }
326
+ const after = versionOf(process.env.KAIZEN_HOME ?? join(home, "kaizen"));
327
+ if (after === now) console.log(`\n ${c.bold(`kaizen ${now} is the latest on npm`)}\n`);
328
+ process.exit(0);
329
+ }
330
+ }
331
+ if (!check && !upgrade) await welcome();
332
+
333
+ async function welcome() {
334
+ const rgb = (r: number, g: number, b: number, s: string) =>
335
+ `\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`;
336
+
337
+ // Fuji: snow on the peak, deepening blue down the slope.
338
+ const WORD = [
339
+ "██╗ ██╗ █████╗ ██╗███████╗███████╗███╗ ██╗",
340
+ "██║ ██╔╝██╔══██╗██║╚══███╔╝██╔════╝████╗ ██║",
341
+ "█████╔╝ ███████║██║ ███╔╝ █████╗ ██╔██╗ ██║",
342
+ "██╔═██╗ ██╔══██║██║ ███╔╝ ██╔══╝ ██║╚██╗██║",
343
+ "██║ ██╗██║ ██║██║███████╗███████╗██║ ╚████║",
344
+ "╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝╚══════╝╚═╝ ╚═══╝",
345
+ ];
346
+ const W = 69, C = 34, SNOW = 3;
347
+ // One constant slope, three columns per row, all the way down. The rows behind
348
+ // the wordmark keep the same rate, so the edge that goes in above the letters
349
+ // comes out below them on the same line -- vary it and the cone reads broken.
350
+ const H = [1, 4, 7, 10, 13, 16, 19, 22, 25, 28, 31, 34];
351
+
352
+ // Anything that would wrap is worse than the smaller thing that fits: the
353
+ // mountain needs the full width, the wordmark needs 47, below that use words.
354
+ const cols = process.stdout.columns || 80;
355
+ const wide = cols >= W + 4;
356
+ if (cols < 49) {
357
+ console.log(`\n ${c.bold("kaizen")}`);
358
+ } else {
359
+
360
+ const cell: string[][] = H.map(() => Array(W).fill(" "));
361
+ const isWord: boolean[][] = H.map(() => Array(W).fill(false));
362
+
363
+ if (wide) {
364
+ for (const [i, h] of H.entries()) {
365
+ const l = C - h, r = C + h;
366
+ if (i < SNOW) { // solid cap
367
+ cell[i]![l] = "▄"; cell[i]![r] = "▄";
368
+ for (let x = l + 1; x < r; x++) cell[i]![x] = "█";
369
+ } else { // slope, drawn as two edges
370
+ // Below the wordmark's top row the letters own the middle, so draw an
371
+ // edge only where all three of its characters clear them.
372
+ const band = i >= H.length - WORD.length;
373
+ const gap = Math.floor((W - 45) / 2) - 2;
374
+ if (!band || l + 2 < gap) {
375
+ cell[i]![l] = "▄"; cell[i]![l + 1] = "█"; cell[i]![l + 2] = "▀";
376
+ cell[i]![r - 2] = "▀"; cell[i]![r - 1] = "█"; cell[i]![r] = "▄";
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ const top = H.length - WORD.length, left = Math.floor((W - 45) / 2);
383
+ for (let r = top; r < H.length; r++) // air around the letters
384
+ for (let x = left - 2; x < left + 47; x++) if (x >= 0 && x < W) cell[r]![x] = " ";
385
+ for (const [r, line] of WORD.entries())
386
+ for (const [x, ch] of [...line].entries())
387
+ if (ch !== " ") { cell[top + r]![left + x] = ch; isWord[top + r]![left + x] = true; }
388
+
389
+ console.log();
390
+ for (const [i, row] of cell.entries()) {
391
+ if (!wide && i < top) continue; // no mountain to draw, no blank rows
392
+ const slope = Math.max(0, i - SNOW) / (H.length - SNOW);
393
+ const mountain: [number, number, number] = i < SNOW
394
+ ? [255 - i * 8, 255 - i * 5, 255]
395
+ : [Math.round(150 - slope * 105), Math.round(190 - slope * 110), Math.round(245 - slope * 80)];
396
+
397
+ // With no mountain there is nothing to sit under, so drop its left margin.
398
+ const off = wide ? 0 : left;
399
+ const chars = row.slice(off), flags = isWord[i]!.slice(off);
400
+
401
+ let out = " ", run = "", runWord = flags[0]!;
402
+ const flush = () => {
403
+ if (!run) return;
404
+ const t = (i - top) / (WORD.length - 1);
405
+ const col: [number, number, number] = runWord
406
+ ? [Math.round(214 - t * 150), Math.round(232 - t * 130), Math.round(255 - t * 45)]
407
+ : mountain;
408
+ out += rgb(col[0], col[1], col[2], run);
409
+ run = "";
410
+ };
411
+ for (const [x, ch] of chars.entries()) {
412
+ if (flags[x] !== runWord) { flush(); runWord = flags[x]!; }
413
+ run += ch;
414
+ }
415
+ flush();
416
+ console.log(out.replace(/\s+$/, ""));
417
+ }
418
+ if (wide) console.log(" " + rgb(38, 66, 130, "▀".repeat(W)));
419
+ }
420
+
421
+ console.log(`
422
+ ${c.bold("改善")} ${c.dim("kaizen — continuous improvement")}
423
+
424
+ ${c.dim("A staged workflow for AI agents — code, writing, research, operations:")}
425
+ ${c.dim("plan -> you approve -> build -> independent review -> bounded fix loop")}
426
+ `);
427
+
428
+ if (interactive) await anyKey();
429
+ }
430
+
431
+ // A pause before anything touches the filesystem, and the place to back out.
432
+ async function anyKey() {
433
+ const { stdin, stdout } = process;
434
+ stdout.write(` ${c.dim("press")} ${c.cyan("enter")} ${c.dim("to install, or")} ${c.cyan("ctrl-c")} ${c.dim("to leave")}`);
435
+ stdin.setRawMode(true);
436
+ stdin.resume();
437
+ await new Promise<void>((resolve) => {
438
+ const onData = (chunk: Buffer) => {
439
+ if (chunk.toString().includes("\x03")) { stdout.write("\n\n nothing installed\n"); process.exit(130); }
440
+ stdin.off("data", onData);
441
+ resolve();
442
+ };
443
+ stdin.on("data", onData);
444
+ });
445
+ stdin.setRawMode(false);
446
+ stdin.pause();
447
+ stdout.write("\r\x1b[2K");
448
+ }
449
+
450
+ const repo = await resolveRepo();
451
+ const root = await chooseRoot();
452
+ const presetChoice = await choosePreset();
453
+ const targets = found(root);
454
+
455
+ // null means "leave the config alone": an upgrade or a --yes run never asked, and
456
+ // answering "medium" for the user overwrote whatever preset they had chosen.
457
+ async function choosePreset(): Promise<PresetName | null> {
458
+ if (presetArg) return presetArg;
459
+ if (!interactive) return null;
460
+
461
+ return select("Choose workflow preset", [
462
+ {
463
+ label: "Medium (Recommended)",
464
+ hint: "balanced — subagents, plan approval, auto-fix high/critical",
465
+ value: "medium",
466
+ },
467
+ {
468
+ label: "Low",
469
+ hint: "inline & manual — lite runner, inline builder, manual approvals, no auto-fix",
470
+ value: "low",
471
+ },
472
+ {
473
+ label: "Ultra",
474
+ hint: "autonomous — subagents, auto mode, auto-fix all findings (up to 4 rounds)",
475
+ value: "ultra",
476
+ },
477
+ ]);
478
+ }
479
+
480
+ // ---------------------------------------------------------------- repo
481
+
482
+ // Settings only needs the clone's default config, not a pull.
483
+ async function resolveRepoQuietly() {
484
+ const here = dirname(import.meta.dir);
485
+ if (existsSync(join(here, ".git"))) return here;
486
+ return process.env.KAIZEN_HOME ?? join(home, "kaizen");
487
+ }
488
+
489
+ async function resolveRepo() {
490
+ const here = dirname(import.meta.dir);
491
+ const dest = process.env.KAIZEN_HOME ?? join(home, "kaizen");
492
+ // The launcher runs the clone itself, so an upgrade from inside it must pull
493
+ // that clone -- returning it untouched was "Up to date" at whatever was there.
494
+ for (const dir of [here, dest]) {
495
+ if (!existsSync(join(dir, ".git"))) continue;
496
+ if (!upgrade && dir === here) return dir;
497
+ // A pull can refuse for reasons that have nothing to do with linking -- local
498
+ // edits, a detached head, no network. Linking the checkout already on disk
499
+ // still works, so say what happened and carry on.
500
+ try {
501
+ await Bun.$`git -C ${dir} pull --ff-only`.quiet();
502
+ step(`updated ${tilde(dir)}`);
503
+ } catch {
504
+ step(`kept ${tilde(dir)} ${c.dim("(could not pull)")}`);
505
+ }
506
+ return dir;
507
+ }
508
+ // A copy made before git was installed can never be pulled, so it keeps upgrading
509
+ // through npm and its caches. Once git is here, replace it with a real clone --
510
+ // into a sibling first, so a clone that fails leaves the working copy alone.
511
+ if (Bun.which("git") && existsSync(join(dest, "cli", "install.ts"))) {
512
+ const fresh = `${dest}.new`;
513
+ try {
514
+ rmSync(fresh, { recursive: true, force: true });
515
+ await Bun.$`git clone --quiet ${REPO_URL} ${fresh}`;
516
+ rmSync(dest, { recursive: true, force: true });
517
+ renameSync(fresh, dest);
518
+ step(`re-cloned ${tilde(dest)} ${c.dim("(git is available now)")}`);
519
+ return dest;
520
+ } catch {
521
+ rmSync(fresh, { recursive: true, force: true });
522
+ // Fall through to the copy below: an unreachable network is not a reason to
523
+ // leave the install untouched.
524
+ }
525
+ }
526
+
527
+ // Cloning needs an empty directory, and a copy made on a machine that had no git
528
+ // is anything but: installing git later must not turn every run into a fatal.
529
+ // An existing directory is updated in place whichever way it was made.
530
+ if (Bun.which("git") && !existsSync(dest)) {
531
+ step(`cloning into ${tilde(dest)}`);
532
+ await Bun.$`git clone --quiet ${REPO_URL} ${dest}`;
533
+ return dest;
534
+ }
535
+ // The bunx download already holds everything a clone would, so copy it in place.
536
+ // Upgrades then come from re-running bunx rather than a pull.
537
+ step(`copying into ${tilde(dest)} ${c.dim(existsSync(dest) ? "(over the copy already there)" : "(no git)")}`);
538
+ cpSync(here, dest, {
539
+ recursive: true, dereference: true,
540
+ filter: (src) => basename(src) !== "node_modules",
541
+ });
542
+ return dest;
543
+ }
544
+
545
+ async function chooseRoot() {
546
+ if (args.has("--project")) return process.cwd();
547
+ if (args.has("--global") || !interactive) return home;
548
+ // A project install only means something in a project. Standing in the home
549
+ // directory it is the global install under another name; any other folder is
550
+ // a project worth installing into, git repository or not.
551
+ const cwd = process.cwd();
552
+ if (cwd === home) return home;
553
+
554
+ const names = (r: string) => found(r).map((a) => a.name).join(", ");
555
+ return select("Install kaizen for", [
556
+ { label: "Globally", hint: `every project \u2014 ${names(home)}`, value: home },
557
+ { label: "This project only", hint: `${basename(cwd)}/ \u2014 ${names(cwd)}`, value: cwd },
558
+ ]);
559
+ }
560
+
561
+ // ---------------------------------------------------------------- linking
562
+
563
+ function dirEntries(sub: string) {
564
+ return readdirSync(join(repo, sub))
565
+ .filter((f) => f.startsWith("kaizen-") && f.endsWith(".md"))
566
+ .map((f) => join(repo, sub, f));
567
+ }
568
+
569
+ const links = targets.flatMap((a) => [
570
+ ...["kaizen", "kaizen-help"].map((n) => [join(repo, "skills", n), join(a.dir, "skills")] as const),
571
+ ...(a.full ? [
572
+ ...dirEntries("agents").map((f) => [f, join(a.dir, "agents")] as const),
573
+ ...dirEntries("commands").map((f) => [f, join(a.dir, "commands")] as const),
574
+ ] : []),
575
+ ]);
576
+
577
+ const isWindows = process.platform === "win32";
578
+
579
+ // Windows only hands out file symlinks to an admin or a Developer Mode box, so
580
+ // there the link is a directory junction (allowed for everyone) or, for a plain
581
+ // file, a copy that is refreshed whenever the source moves ahead.
582
+ function makeLink(src: string, dest: string) {
583
+ if (!isWindows) { symlinkSync(src, dest); return; }
584
+ if (statSync(src).isDirectory()) { symlinkSync(src, dest, "junction"); return; }
585
+ copyFileSync(src, dest);
586
+ }
587
+
588
+ function sameFile(a: string, b: string) {
589
+ try { return readFileSync(a).equals(readFileSync(b)); } catch { return false; }
590
+ }
591
+
592
+ function linkState(src: string, dest: string) {
593
+ try {
594
+ const st = lstatSync(dest);
595
+ if (!st.isSymbolicLink()) {
596
+ // A copied link on Windows is a real file; it is ours as long as it still
597
+ // matches the source, and stale once the source changes.
598
+ if (isWindows && st.isFile()) return sameFile(src, dest) ? "linked" : "stale";
599
+ return "occupied";
600
+ }
601
+ return readlinkSync(dest) === src ? "linked" : "stale";
602
+ } catch {
603
+ return "missing";
604
+ }
605
+ }
606
+
607
+ let blocked = 0, wrote = 0, already = 0;
608
+ for (const [src, sub] of links) {
609
+ const dir = join(root, sub);
610
+ const dest = join(dir, basename(src));
611
+ const state = linkState(src, dest);
612
+ const name = `${sub}/${basename(src)}`;
613
+
614
+ if (check) {
615
+ if (state !== "linked") blocked++;
616
+ console.log(`${state === "linked" ? "ok " : "MISS"} ${name}${state === "linked" ? "" : ` (${state})`}`);
617
+ continue;
618
+ }
619
+ if (state === "linked") { already++; if (verbose) console.log(c.dim(` ok ${name}`)); continue; }
620
+ if (state === "occupied" && !force) {
621
+ console.log(` ${c.bold("skip")} ${name} — real file present, use --force`);
622
+ blocked++;
623
+ continue;
624
+ }
625
+ mkdirSync(dir, { recursive: true });
626
+ if (state !== "missing") rmSync(dest, { recursive: true, force: true });
627
+ makeLink(src, dest);
628
+ wrote++;
629
+ if (verbose) console.log(c.dim(` link ${name}`));
630
+ }
631
+
632
+ if (check) {
633
+ console.log(blocked ? `\n${blocked} link(s) missing — run: bunx kaizen-agent` : "\nall linked");
634
+ process.exit(blocked ? 1 : 0);
635
+ }
636
+
637
+ const into = targets.map((a) => a.name).join(", ");
638
+ step(
639
+ wrote && already ? `linked ${wrote} new, ${already} already in place — ${into}`
640
+ : wrote ? `linked ${wrote} files into ${into}`
641
+ : `already installed for ${into} ${c.dim("(nothing to change)")}`,
642
+ );
643
+ if (targets.length === 1) {
644
+ console.log(` ${c.dim("Codex, Antigravity, OpenCode, Cursor and Gemini CLI get it too,")}`);
645
+ console.log(` ${c.dim("if their directory exists when you install.")}`);
646
+ }
647
+
648
+ // ---------------------------------------------------------------- repo setup
649
+
650
+ if (!args.has("--project")) installLauncher();
651
+
652
+ // bunx runs from a temp copy and leaves nothing behind, so there is no `kaizen` to
653
+ // type afterwards. A launcher pointed at the clone gives one, and keeps working
654
+ // after the clone updates.
655
+ function installLauncher() {
656
+ const entry = join(repo, "cli/install.ts");
657
+ // Windows cannot run a shell script and has no ~/.local/bin on PATH. Bun's own
658
+ // bin directory is already there — bun put it there when it installed itself —
659
+ // so a .cmd beside bun is the launcher that works without touching PATH.
660
+ const win = process.platform === "win32";
661
+ const binDir = win
662
+ ? join(process.env.BUN_INSTALL ?? join(home, ".bun"), "bin")
663
+ : join(home, ".local", "bin");
664
+ const bin = join(binDir, win ? "kaizen.cmd" : "kaizen");
665
+ const script = win
666
+ ? `@echo off\r\nrem kaizen launcher -- installed by kaizen-agent\r\nbun run "${entry}" %*\r\n`
667
+ : `#!/bin/sh\n# kaizen launcher -- installed by kaizen-agent\nexec bun run ${entry} "$@"\n`;
668
+ try {
669
+ if (existsSync(bin) && readFileSync(bin, "utf8") === script) return;
670
+ mkdirSync(binDir, { recursive: true });
671
+ writeFileSync(bin, script, { mode: 0o755 });
672
+ const onPath = (process.env.PATH ?? "").split(win ? ";" : ":")
673
+ .some((p) => p.replace(/[\\/]+$/, "").toLowerCase() === binDir.replace(/[\\/]+$/, "").toLowerCase());
674
+ step(onPath
675
+ ? `${c.cyan("kaizen upgrade")} is now on your PATH`
676
+ : `wrote ${tilde(bin)} ${c.dim(`— add ${tilde(binDir)} to PATH to use it`)}`);
677
+ } catch {
678
+ /* a read-only home is not worth failing the install over */
679
+ }
680
+ }
681
+
682
+ const inProject = process.cwd() !== home;
683
+ const kaizenDir = join(process.cwd(), ".kaizen");
684
+ const globalKaizen = join(home, ".kaizen");
685
+ const globalConfig = join(globalKaizen, "config.yml");
686
+
687
+ // Always global: a project keeps no config of its own, so ~/.kaizen/config.yml is
688
+ // what every project follows.
689
+ {
690
+ mkdirSync(globalKaizen, { recursive: true });
691
+ const defPath = join(repo, "skills/kaizen/config.default.yml");
692
+ const def = readFileSync(defPath, "utf8");
693
+ const existing = existsSync(globalConfig) ? readFileSync(globalConfig, "utf8") : null;
694
+ if (presetChoice) {
695
+ writeFileSync(globalConfig, applyPreset(existing ?? def, presetChoice, def));
696
+ step(`configured ${c.bold(presetChoice)} preset in ${tilde(globalConfig)}`);
697
+ } else if (!existing) {
698
+ writeFileSync(globalConfig, def);
699
+ step(`wrote ${tilde(globalConfig)}`);
700
+ } else {
701
+ step(`kept your settings in ${tilde(globalConfig)} ${c.dim(`(${detectPreset(existing, def)})`)}`);
702
+ }
703
+ }
704
+
705
+ // A .kaizen/ in a parent already owns this folder's state, and setting up a child
706
+ // would shadow it, hiding the parent's runs and leaving the child out of the root
707
+ // .gitignore. Deliberately not shared with cli/dashboard.ts locate(): that one
708
+ // resolves which state directory to read and so falls back to a global ~/.kaizen,
709
+ // while this one decides whether a folder is already owned and must stop *before*
710
+ // $HOME — a global ~/.kaizen would otherwise suppress the setup offer for every
711
+ // non-repo folder under the home directory. Do not re-sync the two walks.
712
+ function kaizenAbove() {
713
+ let dir = process.cwd();
714
+ for (;;) {
715
+ if (dir === home) return false;
716
+ if (existsSync(join(dir, ".kaizen"))) return true;
717
+ if (existsSync(join(dir, ".git"))) return false;
718
+ const up = dirname(dir);
719
+ if (up === dir) return false;
720
+ dir = up;
721
+ }
722
+ }
723
+
724
+ if (inProject && !kaizenAbove()) {
725
+ const now = root === process.cwd()
726
+ ? true
727
+ : interactive
728
+ ? await select(`Set up ${basename(process.cwd())}/ for kaizen now`, [
729
+ // "Not now" leads: the offer now appears in any folder outside home, so the
730
+ // safe answer is the one under the cursor.
731
+ { label: "Not now", hint: "the first /kaizen run will do it", value: false },
732
+ { label: "Yes", hint: "writes .kaizen/ and a CLAUDE.md line so it runs by default", value: true },
733
+ ])
734
+ : false;
735
+ if (now) initProject();
736
+ } else if (existsSync(kaizenDir)) {
737
+ step(`${basename(process.cwd())}/.kaizen already set up`);
738
+ }
739
+
740
+ function initProject() {
741
+ mkdirSync(kaizenDir, { recursive: true });
742
+ for (const f of readdirSync(join(repo, "skills/kaizen")).filter((f) => f.startsWith("spec"))) {
743
+ copyFileSync(join(repo, "skills/kaizen", f), join(kaizenDir, f));
744
+ }
745
+ // No config.yml here on purpose: a project without one follows
746
+ // ~/.kaizen/config.yml, so a setting changed once applies everywhere. Write one
747
+ // only to make this project deliberately different.
748
+
749
+ // Run state is local; the workflow itself is shared, so those files stay tracked.
750
+ // Nothing to ignore where there is no git, so a plain folder gets no .gitignore.
751
+ let ignored = false;
752
+ if (existsSync(join(process.cwd(), ".git"))) {
753
+ const rules = ".kaizen/*\n!.kaizen/spec*.md\n!.kaizen/config.yml\n";
754
+ const gi = join(process.cwd(), ".gitignore");
755
+ const current = existsSync(gi) ? readFileSync(gi, "utf8") : "";
756
+ if (!current.includes(".kaizen/*")) {
757
+ appendFileSync(gi, (current && !current.endsWith("\n") ? "\n" : "") + "\n# kaizen run state\n" + rules);
758
+ ignored = true;
759
+ }
760
+ }
761
+ step(ignored ? "wrote .kaizen/ and the gitignore entry" : "wrote .kaizen/");
762
+ writePointer();
763
+ }
764
+
765
+ // Claude Code loads a skill by matching its description, so an ordinary request
766
+ // ("add a discount cap") does not reach kaizen unless the project says it should.
767
+ // One line in the agent instructions is what makes it the default here.
768
+ function writePointer() {
769
+ 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\nA run costs several times what the same change costs in one session, so spend it\nwhere being wrong is expensive: money, data loss, a migration or anything else hard\nto undo, work a customer sees, or a part of the system nobody has read in a year.\n\nWork where being wrong is cheap and obvious does not start a run. Answering a\nquestion, reading code, a one-line fix, a rename the compiler checks — do those\ndirectly. When it is genuinely unclear, ask rather than assuming the expensive path.\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`;
770
+
771
+ for (const name of ["CLAUDE.md", "AGENTS.md"]) {
772
+ const f = join(process.cwd(), name);
773
+ const current = existsSync(f) ? readFileSync(f, "utf8") : "";
774
+ if (current.includes("## Kaizen workflow")) continue;
775
+ if (!current && name === "AGENTS.md") continue; // do not create one that was never there
776
+ const text = current ? (current.endsWith("\n") ? "" : "\n") + block : block.slice(1);
777
+ appendFileSync(f, text);
778
+ step(`${existsSync(f) && current ? "added to" : "wrote"} ${name} ${c.dim("— kaizen now runs without being asked for")}`);
779
+ }
780
+ }
781
+
782
+ // ---------------------------------------------------------------- done
783
+
784
+ if (upgrade) {
785
+ let version = "";
786
+ try { version = JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).version; } catch {}
787
+ console.log(`
788
+ ${c.bold("Up to date")}${version ? c.dim(` — kaizen ${version}`) : ""}
789
+ ${c.dim("Restart Claude Code to pick up any new slash commands.")}
790
+ `);
791
+ } else {
792
+ console.log(`
793
+ ${c.bold("Next")}
794
+ 1 restart Claude Code ${c.dim("— skills load live, slash commands only at session start")}
795
+ 2 ${c.cyan("/kaizen <what you want done>")} ${c.dim("in any project — it sets itself up on first run")}
796
+
797
+ ${c.dim("/kaizen-help lists every command. kaizen upgrade updates all of this.")}
798
+ `);
799
+ }
800
+ if (blocked) process.exit(1);
801
+
802
+ // ---------------------------------------------------------------- helpers
803
+
804
+ function step(msg: string) { console.log(` ${c.green("+")} ${msg}`); }
805
+
806
+ // Runs the upgrade and reports what it actually did, version to version, so the
807
+ // dashboard can show a result rather than the user wondering whether it worked.
808
+ async function runUpgrade(repoDir: string) {
809
+ const before = versionOf(repoDir);
810
+ const out = await Bun.$`bun run ${join(repoDir, "cli/install.ts")} upgrade`.text().catch(() => "");
811
+ const after = versionOf(repoDir);
812
+ const latest = await npmLatest();
813
+
814
+ const lines = [
815
+ before === after
816
+ ? `${c.bold("Already up to date")} ${c.dim(after)}`
817
+ : `${c.bold("Upgraded")} ${c.dim(before)} → ${c.green(after)}`,
818
+ "",
819
+ // Only the step lines: the captured run's own heading and restart notice
820
+ // repeat what this panel already says above them.
821
+ ...clean(out)
822
+ .filter((l) => l.trim().startsWith("+") || l.trim().startsWith("skip"))
823
+ .map((l) => c.dim(l.trim())),
824
+ ];
825
+ if (latest && latest !== after) {
826
+ lines.push("", c.dim(existsSync(join(repoDir, ".git"))
827
+ ? `npm publishes ${latest}; this install follows the git clone.`
828
+ : `npm publishes ${latest} — run: bunx kaizen-agent@${latest}`));
829
+ }
830
+ lines.push("", c.dim("Restart your agent to pick up new slash commands."));
831
+ return lines;
832
+ }
833
+
834
+ function versionOf(dir: string) {
835
+ try { return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version as string; }
836
+ catch { return "?"; }
837
+ }
838
+
839
+ async function npmLatest() {
840
+ try {
841
+ const r = await fetch("https://registry.npmjs.org/kaizen-agent/latest", {
842
+ signal: AbortSignal.timeout(10000),
843
+ });
844
+ return ((await r.json()) as { version?: string }).version ?? null;
845
+ } catch { return null; } // offline is not an upgrade failure
846
+ }
847
+
848
+ // Captured output still carries its own colour codes, which stop any test on the
849
+ // text from matching and would nest inside this panel's own styling.
850
+ function clean(out: string) {
851
+ return out.split("\n").map((l) => l.replace(/\x1b\[[0-9;]*m/g, "").replace(/\r$/, "").trimEnd());
852
+ }
853
+
854
+ // bunx keeps an extracted copy per package under the temp directory and reuses it
855
+ // without re-resolving, so an upgrade that only pulls the clone still leaves the
856
+ // next `bunx kaizen-agent` running whatever was cached the first time.
857
+ async function clearBunxCache() {
858
+ const tmp = process.env.TMPDIR ?? tmpdir();
859
+ let removed = 0;
860
+ try {
861
+ for (const name of readdirSync(tmp)) {
862
+ if (/^bunx-.*kaizen-agent/.test(name)) {
863
+ rmSync(join(tmp, name), { recursive: true, force: true });
864
+ removed++;
865
+ }
866
+ }
867
+ } catch { /* an unreadable temp directory is not worth failing an upgrade over */ }
868
+
869
+ // bun caches the package manifest too, which is what answers "no version
870
+ // matching 1.3.7 found (but package exists)" hours after it was published.
871
+ // Only kaizen's own entries go: another package's cache is not ours to clear.
872
+ // Where that cache lives depends on the platform and on how bun was installed,
873
+ // so try the lot; a directory that is not there reads as an empty one.
874
+ const caches = [
875
+ process.env.BUN_INSTALL_CACHE_DIR,
876
+ join(process.env.BUN_INSTALL ?? join(home, ".bun"), "install", "cache"),
877
+ join(process.env.XDG_CACHE_HOME ?? join(home, ".cache"), "bun"),
878
+ process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, "bun", "install", "cache") : null,
879
+ ].filter((d): d is string => Boolean(d));
880
+ for (const dir of caches) {
881
+ try {
882
+ for (const name of readdirSync(dir)) {
883
+ if (!name.includes("kaizen-agent")) continue;
884
+ rmSync(join(dir, name), { recursive: true, force: true });
885
+ removed++;
886
+ }
887
+ } catch { /* no cache directory is the same as an empty one */ }
888
+ }
889
+
890
+ step(removed ? `cleared ${removed} cached copy of the installer` : "no installer cache to clear");
891
+ }
892
+ function tilde(p: string) { return p.startsWith(home) ? "~" + p.slice(home.length) : p; }
893
+
894
+ // Arrow-key picker. Raw mode delivers keys unbuffered; the alternative is a
895
+ // dependency for two options and forty lines of escape codes.
896
+ async function select<T>(question: string, options: { label: string; hint: string; value: T }[]) {
897
+ const { stdin, stdout } = process;
898
+ let active = 0;
899
+
900
+ const draw = (first: boolean) => {
901
+ if (!first) stdout.write(`\x1b[${options.length}A`);
902
+ for (const [i, o] of options.entries()) {
903
+ const on = i === active;
904
+ stdout.write(
905
+ `\x1b[2K ${on ? c.cyan(">") : " "} ${on ? c.bold(o.label) : c.dim(o.label)}` +
906
+ ` ${c.dim(o.hint)}\n`,
907
+ );
908
+ }
909
+ };
910
+
911
+ stdout.write(`\n ${c.bold(question)} ${c.dim("(arrows, enter)")}\n`);
912
+ stdout.write("\x1b[?25l");
913
+ draw(true);
914
+ stdin.setRawMode(true);
915
+ stdin.resume();
916
+
917
+ // Read via events rather than `for await`: breaking out of an async iterator
918
+ // destroys stdin, and a second prompt would then have no stream to read.
919
+ await new Promise<void>((resolve) => {
920
+ const onData = (chunk: Buffer) => {
921
+ // A chunk can hold more than one keypress -- an arrow and the enter behind
922
+ // it arrive together when input is piped -- so walk it rather than
923
+ // comparing the whole thing to one key.
924
+ const keys = chunk.toString();
925
+ for (let i = 0; i < keys.length; i++) {
926
+ const rest = keys.slice(i);
927
+ if (rest.startsWith("\x03")) { stdout.write("\ncancelled\n"); process.exit(130); }
928
+ if (rest.startsWith("\r") || rest.startsWith("\n")) {
929
+ stdin.off("data", onData);
930
+ return resolve();
931
+ }
932
+ if (rest.startsWith("\x1b[A") || rest.startsWith("\x1b[B")) {
933
+ active = rest[2] === "A"
934
+ ? (active - 1 + options.length) % options.length
935
+ : (active + 1) % options.length;
936
+ i += 2;
937
+ } else if (rest.startsWith("k")) active = (active - 1 + options.length) % options.length;
938
+ else if (rest.startsWith("j")) active = (active + 1) % options.length;
939
+ else continue;
940
+ draw(false);
941
+ }
942
+ };
943
+ stdin.on("data", onData);
944
+ });
945
+
946
+ stdin.setRawMode(false);
947
+ stdin.pause();
948
+ stdout.write("\x1b[?25h");
949
+ return options[active]!.value;
950
+ }