kaizen-agent 1.7.6 → 1.7.8
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 +950 -947
- package/cli/state.ts +9 -13
- package/cli/web.ts +434 -428
- package/package.json +1 -1
- package/web/board.html +2 -2
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
child.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
return
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
"
|
|
340
|
-
"██║
|
|
341
|
-
"
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
console.log(
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
${c.dim("
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
stdin
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
])
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
if (
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
if (
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
writeFileSync(globalConfig, def);
|
|
696
|
-
step(`
|
|
697
|
-
} else {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
//
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
if (
|
|
717
|
-
dir
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
`
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
${c.dim("
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
process.env.
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
const
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
+
}
|