From 96e3ca270f97df146b622b1beae89c5dcee36941 Mon Sep 17 00:00:00 2001 From: Dan Guido Date: Sat, 31 Jan 2026 16:20:46 -0500 Subject: [PATCH] Add exec, upgrade, and mount commands to devc CLI (#8) * Add exec, upgrade, and mount commands to devc CLI - exec: Run arbitrary commands in the container - upgrade: Update Claude Code to latest version - mount: Add bind mounts to devcontainer.json (recreates container) Also preserves custom mounts when template command overwrites existing devcontainer configuration. Co-Authored-By: Claude Opus 4.5 * Address PR review comments - Replace `npm install -g @anthropic-ai/claude-code@latest` with `claude update` since Claude Code now uses native installer - Remove `[--]` from exec command help/examples (not required) - Replace embedded Python with jq for devcontainer.json manipulation Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- install.sh | 158 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/install.sh b/install.sh index b498a4c..ee056aa 100755 --- a/install.sh +++ b/install.sh @@ -34,6 +34,9 @@ Commands: self-install Install 'devc' command to ~/.local/bin update Update devc to the latest version template [dir] Copy devcontainer template to directory (default: current) + exec Execute a command in the running container + upgrade Upgrade Claude Code to latest version + mount Add a mount to the devcontainer (recreates container) help Show this help message Examples: @@ -43,6 +46,9 @@ Examples: devc shell # Open interactive shell devc self-install # Install devc to PATH devc update # Update to latest version + devc exec ls -la # Run command in container + devc upgrade # Upgrade Claude Code to latest + devc mount ~/data /data # Add mount to container EOF } @@ -74,6 +80,75 @@ get_workspace_folder() { echo "${1:-$(pwd)}" } +# Extract custom mounts from devcontainer.json to a temp file +# Returns the temp file path, or empty string if no custom mounts +extract_mounts_to_file() { + local devcontainer_json="$1" + local temp_file + + [[ -f "$devcontainer_json" ]] || return 0 + + temp_file=$(mktemp) + + # Filter out default mounts (template mounts we don't want to preserve) + local custom_mounts + custom_mounts=$(jq -c ' + .mounts // [] | map( + select( + (startswith("source=claude-code-bashhistory-") | not) and + (startswith("source=claude-code-config-") | not) and + (startswith("source=claude-code-gh-") | not) and + (startswith("source=${localEnv:HOME}/.gitconfig,") | not) + ) + ) | if length > 0 then . else empty end + ' "$devcontainer_json" 2>/dev/null) || true + + if [[ -n "$custom_mounts" ]]; then + echo "$custom_mounts" >"$temp_file" + echo "$temp_file" + fi +} + +# Merge preserved mounts back into devcontainer.json +merge_mounts_from_file() { + local devcontainer_json="$1" + local mounts_file="$2" + + [[ -f "$mounts_file" ]] || return 0 + [[ -s "$mounts_file" ]] || return 0 + + local custom_mounts + custom_mounts=$(cat "$mounts_file") + + local updated + updated=$(jq --argjson custom "$custom_mounts" ' + .mounts = ((.mounts // []) + $custom | unique) + ' "$devcontainer_json") + + echo "$updated" >"$devcontainer_json" +} + +# Add or update a mount in devcontainer.json +update_devcontainer_mounts() { + local devcontainer_json="$1" + local host_path="$2" + local container_path="$3" + local readonly="${4:-false}" + + local mount_str="source=${host_path},target=${container_path},type=bind" + [[ "$readonly" == "true" ]] && mount_str="${mount_str},readonly" + + local updated + updated=$(jq --arg target "$container_path" --arg mount "$mount_str" ' + .mounts = ( + ((.mounts // []) | map(select(contains("target=" + $target + ",") or endswith("target=" + $target) | not))) + + [$mount] + ) + ' "$devcontainer_json") + + echo "$updated" >"$devcontainer_json" +} + cmd_template() { local target_dir="${1:-.}" target_dir="$(cd "$target_dir" 2>/dev/null && pwd)" || { @@ -82,6 +157,8 @@ cmd_template() { } local devcontainer_dir="$target_dir/.devcontainer" + local devcontainer_json="$devcontainer_dir/devcontainer.json" + local preserved_mounts="" if [[ -d "$devcontainer_dir" ]]; then log_warn "Devcontainer already exists at $devcontainer_dir" @@ -91,6 +168,12 @@ cmd_template() { log_info "Aborted." exit 0 fi + + # Preserve custom mounts before overwriting + preserved_mounts=$(extract_mounts_to_file "$devcontainer_json") + if [[ -n "$preserved_mounts" ]]; then + log_info "Preserving custom mounts..." + fi fi mkdir -p "$devcontainer_dir" @@ -101,6 +184,13 @@ cmd_template() { cp "$SCRIPT_DIR/post_install.py" "$devcontainer_dir/" cp "$SCRIPT_DIR/.zshrc" "$devcontainer_dir/" + # Restore preserved mounts + if [[ -n "$preserved_mounts" ]]; then + merge_mounts_from_file "$devcontainer_json" "$preserved_mounts" + rm -f "$preserved_mounts" + log_info "Custom mounts restored" + fi + log_success "Template installed to $devcontainer_dir" } @@ -155,6 +245,64 @@ cmd_shell() { devcontainer exec --workspace-folder "$workspace_folder" zsh } +cmd_exec() { + local workspace_folder + workspace_folder="$(get_workspace_folder)" + + check_devcontainer_cli + devcontainer exec --workspace-folder "$workspace_folder" "$@" +} + +cmd_upgrade() { + local workspace_folder + workspace_folder="$(get_workspace_folder)" + + check_devcontainer_cli + log_info "Upgrading Claude Code..." + + devcontainer exec --workspace-folder "$workspace_folder" claude update + + log_success "Claude Code upgraded" +} + +cmd_mount() { + local host_path="${1:-}" + local container_path="${2:-}" + local readonly="false" + + if [[ -z "$host_path" ]] || [[ -z "$container_path" ]]; then + log_error "Usage: devc mount [--readonly]" + exit 1 + fi + + [[ "${3:-}" == "--readonly" ]] && readonly="true" + + # Expand and validate host path + host_path="$(cd "$host_path" 2>/dev/null && pwd)" || { + log_error "Host path does not exist: $1" + exit 1 + } + + local workspace_folder + workspace_folder="$(get_workspace_folder)" + local devcontainer_json="$workspace_folder/.devcontainer/devcontainer.json" + + if [[ ! -f "$devcontainer_json" ]]; then + log_error "No devcontainer.json found. Run 'devc template' first." + exit 1 + fi + + check_devcontainer_cli + + log_info "Adding mount: $host_path → $container_path" + update_devcontainer_mounts "$devcontainer_json" "$host_path" "$container_path" "$readonly" + + log_info "Recreating container with new mount..." + devcontainer up --workspace-folder "$workspace_folder" --remove-existing-container + + log_success "Mount added: $host_path → $container_path" +} + cmd_self_install() { local install_dir="$HOME/.local/bin" local install_path="$install_dir/devc" @@ -232,6 +380,16 @@ main() { shell) cmd_shell ;; + exec) + [[ "${1:-}" == "--" ]] && shift + cmd_exec "$@" + ;; + upgrade) + cmd_upgrade + ;; + mount) + cmd_mount "$@" + ;; self-install) cmd_self_install ;;