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