@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -1
- package/dist/index.js +423 -5
- package/package.json +1 -1
- package/skills/cctabs/SKILL.md +28 -0
|
@@ -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.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.
|
|
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, {
|
|
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
package/skills/cctabs/SKILL.md
CHANGED
|
@@ -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.
|