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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Dan Guido
2026-01-31 16:20:46 -05:00
committed by GitHub
parent 369b395361
commit 96e3ca270f
+158
View File
@@ -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 <cmd> Execute a command in the running container
upgrade Upgrade Claude Code to latest version
mount <host> <cont> 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 <host_path> <container_path> [--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
;;