kaizen-agent 0.2.0 → 0.4.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 +8 -2
- package/cli/install.ts +175 -48
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,8 +29,12 @@ cd ~/kaizen && bun run cli/install.ts
|
|
|
29
29
|
|
|
30
30
|
Flags, either way:
|
|
31
31
|
|
|
32
|
-
Run interactively
|
|
33
|
-
runs take the global
|
|
32
|
+
Run interactively and it walks you through it: where to install, and whether to set
|
|
33
|
+
up the repository you are standing in. Piped or scripted runs take the global
|
|
34
|
+
install and ask nothing.
|
|
35
|
+
|
|
36
|
+
If `bunx` gives you an older version, it is serving its cache — clear it with
|
|
37
|
+
`rm -rf /tmp/bunx-*-kaizen-agent*`.
|
|
34
38
|
|
|
35
39
|
| Flag | Effect |
|
|
36
40
|
|---|---|
|
|
@@ -39,6 +43,8 @@ runs take the global install without asking.
|
|
|
39
43
|
| `--project` | Install into `.claude/` in the current directory only |
|
|
40
44
|
| `--check` | Report what is linked and exit non-zero if anything is missing |
|
|
41
45
|
| `--force` | Replace a real file sitting where a link belongs |
|
|
46
|
+
| `--yes` | Take every default, ask nothing |
|
|
47
|
+
| `--verbose` | List every link instead of a one-line summary |
|
|
42
48
|
|
|
43
49
|
It is idempotent — an already-correct link is left alone, so re-running after a
|
|
44
50
|
`git pull` is safe and reports what changed.
|
package/cli/install.ts
CHANGED
|
@@ -1,15 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
symlinkSync, mkdirSync, readdirSync, lstatSync, readlinkSync, unlinkSync,
|
|
4
|
+
existsSync, copyFileSync, readFileSync, appendFileSync,
|
|
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 check = args.has("--check");
|
|
13
|
+
const force = args.has("--force");
|
|
14
|
+
const verbose = args.has("--verbose");
|
|
15
|
+
const interactive = process.stdin.isTTY && !check && !args.has("--yes");
|
|
16
|
+
|
|
17
|
+
const c = {
|
|
18
|
+
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
|
|
19
|
+
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
|
|
20
|
+
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
|
|
21
|
+
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
if (!check) {
|
|
25
|
+
console.log(`
|
|
26
|
+
${c.bold("kaizen")} ${c.dim("改善")}
|
|
27
|
+
${c.dim("plan -> approve -> build -> review -> fix")}
|
|
28
|
+
`);
|
|
29
|
+
}
|
|
8
30
|
|
|
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
31
|
const repo = await resolveRepo();
|
|
32
|
+
const root = await chooseRoot();
|
|
33
|
+
const base = join(root, ".claude");
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------- repo
|
|
13
36
|
|
|
14
37
|
async function resolveRepo() {
|
|
15
38
|
const here = dirname(import.meta.dir);
|
|
@@ -17,29 +40,32 @@ async function resolveRepo() {
|
|
|
17
40
|
|
|
18
41
|
const dest = process.env.KAIZEN_HOME ?? join(home, "kaizen");
|
|
19
42
|
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.
|
|
43
|
+
// A pull can refuse for reasons that have nothing to do with linking -- local
|
|
44
|
+
// edits, a detached head, no network. Linking the checkout already on disk
|
|
45
|
+
// still works, so say what happened and carry on.
|
|
24
46
|
try {
|
|
25
47
|
await Bun.$`git -C ${dest} pull --ff-only`.quiet();
|
|
26
|
-
|
|
48
|
+
step(`updated ${tilde(dest)}`);
|
|
27
49
|
} catch {
|
|
28
|
-
|
|
50
|
+
step(`kept ${tilde(dest)} ${c.dim("(could not pull)")}`);
|
|
29
51
|
}
|
|
30
52
|
return dest;
|
|
31
53
|
}
|
|
32
|
-
|
|
54
|
+
step(`cloning into ${tilde(dest)}`);
|
|
33
55
|
await Bun.$`git clone --quiet ${REPO_URL} ${dest}`;
|
|
34
56
|
return dest;
|
|
35
57
|
}
|
|
36
58
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
59
|
+
async function chooseRoot() {
|
|
60
|
+
if (args.has("--project")) return process.cwd();
|
|
61
|
+
if (args.has("--global") || !interactive) return home;
|
|
62
|
+
return select("Install kaizen for", [
|
|
63
|
+
{ label: "Every project", hint: tilde(join(home, ".claude")), value: home },
|
|
64
|
+
{ label: "This project only", hint: join(".claude"), value: process.cwd() },
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------- linking
|
|
43
69
|
|
|
44
70
|
function dirEntries(sub: string) {
|
|
45
71
|
return readdirSync(join(repo, sub))
|
|
@@ -47,6 +73,12 @@ function dirEntries(sub: string) {
|
|
|
47
73
|
.map((f) => join(repo, sub, f));
|
|
48
74
|
}
|
|
49
75
|
|
|
76
|
+
const links = [
|
|
77
|
+
...["kaizen", "kaizen-help"].map((n) => [join(repo, "skills", n), "skills"] as const),
|
|
78
|
+
...dirEntries("agents").map((f) => [f, "agents"] as const),
|
|
79
|
+
...dirEntries("commands").map((f) => [f, "commands"] as const),
|
|
80
|
+
];
|
|
81
|
+
|
|
50
82
|
function linkState(src: string, dest: string) {
|
|
51
83
|
try {
|
|
52
84
|
const st = lstatSync(dest);
|
|
@@ -57,52 +89,147 @@ function linkState(src: string, dest: string) {
|
|
|
57
89
|
}
|
|
58
90
|
}
|
|
59
91
|
|
|
60
|
-
|
|
61
|
-
const check = args.has("--check");
|
|
62
|
-
const force = args.has("--force");
|
|
63
|
-
const base = join(await chooseRoot(), ".claude");
|
|
64
|
-
|
|
65
|
-
async function chooseRoot() {
|
|
66
|
-
if (args.has("--project")) return process.cwd();
|
|
67
|
-
if (args.has("--global") || check) return home;
|
|
68
|
-
|
|
69
|
-
// Only ask when there is someone to answer. Piped, scripted, or CI runs take the
|
|
70
|
-
// global install silently rather than hanging on a prompt nobody sees.
|
|
71
|
-
if (!process.stdin.isTTY) return home;
|
|
72
|
-
|
|
73
|
-
console.log(`\n 1 every project ${join(home, ".claude")}`);
|
|
74
|
-
console.log(` 2 this one only ${join(process.cwd(), ".claude")}\n`);
|
|
75
|
-
const answer = prompt("Install where? [1]") ?? "";
|
|
76
|
-
return answer.trim() === "2" ? process.cwd() : home;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
let bad = 0;
|
|
92
|
+
let blocked = 0, wrote = 0, already = 0;
|
|
80
93
|
for (const [src, sub] of links) {
|
|
81
94
|
const dir = join(base, sub);
|
|
82
95
|
const dest = join(dir, basename(src));
|
|
83
96
|
const state = linkState(src, dest);
|
|
97
|
+
const name = `${sub}/${basename(src)}`;
|
|
84
98
|
|
|
85
99
|
if (check) {
|
|
86
|
-
if (state !== "linked")
|
|
87
|
-
console.log(`${state === "linked" ? "ok " : "MISS"} ${
|
|
100
|
+
if (state !== "linked") blocked++;
|
|
101
|
+
console.log(`${state === "linked" ? "ok " : "MISS"} ${name}${state === "linked" ? "" : ` (${state})`}`);
|
|
88
102
|
continue;
|
|
89
103
|
}
|
|
90
|
-
if (state === "linked") { console.log(`ok
|
|
104
|
+
if (state === "linked") { already++; if (verbose) console.log(c.dim(` ok ${name}`)); continue; }
|
|
91
105
|
if (state === "occupied" && !force) {
|
|
92
|
-
console.log(`
|
|
93
|
-
|
|
106
|
+
console.log(` ${c.bold("skip")} ${name} — real file present, use --force`);
|
|
107
|
+
blocked++;
|
|
94
108
|
continue;
|
|
95
109
|
}
|
|
96
110
|
mkdirSync(dir, { recursive: true });
|
|
97
111
|
if (state !== "missing") unlinkSync(dest);
|
|
98
112
|
symlinkSync(src, dest);
|
|
99
|
-
|
|
113
|
+
wrote++;
|
|
114
|
+
if (verbose) console.log(c.dim(` link ${name}`));
|
|
100
115
|
}
|
|
101
116
|
|
|
102
117
|
if (check) {
|
|
103
|
-
console.log(
|
|
104
|
-
process.exit(
|
|
118
|
+
console.log(blocked ? `\n${blocked} link(s) missing — run: bunx kaizen-agent` : "\nall linked");
|
|
119
|
+
process.exit(blocked ? 1 : 0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
step(
|
|
123
|
+
wrote && already ? `linked ${wrote} new, ${already} already in place`
|
|
124
|
+
: wrote ? `linked ${wrote} skills, agents, and commands into ${tilde(base)}`
|
|
125
|
+
: `already installed in ${tilde(base)} ${c.dim("(nothing to change)")}`,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------- repo setup
|
|
129
|
+
|
|
130
|
+
const inGitRepo = existsSync(join(process.cwd(), ".git"));
|
|
131
|
+
const kaizenDir = join(process.cwd(), ".kaizen");
|
|
132
|
+
|
|
133
|
+
if (inGitRepo && !existsSync(kaizenDir)) {
|
|
134
|
+
const now = interactive
|
|
135
|
+
? await select(`Set up ${basename(process.cwd())}/ for kaizen now`, [
|
|
136
|
+
{ label: "Yes", hint: "writes .kaizen/ — spec, config, gitignore entry", value: true },
|
|
137
|
+
{ label: "Not now", hint: "run /kaizen-init in the repo later", value: false },
|
|
138
|
+
])
|
|
139
|
+
: false;
|
|
140
|
+
if (now) initRepo();
|
|
141
|
+
} else if (existsSync(kaizenDir)) {
|
|
142
|
+
step(`${basename(process.cwd())}/.kaizen already set up`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function initRepo() {
|
|
146
|
+
mkdirSync(kaizenDir, { recursive: true });
|
|
147
|
+
for (const f of readdirSync(join(repo, "skills/kaizen")).filter((f) => f.startsWith("spec"))) {
|
|
148
|
+
copyFileSync(join(repo, "skills/kaizen", f), join(kaizenDir, f));
|
|
149
|
+
}
|
|
150
|
+
copyFileSync(join(repo, "skills/kaizen/config.default.yml"), join(kaizenDir, "config.yml"));
|
|
151
|
+
|
|
152
|
+
// Run state is local; the workflow itself is shared, so those files stay tracked.
|
|
153
|
+
const rules = ".kaizen/*\n!.kaizen/spec*.md\n!.kaizen/config.yml\n";
|
|
154
|
+
const gi = join(process.cwd(), ".gitignore");
|
|
155
|
+
const current = existsSync(gi) ? readFileSync(gi, "utf8") : "";
|
|
156
|
+
if (!current.includes(".kaizen/*")) {
|
|
157
|
+
appendFileSync(gi, (current && !current.endsWith("\n") ? "\n" : "") + "\n# kaizen run state\n" + rules);
|
|
158
|
+
}
|
|
159
|
+
step("wrote .kaizen/ and the gitignore entry");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------- done
|
|
163
|
+
|
|
164
|
+
console.log(`
|
|
165
|
+
${c.bold("Next")}
|
|
166
|
+
1 restart Claude Code ${c.dim("— skills load live, slash commands only at session start")}
|
|
167
|
+
2 ${c.cyan("/kaizen-init")} ${c.dim("in any repo you want to use it on")}
|
|
168
|
+
3 ${c.cyan("/kaizen-plan <what you want built>")}
|
|
169
|
+
|
|
170
|
+
${c.dim("/kaizen-help lists every command.")}
|
|
171
|
+
`);
|
|
172
|
+
if (blocked) process.exit(1);
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------- helpers
|
|
175
|
+
|
|
176
|
+
function step(msg: string) { console.log(` ${c.green("+")} ${msg}`); }
|
|
177
|
+
function tilde(p: string) { return p.startsWith(home) ? "~" + p.slice(home.length) : p; }
|
|
178
|
+
|
|
179
|
+
// Arrow-key picker. Raw mode delivers keys unbuffered; the alternative is a
|
|
180
|
+
// dependency for two options and forty lines of escape codes.
|
|
181
|
+
async function select<T>(question: string, options: { label: string; hint: string; value: T }[]) {
|
|
182
|
+
const { stdin, stdout } = process;
|
|
183
|
+
let active = 0;
|
|
184
|
+
|
|
185
|
+
const draw = (first: boolean) => {
|
|
186
|
+
if (!first) stdout.write(`\x1b[${options.length}A`);
|
|
187
|
+
for (const [i, o] of options.entries()) {
|
|
188
|
+
const on = i === active;
|
|
189
|
+
stdout.write(
|
|
190
|
+
`\x1b[2K ${on ? c.cyan(">") : " "} ${on ? c.bold(o.label) : c.dim(o.label)}` +
|
|
191
|
+
` ${c.dim(o.hint)}\n`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
stdout.write(`\n ${c.bold(question)} ${c.dim("(arrows, enter)")}\n`);
|
|
197
|
+
stdout.write("\x1b[?25l");
|
|
198
|
+
draw(true);
|
|
199
|
+
stdin.setRawMode(true);
|
|
200
|
+
stdin.resume();
|
|
201
|
+
|
|
202
|
+
// Read via events rather than `for await`: breaking out of an async iterator
|
|
203
|
+
// destroys stdin, and a second prompt would then have no stream to read.
|
|
204
|
+
await new Promise<void>((resolve) => {
|
|
205
|
+
const onData = (chunk: Buffer) => {
|
|
206
|
+
// A chunk can hold more than one keypress -- an arrow and the enter behind
|
|
207
|
+
// it arrive together when input is piped -- so walk it rather than
|
|
208
|
+
// comparing the whole thing to one key.
|
|
209
|
+
const keys = chunk.toString();
|
|
210
|
+
for (let i = 0; i < keys.length; i++) {
|
|
211
|
+
const rest = keys.slice(i);
|
|
212
|
+
if (rest.startsWith("\x03")) { stdout.write("\ncancelled\n"); process.exit(130); }
|
|
213
|
+
if (rest.startsWith("\r") || rest.startsWith("\n")) {
|
|
214
|
+
stdin.off("data", onData);
|
|
215
|
+
return resolve();
|
|
216
|
+
}
|
|
217
|
+
if (rest.startsWith("\x1b[A") || rest.startsWith("\x1b[B")) {
|
|
218
|
+
active = rest[2] === "A"
|
|
219
|
+
? (active - 1 + options.length) % options.length
|
|
220
|
+
: (active + 1) % options.length;
|
|
221
|
+
i += 2;
|
|
222
|
+
} else if (rest.startsWith("k")) active = (active - 1 + options.length) % options.length;
|
|
223
|
+
else if (rest.startsWith("j")) active = (active + 1) % options.length;
|
|
224
|
+
else continue;
|
|
225
|
+
draw(false);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
stdin.on("data", onData);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
stdin.setRawMode(false);
|
|
232
|
+
stdin.pause();
|
|
233
|
+
stdout.write("\x1b[?25h");
|
|
234
|
+
return options[active]!.value;
|
|
105
235
|
}
|
|
106
|
-
console.log(`\nInstalled into ${base}. Restart Claude Code: skills load live, slash commands only on session start.`);
|
|
107
|
-
console.log(`Then, in a repository you want to use it on: /kaizen-init`);
|
|
108
|
-
if (bad) process.exit(1);
|