@generativereality/cctabs 0.3.2 → 0.4.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/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +562 -77
- package/package.json +1 -1
- package/skills/cctabs/SKILL.md +38 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctabs",
|
|
3
3
|
"description": "Claude Code tab manager. Terminal tabs as the UI, no tmux. Claude can orchestrate its own sibling sessions.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "generativereality",
|
|
7
7
|
"url": "https://cctabs.com"
|
package/dist/index.js
CHANGED
|
@@ -4,14 +4,14 @@ import { cli, define } from "gunshi";
|
|
|
4
4
|
import { createConnection } from "net";
|
|
5
5
|
import { execFileSync, spawn, spawnSync } from "child_process";
|
|
6
6
|
import { randomUUID } from "crypto";
|
|
7
|
-
import { homedir, platform, tmpdir } from "os";
|
|
7
|
+
import { homedir, hostname, platform, tmpdir } from "os";
|
|
8
8
|
import { basename, dirname, extname, join, resolve } from "path";
|
|
9
|
-
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
9
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "fs";
|
|
10
10
|
import { consola } from "consola";
|
|
11
11
|
import * as p from "@clack/prompts";
|
|
12
12
|
//#region package.json
|
|
13
13
|
var name = "@generativereality/cctabs";
|
|
14
|
-
var version = "0.
|
|
14
|
+
var version = "0.4.1";
|
|
15
15
|
var description = "Claude Code tab manager. Terminal tabs as the UI, no tmux.";
|
|
16
16
|
var package_default = {
|
|
17
17
|
name,
|
|
@@ -651,6 +651,8 @@ var TabbyAdapter = class {
|
|
|
651
651
|
"claude.ai/code",
|
|
652
652
|
"⏵⏵ bypass",
|
|
653
653
|
"⏵⏵ auto",
|
|
654
|
+
"new task?",
|
|
655
|
+
"Checking for updates",
|
|
654
656
|
"Thinking",
|
|
655
657
|
"Hatching",
|
|
656
658
|
"Composing",
|
|
@@ -694,6 +696,27 @@ var TabbyAdapter = class {
|
|
|
694
696
|
async renameTab(tabId, name) {
|
|
695
697
|
await this.http("PUT", `/api/tabs/${tabId}/title`, { title: name });
|
|
696
698
|
}
|
|
699
|
+
/**
|
|
700
|
+
* Fast path: the plugin's POST /api/tabs/new accepts {cwd, title, command,
|
|
701
|
+
* args} and returns the new tab's uuid synchronously. This collapses the
|
|
702
|
+
* whole newTab → waitForNewBlock → renameTab → wait-for-shell-prompt →
|
|
703
|
+
* sendInput sequence into a single round-trip, and (because the uuid is
|
|
704
|
+
* returned, not discovered by diffing) lets the caller open many tabs at once.
|
|
705
|
+
*/
|
|
706
|
+
async openTabDirect(opts) {
|
|
707
|
+
this.ensureHealthy();
|
|
708
|
+
const uuid = (await this.http("POST", "/api/tabs/new", {
|
|
709
|
+
cwd: opts.cwd,
|
|
710
|
+
title: opts.title,
|
|
711
|
+
command: opts.command,
|
|
712
|
+
args: opts.args
|
|
713
|
+
}))?.uuid;
|
|
714
|
+
if (!uuid) throw new Error("Tabby plugin did not return a tab uuid");
|
|
715
|
+
return {
|
|
716
|
+
blockId: uuid,
|
|
717
|
+
tabId: uuid
|
|
718
|
+
};
|
|
719
|
+
}
|
|
697
720
|
async sendInput(blockId, text) {
|
|
698
721
|
return this.http("POST", `/api/tabs/${blockId}/send`, { data: text });
|
|
699
722
|
}
|
|
@@ -1259,66 +1282,114 @@ async function waitForScrollbackMatch(adapter, blockId, pattern, label, timeoutM
|
|
|
1259
1282
|
}
|
|
1260
1283
|
throw new Error(`Timed out waiting for ${label}`);
|
|
1261
1284
|
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Wait for Claude's input prompt, then send the initial task and reliably
|
|
1287
|
+
* submit it.
|
|
1288
|
+
*
|
|
1289
|
+
* Reliability matters because a naive "send text, then send \r" loses the
|
|
1290
|
+
* Enter for multi-line prompts: the terminal treats the burst as a bracketed
|
|
1291
|
+
* paste and swallows a \r that arrives inside the paste window, leaving the
|
|
1292
|
+
* text sitting unsent in the input box. We fix that two ways:
|
|
1293
|
+
* 1. Wrap the prompt in explicit bracketed-paste markers so the (possibly
|
|
1294
|
+
* multi-line) text is ingested as one paste and the following Enter lands
|
|
1295
|
+
* *outside* it — an unambiguous submit, not a newline.
|
|
1296
|
+
* 2. Verify the turn actually started (a spinner / "esc to interrupt" hint
|
|
1297
|
+
* appears only while Claude is processing, never at the idle prompt), and
|
|
1298
|
+
* re-send Enter a few times if not, since a large paste can still be
|
|
1299
|
+
* mid-ingest when the first Enter arrives.
|
|
1300
|
+
*/
|
|
1301
|
+
async function sendInitialPrompt(adapter, blockId, initialPromptFile) {
|
|
1302
|
+
try {
|
|
1303
|
+
await waitForScrollbackMatch(adapter, blockId, "❯", "Claude prompt", 3e4);
|
|
1304
|
+
} catch {
|
|
1305
|
+
adapter.closeSocket();
|
|
1306
|
+
throw new Error("Claude prompt (❯) never appeared — not sending initial prompt. Check that claude started successfully.");
|
|
1307
|
+
}
|
|
1308
|
+
const prompt = readFileSync(initialPromptFile, "utf-8").trimEnd();
|
|
1309
|
+
await adapter.sendInput(blockId, `\x1b[200~${prompt}\x1b[201~`);
|
|
1310
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
1311
|
+
await new Promise((r) => setTimeout(r, attempt === 0 ? 300 : 500));
|
|
1312
|
+
await adapter.sendInput(blockId, "\r");
|
|
1313
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1314
|
+
const tail = adapter.scrollback(blockId, 40);
|
|
1315
|
+
const compact = tail.replace(/\s+/g, "");
|
|
1316
|
+
if (/[✻✽✶✳✢]/.test(tail) || /esctointerrupt/i.test(compact)) return;
|
|
1317
|
+
}
|
|
1318
|
+
consola.warn("Could not confirm the initial prompt was submitted — it may be sitting in the input box. Press Enter in the tab to send it.");
|
|
1319
|
+
}
|
|
1262
1320
|
async function openSession(opts) {
|
|
1263
1321
|
const { tabName, claudeCmd, workspaceQuery, initialPromptFile, envVars, modelOverride } = opts;
|
|
1322
|
+
const tailDelayMs = opts.tailDelayMs ?? 2e3;
|
|
1264
1323
|
const dir = resolve(opts.dir.replace(/^~/, homedir()));
|
|
1265
|
-
if (!existsSync(dir)) {
|
|
1266
|
-
consola.error(`Directory does not exist: ${dir}`);
|
|
1267
|
-
process.exit(1);
|
|
1268
|
-
}
|
|
1324
|
+
if (!existsSync(dir)) throw new Error(`Directory does not exist: ${dir}`);
|
|
1269
1325
|
const config = loadConfig();
|
|
1270
1326
|
const adapter = requireAdapter();
|
|
1327
|
+
const timing = !!process.env.CCTABS_TIMING;
|
|
1328
|
+
let tPhase = Date.now();
|
|
1329
|
+
const mark = (label) => {
|
|
1330
|
+
if (!timing) return;
|
|
1331
|
+
const now = Date.now();
|
|
1332
|
+
consola.log(` ⏱ ${tabName} ${label}: ${now - tPhase}ms`);
|
|
1333
|
+
tPhase = now;
|
|
1334
|
+
};
|
|
1335
|
+
if (adapter.openTabDirect) {
|
|
1336
|
+
const extraFlags = config.claude.flags.join(" ");
|
|
1337
|
+
const namePart = claudeCmd.includes("--resume") ? "" : ` --name ${JSON.stringify(tabName)}`;
|
|
1338
|
+
const modelPart = modelOverride ? ` --model ${JSON.stringify(modelOverride)}` : "";
|
|
1339
|
+
const envPrefix = envVars ? shellQuoteEnv$1(envVars) : "";
|
|
1340
|
+
const claudeCore = `claude${extraFlags ? " " + extraFlags : ""} ${claudeCmd.replace(/^claude\s*/, "")}${namePart}${modelPart}`.replace(/\s+/g, " ").trim();
|
|
1341
|
+
const shell = process.env.SHELL ?? "/bin/zsh";
|
|
1342
|
+
const launch = `${envPrefix}exec ${claudeCore}`;
|
|
1343
|
+
const { blockId, tabId } = await adapter.openTabDirect({
|
|
1344
|
+
cwd: dir,
|
|
1345
|
+
title: tabName,
|
|
1346
|
+
command: shell,
|
|
1347
|
+
args: [
|
|
1348
|
+
"-l",
|
|
1349
|
+
"-c",
|
|
1350
|
+
launch
|
|
1351
|
+
]
|
|
1352
|
+
});
|
|
1353
|
+
mark("openTabDirect");
|
|
1354
|
+
if (initialPromptFile) await sendInitialPrompt(adapter, blockId, initialPromptFile);
|
|
1355
|
+
adapter.closeSocket();
|
|
1356
|
+
return tabId;
|
|
1357
|
+
}
|
|
1271
1358
|
let focusWindowId;
|
|
1272
1359
|
if (workspaceQuery) {
|
|
1273
1360
|
const { workspaces } = await adapter.getAllData();
|
|
1274
1361
|
const matches = adapter.resolveWorkspace(workspaces, workspaceQuery);
|
|
1275
|
-
if (!matches.length) {
|
|
1276
|
-
consola.error(`No workspace matching '${workspaceQuery}'`);
|
|
1277
|
-
process.exit(1);
|
|
1278
|
-
}
|
|
1362
|
+
if (!matches.length) throw new Error(`No workspace matching '${workspaceQuery}'`);
|
|
1279
1363
|
const { data, windowId } = matches[0];
|
|
1280
|
-
if (!windowId) {
|
|
1281
|
-
consola.error(`Workspace '${data.name}' has no open window`);
|
|
1282
|
-
process.exit(1);
|
|
1283
|
-
}
|
|
1364
|
+
if (!windowId) throw new Error(`Workspace '${data.name}' has no open window`);
|
|
1284
1365
|
focusWindowId = windowId;
|
|
1285
1366
|
consola.info(`Workspace: ${data.name}`);
|
|
1286
1367
|
}
|
|
1287
1368
|
const beforeIds = new Set(adapter.blocksList().filter((b) => b.view === "term").map((b) => b.blockid));
|
|
1369
|
+
mark("beforeIds");
|
|
1288
1370
|
await adapter.newTab(focusWindowId);
|
|
1371
|
+
mark("newTab");
|
|
1289
1372
|
const result = await adapter.waitForNewBlock(beforeIds);
|
|
1290
|
-
if (!result)
|
|
1291
|
-
|
|
1292
|
-
process.exit(1);
|
|
1293
|
-
}
|
|
1373
|
+
if (!result) throw new Error("Timed out waiting for new terminal block");
|
|
1374
|
+
mark("waitForNewBlock");
|
|
1294
1375
|
const { blockId, tabId } = result;
|
|
1295
1376
|
await adapter.renameTab(tabId, tabName);
|
|
1377
|
+
mark("renameTab");
|
|
1296
1378
|
try {
|
|
1297
1379
|
await waitForScrollbackMatch(adapter, blockId, /[$%>]\s*$/, "shell prompt", 1e4, 250);
|
|
1298
1380
|
} catch {
|
|
1299
|
-
|
|
1300
|
-
process.exit(1);
|
|
1381
|
+
throw new Error("Shell prompt never appeared in new tab — aborting. Check your shell profile (e.g. nvm default alias).");
|
|
1301
1382
|
}
|
|
1383
|
+
mark("shellPrompt");
|
|
1302
1384
|
const extraFlags = config.claude.flags.join(" ");
|
|
1303
1385
|
const namePart = claudeCmd.includes("--resume") ? "" : ` --name ${JSON.stringify(tabName)}`;
|
|
1304
1386
|
const modelPart = modelOverride ? ` --model ${JSON.stringify(modelOverride)}` : "";
|
|
1305
1387
|
const envPrefix = envVars ? shellQuoteEnv$1(envVars) : "";
|
|
1306
1388
|
const cmd = `cd ${JSON.stringify(dir)} && ${envPrefix}claude${extraFlags ? " " + extraFlags : ""} ${claudeCmd.replace(/^claude\s*/, "")}${namePart}${modelPart}\r`;
|
|
1307
1389
|
await adapter.sendInput(blockId, cmd);
|
|
1308
|
-
if (initialPromptFile)
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
} catch {
|
|
1312
|
-
consola.error("Claude prompt (❯) never appeared — not sending initial prompt. Check that claude started successfully.");
|
|
1313
|
-
adapter.closeSocket();
|
|
1314
|
-
process.exit(1);
|
|
1315
|
-
}
|
|
1316
|
-
const prompt = readFileSync(initialPromptFile, "utf-8").trimEnd();
|
|
1317
|
-
await adapter.sendInput(blockId, prompt);
|
|
1318
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
1319
|
-
await adapter.sendInput(blockId, "\r");
|
|
1320
|
-
}
|
|
1321
|
-
await new Promise((r) => setTimeout(r, 2e3));
|
|
1390
|
+
if (initialPromptFile) await sendInitialPrompt(adapter, blockId, initialPromptFile);
|
|
1391
|
+
if (tailDelayMs > 0) await new Promise((r) => setTimeout(r, tailDelayMs));
|
|
1392
|
+
mark("tail");
|
|
1322
1393
|
adapter.closeSocket();
|
|
1323
1394
|
return tabId;
|
|
1324
1395
|
}
|
|
@@ -2339,24 +2410,29 @@ async function runManifestMode(manifestPath, createMissing, dryRun) {
|
|
|
2339
2410
|
}
|
|
2340
2411
|
}
|
|
2341
2412
|
adapter.closeSocket();
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2413
|
+
const spawnOne = async (entry) => {
|
|
2414
|
+
try {
|
|
2415
|
+
const claudeCmd = entry.session_id ? `claude --resume ${entry.session_id} --name ${JSON.stringify(entry.name)}` : "claude";
|
|
2416
|
+
const newTabId = await openSession({
|
|
2417
|
+
tabName: entry.name,
|
|
2418
|
+
dir: entry.dir,
|
|
2419
|
+
claudeCmd,
|
|
2420
|
+
tailDelayMs: 500
|
|
2421
|
+
});
|
|
2422
|
+
const sid = entry.session_id ? entry.session_id.slice(0, 8) + "…" : "fresh";
|
|
2423
|
+
results.push({
|
|
2424
|
+
name: entry.name,
|
|
2425
|
+
result: `✔ spawned [${newTabId.slice(0, 8)}] (${sid})`
|
|
2426
|
+
});
|
|
2427
|
+
} catch (err) {
|
|
2428
|
+
results.push({
|
|
2429
|
+
name: entry.name,
|
|
2430
|
+
result: `✘ spawn failed: ${err.message}`
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2433
|
+
};
|
|
2434
|
+
if (typeof adapter.openTabDirect === "function") await Promise.all(toSpawn.map(spawnOne));
|
|
2435
|
+
else for (const entry of toSpawn) await spawnOne(entry);
|
|
2360
2436
|
console.log("\nRestore summary:");
|
|
2361
2437
|
for (const r of results) console.log(` ${r.name}: ${r.result}`);
|
|
2362
2438
|
}
|
|
@@ -2391,6 +2467,7 @@ async function runLegacyMode(rawDir, dryRun) {
|
|
|
2391
2467
|
const extraFlags = loadConfig().claude.flags.join(" ");
|
|
2392
2468
|
const results = [];
|
|
2393
2469
|
const toRecreate = [];
|
|
2470
|
+
const resolved = [];
|
|
2394
2471
|
for (const tab of toResume) {
|
|
2395
2472
|
let sessionId = null;
|
|
2396
2473
|
let sessionDir = null;
|
|
@@ -2437,8 +2514,21 @@ async function runLegacyMode(rawDir, dryRun) {
|
|
|
2437
2514
|
});
|
|
2438
2515
|
continue;
|
|
2439
2516
|
}
|
|
2440
|
-
|
|
2441
|
-
|
|
2517
|
+
resolved.push({
|
|
2518
|
+
tab,
|
|
2519
|
+
sessionId,
|
|
2520
|
+
sessionDir
|
|
2521
|
+
});
|
|
2522
|
+
}
|
|
2523
|
+
if (!dryRun) {
|
|
2524
|
+
const unknownTabs = resolved.filter((r) => r.tab.status === "unknown");
|
|
2525
|
+
const emptyById = /* @__PURE__ */ new Map();
|
|
2526
|
+
await Promise.all(unknownTabs.map(async (r) => {
|
|
2527
|
+
emptyById.set(r.tab.tabId, await adapter.confirmScrollbackEmpty(r.tab.blockId));
|
|
2528
|
+
}));
|
|
2529
|
+
for (const r of resolved) {
|
|
2530
|
+
const { tab, sessionId, sessionDir } = r;
|
|
2531
|
+
if (tab.status === "unknown" && emptyById.get(tab.tabId)) {
|
|
2442
2532
|
const blockIds = (tabsById.get(tab.tabId) ?? []).map((b) => b.blockid);
|
|
2443
2533
|
toRecreate.push({
|
|
2444
2534
|
name: tab.name,
|
|
@@ -2453,15 +2543,15 @@ async function runLegacyMode(rawDir, dryRun) {
|
|
|
2453
2543
|
});
|
|
2454
2544
|
continue;
|
|
2455
2545
|
}
|
|
2546
|
+
consola.log(` ${tab.name} → resuming session ${sessionId.slice(0, 8)}… in ${sessionDir} (send)`);
|
|
2547
|
+
const cmd = `cd ${JSON.stringify(sessionDir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(tab.name)}\r`;
|
|
2548
|
+
await adapter.sendInput(tab.blockId, cmd);
|
|
2549
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
2550
|
+
results.push({
|
|
2551
|
+
name: tab.name,
|
|
2552
|
+
result: "sent"
|
|
2553
|
+
});
|
|
2456
2554
|
}
|
|
2457
|
-
consola.log(` ${tab.name} → resuming session ${sessionId.slice(0, 8)}… in ${sessionDir} (send)`);
|
|
2458
|
-
const cmd = `cd ${JSON.stringify(sessionDir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(tab.name)}\r`;
|
|
2459
|
-
await adapter.sendInput(tab.blockId, cmd);
|
|
2460
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
2461
|
-
results.push({
|
|
2462
|
-
name: tab.name,
|
|
2463
|
-
result: "sent"
|
|
2464
|
-
});
|
|
2465
2555
|
}
|
|
2466
2556
|
if (!dryRun) {
|
|
2467
2557
|
const sent = results.filter((r) => r.result === "sent");
|
|
@@ -2481,18 +2571,23 @@ async function runLegacyMode(rawDir, dryRun) {
|
|
|
2481
2571
|
for (const t of toRecreate) for (const bid of t.blockIds) adapter.deleteBlock(bid);
|
|
2482
2572
|
adapter.closeSocket();
|
|
2483
2573
|
consola.info(`Recreating ${toRecreate.length} dead tab(s)…`);
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2574
|
+
const recreateOne = async (t) => {
|
|
2575
|
+
try {
|
|
2576
|
+
const newTabId = await openSession({
|
|
2577
|
+
tabName: t.name,
|
|
2578
|
+
dir: t.sessionDir,
|
|
2579
|
+
claudeCmd: `claude --resume ${t.sessionId} --name ${JSON.stringify(t.name)}`,
|
|
2580
|
+
tailDelayMs: 500
|
|
2581
|
+
});
|
|
2582
|
+
const r = results.find((x) => x.name === t.name);
|
|
2583
|
+
r.result = `✔ recreated [${newTabId.slice(0, 8)}]`;
|
|
2584
|
+
} catch (err) {
|
|
2585
|
+
const r = results.find((x) => x.name === t.name);
|
|
2586
|
+
r.result = `✘ recreate failed: ${err.message}`;
|
|
2587
|
+
}
|
|
2588
|
+
};
|
|
2589
|
+
if (typeof adapter.openTabDirect === "function") await Promise.all(toRecreate.map(recreateOne));
|
|
2590
|
+
else for (const t of toRecreate) await recreateOne(t);
|
|
2496
2591
|
} else adapter.closeSocket();
|
|
2497
2592
|
console.log("\nRestore summary:");
|
|
2498
2593
|
for (const r of results) console.log(` ${r.name}: ${r.result}`);
|
|
@@ -2879,6 +2974,389 @@ echo "[$(date)] done"
|
|
|
2879
2974
|
}
|
|
2880
2975
|
});
|
|
2881
2976
|
//#endregion
|
|
2977
|
+
//#region src/commands/export-cmd.ts
|
|
2978
|
+
/** Replace anything that isn't safe in a filesystem dir name. */
|
|
2979
|
+
function safeDirName(name) {
|
|
2980
|
+
return name.replace(/[^A-Za-z0-9._-]+/g, "_").slice(0, 80) || "tab";
|
|
2981
|
+
}
|
|
2982
|
+
function timestampSlug() {
|
|
2983
|
+
const d = /* @__PURE__ */ new Date();
|
|
2984
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
2985
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
2986
|
+
}
|
|
2987
|
+
const exportCommand = define({
|
|
2988
|
+
name: "export",
|
|
2989
|
+
description: "Export tabs + their Claude sessions to a tarball you can move to another machine (then `cctabs import`).",
|
|
2990
|
+
args: {
|
|
2991
|
+
all: {
|
|
2992
|
+
type: "boolean",
|
|
2993
|
+
short: "a",
|
|
2994
|
+
description: "Export every tab in the workspace (use --workspace to pick one; default is the current workspace)."
|
|
2995
|
+
},
|
|
2996
|
+
workspace: {
|
|
2997
|
+
type: "string",
|
|
2998
|
+
short: "w",
|
|
2999
|
+
description: "Workspace to export from when using --all (defaults to current)."
|
|
3000
|
+
},
|
|
3001
|
+
out: {
|
|
3002
|
+
type: "string",
|
|
3003
|
+
short: "o",
|
|
3004
|
+
description: "Output path for the tarball. Default: ./cctabs-export-<name>-<timestamp>.tar.gz"
|
|
3005
|
+
}
|
|
3006
|
+
},
|
|
3007
|
+
async run(ctx) {
|
|
3008
|
+
const tabQuery = ctx.positionals[1];
|
|
3009
|
+
const exportAll = ctx.values.all ?? false;
|
|
3010
|
+
const workspaceQuery = ctx.values.workspace;
|
|
3011
|
+
const outPath = ctx.values.out;
|
|
3012
|
+
if (!tabQuery && !exportAll) {
|
|
3013
|
+
consola.error("Provide a tab name, or pass --all to export every tab in the workspace.");
|
|
3014
|
+
process.exit(1);
|
|
3015
|
+
}
|
|
3016
|
+
if (tabQuery && exportAll) {
|
|
3017
|
+
consola.error("Pass either a tab name OR --all, not both.");
|
|
3018
|
+
process.exit(1);
|
|
3019
|
+
}
|
|
3020
|
+
const adapter = requireAdapter();
|
|
3021
|
+
const { tabsById, tabNames, workspaces } = await adapter.getAllData();
|
|
3022
|
+
const currentWs = adapter.currentWorkspaceId();
|
|
3023
|
+
let wsData;
|
|
3024
|
+
if (workspaceQuery) {
|
|
3025
|
+
const matches = adapter.resolveWorkspace(workspaces, workspaceQuery);
|
|
3026
|
+
if (matches.length === 0) {
|
|
3027
|
+
consola.error(`Workspace not found: ${workspaceQuery}`);
|
|
3028
|
+
process.exit(1);
|
|
3029
|
+
}
|
|
3030
|
+
if (matches.length > 1) {
|
|
3031
|
+
consola.error(`Workspace query is ambiguous: ${workspaceQuery}`);
|
|
3032
|
+
process.exit(1);
|
|
3033
|
+
}
|
|
3034
|
+
wsData = matches[0].data;
|
|
3035
|
+
} else wsData = workspaces.find((w) => w.workspacedata.oid === currentWs)?.workspacedata;
|
|
3036
|
+
if (!wsData) {
|
|
3037
|
+
consola.error("Could not determine workspace.");
|
|
3038
|
+
process.exit(1);
|
|
3039
|
+
}
|
|
3040
|
+
const wsName = wsData.name;
|
|
3041
|
+
const wsTabIds = wsData.tabids.filter((t) => tabsById.has(t));
|
|
3042
|
+
let targetTabIds;
|
|
3043
|
+
if (exportAll) targetTabIds = wsTabIds;
|
|
3044
|
+
else {
|
|
3045
|
+
const matched = adapter.resolveTab(tabQuery, tabsById, tabNames).filter((tid) => wsTabIds.includes(tid));
|
|
3046
|
+
if (matched.length === 0) {
|
|
3047
|
+
consola.error(`No tab in workspace "${wsName}" matches: ${tabQuery}`);
|
|
3048
|
+
process.exit(1);
|
|
3049
|
+
}
|
|
3050
|
+
if (matched.length > 1) {
|
|
3051
|
+
consola.error(`Tab query is ambiguous: ${tabQuery} (matches ${matched.length} tabs)`);
|
|
3052
|
+
process.exit(1);
|
|
3053
|
+
}
|
|
3054
|
+
targetTabIds = matched;
|
|
3055
|
+
}
|
|
3056
|
+
const stageRoot = mkdtempSync(join(tmpdir(), "cctabs-export-"));
|
|
3057
|
+
const tabsRoot = join(stageRoot, "tabs");
|
|
3058
|
+
mkdirSync(tabsRoot, { recursive: true });
|
|
3059
|
+
const exported = [];
|
|
3060
|
+
const skipped = [];
|
|
3061
|
+
for (const tabId of targetTabIds) {
|
|
3062
|
+
const tabName = tabNames.get(tabId) ?? tabId.slice(0, 8);
|
|
3063
|
+
const termBlock = (tabsById.get(tabId) ?? []).find((b) => b.view === "term");
|
|
3064
|
+
if (!termBlock) {
|
|
3065
|
+
skipped.push({
|
|
3066
|
+
name: tabName,
|
|
3067
|
+
reason: "no terminal block"
|
|
3068
|
+
});
|
|
3069
|
+
continue;
|
|
3070
|
+
}
|
|
3071
|
+
const cwd = termBlock.meta?.["cmd:cwd"];
|
|
3072
|
+
if (!cwd) {
|
|
3073
|
+
skipped.push({
|
|
3074
|
+
name: tabName,
|
|
3075
|
+
reason: "no cwd recorded"
|
|
3076
|
+
});
|
|
3077
|
+
continue;
|
|
3078
|
+
}
|
|
3079
|
+
let sessionId;
|
|
3080
|
+
let effectiveCwd = cwd;
|
|
3081
|
+
try {
|
|
3082
|
+
const matches = findSessionsByName(cwd, tabName);
|
|
3083
|
+
if (matches.length) sessionId = matches[0].id;
|
|
3084
|
+
} catch {}
|
|
3085
|
+
if (!sessionId) {
|
|
3086
|
+
const worktreesDir = join(cwd, ".claude", "worktrees");
|
|
3087
|
+
if (existsSync(worktreesDir)) try {
|
|
3088
|
+
const candidates = [];
|
|
3089
|
+
for (const entry of readdirSync(worktreesDir)) {
|
|
3090
|
+
const wtPath = join(worktreesDir, entry);
|
|
3091
|
+
if (!statSync(wtPath).isDirectory()) continue;
|
|
3092
|
+
try {
|
|
3093
|
+
const matches = findSessionsByName(wtPath, tabName);
|
|
3094
|
+
if (matches.length) candidates.push({
|
|
3095
|
+
id: matches[0].id,
|
|
3096
|
+
mtime: matches[0].mtime,
|
|
3097
|
+
path: wtPath
|
|
3098
|
+
});
|
|
3099
|
+
} catch {}
|
|
3100
|
+
}
|
|
3101
|
+
if (candidates.length) {
|
|
3102
|
+
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
3103
|
+
sessionId = candidates[0].id;
|
|
3104
|
+
effectiveCwd = candidates[0].path;
|
|
3105
|
+
}
|
|
3106
|
+
} catch {}
|
|
3107
|
+
}
|
|
3108
|
+
if (!sessionId) {
|
|
3109
|
+
skipped.push({
|
|
3110
|
+
name: tabName,
|
|
3111
|
+
reason: "no Claude session found for this tab name + cwd"
|
|
3112
|
+
});
|
|
3113
|
+
continue;
|
|
3114
|
+
}
|
|
3115
|
+
const slug = pathToProjectSlug(effectiveCwd);
|
|
3116
|
+
const jsonlPath = join(homedir(), ".claude", "projects", slug, `${sessionId}.jsonl`);
|
|
3117
|
+
if (!existsSync(jsonlPath)) {
|
|
3118
|
+
skipped.push({
|
|
3119
|
+
name: tabName,
|
|
3120
|
+
reason: `session file missing: ${jsonlPath}`
|
|
3121
|
+
});
|
|
3122
|
+
continue;
|
|
3123
|
+
}
|
|
3124
|
+
const tabDir = join(tabsRoot, safeDirName(tabName));
|
|
3125
|
+
mkdirSync(tabDir, { recursive: true });
|
|
3126
|
+
copyFileSync(jsonlPath, join(tabDir, "session.jsonl"));
|
|
3127
|
+
const manifest = {
|
|
3128
|
+
name: tabName,
|
|
3129
|
+
cwd: effectiveCwd,
|
|
3130
|
+
sessionId,
|
|
3131
|
+
claudeProjectSlug: slug,
|
|
3132
|
+
workspace: wsName
|
|
3133
|
+
};
|
|
3134
|
+
writeFileSync(join(tabDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
|
|
3135
|
+
exported.push({ ...manifest });
|
|
3136
|
+
}
|
|
3137
|
+
if (exported.length === 0) {
|
|
3138
|
+
rmSync(stageRoot, {
|
|
3139
|
+
recursive: true,
|
|
3140
|
+
force: true
|
|
3141
|
+
});
|
|
3142
|
+
consola.error("Nothing to export. Skipped tabs:");
|
|
3143
|
+
for (const s of skipped) consola.log(` ${s.name}: ${s.reason}`);
|
|
3144
|
+
process.exit(1);
|
|
3145
|
+
}
|
|
3146
|
+
const meta = {
|
|
3147
|
+
cctabsExportVersion: 1,
|
|
3148
|
+
cctabsVersion: version,
|
|
3149
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3150
|
+
sourceMachine: hostname(),
|
|
3151
|
+
tabs: exported
|
|
3152
|
+
};
|
|
3153
|
+
writeFileSync(join(stageRoot, "meta.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
3154
|
+
const defaultName = exportAll ? `cctabs-export-${safeDirName(wsName)}-${timestampSlug()}.tar.gz` : `cctabs-export-${safeDirName(exported[0].name)}-${timestampSlug()}.tar.gz`;
|
|
3155
|
+
const resolvedOut = outPath ?? join(process.cwd(), defaultName);
|
|
3156
|
+
try {
|
|
3157
|
+
execFileSync("tar", [
|
|
3158
|
+
"-czf",
|
|
3159
|
+
resolvedOut,
|
|
3160
|
+
"-C",
|
|
3161
|
+
stageRoot,
|
|
3162
|
+
"."
|
|
3163
|
+
], { stdio: "inherit" });
|
|
3164
|
+
} catch (err) {
|
|
3165
|
+
rmSync(stageRoot, {
|
|
3166
|
+
recursive: true,
|
|
3167
|
+
force: true
|
|
3168
|
+
});
|
|
3169
|
+
consola.error(`tar failed: ${err.message}`);
|
|
3170
|
+
process.exit(1);
|
|
3171
|
+
}
|
|
3172
|
+
rmSync(stageRoot, {
|
|
3173
|
+
recursive: true,
|
|
3174
|
+
force: true
|
|
3175
|
+
});
|
|
3176
|
+
consola.success(`Exported ${exported.length} tab${exported.length === 1 ? "" : "s"} → ${resolvedOut}`);
|
|
3177
|
+
for (const t of exported) consola.log(` ✓ ${t.name} (${t.sessionId.slice(0, 8)}…) ${t.cwd}`);
|
|
3178
|
+
if (skipped.length) {
|
|
3179
|
+
consola.warn(`Skipped ${skipped.length}:`);
|
|
3180
|
+
for (const s of skipped) consola.log(` - ${s.name}: ${s.reason}`);
|
|
3181
|
+
}
|
|
3182
|
+
consola.log("");
|
|
3183
|
+
consola.log(`Import on another machine with: cctabs import ${defaultName}`);
|
|
3184
|
+
}
|
|
3185
|
+
});
|
|
3186
|
+
//#endregion
|
|
3187
|
+
//#region src/commands/import-cmd.ts
|
|
3188
|
+
function expandHome(p) {
|
|
3189
|
+
return p.startsWith("~") ? join(homedir(), p.slice(1)) : p;
|
|
3190
|
+
}
|
|
3191
|
+
const importCommand = define({
|
|
3192
|
+
name: "import",
|
|
3193
|
+
description: "Import tabs + sessions from a cctabs-export tarball (produced by `cctabs export`) and open each one as a new tab.",
|
|
3194
|
+
args: {
|
|
3195
|
+
cwd: {
|
|
3196
|
+
type: "string",
|
|
3197
|
+
short: "C",
|
|
3198
|
+
description: "Target working directory. With a single-tab archive, replaces the original cwd. Ignored for multi-tab archives."
|
|
3199
|
+
},
|
|
3200
|
+
workspace: {
|
|
3201
|
+
type: "string",
|
|
3202
|
+
short: "w",
|
|
3203
|
+
description: "Workspace to open the new tab(s) in (defaults to current)."
|
|
3204
|
+
},
|
|
3205
|
+
force: {
|
|
3206
|
+
type: "boolean",
|
|
3207
|
+
short: "f",
|
|
3208
|
+
description: "Overwrite an existing session jsonl in ~/.claude/projects/ if the same session id already exists locally."
|
|
3209
|
+
},
|
|
3210
|
+
"dry-run": {
|
|
3211
|
+
type: "boolean",
|
|
3212
|
+
short: "n",
|
|
3213
|
+
description: "Report what would happen without copying files or spawning tabs."
|
|
3214
|
+
}
|
|
3215
|
+
},
|
|
3216
|
+
async run(ctx) {
|
|
3217
|
+
const archive = ctx.positionals[1];
|
|
3218
|
+
const cwdOverride = ctx.values.cwd;
|
|
3219
|
+
const workspaceQuery = ctx.values.workspace;
|
|
3220
|
+
const force = ctx.values.force ?? false;
|
|
3221
|
+
const dryRun = ctx.values["dry-run"] ?? false;
|
|
3222
|
+
if (!archive) {
|
|
3223
|
+
consola.error("Archive path is required.");
|
|
3224
|
+
process.exit(1);
|
|
3225
|
+
}
|
|
3226
|
+
const archivePath = resolve(expandHome(archive));
|
|
3227
|
+
if (!existsSync(archivePath)) {
|
|
3228
|
+
consola.error(`Archive not found: ${archivePath}`);
|
|
3229
|
+
process.exit(1);
|
|
3230
|
+
}
|
|
3231
|
+
const stageRoot = mkdtempSync(join(tmpdir(), "cctabs-import-"));
|
|
3232
|
+
try {
|
|
3233
|
+
execFileSync("tar", [
|
|
3234
|
+
"-xzf",
|
|
3235
|
+
archivePath,
|
|
3236
|
+
"-C",
|
|
3237
|
+
stageRoot
|
|
3238
|
+
], { stdio: "inherit" });
|
|
3239
|
+
} catch (err) {
|
|
3240
|
+
rmSync(stageRoot, {
|
|
3241
|
+
recursive: true,
|
|
3242
|
+
force: true
|
|
3243
|
+
});
|
|
3244
|
+
consola.error(`tar failed to extract ${archivePath}: ${err.message}`);
|
|
3245
|
+
process.exit(1);
|
|
3246
|
+
}
|
|
3247
|
+
const metaPath = join(stageRoot, "meta.json");
|
|
3248
|
+
if (!existsSync(metaPath)) {
|
|
3249
|
+
rmSync(stageRoot, {
|
|
3250
|
+
recursive: true,
|
|
3251
|
+
force: true
|
|
3252
|
+
});
|
|
3253
|
+
consola.error("Archive does not contain meta.json — not a cctabs export.");
|
|
3254
|
+
process.exit(1);
|
|
3255
|
+
}
|
|
3256
|
+
let meta;
|
|
3257
|
+
try {
|
|
3258
|
+
meta = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
3259
|
+
} catch (err) {
|
|
3260
|
+
rmSync(stageRoot, {
|
|
3261
|
+
recursive: true,
|
|
3262
|
+
force: true
|
|
3263
|
+
});
|
|
3264
|
+
consola.error(`meta.json is malformed: ${err.message}`);
|
|
3265
|
+
process.exit(1);
|
|
3266
|
+
}
|
|
3267
|
+
if (meta.cctabsExportVersion !== 1) {
|
|
3268
|
+
rmSync(stageRoot, {
|
|
3269
|
+
recursive: true,
|
|
3270
|
+
force: true
|
|
3271
|
+
});
|
|
3272
|
+
consola.error(`Unsupported cctabsExportVersion: ${meta.cctabsExportVersion} (this build understands version 1).`);
|
|
3273
|
+
process.exit(1);
|
|
3274
|
+
}
|
|
3275
|
+
if (cwdOverride && meta.tabs.length > 1) consola.warn(`--cwd was provided but archive contains ${meta.tabs.length} tabs; --cwd is ignored for multi-tab imports.`);
|
|
3276
|
+
consola.info(`Importing ${meta.tabs.length} tab${meta.tabs.length === 1 ? "" : "s"} from ${archivePath}`);
|
|
3277
|
+
if (meta.sourceMachine) consola.log(` Source: ${meta.sourceMachine}${meta.cctabsVersion ? ` (cctabs ${meta.cctabsVersion})` : ""}`);
|
|
3278
|
+
if (meta.exportedAt) consola.log(` Exported: ${meta.exportedAt}`);
|
|
3279
|
+
consola.log("");
|
|
3280
|
+
const tabsDir = join(stageRoot, "tabs");
|
|
3281
|
+
const stagedDirs = existsSync(tabsDir) ? readdirSync(tabsDir).filter((n) => statSync(join(tabsDir, n)).isDirectory()) : [];
|
|
3282
|
+
const results = [];
|
|
3283
|
+
for (const entry of meta.tabs) {
|
|
3284
|
+
let stagedDir;
|
|
3285
|
+
for (const d of stagedDirs) {
|
|
3286
|
+
const m = join(tabsDir, d, "manifest.json");
|
|
3287
|
+
if (!existsSync(m)) continue;
|
|
3288
|
+
try {
|
|
3289
|
+
if (JSON.parse(readFileSync(m, "utf-8")).sessionId === entry.sessionId) {
|
|
3290
|
+
stagedDir = join(tabsDir, d);
|
|
3291
|
+
break;
|
|
3292
|
+
}
|
|
3293
|
+
} catch {}
|
|
3294
|
+
}
|
|
3295
|
+
if (!stagedDir) {
|
|
3296
|
+
results.push({
|
|
3297
|
+
name: entry.name,
|
|
3298
|
+
status: `staged tab dir not found for sessionId ${entry.sessionId.slice(0, 8)}…`
|
|
3299
|
+
});
|
|
3300
|
+
continue;
|
|
3301
|
+
}
|
|
3302
|
+
const targetCwd = resolve(expandHome(meta.tabs.length === 1 && cwdOverride ? cwdOverride : entry.cwd));
|
|
3303
|
+
if (!existsSync(targetCwd)) {
|
|
3304
|
+
results.push({
|
|
3305
|
+
name: entry.name,
|
|
3306
|
+
status: `cwd missing on this machine: ${targetCwd} (clone the repo, then re-run)`
|
|
3307
|
+
});
|
|
3308
|
+
continue;
|
|
3309
|
+
}
|
|
3310
|
+
const targetSlug = pathToProjectSlug(targetCwd);
|
|
3311
|
+
const targetProjectDir = join(homedir(), ".claude", "projects", targetSlug);
|
|
3312
|
+
const targetJsonl = join(targetProjectDir, `${entry.sessionId}.jsonl`);
|
|
3313
|
+
const srcJsonl = join(stagedDir, "session.jsonl");
|
|
3314
|
+
if (existsSync(targetJsonl) && !force) {
|
|
3315
|
+
results.push({
|
|
3316
|
+
name: entry.name,
|
|
3317
|
+
status: `already present at ${targetJsonl} (pass --force to overwrite)`
|
|
3318
|
+
});
|
|
3319
|
+
continue;
|
|
3320
|
+
}
|
|
3321
|
+
if (dryRun) {
|
|
3322
|
+
results.push({
|
|
3323
|
+
name: entry.name,
|
|
3324
|
+
status: `dry-run: would copy → ${targetJsonl} and open tab in ${targetCwd}`
|
|
3325
|
+
});
|
|
3326
|
+
continue;
|
|
3327
|
+
}
|
|
3328
|
+
mkdirSync(targetProjectDir, { recursive: true });
|
|
3329
|
+
copyFileSync(srcJsonl, targetJsonl);
|
|
3330
|
+
const config = loadConfig();
|
|
3331
|
+
const claudeCmd = `claude${config.claude.flags.length ? " " + config.claude.flags.join(" ") : ""} --resume ${entry.sessionId} --name ${JSON.stringify(entry.name)}`;
|
|
3332
|
+
try {
|
|
3333
|
+
await openSession({
|
|
3334
|
+
tabName: entry.name,
|
|
3335
|
+
dir: targetCwd,
|
|
3336
|
+
claudeCmd,
|
|
3337
|
+
workspaceQuery
|
|
3338
|
+
});
|
|
3339
|
+
results.push({
|
|
3340
|
+
name: entry.name,
|
|
3341
|
+
status: `imported → ${targetJsonl}, tab opened`
|
|
3342
|
+
});
|
|
3343
|
+
} catch (err) {
|
|
3344
|
+
results.push({
|
|
3345
|
+
name: entry.name,
|
|
3346
|
+
status: `jsonl copied but failed to open tab: ${err.message}`
|
|
3347
|
+
});
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
rmSync(stageRoot, {
|
|
3351
|
+
recursive: true,
|
|
3352
|
+
force: true
|
|
3353
|
+
});
|
|
3354
|
+
consola.log("");
|
|
3355
|
+
consola.log("Results:");
|
|
3356
|
+
for (const r of results) consola.log(` ${r.name.padEnd(24)} ${r.status}`);
|
|
3357
|
+
}
|
|
3358
|
+
});
|
|
3359
|
+
//#endregion
|
|
2882
3360
|
//#region src/commands/index.ts
|
|
2883
3361
|
const defaultCommand = define({
|
|
2884
3362
|
name: "cctabs",
|
|
@@ -2903,7 +3381,9 @@ const subCommands = new Map([
|
|
|
2903
3381
|
["restore", restoreCommand],
|
|
2904
3382
|
["backends", backendsCommand],
|
|
2905
3383
|
["doctor", doctorCommand],
|
|
2906
|
-
["install-tabby-plugin", installTabbyPluginCommand]
|
|
3384
|
+
["install-tabby-plugin", installTabbyPluginCommand],
|
|
3385
|
+
["export", exportCommand],
|
|
3386
|
+
["import", importCommand]
|
|
2907
3387
|
]);
|
|
2908
3388
|
async function run() {
|
|
2909
3389
|
await cli(process.argv.slice(2), defaultCommand, {
|
|
@@ -2916,7 +3396,12 @@ async function run() {
|
|
|
2916
3396
|
}
|
|
2917
3397
|
//#endregion
|
|
2918
3398
|
//#region src/index.ts
|
|
2919
|
-
updateNotifier({ pkg: package_default })
|
|
3399
|
+
const notifier = updateNotifier({ pkg: package_default });
|
|
3400
|
+
notifier.notify();
|
|
3401
|
+
if (notifier.update && notifier.update.latest !== notifier.update.current) {
|
|
3402
|
+
const { current, latest } = notifier.update;
|
|
3403
|
+
process.stdout.write(`[cctabs] OUTDATED ${current} < ${latest} — run: npm install -g ${name}@latest\n`);
|
|
3404
|
+
}
|
|
2920
3405
|
run().catch((err) => {
|
|
2921
3406
|
console.error(err instanceof Error ? err.message : String(err));
|
|
2922
3407
|
process.exit(1);
|
package/package.json
CHANGED
package/skills/cctabs/SKILL.md
CHANGED
|
@@ -44,6 +44,14 @@ npm install -g @generativereality/cctabs
|
|
|
44
44
|
|
|
45
45
|
Do not modify PATH or npm configuration beyond this.
|
|
46
46
|
|
|
47
|
+
### Check the installed version isn't stale
|
|
48
|
+
|
|
49
|
+
On your first cctabs invocation in a session, look at the version banner cctabs prints (`(@generativereality/cctabs vX.Y.Z)`) and at any `[cctabs] OUTDATED ...` warning line in the output. If you see the warning — or if the banner version is older than the version in this skill's `plugin.json` — tell the user:
|
|
50
|
+
|
|
51
|
+
> *"Your installed cctabs is `vX.Y.Z`; the current release is `vA.B.C`. Want me to upgrade with `npm install -g @generativereality/cctabs@latest` before continuing?"*
|
|
52
|
+
|
|
53
|
+
Don't silently work around an outdated CLI: detection heuristics, command flags, and bug fixes diverge between versions, so misbehavior on the user's machine is often "binary on PATH lags behind the plugin docs you're reading." The Claude Code marketplace plugin update path only refreshes this skill — the npm-installed CLI binary is a separate channel and must be upgraded explicitly.
|
|
54
|
+
|
|
47
55
|
### Tabby users: a one-time plugin install is needed
|
|
48
56
|
|
|
49
57
|
Wave Terminal works out of the box. **Tabby additionally needs a small companion plugin** that exposes a localhost HTTP API the cctabs CLI talks to.
|
|
@@ -66,7 +74,7 @@ On approval, run `cctabs install-tabby-plugin --yes`. Tabby quits ~2s after the
|
|
|
66
74
|
|
|
67
75
|
If the user wants to keep their other Tabby tabs intact, run `cctabs install-tabby-plugin --no-restart` instead and tell them to quit + reopen Tabby themselves.
|
|
68
76
|
|
|
69
|
-
`cctabs doctor` is also available for a deliberate environment check
|
|
77
|
+
`cctabs doctor` is also available for a deliberate environment check. It adapts to whichever terminal you're running in — terminal detection runs either way; on Wave it additionally inspects Accessibility permission and scans the Wave DB for orphan tabids; on Tabby it probes the cctabs plugin's localhost health endpoint. Useful if something feels off, but **not required as a preflight** since every command fails loudly on its own.
|
|
70
78
|
|
|
71
79
|
#### Auto-install + auto-restart (recommended)
|
|
72
80
|
|
|
@@ -143,6 +151,9 @@ cctabs close <name-or-id> # close a tab
|
|
|
143
151
|
cctabs rename <name-or-id> <new-name> # rename a tab
|
|
144
152
|
cctabs scrollback <tab-or-block> [n] # read terminal output (default: 50 lines)
|
|
145
153
|
cctabs send <tab-or-block> [text] # send input — arg, --file, or stdin pipe
|
|
154
|
+
cctabs export <name> [--out path] # bundle a tab + its claude session into a tarball
|
|
155
|
+
cctabs export --all [-w workspace] # bundle every tab in a workspace
|
|
156
|
+
cctabs import <tarball> [--dry-run] [-f] # restore tabs + sessions from a tarball
|
|
146
157
|
cctabs backends # list available backend presets
|
|
147
158
|
cctabs config # show config and path
|
|
148
159
|
```
|
|
@@ -260,7 +271,7 @@ cctabs resume api ~/Dev/myapp
|
|
|
260
271
|
|
|
261
272
|
## Workflow: Restoring tabs after a reboot
|
|
262
273
|
|
|
263
|
-
After a
|
|
274
|
+
After a terminal restart or computer reboot, every tab loses its Claude session and shows up with `terminal` or `unknown` status (true for both Wave and Tabby). `cctabs restore` walks every such tab, looks up its session by name across **all** Claude project directories, and re-attaches in place.
|
|
264
275
|
|
|
265
276
|
```bash
|
|
266
277
|
cctabs restore # search all projects (default)
|
|
@@ -270,6 +281,31 @@ cctabs restore ~/Dev/myapp # restrict the search to one project dir
|
|
|
270
281
|
|
|
271
282
|
If a session was started in a different `cwd` than the tab's current directory (common after `cd`-ing inside the tab), the global search still finds it via the recorded session metadata — no need to guess the right dir.
|
|
272
283
|
|
|
284
|
+
## Workflow: Moving sessions across machines
|
|
285
|
+
|
|
286
|
+
Use `export` + `import` to migrate a tab (or a whole workspace) — and its underlying Claude conversation — from one machine to another, e.g. when switching laptops or sharing a debug session with a teammate.
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
# On source machine
|
|
290
|
+
cctabs export auth # → ./cctabs-export-auth-<ts>.tar.gz
|
|
291
|
+
cctabs export auth --out ~/Downloads/auth.tar.gz
|
|
292
|
+
cctabs export --all # every tab in the current workspace
|
|
293
|
+
cctabs export --all --workspace tabby
|
|
294
|
+
|
|
295
|
+
# On destination machine
|
|
296
|
+
cctabs import ~/Downloads/auth.tar.gz --dry-run # preview without copying or opening tabs
|
|
297
|
+
cctabs import ~/Downloads/auth.tar.gz # copy session jsonl(s) + open tab(s)
|
|
298
|
+
cctabs import ~/Downloads/auth.tar.gz --cwd ~/Dev/myapp # single-tab archives only — remap the cwd
|
|
299
|
+
cctabs import ~/Downloads/auth.tar.gz --force # overwrite a session id that already exists locally
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Gotchas:
|
|
303
|
+
|
|
304
|
+
- **Target cwd must exist on the destination machine.** Each manifested tab carries the original `cwd` (e.g. `/Users/alice/Dev/myapp`). If that path doesn't exist locally, that entry is skipped with a "clone the repo, then re-run" hint. Either clone/recreate the directory first, or use `--cwd` to remap (single-tab archives only).
|
|
305
|
+
- **No multi-tab cwd remap.** If the source laptop had repos under a different layout (e.g. `~/Dev/Projects/foo` vs `~/Dev/foo`), `--cwd` is ignored. The workaround is to extract the tarball, edit `meta.json`, and re-tar — or split into per-tab archives and import each with `--cwd`.
|
|
306
|
+
- **Session IDs are preserved.** The exported session jsonl lands at `~/.claude/projects/<slug>/<sessionId>.jsonl` on the destination. Pass `--force` to overwrite a colliding session id (e.g. when re-importing an updated export).
|
|
307
|
+
- **Always preview multi-tab imports with `--dry-run` first.** It reports which entries would import, which would be skipped (missing cwd), and where each session jsonl would land — useful before spawning many tabs.
|
|
308
|
+
|
|
273
309
|
## Workflow: Forking a Session
|
|
274
310
|
|
|
275
311
|
Use `fork` when you want to explore an alternative approach without disrupting the original.
|