pi-kage 0.2.1 β 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 +50 -33
- package/bin/kage.mjs +305 -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
|
|
@@ -71,14 +72,14 @@ Requires **git**, [**pi**](https://github.com/earendil-works), and **Node β₯ 18
|
|
|
71
72
|
```
|
|
72
73
|
origin repo (you) shadow clone (independent copy)
|
|
73
74
|
βββββββββββββββββ ββββββββββββββββββββββββββββββ
|
|
74
|
-
$ kage --name fix-login
|
|
75
|
-
$ pi
|
|
75
|
+
$ kage --name fix-login βcopy + historyββΊ ../my-app--fix-login
|
|
76
|
+
$ pi (fresh session; origin history resumable)
|
|
76
77
|
Β· git switch -c fix-login
|
|
77
78
|
Β· edit / commit / push / open PR
|
|
78
79
|
Β· quit pi
|
|
79
|
-
$ kage finish fix-login
|
|
80
|
+
$ kage finish fix-login ββnew sessionsββ (the clone's .jsonl, copied back)
|
|
80
81
|
Β· safety check (committed? pushed?)
|
|
81
|
-
Β· merge
|
|
82
|
+
Β· merge the clone's new sessions into ~/.pi
|
|
82
83
|
Β· delete the clone folder
|
|
83
84
|
code arrives via the merged GitHub PR β
|
|
84
85
|
```
|
|
@@ -88,17 +89,15 @@ Requires **git**, [**pi**](https://github.com/earendil-works), and **Node β₯ 18
|
|
|
88
89
|
```bash
|
|
89
90
|
cd ~/code/my-app
|
|
90
91
|
|
|
91
|
-
kage # clone . β ../my-app--kage-<ts>,
|
|
92
|
-
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
|
|
93
94
|
kage /path/to/other-repo # clone a different repo (path defaults to cwd)
|
|
94
|
-
kage --blank # don't carry any context into the clone
|
|
95
|
-
kage --recent 10 # seed the last 10 turns instead of the default 5
|
|
96
95
|
|
|
97
96
|
# back in the origin after you quit the clone's pi:
|
|
98
97
|
kage # no args inside a repo with clones -> interactive menu
|
|
99
|
-
kage
|
|
100
|
-
kage
|
|
101
|
-
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
|
|
102
101
|
kage finish fix-login --pr # push the branch + open a PR (via gh), then finish
|
|
103
102
|
kage finish --force # skip the uncommitted/unpushed guard
|
|
104
103
|
kage rm old-experiment # discard a clone without merging (refuses if it has local-only work)
|
|
@@ -125,9 +124,9 @@ for subcommands and clone names.
|
|
|
125
124
|
|
|
126
125
|
| Command | Run from | What it does |
|
|
127
126
|
|---|---|---|
|
|
128
|
-
| `kage [path] [--name x]
|
|
129
|
-
| `kage
|
|
130
|
-
| `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. |
|
|
131
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. |
|
|
132
131
|
| `kage pull <path...>` | inside a clone | Copy specific files/dirs (even gitignored ones) back to the origin at the same relative path. |
|
|
133
132
|
| `kage shell-init` | shell rc | Print a shell wrapper (cd-back after `finish`/`rm`) + tab completion. Use `eval "$(kage shell-init)"`. |
|
|
@@ -138,23 +137,41 @@ for subcommands and clone names.
|
|
|
138
137
|
Four invariants keep parallel work safe and lossless:
|
|
139
138
|
|
|
140
139
|
1. **Isolation** β a clone is a full independent copy with its own `.git`.
|
|
141
|
-
2. **Code flows back
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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.
|
|
147
152
|
4. **The origin is read-only to kage** β it only copies out and writes session memory; it never
|
|
148
153
|
touches the origin's working tree, even while another session is live there.
|
|
149
154
|
|
|
150
155
|
## Notes & caveats
|
|
151
156
|
|
|
152
157
|
- The copy is a snapshot of the origin's **current** state, **including uncommitted changes**.
|
|
153
|
-
- kage **doesn't create a branch** β the clone stays on the origin's current branch
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
-
|
|
157
|
-
|
|
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`.
|
|
158
175
|
- **Submodules**: a submodule's `.git` pointer is an absolute path and breaks on copy β run
|
|
159
176
|
`git submodule update --init` in the clone.
|
|
160
177
|
- Non-APFS / non-reflink filesystems fall back to a full (heavier) copy.
|
package/bin/kage.mjs
CHANGED
|
@@ -8,29 +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
|
|
|
31
|
-
const VERSION = "0.
|
|
33
|
+
const VERSION = "0.3.0"; // keep in sync with package.json (enforced by test)
|
|
32
34
|
const MARKER = ".kage.json";
|
|
33
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
|
|
34
37
|
|
|
35
38
|
// ββ output helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
36
39
|
const TTY = process.stderr.isTTY;
|
|
@@ -77,14 +80,6 @@ function readMarker(dir) {
|
|
|
77
80
|
}
|
|
78
81
|
}
|
|
79
82
|
|
|
80
|
-
function mtime(p) {
|
|
81
|
-
try {
|
|
82
|
-
return statSync(p).mtimeMs;
|
|
83
|
-
} catch {
|
|
84
|
-
return 0;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
83
|
/** Copy a whole directory: clonefile on macOS, reflink on Linux, plain copy as fallback. */
|
|
89
84
|
function copyTree(src, dst) {
|
|
90
85
|
const isMac = process.platform === "darwin";
|
|
@@ -93,12 +88,66 @@ function copyTree(src, dst) {
|
|
|
93
88
|
return r;
|
|
94
89
|
}
|
|
95
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
|
+
|
|
96
130
|
function tsName() {
|
|
97
131
|
const d = new Date();
|
|
98
132
|
const p = (n) => String(n).padStart(2, "0");
|
|
99
133
|
return `kage-${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
100
134
|
}
|
|
101
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
|
+
|
|
102
151
|
function parseArgs(argv) {
|
|
103
152
|
const positional = [];
|
|
104
153
|
const flags = {};
|
|
@@ -129,7 +178,8 @@ function listClones(originRepo) {
|
|
|
129
178
|
function cloneStatus(dir) {
|
|
130
179
|
const branch = git(dir, ["rev-parse", "--abbrev-ref", "HEAD"]).out || "?";
|
|
131
180
|
const st = git(dir, ["status", "--porcelain"]).out;
|
|
132
|
-
const
|
|
181
|
+
const changed = st.split("\n").filter((l) => l.trim() && l.slice(3).trim() !== MARKER);
|
|
182
|
+
const dirty = changed.length > 0;
|
|
133
183
|
const up = git(dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
134
184
|
let ahead = 0;
|
|
135
185
|
let behind = 0;
|
|
@@ -141,7 +191,23 @@ function cloneStatus(dir) {
|
|
|
141
191
|
ahead = a || 0;
|
|
142
192
|
}
|
|
143
193
|
}
|
|
144
|
-
|
|
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`;
|
|
145
211
|
}
|
|
146
212
|
|
|
147
213
|
/** Best-effort PR lookup via gh; returns { state, number, url } or undefined. */
|
|
@@ -158,6 +224,14 @@ function prInfo(dir, branch) {
|
|
|
158
224
|
/** True when the clone has no local-only work (clean + pushed) -> safe to remove. */
|
|
159
225
|
const isSafeToClean = (s) => !s.dirty && s.hasUpstream && s.ahead === 0;
|
|
160
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
|
+
|
|
161
235
|
// ββ interactive picker (TUI-lite, arrow keys, no deps) βββββββββββββββββββββββ
|
|
162
236
|
/** Returns the chosen index, or -1 when cancelled / non-interactive. */
|
|
163
237
|
function select(title, labels) {
|
|
@@ -193,10 +267,13 @@ function select(title, labels) {
|
|
|
193
267
|
});
|
|
194
268
|
}
|
|
195
269
|
|
|
196
|
-
async function ask(prompt) {
|
|
270
|
+
async function ask(prompt, prefill) {
|
|
197
271
|
if (!process.stdin.isTTY) return "";
|
|
198
272
|
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
199
|
-
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
|
+
});
|
|
200
277
|
return a.trim();
|
|
201
278
|
}
|
|
202
279
|
|
|
@@ -228,87 +305,50 @@ async function pickClone(action, name) {
|
|
|
228
305
|
return { originRepo, clone: clones[idx] };
|
|
229
306
|
}
|
|
230
307
|
|
|
231
|
-
// ββ
|
|
308
|
+
// ββ copy the origin's session history into the clone βββββββββββββββββββββββββ
|
|
232
309
|
/**
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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).
|
|
237
315
|
*/
|
|
238
|
-
function
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
let preview;
|
|
242
|
-
let srcFile;
|
|
243
|
-
|
|
244
|
-
if (!blank) {
|
|
245
|
-
const srcDir = sessionDirFor(originRepo);
|
|
246
|
-
if (existsSync(srcDir)) {
|
|
247
|
-
const files = readdirSync(srcDir)
|
|
248
|
-
.filter((f) => f.endsWith(".jsonl"))
|
|
249
|
-
.map((f) => ({ f, m: mtime(join(srcDir, f)) }))
|
|
250
|
-
.sort((a, b) => b.m - a.m);
|
|
251
|
-
if (files.length) {
|
|
252
|
-
srcFile = join(srcDir, files[0].f);
|
|
253
|
-
const lines = readFileSync(srcFile, "utf8").split("\n").filter((l) => l.trim());
|
|
254
|
-
if (lines.length >= 2) {
|
|
255
|
-
const entries = lines.slice(1).map((l) => JSON.parse(l));
|
|
256
|
-
const byId = new Map(entries.map((e) => [e.id, e]));
|
|
257
|
-
let cur = entries[entries.length - 1];
|
|
258
|
-
const branch = [];
|
|
259
|
-
while (cur) {
|
|
260
|
-
branch.unshift(cur);
|
|
261
|
-
cur = cur.parentId ? byId.get(cur.parentId) : undefined;
|
|
262
|
-
}
|
|
263
|
-
const messages = branch.filter((e) => e.type === "message");
|
|
264
|
-
const userIdx = [];
|
|
265
|
-
messages.forEach((e, i) => {
|
|
266
|
-
if (e.message?.role === "user") userIdx.push(i);
|
|
267
|
-
});
|
|
268
|
-
const start = userIdx.length > recentTurns ? userIdx[userIdx.length - recentTurns] : 0;
|
|
269
|
-
kept = messages.slice(start);
|
|
270
|
-
turns = userIdx.length > recentTurns ? recentTurns : userIdx.length;
|
|
271
|
-
const fu = kept.find((e) => e.message?.role === "user");
|
|
272
|
-
preview = fu ? snippet(fu.message.content) : undefined;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
316
|
+
function copyOriginHistory(originRepo, cloneDir) {
|
|
317
|
+
const srcDir = sessionDirFor(originRepo);
|
|
318
|
+
if (!existsSync(srcDir)) return 0;
|
|
278
319
|
const destDir = sessionDirFor(cloneDir);
|
|
279
320
|
mkdirSync(destDir, { recursive: true });
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
let
|
|
286
|
-
for (const
|
|
287
|
-
|
|
288
|
-
|
|
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++;
|
|
289
338
|
}
|
|
290
|
-
|
|
291
|
-
const curBranch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out || "the base branch";
|
|
292
|
-
const hintId = randomUUID().replace(/-/g, "").slice(0, 8);
|
|
293
|
-
const hint =
|
|
294
|
-
`[kage] You are working in a shadow clone of ${originRepo} (folder: ${cloneDir}), ` +
|
|
295
|
-
`currently on branch "${curBranch}". Before committing, create a dedicated feature branch ` +
|
|
296
|
-
`(git switch -c <name>), then push it and open a PR β do not commit directly to "${curBranch}".`;
|
|
297
|
-
outl.push(
|
|
298
|
-
JSON.stringify({ type: "custom_message", id: hintId, parentId: prev, timestamp: ts, customType: "kage", content: hint, display: true }),
|
|
299
|
-
);
|
|
300
|
-
writeFileSync(join(destDir, fname), outl.join("\n") + "\n");
|
|
301
|
-
return { seedFile: fname, seedLeafId: hintId, turns, preview, seeded: kept.length > 0 };
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function snippet(content) {
|
|
305
|
-
const text = typeof content === "string" ? content : (content || []).map((c) => c.text || "").join(" ");
|
|
306
|
-
const one = text.replace(/\s+/g, " ").trim();
|
|
307
|
-
return one.length > 60 ? one.slice(0, 57) + "β¦" : one;
|
|
339
|
+
return n;
|
|
308
340
|
}
|
|
309
341
|
|
|
310
|
-
// ββ merge
|
|
311
|
-
|
|
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) {
|
|
312
352
|
const srcDir = sessionDirFor(cloneDir);
|
|
313
353
|
if (!existsSync(srcDir)) return 0;
|
|
314
354
|
const destDir = sessionDirFor(originRepo);
|
|
@@ -316,36 +356,52 @@ function mergeBack(cloneDir, originRepo, marker) {
|
|
|
316
356
|
let n = 0;
|
|
317
357
|
for (const f of readdirSync(srcDir)) {
|
|
318
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;
|
|
319
361
|
const dest = join(destDir, f);
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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;
|
|
323
396
|
let header;
|
|
324
397
|
try {
|
|
325
|
-
header = JSON.parse(
|
|
398
|
+
header = JSON.parse(src[0]);
|
|
326
399
|
} catch {
|
|
327
400
|
continue;
|
|
328
401
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (marker?.seedFile === f && marker?.seedLeafId) {
|
|
333
|
-
const idx = body.findIndex((l) => {
|
|
334
|
-
try {
|
|
335
|
-
return JSON.parse(l).id === marker.seedLeafId;
|
|
336
|
-
} catch {
|
|
337
|
-
return false;
|
|
338
|
-
}
|
|
339
|
-
});
|
|
340
|
-
if (idx >= 0) {
|
|
341
|
-
body = body.slice(idx + 1);
|
|
342
|
-
if (body.length === 0) continue;
|
|
343
|
-
const first = JSON.parse(body[0]);
|
|
344
|
-
first.parentId = null;
|
|
345
|
-
body[0] = JSON.stringify(first);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
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");
|
|
349
405
|
n++;
|
|
350
406
|
}
|
|
351
407
|
try {
|
|
@@ -356,16 +412,25 @@ function mergeBack(cloneDir, originRepo, marker) {
|
|
|
356
412
|
return n;
|
|
357
413
|
}
|
|
358
414
|
|
|
359
|
-
/**
|
|
360
|
-
|
|
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) {
|
|
361
422
|
const f = process.env.KAGE_CD_FILE;
|
|
362
423
|
if (f) {
|
|
363
424
|
try {
|
|
364
|
-
writeFileSync(f,
|
|
425
|
+
writeFileSync(f, originRepo);
|
|
426
|
+
info(paint.dim(` β© back to ${originRepo}`));
|
|
427
|
+
return;
|
|
365
428
|
} catch {
|
|
366
|
-
/*
|
|
429
|
+
/* fall through to the manual hint */
|
|
367
430
|
}
|
|
368
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`));
|
|
369
434
|
}
|
|
370
435
|
|
|
371
436
|
function launchPi(cwd, args) {
|
|
@@ -382,7 +447,7 @@ async function cmdNew(argv) {
|
|
|
382
447
|
let path = positional[0];
|
|
383
448
|
|
|
384
449
|
// Interactive launcher: `kage` with no args, inside a repo that already has clones.
|
|
385
|
-
if (!path && !flags.name &&
|
|
450
|
+
if (!path && !flags.name && process.stdin.isTTY) {
|
|
386
451
|
const repoRoot = repoTopLevel(process.cwd());
|
|
387
452
|
const clones = repoRoot ? listClones(repoRoot) : [];
|
|
388
453
|
if (clones.length > 0) {
|
|
@@ -409,35 +474,38 @@ async function cmdNew(argv) {
|
|
|
409
474
|
if (act === 2) return cmdRm([clone.name]);
|
|
410
475
|
return info("cancelled");
|
|
411
476
|
}
|
|
412
|
-
|
|
413
|
-
if (nm) flags.name = nm;
|
|
477
|
+
// idx === 0: "create" β fall through to the name prompt below.
|
|
414
478
|
}
|
|
415
479
|
}
|
|
416
480
|
|
|
417
481
|
const targetPath = path ? resolve(path) : process.cwd();
|
|
418
|
-
const blank = !!flags.blank;
|
|
419
|
-
const recent = Math.max(1, parseInt(flags.recent, 10) || 5);
|
|
420
482
|
|
|
421
483
|
const repoRoot = repoTopLevel(targetPath);
|
|
422
484
|
if (!repoRoot) die(`not a git repository: ${targetPath}`);
|
|
423
485
|
if (existsSync(join(repoRoot, MARKER))) die("already inside a clone; run kage from the origin repo");
|
|
424
486
|
|
|
425
|
-
|
|
426
|
-
|
|
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);
|
|
427
497
|
const cloneDir = join(dirname(repoRoot), `${basename(repoRoot)}--${safe}`);
|
|
428
498
|
if (existsSync(cloneDir)) die(`directory already exists: ${cloneDir}`);
|
|
429
499
|
|
|
430
|
-
const cp =
|
|
500
|
+
const cp = await copyRepo(repoRoot, cloneDir);
|
|
431
501
|
if (!cp.ok) die(`copy failed: ${cp.err}`);
|
|
432
502
|
|
|
433
503
|
// kage does NOT create a branch β the clone stays on the origin's current branch.
|
|
434
|
-
const
|
|
504
|
+
const histN = copyOriginHistory(repoRoot, cloneDir);
|
|
435
505
|
const marker = {
|
|
436
506
|
originRepo: repoRoot,
|
|
437
507
|
name: safe,
|
|
438
508
|
createdAt: new Date().toISOString(),
|
|
439
|
-
seedFile: built.seedFile,
|
|
440
|
-
seedLeafId: built.seedLeafId,
|
|
441
509
|
};
|
|
442
510
|
writeFileSync(join(cloneDir, MARKER), JSON.stringify(marker, null, 2));
|
|
443
511
|
|
|
@@ -445,17 +513,11 @@ async function cmdNew(argv) {
|
|
|
445
513
|
info("");
|
|
446
514
|
info(`π₯· ${paint.bold("Shadow clone ready")}: ${cloneDir}`);
|
|
447
515
|
info(` origin: ${repoRoot} branch: ${paint.cyan(curBranch)}`);
|
|
448
|
-
if (
|
|
449
|
-
info(` seeded with the last ${built.turns} turn(s) + a kage reminder (pi -c resumes them)`);
|
|
450
|
-
if (built.preview) info(paint.dim(` β³ "${built.preview}"`));
|
|
451
|
-
} else {
|
|
452
|
-
info(paint.dim(" blank clone (kage reminder only)"));
|
|
453
|
-
}
|
|
454
|
-
info(paint.yellow(` β create a feature branch before committing (clone is on ${curBranch})`));
|
|
516
|
+
if (histN > 0) info(paint.dim(` origin's ${histN} session(s) are available via resume (pi: pick from the list)`));
|
|
455
517
|
info(paint.dim(` when done: kage finish ${safe}`));
|
|
456
518
|
info("");
|
|
457
519
|
|
|
458
|
-
launchPi(cloneDir, [
|
|
520
|
+
launchPi(cloneDir, []);
|
|
459
521
|
info("");
|
|
460
522
|
info(`β©οΈ left the clone's pi. To finish: ${paint.bold(`kage finish ${safe}`)}`);
|
|
461
523
|
}
|
|
@@ -495,16 +557,32 @@ async function cmdFinish(argv) {
|
|
|
495
557
|
}
|
|
496
558
|
}
|
|
497
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
|
+
|
|
498
564
|
if (!force) {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
if (s.
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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})`);
|
|
505
583
|
}
|
|
506
584
|
|
|
507
|
-
const n = mergeBack(clone.dir, originRepo
|
|
585
|
+
const n = mergeBack(clone.dir, originRepo);
|
|
508
586
|
try {
|
|
509
587
|
process.chdir(originRepo);
|
|
510
588
|
} catch {
|
|
@@ -513,10 +591,7 @@ async function cmdFinish(argv) {
|
|
|
513
591
|
rmSync(clone.dir, { recursive: true, force: true });
|
|
514
592
|
|
|
515
593
|
info(`π¨ Clone dispelled: merged ${n} session(s) back, removed ${clone.dir}`);
|
|
516
|
-
if (insideClone)
|
|
517
|
-
requestCd(originRepo);
|
|
518
|
-
info(paint.dim(` cd back to: ${originRepo} (auto with: eval "$(kage shell-init)")`));
|
|
519
|
-
}
|
|
594
|
+
if (insideClone) leaveClone(originRepo);
|
|
520
595
|
}
|
|
521
596
|
|
|
522
597
|
async function cmdRm(argv) {
|
|
@@ -529,7 +604,7 @@ async function cmdRm(argv) {
|
|
|
529
604
|
|
|
530
605
|
if (!force) {
|
|
531
606
|
const s = cloneStatus(clone.dir);
|
|
532
|
-
if (s.dirty ||
|
|
607
|
+
if (s.dirty || hasUnpreservedCommits(originRepo, clone.dir, s)) {
|
|
533
608
|
die(`${clone.name} has local-only work β use 'kage finish' to keep it, or 'kage rm --force' to discard`);
|
|
534
609
|
}
|
|
535
610
|
if (!(await confirm(`Discard clone ${clone.name} without merging its memory?`))) return info("aborted");
|
|
@@ -547,44 +622,56 @@ async function cmdRm(argv) {
|
|
|
547
622
|
}
|
|
548
623
|
rmSync(clone.dir, { recursive: true, force: true });
|
|
549
624
|
info(`π Removed clone ${clone.name} (${clone.dir})`);
|
|
550
|
-
if (insideClone)
|
|
551
|
-
requestCd(originRepo);
|
|
552
|
-
info(paint.dim(` cd back to: ${originRepo} (auto with: eval "$(kage shell-init)")`));
|
|
553
|
-
}
|
|
625
|
+
if (insideClone) leaveClone(originRepo);
|
|
554
626
|
}
|
|
555
627
|
|
|
556
628
|
function cmdList(argv) {
|
|
557
629
|
const { flags } = parseArgs(argv);
|
|
558
|
-
const
|
|
559
|
-
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;
|
|
560
634
|
const clones = listClones(repoRoot);
|
|
561
635
|
if (clones.length === 0) return info("No shadow clones.");
|
|
562
636
|
|
|
563
|
-
const rows = clones.map((c) => {
|
|
564
|
-
const s = cloneStatus(c.dir);
|
|
565
|
-
return { c, s, pr: flags.pr ? prInfo(c.dir, s.branch) : undefined };
|
|
566
|
-
});
|
|
567
|
-
const nameW = Math.max(...rows.map((r) => r.c.name.length), 4);
|
|
568
|
-
const brW = Math.max(...rows.map((r) => r.s.branch.length), 6);
|
|
569
|
-
|
|
570
637
|
info(paint.bold(`Shadow clones of ${basename(repoRoot)}:`));
|
|
571
638
|
info("");
|
|
572
|
-
for (const
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
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"));
|
|
576
658
|
else {
|
|
577
|
-
const
|
|
578
|
-
if (s.ahead)
|
|
579
|
-
if (s.behind)
|
|
580
|
-
|
|
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"));
|
|
581
663
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
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("");
|
|
585
673
|
}
|
|
586
|
-
info("");
|
|
587
|
-
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"));
|
|
588
675
|
}
|
|
589
676
|
|
|
590
677
|
function prState(pr) {
|
|
@@ -635,7 +722,7 @@ kage() {
|
|
|
635
722
|
}
|
|
636
723
|
if [ -n "$ZSH_VERSION" ]; then
|
|
637
724
|
_kage() {
|
|
638
|
-
if (( CURRENT == 2 )); then compadd new
|
|
725
|
+
if (( CURRENT == 2 )); then compadd new status finish rm pull; return; fi
|
|
639
726
|
case "\${words[2]}" in
|
|
640
727
|
finish|rm) compadd $(command kage __clones 2>/dev/null);;
|
|
641
728
|
esac
|
|
@@ -644,7 +731,7 @@ if [ -n "$ZSH_VERSION" ]; then
|
|
|
644
731
|
elif [ -n "$BASH_VERSION" ]; then
|
|
645
732
|
_kage() {
|
|
646
733
|
local cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
647
|
-
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
|
|
648
735
|
case "\${COMP_WORDS[1]}" in
|
|
649
736
|
finish|rm) COMPREPLY=( $(compgen -W "$(command kage __clones 2>/dev/null)" -- "$cur") );;
|
|
650
737
|
esac
|
|
@@ -661,20 +748,42 @@ function cmdClones() {
|
|
|
661
748
|
const HELP = `kage π₯· β Shadow Clone Jutsu for your git repo
|
|
662
749
|
|
|
663
750
|
Usage:
|
|
664
|
-
kage [path] [--name <x>]
|
|
751
|
+
kage [path] [--name <x>] clone repo + launch a fresh pi
|
|
665
752
|
(no args inside a repo with clones: interactive menu)
|
|
666
|
-
kage
|
|
667
|
-
kage finish [name] [--force] [--push] [--pr]
|
|
668
|
-
(--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)
|
|
669
757
|
kage rm [name] [--force] discard a clone without merging
|
|
670
758
|
kage pull <path...> (inside a clone) copy files back to the origin
|
|
671
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).
|
|
672
764
|
|
|
673
765
|
Options:
|
|
674
|
-
--name <x> name the clone folder /<repo>--<x> (default: kage-<timestamp>)
|
|
675
|
-
|
|
676
|
-
--
|
|
677
|
-
--
|
|
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
|
|
678
787
|
|
|
679
788
|
Env:
|
|
680
789
|
KAGE_SESSIONS_DIR pi session storage (default: ~/.pi/agent/sessions)`;
|
|
@@ -685,7 +794,8 @@ async function main() {
|
|
|
685
794
|
case undefined:
|
|
686
795
|
case "new":
|
|
687
796
|
return cmdNew(sub === "new" ? rest : process.argv.slice(2));
|
|
688
|
-
case "
|
|
797
|
+
case "status":
|
|
798
|
+
case "list": // alias
|
|
689
799
|
return cmdList(rest);
|
|
690
800
|
case "finish":
|
|
691
801
|
return cmdFinish(rest);
|
|
@@ -705,7 +815,12 @@ async function main() {
|
|
|
705
815
|
case "--version":
|
|
706
816
|
return info(VERSION);
|
|
707
817
|
default:
|
|
708
|
-
|
|
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>`
|
|
709
824
|
}
|
|
710
825
|
}
|
|
711
826
|
|
package/package.json
CHANGED