From 407a36b9335acf379197d2db2a57997fb2365efb Mon Sep 17 00:00:00 2001 From: zcutech Date: Wed, 15 Apr 2026 17:29:14 +0800 Subject: [PATCH] feat: add session transfer commands --- README.md | 12 ++ cac | 321 +++++++++++++++++++++++++++++- docs/commands/env.mdx | 41 ++++ docs/reference/file-layout.mdx | 2 +- docs/zh/commands/env.mdx | 41 ++++ docs/zh/reference/file-layout.mdx | 2 +- src/cmd_env.sh | 269 +++++++++++++++++++++++++ src/cmd_help.sh | 1 + src/templates.sh | 43 ++++ src/utils.sh | 8 +- 10 files changed, 734 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b7f3751..6cd6421 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,9 @@ cac env rm # 删除环境 cac env set [name] proxy # 设置 / 修改代理 cac env set [name] proxy --remove # 移除代理 cac env set [name] version # 切换版本 +cac env sessions ls [env] # 查看某环境里的会话项目 +cac env sessions copy work personal # 拷贝 work 的会话到 personal +cac env sessions move work personal # 移动会话(源环境会被清空) cac # 激活环境(快捷方式) cac ls # = cac env ls ``` @@ -126,6 +129,9 @@ cac ls # = cac env ls | `cac env ls` | 列出环境 | | `cac env rm ` | 删除环境 | | `cac env set [name] ` | 修改环境(proxy / version / telemetry / persona) | +| `cac env sessions ls [env]` | 列出环境中的 Claude Code 会话项目 | +| `cac env sessions copy [--project ] [--session ] [--overwrite]` | 在环境间拷贝会话上下文 | +| `cac env sessions move [--project ] [--session ] [--overwrite]` | 在环境间移动会话上下文 | | `cac env check [-d]` | 验证当前环境(`-d` 显示详情) | | `cac ` | 激活环境 | | **自管理** | | @@ -287,6 +293,9 @@ cac env rm # remove environment cac env set [name] proxy # set / change proxy cac env set [name] proxy --remove # remove proxy cac env set [name] version # change version +cac env sessions ls [env] # list session projects in an env +cac env sessions copy work personal # copy sessions from work to personal +cac env sessions move work personal # move sessions and clear the source cac # activate (shortcut) cac ls # = cac env ls ``` @@ -311,6 +320,9 @@ Each environment is fully isolated: | `cac env ls` | List environments | | `cac env rm ` | Remove environment | | `cac env set [name] ` | Modify environment (proxy / version / telemetry / persona) | +| `cac env sessions ls [env]` | List Claude Code session projects in an environment | +| `cac env sessions copy [--project ] [--session ] [--overwrite]` | Copy session context between environments | +| `cac env sessions move [--project ] [--session ] [--overwrite]` | Move session context between environments | | `cac env check [-d]` | Verify current environment (`-d` for details) | | `cac ` | Activate environment | | **Self-management** | | diff --git a/cac b/cac index 2d312f0..fe9709b 100755 --- a/cac +++ b/cac @@ -263,10 +263,14 @@ _envs_using_version() { } # Elapsed time helper: call _timer_start, then _timer_elapsed -_timer_start() { _TIMER_START=$(date +%s%N 2>/dev/null || date +%s); } +_timer_start() { + _TIMER_START=$(date +%s%N 2>/dev/null || date +%s) + [[ "$_TIMER_START" =~ ^[0-9]+$ ]] || _TIMER_START=$(date +%s) +} _timer_elapsed() { local now; now=$(date +%s%N 2>/dev/null || date +%s) - if [[ ${#now} -gt 10 ]]; then + [[ "$now" =~ ^[0-9]+$ ]] || now=$(date +%s) + if [[ ${#now} -gt 10 && ${#_TIMER_START} -gt 10 ]]; then # nanoseconds available local ms=$(( (now - _TIMER_START) / 1000000 )) if [[ $ms -ge 1000 ]]; then @@ -1081,6 +1085,49 @@ CLAUDEMD_EOF fi } +_write_session_transfer_skill() { + local config_dir="$1" + # Do not mutate a shared host skills directory when --clone symlinked it. + [[ -L "$config_dir/skills" ]] && return 0 + + local skill_dir="$config_dir/skills/cac-session-transfer" + mkdir -p "$skill_dir" + cat > "$skill_dir/SKILL.md" << 'SESSION_SKILL_EOF' +--- +name: cac-session-transfer +description: Use when the user wants to copy, move, list, or migrate Claude Code conversation/session context between cac environments. +--- + +# cac Session Transfer + +Use cac's built-in session commands to migrate Claude Code context between isolated cac environments. + +Session data is stored at: + +```bash +~/.cac/envs//.claude/projects +``` + +Prefer these commands: + +```bash +cac env sessions ls [env] [--project ] +cac env sessions copy [--project ] [--session ] [--overwrite] +cac env sessions move [--project ] [--session ] [--overwrite] +``` + +Guidelines: + +- Run `cac env sessions ls ` first when the user did not specify a project. +- Run `cac env sessions ls --project ` to show session IDs, update times, message counts, and first user messages for one project. +- Use `--session ` together with `--project ` to copy or move exactly one session. +- Use `copy` when the user says copy, clone, sync, reuse, or bring context over. +- Use `move` only when the user explicitly says move, migrate, transfer, or remove from the source. +- Do not pass `--overwrite` unless the user explicitly asks to replace destination session data. +- Use `--project ` only with a project directory name from `cac env sessions ls `. +SESSION_SKILL_EOF +} + _write_wrapper() { mkdir -p "$CAC_DIR/bin" cat > "$CAC_DIR/bin/claude" << 'WRAPPER_EOF' @@ -1854,6 +1901,8 @@ MERGE_EOF fi fi + _write_session_transfer_skill "$env_dir/.claude" + _generate_client_cert "$name" >/dev/null 2>&1 || true # Auto-activate @@ -2084,6 +2133,271 @@ _env_cmd_stop() { echo " $(_dim "resume with:") $(_green "cac ")" } +_env_sessions_help() { + echo + echo " $(_bold "cac env sessions") — copy or move Claude Code session history" + echo + echo " $(_green "ls") [env] [--project ] List projects or sessions" + echo " $(_green "copy") [--project ] [--session ] [--overwrite]" + echo " Copy sessions between environments" + echo " $(_green "move") [--project ] [--session ] [--overwrite]" + echo " Move sessions between environments" + echo + echo " $(_dim "Session data lives in: ~/.cac/envs//.claude/projects")" + echo " $(_dim "If --project is omitted, all projects are copied or moved.")" + echo " $(_dim "Use --session with --project to copy or move one session ID.")" + echo +} + +_env_sessions_project_count() { + local projects_dir="$1" + [[ -d "$projects_dir" ]] || { echo 0; return; } + find "$projects_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d '[:space:]' +} + +_env_sessions_ls() { + _require_setup + local name="" project="" + while [[ $# -gt 0 ]]; do + case "$1" in + --project|-p) [[ $# -ge 2 ]] || _die "$1 requires a value"; project="$2"; shift 2 ;; + -*) _die "unknown option: $1" ;; + *) + [[ -z "$name" ]] || _die "extra argument: $1" + name="$1" + shift + ;; + esac + done + if [[ -z "$name" ]]; then + name=$(_current_env) + [[ -n "$name" ]] || _die "no active environment — specify env name" + fi + _require_env "$name" + + if [[ -n "$project" ]]; then + [[ "$project" != /* && "$project" != *".."* && "$project" != *"/"* ]] || \ + _die "invalid project '$project' (use a single project directory name)" + fi + + local projects_dir="$ENVS_DIR/$name/.claude/projects" + if [[ ! -d "$projects_dir" ]] || [[ "$(_env_sessions_project_count "$projects_dir")" -eq 0 ]]; then + echo "$(_dim " No session projects in '$name'.")" + return + fi + + if [[ -n "$project" ]]; then + local project_dir="$projects_dir/$project" + [[ -d "$project_dir" ]] || _die "project '$project' not found in '$name'" + python3 - "$project_dir" << 'PY_EOF' +import glob +import json +import os +import sys +from datetime import datetime, timezone + +project_dir = sys.argv[1] +files = sorted(glob.glob(os.path.join(project_dir, "*.jsonl")), key=os.path.getmtime, reverse=True) +if not files: + print(" No sessions in this project.") + raise SystemExit(0) + +def text_from_content(content): + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict): + if item.get("type") == "text" and isinstance(item.get("text"), str): + parts.append(item["text"]) + elif isinstance(item.get("content"), str): + parts.append(item["content"]) + elif isinstance(item, str): + parts.append(item) + return " ".join(parts) + return "" + +def clean(s, limit=72): + s = " ".join((s or "").split()) + return s if len(s) <= limit else s[: limit - 1] + "…" + +print(" %-36s %-19s %-8s %-28s %s" % ("SESSION", "UPDATED", "MESSAGES", "SESSION NAME", "FIRST USER MESSAGE")) +for path in files: + sid = os.path.splitext(os.path.basename(path))[0] + updated = datetime.fromtimestamp(os.path.getmtime(path), timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + messages = 0 + first_user = "" + custom_title = "" + ai_title = "" + agent_name = "" + with open(path, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except Exception: + continue + typ = obj.get("type") + if typ in ("user", "assistant"): + messages += 1 + elif typ == "custom-title" and obj.get("sessionId") == sid: + custom_title = obj.get("customTitle") or "" + elif typ == "ai-title" and obj.get("sessionId") == sid: + ai_title = obj.get("aiTitle") or "" + elif typ == "agent-name" and obj.get("sessionId") == sid: + agent_name = obj.get("agentName") or "" + if not first_user and typ == "user" and not obj.get("isMeta"): + msg = obj.get("message") or {} + first_user = text_from_content(msg.get("content")) + session_name = custom_title or ai_title or agent_name + print(" %-36s %-19s %-8s %-28s %s" % (sid, updated, messages, clean(session_name, 28), clean(first_user))) +PY_EOF + return 0 + fi + + printf " $(_dim "%-48s %s")\n" "PROJECT" "SESSIONS" + local project_dir project count + for project_dir in "$projects_dir"/*/; do + [[ -d "$project_dir" ]] || continue + project=$(basename "$project_dir") + count=$(find "$project_dir" -maxdepth 1 -type f -name '*.jsonl' 2>/dev/null | wc -l | tr -d '[:space:]') + printf " $(_cyan "%-48s") %s\n" "$project" "$count" + done + return 0 +} + +_env_sessions_transfer() { + _require_setup + local op="$1"; shift + local from="" to="" project="" session="" overwrite=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --project|-p) [[ $# -ge 2 ]] || _die "$1 requires a value"; project="$2"; shift 2 ;; + --session|-s) [[ $# -ge 2 ]] || _die "$1 requires a value"; session="$2"; shift 2 ;; + --overwrite) overwrite=true; shift ;; + -*) _die "unknown option: $1" ;; + *) + if [[ -z "$from" ]]; then + from="$1" + elif [[ -z "$to" ]]; then + to="$1" + else + _die "extra argument: $1" + fi + shift + ;; + esac + done + + [[ -n "$from" && -n "$to" ]] || _die "usage: cac env sessions $op [--project ] [--session ] [--overwrite]" + [[ "$from" != "$to" ]] || _die "source and destination must be different environments" + _require_env "$from" + _require_env "$to" + [[ -z "$session" || -n "$project" ]] || _die "--session requires --project" + + if [[ -n "$project" ]]; then + [[ "$project" != /* && "$project" != *".."* && "$project" != *"/"* ]] || \ + _die "invalid project '$project' (use a single project directory name from 'cac env sessions ls ')" + fi + if [[ -n "$session" ]]; then + session="${session%.jsonl}" + [[ "$session" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]] || \ + _die "invalid session '$session' (expected a UUID from 'cac env sessions ls --project ')" + fi + + local src_projects="$ENVS_DIR/$from/.claude/projects" + local dst_projects="$ENVS_DIR/$to/.claude/projects" + [[ -d "$src_projects" ]] || _die "environment '$from' has no session data" + + local src_path dst_path src_file dst_file src_sidecar dst_sidecar label + if [[ -n "$session" ]]; then + src_path="$src_projects/$project" + dst_path="$dst_projects/$project" + src_file="$src_path/$session.jsonl" + dst_file="$dst_path/$session.jsonl" + src_sidecar="$src_path/$session" + dst_sidecar="$dst_path/$session" + label="$project/$session" + [[ -d "$src_path" ]] || _die "project '$project' not found in '$from'" + [[ -f "$src_file" ]] || _die "session '$session' not found in '$from' project '$project'" + elif [[ -n "$project" ]]; then + src_path="$src_projects/$project" + dst_path="$dst_projects/$project" + label="$project" + [[ -d "$src_path" ]] || _die "project '$project' not found in '$from'" + else + src_path="$src_projects" + dst_path="$dst_projects" + label="all projects" + [[ "$(_env_sessions_project_count "$src_projects")" -gt 0 ]] || _die "environment '$from' has no session projects" + fi + + if [[ "$overwrite" != "true" ]]; then + if [[ -n "$session" ]] && { [[ -e "$dst_file" ]] || [[ -e "$dst_sidecar" ]]; }; then + _die "destination '$to' already has session '$session' in project '$project' — pass --overwrite to replace it" + fi + if [[ -z "$session" && -n "$project" && -e "$dst_path" ]]; then + _die "destination '$to' already has project '$project' — pass --overwrite to replace it" + fi + if [[ -z "$session" && -z "$project" && -d "$dst_path" && "$(_env_sessions_project_count "$dst_path")" -gt 0 ]]; then + _die "destination '$to' already has session data — pass --overwrite to replace it" + fi + fi + + mkdir -p "$dst_projects" + if [[ "$overwrite" == "true" ]]; then + if [[ -n "$session" ]]; then + rm -rf "$dst_file" "$dst_sidecar" + elif [[ -n "$project" ]]; then + rm -rf "$dst_path" + else + rm -rf "$dst_projects" + mkdir -p "$dst_projects" + fi + fi + + if [[ -n "$session" ]]; then + mkdir -p "$dst_path" + cp "$src_file" "$dst_file" + if [[ -d "$src_sidecar" ]]; then + cp -R "$src_sidecar" "$dst_sidecar" + fi + elif [[ -n "$project" ]]; then + cp -R "$src_path" "$dst_path" + else + cp -R "$src_path/." "$dst_path/" + fi + + if [[ "$op" == "move" ]]; then + if [[ -n "$session" ]]; then + rm -rf "$src_file" "$src_sidecar" + elif [[ -n "$project" ]]; then + rm -rf "$src_path" + else + rm -rf "$src_projects" + mkdir -p "$src_projects" + fi + fi + + local verb="Copied" + [[ "$op" == "move" ]] && verb="Moved" + echo "$(_green_bold "$verb") $(_cyan "$label") from $(_bold "$from") to $(_bold "$to")" +} + +_env_cmd_sessions() { + case "${1:-help}" in + ls|list) _env_sessions_ls "${@:2}" ;; + copy|cp) _env_sessions_transfer copy "${@:2}" ;; + move|mv) _env_sessions_transfer move "${@:2}" ;; + help|-h|--help) _env_sessions_help ;; + *) _die "unknown: cac env sessions $1" ;; + esac +} + cmd_env() { case "${1:-help}" in create) _env_cmd_create "${@:2}" ;; @@ -2091,6 +2405,7 @@ cmd_env() { ls|list) _env_cmd_ls ;; rm|remove) _env_cmd_rm "${@:2}" ;; activate) _env_cmd_activate "${@:2}" ;; + session|sessions) _env_cmd_sessions "${@:2}" ;; stop) _env_cmd_stop ;; check) cmd_check "${@:2}" ;; deactivate) echo "$(_yellow "warning:") deactivate has been removed — switch with 'cac ' or uninstall with 'cac self delete'" >&2 ;; @@ -2104,6 +2419,7 @@ cmd_env() { echo " proxy, version, telemetry, or persona" echo " $(_green "ls") List all environments" echo " $(_green "rm") Remove an environment" + echo " $(_green "sessions") Copy or move Claude Code session history" echo " $(_green "check") Verify current environment" echo " $(_green "stop") Pause cac (claude runs natively, no injection)" echo " $(_green "cac") Switch environment (also resumes if stopped)" @@ -3502,6 +3818,7 @@ cmd_help() { echo " $(_green "cac env set") [name] Modify environment" echo " $(_green "cac env ls") List all environments" echo " $(_green "cac env rm") Remove an environment" + echo " $(_green "cac env sessions") Copy or move session history" echo " $(_green "cac env check") Verify current environment" echo " $(_green "cac") Switch environment" echo diff --git a/docs/commands/env.mdx b/docs/commands/env.mdx index 54ac4b1..86c405e 100644 --- a/docs/commands/env.mdx +++ b/docs/commands/env.mdx @@ -181,6 +181,47 @@ Checks: - Security protections (DNS guard, telemetry env vars, mTLS) - Wrapper PATH detection +## sessions + +Copy or move Claude Code session history between environments. + +```bash +cac env sessions ls [env] [--project ] +cac env sessions copy [--project ] [--session ] [--overwrite] +cac env sessions move [--project ] [--session ] [--overwrite] +``` + +Session history lives under `~/.cac/envs//.claude/projects`. By default, `ls` shows projects and `copy`/`move` operate on all projects. Use `--project ` with `ls` to show the session IDs inside one project, with `copy`/`move` to transfer one project directory, or combine `--project ` and `--session ` to transfer exactly one session. + +The destination is not overwritten unless `--overwrite` is passed. + +New cac environments also include a Claude Code skill named `cac-session-transfer`, so Claude can discover and use these commands when you ask it to migrate context. + +**Examples:** + +```bash +# List projects with session files in the active environment +cac env sessions ls + +# List sessions inside one project +cac env sessions ls work --project -Users-me-project + +# Copy all sessions from work to personal +cac env sessions copy work personal + +# Copy one project only +cac env sessions copy work personal --project -Users-me-project + +# Move one session only +cac env sessions move work personal --project -Users-me-project --session 8154b64e-a6e4-482d-8229-4a33a2bad71c + +# Replace existing destination sessions +cac env sessions copy work personal --overwrite + +# Move sessions and remove them from the source environment +cac env sessions move work personal +``` + ## rm Remove an environment. Cannot remove the active environment. diff --git a/docs/reference/file-layout.mdx b/docs/reference/file-layout.mdx index 909ce1a..b0fd4be 100644 --- a/docs/reference/file-layout.mdx +++ b/docs/reference/file-layout.mdx @@ -20,7 +20,7 @@ icon: "folder-tree" │ │ ├── settings.json # Claude Code settings (statusline, permissions) │ │ ├── statusline-command.sh # status bar script │ │ ├── CLAUDE.md # cac orientation for Claude Code -│ │ ├── projects/ +│ │ ├── projects/ # Claude Code session history, migratable with `cac env sessions` │ │ └── ... │ ├── proxy # proxy URL (optional) │ ├── version # pinned Claude Code version diff --git a/docs/zh/commands/env.mdx b/docs/zh/commands/env.mdx index a51be95..47a3838 100644 --- a/docs/zh/commands/env.mdx +++ b/docs/zh/commands/env.mdx @@ -175,6 +175,47 @@ cac env check -d # 详细输出 - 安全防护(DNS 守卫、遥测环境变量、mTLS) - 包装器 PATH 检测 +## sessions + +在不同环境之间拷贝或移动 Claude Code 会话历史。 + +```bash +cac env sessions ls [env] [--project ] +cac env sessions copy [--project ] [--session ] [--overwrite] +cac env sessions move [--project ] [--session ] [--overwrite] +``` + +会话历史位于 `~/.cac/envs//.claude/projects`。默认情况下,`ls` 展示项目列表,`copy` 和 `move` 会处理所有项目。给 `ls` 传入 `--project ` 可以查看某个项目下的 session ID;给 `copy`/`move` 传入 `--project ` 可以只迁移这一个项目目录;同时传入 `--project ` 和 `--session ` 可以只迁移单个 session。 + +目标环境默认不会被覆盖;只有显式传入 `--overwrite` 才会替换已有会话数据。 + +新的 cac 环境会自动包含一个名为 `cac-session-transfer` 的 Claude Code Skill,因此你在 Claude 中提出迁移上下文需求时,它可以发现并使用这些命令。 + +**示例:** + +```bash +# 查看当前环境中的会话项目 +cac env sessions ls + +# 查看某个项目中的 session ID +cac env sessions ls work --project -Users-me-project + +# 将 work 的全部会话拷贝到 personal +cac env sessions copy work personal + +# 只拷贝一个项目 +cac env sessions copy work personal --project -Users-me-project + +# 只移动一个 session +cac env sessions move work personal --project -Users-me-project --session 8154b64e-a6e4-482d-8229-4a33a2bad71c + +# 覆盖目标环境中已有的会话 +cac env sessions copy work personal --overwrite + +# 移动会话,并从源环境删除 +cac env sessions move work personal +``` + ## rm 移除环境。不能移除当前活跃的环境。 diff --git a/docs/zh/reference/file-layout.mdx b/docs/zh/reference/file-layout.mdx index ea19643..881dcf0 100644 --- a/docs/zh/reference/file-layout.mdx +++ b/docs/zh/reference/file-layout.mdx @@ -20,7 +20,7 @@ icon: "folder-tree" │ │ ├── settings.json │ │ ├── statusline-command.sh │ │ ├── CLAUDE.md -│ │ ├── projects/ +│ │ ├── projects/ # Claude Code 会话历史,可用 `cac env sessions` 迁移 │ │ └── ... │ ├── proxy # 代理 URL(可选) │ ├── version # 锁定的 Claude Code 版本 diff --git a/src/cmd_env.sh b/src/cmd_env.sh index 36993c1..d4c8f59 100644 --- a/src/cmd_env.sh +++ b/src/cmd_env.sh @@ -180,6 +180,8 @@ MERGE_EOF fi fi + _write_session_transfer_skill "$env_dir/.claude" + _generate_client_cert "$name" >/dev/null 2>&1 || true # Auto-activate @@ -410,6 +412,271 @@ _env_cmd_stop() { echo " $(_dim "resume with:") $(_green "cac ")" } +_env_sessions_help() { + echo + echo " $(_bold "cac env sessions") — copy or move Claude Code session history" + echo + echo " $(_green "ls") [env] [--project ] List projects or sessions" + echo " $(_green "copy") [--project ] [--session ] [--overwrite]" + echo " Copy sessions between environments" + echo " $(_green "move") [--project ] [--session ] [--overwrite]" + echo " Move sessions between environments" + echo + echo " $(_dim "Session data lives in: ~/.cac/envs//.claude/projects")" + echo " $(_dim "If --project is omitted, all projects are copied or moved.")" + echo " $(_dim "Use --session with --project to copy or move one session ID.")" + echo +} + +_env_sessions_project_count() { + local projects_dir="$1" + [[ -d "$projects_dir" ]] || { echo 0; return; } + find "$projects_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d '[:space:]' +} + +_env_sessions_ls() { + _require_setup + local name="" project="" + while [[ $# -gt 0 ]]; do + case "$1" in + --project|-p) [[ $# -ge 2 ]] || _die "$1 requires a value"; project="$2"; shift 2 ;; + -*) _die "unknown option: $1" ;; + *) + [[ -z "$name" ]] || _die "extra argument: $1" + name="$1" + shift + ;; + esac + done + if [[ -z "$name" ]]; then + name=$(_current_env) + [[ -n "$name" ]] || _die "no active environment — specify env name" + fi + _require_env "$name" + + if [[ -n "$project" ]]; then + [[ "$project" != /* && "$project" != *".."* && "$project" != *"/"* ]] || \ + _die "invalid project '$project' (use a single project directory name)" + fi + + local projects_dir="$ENVS_DIR/$name/.claude/projects" + if [[ ! -d "$projects_dir" ]] || [[ "$(_env_sessions_project_count "$projects_dir")" -eq 0 ]]; then + echo "$(_dim " No session projects in '$name'.")" + return + fi + + if [[ -n "$project" ]]; then + local project_dir="$projects_dir/$project" + [[ -d "$project_dir" ]] || _die "project '$project' not found in '$name'" + python3 - "$project_dir" << 'PY_EOF' +import glob +import json +import os +import sys +from datetime import datetime, timezone + +project_dir = sys.argv[1] +files = sorted(glob.glob(os.path.join(project_dir, "*.jsonl")), key=os.path.getmtime, reverse=True) +if not files: + print(" No sessions in this project.") + raise SystemExit(0) + +def text_from_content(content): + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict): + if item.get("type") == "text" and isinstance(item.get("text"), str): + parts.append(item["text"]) + elif isinstance(item.get("content"), str): + parts.append(item["content"]) + elif isinstance(item, str): + parts.append(item) + return " ".join(parts) + return "" + +def clean(s, limit=72): + s = " ".join((s or "").split()) + return s if len(s) <= limit else s[: limit - 1] + "…" + +print(" %-36s %-19s %-8s %-28s %s" % ("SESSION", "UPDATED", "MESSAGES", "SESSION NAME", "FIRST USER MESSAGE")) +for path in files: + sid = os.path.splitext(os.path.basename(path))[0] + updated = datetime.fromtimestamp(os.path.getmtime(path), timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + messages = 0 + first_user = "" + custom_title = "" + ai_title = "" + agent_name = "" + with open(path, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except Exception: + continue + typ = obj.get("type") + if typ in ("user", "assistant"): + messages += 1 + elif typ == "custom-title" and obj.get("sessionId") == sid: + custom_title = obj.get("customTitle") or "" + elif typ == "ai-title" and obj.get("sessionId") == sid: + ai_title = obj.get("aiTitle") or "" + elif typ == "agent-name" and obj.get("sessionId") == sid: + agent_name = obj.get("agentName") or "" + if not first_user and typ == "user" and not obj.get("isMeta"): + msg = obj.get("message") or {} + first_user = text_from_content(msg.get("content")) + session_name = custom_title or ai_title or agent_name + print(" %-36s %-19s %-8s %-28s %s" % (sid, updated, messages, clean(session_name, 28), clean(first_user))) +PY_EOF + return 0 + fi + + printf " $(_dim "%-48s %s")\n" "PROJECT" "SESSIONS" + local project_dir project count + for project_dir in "$projects_dir"/*/; do + [[ -d "$project_dir" ]] || continue + project=$(basename "$project_dir") + count=$(find "$project_dir" -maxdepth 1 -type f -name '*.jsonl' 2>/dev/null | wc -l | tr -d '[:space:]') + printf " $(_cyan "%-48s") %s\n" "$project" "$count" + done + return 0 +} + +_env_sessions_transfer() { + _require_setup + local op="$1"; shift + local from="" to="" project="" session="" overwrite=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --project|-p) [[ $# -ge 2 ]] || _die "$1 requires a value"; project="$2"; shift 2 ;; + --session|-s) [[ $# -ge 2 ]] || _die "$1 requires a value"; session="$2"; shift 2 ;; + --overwrite) overwrite=true; shift ;; + -*) _die "unknown option: $1" ;; + *) + if [[ -z "$from" ]]; then + from="$1" + elif [[ -z "$to" ]]; then + to="$1" + else + _die "extra argument: $1" + fi + shift + ;; + esac + done + + [[ -n "$from" && -n "$to" ]] || _die "usage: cac env sessions $op [--project ] [--session ] [--overwrite]" + [[ "$from" != "$to" ]] || _die "source and destination must be different environments" + _require_env "$from" + _require_env "$to" + [[ -z "$session" || -n "$project" ]] || _die "--session requires --project" + + if [[ -n "$project" ]]; then + [[ "$project" != /* && "$project" != *".."* && "$project" != *"/"* ]] || \ + _die "invalid project '$project' (use a single project directory name from 'cac env sessions ls ')" + fi + if [[ -n "$session" ]]; then + session="${session%.jsonl}" + [[ "$session" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]] || \ + _die "invalid session '$session' (expected a UUID from 'cac env sessions ls --project ')" + fi + + local src_projects="$ENVS_DIR/$from/.claude/projects" + local dst_projects="$ENVS_DIR/$to/.claude/projects" + [[ -d "$src_projects" ]] || _die "environment '$from' has no session data" + + local src_path dst_path src_file dst_file src_sidecar dst_sidecar label + if [[ -n "$session" ]]; then + src_path="$src_projects/$project" + dst_path="$dst_projects/$project" + src_file="$src_path/$session.jsonl" + dst_file="$dst_path/$session.jsonl" + src_sidecar="$src_path/$session" + dst_sidecar="$dst_path/$session" + label="$project/$session" + [[ -d "$src_path" ]] || _die "project '$project' not found in '$from'" + [[ -f "$src_file" ]] || _die "session '$session' not found in '$from' project '$project'" + elif [[ -n "$project" ]]; then + src_path="$src_projects/$project" + dst_path="$dst_projects/$project" + label="$project" + [[ -d "$src_path" ]] || _die "project '$project' not found in '$from'" + else + src_path="$src_projects" + dst_path="$dst_projects" + label="all projects" + [[ "$(_env_sessions_project_count "$src_projects")" -gt 0 ]] || _die "environment '$from' has no session projects" + fi + + if [[ "$overwrite" != "true" ]]; then + if [[ -n "$session" ]] && { [[ -e "$dst_file" ]] || [[ -e "$dst_sidecar" ]]; }; then + _die "destination '$to' already has session '$session' in project '$project' — pass --overwrite to replace it" + fi + if [[ -z "$session" && -n "$project" && -e "$dst_path" ]]; then + _die "destination '$to' already has project '$project' — pass --overwrite to replace it" + fi + if [[ -z "$session" && -z "$project" && -d "$dst_path" && "$(_env_sessions_project_count "$dst_path")" -gt 0 ]]; then + _die "destination '$to' already has session data — pass --overwrite to replace it" + fi + fi + + mkdir -p "$dst_projects" + if [[ "$overwrite" == "true" ]]; then + if [[ -n "$session" ]]; then + rm -rf "$dst_file" "$dst_sidecar" + elif [[ -n "$project" ]]; then + rm -rf "$dst_path" + else + rm -rf "$dst_projects" + mkdir -p "$dst_projects" + fi + fi + + if [[ -n "$session" ]]; then + mkdir -p "$dst_path" + cp "$src_file" "$dst_file" + if [[ -d "$src_sidecar" ]]; then + cp -R "$src_sidecar" "$dst_sidecar" + fi + elif [[ -n "$project" ]]; then + cp -R "$src_path" "$dst_path" + else + cp -R "$src_path/." "$dst_path/" + fi + + if [[ "$op" == "move" ]]; then + if [[ -n "$session" ]]; then + rm -rf "$src_file" "$src_sidecar" + elif [[ -n "$project" ]]; then + rm -rf "$src_path" + else + rm -rf "$src_projects" + mkdir -p "$src_projects" + fi + fi + + local verb="Copied" + [[ "$op" == "move" ]] && verb="Moved" + echo "$(_green_bold "$verb") $(_cyan "$label") from $(_bold "$from") to $(_bold "$to")" +} + +_env_cmd_sessions() { + case "${1:-help}" in + ls|list) _env_sessions_ls "${@:2}" ;; + copy|cp) _env_sessions_transfer copy "${@:2}" ;; + move|mv) _env_sessions_transfer move "${@:2}" ;; + help|-h|--help) _env_sessions_help ;; + *) _die "unknown: cac env sessions $1" ;; + esac +} + cmd_env() { case "${1:-help}" in create) _env_cmd_create "${@:2}" ;; @@ -417,6 +684,7 @@ cmd_env() { ls|list) _env_cmd_ls ;; rm|remove) _env_cmd_rm "${@:2}" ;; activate) _env_cmd_activate "${@:2}" ;; + session|sessions) _env_cmd_sessions "${@:2}" ;; stop) _env_cmd_stop ;; check) cmd_check "${@:2}" ;; deactivate) echo "$(_yellow "warning:") deactivate has been removed — switch with 'cac ' or uninstall with 'cac self delete'" >&2 ;; @@ -430,6 +698,7 @@ cmd_env() { echo " proxy, version, telemetry, or persona" echo " $(_green "ls") List all environments" echo " $(_green "rm") Remove an environment" + echo " $(_green "sessions") Copy or move Claude Code session history" echo " $(_green "check") Verify current environment" echo " $(_green "stop") Pause cac (claude runs natively, no injection)" echo " $(_green "cac") Switch environment (also resumes if stopped)" diff --git a/src/cmd_help.sh b/src/cmd_help.sh index 106b54c..6dcc4a4 100644 --- a/src/cmd_help.sh +++ b/src/cmd_help.sh @@ -10,6 +10,7 @@ cmd_help() { echo " $(_green "cac env set") [name] Modify environment" echo " $(_green "cac env ls") List all environments" echo " $(_green "cac env rm") Remove an environment" + echo " $(_green "cac env sessions") Copy or move session history" echo " $(_green "cac env check") Verify current environment" echo " $(_green "cac") Switch environment" echo diff --git a/src/templates.sh b/src/templates.sh index 4b6d0d8..e137953 100644 --- a/src/templates.sh +++ b/src/templates.sh @@ -142,6 +142,49 @@ CLAUDEMD_EOF fi } +_write_session_transfer_skill() { + local config_dir="$1" + # Do not mutate a shared host skills directory when --clone symlinked it. + [[ -L "$config_dir/skills" ]] && return 0 + + local skill_dir="$config_dir/skills/cac-session-transfer" + mkdir -p "$skill_dir" + cat > "$skill_dir/SKILL.md" << 'SESSION_SKILL_EOF' +--- +name: cac-session-transfer +description: Use when the user wants to copy, move, list, or migrate Claude Code conversation/session context between cac environments. +--- + +# cac Session Transfer + +Use cac's built-in session commands to migrate Claude Code context between isolated cac environments. + +Session data is stored at: + +```bash +~/.cac/envs//.claude/projects +``` + +Prefer these commands: + +```bash +cac env sessions ls [env] [--project ] +cac env sessions copy [--project ] [--session ] [--overwrite] +cac env sessions move [--project ] [--session ] [--overwrite] +``` + +Guidelines: + +- Run `cac env sessions ls ` first when the user did not specify a project. +- Run `cac env sessions ls --project ` to show session IDs, update times, message counts, and first user messages for one project. +- Use `--session ` together with `--project ` to copy or move exactly one session. +- Use `copy` when the user says copy, clone, sync, reuse, or bring context over. +- Use `move` only when the user explicitly says move, migrate, transfer, or remove from the source. +- Do not pass `--overwrite` unless the user explicitly asks to replace destination session data. +- Use `--project ` only with a project directory name from `cac env sessions ls `. +SESSION_SKILL_EOF +} + _write_wrapper() { mkdir -p "$CAC_DIR/bin" cat > "$CAC_DIR/bin/claude" << 'WRAPPER_EOF' diff --git a/src/utils.sh b/src/utils.sh index d89141a..606ed5e 100644 --- a/src/utils.sh +++ b/src/utils.sh @@ -253,10 +253,14 @@ _envs_using_version() { } # Elapsed time helper: call _timer_start, then _timer_elapsed -_timer_start() { _TIMER_START=$(date +%s%N 2>/dev/null || date +%s); } +_timer_start() { + _TIMER_START=$(date +%s%N 2>/dev/null || date +%s) + [[ "$_TIMER_START" =~ ^[0-9]+$ ]] || _TIMER_START=$(date +%s) +} _timer_elapsed() { local now; now=$(date +%s%N 2>/dev/null || date +%s) - if [[ ${#now} -gt 10 ]]; then + [[ "$now" =~ ^[0-9]+$ ]] || now=$(date +%s) + if [[ ${#now} -gt 10 && ${#_TIMER_START} -gt 10 ]]; then # nanoseconds available local ms=$(( (now - _TIMER_START) / 1000000 )) if [[ $ms -ge 1000 ]]; then