Skip to content

Commit 8256ebb

Browse files
DarkaMaulclaude
andauthored
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 <noreply@anthropic.com> * Add devc sync to README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d05cd49 commit 8256ebb

2 files changed

Lines changed: 248 additions & 0 deletions

File tree

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,24 @@ devc shell Open zsh shell in container
125125
devc exec CMD Execute command inside the container
126126
devc upgrade Upgrade Claude Code in the container
127127
devc mount SRC DST Add a bind mount (host → container)
128+
devc sync [NAME] Sync Claude Code sessions from devcontainers to host
128129
devc template DIR Copy devcontainer files to directory
129130
devc self-install Install devc to ~/.local/bin
130131
```
131132

133+
## Session Sync for `/insights`
134+
135+
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.
136+
137+
`devc sync` copies session logs from all devcontainers (running and stopped) to the host so `/insights` can include them:
138+
139+
```bash
140+
devc sync # Sync all devcontainers
141+
devc sync crypto # Filter by project name (substring match)
142+
```
143+
144+
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.
145+
132146
## File Sharing
133147

134148
### VS Code / Cursor

install.sh

Lines changed: 234 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] [--trusted] Sync sessions from devcontainers to host
4041
cp <cont> <host> Copy files/directories from container to host
4142
help Show this help message
4243
@@ -50,6 +51,8 @@ Examples:
5051
devc exec ls -la # Run command in container
5152
devc upgrade # Upgrade Claude Code to latest
5253
devc mount ~/data /data # Add mount to container
54+
devc sync # Sync sessions from all devcontainers
55+
devc sync crypto # Sync only matching devcontainer
5356
devc cp /some/file ./out # Copy a path from container to host
5457
EOF
5558
}
@@ -330,6 +333,234 @@ cmd_mount() {
330333
log_success "Mount added: $host_path$container_path"
331334
}
332335

336+
cmd_sync() {
337+
local filter=""
338+
local trusted=false
339+
340+
while [[ $# -gt 0 ]]; do
341+
case "$1" in
342+
--trusted)
343+
trusted=true
344+
shift
345+
;;
346+
*)
347+
filter="$1"
348+
shift
349+
;;
350+
esac
351+
done
352+
353+
local host_projects="${HOME}/.claude/projects"
354+
355+
if [[ "$trusted" == false ]]; then
356+
log_warn "This copies files from devcontainers to your host filesystem."
357+
log_warn "Only proceed if you trust the container contents."
358+
log_info "Use --trusted to skip this prompt."
359+
read -p "Continue? [y/N] " -n 1 -r
360+
echo
361+
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
362+
log_info "Aborted."
363+
exit 0
364+
fi
365+
fi
366+
367+
# Discover all devcontainers (running + stopped) by label.
368+
local container_ids
369+
container_ids=$(docker ps -a -q \
370+
--filter "label=devcontainer.local_folder" 2>/dev/null || true)
371+
372+
if [[ -z "$container_ids" ]]; then
373+
log_error "No devcontainers found (running or stopped)."
374+
exit 1
375+
fi
376+
377+
# List discovered devcontainers.
378+
log_info "Discovered devcontainers:"
379+
local matched_any=false
380+
while IFS= read -r cid; do
381+
local name folder status
382+
name=$(sync_get_project_name "$cid")
383+
folder=$(docker inspect --format \
384+
'{{index .Config.Labels "devcontainer.local_folder"}}' "$cid")
385+
status=$(docker inspect --format '{{.State.Status}}' "$cid")
386+
387+
if [[ -n "$filter" ]]; then
388+
if ! echo "$name" | grep -qi "$filter"; then
389+
continue
390+
fi
391+
fi
392+
393+
matched_any=true
394+
echo " - ${name} (${status}) ${folder}"
395+
done <<< "$container_ids"
396+
397+
if [[ "$matched_any" == false ]]; then
398+
log_error "No devcontainers matching '${filter}'."
399+
echo ""
400+
echo "Available:"
401+
while IFS= read -r cid; do
402+
local name status
403+
name=$(sync_get_project_name "$cid")
404+
status=$(docker inspect --format '{{.State.Status}}' "$cid")
405+
echo " - ${name} (${status})"
406+
done <<< "$container_ids"
407+
exit 1
408+
fi
409+
410+
echo ""
411+
412+
# Sync matching containers.
413+
while IFS= read -r cid; do
414+
local name
415+
name=$(sync_get_project_name "$cid")
416+
417+
if [[ -n "$filter" ]]; then
418+
if ! echo "$name" | grep -qi "$filter"; then
419+
continue
420+
fi
421+
fi
422+
423+
sync_one_container "$cid" "$host_projects"
424+
echo ""
425+
done <<< "$container_ids"
426+
427+
log_success "Run '/insights' in Claude Code to include these sessions."
428+
}
429+
430+
# Extract project name from devcontainer.local_folder label.
431+
sync_get_project_name() {
432+
local folder
433+
folder=$(docker inspect --format \
434+
'{{index .Config.Labels "devcontainer.local_folder"}}' "$1")
435+
basename "$folder"
436+
}
437+
438+
# Resolve the Claude projects dir inside a container without
439+
# docker exec (works on stopped containers too).
440+
# Reads CLAUDE_CONFIG_DIR from container env, falls back to
441+
# /home/<user>/.claude.
442+
sync_get_claude_projects_dir() {
443+
local cid="$1"
444+
local claude_dir
445+
446+
claude_dir=$(docker inspect --format '{{json .Config.Env}}' "$cid" \
447+
| tr ',' '\n' | tr -d '[]"' \
448+
| grep '^CLAUDE_CONFIG_DIR=' \
449+
| cut -d= -f2- || true)
450+
451+
if [[ -n "$claude_dir" ]]; then
452+
echo "${claude_dir}/projects"
453+
return
454+
fi
455+
456+
local user
457+
user=$(docker inspect --format '{{.Config.User}}' "$cid")
458+
if [[ -z "$user" || "$user" == "root" ]]; then
459+
echo "/root/.claude/projects"
460+
else
461+
echo "/home/${user}/.claude/projects"
462+
fi
463+
}
464+
465+
sync_one_container() {
466+
local cid="$1"
467+
local host_projects="$2"
468+
local project_name status claude_dir folder
469+
470+
project_name=$(sync_get_project_name "$cid")
471+
folder=$(docker inspect --format \
472+
'{{index .Config.Labels "devcontainer.local_folder"}}' "$cid")
473+
status=$(docker inspect --format '{{.State.Status}}' "$cid")
474+
claude_dir=$(sync_get_claude_projects_dir "$cid")
475+
476+
log_info "=== ${project_name} (${status}) ==="
477+
echo " Host path: ${folder}"
478+
echo " Container: ${cid:0:12}"
479+
480+
# docker cp works on both running and stopped containers.
481+
local tmpdir
482+
tmpdir=$(mktemp -d)
483+
484+
if ! docker cp "${cid}:${claude_dir}/." "$tmpdir/" 2>/dev/null; then
485+
echo " No sessions found, skipping."
486+
rm -rf "$tmpdir"
487+
return 0
488+
fi
489+
490+
local session_count
491+
session_count=$(find "$tmpdir" -name '*.jsonl' | wc -l | tr -d ' ')
492+
493+
if [[ "$session_count" -eq 0 ]]; then
494+
echo " No sessions found, skipping."
495+
rm -rf "$tmpdir"
496+
return 0
497+
fi
498+
499+
echo " Sessions: ${session_count}"
500+
501+
local total_copied=0
502+
503+
# Sync each project key subdirectory.
504+
for key_path in "$tmpdir"/*/; do
505+
[[ ! -d "$key_path" ]] && continue
506+
local key dest_key
507+
key=$(basename "$key_path")
508+
509+
if [[ "$key" == "-workspace" ]]; then
510+
dest_key="-devcontainer-${project_name}"
511+
else
512+
dest_key="${key}"
513+
fi
514+
515+
local dest_dir="${host_projects}/${dest_key}"
516+
mkdir -p "$dest_dir"
517+
518+
local copied=0
519+
while IFS= read -r -d '' file; do
520+
local rel="${file#"$key_path"}"
521+
local dest_file="${dest_dir}/${rel}"
522+
mkdir -p "$(dirname "$dest_file")"
523+
524+
if [[ ! -e "$dest_file" ]] \
525+
|| [[ "$file" -nt "$dest_file" ]]; then
526+
cp -p "$file" "$dest_file"
527+
copied=$((copied + 1))
528+
fi
529+
done < <(find "$key_path" -type f -print0)
530+
531+
if [[ "$copied" -gt 0 ]]; then
532+
echo " Synced ${copied} file(s) -> ${dest_key}"
533+
fi
534+
total_copied=$((total_copied + copied))
535+
done
536+
537+
# Handle .jsonl files directly in projects/ (no subdirectory).
538+
local orphan_copied=0
539+
local dest_dir="${host_projects}/-devcontainer-${project_name}"
540+
mkdir -p "$dest_dir"
541+
542+
while IFS= read -r -d '' file; do
543+
local name
544+
name=$(basename "$file")
545+
local dest_file="${dest_dir}/${name}"
546+
547+
if [[ ! -e "$dest_file" ]] \
548+
|| [[ "$file" -nt "$dest_file" ]]; then
549+
cp -p "$file" "$dest_file"
550+
orphan_copied=$((orphan_copied + 1))
551+
fi
552+
done < <(find "$tmpdir" -maxdepth 1 -name '*.jsonl' -print0)
553+
554+
if [[ "$orphan_copied" -gt 0 ]]; then
555+
echo " Synced ${orphan_copied} file(s) -> -devcontainer-${project_name}"
556+
total_copied=$((total_copied + orphan_copied))
557+
fi
558+
559+
rm -rf "$tmpdir"
560+
561+
echo " Total: ${total_copied} file(s) synced."
562+
}
563+
333564
cmd_cp() {
334565
local container_path="${1:-}"
335566
local host_path="${2:-}"
@@ -444,6 +675,9 @@ main() {
444675
mount)
445676
cmd_mount "$@"
446677
;;
678+
sync)
679+
cmd_sync "$@"
680+
;;
447681
cp)
448682
cmd_cp "$@"
449683
;;

0 commit comments

Comments
 (0)