Skip to content

Commit 78af7fe

Browse files
DarkaMaulclaude
andcommitted
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 <noreply@anthropic.com>
1 parent c7ec556 commit 78af7fe

1 file changed

Lines changed: 207 additions & 0 deletions

File tree

install.sh

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
4243
Examples:
@@ -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
5255
EOF
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+
331535
cmd_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

Comments
 (0)