@@ -37,6 +37,7 @@ Commands:
3737 exec <cmd> Execute a command in the running container
3838 upgrade Upgrade Claude Code to latest version
3939 mount <host> <cont> Add a mount to the devcontainer (recreates container)
40+ sync [project] Sync Claude Code sessions from devcontainers to host
4041 help Show this help message
4142
4243Examples:
@@ -49,6 +50,8 @@ Examples:
4950 devc exec ls -la # Run command in container
5051 devc upgrade # Upgrade Claude Code to latest
5152 devc mount ~/data /data # Add mount to container
53+ devc sync # Sync sessions from all devcontainers
54+ devc sync crypto # Sync only matching devcontainer
5255EOF
5356}
5457
@@ -328,6 +331,207 @@ cmd_mount() {
328331 log_success " Mount added: $host_path → $container_path "
329332}
330333
334+ cmd_sync () {
335+ local filter=" ${1:- } "
336+ local host_projects=" ${HOME} /.claude/projects"
337+
338+ # Discover all devcontainers (running + stopped) by label.
339+ local container_ids
340+ container_ids=$( docker ps -a -q \
341+ --filter " label=devcontainer.local_folder" 2> /dev/null || true)
342+
343+ if [[ -z " $container_ids " ]]; then
344+ log_error " No devcontainers found (running or stopped)."
345+ exit 1
346+ fi
347+
348+ # List discovered devcontainers.
349+ log_info " Discovered devcontainers:"
350+ local matched_any=false
351+ while IFS= read -r cid; do
352+ local name folder status
353+ name=$( sync_get_project_name " $cid " )
354+ folder=$( docker inspect --format \
355+ ' {{index .Config.Labels "devcontainer.local_folder"}}' " $cid " )
356+ status=$( docker inspect --format ' {{.State.Status}}' " $cid " )
357+
358+ if [[ -n " $filter " ]]; then
359+ if ! echo " $name " | grep -qi " $filter " ; then
360+ continue
361+ fi
362+ fi
363+
364+ matched_any=true
365+ echo " - ${name} (${status} ) ${folder} "
366+ done <<< " $container_ids"
367+
368+ if [[ " $matched_any " == false ]]; then
369+ log_error " No devcontainers matching '${filter} '."
370+ echo " "
371+ echo " Available:"
372+ while IFS= read -r cid; do
373+ local name status
374+ name=$( sync_get_project_name " $cid " )
375+ status=$( docker inspect --format ' {{.State.Status}}' " $cid " )
376+ echo " - ${name} (${status} )"
377+ done <<< " $container_ids"
378+ exit 1
379+ fi
380+
381+ echo " "
382+
383+ # Sync matching containers.
384+ while IFS= read -r cid; do
385+ local name
386+ name=$( sync_get_project_name " $cid " )
387+
388+ if [[ -n " $filter " ]]; then
389+ if ! echo " $name " | grep -qi " $filter " ; then
390+ continue
391+ fi
392+ fi
393+
394+ sync_one_container " $cid " " $host_projects "
395+ echo " "
396+ done <<< " $container_ids"
397+
398+ log_success " Run '/insights' in Claude Code to include these sessions."
399+ }
400+
401+ # Extract project name from devcontainer.local_folder label.
402+ sync_get_project_name () {
403+ local folder
404+ folder=$( docker inspect --format \
405+ ' {{index .Config.Labels "devcontainer.local_folder"}}' " $1 " )
406+ basename " $folder "
407+ }
408+
409+ # Resolve the Claude projects dir inside a container without
410+ # docker exec (works on stopped containers too).
411+ # Reads CLAUDE_CONFIG_DIR from container env, falls back to
412+ # /home/<user>/.claude.
413+ sync_get_claude_projects_dir () {
414+ local cid=" $1 "
415+ local claude_dir
416+
417+ claude_dir=$( docker inspect --format ' {{json .Config.Env}}' " $cid " \
418+ | tr ' ,' ' \n' | tr -d ' []"' \
419+ | grep ' ^CLAUDE_CONFIG_DIR=' \
420+ | cut -d= -f2- || true)
421+
422+ if [[ -n " $claude_dir " ]]; then
423+ echo " ${claude_dir} /projects"
424+ return
425+ fi
426+
427+ local user
428+ user=$( docker inspect --format ' {{.Config.User}}' " $cid " )
429+ if [[ -z " $user " || " $user " == " root" ]]; then
430+ echo " /root/.claude/projects"
431+ else
432+ echo " /home/${user} /.claude/projects"
433+ fi
434+ }
435+
436+ sync_one_container () {
437+ local cid=" $1 "
438+ local host_projects=" $2 "
439+ local project_name status claude_dir folder
440+
441+ project_name=$( sync_get_project_name " $cid " )
442+ folder=$( docker inspect --format \
443+ ' {{index .Config.Labels "devcontainer.local_folder"}}' " $cid " )
444+ status=$( docker inspect --format ' {{.State.Status}}' " $cid " )
445+ claude_dir=$( sync_get_claude_projects_dir " $cid " )
446+
447+ log_info " === ${project_name} (${status} ) ==="
448+ echo " Host path: ${folder} "
449+ echo " Container: ${cid: 0: 12} "
450+
451+ # docker cp works on both running and stopped containers.
452+ local tmpdir
453+ tmpdir=$( mktemp -d)
454+
455+ if ! docker cp " ${cid} :${claude_dir} /." " $tmpdir /" 2> /dev/null; then
456+ echo " No sessions found, skipping."
457+ rm -rf " $tmpdir "
458+ return 0
459+ fi
460+
461+ local session_count
462+ session_count=$( find " $tmpdir " -name ' *.jsonl' | wc -l | tr -d ' ' )
463+
464+ if [[ " $session_count " -eq 0 ]]; then
465+ echo " No sessions found, skipping."
466+ rm -rf " $tmpdir "
467+ return 0
468+ fi
469+
470+ echo " Sessions: ${session_count} "
471+
472+ local total_copied=0
473+
474+ # Sync each project key subdirectory.
475+ for key_path in " $tmpdir " /* /; do
476+ [[ ! -d " $key_path " ]] && continue
477+ local key dest_key
478+ key=$( basename " $key_path " )
479+
480+ if [[ " $key " == " -workspace" ]]; then
481+ dest_key=" -devcontainer-${project_name} "
482+ else
483+ dest_key=" ${key} "
484+ fi
485+
486+ local dest_dir=" ${host_projects} /${dest_key} "
487+ mkdir -p " $dest_dir "
488+
489+ local copied=0
490+ while IFS= read -r -d ' ' file; do
491+ local rel=" ${file# " $key_path " } "
492+ local dest_file=" ${dest_dir} /${rel} "
493+ mkdir -p " $( dirname " $dest_file " ) "
494+
495+ if [[ ! -e " $dest_file " ]] \
496+ || [[ " $file " -nt " $dest_file " ]]; then
497+ cp -p " $file " " $dest_file "
498+ copied=$(( copied + 1 ))
499+ fi
500+ done < <( find " $key_path " -type f -print0)
501+
502+ if [[ " $copied " -gt 0 ]]; then
503+ echo " Synced ${copied} file(s) -> ${dest_key} "
504+ fi
505+ total_copied=$(( total_copied + copied))
506+ done
507+
508+ # Handle .jsonl files directly in projects/ (no subdirectory).
509+ local orphan_copied=0
510+ local dest_dir=" ${host_projects} /-devcontainer-${project_name} "
511+ mkdir -p " $dest_dir "
512+
513+ while IFS= read -r -d ' ' file; do
514+ local name
515+ name=$( basename " $file " )
516+ local dest_file=" ${dest_dir} /${name} "
517+
518+ if [[ ! -e " $dest_file " ]] \
519+ || [[ " $file " -nt " $dest_file " ]]; then
520+ cp -p " $file " " $dest_file "
521+ orphan_copied=$(( orphan_copied + 1 ))
522+ fi
523+ done < <( find " $tmpdir " -maxdepth 1 -name ' *.jsonl' -print0)
524+
525+ if [[ " $orphan_copied " -gt 0 ]]; then
526+ echo " Synced ${orphan_copied} file(s) -> -devcontainer-${project_name} "
527+ total_copied=$(( total_copied + orphan_copied))
528+ fi
529+
530+ rm -rf " $tmpdir "
531+
532+ echo " Total: ${total_copied} file(s) synced."
533+ }
534+
331535cmd_self_install () {
332536 local install_dir=" $HOME /.local/bin"
333537 local install_path=" $install_dir /devc"
@@ -415,6 +619,9 @@ main() {
415619 mount)
416620 cmd_mount " $@ "
417621 ;;
622+ sync)
623+ cmd_sync " $@ "
624+ ;;
418625 self-install)
419626 cmd_self_install
420627 ;;
0 commit comments