Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 30 additions & 15 deletions lib/cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,27 @@ readonly SCRIPT_COMMANDS=(shell create slot slots revoke profiles projects profi
# pass_through: Array of args to pass to Claude in container
# Note: Each argument goes into exactly ONE bucket - no duplication
parse_cli_args() {
local all_args=("$@")

# Initialize bucket arrays
# Initialize bucket arrays (local)
host_flags=()
control_flags=()
script_command=""
pass_through=()


# Initialize global CLI variables (must be set even if no args)
CLI_HOST_FLAGS=()
CLI_CONTROL_FLAGS=()
CLI_SCRIPT_COMMAND=""
CLI_PASS_THROUGH=()

# Early return if no arguments
if [[ $# -eq 0 ]]; then
return 0
fi

# Single parsing loop - each arg goes into exactly ONE bucket
local found_script_command=false
for arg in "${all_args[@]}"; do

for arg in "$@"; do
if [[ " ${HOST_ONLY_FLAGS[*]} " == *" $arg "* ]]; then
# Bucket 1: Host-only flags
host_flags+=("$arg")
Expand All @@ -49,14 +58,20 @@ parse_cli_args() {
done

# Export results for use by main script
export CLI_HOST_FLAGS=("${host_flags[@]}")
export CLI_CONTROL_FLAGS=("${control_flags[@]}")
export CLI_SCRIPT_COMMAND="$script_command"
export CLI_PASS_THROUGH=("${pass_through[@]}")
# Arrays are always initialized above, so "${arr[@]}" is safe with set -u
CLI_HOST_FLAGS=("${host_flags[@]}")
CLI_CONTROL_FLAGS=("${control_flags[@]}")
CLI_SCRIPT_COMMAND="$script_command"
CLI_PASS_THROUGH=("${pass_through[@]}")
}

# Process host-only flags and set environment variables
process_host_flags() {
# Handle empty array with set -u
if [[ ${#CLI_HOST_FLAGS[@]} -eq 0 ]]; then
return 0
fi

for flag in "${CLI_HOST_FLAGS[@]}"; do
case "$flag" in
--verbose)
Expand Down Expand Up @@ -125,11 +140,11 @@ requires_slot() {
# Debug output for parsed arguments (only if VERBOSE=true)
debug_parsed_args() {
if [[ "${VERBOSE:-false}" == "true" ]]; then
echo "[DEBUG] CLI Parser Results:" >&2
echo "[DEBUG] Host flags: ${CLI_HOST_FLAGS[*]}" >&2
echo "[DEBUG] Control flags: ${CLI_CONTROL_FLAGS[*]}" >&2
echo "[DEBUG] Script command: ${CLI_SCRIPT_COMMAND}" >&2
echo "[DEBUG] Pass-through: ${CLI_PASS_THROUGH[*]}" >&2
printf '[DEBUG] CLI Parser Results:\n' >&2
printf '[DEBUG] Host flags: %s\n' "${CLI_HOST_FLAGS[*]}" >&2
printf '[DEBUG] Control flags: %s\n' "${CLI_CONTROL_FLAGS[*]}" >&2
printf '[DEBUG] Script command: %s\n' "${CLI_SCRIPT_COMMAND}" >&2
printf '[DEBUG] Pass-through: %s\n' "${CLI_PASS_THROUGH[*]}" >&2
fi
}

Expand Down
14 changes: 8 additions & 6 deletions lib/commands.profile.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ _cmd_profiles() {
local desc=$(get_profile_description "$profile")
local is_enabled=false
# Check if profile is currently enabled
for enabled in "${current_profiles[@]}"; do
if [[ "$enabled" == "$profile" ]]; then
is_enabled=true
break
fi
done
if [[ ${#current_profiles[@]} -gt 0 ]]; then
for enabled in "${current_profiles[@]}"; do
if [[ "$enabled" == "$profile" ]]; then
is_enabled=true
break
fi
done
fi
printf " ${GREEN}%-15s${NC} " "$profile"
if [[ "$is_enabled" == "true" ]]; then
printf "${GREEN}✓${NC} "
Expand Down
12 changes: 9 additions & 3 deletions lib/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,10 @@ read_profile_section() {
done < <(sed -n "/^\[$section\]/,/^\[/p" "$profile_file" | tail -n +2 | grep -v '^\[')
fi

printf '%s\n' "${result[@]}"
# Only print if array has elements
if [[ ${#result[@]} -gt 0 ]]; then
printf '%s\n' "${result[@]}"
fi
}

update_profile_section() {
Expand Down Expand Up @@ -185,8 +188,11 @@ get_current_profiles() {
[[ -n "$line" ]] && current_profiles+=("$line")
done < <(read_profile_section "$profiles_file" "profiles")
fi

printf '%s\n' "${current_profiles[@]}"

# Only print if array has elements
if [[ ${#current_profiles[@]} -gt 0 ]]; then
printf '%s\n' "${current_profiles[@]}"
fi
}

# -------- Profile installation functions for Docker builds -------------------
Expand Down
12 changes: 7 additions & 5 deletions lib/docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -296,11 +296,13 @@ run_claudebox_container() {
# Set up cleanup trap for temporary MCP config files
cleanup_mcp_files() {
local file
for file in "${mcp_temp_files[@]}"; do
if [[ -f "$file" ]]; then
rm -f "$file"
fi
done
if [[ ${#mcp_temp_files[@]} -gt 0 ]]; then
for file in "${mcp_temp_files[@]}"; do
if [[ -f "$file" ]]; then
rm -f "$file"
fi
done
fi
if [[ -n "$user_mcp_file" ]] && [[ -f "$user_mcp_file" ]]; then
rm -f "$user_mcp_file"
fi
Expand Down
111 changes: 64 additions & 47 deletions main.sh
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ main() {
if [[ ${#saved_flags[@]} -gt 0 ]]; then
# Re-parse WITH saved flags, but the command structure is preserved
# because the command was already identified from original args
# original_args is always initialized on line 79, so "${arr[@]}" is safe
parse_cli_args "${original_args[@]}" "${saved_flags[@]}"
process_host_flags

Expand Down Expand Up @@ -137,6 +138,7 @@ main() {
# If command doesn't need Docker, skip all Docker setup
if [[ "$cmd_requirements" == "none" ]]; then
# Dispatch the command directly and exit
# CLI arrays are always initialized by parse_cli_args, so "${arr[@]}" is safe
dispatch_command "${CLI_SCRIPT_COMMAND}" "${CLI_PASS_THROUGH[@]}" "${CLI_CONTROL_FLAGS[@]}"
exit $?
fi
Expand Down Expand Up @@ -352,7 +354,8 @@ main() {
# Separate Python-only profiles from Docker-affecting profiles
local docker_profiles=()
local python_only_profiles=("python" "ml" "datascience")


if [[ ${#current_profiles[@]} -gt 0 ]]; then
for profile in "${current_profiles[@]}"; do
local is_python_only=false
for py_profile in "${python_only_profiles[@]}"; do
Expand All @@ -365,7 +368,8 @@ main() {
docker_profiles+=("$profile")
fi
done

fi

# Calculate hash only for Docker-affecting profiles
local docker_profiles_hash=""
if [[ ${#docker_profiles[@]} -gt 0 ]]; then
Expand Down Expand Up @@ -455,9 +459,14 @@ main() {

# Re-parse all arguments with saved flags included
if [[ ${#saved_flags[@]} -gt 0 ]]; then
# Combine original args with saved flags
local all_args=("${original_args[@]}" "${saved_flags[@]}")

# Combine original args with saved flags using append pattern
# to preserve argument boundaries safely
local all_args=()
if [[ ${#original_args[@]} -gt 0 ]]; then
all_args+=("${original_args[@]}")
fi
all_args+=("${saved_flags[@]}")

# Re-parse to properly sort flags
parse_cli_args "${all_args[@]}"
process_host_flags
Expand All @@ -472,12 +481,14 @@ main() {
# Check if stdin is not a terminal (i.e., we're receiving piped input)
# and -p/--print flag isn't already present
local has_print_flag=false
for arg in "${CLI_PASS_THROUGH[@]}"; do
if [[ "$arg" == "-p" ]] || [[ "$arg" == "--print" ]]; then
has_print_flag=true
break
fi
done
if [[ ${#CLI_PASS_THROUGH[@]} -gt 0 ]]; then
for arg in "${CLI_PASS_THROUGH[@]}"; do
if [[ "$arg" == "-p" ]] || [[ "$arg" == "--print" ]]; then
has_print_flag=true
break
fi
done
fi

if [[ "$VERBOSE" == "true" ]]; then
if [[ -t 0 ]]; then
Expand Down Expand Up @@ -535,35 +546,39 @@ build_docker_image() {
while IFS= read -r line; do
[[ -n "$line" ]] && current_profiles+=("$line")
done < <(read_profile_section "$profiles_file" "profiles")

# Generate profile installations
for profile in "${current_profiles[@]}"; do
profile=$(echo "$profile" | tr -d '[:space:]')
[[ -z "$profile" ]] && continue

# Convert hyphens to underscores for function names
local profile_fn="get_profile_${profile//-/_}"
if type -t "$profile_fn" >/dev/null; then
profile_installations+=$'\n'"$($profile_fn)"
fi
done

if [[ ${#current_profiles[@]} -gt 0 ]]; then
for profile in "${current_profiles[@]}"; do
profile=$(echo "$profile" | tr -d '[:space:]')
[[ -z "$profile" ]] && continue

# Convert hyphens to underscores for function names
local profile_fn="get_profile_${profile//-/_}"
if type -t "$profile_fn" >/dev/null; then
profile_installations+=$'\n'"$($profile_fn)"
fi
done
fi

# Calculate hash only for Docker-affecting profiles
local docker_profiles=()
local python_only_profiles=("python" "ml" "datascience")

for profile in "${current_profiles[@]}"; do
local is_python_only=false
for py_profile in "${python_only_profiles[@]}"; do
if [[ "$profile" == "$py_profile" ]]; then
is_python_only=true
break

if [[ ${#current_profiles[@]} -gt 0 ]]; then
for profile in "${current_profiles[@]}"; do
local is_python_only=false
for py_profile in "${python_only_profiles[@]}"; do
if [[ "$profile" == "$py_profile" ]]; then
is_python_only=true
break
fi
done
if [[ "$is_python_only" == "false" ]]; then
docker_profiles+=("$profile")
fi
done
if [[ "$is_python_only" == "false" ]]; then
docker_profiles+=("$profile")
fi
done
fi

if [[ ${#docker_profiles[@]} -gt 0 ]]; then
profile_hash=$(printf '%s\n' "${docker_profiles[@]}" | sort | cksum | cut -d' ' -f1)
Expand All @@ -586,22 +601,24 @@ LABEL claudebox.profiles.crc=\"$profiles_file_hash\"
LABEL claudebox.project=\"$project_folder_name\""

# Replace placeholders in the project template
local final_dockerfile="$base_dockerfile"

# Replace WHOLE lines that contain the placeholders (with optional spaces)
# Use environment variables for awk to handle multiline strings properly
local final_dockerfile
final_dockerfile=$(awk -v pi="$profile_installations" -v lbs="$labels" '
# If the whole line is {{ PROFILE_INSTALLATIONS }}, print injected block and skip
/^[[:space:]]*\{\{[[:space:]]*PROFILE_INSTALLATIONS[[:space:]]*\}\}[[:space:]]*$/ { print pi; next }
# If the whole line is {{ LABELS }}, print labels block and skip
/^[[:space:]]*\{\{[[:space:]]*LABELS[[:space:]]*\}\}[[:space:]]*$/ { print lbs; next }
# Otherwise, print the line unchanged
{ print }
' <<<"$base_dockerfile") || error "Failed to apply Dockerfile substitutions"
final_dockerfile=$(
PROFILE_INSTALLS="$profile_installations" \
DOCKER_LABELS="$labels" \
awk '
# If the whole line is {{ PROFILE_INSTALLATIONS }}, print injected block and skip
/^[[:space:]]*\{\{[[:space:]]*PROFILE_INSTALLATIONS[[:space:]]*\}\}[[:space:]]*$/ { print ENVIRON["PROFILE_INSTALLS"]; next }
# If the whole line is {{ LABELS }}, print labels block and skip
/^[[:space:]]*\{\{[[:space:]]*LABELS[[:space:]]*\}\}[[:space:]]*$/ { print ENVIRON["DOCKER_LABELS"]; next }
# Otherwise, print the line unchanged
{ print }
' <<<"$base_dockerfile"
) || error "Failed to apply Dockerfile substitutions"

# Guard: ensure no unreplaced placeholders remain
if grep -q '{{PROFILE_INSTALLATIONS}}' <<<"$final_dockerfile" grep -q '{{LABELS}}' <<<"$final_dockerfile"; then
error "Unreplaced placeholders remain in generated Dockerfile"
if grep -q '{{PROFILE_INSTALLATIONS}}' <<<"$final_dockerfile" || grep -q '{{LABELS}}' <<<"$final_dockerfile"; then
error "Unreplaced placeholders remain in generated Dockerfile"
fi

printf '%s' "$final_dockerfile" > "$dockerfile"
Expand Down