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.
Files changed (3) hide show
  1. package/README.md +8 -2
  2. package/cli/install.ts +175 -48
  3. 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 with no flags and it asks where to install; piped or scripted
33
- runs take the global install without asking.
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 { symlinkSync, mkdirSync, readdirSync, lstatSync, readlinkSync, unlinkSync, existsSync } from "node:fs";
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 — local
21
- // edits, a detached head, no network. Linking the checkout that is already
22
- // there still works, so say what happened and carry on rather than failing
23
- // the install over it.
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
- console.log(`update ${dest}`);
48
+ step(`updated ${tilde(dest)}`);
27
49
  } catch {
28
- console.log(`skip ${dest} — could not pull, linking what is there`);
50
+ step(`kept ${tilde(dest)} ${c.dim("(could not pull)")}`);
29
51
  }
30
52
  return dest;
31
53
  }
32
- console.log(`clone ${REPO_URL} -> ${dest}`);
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
- // Each entry: source path in the repo -> directory it is linked into.
38
- const links = [
39
- ...["kaizen", "kaizen-help"].map((n) => [join(repo, "skills", n), "skills"] as const),
40
- ...dirEntries("agents").map((f) => [f, "agents"] as const),
41
- ...dirEntries("commands").map((f) => [f, "commands"] as const),
42
- ];
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
- const args = new Set(Bun.argv.slice(2));
61
- const check = args.has("--check");
62
- const force = args.has("--force");
63
- const base = join(await chooseRoot(), ".claude");
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") bad++;
87
- console.log(`${state === "linked" ? "ok " : "MISS"} ${sub}/${basename(src)}${state === "linked" ? "" : ` (${state})`}`);
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 ${sub}/${basename(src)}`); continue; }
104
+ if (state === "linked") { already++; if (verbose) console.log(c.dim(` ok ${name}`)); continue; }
91
105
  if (state === "occupied" && !force) {
92
- console.log(`skip ${sub}/${basename(src)} — real file present, use --force`);
93
- bad++;
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
- console.log(`link ${sub}/${basename(src)}`);
113
+ wrote++;
114
+ if (verbose) console.log(c.dim(` link ${name}`));
100
115
  }
101
116
 
102
117
  if (check) {
103
- console.log(bad ? `\n${bad} link(s) missing — run: bun run ${repo}/cli/install.ts` : "\nall linked");
104
- process.exit(bad ? 1 : 0);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Staged AI development workflow: plan, human approval, implement, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {