@generativereality/cctabs 0.3.2 → 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.2",
4
+ "version": "0.4.0",
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.3.2";
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,
@@ -2879,6 +2879,389 @@ echo "[$(date)] done"
2879
2879
  }
2880
2880
  });
2881
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
2882
3265
  //#region src/commands/index.ts
2883
3266
  const defaultCommand = define({
2884
3267
  name: "cctabs",
@@ -2903,7 +3286,9 @@ const subCommands = new Map([
2903
3286
  ["restore", restoreCommand],
2904
3287
  ["backends", backendsCommand],
2905
3288
  ["doctor", doctorCommand],
2906
- ["install-tabby-plugin", installTabbyPluginCommand]
3289
+ ["install-tabby-plugin", installTabbyPluginCommand],
3290
+ ["export", exportCommand],
3291
+ ["import", importCommand]
2907
3292
  ]);
2908
3293
  async function run() {
2909
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.2",
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.