instabox 0.4.3 → 0.5.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 +7 -4
- package/bin/instabox.mjs +125 -84
- package/lib/api.mjs +4 -2
- package/lib/boxes.mjs +1 -10
- package/lib/cli.mjs +2 -15
- package/lib/login.mjs +5 -2
- package/package.json +1 -1
- package/skills/instabox/SKILL.md +11 -5
- package/skills/instabox-basic-setup/SKILL.md +108 -0
- package/skills/instabox-basic-setup/scripts/content.mjs +98 -0
- package/skills/instabox-basic-setup/scripts/lib.mjs +233 -0
- package/skills/instabox-basic-setup/scripts/plan.mjs +295 -0
- package/skills/instabox-basic-setup/scripts/push.mjs +252 -0
- package/skills/instabox-basic-setup/scripts/scan.mjs +246 -0
- package/skills/instabox-move-repo/SKILL.md +193 -0
- package/skills/instabox-move-repo/scripts/deps.sh +370 -0
- package/skills/instabox-move-repo/scripts/history.mjs +340 -0
- package/skills/instabox-move-repo/scripts/repo-files.mjs +552 -0
- package/skills/instabox-snapshots/SKILL.md +2 -2
- package/skills/instabox-migrate/SKILL.md +0 -132
- package/skills/instabox-move-all/SKILL.md +0 -158
- package/skills/instabox-move-all/scripts/lib.mjs +0 -226
- package/skills/instabox-move-all/scripts/plan.mjs +0 -263
- package/skills/instabox-move-all/scripts/push-creds.mjs +0 -154
- package/skills/instabox-move-all/scripts/scan.mjs +0 -811
- package/skills/instabox-move-all/scripts/stage-env.mjs +0 -313
- package/skills/instabox-move-all/scripts/tools.mjs +0 -173
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// instabox-move-repo: stage the Claude Code and Codex conversations of a repo (and its worktrees)
|
|
3
|
+
// for the box, with every local path rewritten to the box path, so `claude --continue` and
|
|
4
|
+
// `codex resume` find them there. Node >= 18, no dependencies.
|
|
5
|
+
//
|
|
6
|
+
// node history.mjs --plan plan.json --out STAGE [--no-claude] [--no-codex]
|
|
7
|
+
// node history.mjs --map /local/path=/box/path [--map ...] --out STAGE
|
|
8
|
+
//
|
|
9
|
+
// --plan is the JSON of `repo-files.mjs --json`: the main checkout and every moved worktree are
|
|
10
|
+
// mapped. STAGE is laid out like the box's HOME:
|
|
11
|
+
// .claude/projects/<box path, Claude Code's way>/... sessions, subagents, tool results
|
|
12
|
+
// .codex/sessions/YYYY/MM/DD/rollout-*.jsonl (and archived_sessions/)
|
|
13
|
+
// .cache/instabox-move-repo/{claude,codex}-history.jsonl + apply-history.sh
|
|
14
|
+
// prompt history lines, appended on the box
|
|
15
|
+
// File times are kept, so the latest session stays the latest. The output is a JSON summary:
|
|
16
|
+
// counts, bytes and session ids. Conversation contents are rewritten, never printed.
|
|
17
|
+
import {
|
|
18
|
+
chmodSync,
|
|
19
|
+
closeSync,
|
|
20
|
+
existsSync,
|
|
21
|
+
openSync,
|
|
22
|
+
readSync,
|
|
23
|
+
lstatSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
readdirSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
realpathSync,
|
|
28
|
+
statSync,
|
|
29
|
+
utimesSync,
|
|
30
|
+
writeFileSync,
|
|
31
|
+
} from 'node:fs';
|
|
32
|
+
import { homedir } from 'node:os';
|
|
33
|
+
import { dirname, join, relative } from 'node:path';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
|
|
36
|
+
// Claude Code's directory name for a project path: every non-alphanumeric character becomes '-'.
|
|
37
|
+
export function claudeProjectName(path) {
|
|
38
|
+
return path.replace(/[^A-Za-z0-9]/g, '-');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const reEscape = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
42
|
+
|
|
43
|
+
// A rewriter for text: each local path (as written raw, and as escaped inside a JSON string) is
|
|
44
|
+
// replaced by its box path when it is followed by something that cannot continue a path name, so
|
|
45
|
+
// /a/app does not touch /a/app-2. Longest paths first: a worktree inside the main checkout wins.
|
|
46
|
+
export function makeRewriter(maps) {
|
|
47
|
+
const pairs = [];
|
|
48
|
+
for (const [from, to] of maps) {
|
|
49
|
+
if (from === to) continue;
|
|
50
|
+
pairs.push([from, to]);
|
|
51
|
+
const jf = JSON.stringify(from).slice(1, -1);
|
|
52
|
+
if (jf !== from) pairs.push([jf, JSON.stringify(to).slice(1, -1)]);
|
|
53
|
+
}
|
|
54
|
+
pairs.sort((a, b) => b[0].length - a[0].length);
|
|
55
|
+
if (!pairs.length) return (s) => s;
|
|
56
|
+
const table = new Map(pairs);
|
|
57
|
+
const re = new RegExp(`(${pairs.map(([f]) => reEscape(f)).join('|')})(?![A-Za-z0-9._-])`, 'g');
|
|
58
|
+
return (s) => s.replace(re, (m) => table.get(m));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The box path for a local path under one of the maps, or null.
|
|
62
|
+
export function mapPath(maps, p) {
|
|
63
|
+
let best = null;
|
|
64
|
+
for (const [from, to] of maps)
|
|
65
|
+
if ((p === from || p.startsWith(from + '/')) && (!best || from.length > best[0].length)) best = [from, to];
|
|
66
|
+
return best ? best[1] + p.slice(best[0].length) : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function lstat(p) {
|
|
70
|
+
try {
|
|
71
|
+
return lstatSync(p);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function filesUnder(dir, rel = '') {
|
|
78
|
+
const out = [];
|
|
79
|
+
let names = [];
|
|
80
|
+
try {
|
|
81
|
+
names = readdirSync(join(dir, rel)).sort();
|
|
82
|
+
} catch {
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
for (const n of names) {
|
|
86
|
+
const r = rel ? `${rel}/${n}` : n;
|
|
87
|
+
const st = lstat(join(dir, r));
|
|
88
|
+
if (!st) continue;
|
|
89
|
+
if (st.isDirectory()) out.push(...filesUnder(dir, r));
|
|
90
|
+
else if (st.isFile()) out.push(r);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The first "cwd" recorded in a JSONL file, read from its start.
|
|
96
|
+
function firstCwd(path, limit = 4 << 20) {
|
|
97
|
+
let text;
|
|
98
|
+
let fd;
|
|
99
|
+
try {
|
|
100
|
+
fd = openSync(path, 'r');
|
|
101
|
+
const buf = Buffer.alloc(Math.min(limit, statSync(path).size));
|
|
102
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
103
|
+
text = buf.subarray(0, n).toString('utf8');
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
} finally {
|
|
107
|
+
if (fd !== undefined) closeSync(fd);
|
|
108
|
+
}
|
|
109
|
+
const m = /"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(text);
|
|
110
|
+
if (!m) return null;
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(`"${m[1]}"`);
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const TEXT = /\.(?:jsonl|json|txt|md|log)$/i;
|
|
119
|
+
|
|
120
|
+
// Copy src to dst, rewritten when it is text, keeping its times and a private mode.
|
|
121
|
+
function copyRewritten(src, dst, rewrite) {
|
|
122
|
+
const st = statSync(src);
|
|
123
|
+
const buf = readFileSync(src);
|
|
124
|
+
const out = TEXT.test(src) && !buf.includes(0) ? Buffer.from(rewrite(buf.toString('utf8')), 'utf8') : buf;
|
|
125
|
+
mkdirSync(dirname(dst), { recursive: true, mode: 0o700 });
|
|
126
|
+
writeFileSync(dst, out, { mode: 0o600 });
|
|
127
|
+
chmodSync(dst, 0o600);
|
|
128
|
+
utimesSync(dst, st.atime, st.mtime);
|
|
129
|
+
return { bytes: out.length, mtime: st.mtimeMs };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function claude(maps, rewrite, out, home) {
|
|
133
|
+
const root = join(process.env.CLAUDE_CONFIG_DIR || join(home, '.claude'));
|
|
134
|
+
const projects = join(root, 'projects');
|
|
135
|
+
const res = { projects: [], sessions: 0, bytes: 0, recent: [], historyLines: 0 };
|
|
136
|
+
const prefixes = maps.map(([from]) => claudeProjectName(from));
|
|
137
|
+
let names = [];
|
|
138
|
+
try {
|
|
139
|
+
names = readdirSync(projects);
|
|
140
|
+
} catch {}
|
|
141
|
+
const sessions = [];
|
|
142
|
+
for (const name of names.sort()) {
|
|
143
|
+
if (!prefixes.some((p) => name === p || name.startsWith(p + '-'))) continue;
|
|
144
|
+
const dir = join(projects, name);
|
|
145
|
+
if (!lstat(dir)?.isDirectory()) continue;
|
|
146
|
+
const files = filesUnder(dir);
|
|
147
|
+
// Which path the directory is for: the cwd its sessions recorded (the name alone is lossy:
|
|
148
|
+
// /a/app-x and /a/app/x share it), or the exact name of a mapped path when nothing recorded one.
|
|
149
|
+
let cwd = null;
|
|
150
|
+
for (const f of files.filter((f) => f.endsWith('.jsonl'))) if ((cwd = firstCwd(join(dir, f)))) break;
|
|
151
|
+
let to = cwd ? mapPath(maps, cwd) : null;
|
|
152
|
+
if (!cwd) {
|
|
153
|
+
const exact = maps.find(([from]) => claudeProjectName(from) === name);
|
|
154
|
+
to = exact ? exact[1] : null;
|
|
155
|
+
}
|
|
156
|
+
if (!to) continue;
|
|
157
|
+
const newName = claudeProjectName(to);
|
|
158
|
+
let bytes = 0;
|
|
159
|
+
for (const f of files) {
|
|
160
|
+
const r = copyRewritten(join(dir, f), join(out, '.claude', 'projects', newName, f), rewrite);
|
|
161
|
+
bytes += r.bytes;
|
|
162
|
+
if (!f.includes('/') && f.endsWith('.jsonl')) sessions.push({ id: f.slice(0, -6), mtime: r.mtime, dir: to });
|
|
163
|
+
}
|
|
164
|
+
res.projects.push({ from: cwd || name, to, dir: newName, files: files.length, bytes });
|
|
165
|
+
res.bytes += bytes;
|
|
166
|
+
}
|
|
167
|
+
res.sessions = sessions.length;
|
|
168
|
+
res.recent = sessions
|
|
169
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
170
|
+
.slice(0, 5)
|
|
171
|
+
.map(({ id, dir }) => ({ id, dir }));
|
|
172
|
+
|
|
173
|
+
// Prompt history (up-arrow in claude): lines whose "project" is a mapped path.
|
|
174
|
+
const hist = join(root, 'history.jsonl');
|
|
175
|
+
if (existsSync(hist)) {
|
|
176
|
+
const lines = [];
|
|
177
|
+
for (const line of readFileSync(hist, 'utf8').split('\n')) {
|
|
178
|
+
if (!line.trim()) continue;
|
|
179
|
+
let o;
|
|
180
|
+
try {
|
|
181
|
+
o = JSON.parse(line);
|
|
182
|
+
} catch {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const to = typeof o.project === 'string' ? mapPath(maps, o.project) : null;
|
|
186
|
+
if (!to) continue;
|
|
187
|
+
lines.push(rewrite(line));
|
|
188
|
+
}
|
|
189
|
+
if (lines.length) {
|
|
190
|
+
const dst = join(out, '.cache', 'instabox-move-repo', 'claude-history.jsonl');
|
|
191
|
+
mkdirSync(dirname(dst), { recursive: true, mode: 0o700 });
|
|
192
|
+
writeFileSync(dst, lines.join('\n') + '\n', { mode: 0o600 });
|
|
193
|
+
res.historyLines = lines.length;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return res;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function codex(maps, rewrite, out, home) {
|
|
200
|
+
const root = process.env.CODEX_HOME || join(home, '.codex');
|
|
201
|
+
const res = { sessions: 0, bytes: 0, recent: [], historyLines: 0 };
|
|
202
|
+
const ids = new Set();
|
|
203
|
+
const found = [];
|
|
204
|
+
for (const sub of ['sessions', 'archived_sessions']) {
|
|
205
|
+
const dir = join(root, sub);
|
|
206
|
+
for (const f of filesUnder(dir)) {
|
|
207
|
+
const base = f.split('/').pop();
|
|
208
|
+
if (!/^rollout-.*\.jsonl$/.test(base)) continue;
|
|
209
|
+
const cwd = firstCwd(join(dir, f));
|
|
210
|
+
if (!cwd || !mapPath(maps, cwd)) continue;
|
|
211
|
+
const r = copyRewritten(join(dir, f), join(out, '.codex', sub, f), rewrite);
|
|
212
|
+
res.bytes += r.bytes;
|
|
213
|
+
const id = (/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/.exec(base) || [])[1];
|
|
214
|
+
if (id) ids.add(id);
|
|
215
|
+
found.push({ id: id || base, name: base, archived: sub !== 'sessions', dir: mapPath(maps, cwd) });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
res.sessions = found.length;
|
|
219
|
+
// Rollout file names start with the time they began.
|
|
220
|
+
res.recent = found
|
|
221
|
+
.filter((s) => !s.archived)
|
|
222
|
+
.sort((a, b) => b.name.localeCompare(a.name))
|
|
223
|
+
.slice(0, 5)
|
|
224
|
+
.map(({ id, dir }) => ({ id, dir }));
|
|
225
|
+
|
|
226
|
+
// Prompt history: lines of the moved sessions.
|
|
227
|
+
const hist = join(root, 'history.jsonl');
|
|
228
|
+
if (ids.size && existsSync(hist)) {
|
|
229
|
+
const lines = [];
|
|
230
|
+
for (const line of readFileSync(hist, 'utf8').split('\n')) {
|
|
231
|
+
if (!line.trim()) continue;
|
|
232
|
+
try {
|
|
233
|
+
if (ids.has(JSON.parse(line).session_id)) lines.push(rewrite(line));
|
|
234
|
+
} catch {}
|
|
235
|
+
}
|
|
236
|
+
if (lines.length) {
|
|
237
|
+
const dst = join(out, '.cache', 'instabox-move-repo', 'codex-history.jsonl');
|
|
238
|
+
mkdirSync(dirname(dst), { recursive: true, mode: 0o700 });
|
|
239
|
+
writeFileSync(dst, lines.join('\n') + '\n', { mode: 0o600 });
|
|
240
|
+
res.historyLines = lines.length;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return res;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Run on the box after the stage is unpacked into HOME: appends the prompt history lines that
|
|
247
|
+
// are not there yet, then removes the staged copies. Safe to run again.
|
|
248
|
+
const APPLY = `#!/usr/bin/env bash
|
|
249
|
+
# Written by instabox-move-repo (history.mjs). Safe to run again.
|
|
250
|
+
set -eu
|
|
251
|
+
A="$HOME/.cache/instabox-move-repo"
|
|
252
|
+
for t in claude codex; do
|
|
253
|
+
src="$A/$t-history.jsonl"
|
|
254
|
+
[ -f "$src" ] || continue
|
|
255
|
+
dst="$HOME/.$t/history.jsonl"
|
|
256
|
+
mkdir -p "$HOME/.$t"
|
|
257
|
+
touch "$dst"
|
|
258
|
+
cat "$dst" "$src" | awk '!seen[$0]++' > "$dst.tmp"
|
|
259
|
+
chmod 600 "$dst.tmp"
|
|
260
|
+
mv "$dst.tmp" "$dst"
|
|
261
|
+
rm -f "$src"
|
|
262
|
+
done
|
|
263
|
+
echo "instabox-move-repo: history in place"
|
|
264
|
+
`;
|
|
265
|
+
|
|
266
|
+
export function stageHistory({ maps, out, home = homedir(), claude: doClaude = true, codex: doCodex = true }) {
|
|
267
|
+
const rewrite = makeRewriter(maps);
|
|
268
|
+
mkdirSync(out, { recursive: true, mode: 0o700 });
|
|
269
|
+
const res = {
|
|
270
|
+
maps: maps.map(([from, to]) => ({ from, to })),
|
|
271
|
+
claude: doClaude ? claude(maps, rewrite, out, home) : null,
|
|
272
|
+
codex: doCodex ? codex(maps, rewrite, out, home) : null,
|
|
273
|
+
};
|
|
274
|
+
const apply = join(out, '.cache', 'instabox-move-repo', 'apply-history.sh');
|
|
275
|
+
mkdirSync(dirname(apply), { recursive: true, mode: 0o700 });
|
|
276
|
+
writeFileSync(apply, APPLY, { mode: 0o700 });
|
|
277
|
+
res.entries = readdirSync(out).sort(); // the top-level names to tar
|
|
278
|
+
return res;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function mapsFromPlan(plan) {
|
|
282
|
+
const maps = [[plan.main.src, plan.main.box]];
|
|
283
|
+
for (const w of plan.worktrees || []) if (w.move) maps.push([w.src, w.box]);
|
|
284
|
+
return maps;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function main(argv) {
|
|
288
|
+
const maps = [];
|
|
289
|
+
let out;
|
|
290
|
+
let doClaude = true;
|
|
291
|
+
let doCodex = true;
|
|
292
|
+
for (let i = 0; i < argv.length; i++) {
|
|
293
|
+
const a = argv[i];
|
|
294
|
+
const val = () => {
|
|
295
|
+
if (argv[i + 1] === undefined) throw new Error(`${a} needs a value`);
|
|
296
|
+
return argv[++i];
|
|
297
|
+
};
|
|
298
|
+
try {
|
|
299
|
+
if (a === '--plan') maps.push(...mapsFromPlan(JSON.parse(readFileSync(val(), 'utf8'))));
|
|
300
|
+
else if (a === '--map') {
|
|
301
|
+
const v = val();
|
|
302
|
+
const eq = v.indexOf('=');
|
|
303
|
+
if (eq <= 0) throw new Error(`--map wants LOCAL=BOX, got ${v}`);
|
|
304
|
+
maps.push([v.slice(0, eq), v.slice(eq + 1)]);
|
|
305
|
+
} else if (a === '--out') out = val();
|
|
306
|
+
else if (a === '--no-claude') doClaude = false;
|
|
307
|
+
else if (a === '--no-codex') doCodex = false;
|
|
308
|
+
else if (a === '-h' || a === '--help') {
|
|
309
|
+
process.stdout.write('usage: node history.mjs (--plan plan.json | --map LOCAL=BOX ...) --out DIR [--no-claude] [--no-codex]\n');
|
|
310
|
+
return 0;
|
|
311
|
+
} else throw new Error(`unknown argument ${a}`);
|
|
312
|
+
} catch (e) {
|
|
313
|
+
process.stderr.write(`history: ${e.message}\n`);
|
|
314
|
+
return 2;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (!out || !maps.length) {
|
|
318
|
+
process.stderr.write('history: --out and --plan (or --map) are required\n');
|
|
319
|
+
return 2;
|
|
320
|
+
}
|
|
321
|
+
// Local paths as the agents recorded them: also the symlink-resolved spelling (macOS /tmp, /var).
|
|
322
|
+
const all = [];
|
|
323
|
+
for (const [from, to] of maps) {
|
|
324
|
+
all.push([from, to]);
|
|
325
|
+
try {
|
|
326
|
+
const r = realpathSync(from);
|
|
327
|
+
if (r !== from) all.push([r, to]);
|
|
328
|
+
} catch {}
|
|
329
|
+
}
|
|
330
|
+
const res = stageHistory({ maps: all, out, claude: doClaude, codex: doCodex });
|
|
331
|
+
res.out = relative(process.cwd(), out) || '.';
|
|
332
|
+
process.stdout.write(JSON.stringify(res, null, 2) + '\n');
|
|
333
|
+
return 0;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let isMain = false;
|
|
337
|
+
try {
|
|
338
|
+
isMain = realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
339
|
+
} catch {}
|
|
340
|
+
if (isMain) process.exitCode = main(process.argv.slice(2));
|