pi-kage 0.2.0 β 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -35
- package/bin/kage.mjs +306 -190
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,16 +8,16 @@
|
|
|
8
8
|
|
|
9
9
|
<p align="center"><img src="./assets/demo.svg" alt="kage demo" width="100%"></p>
|
|
10
10
|
|
|
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
|
-
|
|
11
|
+
`kage` copies your repo into an isolated sibling folder, drops you straight into a **fresh**
|
|
12
|
+
[pi](https://github.com/earendil-works) session to work in parallel, and when you're done merges the
|
|
13
|
+
clone's new sessions back into the original and dispels the clone.
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
npm install -g pi-kage
|
|
17
17
|
cd my-app
|
|
18
|
-
kage # π₯· clone β ../my-app--kage-<ts>, open pi
|
|
18
|
+
kage # π₯· clone β ../my-app--kage-<ts>, open a fresh pi (origin history resumable)
|
|
19
19
|
# ...work in the clone: commit, push, open a PR, quit pi...
|
|
20
|
-
kage finish # π¨ merge the
|
|
20
|
+
kage finish # π¨ merge the clone's new sessions back, delete the clone
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
---
|
|
@@ -34,9 +34,10 @@ A shadow clone is a **full, independent copy** of the repo β like a second eng
|
|
|
34
34
|
machine. Each parallel session gets its own working tree, its own branch, its own commits and PR.
|
|
35
35
|
Code merges the normal way: on GitHub. No local collisions, ever.
|
|
36
36
|
|
|
37
|
-
And like a real Naruto shadow clone, it **carries your memory out** (the
|
|
38
|
-
|
|
39
|
-
back into the original when you `finish`).
|
|
37
|
+
And like a real Naruto shadow clone, it **carries your memory out** (the origin's 5 most recent pi
|
|
38
|
+
sessions are copied into the clone, so you can `resume` them there) and **returns it on dispel** (the clone's
|
|
39
|
+
*new* sessions are merged back into the original when you `finish`). The clone always opens a **fresh**
|
|
40
|
+
session β kage never replays your old turns or fakes a "resumed" conversation.
|
|
40
41
|
|
|
41
42
|
Why a full folder copy instead of `git worktree`? A worktree shares one `.git`, which means you
|
|
42
43
|
can't check out the same branch twice, you share stash/refs, and you get a *fresh* checkout with no
|
|
@@ -46,11 +47,17 @@ can't check out the same branch twice, you share stash/refs, and you get a *fres
|
|
|
46
47
|
## Install
|
|
47
48
|
|
|
48
49
|
```bash
|
|
50
|
+
# npm
|
|
49
51
|
npm install -g pi-kage # then use `kage` anywhere
|
|
50
|
-
# or
|
|
51
|
-
|
|
52
|
+
npx pi-kage # or run without installing
|
|
53
|
+
|
|
54
|
+
# or install script (no npm needed β kage is a single, zero-dependency Node script)
|
|
55
|
+
curl -fsSL https://raw.githubusercontent.com/kid7st/kage/main/install.sh | sh
|
|
52
56
|
```
|
|
53
57
|
|
|
58
|
+
The install script drops the single `kage` file into `~/.local/bin` (override with `KAGE_BIN_DIR`,
|
|
59
|
+
pin a version with `KAGE_VERSION`). kage has **no dependencies** β it only needs Node, git, and pi.
|
|
60
|
+
|
|
54
61
|
From source:
|
|
55
62
|
|
|
56
63
|
```bash
|
|
@@ -65,14 +72,14 @@ Requires **git**, [**pi**](https://github.com/earendil-works), and **Node β₯ 18
|
|
|
65
72
|
```
|
|
66
73
|
origin repo (you) shadow clone (independent copy)
|
|
67
74
|
βββββββββββββββββ ββββββββββββββββββββββββββββββ
|
|
68
|
-
$ kage --name fix-login
|
|
69
|
-
$ pi
|
|
75
|
+
$ kage --name fix-login βcopy + historyββΊ ../my-app--fix-login
|
|
76
|
+
$ pi (fresh session; origin history resumable)
|
|
70
77
|
Β· git switch -c fix-login
|
|
71
78
|
Β· edit / commit / push / open PR
|
|
72
79
|
Β· quit pi
|
|
73
|
-
$ kage finish fix-login
|
|
80
|
+
$ kage finish fix-login ββnew sessionsββ (the clone's .jsonl, copied back)
|
|
74
81
|
Β· safety check (committed? pushed?)
|
|
75
|
-
Β· merge
|
|
82
|
+
Β· merge the clone's new sessions into ~/.pi
|
|
76
83
|
Β· delete the clone folder
|
|
77
84
|
code arrives via the merged GitHub PR β
|
|
78
85
|
```
|
|
@@ -82,17 +89,15 @@ Requires **git**, [**pi**](https://github.com/earendil-works), and **Node β₯ 18
|
|
|
82
89
|
```bash
|
|
83
90
|
cd ~/code/my-app
|
|
84
91
|
|
|
85
|
-
kage # clone . β ../my-app--kage-<ts>,
|
|
86
|
-
kage --name fix-login # name the clone folder
|
|
92
|
+
kage # clone . β ../my-app--kage-<ts>, open a fresh pi (origin history resumable)
|
|
93
|
+
kage --name fix-login # name the clone folder: ../my-app--fix-login
|
|
87
94
|
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
95
|
|
|
91
96
|
# back in the origin after you quit the clone's pi:
|
|
92
97
|
kage # no args inside a repo with clones -> interactive menu
|
|
93
|
-
kage
|
|
94
|
-
kage
|
|
95
|
-
kage finish fix-login # check β merge
|
|
98
|
+
kage status # status dashboard: branch Β· dirty Β· ahead/behind Β· safe-to-clean
|
|
99
|
+
kage status --pr # also show PR state (via gh)
|
|
100
|
+
kage finish fix-login # check β merge the clone's new sessions back β delete the clone
|
|
96
101
|
kage finish fix-login --pr # push the branch + open a PR (via gh), then finish
|
|
97
102
|
kage finish --force # skip the uncommitted/unpushed guard
|
|
98
103
|
kage rm old-experiment # discard a clone without merging (refuses if it has local-only work)
|
|
@@ -119,9 +124,9 @@ for subcommands and clone names.
|
|
|
119
124
|
|
|
120
125
|
| Command | Run from | What it does |
|
|
121
126
|
|---|---|---|
|
|
122
|
-
| `kage [path] [--name x]
|
|
123
|
-
| `kage
|
|
124
|
-
| `kage finish [name] [--force] [--push] [--pr]` | origin (or inside the clone) | Refuse if the clone has uncommitted or unpushed work (`--force` overrides), merge
|
|
127
|
+
| `kage [path] [--name x]` | origin repo | Copy the repo to `../<repo>--<name>` (default `kage-<ts>`), copy the origin's 5 most recent pi sessions into the clone (resumable there, never replayed), and launch a **fresh** `pi` session. `--name` only names the folder β kage never creates a branch. With no args (and existing clones) it opens an interactive picker. |
|
|
128
|
+
| `kage status [--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`. (`kage list` is a kept alias.) |
|
|
129
|
+
| `kage finish [name] [--force] [--push] [--pr]` | origin (or inside the clone) | Refuse if the clone has uncommitted or unpushed work (`--force` overrides), merge the clone's **new** sessions back (copied-in origin history is skipped), 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
130
|
| `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. |
|
|
126
131
|
| `kage pull <path...>` | inside a clone | Copy specific files/dirs (even gitignored ones) back to the origin at the same relative path. |
|
|
127
132
|
| `kage shell-init` | shell rc | Print a shell wrapper (cd-back after `finish`/`rm`) + tab completion. Use `eval "$(kage shell-init)"`. |
|
|
@@ -132,23 +137,41 @@ for subcommands and clone names.
|
|
|
132
137
|
Four invariants keep parallel work safe and lossless:
|
|
133
138
|
|
|
134
139
|
1. **Isolation** β a clone is a full independent copy with its own `.git`.
|
|
135
|
-
2. **Code flows back
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
2. **Code flows back via git, never the working tree.** With a remote you push the branch and merge a
|
|
141
|
+
PR; with **no remote**, `finish` fetches the clone's branch into the origin's git as a local
|
|
142
|
+
`kage/<name>-<sha>` branch (the origin's working tree is left untouched β merge it when you like). Either
|
|
143
|
+
way kage never copies the clone's working tree onto the origin, which would re-create the collisions
|
|
144
|
+
it avoids. `finish` still refuses to delete **uncommitted** work (it can't be preserved by a fetch).
|
|
145
|
+
3. **Memory flows through `~/.pi`.** On create, the origin's session `.jsonl` files are copied into the
|
|
146
|
+
clone (the 5 most recent, by mtime) β so `pi`'s resume picker inside the clone surfaces them if you
|
|
147
|
+
want it, but the clone itself opens a **fresh** session (kage never replays turns or fakes a resumed
|
|
148
|
+
conversation). On `finish`, sessions the clone created are copied back whole; an unchanged copied-in
|
|
149
|
+
session adds nothing; and a copied-in session you *resumed and added to* comes back as a **new,
|
|
150
|
+
self-contained session** β so the origin's original session (and the leaf pi would resume) is never
|
|
151
|
+
mutated, and your added turns aren't lost.
|
|
141
152
|
4. **The origin is read-only to kage** β it only copies out and writes session memory; it never
|
|
142
153
|
touches the origin's working tree, even while another session is live there.
|
|
143
154
|
|
|
144
155
|
## Notes & caveats
|
|
145
156
|
|
|
146
157
|
- 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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
-
|
|
151
|
-
|
|
158
|
+
- kage **doesn't create a branch** β the clone stays on the origin's current branch, and kage stays out
|
|
159
|
+
of git flow entirely. Decide your own branching/PR workflow inside the clone (instruct the agent via
|
|
160
|
+
your `AGENTS.md` / project conventions).
|
|
161
|
+
- The clone opens a **fresh** pi session. The origin's 5 most recent sessions are copied in and are **resumable** via
|
|
162
|
+
pi's resume picker. Real work belongs in the clone's own fresh session, but if you do resume a copied
|
|
163
|
+
origin session and add turns, on `finish` that continuation is written back as a **separate** session
|
|
164
|
+
(the origin's original session is left untouched), so nothing is lost and no active conversation is hijacked.
|
|
165
|
+
- **Upgrading from an older kage:** clones created before the copy-in/fresh-session redesign carry a
|
|
166
|
+
fabricated *seed* session in their `.kage.json` (`seedFile`/`seedLeafId`). `finish` no longer special-
|
|
167
|
+
cases those, so finishing such a clone would copy the replayed seed context back into the origin. For
|
|
168
|
+
any clone created by an older kage, prefer `kage rm` (the code is already on its branch / PR) instead
|
|
169
|
+
of `kage finish`.
|
|
170
|
+
- **No remote?** `finish` still works losslessly: committed work that isn't on a remote is fetched into
|
|
171
|
+
the origin as a local `kage/<name>-<sha>` branch (the exact name is printed; `git merge` it to
|
|
172
|
+
integrate). The short sha keeps the ref unique, so reusing a clone name never collides. With a remote
|
|
173
|
+
configured, `finish` keeps nudging you to push first (so PR-flow mistakes surface) unless you
|
|
174
|
+
`--push`/`--pr` or `--force`.
|
|
152
175
|
- **Submodules**: a submodule's `.git` pointer is an absolute path and breaks on copy β run
|
|
153
176
|
`git submodule update --init` in the clone.
|
|
154
177
|
- Non-APFS / non-reflink filesystems fall back to a full (heavier) copy.
|
package/bin/kage.mjs
CHANGED
|
@@ -8,28 +8,32 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Design invariants:
|
|
10
10
|
* 1. Isolation β a clone is a full independent copy (its own .git).
|
|
11
|
-
* 2. Code flows back
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* 2. Code flows back via git only β a remote PR, or (no remote) a fetch of the clone's branch
|
|
12
|
+
* into the origin's git on finish; kage never copies the working tree back onto the origin.
|
|
13
|
+
* 3. Memory flows via ~/.pi β the origin's session history is copied into the clone on create
|
|
14
|
+
* (resumable, never replayed) and the clone's new sessions are merged back on finish. These
|
|
15
|
+
* are session .jsonl files, not the working tree, so there's no collision.
|
|
14
16
|
* 4. The origin is read-only to kage β it only copies out and writes session memory.
|
|
15
17
|
*
|
|
16
18
|
* Commands:
|
|
17
|
-
* kage [path] [--name x]
|
|
18
|
-
* kage
|
|
19
|
+
* kage [path] [--name x] clone repo + launch a fresh pi (no args: interactive)
|
|
20
|
+
* kage status [--pr] dashboard of clones (+ PR status via gh)
|
|
19
21
|
* kage finish [name] [--force] check -> merge memory back -> delete clone
|
|
20
22
|
* kage rm [name] [--force] discard a clone (no merge)
|
|
21
23
|
* kage pull <path...> (inside a clone) copy files back to the origin
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
|
-
import { spawnSync } from "node:child_process";
|
|
26
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
25
27
|
import { randomUUID } from "node:crypto";
|
|
26
28
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
27
29
|
import { homedir } from "node:os";
|
|
28
30
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
29
31
|
import readline from "node:readline";
|
|
30
32
|
|
|
33
|
+
const VERSION = "0.3.0"; // keep in sync with package.json (enforced by test)
|
|
31
34
|
const MARKER = ".kage.json";
|
|
32
35
|
const SESSIONS = process.env.KAGE_SESSIONS_DIR || join(homedir(), ".pi", "agent", "sessions");
|
|
36
|
+
const RECENT_SESSIONS = 5; // how many of the origin's most-recent sessions to copy into a clone
|
|
33
37
|
|
|
34
38
|
// ββ output helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
35
39
|
const TTY = process.stderr.isTTY;
|
|
@@ -76,14 +80,6 @@ function readMarker(dir) {
|
|
|
76
80
|
}
|
|
77
81
|
}
|
|
78
82
|
|
|
79
|
-
function mtime(p) {
|
|
80
|
-
try {
|
|
81
|
-
return statSync(p).mtimeMs;
|
|
82
|
-
} catch {
|
|
83
|
-
return 0;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
83
|
/** Copy a whole directory: clonefile on macOS, reflink on Linux, plain copy as fallback. */
|
|
88
84
|
function copyTree(src, dst) {
|
|
89
85
|
const isMac = process.platform === "darwin";
|
|
@@ -92,12 +88,66 @@ function copyTree(src, dst) {
|
|
|
92
88
|
return r;
|
|
93
89
|
}
|
|
94
90
|
|
|
91
|
+
/** An indeterminate spinner on stderr (no-op when not a TTY). Returns { stop() }. */
|
|
92
|
+
function spinner(label) {
|
|
93
|
+
if (!process.stderr.isTTY) return { stop() {} };
|
|
94
|
+
const frames = ["β ", "β ", "β Ή", "β Έ", "β Ό", "β ΄", "β ¦", "β §", "β ", "β "];
|
|
95
|
+
const t0 = Date.now();
|
|
96
|
+
let i = 0;
|
|
97
|
+
const tick = () => {
|
|
98
|
+
const s = ((Date.now() - t0) / 1000).toFixed(1);
|
|
99
|
+
process.stderr.write(`\r\x1b[2K${paint.cyan(frames[(i = (i + 1) % frames.length)])} ${label} ${paint.dim(`${s}s`)}`);
|
|
100
|
+
};
|
|
101
|
+
tick();
|
|
102
|
+
const id = setInterval(tick, 80);
|
|
103
|
+
return {
|
|
104
|
+
stop() {
|
|
105
|
+
clearInterval(id);
|
|
106
|
+
process.stderr.write("\r\x1b[2K");
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Copy the repo with a spinner (the copy can be slow on non-reflink filesystems). */
|
|
112
|
+
async function copyRepo(src, dst) {
|
|
113
|
+
const isMac = process.platform === "darwin";
|
|
114
|
+
const primary = isMac ? ["-c", "-R", src, dst] : ["--reflink=auto", "-R", src, dst];
|
|
115
|
+
const tryCp = (args) =>
|
|
116
|
+
new Promise((res) => {
|
|
117
|
+
const p = spawn("cp", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
118
|
+
let err = "";
|
|
119
|
+
p.stderr.on("data", (d) => (err += d));
|
|
120
|
+
p.on("error", (e) => res({ ok: false, err: e.message }));
|
|
121
|
+
p.on("close", (code) => res({ ok: code === 0, err: err.trim() }));
|
|
122
|
+
});
|
|
123
|
+
const sp = spinner(`copying ${basename(dst)}`);
|
|
124
|
+
let r = await tryCp(primary);
|
|
125
|
+
if (!r.ok) r = await tryCp(["-R", src, dst]);
|
|
126
|
+
sp.stop();
|
|
127
|
+
return r;
|
|
128
|
+
}
|
|
129
|
+
|
|
95
130
|
function tsName() {
|
|
96
131
|
const d = new Date();
|
|
97
132
|
const p = (n) => String(n).padStart(2, "0");
|
|
98
133
|
return `kage-${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
99
134
|
}
|
|
100
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Sanitize a clone name into a slug that's safe as both a folder suffix and a git branch/ref:
|
|
138
|
+
* ref-illegal chars (spaces, /, ~^:?*[\\, etc.) -> '-', no '..', no leading/trailing '-'/'.',
|
|
139
|
+
* no trailing '.lock'. Falls back to a timestamp name if it sanitizes to empty.
|
|
140
|
+
*/
|
|
141
|
+
function slug(name) {
|
|
142
|
+
const s = name
|
|
143
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
144
|
+
.replace(/\.{2,}/g, ".")
|
|
145
|
+
.replace(/-{2,}/g, "-")
|
|
146
|
+
.replace(/^[-.]+|[-.]+$/g, "")
|
|
147
|
+
.replace(/\.lock$/i, "-lock");
|
|
148
|
+
return s || tsName();
|
|
149
|
+
}
|
|
150
|
+
|
|
101
151
|
function parseArgs(argv) {
|
|
102
152
|
const positional = [];
|
|
103
153
|
const flags = {};
|
|
@@ -128,7 +178,8 @@ function listClones(originRepo) {
|
|
|
128
178
|
function cloneStatus(dir) {
|
|
129
179
|
const branch = git(dir, ["rev-parse", "--abbrev-ref", "HEAD"]).out || "?";
|
|
130
180
|
const st = git(dir, ["status", "--porcelain"]).out;
|
|
131
|
-
const
|
|
181
|
+
const changed = st.split("\n").filter((l) => l.trim() && l.slice(3).trim() !== MARKER);
|
|
182
|
+
const dirty = changed.length > 0;
|
|
132
183
|
const up = git(dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
133
184
|
let ahead = 0;
|
|
134
185
|
let behind = 0;
|
|
@@ -140,7 +191,23 @@ function cloneStatus(dir) {
|
|
|
140
191
|
ahead = a || 0;
|
|
141
192
|
}
|
|
142
193
|
}
|
|
143
|
-
|
|
194
|
+
// uncommitted line changes (tracked, vs HEAD) and the last commit on this branch
|
|
195
|
+
const ss = git(dir, ["diff", "HEAD", "--shortstat"]).out;
|
|
196
|
+
const added = Number(ss.match(/(\d+) insertion/)?.[1] || 0);
|
|
197
|
+
const removed = Number(ss.match(/(\d+) deletion/)?.[1] || 0);
|
|
198
|
+
const lc = git(dir, ["log", "-1", "--format=%h\x1f%s\x1f%cr"]).out;
|
|
199
|
+
const [sha, subject, when] = lc ? lc.split("\x1f") : [];
|
|
200
|
+
const lastCommit = sha ? { sha, subject, when } : undefined;
|
|
201
|
+
return { branch, dirty, dirtyCount: changed.length, added, removed, ahead, behind, hasUpstream: up.ok, lastCommit };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Compact relative age, e.g. "2h ago". */
|
|
205
|
+
function ago(date) {
|
|
206
|
+
const s = Math.max(0, (Date.now() - new Date(date).getTime()) / 1000);
|
|
207
|
+
if (s < 60) return `${Math.floor(s)}s ago`;
|
|
208
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
209
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
210
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
144
211
|
}
|
|
145
212
|
|
|
146
213
|
/** Best-effort PR lookup via gh; returns { state, number, url } or undefined. */
|
|
@@ -157,6 +224,14 @@ function prInfo(dir, branch) {
|
|
|
157
224
|
/** True when the clone has no local-only work (clean + pushed) -> safe to remove. */
|
|
158
225
|
const isSafeToClean = (s) => !s.dirty && s.hasUpstream && s.ahead === 0;
|
|
159
226
|
|
|
227
|
+
/** True if the clone has committed work that lives only in the clone (not on a remote, not yet in the origin). */
|
|
228
|
+
function hasUnpreservedCommits(originRepo, cloneDir, s) {
|
|
229
|
+
if (s.hasUpstream && s.ahead === 0) return false; // already on a remote
|
|
230
|
+
const head = git(cloneDir, ["rev-parse", "HEAD"]).out;
|
|
231
|
+
if (!head) return false;
|
|
232
|
+
return !git(originRepo, ["cat-file", "-e", `${head}^{commit}`]).ok;
|
|
233
|
+
}
|
|
234
|
+
|
|
160
235
|
// ββ interactive picker (TUI-lite, arrow keys, no deps) βββββββββββββββββββββββ
|
|
161
236
|
/** Returns the chosen index, or -1 when cancelled / non-interactive. */
|
|
162
237
|
function select(title, labels) {
|
|
@@ -192,10 +267,13 @@ function select(title, labels) {
|
|
|
192
267
|
});
|
|
193
268
|
}
|
|
194
269
|
|
|
195
|
-
async function ask(prompt) {
|
|
270
|
+
async function ask(prompt, prefill) {
|
|
196
271
|
if (!process.stdin.isTTY) return "";
|
|
197
272
|
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
198
|
-
const a = await new Promise((r) =>
|
|
273
|
+
const a = await new Promise((r) => {
|
|
274
|
+
rl.question(prompt, (x) => (rl.close(), r(x)));
|
|
275
|
+
if (prefill) rl.write(prefill); // pre-fill an editable default: Enter accepts, or edit it
|
|
276
|
+
});
|
|
199
277
|
return a.trim();
|
|
200
278
|
}
|
|
201
279
|
|
|
@@ -227,87 +305,50 @@ async function pickClone(action, name) {
|
|
|
227
305
|
return { originRepo, clone: clones[idx] };
|
|
228
306
|
}
|
|
229
307
|
|
|
230
|
-
// ββ
|
|
308
|
+
// ββ copy the origin's session history into the clone βββββββββββββββββββββββββ
|
|
231
309
|
/**
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
310
|
+
* Copies the origin's most recent session files (up to RECENT_SESSIONS, by mtime) into the
|
|
311
|
+
* clone's session dir, so `pi` resume inside the clone surfaces them (you decide whether to
|
|
312
|
+
* resume any of it). The clone itself opens a fresh session β kage never replays turns or
|
|
313
|
+
* fabricates a "resumed" conversation. On merge-back an unchanged copy adds nothing; if you
|
|
314
|
+
* resumed one and added turns, it comes back as a separate session (see mergeBack).
|
|
236
315
|
*/
|
|
237
|
-
function
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
}
|
|
276
|
-
|
|
316
|
+
function copyOriginHistory(originRepo, cloneDir) {
|
|
317
|
+
const srcDir = sessionDirFor(originRepo);
|
|
318
|
+
if (!existsSync(srcDir)) return 0;
|
|
277
319
|
const destDir = sessionDirFor(cloneDir);
|
|
278
320
|
mkdirSync(destDir, { recursive: true });
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
let
|
|
285
|
-
for (const
|
|
286
|
-
|
|
287
|
-
|
|
321
|
+
const recent = readdirSync(srcDir)
|
|
322
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
323
|
+
.map((f) => ({ f, m: statSync(join(srcDir, f)).mtimeMs }))
|
|
324
|
+
.sort((a, b) => b.m - a.m)
|
|
325
|
+
.slice(0, RECENT_SESSIONS);
|
|
326
|
+
let n = 0;
|
|
327
|
+
for (const { f } of recent) {
|
|
328
|
+
const lines = readFileSync(join(srcDir, f), "utf8").split("\n");
|
|
329
|
+
try {
|
|
330
|
+
const header = JSON.parse(lines[0]);
|
|
331
|
+
header.cwd = cloneDir;
|
|
332
|
+
lines[0] = JSON.stringify(header);
|
|
333
|
+
} catch {
|
|
334
|
+
/* leave malformed header as-is */
|
|
335
|
+
}
|
|
336
|
+
writeFileSync(join(destDir, f), lines.join("\n"));
|
|
337
|
+
n++;
|
|
288
338
|
}
|
|
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;
|
|
339
|
+
return n;
|
|
307
340
|
}
|
|
308
341
|
|
|
309
|
-
// ββ merge
|
|
310
|
-
|
|
342
|
+
// ββ merge the clone's new sessions back into the origin ββββββββββββββββββββββ
|
|
343
|
+
/**
|
|
344
|
+
* Copies the clone's sessions into the origin's session dir:
|
|
345
|
+
* - a session the clone created (filename not in the origin) -> copied back whole.
|
|
346
|
+
* - a copied-in origin session left unchanged -> skipped (nothing new).
|
|
347
|
+
* - a copied-in origin session you resumed and added turns to -> written back as a NEW,
|
|
348
|
+
* self-contained session file, so the origin's original session (and the active leaf pi
|
|
349
|
+
* resumes) is never mutated. Costs a duplicated prefix; avoids hijacking the origin's leaf.
|
|
350
|
+
*/
|
|
351
|
+
function mergeBack(cloneDir, originRepo) {
|
|
311
352
|
const srcDir = sessionDirFor(cloneDir);
|
|
312
353
|
if (!existsSync(srcDir)) return 0;
|
|
313
354
|
const destDir = sessionDirFor(originRepo);
|
|
@@ -315,36 +356,52 @@ function mergeBack(cloneDir, originRepo, marker) {
|
|
|
315
356
|
let n = 0;
|
|
316
357
|
for (const f of readdirSync(srcDir)) {
|
|
317
358
|
if (!f.endsWith(".jsonl")) continue;
|
|
359
|
+
const src = readFileSync(join(srcDir, f), "utf8").split("\n").filter((l) => l.trim());
|
|
360
|
+
if (src.length === 0) continue;
|
|
318
361
|
const dest = join(destDir, f);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
362
|
+
|
|
363
|
+
if (!existsSync(dest)) {
|
|
364
|
+
let header;
|
|
365
|
+
try {
|
|
366
|
+
header = JSON.parse(src[0]);
|
|
367
|
+
} catch {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
header.cwd = originRepo;
|
|
371
|
+
writeFileSync(dest, [JSON.stringify(header), ...src.slice(1)].join("\n") + "\n");
|
|
372
|
+
n++;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// A copied-in origin session. If the clone added records (e.g. you resumed it there),
|
|
377
|
+
// write the clone's full session back as a NEW, self-contained file β leaving the origin's
|
|
378
|
+
// original file (and the leaf pi resumes) untouched. Unchanged copies add nothing.
|
|
379
|
+
const have = new Set();
|
|
380
|
+
for (const l of readFileSync(dest, "utf8").split("\n")) {
|
|
381
|
+
if (!l.trim()) continue;
|
|
382
|
+
try {
|
|
383
|
+
have.add(JSON.parse(l).id);
|
|
384
|
+
} catch {
|
|
385
|
+
/* ignore */
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const hasNew = src.slice(1).some((l) => {
|
|
389
|
+
try {
|
|
390
|
+
return !have.has(JSON.parse(l).id);
|
|
391
|
+
} catch {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
if (!hasNew) continue;
|
|
322
396
|
let header;
|
|
323
397
|
try {
|
|
324
|
-
header = JSON.parse(
|
|
398
|
+
header = JSON.parse(src[0]);
|
|
325
399
|
} catch {
|
|
326
400
|
continue;
|
|
327
401
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (marker?.seedFile === f && marker?.seedLeafId) {
|
|
332
|
-
const idx = body.findIndex((l) => {
|
|
333
|
-
try {
|
|
334
|
-
return JSON.parse(l).id === marker.seedLeafId;
|
|
335
|
-
} catch {
|
|
336
|
-
return false;
|
|
337
|
-
}
|
|
338
|
-
});
|
|
339
|
-
if (idx >= 0) {
|
|
340
|
-
body = body.slice(idx + 1);
|
|
341
|
-
if (body.length === 0) continue;
|
|
342
|
-
const first = JSON.parse(body[0]);
|
|
343
|
-
first.parentId = null;
|
|
344
|
-
body[0] = JSON.stringify(first);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
writeFileSync(dest, [JSON.stringify(header), ...body].join("\n") + "\n");
|
|
402
|
+
const id = randomUUID();
|
|
403
|
+
const fname = `${new Date().toISOString().replace(/[:.]/g, "-")}_${id}.jsonl`;
|
|
404
|
+
writeFileSync(join(destDir, fname), [JSON.stringify({ ...header, id, cwd: originRepo }), ...src.slice(1)].join("\n") + "\n");
|
|
348
405
|
n++;
|
|
349
406
|
}
|
|
350
407
|
try {
|
|
@@ -355,16 +412,25 @@ function mergeBack(cloneDir, originRepo, marker) {
|
|
|
355
412
|
return n;
|
|
356
413
|
}
|
|
357
414
|
|
|
358
|
-
/**
|
|
359
|
-
|
|
415
|
+
/**
|
|
416
|
+
* We just deleted the clone we were running inside, so the parent shell is now in a
|
|
417
|
+
* deleted directory. A CLI can't cd its parent shell, so: if the shell wrapper is active
|
|
418
|
+
* (KAGE_CD_FILE set by `eval "$(kage shell-init)"`), hand it the origin path to cd into;
|
|
419
|
+
* otherwise print a copy-pasteable `cd` and how to enable the auto version.
|
|
420
|
+
*/
|
|
421
|
+
function leaveClone(originRepo) {
|
|
360
422
|
const f = process.env.KAGE_CD_FILE;
|
|
361
423
|
if (f) {
|
|
362
424
|
try {
|
|
363
|
-
writeFileSync(f,
|
|
425
|
+
writeFileSync(f, originRepo);
|
|
426
|
+
info(paint.dim(` β© back to ${originRepo}`));
|
|
427
|
+
return;
|
|
364
428
|
} catch {
|
|
365
|
-
/*
|
|
429
|
+
/* fall through to the manual hint */
|
|
366
430
|
}
|
|
367
431
|
}
|
|
432
|
+
info(paint.yellow(` β© your shell is still in the deleted clone β run: ${paint.bold(`cd ${originRepo}`)}`));
|
|
433
|
+
info(paint.dim(` enable auto cd-back: add eval "$(kage shell-init)" to your ~/.zshrc`));
|
|
368
434
|
}
|
|
369
435
|
|
|
370
436
|
function launchPi(cwd, args) {
|
|
@@ -381,7 +447,7 @@ async function cmdNew(argv) {
|
|
|
381
447
|
let path = positional[0];
|
|
382
448
|
|
|
383
449
|
// Interactive launcher: `kage` with no args, inside a repo that already has clones.
|
|
384
|
-
if (!path && !flags.name &&
|
|
450
|
+
if (!path && !flags.name && process.stdin.isTTY) {
|
|
385
451
|
const repoRoot = repoTopLevel(process.cwd());
|
|
386
452
|
const clones = repoRoot ? listClones(repoRoot) : [];
|
|
387
453
|
if (clones.length > 0) {
|
|
@@ -408,35 +474,38 @@ async function cmdNew(argv) {
|
|
|
408
474
|
if (act === 2) return cmdRm([clone.name]);
|
|
409
475
|
return info("cancelled");
|
|
410
476
|
}
|
|
411
|
-
|
|
412
|
-
if (nm) flags.name = nm;
|
|
477
|
+
// idx === 0: "create" β fall through to the name prompt below.
|
|
413
478
|
}
|
|
414
479
|
}
|
|
415
480
|
|
|
416
481
|
const targetPath = path ? resolve(path) : process.cwd();
|
|
417
|
-
const blank = !!flags.blank;
|
|
418
|
-
const recent = Math.max(1, parseInt(flags.recent, 10) || 5);
|
|
419
482
|
|
|
420
483
|
const repoRoot = repoTopLevel(targetPath);
|
|
421
484
|
if (!repoRoot) die(`not a git repository: ${targetPath}`);
|
|
422
485
|
if (existsSync(join(repoRoot, MARKER))) die("already inside a clone; run kage from the origin repo");
|
|
423
486
|
|
|
424
|
-
|
|
425
|
-
|
|
487
|
+
// Resolve the clone name: explicit --name wins; otherwise show the full folder name with
|
|
488
|
+
// the fixed "<repo>--" prefix in the prompt and an editable default suffix β press Enter to
|
|
489
|
+
// accept, or edit the suffix (non-interactive falls back to the default).
|
|
490
|
+
let name = typeof flags.name === "string" && flags.name ? flags.name : "";
|
|
491
|
+
if (!name) {
|
|
492
|
+
const def = tsName();
|
|
493
|
+
const prompt = `Kage name: ${basename(repoRoot)}--`;
|
|
494
|
+
name = (process.stdin.isTTY ? await ask(prompt, def) : "") || def;
|
|
495
|
+
}
|
|
496
|
+
const safe = slug(name);
|
|
426
497
|
const cloneDir = join(dirname(repoRoot), `${basename(repoRoot)}--${safe}`);
|
|
427
498
|
if (existsSync(cloneDir)) die(`directory already exists: ${cloneDir}`);
|
|
428
499
|
|
|
429
|
-
const cp =
|
|
500
|
+
const cp = await copyRepo(repoRoot, cloneDir);
|
|
430
501
|
if (!cp.ok) die(`copy failed: ${cp.err}`);
|
|
431
502
|
|
|
432
503
|
// kage does NOT create a branch β the clone stays on the origin's current branch.
|
|
433
|
-
const
|
|
504
|
+
const histN = copyOriginHistory(repoRoot, cloneDir);
|
|
434
505
|
const marker = {
|
|
435
506
|
originRepo: repoRoot,
|
|
436
507
|
name: safe,
|
|
437
508
|
createdAt: new Date().toISOString(),
|
|
438
|
-
seedFile: built.seedFile,
|
|
439
|
-
seedLeafId: built.seedLeafId,
|
|
440
509
|
};
|
|
441
510
|
writeFileSync(join(cloneDir, MARKER), JSON.stringify(marker, null, 2));
|
|
442
511
|
|
|
@@ -444,17 +513,11 @@ async function cmdNew(argv) {
|
|
|
444
513
|
info("");
|
|
445
514
|
info(`π₯· ${paint.bold("Shadow clone ready")}: ${cloneDir}`);
|
|
446
515
|
info(` origin: ${repoRoot} branch: ${paint.cyan(curBranch)}`);
|
|
447
|
-
if (
|
|
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})`));
|
|
516
|
+
if (histN > 0) info(paint.dim(` origin's ${histN} session(s) are available via resume (pi: pick from the list)`));
|
|
454
517
|
info(paint.dim(` when done: kage finish ${safe}`));
|
|
455
518
|
info("");
|
|
456
519
|
|
|
457
|
-
launchPi(cloneDir, [
|
|
520
|
+
launchPi(cloneDir, []);
|
|
458
521
|
info("");
|
|
459
522
|
info(`β©οΈ left the clone's pi. To finish: ${paint.bold(`kage finish ${safe}`)}`);
|
|
460
523
|
}
|
|
@@ -494,16 +557,32 @@ async function cmdFinish(argv) {
|
|
|
494
557
|
}
|
|
495
558
|
}
|
|
496
559
|
|
|
560
|
+
// Decide how to preserve the clone's committed work before deleting it.
|
|
561
|
+
const s = cloneStatus(clone.dir);
|
|
562
|
+
const hasRemote = git(clone.dir, ["remote"]).out.trim().length > 0;
|
|
563
|
+
|
|
497
564
|
if (!force) {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
if (s.
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
565
|
+
if (s.dirty) die(`${clone.name}: uncommitted changes β commit them, or pass --force to discard them`);
|
|
566
|
+
// With a remote, keep the "push your work" guard so PR-flow mistakes surface.
|
|
567
|
+
if (hasRemote && (!s.hasUpstream || s.ahead > 0)) {
|
|
568
|
+
die(`${clone.name}: branch not pushed β push it (or use --push / --pr), or pass --force`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Preserve committed work that isn't on a remote: fetch the clone's branch into the origin
|
|
573
|
+
// as a local 'kage/<name>' branch (origin's working tree is left untouched). This is what
|
|
574
|
+
// makes finish lossless without GitHub β the commits land in the origin's git, ready to merge.
|
|
575
|
+
if (hasUnpreservedCommits(originRepo, clone.dir, s)) {
|
|
576
|
+
const head = git(clone.dir, ["rev-parse", "HEAD"]).out;
|
|
577
|
+
// Always a unique ref (name + short sha) so reusing a clone name never collides with an
|
|
578
|
+
// earlier preserved branch β which would either abort the fetch (non-ff) or clobber it.
|
|
579
|
+
const target = `kage/${slug(clone.name)}-${head.slice(0, 7)}`;
|
|
580
|
+
const r = git(originRepo, ["fetch", clone.dir, `${s.branch}:refs/heads/${target}`]);
|
|
581
|
+
if (!r.ok) die(`failed to preserve the clone's branch into the origin: ${r.err}`);
|
|
582
|
+
info(`πΏ preserved the clone's commits in the origin as ${paint.cyan(target)} (merge with: git merge ${target})`);
|
|
504
583
|
}
|
|
505
584
|
|
|
506
|
-
const n = mergeBack(clone.dir, originRepo
|
|
585
|
+
const n = mergeBack(clone.dir, originRepo);
|
|
507
586
|
try {
|
|
508
587
|
process.chdir(originRepo);
|
|
509
588
|
} catch {
|
|
@@ -512,10 +591,7 @@ async function cmdFinish(argv) {
|
|
|
512
591
|
rmSync(clone.dir, { recursive: true, force: true });
|
|
513
592
|
|
|
514
593
|
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
|
-
}
|
|
594
|
+
if (insideClone) leaveClone(originRepo);
|
|
519
595
|
}
|
|
520
596
|
|
|
521
597
|
async function cmdRm(argv) {
|
|
@@ -528,7 +604,7 @@ async function cmdRm(argv) {
|
|
|
528
604
|
|
|
529
605
|
if (!force) {
|
|
530
606
|
const s = cloneStatus(clone.dir);
|
|
531
|
-
if (s.dirty ||
|
|
607
|
+
if (s.dirty || hasUnpreservedCommits(originRepo, clone.dir, s)) {
|
|
532
608
|
die(`${clone.name} has local-only work β use 'kage finish' to keep it, or 'kage rm --force' to discard`);
|
|
533
609
|
}
|
|
534
610
|
if (!(await confirm(`Discard clone ${clone.name} without merging its memory?`))) return info("aborted");
|
|
@@ -546,44 +622,56 @@ async function cmdRm(argv) {
|
|
|
546
622
|
}
|
|
547
623
|
rmSync(clone.dir, { recursive: true, force: true });
|
|
548
624
|
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)")`));
|
|
552
|
-
}
|
|
625
|
+
if (insideClone) leaveClone(originRepo);
|
|
553
626
|
}
|
|
554
627
|
|
|
555
628
|
function cmdList(argv) {
|
|
556
629
|
const { flags } = parseArgs(argv);
|
|
557
|
-
const
|
|
558
|
-
if (!
|
|
630
|
+
const here = repoTopLevel(process.cwd());
|
|
631
|
+
if (!here) die("not a git repository");
|
|
632
|
+
// Works from inside a clone too: resolve to the origin via the marker, then list its clones.
|
|
633
|
+
const repoRoot = readMarker(here)?.originRepo || here;
|
|
559
634
|
const clones = listClones(repoRoot);
|
|
560
635
|
if (clones.length === 0) return info("No shadow clones.");
|
|
561
636
|
|
|
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
637
|
info(paint.bold(`Shadow clones of ${basename(repoRoot)}:`));
|
|
570
638
|
info("");
|
|
571
|
-
for (const
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
639
|
+
for (const c of clones) {
|
|
640
|
+
const s = cloneStatus(c.dir);
|
|
641
|
+
const pr = flags.pr ? prInfo(c.dir, s.branch) : undefined;
|
|
642
|
+
|
|
643
|
+
// header: status glyph Β· name Β· branch Β· age
|
|
644
|
+
const glyph = s.dirty ? paint.yellow("β") : isSafeToClean(s) ? paint.green("β") : paint.cyan("Β·");
|
|
645
|
+
const age = c.marker?.createdAt ? paint.dim(`created ${ago(c.marker.createdAt)}`) : "";
|
|
646
|
+
info(` ${glyph} ${paint.bold(c.name)} ${paint.cyan(s.branch)} ${age}`);
|
|
647
|
+
|
|
648
|
+
// detail: working-tree state Β· sync Β· PR Β· safe-to-clean
|
|
649
|
+
const parts = [];
|
|
650
|
+
if (s.dirty) {
|
|
651
|
+
let d = `${s.dirtyCount} changed`;
|
|
652
|
+
if (s.added || s.removed) d += ` (${paint.green(`+${s.added}`)} ${paint.red(`-${s.removed}`)})`;
|
|
653
|
+
parts.push(paint.yellow(d));
|
|
654
|
+
} else {
|
|
655
|
+
parts.push(paint.green("clean"));
|
|
656
|
+
}
|
|
657
|
+
if (!s.hasUpstream) parts.push(paint.dim("not pushed"));
|
|
575
658
|
else {
|
|
576
|
-
const
|
|
577
|
-
if (s.ahead)
|
|
578
|
-
if (s.behind)
|
|
579
|
-
|
|
659
|
+
const sync = [];
|
|
660
|
+
if (s.ahead) sync.push(`β${s.ahead}`);
|
|
661
|
+
if (s.behind) sync.push(`β${s.behind}`);
|
|
662
|
+
parts.push(sync.length ? sync.join(" ") : paint.dim("in sync"));
|
|
580
663
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
info(`
|
|
664
|
+
if (pr) parts.push(prState(pr));
|
|
665
|
+
if (isSafeToClean(s)) parts.push(paint.green("safe to clean"));
|
|
666
|
+
info(` ${parts.join(paint.dim(" Β· "))}`);
|
|
667
|
+
|
|
668
|
+
// last commit on the branch
|
|
669
|
+
if (s.lastCommit) {
|
|
670
|
+
info(paint.dim(` last: ${s.lastCommit.sha} "${s.lastCommit.subject}" (${s.lastCommit.when})`));
|
|
671
|
+
}
|
|
672
|
+
info("");
|
|
584
673
|
}
|
|
585
|
-
info("");
|
|
586
|
-
info(paint.dim(" finish <name> to merge & remove Β· rm <name> to discard Β· list --pr for PR status"));
|
|
674
|
+
info(paint.dim(" finish <name> to merge & remove Β· rm <name> to discard Β· status --pr for PR status"));
|
|
587
675
|
}
|
|
588
676
|
|
|
589
677
|
function prState(pr) {
|
|
@@ -634,7 +722,7 @@ kage() {
|
|
|
634
722
|
}
|
|
635
723
|
if [ -n "$ZSH_VERSION" ]; then
|
|
636
724
|
_kage() {
|
|
637
|
-
if (( CURRENT == 2 )); then compadd new
|
|
725
|
+
if (( CURRENT == 2 )); then compadd new status finish rm pull; return; fi
|
|
638
726
|
case "\${words[2]}" in
|
|
639
727
|
finish|rm) compadd $(command kage __clones 2>/dev/null);;
|
|
640
728
|
esac
|
|
@@ -643,7 +731,7 @@ if [ -n "$ZSH_VERSION" ]; then
|
|
|
643
731
|
elif [ -n "$BASH_VERSION" ]; then
|
|
644
732
|
_kage() {
|
|
645
733
|
local cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
646
|
-
if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=( $(compgen -W "new
|
|
734
|
+
if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=( $(compgen -W "new status finish rm pull" -- "$cur") ); return; fi
|
|
647
735
|
case "\${COMP_WORDS[1]}" in
|
|
648
736
|
finish|rm) COMPREPLY=( $(compgen -W "$(command kage __clones 2>/dev/null)" -- "$cur") );;
|
|
649
737
|
esac
|
|
@@ -660,20 +748,42 @@ function cmdClones() {
|
|
|
660
748
|
const HELP = `kage π₯· β Shadow Clone Jutsu for your git repo
|
|
661
749
|
|
|
662
750
|
Usage:
|
|
663
|
-
kage [path] [--name <x>]
|
|
751
|
+
kage [path] [--name <x>] clone repo + launch a fresh pi
|
|
664
752
|
(no args inside a repo with clones: interactive menu)
|
|
665
|
-
kage
|
|
666
|
-
kage finish [name] [--force] [--push] [--pr]
|
|
667
|
-
(--push
|
|
753
|
+
kage status [--pr] dashboard of clones (--pr adds PR status via gh)
|
|
754
|
+
kage finish [name] [--force] [--push] [--pr] preserve work -> merge memory back -> delete clone
|
|
755
|
+
(--push/--pr use a remote; with no remote the branch is
|
|
756
|
+
kept in the origin as a local 'kage/<name>-<sha>' branch)
|
|
668
757
|
kage rm [name] [--force] discard a clone without merging
|
|
669
758
|
kage pull <path...> (inside a clone) copy files back to the origin
|
|
670
759
|
kage shell-init shell wrapper (cd-back) + tab completion
|
|
760
|
+
kage --help | --version show this help / print the version
|
|
761
|
+
|
|
762
|
+
With no args inside a repo that already has clones, kage opens an interactive menu
|
|
763
|
+
(create a new clone, or enter / finish / remove an existing one).
|
|
671
764
|
|
|
672
765
|
Options:
|
|
673
|
-
--name <x> name the clone folder /<repo>--<x> (default: kage-<timestamp>)
|
|
674
|
-
|
|
675
|
-
--
|
|
676
|
-
--
|
|
766
|
+
--name <x> name the clone folder /<repo>--<x> (default: kage-<timestamp>); skips the name prompt
|
|
767
|
+
(sanitized to a git-ref-safe slug, since the name is also used as a branch name)
|
|
768
|
+
--pr (finish) push the branch and open a GitHub PR via gh, then finish
|
|
769
|
+
--push (finish) push the branch before finishing (implied by --pr)
|
|
770
|
+
--force skip the safety checks: uncommitted/unpushed guard (finish) or local-only guard (rm)
|
|
771
|
+
|
|
772
|
+
Examples:
|
|
773
|
+
kage # clone the current repo, pick a name, open a fresh pi to work in
|
|
774
|
+
kage --name fix-login # same, but name the clone ../<repo>--fix-login (no prompt)
|
|
775
|
+
kage ~/code/other-repo # clone a different repo instead of the current dir
|
|
776
|
+
|
|
777
|
+
kage status # in the origin: list your clones + their git state
|
|
778
|
+
kage status --pr # ...also show each clone's PR state (needs gh)
|
|
779
|
+
|
|
780
|
+
# after you've worked in a clone (committed your changes), from the origin:
|
|
781
|
+
kage finish fix-login # you already pushed -> merge memory back, delete the clone
|
|
782
|
+
kage finish fix-login --pr # push the branch + open a PR via gh, then finish
|
|
783
|
+
kage finish fix-login --force # finish even with uncommitted/unpushed work
|
|
784
|
+
kage rm experiment # throw a clone away without merging its memory
|
|
785
|
+
|
|
786
|
+
kage pull .env # inside a clone: copy a gitignored file back to the origin
|
|
677
787
|
|
|
678
788
|
Env:
|
|
679
789
|
KAGE_SESSIONS_DIR pi session storage (default: ~/.pi/agent/sessions)`;
|
|
@@ -684,7 +794,8 @@ async function main() {
|
|
|
684
794
|
case undefined:
|
|
685
795
|
case "new":
|
|
686
796
|
return cmdNew(sub === "new" ? rest : process.argv.slice(2));
|
|
687
|
-
case "
|
|
797
|
+
case "status":
|
|
798
|
+
case "list": // alias
|
|
688
799
|
return cmdList(rest);
|
|
689
800
|
case "finish":
|
|
690
801
|
return cmdFinish(rest);
|
|
@@ -702,9 +813,14 @@ async function main() {
|
|
|
702
813
|
return info(HELP);
|
|
703
814
|
case "-v":
|
|
704
815
|
case "--version":
|
|
705
|
-
return info(
|
|
816
|
+
return info(VERSION);
|
|
706
817
|
default:
|
|
707
|
-
|
|
818
|
+
// `kage <path>` clones another repo, but a bare word that isn't an existing directory
|
|
819
|
+
// is a mistyped command (e.g. `kage statsu`) β fail clearly instead of "not a git repository".
|
|
820
|
+
if (!sub.startsWith("-") && !(existsSync(resolve(sub)) && statSync(resolve(sub)).isDirectory())) {
|
|
821
|
+
die(`unknown command or path: ${sub} (run 'kage --help')`);
|
|
822
|
+
}
|
|
823
|
+
return cmdNew(process.argv.slice(2)); // `kage <path>` or `kage <flags>`
|
|
708
824
|
}
|
|
709
825
|
}
|
|
710
826
|
|
package/package.json
CHANGED