pi-kage 0.1.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/bin/kage.mjs +414 -0
  4. package/package.json +43 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kid7st
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # kage ðŸĨ·ïžˆå―ąåˆ†čšŦãŪčĄ“ïž‰
2
+
3
+ [![CI](https://github.com/kid7st/kage/actions/workflows/ci.yml/badge.svg)](https://github.com/kid7st/kage/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/pi-kage)](https://www.npmjs.com/package/pi-kage)
5
+ [![license](https://img.shields.io/npm/l/pi-kage)](./LICENSE)
6
+
7
+ A tiny CLI that casts the **Shadow Clone Jutsu (Kage Bunshin no Jutsu, å―ąåˆ†čšŦãŪ術)** on your git repo — copying it
8
+ into an isolated sibling folder, dropping you straight into [pi](https://github.com/earendil-works) to work in
9
+ parallel, then merging the session memory back when you're done.
10
+
11
+ > Shadow Clone Jutsu: spawn an independent physical copy that carries the original's memory and acts on its own;
12
+ > when it dispels, its memory flows back to the original.
13
+
14
+ ## Why
15
+
16
+ Running multiple agent sessions against the same repo at once → they edit the same files and collide on branches.
17
+ kage gives each parallel session its **own independent copy** of the repo — like a second engineer on a second
18
+ machine: separate working tree, separate commits/push/PR, merge on GitHub. On macOS APFS the copy is a `cp -c`
19
+ clonefile (copy-on-write): near-instant and space-free until files diverge, and it keeps `node_modules` / `.env` /
20
+ build caches intact, so the clone can build & test immediately.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm install -g pi-kage # then use `kage` anywhere
26
+ # or run without installing:
27
+ npx pi-kage
28
+ ```
29
+
30
+ From source:
31
+
32
+ ```bash
33
+ git clone https://github.com/kid7st/kage ~/coding/kage
34
+ cd ~/coding/kage && npm link
35
+ ```
36
+
37
+ Requires `git`, [`pi`](https://github.com/earendil-works), and Node â‰Ĩ 18 on your PATH.
38
+
39
+ ## Usage
40
+
41
+ ```bash
42
+ cd ~/code/my-app
43
+ kage # kage bunshin . → ../my-app--kage-<ts>, seed recent context, open pi -c
44
+ kage --name fix-login # name the clone folder: ../my-app--fix-login
45
+ kage /path/to/other-repo # clone a different repo (path defaults to cwd)
46
+ ```
47
+
48
+ `kage` copies the repo, **does not create a branch** (you/the agent branch yourself, like a real second machine),
49
+ seeds the clone's pi session with your **last 5 turns** of context, and launches `pi -c` inside the clone.
50
+ When you quit pi you're back in your original shell. Then:
51
+
52
+ ```bash
53
+ kage finish fix-login # safety-check → merge session memory back → delete the clone
54
+ kage list # list active clones of this repo
55
+ kage pull .env config/x # (run inside a clone) copy specific files back to the origin
56
+ ```
57
+
58
+ ### Commands
59
+
60
+ | Command | Where | What |
61
+ |---|---|---|
62
+ | `kage [path] [--name x] [--blank] [--recent N]` | origin repo | Copy repo to `../<repo>--<name>` (default name `kage-<ts>`), seed last N turns (default 5; `--blank` = none), launch `pi -c`. |
63
+ | `kage finish [name] [--force]` | origin (or inside clone) | Refuse if the clone has uncommitted / unpushed work (`--force` overrides), merge its session memory back (deduped), delete the clone. Auto-picks when there's one clone. |
64
+ | `kage list` | origin repo | List active clones. |
65
+ | `kage pull <path...>` | inside a clone | Copy specific files/dirs (even gitignored ones) back to the origin at the same relative path. |
66
+
67
+ ## Design invariants
68
+
69
+ 1. **Isolation** — the clone is a full independent copy (its own `.git`).
70
+ 2. **Code flows back only via git/PR** — kage never copies the clone's working tree onto the origin (that would
71
+ re-introduce the collisions it avoids). `finish` makes you commit + push first.
72
+ 3. **Memory flows via `~/.pi`** — context is seeded in on create, and merged back on finish. These are session
73
+ `.jsonl` files (not the working tree), so there's zero collision risk. The seeded prefix is **deduped** on the
74
+ way back (only the clone's new turns are kept), so you don't get two overlapping sessions.
75
+ 4. **The origin is read-only** to kage — it only copies out and writes session memory; it never touches the
76
+ origin's working tree.
77
+
78
+ ## Notes & caveats
79
+
80
+ - The copy is a snapshot of the origin's **current** state, including uncommitted changes.
81
+ - Context seed reads the origin's **most recent** session file. Use `--blank` if that's not the one you want.
82
+ - The clone stays on the origin's current branch — **create a feature branch before committing** (kage prints a reminder).
83
+ - `kage finish` deletes the clone; run it from the origin (after quitting pi), or from inside the clone (it'll tell you to `cd` back).
84
+ - **Submodule** repos: submodule `.git` pointers are absolute and break on copy — run `git submodule update` in the clone.
85
+ - Non-APFS / non-reflink filesystems fall back to a full (heavier) copy.
86
+ - Session storage is assumed at `~/.pi/agent/sessions`; override with `KAGE_SESSIONS_DIR`.
87
+
88
+ ## License
89
+
90
+ MIT
package/bin/kage.mjs ADDED
@@ -0,0 +1,414 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * kage ðŸĨ· — cast the Shadow Clone Jutsu on a git repo.
4
+ *
5
+ * Copy the current repo into an isolated sibling folder (its own working tree and .git),
6
+ * drop straight into `pi` to work in parallel, then `kage finish` merges the session memory
7
+ * back into the original and deletes the clone.
8
+ *
9
+ * Design invariants:
10
+ * 1. Isolation — a clone is a full independent copy (its own .git).
11
+ * 2. Code flows back only via git/PR — kage never copies the working tree back onto the origin.
12
+ * 3. Memory flows via ~/.pi — recent context is seeded in on create and merged back on finish
13
+ * (deduped). These are session .jsonl files, not the working tree, so there's no collision.
14
+ * 4. The origin is read-only to kage — it only copies out and writes session memory.
15
+ *
16
+ * Commands:
17
+ * kage [path] [--name x] [--blank] [--recent N] clone repo + launch pi (path defaults to cwd)
18
+ * kage finish [name] [--force] check -> merge memory back -> delete clone
19
+ * kage list list clones of the current repo
20
+ * kage pull <path...> (inside a clone) copy files back to the origin
21
+ */
22
+
23
+ import { spawnSync } from "node:child_process";
24
+ import { randomUUID } from "node:crypto";
25
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { basename, dirname, join, resolve, sep } from "node:path";
28
+
29
+ const MARKER = ".kage.json";
30
+ const SESSIONS = process.env.KAGE_SESSIONS_DIR || join(homedir(), ".pi", "agent", "sessions");
31
+
32
+ // ── helpers ────────────────────────────────────────────────────────────────
33
+ function sh(cmd, args, opts = {}) {
34
+ const r = spawnSync(cmd, args, { encoding: "utf8", ...opts });
35
+ return { ok: r.status === 0, out: (r.stdout || "").trim(), err: (r.stderr || "").trim(), code: r.status };
36
+ }
37
+ const git = (cwd, args) => sh("git", args, { cwd });
38
+ const die = (msg) => {
39
+ console.error(`✗ ${msg}`);
40
+ process.exit(1);
41
+ };
42
+ const info = (msg) => console.error(msg);
43
+
44
+ /** Absolute path -> pi's session dir name: /a/b -> --a-b-- */
45
+ const encodeCwd = (abs) => `--${abs.replace(/^\//, "").replace(/\//g, "-")}--`;
46
+ const sessionDirFor = (repoAbs) => join(SESSIONS, encodeCwd(repoAbs));
47
+
48
+ function repoTopLevel(cwd) {
49
+ const r = git(cwd, ["rev-parse", "--show-toplevel"]);
50
+ return r.ok ? r.out : undefined;
51
+ }
52
+
53
+ function readMarker(dir) {
54
+ const p = join(dir, MARKER);
55
+ if (!existsSync(p)) return undefined;
56
+ try {
57
+ return JSON.parse(readFileSync(p, "utf8"));
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ }
62
+
63
+ function mtime(p) {
64
+ try {
65
+ return statSync(p).mtimeMs;
66
+ } catch {
67
+ return 0;
68
+ }
69
+ }
70
+
71
+ /** Copy a whole directory: clonefile on macOS, reflink on Linux, plain copy as fallback. */
72
+ function copyTree(src, dst) {
73
+ const isMac = process.platform === "darwin";
74
+ let r = sh("cp", isMac ? ["-c", "-R", src, dst] : ["--reflink=auto", "-R", src, dst]);
75
+ if (!r.ok) r = sh("cp", ["-R", src, dst]);
76
+ return r;
77
+ }
78
+
79
+ function tsName() {
80
+ const d = new Date();
81
+ const p = (n) => String(n).padStart(2, "0");
82
+ return `kage-${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
83
+ }
84
+
85
+ function parseArgs(argv) {
86
+ const positional = [];
87
+ const flags = {};
88
+ for (let i = 0; i < argv.length; i++) {
89
+ const a = argv[i];
90
+ if (a.startsWith("--")) {
91
+ const eq = a.indexOf("=");
92
+ if (eq >= 0) flags[a.slice(2, eq)] = a.slice(eq + 1);
93
+ else if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) flags[a.slice(2)] = argv[++i];
94
+ else flags[a.slice(2)] = true;
95
+ } else positional.push(a);
96
+ }
97
+ return { positional, flags };
98
+ }
99
+
100
+ // ── seed: write the origin's last N turns into the clone's session dir ───────
101
+ /** Returns { seedFile, seedLeafId } or undefined when seeding isn't possible. */
102
+ function seedSession(originRepo, cloneDir, recentTurns) {
103
+ const srcDir = sessionDirFor(originRepo);
104
+ if (!existsSync(srcDir)) return undefined;
105
+ const files = readdirSync(srcDir)
106
+ .filter((f) => f.endsWith(".jsonl"))
107
+ .map((f) => ({ f, m: mtime(join(srcDir, f)) }))
108
+ .sort((a, b) => b.m - a.m);
109
+ if (files.length === 0) return undefined;
110
+ const srcFile = join(srcDir, files[0].f);
111
+
112
+ const lines = readFileSync(srcFile, "utf8").split("\n").filter((l) => l.trim());
113
+ if (lines.length < 2) return undefined;
114
+ const entries = lines.slice(1).map((l) => JSON.parse(l));
115
+ const byId = new Map(entries.map((e) => [e.id, e]));
116
+
117
+ // Walk from the last entry up via parentId to get the current branch in chronological order.
118
+ let cur = entries[entries.length - 1];
119
+ const branch = [];
120
+ while (cur) {
121
+ branch.unshift(cur);
122
+ cur = cur.parentId ? byId.get(cur.parentId) : undefined;
123
+ }
124
+ const messages = branch.filter((e) => e.type === "message");
125
+ if (messages.length === 0) return undefined;
126
+
127
+ const userIdx = [];
128
+ messages.forEach((e, i) => {
129
+ if (e.message?.role === "user") userIdx.push(i);
130
+ });
131
+ const start = userIdx.length > recentTurns ? userIdx[userIdx.length - recentTurns] : 0;
132
+ const kept = messages.slice(start);
133
+
134
+ const destDir = sessionDirFor(cloneDir);
135
+ mkdirSync(destDir, { recursive: true });
136
+ const id = randomUUID();
137
+ const ts = new Date().toISOString();
138
+ const fname = `${ts.replace(/[:.]/g, "-")}_${id}.jsonl`;
139
+ const header = { type: "session", version: 3, id, timestamp: ts, cwd: cloneDir, parentSession: srcFile };
140
+ const out = [JSON.stringify(header)];
141
+ let prev = null;
142
+ for (const e of kept) {
143
+ out.push(JSON.stringify({ ...e, parentId: prev }));
144
+ prev = e.id;
145
+ }
146
+ writeFileSync(join(destDir, fname), out.join("\n") + "\n");
147
+ return { seedFile: fname, seedLeafId: prev };
148
+ }
149
+
150
+ // ── merge session memory back (deduped) ─────────────────────────────────────
151
+ /** Copy the clone's session dir back to the origin; strip the seeded prefix via seedLeafId. */
152
+ function mergeBack(cloneDir, originRepo, marker) {
153
+ const srcDir = sessionDirFor(cloneDir);
154
+ if (!existsSync(srcDir)) return 0;
155
+ const destDir = sessionDirFor(originRepo);
156
+ mkdirSync(destDir, { recursive: true });
157
+ let n = 0;
158
+ for (const f of readdirSync(srcDir)) {
159
+ if (!f.endsWith(".jsonl")) continue;
160
+ const dest = join(destDir, f);
161
+ if (existsSync(dest)) continue;
162
+ const lines = readFileSync(join(srcDir, f), "utf8").split("\n").filter((l) => l.trim());
163
+ if (lines.length === 0) continue;
164
+ let header;
165
+ try {
166
+ header = JSON.parse(lines[0]);
167
+ } catch {
168
+ continue;
169
+ }
170
+ header.cwd = originRepo;
171
+ let body = lines.slice(1);
172
+
173
+ // Dedupe: if this is the seeded session, drop everything up to and including seedLeafId.
174
+ if (marker?.seedFile === f && marker?.seedLeafId) {
175
+ const idx = body.findIndex((l) => {
176
+ try {
177
+ return JSON.parse(l).id === marker.seedLeafId;
178
+ } catch {
179
+ return false;
180
+ }
181
+ });
182
+ if (idx >= 0) {
183
+ body = body.slice(idx + 1);
184
+ if (body.length === 0) continue; // clone added nothing on top of the seed
185
+ const first = JSON.parse(body[0]);
186
+ first.parentId = null; // re-root
187
+ body[0] = JSON.stringify(first);
188
+ }
189
+ // seedLeafId not found -> copy the whole thing (safe fallback, may overlap)
190
+ }
191
+ writeFileSync(dest, [JSON.stringify(header), ...body].join("\n") + "\n");
192
+ n++;
193
+ }
194
+ // Remove the clone's now-orphaned session dir under ~/.pi (pi has exited, safe to delete).
195
+ try {
196
+ rmSync(srcDir, { recursive: true, force: true });
197
+ } catch {
198
+ /* ignore */
199
+ }
200
+ return n;
201
+ }
202
+
203
+ // ── subcommands ─────────────────────────────────────────────────────────────
204
+ function cmdNew(argv) {
205
+ const { positional, flags } = parseArgs(argv);
206
+ const targetPath = positional[0] ? resolve(positional[0]) : process.cwd();
207
+ const blank = !!flags.blank;
208
+ const recent = Math.max(1, parseInt(flags.recent, 10) || 5);
209
+
210
+ const repoRoot = repoTopLevel(targetPath);
211
+ if (!repoRoot) die(`not a git repository: ${targetPath}`);
212
+ if (existsSync(join(repoRoot, MARKER))) die("already inside a clone; run kage from the origin repo");
213
+
214
+ const name = (typeof flags.name === "string" && flags.name) || tsName();
215
+ const safe = name.replace(/\//g, "-");
216
+ const cloneDir = join(dirname(repoRoot), `${basename(repoRoot)}--${safe}`);
217
+ if (existsSync(cloneDir)) die(`directory already exists: ${cloneDir}`);
218
+
219
+ const cp = copyTree(repoRoot, cloneDir);
220
+ if (!cp.ok) die(`copy failed: ${cp.err}`);
221
+
222
+ // Note: kage does NOT create a branch. The clone stays on the origin's current branch,
223
+ // just like a fresh checkout on a second machine; you/the agent branch yourself.
224
+
225
+ const seed = blank ? undefined : seedSession(repoRoot, cloneDir, recent);
226
+ const marker = {
227
+ originRepo: repoRoot,
228
+ name: safe,
229
+ createdAt: new Date().toISOString(),
230
+ seedFile: seed?.seedFile,
231
+ seedLeafId: seed?.seedLeafId,
232
+ };
233
+ writeFileSync(join(cloneDir, MARKER), JSON.stringify(marker, null, 2));
234
+
235
+ const curBranch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out || "?";
236
+ info("");
237
+ info(`ðŸĨ· Shadow clone ready: ${cloneDir}`);
238
+ info(` origin: ${repoRoot} branch: ${curBranch}`);
239
+ info(seed ? ` seeded with the origin's last ${recent} turns (pi -c resumes them)` : ` blank clone (no context)`);
240
+ info(` ⚠ïļ create a feature branch before committing (the clone is on ${curBranch})`);
241
+ info(` when done, from the origin run: kage finish ${safe}`);
242
+ info("");
243
+
244
+ // Launch pi (resume the seeded session with -c). Returns to your shell when pi exits.
245
+ const piArgs = seed ? ["-c"] : [];
246
+ const r = spawnSync("pi", piArgs, { cwd: cloneDir, stdio: "inherit" });
247
+ if (r.error) {
248
+ if (r.error.code === "ENOENT") die("pi not found (make sure it is installed and on your PATH)");
249
+ die(`failed to launch pi: ${r.error.message}`);
250
+ }
251
+ info("");
252
+ info(`â†ĐïļŽ left the clone's pi. To finish: kage finish ${safe}`);
253
+ }
254
+
255
+ function cmdFinish(argv) {
256
+ const { positional, flags } = parseArgs(argv);
257
+ const force = !!flags.force;
258
+
259
+ // Locate the clone: either we're inside it, or we're in the origin (by name or uniqueness).
260
+ const here = repoTopLevel(process.cwd());
261
+ let cloneDir, originRepo, marker;
262
+ const hereMarker = here && readMarker(here);
263
+ if (hereMarker) {
264
+ cloneDir = here;
265
+ marker = hereMarker;
266
+ originRepo = marker.originRepo;
267
+ } else {
268
+ if (!here) die("not a git repository");
269
+ originRepo = here;
270
+ const clones = listClones(originRepo);
271
+ if (clones.length === 0) die("no shadow clones found for this repo");
272
+ const pick = positional[0]
273
+ ? clones.find((c) => c.name === positional[0] || basename(c.dir) === positional[0])
274
+ : clones.length === 1
275
+ ? clones[0]
276
+ : undefined;
277
+ if (!pick) {
278
+ info("multiple clones — specify a name:");
279
+ clones.forEach((c) => info(` ${c.name}`));
280
+ process.exit(1);
281
+ }
282
+ cloneDir = pick.dir;
283
+ marker = pick.marker;
284
+ }
285
+
286
+ // Safety checks (fail visibly; don't silently delete work).
287
+ if (!force) {
288
+ const status = git(cloneDir, ["status", "--porcelain"]);
289
+ const dirty = status.out.split("\n").filter((l) => l.trim() && l.slice(3).trim() !== MARKER);
290
+ if (dirty.length > 0) die("clone has uncommitted changes; commit them or pass --force");
291
+ const up = git(cloneDir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
292
+ if (!up.ok) die("clone's branch has no upstream (not pushed); push it or pass --force");
293
+ const ahead = git(cloneDir, ["rev-list", "@{u}..HEAD", "--count"]);
294
+ if (ahead.ok && ahead.out !== "0") die(`clone has ${ahead.out} unpushed commit(s); push them or pass --force`);
295
+ }
296
+
297
+ const n = mergeBack(cloneDir, originRepo, marker);
298
+
299
+ // Delete the clone (move out of it first so we don't delete our own cwd).
300
+ try {
301
+ process.chdir(originRepo);
302
+ } catch {
303
+ /* ignore */
304
+ }
305
+ rmSync(cloneDir, { recursive: true, force: true });
306
+
307
+ info(`ðŸ’Ļ Clone dispelled: merged ${n} session(s) back, removed ${cloneDir}`);
308
+ if (hereMarker) info(` your shell is still in the deleted dir; cd back to: ${originRepo}`);
309
+ }
310
+
311
+ function listClones(originRepo) {
312
+ const parent = dirname(originRepo);
313
+ const out = [];
314
+ for (const name of readdirSync(parent)) {
315
+ const dir = join(parent, name);
316
+ const m = readMarker(dir);
317
+ if (m && m.originRepo === originRepo) out.push({ dir, name: m.name || basename(dir), marker: m });
318
+ }
319
+ return out;
320
+ }
321
+
322
+ function cmdList() {
323
+ const repoRoot = repoTopLevel(process.cwd());
324
+ if (!repoRoot) die("not a git repository");
325
+ const clones = listClones(repoRoot);
326
+ if (clones.length === 0) {
327
+ info("No shadow clones.");
328
+ return;
329
+ }
330
+ info("Shadow clones:");
331
+ for (const c of clones) {
332
+ const br = git(c.dir, ["rev-parse", "--abbrev-ref", "HEAD"]).out || "?";
333
+ info(` ${c.name} [${br}] ${c.dir}`);
334
+ }
335
+ }
336
+
337
+ function cmdPull(argv) {
338
+ const { positional } = parseArgs(argv);
339
+ const cloneDir = repoTopLevel(process.cwd());
340
+ const marker = cloneDir && readMarker(cloneDir);
341
+ if (!marker) die("kage pull only runs inside a clone (edit the origin directly otherwise)");
342
+ if (positional.length === 0) die("usage: kage pull <relative-path> [more paths...]");
343
+ const originRepo = marker.originRepo;
344
+ const cloneRoot = cloneDir.endsWith(sep) ? cloneDir : cloneDir + sep;
345
+ const originRoot = originRepo.endsWith(sep) ? originRepo : originRepo + sep;
346
+ let done = 0;
347
+ for (const rel of positional) {
348
+ const src = resolve(cloneDir, rel);
349
+ const dst = resolve(originRepo, rel);
350
+ if (!src.startsWith(cloneRoot) || !dst.startsWith(originRoot)) {
351
+ info(`✗ path escapes the repo, skipped: ${rel}`);
352
+ continue;
353
+ }
354
+ if (!existsSync(src)) {
355
+ info(`✗ not found in clone, skipped: ${rel}`);
356
+ continue;
357
+ }
358
+ if (existsSync(dst)) rmSync(dst, { recursive: true, force: true });
359
+ mkdirSync(dirname(dst), { recursive: true });
360
+ const cp = copyTree(src, dst);
361
+ if (!cp.ok) {
362
+ info(`✗ copy failed ${rel}: ${cp.err}`);
363
+ continue;
364
+ }
365
+ done++;
366
+ }
367
+ info(`ðŸ“Ī Pulled ${done}/${positional.length} path(s) from the clone back to the origin (${originRepo})`);
368
+ }
369
+
370
+ const HELP = `kage ðŸĨ· — Shadow Clone Jutsu for your git repo
371
+
372
+ Usage:
373
+ kage [path] [--name <x>] [--blank] [--recent <N>] clone repo + launch pi (path defaults to cwd)
374
+ kage finish [name] [--force] check -> merge memory back -> delete clone
375
+ kage list list clones of the current repo
376
+ kage pull <path...> (inside a clone) copy files back to the origin
377
+
378
+ Options:
379
+ --name <x> name the clone folder/<repo>--<x> (default: kage-<timestamp>)
380
+ --blank don't seed the clone with the origin's recent context
381
+ --recent <N> number of recent turns to seed (default: 5)
382
+ --force skip the uncommitted/unpushed safety check in finish
383
+
384
+ Env:
385
+ KAGE_SESSIONS_DIR pi session storage (default: ~/.pi/agent/sessions)`;
386
+
387
+ // ── entry ───────────────────────────────────────────────────────────────────
388
+ function main() {
389
+ const [sub, ...rest] = process.argv.slice(2);
390
+ switch (sub) {
391
+ case undefined:
392
+ case "new":
393
+ return cmdNew(sub === "new" ? rest : process.argv.slice(2));
394
+ case "finish":
395
+ return cmdFinish(rest);
396
+ case "list":
397
+ return cmdList();
398
+ case "pull":
399
+ return cmdPull(rest);
400
+ case "-h":
401
+ case "--help":
402
+ return info(HELP);
403
+ case "-v":
404
+ case "--version": {
405
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
406
+ return info(pkg.version);
407
+ }
408
+ default:
409
+ // Unknown subcommand -> treat as `kage <path>`.
410
+ return cmdNew(process.argv.slice(2));
411
+ }
412
+ }
413
+
414
+ main();
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "pi-kage",
3
+ "version": "0.1.0",
4
+ "description": "ðŸĨ· Shadow Clone Jutsu for your git repo: copy it into an isolated folder, work in parallel with pi, then merge the session memory back",
5
+ "keywords": [
6
+ "pi",
7
+ "coding-agent",
8
+ "ai",
9
+ "cli",
10
+ "git",
11
+ "parallel",
12
+ "worktree",
13
+ "developer-tools"
14
+ ],
15
+ "homepage": "https://github.com/kid7st/kage#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/kid7st/kage/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/kid7st/kage.git"
22
+ },
23
+ "license": "MIT",
24
+ "author": "kid7st (https://github.com/kid7st)",
25
+ "type": "module",
26
+ "bin": {
27
+ "kage": "bin/kage.mjs"
28
+ },
29
+ "files": [
30
+ "bin/"
31
+ ],
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "publishConfig": {
36
+ "registry": "https://registry.npmjs.org/"
37
+ },
38
+ "scripts": {
39
+ "test": "node --test",
40
+ "lint": "node --check bin/kage.mjs",
41
+ "prepublishOnly": "npm run lint && npm test"
42
+ }
43
+ }