pi-kage 0.3.5 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +18 -3
  2. package/bin/kage.mjs +690 -620
  3. package/package.json +13 -3
package/bin/kage.mjs CHANGED
@@ -22,296 +22,336 @@
22
22
  * kage rm [name] [--force] discard a clone (no merge)
23
23
  * kage pull <path...> (inside a clone) copy files back to the origin
24
24
  */
25
-
26
25
  import { spawn, spawnSync } from "node:child_process";
27
26
  import { randomUUID } from "node:crypto";
28
27
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
29
28
  import { homedir } from "node:os";
30
29
  import { basename, dirname, join, resolve, sep } from "node:path";
31
30
  import readline from "node:readline";
32
-
33
- const VERSION = "0.3.5"; // keep in sync with package.json (enforced by test)
31
+ const VERSION = "0.3.6"; // keep in sync with package.json (enforced by test)
34
32
  const MARKER = ".kage.json";
35
33
  const SESSIONS = process.env.KAGE_SESSIONS_DIR || join(homedir(), ".pi", "agent", "sessions");
36
34
  const RECENT_SESSIONS = 5; // how many of the origin's most-recent sessions to copy into a clone
37
-
35
+ /** Typed accessors for the loose flags bag, so consumers don't re-derive the shape inline. */
36
+ const boolFlag = (flags, name) => Boolean(flags[name]);
37
+ const strFlag = (flags, name) => {
38
+ const v = flags[name];
39
+ return typeof v === "string" ? v : undefined;
40
+ };
38
41
  // ── output helpers ───────────────────────────────────────────────────────────
39
42
  const TTY = process.stderr.isTTY;
40
43
  const col = (code, s) => (TTY ? `\x1b[${code}m${s}\x1b[0m` : s);
41
44
  const paint = {
42
- bold: (s) => col("1", s),
43
- dim: (s) => col("90", s),
44
- red: (s) => col("31", s),
45
- green: (s) => col("32", s),
46
- yellow: (s) => col("33", s),
47
- blue: (s) => col("34", s),
48
- magenta: (s) => col("35", s),
49
- cyan: (s) => col("36", s),
45
+ bold: (s) => col("1", s),
46
+ dim: (s) => col("90", s),
47
+ red: (s) => col("31", s),
48
+ green: (s) => col("32", s),
49
+ yellow: (s) => col("33", s),
50
+ magenta: (s) => col("35", s),
51
+ cyan: (s) => col("36", s),
50
52
  };
51
53
  const info = (msg) => console.error(msg);
52
- const die = (msg) => {
53
- console.error(`✗ ${msg}`);
54
- process.exit(1);
55
- };
56
-
54
+ // A function declaration (not an arrow const) so its `never` return type drives
55
+ // TypeScript's control-flow narrowing at call sites (`if (!x) die(...)` -> x is defined).
56
+ function die(msg) {
57
+ console.error(`✗ ${msg}`);
58
+ process.exit(1);
59
+ }
57
60
  // ── shell / git helpers ──────────────────────────────────────────────────────
58
61
  function sh(cmd, args, opts = {}) {
59
- const r = spawnSync(cmd, args, { encoding: "utf8", ...opts });
60
- return { ok: r.status === 0, out: (r.stdout || "").trim(), err: (r.stderr || "").trim(), code: r.status };
62
+ // `encoding: "utf8"` selects spawnSync's string overload, so stdout/stderr are typed strings.
63
+ const r = spawnSync(cmd, args, { encoding: "utf8", ...opts });
64
+ return { ok: r.status === 0, out: (r.stdout || "").trim(), err: (r.stderr || "").trim() };
61
65
  }
62
66
  const git = (cwd, args) => sh("git", args, { cwd });
63
-
64
67
  /** Absolute path -> pi's session dir name: /a/b -> --a-b-- */
65
68
  const encodeCwd = (abs) => `--${abs.replace(/^\//, "").replace(/\//g, "-")}--`;
66
69
  const sessionDirFor = (repoAbs) => join(SESSIONS, encodeCwd(repoAbs));
67
-
68
70
  function repoTopLevel(cwd) {
69
- const r = git(cwd, ["rev-parse", "--show-toplevel"]);
70
- return r.ok ? r.out : undefined;
71
+ const r = git(cwd, ["rev-parse", "--show-toplevel"]);
72
+ return r.ok ? r.out : undefined;
73
+ }
74
+ /** Validate parsed JSON is a kage marker (only originRepo is required; name/createdAt are best-effort). */
75
+ function isMarker(v) {
76
+ return typeof v === "object" && v !== null && typeof v.originRepo === "string";
71
77
  }
72
-
73
78
  function readMarker(dir) {
74
- const p = join(dir, MARKER);
75
- if (!existsSync(p)) return undefined;
76
- try {
77
- return JSON.parse(readFileSync(p, "utf8"));
78
- } catch {
79
- return undefined;
80
- }
79
+ const p = join(dir, MARKER);
80
+ if (!existsSync(p))
81
+ return undefined;
82
+ try {
83
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
84
+ return isMarker(parsed) ? parsed : undefined;
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
81
89
  }
82
-
83
90
  /** Copy a whole directory: clonefile on macOS, reflink on Linux, plain copy as fallback. */
84
91
  function copyTree(src, dst) {
85
- const isMac = process.platform === "darwin";
86
- let r = sh("cp", isMac ? ["-c", "-R", src, dst] : ["--reflink=auto", "-R", src, dst]);
87
- if (!r.ok) r = sh("cp", ["-R", src, dst]);
88
- return r;
92
+ const isMac = process.platform === "darwin";
93
+ let r = sh("cp", isMac ? ["-c", "-R", src, dst] : ["--reflink=auto", "-R", src, dst]);
94
+ if (!r.ok)
95
+ r = sh("cp", ["-R", src, dst]);
96
+ return r;
89
97
  }
90
-
91
98
  /** An indeterminate spinner on stderr (no-op when not a TTY). Returns { stop() }. */
92
99
  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
- };
100
+ if (!process.stderr.isTTY)
101
+ return { stop() { } };
102
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
103
+ const t0 = Date.now();
104
+ let i = 0;
105
+ const tick = () => {
106
+ const s = ((Date.now() - t0) / 1000).toFixed(1);
107
+ i = (i + 1) % frames.length;
108
+ process.stderr.write(`\r\x1b[2K${paint.cyan(frames[i] ?? "")} ${label} ${paint.dim(`${s}s`)}`);
109
+ };
110
+ tick();
111
+ const id = setInterval(tick, 80);
112
+ return {
113
+ stop() {
114
+ clearInterval(id);
115
+ process.stderr.write("\r\x1b[2K");
116
+ },
117
+ };
109
118
  }
110
-
111
119
  /** Copy the repo with a spinner (the copy can be slow on non-reflink filesystems). */
112
120
  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;
121
+ const isMac = process.platform === "darwin";
122
+ const primary = isMac ? ["-c", "-R", src, dst] : ["--reflink=auto", "-R", src, dst];
123
+ const tryCp = (args) => new Promise((res) => {
124
+ const p = spawn("cp", args, { stdio: ["ignore", "ignore", "pipe"] });
125
+ let err = "";
126
+ p.stderr?.on("data", (d) => (err += d));
127
+ p.on("error", (e) => res({ ok: false, err: e.message }));
128
+ p.on("close", (code) => res({ ok: code === 0, err: err.trim() }));
129
+ });
130
+ const sp = spinner(`copying ${basename(dst)}`);
131
+ let r = await tryCp(primary);
132
+ if (!r.ok)
133
+ r = await tryCp(["-R", src, dst]);
134
+ sp.stop();
135
+ return r;
128
136
  }
129
-
130
137
  function tsName() {
131
- const d = new Date();
132
- const p = (n) => String(n).padStart(2, "0");
133
- return `kage-${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
138
+ const d = new Date();
139
+ const p = (n) => String(n).padStart(2, "0");
140
+ return `kage-${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
134
141
  }
135
-
136
142
  /**
137
143
  * Sanitize a clone name into a slug that's safe as both a folder suffix and a git branch/ref:
138
144
  * ref-illegal chars (spaces, /, ~^:?*[\\, etc.) -> '-', no '..', no leading/trailing '-'/'.',
139
145
  * no trailing '.lock'. Falls back to a timestamp name if it sanitizes to empty.
140
146
  */
141
147
  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();
148
+ const s = name
149
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
150
+ .replace(/\.{2,}/g, ".")
151
+ .replace(/-{2,}/g, "-")
152
+ .replace(/^[-.]+|[-.]+$/g, "")
153
+ .replace(/\.lock$/i, "-lock");
154
+ return s || tsName();
149
155
  }
150
-
151
156
  function parseArgs(argv) {
152
- const positional = [];
153
- const flags = {};
154
- for (let i = 0; i < argv.length; i++) {
155
- const a = argv[i];
156
- if (a.startsWith("--")) {
157
- const eq = a.indexOf("=");
158
- if (eq >= 0) flags[a.slice(2, eq)] = a.slice(eq + 1);
159
- else if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) flags[a.slice(2)] = argv[++i];
160
- else flags[a.slice(2)] = true;
161
- } else positional.push(a);
162
- }
163
- return { positional, flags };
157
+ const positional = [];
158
+ const flags = {};
159
+ for (let i = 0; i < argv.length; i++) {
160
+ const a = argv[i];
161
+ if (a === undefined)
162
+ continue; // unreachable (bounded loop), but proves index safety to the checker
163
+ if (a.startsWith("--")) {
164
+ const eq = a.indexOf("=");
165
+ const next = argv[i + 1];
166
+ if (eq >= 0)
167
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
168
+ else if (next !== undefined && !next.startsWith("--")) {
169
+ flags[a.slice(2)] = next;
170
+ i++;
171
+ }
172
+ else
173
+ flags[a.slice(2)] = true;
174
+ }
175
+ else
176
+ positional.push(a);
177
+ }
178
+ return { positional, flags };
164
179
  }
165
-
166
180
  // ── clone discovery & status ─────────────────────────────────────────────────
167
181
  function listClones(originRepo) {
168
- const parent = dirname(originRepo);
169
- const out = [];
170
- for (const name of readdirSync(parent)) {
171
- const dir = join(parent, name);
172
- const m = readMarker(dir);
173
- if (m && m.originRepo === originRepo) out.push({ dir, name: m.name || basename(dir), marker: m });
174
- }
175
- return out;
182
+ const parent = dirname(originRepo);
183
+ const out = [];
184
+ for (const name of readdirSync(parent)) {
185
+ const dir = join(parent, name);
186
+ const m = readMarker(dir);
187
+ if (m && m.originRepo === originRepo)
188
+ out.push({ dir, name: m.name || basename(dir), marker: m });
189
+ }
190
+ return out;
176
191
  }
177
-
178
192
  function cloneStatus(dir) {
179
- const branch = git(dir, ["rev-parse", "--abbrev-ref", "HEAD"]).out || "?";
180
- const st = git(dir, ["status", "--porcelain"]).out;
181
- const changed = st.split("\n").filter((l) => l.trim() && l.slice(3).trim() !== MARKER);
182
- const dirty = changed.length > 0;
183
- const up = git(dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
184
- let ahead = 0;
185
- let behind = 0;
186
- if (up.ok) {
187
- const rl = git(dir, ["rev-list", "--left-right", "--count", "@{u}...HEAD"]);
188
- if (rl.ok) {
189
- const [b, a] = rl.out.split(/\s+/).map(Number);
190
- behind = b || 0;
191
- ahead = a || 0;
192
- }
193
- }
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 };
193
+ const branch = git(dir, ["rev-parse", "--abbrev-ref", "HEAD"]).out || "?";
194
+ const st = git(dir, ["status", "--porcelain"]).out;
195
+ const changed = st.split("\n").filter((l) => l.trim() && l.slice(3).trim() !== MARKER);
196
+ const dirty = changed.length > 0;
197
+ const up = git(dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
198
+ let ahead = 0;
199
+ let behind = 0;
200
+ if (up.ok) {
201
+ const rl = git(dir, ["rev-list", "--left-right", "--count", "@{u}...HEAD"]);
202
+ if (rl.ok) {
203
+ const [b, a] = rl.out.split(/\s+/).map(Number);
204
+ behind = b || 0;
205
+ ahead = a || 0;
206
+ }
207
+ }
208
+ // uncommitted line changes (tracked, vs HEAD) and the last commit on this branch
209
+ const ss = git(dir, ["diff", "HEAD", "--shortstat"]).out;
210
+ const added = Number(ss.match(/(\d+) insertion/)?.[1] || 0);
211
+ const removed = Number(ss.match(/(\d+) deletion/)?.[1] || 0);
212
+ const lc = git(dir, ["log", "-1", "--format=%h\x1f%s\x1f%cr"]).out;
213
+ const [sha = "", subject = "", when = ""] = lc ? lc.split("\x1f") : [];
214
+ const lastCommit = sha ? { sha, subject, when } : undefined;
215
+ return { branch, dirty, dirtyCount: changed.length, added, removed, ahead, behind, hasUpstream: up.ok, lastCommit };
202
216
  }
203
-
204
217
  /** Compact relative age, e.g. "2h ago". */
205
218
  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`;
219
+ const s = Math.max(0, (Date.now() - new Date(date).getTime()) / 1000);
220
+ if (s < 60)
221
+ return `${Math.floor(s)}s ago`;
222
+ if (s < 3600)
223
+ return `${Math.floor(s / 60)}m ago`;
224
+ if (s < 86400)
225
+ return `${Math.floor(s / 3600)}h ago`;
226
+ return `${Math.floor(s / 86400)}d ago`;
211
227
  }
212
-
213
228
  /** Best-effort PR lookup via gh; returns { state, number, url } or undefined. */
229
+ /** Validate `gh pr view --json` output has the fields we read. */
230
+ function isPr(v) {
231
+ if (typeof v !== "object" || v === null)
232
+ return false;
233
+ const p = v;
234
+ return typeof p.state === "string" && typeof p.number === "number" && typeof p.url === "string";
235
+ }
214
236
  function prInfo(dir, branch) {
215
- const r = sh("gh", ["pr", "view", branch, "--json", "state,number,url"], { cwd: dir });
216
- if (!r.ok) return undefined;
217
- try {
218
- return JSON.parse(r.out);
219
- } catch {
220
- return undefined;
221
- }
237
+ const r = sh("gh", ["pr", "view", branch, "--json", "state,number,url"], { cwd: dir });
238
+ if (!r.ok)
239
+ return undefined;
240
+ try {
241
+ const parsed = JSON.parse(r.out);
242
+ return isPr(parsed) ? parsed : undefined;
243
+ }
244
+ catch {
245
+ return undefined;
246
+ }
222
247
  }
223
-
224
248
  /** True when the clone has no local-only work (clean + pushed) -> safe to remove. */
225
249
  const isSafeToClean = (s) => !s.dirty && s.hasUpstream && s.ahead === 0;
226
-
227
250
  /** True if the clone has committed work that lives only in the clone (not on a remote, not yet in the origin). */
228
251
  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;
252
+ if (s.hasUpstream && s.ahead === 0)
253
+ return false; // already on a remote
254
+ const head = git(cloneDir, ["rev-parse", "HEAD"]).out;
255
+ if (!head)
256
+ return false;
257
+ return !git(originRepo, ["cat-file", "-e", `${head}^{commit}`]).ok;
233
258
  }
234
-
235
259
  // ── interactive picker (TUI-lite, arrow keys, no deps) ───────────────────────
236
260
  /** Returns the chosen index, or -1 when cancelled / non-interactive. */
237
261
  function select(title, labels) {
238
- return new Promise((resolve) => {
239
- if (!process.stdin.isTTY || labels.length === 0) return resolve(-1);
240
- let idx = 0;
241
- const n = labels.length;
242
- const out = process.stderr;
243
- readline.emitKeypressEvents(process.stdin);
244
- process.stdin.setRawMode(true);
245
- process.stdin.resume();
246
- out.write(`${title}\n`);
247
- const draw = () =>
248
- labels.forEach((l, i) => out.write(`\x1b[2K${i === idx ? paint.cyan("❯ ") : " "}${l}\n`));
249
- draw();
250
- const done = (r) => {
251
- process.stdin.removeListener("keypress", onKey);
252
- process.stdin.setRawMode(false);
253
- process.stdin.pause();
254
- out.write("\n");
255
- resolve(r);
256
- };
257
- const onKey = (str, key) => {
258
- if (key.name === "up" || str === "k") idx = (idx - 1 + n) % n;
259
- else if (key.name === "down" || str === "j") idx = (idx + 1) % n;
260
- else if (key.name === "return") return done(idx);
261
- else if (key.name === "escape" || str === "q" || (key.ctrl && key.name === "c")) return done(-1);
262
- else return;
263
- out.write(`\x1b[${n}A`);
264
- draw();
265
- };
266
- process.stdin.on("keypress", onKey);
267
- });
262
+ // `settle` (not `resolve`) avoids shadowing the imported path.resolve.
263
+ return new Promise((settle) => {
264
+ if (!process.stdin.isTTY || labels.length === 0)
265
+ return settle(-1);
266
+ let idx = 0;
267
+ const n = labels.length;
268
+ const out = process.stderr;
269
+ readline.emitKeypressEvents(process.stdin);
270
+ process.stdin.setRawMode(true);
271
+ process.stdin.resume();
272
+ out.write(`${title}\n`);
273
+ const draw = () => labels.forEach((l, i) => {
274
+ out.write(`\x1b[2K${i === idx ? paint.cyan("❯ ") : " "}${l}\n`);
275
+ });
276
+ draw();
277
+ const done = (r) => {
278
+ process.stdin.removeListener("keypress", onKey);
279
+ process.stdin.setRawMode(false);
280
+ process.stdin.pause();
281
+ out.write("\n");
282
+ settle(r);
283
+ };
284
+ const onKey = (str, key) => {
285
+ if (key.name === "up" || str === "k")
286
+ idx = (idx - 1 + n) % n;
287
+ else if (key.name === "down" || str === "j")
288
+ idx = (idx + 1) % n;
289
+ else if (key.name === "return")
290
+ return done(idx);
291
+ else if (key.name === "escape" || str === "q" || (key.ctrl && key.name === "c"))
292
+ return done(-1);
293
+ else
294
+ return;
295
+ out.write(`\x1b[${n}A`);
296
+ draw();
297
+ };
298
+ process.stdin.on("keypress", onKey);
299
+ });
268
300
  }
269
-
270
301
  async function ask(prompt, prefill) {
271
- if (!process.stdin.isTTY) return "";
272
- const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
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
- });
277
- return a.trim();
302
+ if (!process.stdin.isTTY)
303
+ return "";
304
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
305
+ const a = await new Promise((r) => {
306
+ rl.question(prompt, (x) => {
307
+ rl.close();
308
+ r(x);
309
+ });
310
+ if (prefill)
311
+ rl.write(prefill); // pre-fill an editable default: Enter accepts, or edit it
312
+ });
313
+ return a.trim();
278
314
  }
279
-
280
315
  async function confirm(msg) {
281
- if (!process.stdin.isTTY) return false;
282
- return /^y(es)?$/i.test(await ask(`${msg} [y/N] `));
316
+ if (!process.stdin.isTTY)
317
+ return false;
318
+ return /^y(es)?$/i.test(await ask(`${msg} [y/N] `));
283
319
  }
284
-
285
320
  /** Resolve which clone to act on: inside a clone, by name, only-one, or interactive pick. */
286
321
  async function pickClone(action, name) {
287
- const here = repoTopLevel(process.cwd());
288
- const hm = here && readMarker(here);
289
- if (hm && !name) return { originRepo: hm.originRepo, clone: { dir: here, name: hm.name || basename(here), marker: hm } };
290
- // If `name` resolves to a clone directory, use its marker directly works from anywhere,
291
- // even outside a repo (e.g. `kage rm ../app--fix` from the parent dir).
292
- if (name) {
293
- const asPath = resolve(name);
294
- const pm = readMarker(asPath);
295
- if (pm) return { originRepo: pm.originRepo, clone: { dir: asPath, name: pm.name || basename(asPath), marker: pm } };
296
- }
297
- const originRepo = hm ? hm.originRepo : here;
298
- if (!originRepo) die("not a git repository (run inside the repo or clone, or pass a path to a clone)");
299
- const clones = listClones(originRepo);
300
- if (clones.length === 0) die("no shadow clones found for this repo");
301
- if (name) {
302
- const c = clones.find((x) => x.name === name || basename(x.dir) === name);
303
- if (!c) die(`no clone named ${name}`);
304
- return { originRepo, clone: c };
305
- }
306
- if (clones.length === 1) return { originRepo, clone: clones[0] };
307
- const idx = await select(
308
- `Multiple clones pick one to ${action}:`,
309
- clones.map((c) => `${c.name} ${paint.dim(cloneStatus(c.dir).branch)}`),
310
- );
311
- if (idx < 0) return null;
312
- return { originRepo, clone: clones[idx] };
322
+ const here = repoTopLevel(process.cwd());
323
+ const hm = here ? readMarker(here) : undefined;
324
+ if (here && hm && !name)
325
+ return { originRepo: hm.originRepo, clone: { dir: here, name: hm.name || basename(here), marker: hm } };
326
+ // If `name` resolves to a clone directory, use its marker directly works from anywhere,
327
+ // even outside a repo (e.g. `kage rm ../app--fix` from the parent dir).
328
+ if (name) {
329
+ const asPath = resolve(name);
330
+ const pm = readMarker(asPath);
331
+ if (pm)
332
+ return { originRepo: pm.originRepo, clone: { dir: asPath, name: pm.name || basename(asPath), marker: pm } };
333
+ }
334
+ const originRepo = hm ? hm.originRepo : here;
335
+ if (!originRepo)
336
+ die("not a git repository (run inside the repo or clone, or pass a path to a clone)");
337
+ const clones = listClones(originRepo);
338
+ if (clones.length === 0)
339
+ die("no shadow clones found for this repo");
340
+ if (name) {
341
+ const c = clones.find((x) => x.name === name || basename(x.dir) === name);
342
+ if (!c)
343
+ die(`no clone named ${name}`);
344
+ return { originRepo, clone: c };
345
+ }
346
+ const first = clones[0];
347
+ if (first && clones.length === 1)
348
+ return { originRepo, clone: first };
349
+ const idx = await select(`Multiple clones — pick one to ${action}:`, clones.map((c) => `${c.name} ${paint.dim(cloneStatus(c.dir).branch)}`));
350
+ const chosen = idx < 0 ? undefined : clones[idx];
351
+ if (!chosen)
352
+ return null;
353
+ return { originRepo, clone: chosen };
313
354
  }
314
-
315
355
  // ── copy the origin's session history into the clone ─────────────────────────
316
356
  /**
317
357
  * Copies the origin's most recent session files (up to RECENT_SESSIONS, by mtime) into the
@@ -321,31 +361,32 @@ async function pickClone(action, name) {
321
361
  * resumed one and added turns, it comes back as a separate session (see mergeBack).
322
362
  */
323
363
  function copyOriginHistory(originRepo, cloneDir) {
324
- const srcDir = sessionDirFor(originRepo);
325
- if (!existsSync(srcDir)) return 0;
326
- const destDir = sessionDirFor(cloneDir);
327
- mkdirSync(destDir, { recursive: true });
328
- const recent = readdirSync(srcDir)
329
- .filter((f) => f.endsWith(".jsonl"))
330
- .map((f) => ({ f, m: statSync(join(srcDir, f)).mtimeMs }))
331
- .sort((a, b) => b.m - a.m)
332
- .slice(0, RECENT_SESSIONS);
333
- let n = 0;
334
- for (const { f } of recent) {
335
- const lines = readFileSync(join(srcDir, f), "utf8").split("\n");
336
- try {
337
- const header = JSON.parse(lines[0]);
338
- header.cwd = cloneDir;
339
- lines[0] = JSON.stringify(header);
340
- } catch {
341
- /* leave malformed header as-is */
342
- }
343
- writeFileSync(join(destDir, f), lines.join("\n"));
344
- n++;
345
- }
346
- return n;
364
+ const srcDir = sessionDirFor(originRepo);
365
+ if (!existsSync(srcDir))
366
+ return 0;
367
+ const destDir = sessionDirFor(cloneDir);
368
+ mkdirSync(destDir, { recursive: true });
369
+ const recent = readdirSync(srcDir)
370
+ .filter((f) => f.endsWith(".jsonl"))
371
+ .map((f) => ({ f, m: statSync(join(srcDir, f)).mtimeMs }))
372
+ .sort((a, b) => b.m - a.m)
373
+ .slice(0, RECENT_SESSIONS);
374
+ let n = 0;
375
+ for (const { f } of recent) {
376
+ const lines = readFileSync(join(srcDir, f), "utf8").split("\n");
377
+ try {
378
+ const header = JSON.parse(lines[0] ?? "");
379
+ header.cwd = cloneDir;
380
+ lines[0] = JSON.stringify(header);
381
+ }
382
+ catch {
383
+ /* leave malformed header as-is */
384
+ }
385
+ writeFileSync(join(destDir, f), lines.join("\n"));
386
+ n++;
387
+ }
388
+ return n;
347
389
  }
348
-
349
390
  // ── merge the clone's new sessions back into the origin ──────────────────────
350
391
  /**
351
392
  * Copies the clone's sessions into the origin's session dir:
@@ -356,69 +397,78 @@ function copyOriginHistory(originRepo, cloneDir) {
356
397
  * resumes) is never mutated. Costs a duplicated prefix; avoids hijacking the origin's leaf.
357
398
  */
358
399
  function mergeBack(cloneDir, originRepo) {
359
- const srcDir = sessionDirFor(cloneDir);
360
- if (!existsSync(srcDir)) return 0;
361
- const destDir = sessionDirFor(originRepo);
362
- mkdirSync(destDir, { recursive: true });
363
- let n = 0;
364
- for (const f of readdirSync(srcDir)) {
365
- if (!f.endsWith(".jsonl")) continue;
366
- const src = readFileSync(join(srcDir, f), "utf8").split("\n").filter((l) => l.trim());
367
- if (src.length === 0) continue;
368
- const dest = join(destDir, f);
369
-
370
- if (!existsSync(dest)) {
371
- let header;
372
- try {
373
- header = JSON.parse(src[0]);
374
- } catch {
375
- continue;
376
- }
377
- header.cwd = originRepo;
378
- writeFileSync(dest, [JSON.stringify(header), ...src.slice(1)].join("\n") + "\n");
379
- n++;
380
- continue;
381
- }
382
-
383
- // A copied-in origin session. If the clone added records (e.g. you resumed it there),
384
- // write the clone's full session back as a NEW, self-contained file — leaving the origin's
385
- // original file (and the leaf pi resumes) untouched. Unchanged copies add nothing.
386
- const have = new Set();
387
- for (const l of readFileSync(dest, "utf8").split("\n")) {
388
- if (!l.trim()) continue;
389
- try {
390
- have.add(JSON.parse(l).id);
391
- } catch {
392
- /* ignore */
393
- }
394
- }
395
- const hasNew = src.slice(1).some((l) => {
396
- try {
397
- return !have.has(JSON.parse(l).id);
398
- } catch {
399
- return false;
400
- }
401
- });
402
- if (!hasNew) continue;
403
- let header;
404
- try {
405
- header = JSON.parse(src[0]);
406
- } catch {
407
- continue;
408
- }
409
- const id = randomUUID();
410
- const fname = `${new Date().toISOString().replace(/[:.]/g, "-")}_${id}.jsonl`;
411
- writeFileSync(join(destDir, fname), [JSON.stringify({ ...header, id, cwd: originRepo }), ...src.slice(1)].join("\n") + "\n");
412
- n++;
413
- }
414
- try {
415
- rmSync(srcDir, { recursive: true, force: true });
416
- } catch {
417
- /* ignore */
418
- }
419
- return n;
400
+ const srcDir = sessionDirFor(cloneDir);
401
+ if (!existsSync(srcDir))
402
+ return 0;
403
+ const destDir = sessionDirFor(originRepo);
404
+ mkdirSync(destDir, { recursive: true });
405
+ let n = 0;
406
+ for (const f of readdirSync(srcDir)) {
407
+ if (!f.endsWith(".jsonl"))
408
+ continue;
409
+ const src = readFileSync(join(srcDir, f), "utf8")
410
+ .split("\n")
411
+ .filter((l) => l.trim());
412
+ if (src.length === 0)
413
+ continue;
414
+ const dest = join(destDir, f);
415
+ if (!existsSync(dest)) {
416
+ let header;
417
+ try {
418
+ header = JSON.parse(src[0] ?? "");
419
+ }
420
+ catch {
421
+ continue;
422
+ }
423
+ header.cwd = originRepo;
424
+ writeFileSync(dest, `${[JSON.stringify(header), ...src.slice(1)].join("\n")}\n`);
425
+ n++;
426
+ continue;
427
+ }
428
+ // A copied-in origin session. If the clone added records (e.g. you resumed it there),
429
+ // write the clone's full session back as a NEW, self-contained file — leaving the origin's
430
+ // original file (and the leaf pi resumes) untouched. Unchanged copies add nothing.
431
+ const have = new Set();
432
+ for (const l of readFileSync(dest, "utf8").split("\n")) {
433
+ if (!l.trim())
434
+ continue;
435
+ try {
436
+ have.add(JSON.parse(l).id);
437
+ }
438
+ catch {
439
+ /* ignore */
440
+ }
441
+ }
442
+ const hasNew = src.slice(1).some((l) => {
443
+ try {
444
+ return !have.has(JSON.parse(l).id);
445
+ }
446
+ catch {
447
+ return false;
448
+ }
449
+ });
450
+ if (!hasNew)
451
+ continue;
452
+ let header;
453
+ try {
454
+ header = JSON.parse(src[0] ?? "");
455
+ }
456
+ catch {
457
+ continue;
458
+ }
459
+ const id = randomUUID();
460
+ const fname = `${new Date().toISOString().replace(/[:.]/g, "-")}_${id}.jsonl`;
461
+ writeFileSync(join(destDir, fname), `${[JSON.stringify({ ...header, id, cwd: originRepo }), ...src.slice(1)].join("\n")}\n`);
462
+ n++;
463
+ }
464
+ try {
465
+ rmSync(srcDir, { recursive: true, force: true });
466
+ }
467
+ catch {
468
+ /* ignore */
469
+ }
470
+ return n;
420
471
  }
421
-
422
472
  /**
423
473
  * We just deleted the clone we were running inside, so the parent shell is now in a
424
474
  * deleted directory. A CLI can't cd its parent shell, so: if the shell wrapper is active
@@ -426,299 +476,311 @@ function mergeBack(cloneDir, originRepo) {
426
476
  * otherwise print a copy-pasteable `cd` and how to enable the auto version.
427
477
  */
428
478
  function leaveClone(originRepo) {
429
- const f = process.env.KAGE_CD_FILE;
430
- if (f) {
431
- try {
432
- writeFileSync(f, originRepo);
433
- info(paint.dim(` ↩ back to ${originRepo}`));
434
- return;
435
- } catch {
436
- /* fall through to the manual hint */
437
- }
438
- }
439
- info(paint.yellow(` ↩ your shell is still in the deleted clone — run: ${paint.bold(`cd ${originRepo}`)}`));
440
- info(paint.dim(` enable auto cd-back: add eval "$(kage shell-init)" to your ~/.zshrc`));
479
+ const f = process.env.KAGE_CD_FILE;
480
+ if (f) {
481
+ try {
482
+ writeFileSync(f, originRepo);
483
+ info(paint.dim(` ↩ back to ${originRepo}`));
484
+ return;
485
+ }
486
+ catch {
487
+ /* fall through to the manual hint */
488
+ }
489
+ }
490
+ info(paint.yellow(` ↩ your shell is still in the deleted clone — run: ${paint.bold(`cd ${originRepo}`)}`));
491
+ info(paint.dim(` enable auto cd-back: add eval "$(kage shell-init)" to your ~/.zshrc`));
441
492
  }
442
-
443
493
  function launchPi(cwd, args) {
444
- const r = spawnSync("pi", args, { cwd, stdio: "inherit" });
445
- if (r.error) {
446
- if (r.error.code === "ENOENT") die("pi not found (make sure it is installed and on your PATH)");
447
- die(`failed to launch pi: ${r.error.message}`);
448
- }
494
+ const r = spawnSync("pi", args, { cwd, stdio: "inherit" });
495
+ if (r.error) {
496
+ const err = r.error;
497
+ if (err.code === "ENOENT")
498
+ die("pi not found (make sure it is installed and on your PATH)");
499
+ die(`failed to launch pi: ${err.message}`);
500
+ }
449
501
  }
450
-
451
502
  // ── subcommands ───────────────────────────────────────────────────────────────
452
503
  async function cmdNew(argv) {
453
- const { positional, flags } = parseArgs(argv);
454
- let path = positional[0];
455
-
456
- // Interactive launcher: `kage` with no args, inside a repo that already has clones.
457
- if (!path && !flags.name && process.stdin.isTTY) {
458
- const repoRoot = repoTopLevel(process.cwd());
459
- const clones = repoRoot ? listClones(repoRoot) : [];
460
- if (clones.length > 0) {
461
- const labels = [
462
- "+ Create a new shadow clone",
463
- ...clones.map((c) => {
464
- const s = cloneStatus(c.dir);
465
- const tag = s.dirty ? paint.yellow(" ●") : isSafeToClean(s) ? paint.green(" ✓") : "";
466
- return `→ Enter ${c.name} ${paint.cyan(s.branch)}${tag}`;
467
- }),
468
- ];
469
- const idx = await select(`Shadow clones of ${basename(repoRoot)} — pick one, or create:`, labels);
470
- if (idx < 0) return info("cancelled");
471
- if (idx > 0) {
472
- const clone = clones[idx - 1];
473
- const act = await select(`${clone.name}:`, [
474
- "Enter (resume pi)",
475
- "Finish (merge memory & remove)",
476
- "Remove (discard)",
477
- "Cancel",
478
- ]);
479
- if (act === 0) return launchPi(clone.dir, ["-c"]);
480
- if (act === 1) return cmdFinish([clone.name]);
481
- if (act === 2) return cmdRm([clone.name]);
482
- return info("cancelled");
483
- }
484
- // idx === 0: "create" — fall through to the name prompt below.
485
- }
486
- }
487
-
488
- const targetPath = path ? resolve(path) : process.cwd();
489
-
490
- const repoRoot = repoTopLevel(targetPath);
491
- if (!repoRoot) die(`not a git repository: ${targetPath}`);
492
- if (existsSync(join(repoRoot, MARKER))) die("already inside a clone; run kage from the origin repo");
493
-
494
- // Resolve the clone name: explicit --name wins; otherwise show the full folder name with
495
- // the fixed "<repo>--" prefix in the prompt and an editable default suffix — press Enter to
496
- // accept, or edit the suffix (non-interactive falls back to the default).
497
- let name = typeof flags.name === "string" && flags.name ? flags.name : "";
498
- if (!name) {
499
- const def = tsName();
500
- const prompt = `Kage name: ${basename(repoRoot)}--`;
501
- name = (process.stdin.isTTY ? await ask(prompt, def) : "") || def;
502
- }
503
- const safe = slug(name);
504
- const cloneDir = join(dirname(repoRoot), `${basename(repoRoot)}--${safe}`);
505
- if (existsSync(cloneDir)) die(`directory already exists: ${cloneDir}`);
506
-
507
- const cp = await copyRepo(repoRoot, cloneDir);
508
- if (!cp.ok) die(`copy failed: ${cp.err}`);
509
-
510
- // kage does NOT create a branch — the clone stays on the origin's current branch.
511
- const histN = copyOriginHistory(repoRoot, cloneDir);
512
- const marker = {
513
- originRepo: repoRoot,
514
- name: safe,
515
- createdAt: new Date().toISOString(),
516
- };
517
- writeFileSync(join(cloneDir, MARKER), JSON.stringify(marker, null, 2));
518
-
519
- const curBranch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out || "?";
520
- info("");
521
- info(`🥷 ${paint.bold("Shadow clone ready")}: ${cloneDir}`);
522
- info(` origin: ${repoRoot} branch: ${paint.cyan(curBranch)}`);
523
- if (histN > 0) info(paint.dim(` origin's ${histN} session(s) are available via resume (pi: pick from the list)`));
524
- info(paint.dim(` when done: kage finish ${safe}`));
525
- info("");
526
-
527
- launchPi(cloneDir, []);
528
- info("");
529
- info(`↩︎ left the clone's pi. To finish: ${paint.bold(`kage finish ${safe}`)}`);
504
+ const { positional, flags } = parseArgs(argv);
505
+ const path = positional[0];
506
+ // Interactive launcher: `kage` with no args, inside a repo that already has clones.
507
+ if (!path && !boolFlag(flags, "name") && process.stdin.isTTY) {
508
+ const repoRoot = repoTopLevel(process.cwd());
509
+ const clones = repoRoot ? listClones(repoRoot) : [];
510
+ if (repoRoot && clones.length > 0) {
511
+ const labels = [
512
+ "+ Create a new shadow clone",
513
+ ...clones.map((c) => {
514
+ const s = cloneStatus(c.dir);
515
+ const tag = s.dirty ? paint.yellow(" ●") : isSafeToClean(s) ? paint.green(" ✓") : "";
516
+ return `→ Enter ${c.name} ${paint.cyan(s.branch)}${tag}`;
517
+ }),
518
+ ];
519
+ const idx = await select(`Shadow clones of ${basename(repoRoot)} — pick one, or create:`, labels);
520
+ if (idx < 0)
521
+ return info("cancelled");
522
+ if (idx > 0) {
523
+ const clone = clones[idx - 1];
524
+ if (!clone)
525
+ return info("cancelled");
526
+ const act = await select(`${clone.name}:`, ["Enter (resume pi)", "Finish (merge memory & remove)", "Remove (discard)", "Cancel"]);
527
+ if (act === 0)
528
+ return launchPi(clone.dir, ["-c"]);
529
+ if (act === 1)
530
+ return cmdFinish([clone.name]);
531
+ if (act === 2)
532
+ return cmdRm([clone.name]);
533
+ return info("cancelled");
534
+ }
535
+ // idx === 0: "create" — fall through to the name prompt below.
536
+ }
537
+ }
538
+ const targetPath = path ? resolve(path) : process.cwd();
539
+ const repoRoot = repoTopLevel(targetPath);
540
+ if (!repoRoot)
541
+ die(`not a git repository: ${targetPath}`);
542
+ if (existsSync(join(repoRoot, MARKER)))
543
+ die("already inside a clone; run kage from the origin repo");
544
+ // Resolve the clone name: explicit --name wins; otherwise show the full folder name with
545
+ // the fixed "<repo>--" prefix in the prompt and an editable default suffix — press Enter to
546
+ // accept, or edit the suffix (non-interactive falls back to the default).
547
+ let name = strFlag(flags, "name") ?? "";
548
+ if (!name) {
549
+ const def = tsName();
550
+ const prompt = `Kage name: ${basename(repoRoot)}--`;
551
+ name = (process.stdin.isTTY ? await ask(prompt, def) : "") || def;
552
+ }
553
+ const safe = slug(name);
554
+ const cloneDir = join(dirname(repoRoot), `${basename(repoRoot)}--${safe}`);
555
+ if (existsSync(cloneDir))
556
+ die(`directory already exists: ${cloneDir}`);
557
+ const cp = await copyRepo(repoRoot, cloneDir);
558
+ if (!cp.ok)
559
+ die(`copy failed: ${cp.err}`);
560
+ // kage does NOT create a branch — the clone stays on the origin's current branch.
561
+ const histN = copyOriginHistory(repoRoot, cloneDir);
562
+ const marker = {
563
+ originRepo: repoRoot,
564
+ name: safe,
565
+ createdAt: new Date().toISOString(),
566
+ };
567
+ writeFileSync(join(cloneDir, MARKER), JSON.stringify(marker, null, 2));
568
+ const curBranch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out || "?";
569
+ info("");
570
+ info(`🥷 ${paint.bold("Shadow clone ready")}: ${cloneDir}`);
571
+ info(` origin: ${repoRoot} branch: ${paint.cyan(curBranch)}`);
572
+ if (histN > 0)
573
+ info(paint.dim(` origin's ${histN} session(s) are available via resume (pi: pick from the list)`));
574
+ info(paint.dim(` when done: kage finish ${safe}`));
575
+ info("");
576
+ launchPi(cloneDir, []);
577
+ info("");
578
+ info(`↩︎ left the clone's pi. To finish: ${paint.bold(`kage finish ${safe}`)}`);
530
579
  }
531
-
532
580
  async function cmdFinish(argv) {
533
- const { positional, flags } = parseArgs(argv);
534
- const force = !!flags.force;
535
- const pr = !!flags.pr;
536
- const push = pr || !!flags.push; // --pr implies --push
537
- const picked = await pickClone("finish", positional[0]);
538
- if (!picked) return info("cancelled");
539
- const { originRepo, clone } = picked;
540
- const insideClone = repoTopLevel(process.cwd()) === clone.dir;
541
-
542
- // Optional convenience: push the branch (and open a PR) before finishing.
543
- if (push) {
544
- const s = cloneStatus(clone.dir);
545
- if (s.dirty) die(`${clone.name} has uncommitted changes — commit them first (kage won't auto-commit)`);
546
- if (!s.hasUpstream) {
547
- const r = git(clone.dir, ["push", "-u", "origin", s.branch]);
548
- if (!r.ok) die(`push failed: ${r.err}`);
549
- info(`⬆ pushed ${s.branch} to origin`);
550
- } else if (s.ahead > 0) {
551
- const r = git(clone.dir, ["push"]);
552
- if (!r.ok) die(`push failed: ${r.err}`);
553
- info(`⬆ pushed ${s.ahead} commit(s)`);
554
- }
555
- if (pr) {
556
- const existing = prInfo(clone.dir, s.branch);
557
- if (existing) {
558
- info(`🔗 PR already open: ${existing.url}`);
559
- } else {
560
- const r = sh("gh", ["pr", "create", "--fill"], { cwd: clone.dir });
561
- if (!r.ok) die(`gh pr create failed: ${r.err || r.out || "is gh installed & authed?"}`);
562
- info(`🔗 opened PR: ${r.out.split("\n").pop()}`);
563
- }
564
- }
565
- }
566
-
567
- // Decide how to preserve the clone's committed work before deleting it.
568
- const s = cloneStatus(clone.dir);
569
- const hasRemote = git(clone.dir, ["remote"]).out.trim().length > 0;
570
-
571
- if (!force) {
572
- if (s.dirty) die(`${clone.name}: uncommitted changes commit them, or pass --force to discard them`);
573
- // With a remote, keep the "push your work" guard so PR-flow mistakes surface.
574
- if (hasRemote && (!s.hasUpstream || s.ahead > 0)) {
575
- die(`${clone.name}: branch not pushed — push it (or use --push / --pr), or pass --force`);
576
- }
577
- }
578
-
579
- // Preserve committed work that isn't on a remote: fetch the clone's branch into the origin
580
- // as a local 'kage/<name>' branch (origin's working tree is left untouched). This is what
581
- // makes finish lossless without GitHub — the commits land in the origin's git, ready to merge.
582
- if (hasUnpreservedCommits(originRepo, clone.dir, s)) {
583
- const head = git(clone.dir, ["rev-parse", "HEAD"]).out;
584
- // Always a unique ref (name + short sha) so reusing a clone name never collides with an
585
- // earlier preserved branchwhich would either abort the fetch (non-ff) or clobber it.
586
- const target = `kage/${slug(clone.name)}-${head.slice(0, 7)}`;
587
- const r = git(originRepo, ["fetch", clone.dir, `${s.branch}:refs/heads/${target}`]);
588
- if (!r.ok) die(`failed to preserve the clone's branch into the origin: ${r.err}`);
589
- info(`🌿 preserved the clone's commits in the origin as ${paint.cyan(target)} (merge with: git merge ${target})`);
590
- }
591
-
592
- const n = mergeBack(clone.dir, originRepo);
593
- try {
594
- process.chdir(originRepo);
595
- } catch {
596
- /* ignore */
597
- }
598
- rmSync(clone.dir, { recursive: true, force: true });
599
-
600
- info(`💨 Clone dispelled: merged ${n} session(s) back, removed ${clone.dir}`);
601
- if (insideClone) leaveClone(originRepo);
581
+ const { positional, flags } = parseArgs(argv);
582
+ const force = boolFlag(flags, "force");
583
+ const pr = boolFlag(flags, "pr");
584
+ const push = pr || boolFlag(flags, "push"); // --pr implies --push
585
+ const picked = await pickClone("finish", positional[0]);
586
+ if (!picked)
587
+ return info("cancelled");
588
+ const { originRepo, clone } = picked;
589
+ const insideClone = repoTopLevel(process.cwd()) === clone.dir;
590
+ // Optional convenience: push the branch (and open a PR) before finishing.
591
+ if (push) {
592
+ const s = cloneStatus(clone.dir);
593
+ if (s.dirty)
594
+ die(`${clone.name} has uncommitted changes — commit them first (kage won't auto-commit)`);
595
+ if (!s.hasUpstream) {
596
+ const r = git(clone.dir, ["push", "-u", "origin", s.branch]);
597
+ if (!r.ok)
598
+ die(`push failed: ${r.err}`);
599
+ info(`⬆ pushed ${s.branch} to origin`);
600
+ }
601
+ else if (s.ahead > 0) {
602
+ const r = git(clone.dir, ["push"]);
603
+ if (!r.ok)
604
+ die(`push failed: ${r.err}`);
605
+ info(`⬆ pushed ${s.ahead} commit(s)`);
606
+ }
607
+ if (pr) {
608
+ const existing = prInfo(clone.dir, s.branch);
609
+ if (existing) {
610
+ info(`🔗 PR already open: ${existing.url}`);
611
+ }
612
+ else {
613
+ const r = sh("gh", ["pr", "create", "--fill"], { cwd: clone.dir });
614
+ if (!r.ok)
615
+ die(`gh pr create failed: ${r.err || r.out || "is gh installed & authed?"}`);
616
+ info(`🔗 opened PR: ${r.out.split("\n").pop()}`);
617
+ }
618
+ }
619
+ }
620
+ // Decide how to preserve the clone's committed work before deleting it.
621
+ const s = cloneStatus(clone.dir);
622
+ const hasRemote = git(clone.dir, ["remote"]).out.trim().length > 0;
623
+ if (!force) {
624
+ if (s.dirty)
625
+ die(`${clone.name}: uncommitted changes — commit them, or pass --force to discard them`);
626
+ // With a remote, keep the "push your work" guard so PR-flow mistakes surface.
627
+ if (hasRemote && (!s.hasUpstream || s.ahead > 0)) {
628
+ die(`${clone.name}: branch not pushed push it (or use --push / --pr), or pass --force`);
629
+ }
630
+ }
631
+ // Preserve committed work that isn't on a remote: fetch the clone's branch into the origin
632
+ // as a local 'kage/<name>' branch (origin's working tree is left untouched). This is what
633
+ // makes finish lossless without GitHub the commits land in the origin's git, ready to merge.
634
+ if (hasUnpreservedCommits(originRepo, clone.dir, s)) {
635
+ const head = git(clone.dir, ["rev-parse", "HEAD"]).out;
636
+ // Always a unique ref (name + short sha) so reusing a clone name never collides with an
637
+ // earlier preserved branch which would either abort the fetch (non-ff) or clobber it.
638
+ const target = `kage/${slug(clone.name)}-${head.slice(0, 7)}`;
639
+ const r = git(originRepo, ["fetch", clone.dir, `${s.branch}:refs/heads/${target}`]);
640
+ if (!r.ok)
641
+ die(`failed to preserve the clone's branch into the origin: ${r.err}`);
642
+ info(`🌿 preserved the clone's commits in the origin as ${paint.cyan(target)} (merge with: git merge ${target})`);
643
+ }
644
+ const n = mergeBack(clone.dir, originRepo);
645
+ try {
646
+ process.chdir(originRepo);
647
+ }
648
+ catch {
649
+ /* ignore */
650
+ }
651
+ rmSync(clone.dir, { recursive: true, force: true });
652
+ info(`💨 Clone dispelled: merged ${n} session(s) back, removed ${clone.dir}`);
653
+ if (insideClone)
654
+ leaveClone(originRepo);
602
655
  }
603
-
604
656
  async function cmdRm(argv) {
605
- const { positional, flags } = parseArgs(argv);
606
- const force = !!flags.force;
607
- const picked = await pickClone("remove", positional[0]);
608
- if (!picked) return info("cancelled");
609
- const { originRepo, clone } = picked;
610
- const insideClone = repoTopLevel(process.cwd()) === clone.dir;
611
-
612
- if (!force) {
613
- const s = cloneStatus(clone.dir);
614
- if (s.dirty || hasUnpreservedCommits(originRepo, clone.dir, s)) {
615
- die(`${clone.name} has local-only work — use 'kage finish' to keep it, or 'kage rm --force' to discard`);
616
- }
617
- if (!(await confirm(`Discard clone ${clone.name} without merging its memory?`))) return info("aborted");
618
- }
619
-
620
- try {
621
- process.chdir(originRepo);
622
- } catch {
623
- /* ignore */
624
- }
625
- try {
626
- rmSync(sessionDirFor(clone.dir), { recursive: true, force: true });
627
- } catch {
628
- /* ignore */
629
- }
630
- rmSync(clone.dir, { recursive: true, force: true });
631
- info(`🗑 Removed clone ${clone.name} (${clone.dir})`);
632
- if (insideClone) leaveClone(originRepo);
657
+ const { positional, flags } = parseArgs(argv);
658
+ const force = boolFlag(flags, "force");
659
+ const picked = await pickClone("remove", positional[0]);
660
+ if (!picked)
661
+ return info("cancelled");
662
+ const { originRepo, clone } = picked;
663
+ const insideClone = repoTopLevel(process.cwd()) === clone.dir;
664
+ if (!force) {
665
+ const s = cloneStatus(clone.dir);
666
+ if (s.dirty || hasUnpreservedCommits(originRepo, clone.dir, s)) {
667
+ die(`${clone.name} has local-only work — use 'kage finish' to keep it, or 'kage rm --force' to discard`);
668
+ }
669
+ if (!(await confirm(`Discard clone ${clone.name} without merging its memory?`)))
670
+ return info("aborted");
671
+ }
672
+ try {
673
+ process.chdir(originRepo);
674
+ }
675
+ catch {
676
+ /* ignore */
677
+ }
678
+ try {
679
+ rmSync(sessionDirFor(clone.dir), { recursive: true, force: true });
680
+ }
681
+ catch {
682
+ /* ignore */
683
+ }
684
+ rmSync(clone.dir, { recursive: true, force: true });
685
+ info(`🗑 Removed clone ${clone.name} (${clone.dir})`);
686
+ if (insideClone)
687
+ leaveClone(originRepo);
633
688
  }
634
-
635
689
  function cmdList(argv) {
636
- const { flags } = parseArgs(argv);
637
- const here = repoTopLevel(process.cwd());
638
- if (!here) die("not a git repository");
639
- // Works from inside a clone too: resolve to the origin via the marker, then list its clones.
640
- const repoRoot = readMarker(here)?.originRepo || here;
641
- const clones = listClones(repoRoot);
642
- if (clones.length === 0) return info("No shadow clones.");
643
-
644
- info(paint.bold(`Shadow clones of ${basename(repoRoot)}:`));
645
- info("");
646
- for (const c of clones) {
647
- const s = cloneStatus(c.dir);
648
- const pr = flags.pr ? prInfo(c.dir, s.branch) : undefined;
649
-
650
- // header: status glyph · name · branch · age
651
- const glyph = s.dirty ? paint.yellow("") : isSafeToClean(s) ? paint.green("✓") : paint.cyan("·");
652
- const age = c.marker?.createdAt ? paint.dim(`created ${ago(c.marker.createdAt)}`) : "";
653
- info(` ${glyph} ${paint.bold(c.name)} ${paint.cyan(s.branch)} ${age}`);
654
-
655
- // detail: working-tree state · sync · PR · safe-to-clean
656
- const parts = [];
657
- if (s.dirty) {
658
- let d = `${s.dirtyCount} changed`;
659
- if (s.added || s.removed) d += ` (${paint.green(`+${s.added}`)} ${paint.red(`-${s.removed}`)})`;
660
- parts.push(paint.yellow(d));
661
- } else {
662
- parts.push(paint.green("clean"));
663
- }
664
- if (!s.hasUpstream) parts.push(paint.dim("not pushed"));
665
- else {
666
- const sync = [];
667
- if (s.ahead) sync.push(`↑${s.ahead}`);
668
- if (s.behind) sync.push(`↓${s.behind}`);
669
- parts.push(sync.length ? sync.join(" ") : paint.dim("in sync"));
670
- }
671
- if (pr) parts.push(prState(pr));
672
- if (isSafeToClean(s)) parts.push(paint.green("safe to clean"));
673
- info(` ${parts.join(paint.dim(" · "))}`);
674
-
675
- // last commit on the branch
676
- if (s.lastCommit) {
677
- info(paint.dim(` last: ${s.lastCommit.sha} "${s.lastCommit.subject}" (${s.lastCommit.when})`));
678
- }
679
- info("");
680
- }
681
- info(paint.dim(" finish <name> to merge & remove · rm <name> to discard · status --pr for PR status"));
690
+ const { flags } = parseArgs(argv);
691
+ const here = repoTopLevel(process.cwd());
692
+ if (!here)
693
+ die("not a git repository");
694
+ // Works from inside a clone too: resolve to the origin via the marker, then list its clones.
695
+ const repoRoot = readMarker(here)?.originRepo || here;
696
+ const clones = listClones(repoRoot);
697
+ if (clones.length === 0) {
698
+ info("No shadow clones.");
699
+ return;
700
+ }
701
+ info(paint.bold(`Shadow clones of ${basename(repoRoot)}:`));
702
+ info("");
703
+ for (const c of clones) {
704
+ const s = cloneStatus(c.dir);
705
+ const pr = boolFlag(flags, "pr") ? prInfo(c.dir, s.branch) : undefined;
706
+ // header: status glyph · name · branch · age
707
+ const glyph = s.dirty ? paint.yellow("●") : isSafeToClean(s) ? paint.green("✓") : paint.cyan("·");
708
+ const age = c.marker?.createdAt ? paint.dim(`created ${ago(c.marker.createdAt)}`) : "";
709
+ info(` ${glyph} ${paint.bold(c.name)} ${paint.cyan(s.branch)} ${age}`);
710
+ // detail: working-tree state · sync · PR · safe-to-clean
711
+ const parts = [];
712
+ if (s.dirty) {
713
+ let d = `${s.dirtyCount} changed`;
714
+ if (s.added || s.removed)
715
+ d += ` (${paint.green(`+${s.added}`)} ${paint.red(`-${s.removed}`)})`;
716
+ parts.push(paint.yellow(d));
717
+ }
718
+ else {
719
+ parts.push(paint.green("clean"));
720
+ }
721
+ if (!s.hasUpstream)
722
+ parts.push(paint.dim("not pushed"));
723
+ else {
724
+ const sync = [];
725
+ if (s.ahead)
726
+ sync.push(`↑${s.ahead}`);
727
+ if (s.behind)
728
+ sync.push(`↓${s.behind}`);
729
+ parts.push(sync.length ? sync.join(" ") : paint.dim("in sync"));
730
+ }
731
+ if (pr)
732
+ parts.push(prState(pr));
733
+ if (isSafeToClean(s))
734
+ parts.push(paint.green("safe to clean"));
735
+ info(` ${parts.join(paint.dim(" · "))}`);
736
+ // last commit on the branch
737
+ if (s.lastCommit) {
738
+ info(paint.dim(` last: ${s.lastCommit.sha} "${s.lastCommit.subject}" (${s.lastCommit.when})`));
739
+ }
740
+ info("");
741
+ }
742
+ info(paint.dim(" finish <name> to merge & remove · rm <name> to discard · status --pr for PR status"));
682
743
  }
683
-
744
+ const PR_COLORS = { OPEN: paint.green, MERGED: paint.magenta, CLOSED: paint.red };
684
745
  function prState(pr) {
685
- const f = { OPEN: paint.green, MERGED: paint.magenta, CLOSED: paint.red }[pr.state] || paint.dim;
686
- return f(`PR #${pr.number} ${pr.state.toLowerCase()}`);
746
+ const f = PR_COLORS[pr.state] ?? paint.dim;
747
+ return f(`PR #${pr.number} ${pr.state.toLowerCase()}`);
687
748
  }
688
-
689
749
  function cmdPull(argv) {
690
- const { positional } = parseArgs(argv);
691
- const cloneDir = repoTopLevel(process.cwd());
692
- const marker = cloneDir && readMarker(cloneDir);
693
- if (!marker) die("kage pull only runs inside a clone (edit the origin directly otherwise)");
694
- if (positional.length === 0) die("usage: kage pull <relative-path> [more paths...]");
695
- const originRepo = marker.originRepo;
696
- const cloneRoot = cloneDir.endsWith(sep) ? cloneDir : cloneDir + sep;
697
- const originRoot = originRepo.endsWith(sep) ? originRepo : originRepo + sep;
698
- let done = 0;
699
- for (const rel of positional) {
700
- const src = resolve(cloneDir, rel);
701
- const dst = resolve(originRepo, rel);
702
- if (!src.startsWith(cloneRoot) || !dst.startsWith(originRoot)) {
703
- info(`✗ path escapes the repo, skipped: ${rel}`);
704
- continue;
705
- }
706
- if (!existsSync(src)) {
707
- info(`✗ not found in clone, skipped: ${rel}`);
708
- continue;
709
- }
710
- if (existsSync(dst)) rmSync(dst, { recursive: true, force: true });
711
- mkdirSync(dirname(dst), { recursive: true });
712
- const cp = copyTree(src, dst);
713
- if (!cp.ok) {
714
- info(`✗ copy failed ${rel}: ${cp.err}`);
715
- continue;
716
- }
717
- done++;
718
- }
719
- info(`📤 Pulled ${done}/${positional.length} path(s) from the clone back to the origin (${originRepo})`);
750
+ const { positional } = parseArgs(argv);
751
+ const cloneDir = repoTopLevel(process.cwd());
752
+ const marker = cloneDir ? readMarker(cloneDir) : undefined;
753
+ if (!cloneDir || !marker)
754
+ die("kage pull only runs inside a clone (edit the origin directly otherwise)");
755
+ if (positional.length === 0)
756
+ die("usage: kage pull <relative-path> [more paths...]");
757
+ const originRepo = marker.originRepo;
758
+ const cloneRoot = cloneDir.endsWith(sep) ? cloneDir : cloneDir + sep;
759
+ const originRoot = originRepo.endsWith(sep) ? originRepo : originRepo + sep;
760
+ let done = 0;
761
+ for (const rel of positional) {
762
+ const src = resolve(cloneDir, rel);
763
+ const dst = resolve(originRepo, rel);
764
+ if (!src.startsWith(cloneRoot) || !dst.startsWith(originRoot)) {
765
+ info(`✗ path escapes the repo, skipped: ${rel}`);
766
+ continue;
767
+ }
768
+ if (!existsSync(src)) {
769
+ info(`✗ not found in clone, skipped: ${rel}`);
770
+ continue;
771
+ }
772
+ if (existsSync(dst))
773
+ rmSync(dst, { recursive: true, force: true });
774
+ mkdirSync(dirname(dst), { recursive: true });
775
+ const cp = copyTree(src, dst);
776
+ if (!cp.ok) {
777
+ info(`✗ copy failed ${rel}: ${cp.err}`);
778
+ continue;
779
+ }
780
+ done++;
781
+ }
782
+ info(`📤 Pulled ${done}/${positional.length} path(s) from the clone back to the origin (${originRepo})`);
720
783
  }
721
-
722
784
  const SHELL_INIT = `# kage shell integration — add to ~/.zshrc or ~/.bashrc: eval "$(kage shell-init)"
723
785
  kage() {
724
786
  local f; f="$(mktemp "\${TMPDIR:-/tmp}/kage-cd.XXXXXX")"
@@ -745,13 +807,13 @@ elif [ -n "$BASH_VERSION" ]; then
745
807
  }
746
808
  complete -F _kage kage
747
809
  fi`;
748
-
749
810
  function cmdClones() {
750
- const repoRoot = repoTopLevel(process.cwd());
751
- if (!repoRoot) return;
752
- for (const c of listClones(repoRoot)) process.stdout.write(`${c.name}\n`);
811
+ const repoRoot = repoTopLevel(process.cwd());
812
+ if (!repoRoot)
813
+ return;
814
+ for (const c of listClones(repoRoot))
815
+ process.stdout.write(`${c.name}\n`);
753
816
  }
754
-
755
817
  const HELP = `kage 🥷 — Shadow Clone Jutsu for your git repo
756
818
 
757
819
  Usage:
@@ -794,41 +856,49 @@ Examples:
794
856
 
795
857
  Env:
796
858
  KAGE_SESSIONS_DIR pi session storage (default: ~/.pi/agent/sessions)`;
797
-
798
859
  async function main() {
799
- const [sub, ...rest] = process.argv.slice(2);
800
- switch (sub) {
801
- case undefined:
802
- case "new":
803
- return cmdNew(sub === "new" ? rest : process.argv.slice(2));
804
- case "status":
805
- case "list": // alias
806
- return cmdList(rest);
807
- case "finish":
808
- return cmdFinish(rest);
809
- case "rm":
810
- return cmdRm(rest);
811
- case "pull":
812
- return cmdPull(rest);
813
- case "shell-init":
814
- case "completion":
815
- return process.stdout.write(SHELL_INIT + "\n");
816
- case "__clones":
817
- return cmdClones();
818
- case "-h":
819
- case "--help":
820
- return info(HELP);
821
- case "-v":
822
- case "--version":
823
- return info(VERSION);
824
- default:
825
- // `kage <path>` clones another repo, but a bare word that isn't an existing directory
826
- // is a mistyped command (e.g. `kage statsu`) — fail clearly instead of "not a git repository".
827
- if (!sub.startsWith("-") && !(existsSync(resolve(sub)) && statSync(resolve(sub)).isDirectory())) {
828
- die(`unknown command or path: ${sub} (run 'kage --help')`);
829
- }
830
- return cmdNew(process.argv.slice(2)); // `kage <path>` or `kage <flags>`
831
- }
860
+ const [sub, ...rest] = process.argv.slice(2);
861
+ switch (sub) {
862
+ case undefined:
863
+ case "new":
864
+ return cmdNew(sub === "new" ? rest : process.argv.slice(2));
865
+ case "status":
866
+ case "list": // alias
867
+ return cmdList(rest);
868
+ case "finish":
869
+ return cmdFinish(rest);
870
+ case "rm":
871
+ return cmdRm(rest);
872
+ case "pull":
873
+ return cmdPull(rest);
874
+ case "shell-init":
875
+ case "completion": {
876
+ process.stdout.write(`${SHELL_INIT}\n`);
877
+ // When a human runs this directly (stdout is a TTY, not captured by `$(...)`),
878
+ // the script just scrolled past unused — show how to actually activate it.
879
+ // During `eval "$(kage shell-init)"` stdout is a pipe, so this stays silent.
880
+ if (process.stdout.isTTY) {
881
+ info("");
882
+ info(paint.dim("# ↑ the script above isn't run by printing it — activate it with:"));
883
+ info(` eval "$(kage shell-init)" ${paint.dim("# add this line to your ~/.zshrc or ~/.bashrc")}`);
884
+ }
885
+ return;
886
+ }
887
+ case "__clones":
888
+ return cmdClones();
889
+ case "-h":
890
+ case "--help":
891
+ return info(HELP);
892
+ case "-v":
893
+ case "--version":
894
+ return info(VERSION);
895
+ default:
896
+ // `kage <path>` clones another repo, but a bare word that isn't an existing directory
897
+ // is a mistyped command (e.g. `kage statsu`) — fail clearly instead of "not a git repository".
898
+ if (!sub.startsWith("-") && !(existsSync(resolve(sub)) && statSync(resolve(sub)).isDirectory())) {
899
+ die(`unknown command or path: ${sub} (run 'kage --help')`);
900
+ }
901
+ return cmdNew(process.argv.slice(2)); // `kage <path>` or `kage <flags>`
902
+ }
832
903
  }
833
-
834
904
  main();