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