From 8256ebba7667294bf6dc96a5ffed8d6a9ff9e9e1 Mon Sep 17 00:00:00 2001 From: dm Date: Mon, 9 Mar 2026 09:31:45 +0100 Subject: [PATCH] Add `devc sync` to copy devcontainer sessions to host (#27) * Add `devc sync` to copy devcontainer sessions to host for /insights Claude Code's /insights reads sessions from ~/.claude/projects/ on the host, but devcontainer sessions live inside container volumes. This adds a `devc sync [project]` command that copies them over so /insights can analyze devcontainer work alongside local sessions. - Auto-discovers devcontainers via devcontainer.local_folder label - Works on both running and stopped containers (docker cp only) - Reads CLAUDE_CONFIG_DIR from container env for non-standard paths - Incremental: only copies new/updated files - Optional project name filter (case-insensitive substring) Co-Authored-By: Claude Opus 4.6 * Add devc sync to README Co-Authored-By: Claude Opus 4.6 * Add safety prompt to `devc sync` before copying from containers Warns users that sync copies files from devcontainers to the host filesystem. Adds --trusted flag to skip the prompt for automation. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- README.md | 14 ++++ install.sh | 234 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) diff --git a/README.md b/README.md index 938b8f5..3366c44 100644 --- a/README.md +++ b/README.md @@ -125,10 +125,24 @@ devc shell Open zsh shell in container devc exec CMD Execute command inside the container devc upgrade Upgrade Claude Code in the container devc mount SRC DST Add a bind mount (host → container) +devc sync [NAME] Sync Claude Code sessions from devcontainers to host devc template DIR Copy devcontainer files to directory devc self-install Install devc to ~/.local/bin ``` +## Session Sync for `/insights` + +Claude Code's `/insights` command analyzes your session history, but it only reads from `~/.claude/projects/` on the host. Sessions inside devcontainer volumes are invisible to it. + +`devc sync` copies session logs from all devcontainers (running and stopped) to the host so `/insights` can include them: + +```bash +devc sync # Sync all devcontainers +devc sync crypto # Filter by project name (substring match) +``` + +Devcontainers are auto-discovered via Docker labels — no need to know container names or IDs. The sync is incremental, so it's safe to run repeatedly. + ## File Sharing ### VS Code / Cursor diff --git a/install.sh b/install.sh index 5f8c7f4..ce9f050 100755 --- a/install.sh +++ b/install.sh @@ -37,6 +37,7 @@ Commands: exec Execute a command in the running container upgrade Upgrade Claude Code to latest version mount Add a mount to the devcontainer (recreates container) + sync [project] [--trusted] Sync sessions from devcontainers to host cp Copy files/directories from container to host help Show this help message @@ -50,6 +51,8 @@ Examples: devc exec ls -la # Run command in container devc upgrade # Upgrade Claude Code to latest devc mount ~/data /data # Add mount to container + devc sync # Sync sessions from all devcontainers + devc sync crypto # Sync only matching devcontainer devc cp /some/file ./out # Copy a path from container to host EOF } @@ -330,6 +333,234 @@ cmd_mount() { log_success "Mount added: $host_path → $container_path" } +cmd_sync() { + local filter="" + local trusted=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --trusted) + trusted=true + shift + ;; + *) + filter="$1" + shift + ;; + esac + done + + local host_projects="${HOME}/.claude/projects" + + if [[ "$trusted" == false ]]; then + log_warn "This copies files from devcontainers to your host filesystem." + log_warn "Only proceed if you trust the container contents." + log_info "Use --trusted to skip this prompt." + read -p "Continue? [y/N] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + log_info "Aborted." + exit 0 + fi + fi + + # Discover all devcontainers (running + stopped) by label. + local container_ids + container_ids=$(docker ps -a -q \ + --filter "label=devcontainer.local_folder" 2>/dev/null || true) + + if [[ -z "$container_ids" ]]; then + log_error "No devcontainers found (running or stopped)." + exit 1 + fi + + # List discovered devcontainers. + log_info "Discovered devcontainers:" + local matched_any=false + while IFS= read -r cid; do + local name folder status + name=$(sync_get_project_name "$cid") + folder=$(docker inspect --format \ + '{{index .Config.Labels "devcontainer.local_folder"}}' "$cid") + status=$(docker inspect --format '{{.State.Status}}' "$cid") + + if [[ -n "$filter" ]]; then + if ! echo "$name" | grep -qi "$filter"; then + continue + fi + fi + + matched_any=true + echo " - ${name} (${status}) ${folder}" + done <<< "$container_ids" + + if [[ "$matched_any" == false ]]; then + log_error "No devcontainers matching '${filter}'." + echo "" + echo "Available:" + while IFS= read -r cid; do + local name status + name=$(sync_get_project_name "$cid") + status=$(docker inspect --format '{{.State.Status}}' "$cid") + echo " - ${name} (${status})" + done <<< "$container_ids" + exit 1 + fi + + echo "" + + # Sync matching containers. + while IFS= read -r cid; do + local name + name=$(sync_get_project_name "$cid") + + if [[ -n "$filter" ]]; then + if ! echo "$name" | grep -qi "$filter"; then + continue + fi + fi + + sync_one_container "$cid" "$host_projects" + echo "" + done <<< "$container_ids" + + log_success "Run '/insights' in Claude Code to include these sessions." +} + +# Extract project name from devcontainer.local_folder label. +sync_get_project_name() { + local folder + folder=$(docker inspect --format \ + '{{index .Config.Labels "devcontainer.local_folder"}}' "$1") + basename "$folder" +} + +# Resolve the Claude projects dir inside a container without +# docker exec (works on stopped containers too). +# Reads CLAUDE_CONFIG_DIR from container env, falls back to +# /home//.claude. +sync_get_claude_projects_dir() { + local cid="$1" + local claude_dir + + claude_dir=$(docker inspect --format '{{json .Config.Env}}' "$cid" \ + | tr ',' '\n' | tr -d '[]"' \ + | grep '^CLAUDE_CONFIG_DIR=' \ + | cut -d= -f2- || true) + + if [[ -n "$claude_dir" ]]; then + echo "${claude_dir}/projects" + return + fi + + local user + user=$(docker inspect --format '{{.Config.User}}' "$cid") + if [[ -z "$user" || "$user" == "root" ]]; then + echo "/root/.claude/projects" + else + echo "/home/${user}/.claude/projects" + fi +} + +sync_one_container() { + local cid="$1" + local host_projects="$2" + local project_name status claude_dir folder + + project_name=$(sync_get_project_name "$cid") + folder=$(docker inspect --format \ + '{{index .Config.Labels "devcontainer.local_folder"}}' "$cid") + status=$(docker inspect --format '{{.State.Status}}' "$cid") + claude_dir=$(sync_get_claude_projects_dir "$cid") + + log_info "=== ${project_name} (${status}) ===" + echo " Host path: ${folder}" + echo " Container: ${cid:0:12}" + + # docker cp works on both running and stopped containers. + local tmpdir + tmpdir=$(mktemp -d) + + if ! docker cp "${cid}:${claude_dir}/." "$tmpdir/" 2>/dev/null; then + echo " No sessions found, skipping." + rm -rf "$tmpdir" + return 0 + fi + + local session_count + session_count=$(find "$tmpdir" -name '*.jsonl' | wc -l | tr -d ' ') + + if [[ "$session_count" -eq 0 ]]; then + echo " No sessions found, skipping." + rm -rf "$tmpdir" + return 0 + fi + + echo " Sessions: ${session_count}" + + local total_copied=0 + + # Sync each project key subdirectory. + for key_path in "$tmpdir"/*/; do + [[ ! -d "$key_path" ]] && continue + local key dest_key + key=$(basename "$key_path") + + if [[ "$key" == "-workspace" ]]; then + dest_key="-devcontainer-${project_name}" + else + dest_key="${key}" + fi + + local dest_dir="${host_projects}/${dest_key}" + mkdir -p "$dest_dir" + + local copied=0 + while IFS= read -r -d '' file; do + local rel="${file#"$key_path"}" + local dest_file="${dest_dir}/${rel}" + mkdir -p "$(dirname "$dest_file")" + + if [[ ! -e "$dest_file" ]] \ + || [[ "$file" -nt "$dest_file" ]]; then + cp -p "$file" "$dest_file" + copied=$((copied + 1)) + fi + done < <(find "$key_path" -type f -print0) + + if [[ "$copied" -gt 0 ]]; then + echo " Synced ${copied} file(s) -> ${dest_key}" + fi + total_copied=$((total_copied + copied)) + done + + # Handle .jsonl files directly in projects/ (no subdirectory). + local orphan_copied=0 + local dest_dir="${host_projects}/-devcontainer-${project_name}" + mkdir -p "$dest_dir" + + while IFS= read -r -d '' file; do + local name + name=$(basename "$file") + local dest_file="${dest_dir}/${name}" + + if [[ ! -e "$dest_file" ]] \ + || [[ "$file" -nt "$dest_file" ]]; then + cp -p "$file" "$dest_file" + orphan_copied=$((orphan_copied + 1)) + fi + done < <(find "$tmpdir" -maxdepth 1 -name '*.jsonl' -print0) + + if [[ "$orphan_copied" -gt 0 ]]; then + echo " Synced ${orphan_copied} file(s) -> -devcontainer-${project_name}" + total_copied=$((total_copied + orphan_copied)) + fi + + rm -rf "$tmpdir" + + echo " Total: ${total_copied} file(s) synced." +} + cmd_cp() { local container_path="${1:-}" local host_path="${2:-}" @@ -444,6 +675,9 @@ main() { mount) cmd_mount "$@" ;; + sync) + cmd_sync "$@" + ;; cp) cmd_cp "$@" ;;