@generativereality/cctabs 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.3.1",
4
+ "version": "0.4.0",
5
5
  "author": {
6
6
  "name": "generativereality",
7
7
  "url": "https://cctabs.com"
package/README.md CHANGED
@@ -206,11 +206,16 @@ flags = ["--allow-dangerously-skip-permissions"]
206
206
  | Terminal | Status |
207
207
  |----------|--------|
208
208
  | [Wave Terminal](https://waveterm.dev) | ✅ Full support |
209
+ | [Tabby](https://tabby.sh) | ✅ Full support (requires the [`tabby-cctabs`](./tabby-plugin/) companion plugin) |
209
210
  | iTerm2 | Planned |
210
211
  | Ghostty | Planned |
211
212
  | Warp | Planned |
212
213
 
213
- Wave is supported via its unix socket RPC. Other terminals will follow as adapters — PRs welcome.
214
+ Wave is supported via its unix socket RPC. Tabby is supported via a small companion plugin that exposes a localhost HTTP API the cctabs CLI talks to — install with `cctabs install-tabby-plugin`. Other terminals will follow as adapters — PRs welcome.
215
+
216
+ ### Login shells on macOS
217
+
218
+ cctabs-spawned tabs default to **login shells** (`zsh -l`, `bash -l`, etc.) so PATH is initialised the same way Tabby's UI-spawned tabs are. Without `-l`, macOS's `/etc/zprofile` doesn't run, `path_helper` doesn't populate `/usr/local/bin` and `/opt/homebrew/bin` from `/etc/paths`, and brand-new tabs are missing Node, Homebrew, and anything else that lives there. Symptoms: `env: node: No such file or directory` in Claude Code's Bash tool, plugin MCP servers failing to start with ENOENT when they shell out to `npx`, and cctabs CLI itself failing inside the tab it just spawned. See [Tabby issue #2](https://github.com/Eugeny/tabby/issues/2) for the historical context. Pass an explicit `args` array (including `[]`) when calling the `/tabs` endpoint if you need a non-login shell.
214
219
 
215
220
  ## License
216
221
 
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.3.1";
14
+ var version = "0.4.0";
15
15
  var description = "Claude Code tab manager. Terminal tabs as the UI, no tmux.";
16
16
  var package_default = {
17
17
  name,
@@ -2601,6 +2601,38 @@ function checkTabbyPlugin() {
2601
2601
  hint: "Run `cctabs install-tabby-plugin` from inside a Tabby tab — it npm-installs the plugin and reopens Tabby. Or do it by hand: `npm install --legacy-peer-deps --prefix \"$HOME/Library/Application Support/tabby/plugins\" tabby-cctabs`, then quit + reopen Tabby."
2602
2602
  };
2603
2603
  }
2604
+ /**
2605
+ * Probe whether `node` is findable in a freshly spawned shell — the canonical
2606
+ * symptom of the macOS non-login PATH bug. Spawning `zsh -l -c 'command -v
2607
+ * node'` simulates the same login-shell init (/etc/zprofile → path_helper)
2608
+ * that cctabs now uses when it opens new Tabby tabs. If this fails, brand-new
2609
+ * tabs will also fail to find Node, every plugin MCP that shells out to npx
2610
+ * will ENOENT, and the cctabs CLI itself becomes unusable from inside those
2611
+ * tabs (chicken-and-egg). The remediation hint covers both the upstream-fix
2612
+ * path (rely on a recent cctabs that defaults to `-l`) and the dotfile
2613
+ * workaround for users on older versions or non-Tabby terminals.
2614
+ */
2615
+ function checkSpawnedShellPath() {
2616
+ const r = spawnSync("zsh", [
2617
+ "-l",
2618
+ "-c",
2619
+ "command -v node"
2620
+ ], {
2621
+ encoding: "utf-8",
2622
+ timeout: 3e3
2623
+ });
2624
+ if (r.status === 0 && r.stdout?.trim()) return {
2625
+ name: "Spawned shell PATH (node findable)",
2626
+ status: "ok",
2627
+ detail: r.stdout.trim()
2628
+ };
2629
+ return {
2630
+ name: "Spawned shell PATH",
2631
+ status: "warn",
2632
+ detail: r.error?.message ?? r.stderr?.trim() ?? "node not found in a login zsh",
2633
+ hint: "A login zsh cannot find `node`. Either node is not installed, or PATH is broken. On macOS, `/usr/local/bin` is added by /etc/zprofile's path_helper — non-login shells skip it. Add `export PATH=\"/usr/local/bin:$PATH\"` to ~/.zshenv as a belt-and-braces fix."
2634
+ };
2635
+ }
2604
2636
  function checkWaveDb() {
2605
2637
  if (!existsSync(WAVE_DB_PATH)) return { result: {
2606
2638
  name: "Wave DB",
@@ -2705,6 +2737,7 @@ const doctorCommand = define({
2705
2737
  const results = [];
2706
2738
  let waveDb = null;
2707
2739
  results.push(checkTerminal(terminal));
2740
+ results.push(checkSpawnedShellPath());
2708
2741
  if (terminal === "tabby") results.push(checkTabbyPlugin());
2709
2742
  if (terminal === "wave") {
2710
2743
  results.push(checkWaveAccessibility());
@@ -2846,13 +2879,396 @@ echo "[$(date)] done"
2846
2879
  }
2847
2880
  });
2848
2881
  //#endregion
2882
+ //#region src/commands/export-cmd.ts
2883
+ /** Replace anything that isn't safe in a filesystem dir name. */
2884
+ function safeDirName(name) {
2885
+ return name.replace(/[^A-Za-z0-9._-]+/g, "_").slice(0, 80) || "tab";
2886
+ }
2887
+ function timestampSlug() {
2888
+ const d = /* @__PURE__ */ new Date();
2889
+ const pad = (n) => String(n).padStart(2, "0");
2890
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
2891
+ }
2892
+ const exportCommand = define({
2893
+ name: "export",
2894
+ description: "Export tabs + their Claude sessions to a tarball you can move to another machine (then `cctabs import`).",
2895
+ args: {
2896
+ all: {
2897
+ type: "boolean",
2898
+ short: "a",
2899
+ description: "Export every tab in the workspace (use --workspace to pick one; default is the current workspace)."
2900
+ },
2901
+ workspace: {
2902
+ type: "string",
2903
+ short: "w",
2904
+ description: "Workspace to export from when using --all (defaults to current)."
2905
+ },
2906
+ out: {
2907
+ type: "string",
2908
+ short: "o",
2909
+ description: "Output path for the tarball. Default: ./cctabs-export-<name>-<timestamp>.tar.gz"
2910
+ }
2911
+ },
2912
+ async run(ctx) {
2913
+ const tabQuery = ctx.positionals[1];
2914
+ const exportAll = ctx.values.all ?? false;
2915
+ const workspaceQuery = ctx.values.workspace;
2916
+ const outPath = ctx.values.out;
2917
+ if (!tabQuery && !exportAll) {
2918
+ consola.error("Provide a tab name, or pass --all to export every tab in the workspace.");
2919
+ process.exit(1);
2920
+ }
2921
+ if (tabQuery && exportAll) {
2922
+ consola.error("Pass either a tab name OR --all, not both.");
2923
+ process.exit(1);
2924
+ }
2925
+ const adapter = requireAdapter();
2926
+ const { tabsById, tabNames, workspaces } = await adapter.getAllData();
2927
+ const currentWs = adapter.currentWorkspaceId();
2928
+ let wsData;
2929
+ if (workspaceQuery) {
2930
+ const matches = adapter.resolveWorkspace(workspaces, workspaceQuery);
2931
+ if (matches.length === 0) {
2932
+ consola.error(`Workspace not found: ${workspaceQuery}`);
2933
+ process.exit(1);
2934
+ }
2935
+ if (matches.length > 1) {
2936
+ consola.error(`Workspace query is ambiguous: ${workspaceQuery}`);
2937
+ process.exit(1);
2938
+ }
2939
+ wsData = matches[0].data;
2940
+ } else wsData = workspaces.find((w) => w.workspacedata.oid === currentWs)?.workspacedata;
2941
+ if (!wsData) {
2942
+ consola.error("Could not determine workspace.");
2943
+ process.exit(1);
2944
+ }
2945
+ const wsName = wsData.name;
2946
+ const wsTabIds = wsData.tabids.filter((t) => tabsById.has(t));
2947
+ let targetTabIds;
2948
+ if (exportAll) targetTabIds = wsTabIds;
2949
+ else {
2950
+ const matched = adapter.resolveTab(tabQuery, tabsById, tabNames).filter((tid) => wsTabIds.includes(tid));
2951
+ if (matched.length === 0) {
2952
+ consola.error(`No tab in workspace "${wsName}" matches: ${tabQuery}`);
2953
+ process.exit(1);
2954
+ }
2955
+ if (matched.length > 1) {
2956
+ consola.error(`Tab query is ambiguous: ${tabQuery} (matches ${matched.length} tabs)`);
2957
+ process.exit(1);
2958
+ }
2959
+ targetTabIds = matched;
2960
+ }
2961
+ const stageRoot = mkdtempSync(join(tmpdir(), "cctabs-export-"));
2962
+ const tabsRoot = join(stageRoot, "tabs");
2963
+ mkdirSync(tabsRoot, { recursive: true });
2964
+ const exported = [];
2965
+ const skipped = [];
2966
+ for (const tabId of targetTabIds) {
2967
+ const tabName = tabNames.get(tabId) ?? tabId.slice(0, 8);
2968
+ const termBlock = (tabsById.get(tabId) ?? []).find((b) => b.view === "term");
2969
+ if (!termBlock) {
2970
+ skipped.push({
2971
+ name: tabName,
2972
+ reason: "no terminal block"
2973
+ });
2974
+ continue;
2975
+ }
2976
+ const cwd = termBlock.meta?.["cmd:cwd"];
2977
+ if (!cwd) {
2978
+ skipped.push({
2979
+ name: tabName,
2980
+ reason: "no cwd recorded"
2981
+ });
2982
+ continue;
2983
+ }
2984
+ let sessionId;
2985
+ let effectiveCwd = cwd;
2986
+ try {
2987
+ const matches = findSessionsByName(cwd, tabName);
2988
+ if (matches.length) sessionId = matches[0].id;
2989
+ } catch {}
2990
+ if (!sessionId) {
2991
+ const worktreesDir = join(cwd, ".claude", "worktrees");
2992
+ if (existsSync(worktreesDir)) try {
2993
+ const candidates = [];
2994
+ for (const entry of readdirSync(worktreesDir)) {
2995
+ const wtPath = join(worktreesDir, entry);
2996
+ if (!statSync(wtPath).isDirectory()) continue;
2997
+ try {
2998
+ const matches = findSessionsByName(wtPath, tabName);
2999
+ if (matches.length) candidates.push({
3000
+ id: matches[0].id,
3001
+ mtime: matches[0].mtime,
3002
+ path: wtPath
3003
+ });
3004
+ } catch {}
3005
+ }
3006
+ if (candidates.length) {
3007
+ candidates.sort((a, b) => b.mtime - a.mtime);
3008
+ sessionId = candidates[0].id;
3009
+ effectiveCwd = candidates[0].path;
3010
+ }
3011
+ } catch {}
3012
+ }
3013
+ if (!sessionId) {
3014
+ skipped.push({
3015
+ name: tabName,
3016
+ reason: "no Claude session found for this tab name + cwd"
3017
+ });
3018
+ continue;
3019
+ }
3020
+ const slug = pathToProjectSlug(effectiveCwd);
3021
+ const jsonlPath = join(homedir(), ".claude", "projects", slug, `${sessionId}.jsonl`);
3022
+ if (!existsSync(jsonlPath)) {
3023
+ skipped.push({
3024
+ name: tabName,
3025
+ reason: `session file missing: ${jsonlPath}`
3026
+ });
3027
+ continue;
3028
+ }
3029
+ const tabDir = join(tabsRoot, safeDirName(tabName));
3030
+ mkdirSync(tabDir, { recursive: true });
3031
+ copyFileSync(jsonlPath, join(tabDir, "session.jsonl"));
3032
+ const manifest = {
3033
+ name: tabName,
3034
+ cwd: effectiveCwd,
3035
+ sessionId,
3036
+ claudeProjectSlug: slug,
3037
+ workspace: wsName
3038
+ };
3039
+ writeFileSync(join(tabDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
3040
+ exported.push({ ...manifest });
3041
+ }
3042
+ if (exported.length === 0) {
3043
+ rmSync(stageRoot, {
3044
+ recursive: true,
3045
+ force: true
3046
+ });
3047
+ consola.error("Nothing to export. Skipped tabs:");
3048
+ for (const s of skipped) consola.log(` ${s.name}: ${s.reason}`);
3049
+ process.exit(1);
3050
+ }
3051
+ const meta = {
3052
+ cctabsExportVersion: 1,
3053
+ cctabsVersion: version,
3054
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
3055
+ sourceMachine: hostname(),
3056
+ tabs: exported
3057
+ };
3058
+ writeFileSync(join(stageRoot, "meta.json"), JSON.stringify(meta, null, 2) + "\n");
3059
+ const defaultName = exportAll ? `cctabs-export-${safeDirName(wsName)}-${timestampSlug()}.tar.gz` : `cctabs-export-${safeDirName(exported[0].name)}-${timestampSlug()}.tar.gz`;
3060
+ const resolvedOut = outPath ?? join(process.cwd(), defaultName);
3061
+ try {
3062
+ execFileSync("tar", [
3063
+ "-czf",
3064
+ resolvedOut,
3065
+ "-C",
3066
+ stageRoot,
3067
+ "."
3068
+ ], { stdio: "inherit" });
3069
+ } catch (err) {
3070
+ rmSync(stageRoot, {
3071
+ recursive: true,
3072
+ force: true
3073
+ });
3074
+ consola.error(`tar failed: ${err.message}`);
3075
+ process.exit(1);
3076
+ }
3077
+ rmSync(stageRoot, {
3078
+ recursive: true,
3079
+ force: true
3080
+ });
3081
+ consola.success(`Exported ${exported.length} tab${exported.length === 1 ? "" : "s"} → ${resolvedOut}`);
3082
+ for (const t of exported) consola.log(` ✓ ${t.name} (${t.sessionId.slice(0, 8)}…) ${t.cwd}`);
3083
+ if (skipped.length) {
3084
+ consola.warn(`Skipped ${skipped.length}:`);
3085
+ for (const s of skipped) consola.log(` - ${s.name}: ${s.reason}`);
3086
+ }
3087
+ consola.log("");
3088
+ consola.log(`Import on another machine with: cctabs import ${defaultName}`);
3089
+ }
3090
+ });
3091
+ //#endregion
3092
+ //#region src/commands/import-cmd.ts
3093
+ function expandHome(p) {
3094
+ return p.startsWith("~") ? join(homedir(), p.slice(1)) : p;
3095
+ }
3096
+ const importCommand = define({
3097
+ name: "import",
3098
+ description: "Import tabs + sessions from a cctabs-export tarball (produced by `cctabs export`) and open each one as a new tab.",
3099
+ args: {
3100
+ cwd: {
3101
+ type: "string",
3102
+ short: "C",
3103
+ description: "Target working directory. With a single-tab archive, replaces the original cwd. Ignored for multi-tab archives."
3104
+ },
3105
+ workspace: {
3106
+ type: "string",
3107
+ short: "w",
3108
+ description: "Workspace to open the new tab(s) in (defaults to current)."
3109
+ },
3110
+ force: {
3111
+ type: "boolean",
3112
+ short: "f",
3113
+ description: "Overwrite an existing session jsonl in ~/.claude/projects/ if the same session id already exists locally."
3114
+ },
3115
+ "dry-run": {
3116
+ type: "boolean",
3117
+ short: "n",
3118
+ description: "Report what would happen without copying files or spawning tabs."
3119
+ }
3120
+ },
3121
+ async run(ctx) {
3122
+ const archive = ctx.positionals[1];
3123
+ const cwdOverride = ctx.values.cwd;
3124
+ const workspaceQuery = ctx.values.workspace;
3125
+ const force = ctx.values.force ?? false;
3126
+ const dryRun = ctx.values["dry-run"] ?? false;
3127
+ if (!archive) {
3128
+ consola.error("Archive path is required.");
3129
+ process.exit(1);
3130
+ }
3131
+ const archivePath = resolve(expandHome(archive));
3132
+ if (!existsSync(archivePath)) {
3133
+ consola.error(`Archive not found: ${archivePath}`);
3134
+ process.exit(1);
3135
+ }
3136
+ const stageRoot = mkdtempSync(join(tmpdir(), "cctabs-import-"));
3137
+ try {
3138
+ execFileSync("tar", [
3139
+ "-xzf",
3140
+ archivePath,
3141
+ "-C",
3142
+ stageRoot
3143
+ ], { stdio: "inherit" });
3144
+ } catch (err) {
3145
+ rmSync(stageRoot, {
3146
+ recursive: true,
3147
+ force: true
3148
+ });
3149
+ consola.error(`tar failed to extract ${archivePath}: ${err.message}`);
3150
+ process.exit(1);
3151
+ }
3152
+ const metaPath = join(stageRoot, "meta.json");
3153
+ if (!existsSync(metaPath)) {
3154
+ rmSync(stageRoot, {
3155
+ recursive: true,
3156
+ force: true
3157
+ });
3158
+ consola.error("Archive does not contain meta.json — not a cctabs export.");
3159
+ process.exit(1);
3160
+ }
3161
+ let meta;
3162
+ try {
3163
+ meta = JSON.parse(readFileSync(metaPath, "utf-8"));
3164
+ } catch (err) {
3165
+ rmSync(stageRoot, {
3166
+ recursive: true,
3167
+ force: true
3168
+ });
3169
+ consola.error(`meta.json is malformed: ${err.message}`);
3170
+ process.exit(1);
3171
+ }
3172
+ if (meta.cctabsExportVersion !== 1) {
3173
+ rmSync(stageRoot, {
3174
+ recursive: true,
3175
+ force: true
3176
+ });
3177
+ consola.error(`Unsupported cctabsExportVersion: ${meta.cctabsExportVersion} (this build understands version 1).`);
3178
+ process.exit(1);
3179
+ }
3180
+ 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.`);
3181
+ consola.info(`Importing ${meta.tabs.length} tab${meta.tabs.length === 1 ? "" : "s"} from ${archivePath}`);
3182
+ if (meta.sourceMachine) consola.log(` Source: ${meta.sourceMachine}${meta.cctabsVersion ? ` (cctabs ${meta.cctabsVersion})` : ""}`);
3183
+ if (meta.exportedAt) consola.log(` Exported: ${meta.exportedAt}`);
3184
+ consola.log("");
3185
+ const tabsDir = join(stageRoot, "tabs");
3186
+ const stagedDirs = existsSync(tabsDir) ? readdirSync(tabsDir).filter((n) => statSync(join(tabsDir, n)).isDirectory()) : [];
3187
+ const results = [];
3188
+ for (const entry of meta.tabs) {
3189
+ let stagedDir;
3190
+ for (const d of stagedDirs) {
3191
+ const m = join(tabsDir, d, "manifest.json");
3192
+ if (!existsSync(m)) continue;
3193
+ try {
3194
+ if (JSON.parse(readFileSync(m, "utf-8")).sessionId === entry.sessionId) {
3195
+ stagedDir = join(tabsDir, d);
3196
+ break;
3197
+ }
3198
+ } catch {}
3199
+ }
3200
+ if (!stagedDir) {
3201
+ results.push({
3202
+ name: entry.name,
3203
+ status: `staged tab dir not found for sessionId ${entry.sessionId.slice(0, 8)}…`
3204
+ });
3205
+ continue;
3206
+ }
3207
+ const targetCwd = resolve(expandHome(meta.tabs.length === 1 && cwdOverride ? cwdOverride : entry.cwd));
3208
+ if (!existsSync(targetCwd)) {
3209
+ results.push({
3210
+ name: entry.name,
3211
+ status: `cwd missing on this machine: ${targetCwd} (clone the repo, then re-run)`
3212
+ });
3213
+ continue;
3214
+ }
3215
+ const targetSlug = pathToProjectSlug(targetCwd);
3216
+ const targetProjectDir = join(homedir(), ".claude", "projects", targetSlug);
3217
+ const targetJsonl = join(targetProjectDir, `${entry.sessionId}.jsonl`);
3218
+ const srcJsonl = join(stagedDir, "session.jsonl");
3219
+ if (existsSync(targetJsonl) && !force) {
3220
+ results.push({
3221
+ name: entry.name,
3222
+ status: `already present at ${targetJsonl} (pass --force to overwrite)`
3223
+ });
3224
+ continue;
3225
+ }
3226
+ if (dryRun) {
3227
+ results.push({
3228
+ name: entry.name,
3229
+ status: `dry-run: would copy → ${targetJsonl} and open tab in ${targetCwd}`
3230
+ });
3231
+ continue;
3232
+ }
3233
+ mkdirSync(targetProjectDir, { recursive: true });
3234
+ copyFileSync(srcJsonl, targetJsonl);
3235
+ const config = loadConfig();
3236
+ const claudeCmd = `claude${config.claude.flags.length ? " " + config.claude.flags.join(" ") : ""} --resume ${entry.sessionId} --name ${JSON.stringify(entry.name)}`;
3237
+ try {
3238
+ await openSession({
3239
+ tabName: entry.name,
3240
+ dir: targetCwd,
3241
+ claudeCmd,
3242
+ workspaceQuery
3243
+ });
3244
+ results.push({
3245
+ name: entry.name,
3246
+ status: `imported → ${targetJsonl}, tab opened`
3247
+ });
3248
+ } catch (err) {
3249
+ results.push({
3250
+ name: entry.name,
3251
+ status: `jsonl copied but failed to open tab: ${err.message}`
3252
+ });
3253
+ }
3254
+ }
3255
+ rmSync(stageRoot, {
3256
+ recursive: true,
3257
+ force: true
3258
+ });
3259
+ consola.log("");
3260
+ consola.log("Results:");
3261
+ for (const r of results) consola.log(` ${r.name.padEnd(24)} ${r.status}`);
3262
+ }
3263
+ });
3264
+ //#endregion
2849
3265
  //#region src/commands/index.ts
2850
3266
  const defaultCommand = define({
2851
3267
  name: "cctabs",
2852
3268
  description,
2853
3269
  args: {},
2854
3270
  async run() {
2855
- await sessionsCommand.run?.call(this, { args: {} });
3271
+ await sessionsCommand.run?.call(this, { values: {} });
2856
3272
  }
2857
3273
  });
2858
3274
  const subCommands = new Map([
@@ -2870,7 +3286,9 @@ const subCommands = new Map([
2870
3286
  ["restore", restoreCommand],
2871
3287
  ["backends", backendsCommand],
2872
3288
  ["doctor", doctorCommand],
2873
- ["install-tabby-plugin", installTabbyPluginCommand]
3289
+ ["install-tabby-plugin", installTabbyPluginCommand],
3290
+ ["export", exportCommand],
3291
+ ["import", importCommand]
2874
3292
  ]);
2875
3293
  async function run() {
2876
3294
  await cli(process.argv.slice(2), defaultCommand, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@generativereality/cctabs",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Claude Code tab manager. Terminal tabs as the UI, no tmux.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -143,6 +143,9 @@ cctabs close <name-or-id> # close a tab
143
143
  cctabs rename <name-or-id> <new-name> # rename a tab
144
144
  cctabs scrollback <tab-or-block> [n] # read terminal output (default: 50 lines)
145
145
  cctabs send <tab-or-block> [text] # send input — arg, --file, or stdin pipe
146
+ cctabs export <name> [--out path] # bundle a tab + its claude session into a tarball
147
+ cctabs export --all [-w workspace] # bundle every tab in a workspace
148
+ cctabs import <tarball> [--dry-run] [-f] # restore tabs + sessions from a tarball
146
149
  cctabs backends # list available backend presets
147
150
  cctabs config # show config and path
148
151
  ```
@@ -270,6 +273,31 @@ cctabs restore ~/Dev/myapp # restrict the search to one project dir
270
273
 
271
274
  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
275
 
276
+ ## Workflow: Moving sessions across machines
277
+
278
+ 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.
279
+
280
+ ```bash
281
+ # On source machine
282
+ cctabs export auth # → ./cctabs-export-auth-<ts>.tar.gz
283
+ cctabs export auth --out ~/Downloads/auth.tar.gz
284
+ cctabs export --all # every tab in the current workspace
285
+ cctabs export --all --workspace tabby
286
+
287
+ # On destination machine
288
+ cctabs import ~/Downloads/auth.tar.gz --dry-run # preview without copying or opening tabs
289
+ cctabs import ~/Downloads/auth.tar.gz # copy session jsonl(s) + open tab(s)
290
+ cctabs import ~/Downloads/auth.tar.gz --cwd ~/Dev/myapp # single-tab archives only — remap the cwd
291
+ cctabs import ~/Downloads/auth.tar.gz --force # overwrite a session id that already exists locally
292
+ ```
293
+
294
+ Gotchas:
295
+
296
+ - **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).
297
+ - **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`.
298
+ - **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).
299
+ - **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.
300
+
273
301
  ## Workflow: Forking a Session
274
302
 
275
303
  Use `fork` when you want to explore an alternative approach without disrupting the original.