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