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