pi-kage 0.1.0 โ†’ 0.2.1

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 +134 -44
  2. package/bin/kage.mjs +442 -144
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,90 +1,180 @@
1
- # kage ๐Ÿฅท๏ผˆๅฝฑๅˆ†่บซใฎ่ก“๏ผ‰
1
+ # kage ๐Ÿฅท
2
2
 
3
3
  [![CI](https://github.com/kid7st/kage/actions/workflows/ci.yml/badge.svg)](https://github.com/kid7st/kage/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/pi-kage)](https://www.npmjs.com/package/pi-kage)
5
5
  [![license](https://img.shields.io/npm/l/pi-kage)](./LICENSE)
6
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.
7
+ > **ๅฝฑๅˆ†่บซใฎ่ก“** โ€” cast the **Shadow Clone Jutsu** on your git repo.
10
8
 
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.
9
+ <p align="center"><img src="./assets/demo.svg" alt="kage demo" width="100%"></p>
13
10
 
14
- ## Why
11
+ `kage` copies your repo into an isolated sibling folder, drops you straight into
12
+ [pi](https://github.com/earendil-works) to work in parallel, and when you're done merges the
13
+ session memory back into the original and dispels the clone.
15
14
 
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.
15
+ ```bash
16
+ npm install -g pi-kage
17
+ cd my-app
18
+ kage # ๐Ÿฅท clone โ†’ ../my-app--kage-<ts>, open pi with your recent context
19
+ # ...work in the clone: commit, push, open a PR, quit pi...
20
+ kage finish # ๐Ÿ’จ merge the session memory back, delete the clone
21
+ ```
22
+
23
+ ---
24
+
25
+ ## The problem
26
+
27
+ Running **multiple agent sessions on the same repo at once** is a mess: they edit the same files,
28
+ fight over the working tree, and collide on branches. You end up babysitting merge conflicts
29
+ instead of shipping.
30
+
31
+ ## The idea
32
+
33
+ A shadow clone is a **full, independent copy** of the repo โ€” like a second engineer on a second
34
+ machine. Each parallel session gets its own working tree, its own branch, its own commits and PR.
35
+ Code merges the normal way: on GitHub. No local collisions, ever.
36
+
37
+ And like a real Naruto shadow clone, it **carries your memory out** (the clone's pi session is
38
+ seeded with your recent conversation) and **returns it on dispel** (the clone's session is merged
39
+ back into the original when you `finish`).
40
+
41
+ Why a full folder copy instead of `git worktree`? A worktree shares one `.git`, which means you
42
+ can't check out the same branch twice, you share stash/refs, and you get a *fresh* checkout with no
43
+ `node_modules` / `.env` / build cache. A real copy avoids all of that. On macOS APFS the copy is a
44
+ `cp -c` clonefile (copy-on-write): near-instant and space-free until files diverge.
21
45
 
22
46
  ## Install
23
47
 
24
48
  ```bash
49
+ # npm
25
50
  npm install -g pi-kage # then use `kage` anywhere
26
- # or run without installing:
27
- npx pi-kage
51
+ npx pi-kage # or run without installing
52
+
53
+ # or install script (no npm needed โ€” kage is a single, zero-dependency Node script)
54
+ curl -fsSL https://raw.githubusercontent.com/kid7st/kage/main/install.sh | sh
28
55
  ```
29
56
 
57
+ The install script drops the single `kage` file into `~/.local/bin` (override with `KAGE_BIN_DIR`,
58
+ pin a version with `KAGE_VERSION`). kage has **no dependencies** โ€” it only needs Node, git, and pi.
59
+
30
60
  From source:
31
61
 
32
62
  ```bash
33
- git clone https://github.com/kid7st/kage ~/coding/kage
34
- cd ~/coding/kage && npm link
63
+ git clone https://github.com/kid7st/kage
64
+ cd kage && npm link
35
65
  ```
36
66
 
37
- Requires `git`, [`pi`](https://github.com/earendil-works), and Node โ‰ฅ 18 on your PATH.
67
+ Requires **git**, [**pi**](https://github.com/earendil-works), and **Node โ‰ฅ 18** on your `PATH`.
68
+
69
+ ## Lifecycle
70
+
71
+ ```
72
+ origin repo (you) shadow clone (independent copy)
73
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
74
+ $ kage --name fix-login โ”€โ”€copy + seedโ”€โ”€โ–บ ../my-app--fix-login
75
+ $ pi -c (your recent context, resumed)
76
+ ยท git switch -c fix-login
77
+ ยท edit / commit / push / open PR
78
+ ยท quit pi
79
+ $ kage finish fix-login โ—„โ”€โ”€merge memoryโ”€โ”€ (session .jsonl, deduped)
80
+ ยท safety check (committed? pushed?)
81
+ ยท merge session back into ~/.pi
82
+ ยท delete the clone folder
83
+ code arrives via the merged GitHub PR โœ“
84
+ ```
38
85
 
39
86
  ## Usage
40
87
 
41
88
  ```bash
42
89
  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
90
+
91
+ kage # clone . โ†’ ../my-app--kage-<ts>, seed recent context, open `pi -c`
92
+ kage --name fix-login # name the clone folder/branch suffix: ../my-app--fix-login
45
93
  kage /path/to/other-repo # clone a different repo (path defaults to cwd)
94
+ kage --blank # don't carry any context into the clone
95
+ kage --recent 10 # seed the last 10 turns instead of the default 5
96
+
97
+ # back in the origin after you quit the clone's pi:
98
+ kage # no args inside a repo with clones -> interactive menu
99
+ kage list # status dashboard: branch ยท dirty ยท ahead/behind ยท safe-to-clean
100
+ kage list --pr # also show PR state (via gh)
101
+ kage finish fix-login # check โ†’ merge memory back โ†’ delete the clone
102
+ kage finish fix-login --pr # push the branch + open a PR (via gh), then finish
103
+ kage finish --force # skip the uncommitted/unpushed guard
104
+ kage rm old-experiment # discard a clone without merging (refuses if it has local-only work)
105
+
106
+ # inside a clone, to retrieve a non-git file (e.g. a generated .env):
107
+ kage pull .env config/local.json
46
108
  ```
47
109
 
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:
110
+ With no arguments inside a repo that already has clones, `kage` shows an interactive picker: create a
111
+ new clone, or select an existing one to **enter** (`pi -c`), **finish**, or **remove**. `finish` and `rm`
112
+ show the same picker when you have multiple clones and don't name one.
113
+
114
+ ### Shell integration (optional)
51
115
 
52
116
  ```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
117
+ eval "$(kage shell-init)" # add to ~/.zshrc or ~/.bashrc
56
118
  ```
57
119
 
120
+ This wraps `kage` so that `finish`/`rm` run from inside a clone **cd you back to the origin**
121
+ automatically (a CLI can't change its parent shell's directory otherwise), and adds tab completion
122
+ for subcommands and clone names.
123
+
58
124
  ### Commands
59
125
 
60
- | Command | Where | What |
126
+ | Command | Run from | What it does |
61
127
  |---|---|---|
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. |
128
+ | `kage [path] [--name x] [--blank] [--recent N]` | origin repo | Copy the repo to `../<repo>--<name>` (default `kage-<ts>`), seed the clone's pi session with the last N turns (default 5; `--blank` for none), and launch `pi -c`. With no args (and existing clones) it opens an interactive picker. |
129
+ | `kage list [--pr]` | origin repo | Status dashboard of clones: branch, dirty/clean, ahead/behind upstream, and a โ€œsafe to cleanโ€ flag. `--pr` adds PR state via `gh`. |
130
+ | `kage finish [name] [--force] [--push] [--pr]` | origin (or inside the clone) | Refuse if the clone has uncommitted or unpushed work (`--force` overrides), merge its session memory back (deduped), then delete the clone. `--push` pushes the branch first; `--pr` pushes and opens a PR via `gh`. Auto-selects / prompts when there are several. |
131
+ | `kage rm [name] [--force]` | origin (or inside the clone) | Discard a clone **without** merging memory. Refuses if it has local-only work unless `--force`. For abandoned experiments. |
65
132
  | `kage pull <path...>` | inside a clone | Copy specific files/dirs (even gitignored ones) back to the origin at the same relative path. |
133
+ | `kage shell-init` | shell rc | Print a shell wrapper (cd-back after `finish`/`rm`) + tab completion. Use `eval "$(kage shell-init)"`. |
134
+ | `kage --help` / `--version` | anywhere | Usage / version. |
66
135
 
67
- ## Design invariants
136
+ ## How it works
68
137
 
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.
138
+ Four invariants keep parallel work safe and lossless:
139
+
140
+ 1. **Isolation** โ€” a clone is a full independent copy with its own `.git`.
141
+ 2. **Code flows back only via git/PR.** kage never copies the clone's working tree onto the origin โ€”
142
+ that would re-create the very collisions it avoids. `finish` makes you commit + push first.
143
+ 3. **Memory flows through `~/.pi`.** Context is *seeded in* on create and *merged back* on finish.
144
+ These are pi session `.jsonl` files (not the working tree), so there's zero collision risk. The
145
+ seeded prefix is **deduped** on the way back โ€” only the clone's new turns are kept, so you don't
146
+ end up with two overlapping sessions.
147
+ 4. **The origin is read-only to kage** โ€” it only copies out and writes session memory; it never
148
+ touches the origin's working tree, even while another session is live there.
77
149
 
78
150
  ## Notes & caveats
79
151
 
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.
152
+ - The copy is a snapshot of the origin's **current** state, **including uncommitted changes**.
153
+ - kage **doesn't create a branch** โ€” the clone stays on the origin's current branch. To keep the agent
154
+ from committing to it, kage injects a short in-context reminder into the clone's session, so the
155
+ agent itself is told to branch first (this reminder is deduped out when memory merges back).
156
+ - Context seeding reads the origin's **most recent** session file. Pass `--blank` if that isn't the
157
+ one you want carried over.
158
+ - **Submodules**: a submodule's `.git` pointer is an absolute path and breaks on copy โ€” run
159
+ `git submodule update --init` in the clone.
85
160
  - Non-APFS / non-reflink filesystems fall back to a full (heavier) copy.
86
161
  - Session storage is assumed at `~/.pi/agent/sessions`; override with `KAGE_SESSIONS_DIR`.
87
162
 
163
+ ## Development
164
+
165
+ ```bash
166
+ npm run lint # syntax check
167
+ npm test # node:test smoke tests (temp repos, no network)
168
+ ```
169
+
170
+ Releases publish automatically: bump `version` in `package.json`, then
171
+
172
+ ```bash
173
+ git tag vX.Y.Z && git push origin main vX.Y.Z
174
+ ```
175
+
176
+ CI runs lint + tests and `npm publish --provenance` on any `v*` tag.
177
+
88
178
  ## License
89
179
 
90
- MIT
180
+ [MIT](./LICENSE)
package/bin/kage.mjs CHANGED
@@ -14,9 +14,10 @@
14
14
  * 4. The origin is read-only to kage โ€” it only copies out and writes session memory.
15
15
  *
16
16
  * Commands:
17
- * kage [path] [--name x] [--blank] [--recent N] clone repo + launch pi (path defaults to cwd)
17
+ * kage [path] [--name x] [--blank] [--recent N] clone repo + launch pi (no args: interactive)
18
+ * kage list [--pr] dashboard of clones (+ PR status via gh)
18
19
  * kage finish [name] [--force] check -> merge memory back -> delete clone
19
- * kage list list clones of the current repo
20
+ * kage rm [name] [--force] discard a clone (no merge)
20
21
  * kage pull <path...> (inside a clone) copy files back to the origin
21
22
  */
22
23
 
@@ -25,21 +26,37 @@ import { randomUUID } from "node:crypto";
25
26
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
26
27
  import { homedir } from "node:os";
27
28
  import { basename, dirname, join, resolve, sep } from "node:path";
29
+ import readline from "node:readline";
28
30
 
31
+ const VERSION = "0.2.1"; // keep in sync with package.json (enforced by test)
29
32
  const MARKER = ".kage.json";
30
33
  const SESSIONS = process.env.KAGE_SESSIONS_DIR || join(homedir(), ".pi", "agent", "sessions");
31
34
 
32
- // โ”€โ”€ helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
35
+ // โ”€โ”€ output helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
36
+ const TTY = process.stderr.isTTY;
37
+ const col = (code, s) => (TTY ? `\x1b[${code}m${s}\x1b[0m` : s);
38
+ const paint = {
39
+ bold: (s) => col("1", s),
40
+ dim: (s) => col("90", s),
41
+ red: (s) => col("31", s),
42
+ green: (s) => col("32", s),
43
+ yellow: (s) => col("33", s),
44
+ blue: (s) => col("34", s),
45
+ magenta: (s) => col("35", s),
46
+ cyan: (s) => col("36", s),
47
+ };
48
+ const info = (msg) => console.error(msg);
49
+ const die = (msg) => {
50
+ console.error(`โœ— ${msg}`);
51
+ process.exit(1);
52
+ };
53
+
54
+ // โ”€โ”€ shell / git helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
33
55
  function sh(cmd, args, opts = {}) {
34
56
  const r = spawnSync(cmd, args, { encoding: "utf8", ...opts });
35
57
  return { ok: r.status === 0, out: (r.stdout || "").trim(), err: (r.stderr || "").trim(), code: r.status };
36
58
  }
37
59
  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
60
 
44
61
  /** Absolute path -> pi's session dir name: /a/b -> --a-b-- */
45
62
  const encodeCwd = (abs) => `--${abs.replace(/^\//, "").replace(/\//g, "-")}--`;
@@ -97,58 +114,200 @@ function parseArgs(argv) {
97
114
  return { positional, flags };
98
115
  }
99
116
 
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;
117
+ // โ”€โ”€ clone discovery & status โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
118
+ function listClones(originRepo) {
119
+ const parent = dirname(originRepo);
120
+ const out = [];
121
+ for (const name of readdirSync(parent)) {
122
+ const dir = join(parent, name);
123
+ const m = readMarker(dir);
124
+ if (m && m.originRepo === originRepo) out.push({ dir, name: m.name || basename(dir), marker: m });
125
+ }
126
+ return out;
127
+ }
128
+
129
+ function cloneStatus(dir) {
130
+ const branch = git(dir, ["rev-parse", "--abbrev-ref", "HEAD"]).out || "?";
131
+ const st = git(dir, ["status", "--porcelain"]).out;
132
+ const dirty = st.split("\n").some((l) => l.trim() && l.slice(3).trim() !== MARKER);
133
+ const up = git(dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
134
+ let ahead = 0;
135
+ let behind = 0;
136
+ if (up.ok) {
137
+ const rl = git(dir, ["rev-list", "--left-right", "--count", "@{u}...HEAD"]);
138
+ if (rl.ok) {
139
+ const [b, a] = rl.out.split(/\s+/).map(Number);
140
+ behind = b || 0;
141
+ ahead = a || 0;
142
+ }
143
+ }
144
+ return { branch, dirty, ahead, behind, hasUpstream: up.ok };
145
+ }
146
+
147
+ /** Best-effort PR lookup via gh; returns { state, number, url } or undefined. */
148
+ function prInfo(dir, branch) {
149
+ const r = sh("gh", ["pr", "view", branch, "--json", "state,number,url"], { cwd: dir });
150
+ if (!r.ok) return undefined;
151
+ try {
152
+ return JSON.parse(r.out);
153
+ } catch {
154
+ return undefined;
123
155
  }
124
- const messages = branch.filter((e) => e.type === "message");
125
- if (messages.length === 0) return undefined;
156
+ }
126
157
 
127
- const userIdx = [];
128
- messages.forEach((e, i) => {
129
- if (e.message?.role === "user") userIdx.push(i);
158
+ /** True when the clone has no local-only work (clean + pushed) -> safe to remove. */
159
+ const isSafeToClean = (s) => !s.dirty && s.hasUpstream && s.ahead === 0;
160
+
161
+ // โ”€โ”€ interactive picker (TUI-lite, arrow keys, no deps) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
162
+ /** Returns the chosen index, or -1 when cancelled / non-interactive. */
163
+ function select(title, labels) {
164
+ return new Promise((resolve) => {
165
+ if (!process.stdin.isTTY || labels.length === 0) return resolve(-1);
166
+ let idx = 0;
167
+ const n = labels.length;
168
+ const out = process.stderr;
169
+ readline.emitKeypressEvents(process.stdin);
170
+ process.stdin.setRawMode(true);
171
+ process.stdin.resume();
172
+ out.write(`${title}\n`);
173
+ const draw = () =>
174
+ labels.forEach((l, i) => out.write(`\x1b[2K${i === idx ? paint.cyan("โฏ ") : " "}${l}\n`));
175
+ draw();
176
+ const done = (r) => {
177
+ process.stdin.removeListener("keypress", onKey);
178
+ process.stdin.setRawMode(false);
179
+ process.stdin.pause();
180
+ out.write("\n");
181
+ resolve(r);
182
+ };
183
+ const onKey = (str, key) => {
184
+ if (key.name === "up" || str === "k") idx = (idx - 1 + n) % n;
185
+ else if (key.name === "down" || str === "j") idx = (idx + 1) % n;
186
+ else if (key.name === "return") return done(idx);
187
+ else if (key.name === "escape" || str === "q" || (key.ctrl && key.name === "c")) return done(-1);
188
+ else return;
189
+ out.write(`\x1b[${n}A`);
190
+ draw();
191
+ };
192
+ process.stdin.on("keypress", onKey);
130
193
  });
131
- const start = userIdx.length > recentTurns ? userIdx[userIdx.length - recentTurns] : 0;
132
- const kept = messages.slice(start);
194
+ }
195
+
196
+ async function ask(prompt) {
197
+ if (!process.stdin.isTTY) return "";
198
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
199
+ const a = await new Promise((r) => rl.question(prompt, (x) => (rl.close(), r(x))));
200
+ return a.trim();
201
+ }
202
+
203
+ async function confirm(msg) {
204
+ if (!process.stdin.isTTY) return false;
205
+ return /^y(es)?$/i.test(await ask(`${msg} [y/N] `));
206
+ }
207
+
208
+ /** Resolve which clone to act on: inside a clone, by name, only-one, or interactive pick. */
209
+ async function pickClone(action, name) {
210
+ const here = repoTopLevel(process.cwd());
211
+ const hm = here && readMarker(here);
212
+ if (hm && !name) return { originRepo: hm.originRepo, clone: { dir: here, name: hm.name || basename(here), marker: hm } };
213
+ const originRepo = hm ? hm.originRepo : here;
214
+ if (!originRepo) die("not a git repository");
215
+ const clones = listClones(originRepo);
216
+ if (clones.length === 0) die("no shadow clones found for this repo");
217
+ if (name) {
218
+ const c = clones.find((x) => x.name === name || basename(x.dir) === name);
219
+ if (!c) die(`no clone named ${name}`);
220
+ return { originRepo, clone: c };
221
+ }
222
+ if (clones.length === 1) return { originRepo, clone: clones[0] };
223
+ const idx = await select(
224
+ `Multiple clones โ€” pick one to ${action}:`,
225
+ clones.map((c) => `${c.name} ${paint.dim(cloneStatus(c.dir).branch)}`),
226
+ );
227
+ if (idx < 0) return null;
228
+ return { originRepo, clone: clones[idx] };
229
+ }
230
+
231
+ // โ”€โ”€ build the clone's pi session: seeded context + a kage operational reminder โ”€โ”€
232
+ /**
233
+ * Writes the clone's session: the origin's last N turns (unless blank) followed by an
234
+ * in-context reminder telling the agent it's in a clone and to branch before committing.
235
+ * The reminder is the leaf and `seedLeafId` points at it, so the whole seeded prefix
236
+ * (context + reminder) is deduped out when memory merges back to the origin.
237
+ */
238
+ function buildCloneSession(originRepo, cloneDir, recentTurns, blank) {
239
+ let kept = [];
240
+ let turns = 0;
241
+ let preview;
242
+ let srcFile;
243
+
244
+ if (!blank) {
245
+ const srcDir = sessionDirFor(originRepo);
246
+ if (existsSync(srcDir)) {
247
+ const files = readdirSync(srcDir)
248
+ .filter((f) => f.endsWith(".jsonl"))
249
+ .map((f) => ({ f, m: mtime(join(srcDir, f)) }))
250
+ .sort((a, b) => b.m - a.m);
251
+ if (files.length) {
252
+ srcFile = join(srcDir, files[0].f);
253
+ const lines = readFileSync(srcFile, "utf8").split("\n").filter((l) => l.trim());
254
+ if (lines.length >= 2) {
255
+ const entries = lines.slice(1).map((l) => JSON.parse(l));
256
+ const byId = new Map(entries.map((e) => [e.id, e]));
257
+ let cur = entries[entries.length - 1];
258
+ const branch = [];
259
+ while (cur) {
260
+ branch.unshift(cur);
261
+ cur = cur.parentId ? byId.get(cur.parentId) : undefined;
262
+ }
263
+ const messages = branch.filter((e) => e.type === "message");
264
+ const userIdx = [];
265
+ messages.forEach((e, i) => {
266
+ if (e.message?.role === "user") userIdx.push(i);
267
+ });
268
+ const start = userIdx.length > recentTurns ? userIdx[userIdx.length - recentTurns] : 0;
269
+ kept = messages.slice(start);
270
+ turns = userIdx.length > recentTurns ? recentTurns : userIdx.length;
271
+ const fu = kept.find((e) => e.message?.role === "user");
272
+ preview = fu ? snippet(fu.message.content) : undefined;
273
+ }
274
+ }
275
+ }
276
+ }
133
277
 
134
278
  const destDir = sessionDirFor(cloneDir);
135
279
  mkdirSync(destDir, { recursive: true });
136
280
  const id = randomUUID();
137
281
  const ts = new Date().toISOString();
138
282
  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)];
283
+ const header = { type: "session", version: 3, id, timestamp: ts, cwd: cloneDir, ...(srcFile ? { parentSession: srcFile } : {}) };
284
+ const outl = [JSON.stringify(header)];
141
285
  let prev = null;
142
286
  for (const e of kept) {
143
- out.push(JSON.stringify({ ...e, parentId: prev }));
287
+ outl.push(JSON.stringify({ ...e, parentId: prev }));
144
288
  prev = e.id;
145
289
  }
146
- writeFileSync(join(destDir, fname), out.join("\n") + "\n");
147
- return { seedFile: fname, seedLeafId: prev };
290
+
291
+ const curBranch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out || "the base branch";
292
+ const hintId = randomUUID().replace(/-/g, "").slice(0, 8);
293
+ const hint =
294
+ `[kage] You are working in a shadow clone of ${originRepo} (folder: ${cloneDir}), ` +
295
+ `currently on branch "${curBranch}". Before committing, create a dedicated feature branch ` +
296
+ `(git switch -c <name>), then push it and open a PR โ€” do not commit directly to "${curBranch}".`;
297
+ outl.push(
298
+ JSON.stringify({ type: "custom_message", id: hintId, parentId: prev, timestamp: ts, customType: "kage", content: hint, display: true }),
299
+ );
300
+ writeFileSync(join(destDir, fname), outl.join("\n") + "\n");
301
+ return { seedFile: fname, seedLeafId: hintId, turns, preview, seeded: kept.length > 0 };
302
+ }
303
+
304
+ function snippet(content) {
305
+ const text = typeof content === "string" ? content : (content || []).map((c) => c.text || "").join(" ");
306
+ const one = text.replace(/\s+/g, " ").trim();
307
+ return one.length > 60 ? one.slice(0, 57) + "โ€ฆ" : one;
148
308
  }
149
309
 
150
- // โ”€โ”€ merge session memory back (deduped) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
151
- /** Copy the clone's session dir back to the origin; strip the seeded prefix via seedLeafId. */
310
+ // โ”€โ”€ merge session memory back (deduped) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
152
311
  function mergeBack(cloneDir, originRepo, marker) {
153
312
  const srcDir = sessionDirFor(cloneDir);
154
313
  if (!existsSync(srcDir)) return 0;
@@ -170,7 +329,6 @@ function mergeBack(cloneDir, originRepo, marker) {
170
329
  header.cwd = originRepo;
171
330
  let body = lines.slice(1);
172
331
 
173
- // Dedupe: if this is the seeded session, drop everything up to and including seedLeafId.
174
332
  if (marker?.seedFile === f && marker?.seedLeafId) {
175
333
  const idx = body.findIndex((l) => {
176
334
  try {
@@ -181,17 +339,15 @@ function mergeBack(cloneDir, originRepo, marker) {
181
339
  });
182
340
  if (idx >= 0) {
183
341
  body = body.slice(idx + 1);
184
- if (body.length === 0) continue; // clone added nothing on top of the seed
342
+ if (body.length === 0) continue;
185
343
  const first = JSON.parse(body[0]);
186
- first.parentId = null; // re-root
344
+ first.parentId = null;
187
345
  body[0] = JSON.stringify(first);
188
346
  }
189
- // seedLeafId not found -> copy the whole thing (safe fallback, may overlap)
190
347
  }
191
348
  writeFileSync(dest, [JSON.stringify(header), ...body].join("\n") + "\n");
192
349
  n++;
193
350
  }
194
- // Remove the clone's now-orphaned session dir under ~/.pi (pi has exited, safe to delete).
195
351
  try {
196
352
  rmSync(srcDir, { recursive: true, force: true });
197
353
  } catch {
@@ -200,10 +356,65 @@ function mergeBack(cloneDir, originRepo, marker) {
200
356
  return n;
201
357
  }
202
358
 
203
- // โ”€โ”€ subcommands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
204
- function cmdNew(argv) {
359
+ /** Ask the shell wrapper (kage shell-init) to cd somewhere after we exit. */
360
+ function requestCd(path) {
361
+ const f = process.env.KAGE_CD_FILE;
362
+ if (f) {
363
+ try {
364
+ writeFileSync(f, path);
365
+ } catch {
366
+ /* ignore */
367
+ }
368
+ }
369
+ }
370
+
371
+ function launchPi(cwd, args) {
372
+ const r = spawnSync("pi", args, { cwd, stdio: "inherit" });
373
+ if (r.error) {
374
+ if (r.error.code === "ENOENT") die("pi not found (make sure it is installed and on your PATH)");
375
+ die(`failed to launch pi: ${r.error.message}`);
376
+ }
377
+ }
378
+
379
+ // โ”€โ”€ subcommands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
380
+ async function cmdNew(argv) {
205
381
  const { positional, flags } = parseArgs(argv);
206
- const targetPath = positional[0] ? resolve(positional[0]) : process.cwd();
382
+ let path = positional[0];
383
+
384
+ // Interactive launcher: `kage` with no args, inside a repo that already has clones.
385
+ if (!path && !flags.name && !flags.blank && process.stdin.isTTY) {
386
+ const repoRoot = repoTopLevel(process.cwd());
387
+ const clones = repoRoot ? listClones(repoRoot) : [];
388
+ if (clones.length > 0) {
389
+ const labels = [
390
+ "๏ผ‹ Create a new shadow clone",
391
+ ...clones.map((c) => {
392
+ const s = cloneStatus(c.dir);
393
+ const tag = s.dirty ? paint.yellow(" โ—") : isSafeToClean(s) ? paint.green(" โœ“") : "";
394
+ return `โ†’ Enter ${c.name} ${paint.cyan(s.branch)}${tag}`;
395
+ }),
396
+ ];
397
+ const idx = await select(`Shadow clones of ${basename(repoRoot)} โ€” pick one, or create:`, labels);
398
+ if (idx < 0) return info("cancelled");
399
+ if (idx > 0) {
400
+ const clone = clones[idx - 1];
401
+ const act = await select(`${clone.name}:`, [
402
+ "Enter (resume pi)",
403
+ "Finish (merge memory & remove)",
404
+ "Remove (discard)",
405
+ "Cancel",
406
+ ]);
407
+ if (act === 0) return launchPi(clone.dir, ["-c"]);
408
+ if (act === 1) return cmdFinish([clone.name]);
409
+ if (act === 2) return cmdRm([clone.name]);
410
+ return info("cancelled");
411
+ }
412
+ const nm = await ask("Name (blank = auto): ");
413
+ if (nm) flags.name = nm;
414
+ }
415
+ }
416
+
417
+ const targetPath = path ? resolve(path) : process.cwd();
207
418
  const blank = !!flags.blank;
208
419
  const recent = Math.max(1, parseInt(flags.recent, 10) || 5);
209
420
 
@@ -219,119 +430,166 @@ function cmdNew(argv) {
219
430
  const cp = copyTree(repoRoot, cloneDir);
220
431
  if (!cp.ok) die(`copy failed: ${cp.err}`);
221
432
 
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);
433
+ // kage does NOT create a branch โ€” the clone stays on the origin's current branch.
434
+ const built = buildCloneSession(repoRoot, cloneDir, recent, blank);
226
435
  const marker = {
227
436
  originRepo: repoRoot,
228
437
  name: safe,
229
438
  createdAt: new Date().toISOString(),
230
- seedFile: seed?.seedFile,
231
- seedLeafId: seed?.seedLeafId,
439
+ seedFile: built.seedFile,
440
+ seedLeafId: built.seedLeafId,
232
441
  };
233
442
  writeFileSync(join(cloneDir, MARKER), JSON.stringify(marker, null, 2));
234
443
 
235
444
  const curBranch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out || "?";
236
445
  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}`);
446
+ info(`๐Ÿฅท ${paint.bold("Shadow clone ready")}: ${cloneDir}`);
447
+ info(` origin: ${repoRoot} branch: ${paint.cyan(curBranch)}`);
448
+ if (built.seeded) {
449
+ info(` seeded with the last ${built.turns} turn(s) + a kage reminder (pi -c resumes them)`);
450
+ if (built.preview) info(paint.dim(` โ†ณ "${built.preview}"`));
451
+ } else {
452
+ info(paint.dim(" blank clone (kage reminder only)"));
453
+ }
454
+ info(paint.yellow(` โš  create a feature branch before committing (clone is on ${curBranch})`));
455
+ info(paint.dim(` when done: kage finish ${safe}`));
242
456
  info("");
243
457
 
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
- }
458
+ launchPi(cloneDir, ["-c"]);
251
459
  info("");
252
- info(`โ†ฉ๏ธŽ left the clone's pi. To finish: kage finish ${safe}`);
460
+ info(`โ†ฉ๏ธŽ left the clone's pi. To finish: ${paint.bold(`kage finish ${safe}`)}`);
253
461
  }
254
462
 
255
- function cmdFinish(argv) {
463
+ async function cmdFinish(argv) {
256
464
  const { positional, flags } = parseArgs(argv);
257
465
  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);
466
+ const pr = !!flags.pr;
467
+ const push = pr || !!flags.push; // --pr implies --push
468
+ const picked = await pickClone("finish", positional[0]);
469
+ if (!picked) return info("cancelled");
470
+ const { originRepo, clone } = picked;
471
+ const insideClone = repoTopLevel(process.cwd()) === clone.dir;
472
+
473
+ // Optional convenience: push the branch (and open a PR) before finishing.
474
+ if (push) {
475
+ const s = cloneStatus(clone.dir);
476
+ if (s.dirty) die(`${clone.name} has uncommitted changes โ€” commit them first (kage won't auto-commit)`);
477
+ if (!s.hasUpstream) {
478
+ const r = git(clone.dir, ["push", "-u", "origin", s.branch]);
479
+ if (!r.ok) die(`push failed: ${r.err}`);
480
+ info(`โฌ† pushed ${s.branch} to origin`);
481
+ } else if (s.ahead > 0) {
482
+ const r = git(clone.dir, ["push"]);
483
+ if (!r.ok) die(`push failed: ${r.err}`);
484
+ info(`โฌ† pushed ${s.ahead} commit(s)`);
485
+ }
486
+ if (pr) {
487
+ const existing = prInfo(clone.dir, s.branch);
488
+ if (existing) {
489
+ info(`๐Ÿ”— PR already open: ${existing.url}`);
490
+ } else {
491
+ const r = sh("gh", ["pr", "create", "--fill"], { cwd: clone.dir });
492
+ if (!r.ok) die(`gh pr create failed: ${r.err || r.out || "is gh installed & authed?"}`);
493
+ info(`๐Ÿ”— opened PR: ${r.out.split("\n").pop()}`);
494
+ }
281
495
  }
282
- cloneDir = pick.dir;
283
- marker = pick.marker;
284
496
  }
285
497
 
286
- // Safety checks (fail visibly; don't silently delete work).
287
498
  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`);
499
+ const s = cloneStatus(clone.dir);
500
+ const problems = [];
501
+ if (s.dirty) problems.push("uncommitted changes");
502
+ if (!s.hasUpstream) problems.push("branch not pushed (no upstream)");
503
+ else if (s.ahead > 0) problems.push(`${s.ahead} unpushed commit(s)`);
504
+ if (problems.length) die(`${clone.name}: ${problems.join(", ")} โ€” push your work, or pass --force`);
295
505
  }
296
506
 
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).
507
+ const n = mergeBack(clone.dir, originRepo, clone.marker);
300
508
  try {
301
509
  process.chdir(originRepo);
302
510
  } catch {
303
511
  /* ignore */
304
512
  }
305
- rmSync(cloneDir, { recursive: true, force: true });
513
+ rmSync(clone.dir, { recursive: true, force: true });
306
514
 
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}`);
515
+ info(`๐Ÿ’จ Clone dispelled: merged ${n} session(s) back, removed ${clone.dir}`);
516
+ if (insideClone) {
517
+ requestCd(originRepo);
518
+ info(paint.dim(` cd back to: ${originRepo} (auto with: eval "$(kage shell-init)")`));
519
+ }
309
520
  }
310
521
 
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 });
522
+ async function cmdRm(argv) {
523
+ const { positional, flags } = parseArgs(argv);
524
+ const force = !!flags.force;
525
+ const picked = await pickClone("remove", positional[0]);
526
+ if (!picked) return info("cancelled");
527
+ const { originRepo, clone } = picked;
528
+ const insideClone = repoTopLevel(process.cwd()) === clone.dir;
529
+
530
+ if (!force) {
531
+ const s = cloneStatus(clone.dir);
532
+ if (s.dirty || !s.hasUpstream || s.ahead > 0) {
533
+ die(`${clone.name} has local-only work โ€” use 'kage finish' to keep it, or 'kage rm --force' to discard`);
534
+ }
535
+ if (!(await confirm(`Discard clone ${clone.name} without merging its memory?`))) return info("aborted");
536
+ }
537
+
538
+ try {
539
+ process.chdir(originRepo);
540
+ } catch {
541
+ /* ignore */
542
+ }
543
+ try {
544
+ rmSync(sessionDirFor(clone.dir), { recursive: true, force: true });
545
+ } catch {
546
+ /* ignore */
547
+ }
548
+ rmSync(clone.dir, { recursive: true, force: true });
549
+ info(`๐Ÿ—‘ Removed clone ${clone.name} (${clone.dir})`);
550
+ if (insideClone) {
551
+ requestCd(originRepo);
552
+ info(paint.dim(` cd back to: ${originRepo} (auto with: eval "$(kage shell-init)")`));
318
553
  }
319
- return out;
320
554
  }
321
555
 
322
- function cmdList() {
556
+ function cmdList(argv) {
557
+ const { flags } = parseArgs(argv);
323
558
  const repoRoot = repoTopLevel(process.cwd());
324
559
  if (!repoRoot) die("not a git repository");
325
560
  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}`);
561
+ if (clones.length === 0) return info("No shadow clones.");
562
+
563
+ const rows = clones.map((c) => {
564
+ const s = cloneStatus(c.dir);
565
+ return { c, s, pr: flags.pr ? prInfo(c.dir, s.branch) : undefined };
566
+ });
567
+ const nameW = Math.max(...rows.map((r) => r.c.name.length), 4);
568
+ const brW = Math.max(...rows.map((r) => r.s.branch.length), 6);
569
+
570
+ info(paint.bold(`Shadow clones of ${basename(repoRoot)}:`));
571
+ info("");
572
+ for (const { c, s, pr } of rows) {
573
+ const dirty = s.dirty ? paint.yellow("โ— dirty") : paint.green("clean ");
574
+ let sync;
575
+ if (!s.hasUpstream) sync = paint.dim("not pushed");
576
+ else {
577
+ const parts = [];
578
+ if (s.ahead) parts.push(`โ†‘${s.ahead}`);
579
+ if (s.behind) parts.push(`โ†“${s.behind}`);
580
+ sync = parts.length ? parts.join(" ") : paint.dim("in sync");
581
+ }
582
+ const prStr = pr ? ` ${prState(pr)}` : "";
583
+ const safe = isSafeToClean(s) ? paint.green(" โœ“ safe to clean") : "";
584
+ info(` ${c.name.padEnd(nameW)} ${paint.cyan(s.branch.padEnd(brW))} ${dirty} ${sync}${prStr}${safe}`);
334
585
  }
586
+ info("");
587
+ info(paint.dim(" finish <name> to merge & remove ยท rm <name> to discard ยท list --pr for PR status"));
588
+ }
589
+
590
+ function prState(pr) {
591
+ const f = { OPEN: paint.green, MERGED: paint.magenta, CLOSED: paint.red }[pr.state] || paint.dim;
592
+ return f(`PR #${pr.number} ${pr.state.toLowerCase()}`);
335
593
  }
336
594
 
337
595
  function cmdPull(argv) {
@@ -367,47 +625,87 @@ function cmdPull(argv) {
367
625
  info(`๐Ÿ“ค Pulled ${done}/${positional.length} path(s) from the clone back to the origin (${originRepo})`);
368
626
  }
369
627
 
628
+ const SHELL_INIT = `# kage shell integration โ€” add to ~/.zshrc or ~/.bashrc: eval "$(kage shell-init)"
629
+ kage() {
630
+ local f; f="$(mktemp "\${TMPDIR:-/tmp}/kage-cd.XXXXXX")"
631
+ KAGE_CD_FILE="$f" command kage "$@"; local rc=$?
632
+ if [ -s "$f" ]; then cd "$(cat "$f")"; fi
633
+ rm -f "$f"
634
+ return $rc
635
+ }
636
+ if [ -n "$ZSH_VERSION" ]; then
637
+ _kage() {
638
+ if (( CURRENT == 2 )); then compadd new list finish rm pull; return; fi
639
+ case "\${words[2]}" in
640
+ finish|rm) compadd $(command kage __clones 2>/dev/null);;
641
+ esac
642
+ }
643
+ compdef _kage kage
644
+ elif [ -n "$BASH_VERSION" ]; then
645
+ _kage() {
646
+ local cur="\${COMP_WORDS[COMP_CWORD]}"
647
+ if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=( $(compgen -W "new list finish rm pull" -- "$cur") ); return; fi
648
+ case "\${COMP_WORDS[1]}" in
649
+ finish|rm) COMPREPLY=( $(compgen -W "$(command kage __clones 2>/dev/null)" -- "$cur") );;
650
+ esac
651
+ }
652
+ complete -F _kage kage
653
+ fi`;
654
+
655
+ function cmdClones() {
656
+ const repoRoot = repoTopLevel(process.cwd());
657
+ if (!repoRoot) return;
658
+ for (const c of listClones(repoRoot)) process.stdout.write(`${c.name}\n`);
659
+ }
660
+
370
661
  const HELP = `kage ๐Ÿฅท โ€” Shadow Clone Jutsu for your git repo
371
662
 
372
663
  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
664
+ kage [path] [--name <x>] [--blank] [--recent <N>] clone repo + launch pi
665
+ (no args inside a repo with clones: interactive menu)
666
+ kage list [--pr] dashboard of clones (--pr adds PR status via gh)
667
+ kage finish [name] [--force] [--push] [--pr] check -> merge memory back -> delete clone
668
+ (--push: push first ยท --pr: push + open a PR via gh)
669
+ kage rm [name] [--force] discard a clone without merging
376
670
  kage pull <path...> (inside a clone) copy files back to the origin
671
+ kage shell-init shell wrapper (cd-back) + tab completion
377
672
 
378
673
  Options:
379
- --name <x> name the clone folder/<repo>--<x> (default: kage-<timestamp>)
674
+ --name <x> name the clone folder /<repo>--<x> (default: kage-<timestamp>)
380
675
  --blank don't seed the clone with the origin's recent context
381
676
  --recent <N> number of recent turns to seed (default: 5)
382
- --force skip the uncommitted/unpushed safety check in finish
677
+ --force skip the safety checks (finish/rm)
383
678
 
384
679
  Env:
385
680
  KAGE_SESSIONS_DIR pi session storage (default: ~/.pi/agent/sessions)`;
386
681
 
387
- // โ”€โ”€ entry โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
388
- function main() {
682
+ async function main() {
389
683
  const [sub, ...rest] = process.argv.slice(2);
390
684
  switch (sub) {
391
685
  case undefined:
392
686
  case "new":
393
687
  return cmdNew(sub === "new" ? rest : process.argv.slice(2));
688
+ case "list":
689
+ return cmdList(rest);
394
690
  case "finish":
395
691
  return cmdFinish(rest);
396
- case "list":
397
- return cmdList();
692
+ case "rm":
693
+ return cmdRm(rest);
398
694
  case "pull":
399
695
  return cmdPull(rest);
696
+ case "shell-init":
697
+ case "completion":
698
+ return process.stdout.write(SHELL_INIT + "\n");
699
+ case "__clones":
700
+ return cmdClones();
400
701
  case "-h":
401
702
  case "--help":
402
703
  return info(HELP);
403
704
  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
- }
705
+ case "--version":
706
+ return info(VERSION);
408
707
  default:
409
- // Unknown subcommand -> treat as `kage <path>`.
410
- return cmdNew(process.argv.slice(2));
708
+ return cmdNew(process.argv.slice(2)); // unknown subcommand -> treat as `kage <path>`
411
709
  }
412
710
  }
413
711
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-kage",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
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
5
  "keywords": [
6
6
  "pi",