diff --git a/README.md b/README.md index ca7a50a..f2efe17 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ The Ultimate Claude Code Docker Development Environment - Run Claude AI's coding ██████╗ ██████╗ ██╗ ██╗ ██╔══██╗██╔═══██╗╚██╗██╔╝ -██████╔╝██║ ██║ ╚███╔╝ -██╔══██╗██║ ██║ ██╔██╗ +██████╔╝██║ ██║ ╚███╔╝ +██╔══██╗██║ ██║ ██╔██╗ ██████╔╝╚██████╔╝██╔╝ ██╗ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ``` @@ -71,6 +71,7 @@ chmod +x claudebox.run ``` This will: + - Extract ClaudeBox to `~/.claudebox/source/` - Create a symlink at `~/.local/bin/claudebox` (you may need to add `~/.local/bin` to your PATH) - Show setup instructions if PATH configuration is needed @@ -98,6 +99,7 @@ ln -s ~/my-tools/claudebox/main.sh ~/.local/bin/claudebox ### Development Installation For development or testing the latest changes: + ```bash # Clone the repository git clone https://github.com/RchGrav/claudebox.git @@ -125,12 +127,12 @@ source ~/.zshrc ``` The installer will: + - ✅ Extract ClaudeBox to `~/.claudebox/source/` - ✅ Create symlink at `~/.local/bin/claudebox` - ✅ Check for Docker (install if needed on first run) - ✅ Configure Docker for non-root usage (on first run) - ## 📚 Usage ### Basic Usage @@ -171,6 +173,7 @@ claudebox profile python ml ``` Each project maintains its own: + - Docker image (`claudebox-`) - Language profiles and installed packages - Firewall allowlist @@ -198,15 +201,17 @@ claudebox profile c openwrt # C/C++ + OpenWRT claudebox profile rust go # Rust + Go ``` -#### Available Profiles: +#### Available Profiles **Core Profiles:** + - **core** - Core Development Utilities (compilers, VCS, shell tools) - **build-tools** - Build Tools (CMake, autotools, Ninja) - **shell** - Optional Shell Tools (fzf, SSH, man, rsync, file) - **networking** - Network Tools (IP stack, DNS, route tools) **Language Profiles:** + - **c** - C/C++ Development (debuggers, analyzers, Boost, ncurses, cmocka) - **rust** - Rust Development (installed via rustup) - **python** - Python Development (managed via uv) @@ -218,6 +223,7 @@ claudebox profile rust go # Rust + Go - **php** - PHP Development (PHP + extensions + Composer) **Specialized Profiles:** + - **openwrt** - OpenWRT Development (cross toolchain, QEMU, distro tools) - **database** - Database Tools (clients for major databases) - **devops** - DevOps Tools (Docker, Kubernetes, Terraform, etc.) @@ -252,6 +258,7 @@ claudebox info ``` The info command displays: + - **Current Project**: Path, ID, and data directory - **ClaudeBox Installation**: Script location and symlink - **Saved CLI Flags**: Your default flags configuration @@ -289,7 +296,7 @@ claudebox tmux # Use tmux commands inside the container: # - Create new panes: Ctrl+b % (vertical) or Ctrl+b " (horizontal) -# - Switch panes: Ctrl+b arrow-keys +# - Switch panes: Ctrl+b arrow-keys # - Create new windows: Ctrl+b c # - Switch windows: Ctrl+b n/p or Ctrl+b 0-9 ``` @@ -350,7 +357,8 @@ claudebox rebuild ## 🔧 Configuration ClaudeBox stores data in: -- `~/.claude/` - Global Claude configuration (mounted read-only) + +- `~/.claudebox/.claude/` - Global Claude configuration and shared commands - `~/.claudebox/` - Global ClaudeBox data - `~/.claudebox/profiles/` - Per-project profile configurations (*.ini files) - `~/.claudebox//` - Project-specific data: @@ -364,6 +372,7 @@ ClaudeBox stores data in: ### Project-Specific Features Each project automatically gets: + - **Docker Image**: `claudebox-` with installed profiles - **Profile Configuration**: `~/.claudebox/profiles/.ini` - **Python Virtual Environment**: `.venv` created with uv when Python profile is active @@ -378,6 +387,7 @@ Each project automatically gets: ## 🏗️ Architecture ClaudeBox creates a per-project Debian-based Docker image with: + - Node.js (via NVM for version flexibility) - Claude Code CLI (@anthropic-ai/claude-code) - User account matching host UID/GID @@ -403,12 +413,15 @@ This project is licensed under the MIT License - see the LICENSE file for detail ## 🐛 Troubleshooting ### Docker Permission Issues + ClaudeBox automatically handles Docker setup, but if you encounter issues: + 1. The script will add you to the docker group 2. You may need to log out/in or run `newgrp docker` 3. Run `claudebox` again ### Profile Installation Failed + ```bash # Clean and rebuild for current project claudebox clean --project @@ -417,14 +430,18 @@ claudebox profile ``` ### Profile Changes Not Taking Effect + ClaudeBox automatically detects profile changes and rebuilds when needed. If you're having issues: + ```bash # Force rebuild claudebox rebuild ``` ### Python Virtual Environment Issues + ClaudeBox automatically creates a venv when Python profile is active: + ```bash # The venv is created at ~/.claudebox//.venv # It's automatically activated in the container @@ -433,7 +450,9 @@ which python # Should show the venv python ``` ### Can't Find Command + Ensure the symlink was created: + ```bash ls -la ~/.local/bin/claudebox # Or manually create it @@ -441,7 +460,9 @@ ln -s /path/to/claudebox ~/.local/bin/claudebox ``` ### Multiple Instance Conflicts + Each project has its own Docker image and is fully isolated. To check status: + ```bash # Check all ClaudeBox images and containers claudebox info @@ -451,7 +472,9 @@ claudebox clean --project ``` ### Build Cache Issues + If builds are slow or failing: + ```bash # Clear Docker build cache claudebox clean --cache @@ -474,5 +497,5 @@ Made with ❤️ for developers who love clean, reproducible environments ## Contact -**Author/Maintainer:** RchGrav +**Author/Maintainer:** RchGrav **GitHub:** [@RchGrav](https://github.com/RchGrav) diff --git a/commands/controlflow.md b/commands/controlflow.md index 3a23e50..0a6f863 100644 --- a/commands/controlflow.md +++ b/commands/controlflow.md @@ -7,9 +7,9 @@ ### 1. MODE SELECTION - Prompt (MANDATORY, NO VARIATION): - > "**Select operation:** - > 1. Run an existing workflow - > 2. Create a new workflow + > "**Select operation:** + > 1. Run an existing workflow + > 2. Create a new workflow > (All workflows indexed at `~/.claudebox/meta/workflows/index.md`)" - Await user selection. @@ -23,11 +23,13 @@ - Check for `~/.claudebox/meta/workflows/index.md`. - If missing or empty, notify user: "No workflows available." Immediately proceed to Section 3. - Read and display all workflows from index as: + ``` {number}. {workflow\_name}: {description} ```` + - Require user to select by name or number. Do not proceed on ambiguity. - For chosen workflow: - Load from: @@ -36,58 +38,78 @@ - Any templates/phases as needed - Enforce absolute immutability of workflow—NO modifications. - Initialize output directory: + ``` ~/.claudebox/outputs/{workflow_name}_{timestamp}/ ``` + - For each phase N: - Prepare per-phase directory: + ``` ~/.claudebox/outputs/{workflow_name}_{timestamp}/phase{N}/ ``` + - For each agent in phase: - Create task file: + ``` ~/.claudebox/outputs/{workflow_name}_{timestamp}/phase{N}/{agent}_task.md ``` + - Agent receives ONLY: - Persona prompt: `~/.claudebox/meta/workflows/{workflow_name}/roles/{agent}.md` - - Context injected via: + - Context injected via: + ``` ~/.claudebox/outputs/{workflow_name}_{timestamp}/phase{N}/{agent}_task.md ``` + - Agent outputs to: + ``` ~/.claudebox/outputs/{workflow_name}_{timestamp}/phase{N}/{agent}_output.md ``` + - If critic present for phase: - Spawn stateless critic; provide ONLY: - Output to review: `.../{agent}_output.md` - Workflow spec: from contract in config - Critic outputs to: + ``` ~/.claudebox/outputs/{workflow_name}_{timestamp}/evaluations/phase{N}_{agent}_eval.md ``` - - **MANDATORY LOOP:** + + - **MANDATORY LOOP:** - If critic verdict is "ITERATE," the corresponding agent MUST revise. - **No upper limit on iterations unless explicitly set in config.** Loop continues until critic returns "APPROVE" or hard stop as per workflow contract. - All loop artifacts (every iteration) are logged as: + ``` ~/.claudebox/outputs/{workflow_name}_{timestamp}/phase{N}/history/{agent}_output_iter{I}.md ~/.claudebox/outputs/{workflow_name}_{timestamp}/evaluations/history/phase{N}_{agent}_eval_iter{I}.md ``` + - After final phase, copy or symlink final outputs to: + ``` ~/.claudebox/outputs/{workflow_name}_{timestamp}/final/ ``` + and optionally + ``` ~/.claudebox/meta/workflows/{workflow_name}/roles/final_{timestamp}/ ``` + - At completion, display: + ``` ✅ Workflow {workflow_name} executed successfully. Final outputs: ~/.claudebox/outputs/{workflow_name}_{timestamp}/final/ ``` + - TERMINATE. --- @@ -95,15 +117,17 @@ ### 3. CREATE NEW WORKFLOW - Prompt user, in order: + 1. **Task/Goal:** "Describe your task or goal." 2. **Constraints/Requirements:** "List any requirements, constraints, or preferences (language, deadlines, tools, etc.)." 3. **Augmentations:** If `~/.claudebox/newskills.md` exists, display it and prompt: "Specify any new skills/tools to enable." -4. **Refinement Loop Strategy:** - - Prompt: "Specify refinement loop: - - Fixed N iterations - - Iterate until zero deviation/spec - - Continuous/manual stop +4. **Refinement Loop Strategy:** + - Prompt: "Specify refinement loop: + - Fixed N iterations + - Iterate until zero deviation/spec + - Continuous/manual stop - (If skipped, system will default to 'Iterate until zero deviation/spec met.')" + - If user says "skip" at any prompt, record as "no additional info provided." - Lock all responses as the **Workflow Contract**. @@ -112,45 +136,57 @@ ### 4. WORKFLOW DESIGN (MANDATORY AGENTIC DECOMPOSITION) - Spawn Workflow Designer Agent with Workflow Contract. -- **MANDATE:** +- **MANDATE:** - Explicitly decompose into PHASES (`phase1`, `phase2`, etc.), each with agents. - For each agent: - Persona prompt at: + ``` ~/.claudebox/meta/workflows/{workflow_name}/roles/{agent}.md ``` - - Must include: - - "THINK HARD" or "ULTRATHINK" directive for deep reasoning - - **IMPORTANT:** tags for critical, non-negotiable instructions + + - Must include: + - "THINK HARD" or "ULTRATHINK" directive for deep reasoning + - **IMPORTANT:** tags for critical, non-negotiable instructions - **MANDATE:** All reasoning/critical flags are to be **propagated** to downstream roles and task files in every loop/phase. - Input file (for stateless, context-isolated execution): + ``` ~/.claudebox/outputs/{workflow_name}_{timestamp}/phase{N}/{agent}_task.md ``` + - Output file: + ``` ~/.claudebox/outputs/{workflow_name}_{timestamp}/phase{N}/{agent}_output.md ``` + - For each critic: - Persona prompt at: + ``` ~/.claudebox/meta/workflows/{workflow_name}/roles/{critic}.md ``` + - Must be **stateless**: may see ONLY the output file and workflow contract/spec. - **MANDATE:** Critic only evaluates outcome vs. end-goal/spec—not instructions or process. - Output: + ``` ~/.claudebox/outputs/{workflow_name}_{timestamp}/evaluations/phase{N}_{agent}_eval.md ``` - - **MANDATORY LOOP:** + + - **MANDATORY LOOP:** - Critic returns "APPROVE" or "ITERATE" + actionable issues. - Loop repeats, creating new output/evaluation files per iteration: + ``` ~/.claudebox/outputs/{workflow_name}_{timestamp}/phase{N}/history/{agent}_output_iter{I}.md ~/.claudebox/outputs/{workflow_name}_{timestamp}/evaluations/history/phase{N}_{agent}_eval_iter{I}.md ``` - - **IMPORTANT:** - - No upper iteration cap unless set by user/workflow contract. + + - **IMPORTANT:** + - No upper iteration cap unless set by user/workflow contract. - Default is infinite loop until critic returns "APPROVE." - Design agent must also output: @@ -180,6 +216,7 @@ - Roles: `~/.claudebox/meta/workflows/{workflow_name}/roles/{agent}.md` - Templates, phases, as needed. - Append workflow to: + ```` ~/.claudebox/meta/workflows/index.md @@ -195,7 +232,7 @@ as: ``` ✅ Created workflow: {workflow\_name} -\- Orchestrator: ~/.claude/commands/{workflow\_name}.md +\- Orchestrator: ~/.claudebox/.claude/commands/{workflow\_name}.md \- Config: ~/.claudebox/meta/workflows/{workflow\_name}/config.md \- Roles: ~/.claudebox/meta/workflows/{workflow\_name}/roles/ \- Outputs: ~/.claudebox/outputs/{workflow\_name}\_{timestamp}/ @@ -203,24 +240,27 @@ as: To launch: /project:{workflow\_name} ```` + - TERMINATE. --- ## ABSOLUTE RULES (FOR ALL AGENTS, CRITICS, ORCHESTRATOR) -- **IMPORTANT:** +- **IMPORTANT:** - No arbitrary loop limits. Infinite iterations are required unless a cap is explicitly stated in workflow contract. - All "THINK HARD", "ULTRATHINK", and "IMPORTANT" reasoning/contract tags must propagate in every new role/task prompt, phase, or agent. - Critics only judge output against end-goal/spec—never process, prior instructions, or previous critiques. - Each agent/critic must be stateless; context-limited to persona prompt + injected `` for the current round only. - All paths and outputs must match the above structure—no variation allowed. - Every round, all outputs, tasks, and evaluations are to be saved under + ``` ~/.claudebox/outputs/{workflow_name}_{timestamp}/phase{N}/ ~/.claudebox/outputs/{workflow_name}_{timestamp}/evaluations/ ``` + and their `history/` subfolders as iterations continue. - Do not pass unverified or hallucinated facts. All outputs must be reviewed and validated unless waiver is explicit. - Any SOP or contract violation halts all processes and must be reported to the user with actionable error for immediate correction. -- **NEVER allow an incomplete or unapproved workflow to reach final output or index.** \ No newline at end of file +- **NEVER allow an incomplete or unapproved workflow to reach final output or index.** diff --git a/lib/cli.sh b/lib/cli.sh index c70d574..8213c2b 100644 --- a/lib/cli.sh +++ b/lib/cli.sh @@ -9,7 +9,7 @@ # Four flag buckets (Bash 3.2 compatible - no associative arrays) readonly HOST_ONLY_FLAGS=(--verbose rebuild) readonly CONTROL_FLAGS=(--enable-sudo --disable-firewall) -readonly SCRIPT_COMMANDS=(shell create slot slots revoke profiles projects profile info help -h --help add remove install allowlist clean save project tmux kill) +readonly SCRIPT_COMMANDS=(shell create slot slots revoke profiles projects profile info help -h --help add remove install allowlist clean save project tmux kill update import unlink undo redo config mcp migrate-installer) # parse_cli_args - Central CLI parsing with four-bucket architecture # Usage: parse_cli_args "$@" @@ -20,18 +20,17 @@ 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 host_flags=() control_flags=() script_command="" pass_through=() - + # Single parsing loop - each arg goes into exactly ONE bucket local found_script_command=false - - for arg in "${all_args[@]}"; do + local arg="" + + for arg in "$@"; do if [[ " ${HOST_ONLY_FLAGS[*]} " == *" $arg "* ]]; then # Bucket 1: Host-only flags host_flags+=("$arg") @@ -47,17 +46,18 @@ parse_cli_args() { pass_through+=("$arg") fi done - + # Export results for use by main script - export CLI_HOST_FLAGS=("${host_flags[@]}") - export CLI_CONTROL_FLAGS=("${control_flags[@]}") + CLI_HOST_FLAGS=("${host_flags[@]+"${host_flags[@]}"}") + CLI_CONTROL_FLAGS=("${control_flags[@]+"${control_flags[@]}"}") export CLI_SCRIPT_COMMAND="$script_command" - export CLI_PASS_THROUGH=("${pass_through[@]}") + CLI_PASS_THROUGH=("${pass_through[@]+"${pass_through[@]}"}") } # Process host-only flags and set environment variables process_host_flags() { - for flag in "${CLI_HOST_FLAGS[@]}"; do + local flag="" + for flag in "${CLI_HOST_FLAGS[@]+"${CLI_HOST_FLAGS[@]}"}"; do case "$flag" in --verbose) export VERBOSE=true @@ -72,6 +72,83 @@ process_host_flags() { done } +_is_close_command_match() { + local left="$1" + local right="$2" + local left_len=${#left} + local right_len=${#right} + local len_diff=$((left_len - right_len)) + local edits=0 + local left_index=0 + local right_index=0 + + if [[ "$left" == "$right" ]]; then + return 0 + fi + + if (( len_diff < 0 )); then + len_diff=$(( -len_diff )) + fi + if (( len_diff > 1 )); then + return 1 + fi + + while (( left_index < left_len && right_index < right_len )); do + if [[ "${left:$left_index:1}" == "${right:$right_index:1}" ]]; then + ((left_index++)) + ((right_index++)) + continue + fi + + ((edits++)) + if (( edits > 1 )); then + return 1 + fi + + if (( left_len == right_len )); then + ((left_index++)) + ((right_index++)) + elif (( left_len > right_len )); then + ((left_index++)) + else + ((right_index++)) + fi + done + + if (( left_index < left_len || right_index < right_len )); then + ((edits++)) + fi + + (( edits <= 1 )) +} + +get_script_command_suggestion() { + local input="$1" + local normalized_input=$(printf '%s' "$input" | tr '[:upper:]' '[:lower:]') + local candidate="" + local normalized_candidate="" + + if [[ -z "$normalized_input" ]] || [[ "$normalized_input" == -* ]]; then + return 1 + fi + + for candidate in "${SCRIPT_COMMANDS[@]+"${SCRIPT_COMMANDS[@]}"}"; do + case "$candidate" in + -h|--help) + continue + ;; + esac + + normalized_candidate=$(printf '%s' "$candidate" | tr '[:upper:]' '[:lower:]') + if _is_close_command_match "$normalized_input" "$normalized_candidate"; then + printf '%s' "$candidate" + return 0 + fi + done + + return 1 +} + # Get command requirements - returns one of: # "none" - pure host command, no Docker or image needed # "image" - needs image name but not Docker running @@ -79,10 +156,10 @@ process_host_flags() { get_command_requirements() { local cmd="${1:-}" local subcommand="${2:-}" - + case "$cmd" in # Pure host commands - no Docker or image needed - profiles|projects|help|-h|--help|slots|create|revoke|clean|import|unlink|kill) + profiles|projects|help|-h|--help|slots|create|revoke|clean|import|unlink|kill|undo|redo) echo "none" ;; # Commands that need image name but not Docker @@ -110,7 +187,7 @@ requires_docker_image() { # Check if current command requires a slot requires_slot() { local cmd="${1:-}" - + # Commands that need a slot case "$cmd" in shell|update|config|mcp|migrate-installer|create|slot|"") @@ -126,12 +203,12 @@ requires_slot() { 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] 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 + echo "[DEBUG] Pass-through: ${CLI_PASS_THROUGH[*]-}" >&2 fi } # Export all functions -export -f parse_cli_args process_host_flags get_command_requirements requires_docker_image requires_slot debug_parsed_args \ No newline at end of file +export -f parse_cli_args process_host_flags _is_close_command_match get_script_command_suggestion get_command_requirements requires_docker_image requires_slot debug_parsed_args \ No newline at end of file diff --git a/lib/commands.core.sh b/lib/commands.core.sh index 08d97bc..be3109a 100644 --- a/lib/commands.core.sh +++ b/lib/commands.core.sh @@ -12,10 +12,10 @@ _cmd_help() { init_project_dir "$PROJECT_DIR" IMAGE_NAME=$(get_image_name 2>/dev/null || echo "") fi - + # Check for subcommands local subcommand="${1:-}" - + case "$subcommand" in "full") show_full_help @@ -27,7 +27,7 @@ _cmd_help() { # Default behavior - check if we have project and show appropriate help local project_folder_name project_folder_name=$(get_project_folder_name "$PROJECT_DIR" 2>/dev/null || echo "NONE") - + if [[ "$project_folder_name" != "NONE" ]] && [[ -n "${IMAGE_NAME:-}" ]] && docker image inspect "$IMAGE_NAME" &>/dev/null; then # In project directory with image - show Claude help show_claude_help @@ -41,7 +41,7 @@ _cmd_help() { show_help ;; esac - + exit 0 } @@ -49,29 +49,29 @@ _cmd_shell() { if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] _cmd_shell called with args: $*" >&2 fi - + # Set up slot variables if not already set if [[ -z "${IMAGE_NAME:-}" ]]; then local project_folder_name project_folder_name=$(get_project_folder_name "$PROJECT_DIR" 2>/dev/null || echo "NONE") - + if [[ "$project_folder_name" == "NONE" ]]; then show_no_slots_menu # This will exit fi - + IMAGE_NAME=$(get_image_name) PROJECT_SLOT_DIR="$PROJECT_PARENT_DIR/$project_folder_name" export PROJECT_SLOT_DIR fi - + # Check if image exists if ! docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then error "No Docker image found for this project.\nRun 'claudebox' first to build the image." fi - + local persist_mode=false local shell_flags=() - + # Check if first arg is "admin" if [[ "${1:-}" == "admin" ]]; then persist_mode=true @@ -79,7 +79,7 @@ _cmd_shell() { # In admin mode, automatically enable sudo and disable firewall shell_flags+=("--enable-sudo" "--disable-firewall") fi - + # Process remaining flags (only for non-persist mode) while [[ $# -gt 0 ]]; do case "$1" in @@ -94,31 +94,31 @@ _cmd_shell() { ;; esac done - + # Run container for shell if [[ "$persist_mode" == "true" ]]; then cecho "Administration Mode" "$YELLOW" echo "Sudo enabled, firewall disabled." echo "Changes will be saved to the image when you exit." echo - + # Create a named container for admin mode so we can commit it local temp_container="claudebox-admin-$$" - + # Ensure cleanup runs on any exit (including Ctrl-C) cleanup_admin() { docker commit "$temp_container" "$IMAGE_NAME" >/dev/null 2>&1 docker rm -f "$temp_container" >/dev/null 2>&1 } trap cleanup_admin EXIT - + if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] Running admin container with flags: ${shell_flags[*]}" >&2 echo "[DEBUG] Remaining args after processing: $*" >&2 fi # Don't pass any remaining arguments - only shell and the flags run_claudebox_container "$temp_container" "interactive" shell "${shell_flags[@]}" - + # Commit changes back to image fillbar docker commit "$temp_container" "$IMAGE_NAME" >/dev/null @@ -129,7 +129,7 @@ _cmd_shell() { # Regular shell mode - just run without committing run_claudebox_container "" "interactive" shell "${shell_flags[@]}" fi - + exit 0 } @@ -138,7 +138,7 @@ _cmd_update() { if [[ "${1:-}" == "all" ]]; then info "Updating all components..." echo - + # Update claudebox script info "Updating claudebox script..." if command -v curl >/dev/null 2>&1; then @@ -148,11 +148,11 @@ _cmd_update() { else error "Neither curl nor wget found" fi - + if [[ -f /tmp/claudebox.new ]]; then # Find the installed claudebox (not the source) local installed_path=$(which claudebox 2>/dev/null || echo "/usr/local/bin/claudebox") - + # If it's a symlink, replace it with the actual file first if [[ -L "$installed_path" ]]; then info "Converting symlink to real file..." @@ -167,21 +167,21 @@ _cmd_update() { sudo chmod +x "$installed_path" fi fi - + # Compare hashes of the INSTALLED file current_hash=$(crc32_file "$installed_path" || echo "none") new_hash=$(crc32_file /tmp/claudebox.new) - + if [[ "$current_hash" != "$new_hash" ]]; then info "New version available, updating..." - + # Backup current installed version local backups_dir="$HOME/.claudebox/backups" mkdir -p "$backups_dir" local timestamp=$(date +%s) cp "$installed_path" "$backups_dir/$timestamp" info "Backed up current version to $backups_dir/$timestamp" - + # Update the INSTALLED file if [[ -w "$installed_path" ]] || [[ -w "$(dirname "$installed_path")" ]]; then cp /tmp/claudebox.new "$installed_path" @@ -197,12 +197,12 @@ _cmd_update() { rm -f /tmp/claudebox.new fi echo - + # Update commands info "Updating commands..." - local commands_dir="$HOME/.claudebox/commands" + local commands_dir="$CLAUDEBOX_GLOBAL_CLAUDE_COMMANDS_DIR" mkdir -p "$commands_dir" - + for cmd in taskengine devops; do echo -n " Updating $cmd.md... " if command -v curl >/dev/null 2>&1; then @@ -213,19 +213,19 @@ _cmd_update() { echo "✓" done echo - + # Now update Claude info "Updating Claude..." shift # Remove "update" shift # Remove "all" set -- "update" "$@" # Put back just "update" fi - + # Check if image exists first if ! docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then error "No Docker image found for this project folder: $PROJECT_DIR\nRun 'claudebox' first to build the image, or cd to your project directory." fi - + # Continue with normal update flow _cmd_special "update" "$@" } diff --git a/lib/commands.info.sh b/lib/commands.info.sh index e325288..190144b 100644 --- a/lib/commands.info.sh +++ b/lib/commands.info.sh @@ -108,8 +108,8 @@ _cmd_info() { # Claude Commands cecho "📝 Claude Commands" "$WHITE" local cmd_count=0 - if [[ -d "$HOME/.claude/commands" ]]; then - cmd_count=$(ls -1 "$HOME/.claude/commands"/*.md 2>/dev/null | wc -l) + if [[ -d "$CLAUDEBOX_GLOBAL_CLAUDE_COMMANDS_DIR" ]]; then + cmd_count=$(ls -1 "$CLAUDEBOX_GLOBAL_CLAUDE_COMMANDS_DIR"/*.md 2>/dev/null | wc -l) fi local project_cmd_count=0 if [[ -e "$PROJECT_PARENT_DIR/commands" ]]; then @@ -118,8 +118,8 @@ _cmd_info() { if [[ $cmd_count -gt 0 ]] || [[ $project_cmd_count -gt 0 ]]; then echo " Host: $cmd_count command(s)" - if [[ $cmd_count -gt 0 ]] && [[ -d "$HOME/.claude/commands" ]]; then - for cmd_file in "$HOME/.claude/commands"/*.md; do + if [[ $cmd_count -gt 0 ]] && [[ -d "$CLAUDEBOX_GLOBAL_CLAUDE_COMMANDS_DIR" ]]; then + for cmd_file in "$CLAUDEBOX_GLOBAL_CLAUDE_COMMANDS_DIR"/*.md; do [[ -f "$cmd_file" ]] || continue echo " - $(basename "$cmd_file" .md)" done @@ -133,7 +133,7 @@ _cmd_info() { fi else echo -e " ${YELLOW}No custom commands found${NC}" - echo -e " Location: ~/.claude/commands/ (host), project/commands/ (shared)" + echo -e " Location: ~/.claudebox/.claude/commands/ (host), project/commands/ (shared)" fi echo diff --git a/lib/commands.profile.sh b/lib/commands.profile.sh index 462d5d4..de43e38 100644 --- a/lib/commands.profile.sh +++ b/lib/commands.profile.sh @@ -7,24 +7,25 @@ _cmd_profiles() { # Get current profiles local current_profiles=($(get_current_profiles)) - + local enabled="" + # Show logo first logo_small printf '\n' - + # Show commands at the top printf '%s\n' "Commands:" printf " ${CYAN}claudebox add ${NC} - Add development profiles to your project\n" printf " ${CYAN}claudebox remove ${NC} - Remove profiles from your project\n" printf '\n' - + # Show currently enabled profiles if [[ ${#current_profiles[@]} -gt 0 ]]; then cecho "Currently enabled:" "$YELLOW" printf " %s\n" "${current_profiles[*]}" printf '\n' fi - + # Show available profiles cecho "Available profiles:" "$CYAN" printf '\n' @@ -32,7 +33,7 @@ _cmd_profiles() { local desc=$(get_profile_description "$profile") local is_enabled=false # Check if profile is currently enabled - for enabled in "${current_profiles[@]}"; do + for enabled in "${current_profiles[@]+"${current_profiles[@]}"}"; do if [[ "$enabled" == "$profile" ]]; then is_enabled=true break @@ -58,7 +59,7 @@ _cmd_profile() { echo echo -e " ${GREEN}profiles${NC} Show all available profiles" echo -e " ${GREEN}add ${NC} Add development profiles" - echo -e " ${GREEN}remove ${NC} Remove development profiles" + echo -e " ${GREEN}remove ${NC} Remove development profiles" echo -e " ${GREEN}add status${NC} Show current project's profiles" echo cecho "Examples:" "$YELLOW" @@ -115,7 +116,7 @@ _cmd_add() { remaining=("$@") break fi - + if profile_exists "$1"; then selected+=("$1") shift @@ -141,7 +142,7 @@ _cmd_add() { cecho "All active profiles: ${all_profiles[*]}" "$GREEN" fi echo - + # Check if any Python-related profiles were added local python_profiles_added=false for profile in "${selected[@]}"; do @@ -150,7 +151,7 @@ _cmd_add() { break fi done - + # If Python profiles were added, remove the pydev flag to trigger reinstall if [[ "$python_profiles_added" == "true" ]]; then local parent_dir=$(get_parent_dir "$PROJECT_DIR") @@ -159,7 +160,7 @@ _cmd_add() { info "Python packages will be updated on next run" fi fi - + # Only show rebuild message for non-Python profiles local needs_rebuild=false for profile in "${selected[@]}"; do @@ -168,7 +169,7 @@ _cmd_add() { break fi done - + if [[ "$needs_rebuild" == "true" ]]; then warn "The Docker image will be rebuilt with new profiles on next run." fi @@ -213,7 +214,7 @@ _cmd_remove() { if [[ "$1" == -* ]]; then break fi - + if profile_exists "$1"; then to_remove+=("$1") shift @@ -229,9 +230,10 @@ _cmd_remove() { # Remove specified profiles local new_profiles=() local python_profiles_removed=false - for profile in "${current_profiles[@]}"; do + local remove="" + for profile in "${current_profiles[@]+"${current_profiles[@]}"}"; do local keep=true - for remove in "${to_remove[@]}"; do + for remove in "${to_remove[@]+"${to_remove[@]}"}"; do if [[ "$profile" == "$remove" ]]; then keep=false # Check if we're removing a Python-related profile @@ -243,21 +245,21 @@ _cmd_remove() { done [[ "$keep" == "true" ]] && new_profiles+=("$profile") done - + # Check if any Python-related profiles remain local has_python_profiles=false - for profile in "${new_profiles[@]}"; do + for profile in "${new_profiles[@]+"${new_profiles[@]}"}"; do if [[ "$profile" == "python" ]] || [[ "$profile" == "ml" ]] || [[ "$profile" == "datascience" ]]; then has_python_profiles=true break fi done - + # If we removed Python profiles and no Python profiles remain, clean up Python flags if [[ "$python_profiles_removed" == "true" ]] && [[ "$has_python_profiles" == "false" ]]; then init_project_dir "$PROJECT_DIR" PROJECT_PARENT_DIR=$(get_parent_dir "$PROJECT_DIR") - + # Remove Python flags and venv folder if they exist if [[ -f "$PROJECT_PARENT_DIR/.venv_flag" ]]; then rm -f "$PROJECT_PARENT_DIR/.venv_flag" @@ -268,18 +270,18 @@ _cmd_remove() { if [[ -d "$PROJECT_PARENT_DIR/.venv" ]]; then rm -rf "$PROJECT_PARENT_DIR/.venv" fi - + cecho "Cleaned up Python environment flags and venv folder" "$YELLOW" fi # Write back the filtered profiles { echo "[profiles]" - for profile in "${new_profiles[@]}"; do + for profile in "${new_profiles[@]+"${new_profiles[@]}"}"; do echo "$profile" done echo "" - + # Preserve packages section if it exists if [[ -f "$profile_file" ]] && grep -q "^\[packages\]" "$profile_file"; then echo "[packages]" diff --git a/lib/commands.system.sh b/lib/commands.system.sh index 75f7f7f..22b8f52 100644 --- a/lib/commands.system.sh +++ b/lib/commands.system.sh @@ -36,7 +36,7 @@ _cmd_unlink() { _cmd_rebuild() { # Set rebuild flag and continue with normal execution export REBUILD=true - + # Remove 'rebuild' from the arguments and continue # This allows "claudebox rebuild" to rebuild then launch Claude # or "claudebox rebuild shell" to rebuild then open shell @@ -46,11 +46,11 @@ _cmd_rebuild() { _cmd_kill() { local target="${1:-}" local killed_containers=0 - + if [[ "$target" == "all" ]]; then # Kill ALL claudebox containers info "Killing all ClaudeBox containers..." - + local containers=$(docker ps --filter "name=^claudebox-" --format "{{.Names}}") if [[ -n "$containers" ]]; then while IFS= read -r container; do @@ -65,7 +65,7 @@ _cmd_kill() { elif [[ -n "$target" ]]; then # Kill specific container by hash or name local matching_container="" - + # Check if it's a hash (8 hex chars) if [[ "$target" =~ ^[a-f0-9]{8}$ ]]; then matching_container=$(docker ps --filter "name=claudebox-.*-$target$" --format "{{.Names}}" | head -1) @@ -73,7 +73,7 @@ _cmd_kill() { # Try partial name match matching_container=$(docker ps --filter "name=claudebox-.*$target" --format "{{.Names}}" | head -1) fi - + if [[ -n "$matching_container" ]]; then info "Killing container: $matching_container" if docker stop "$matching_container" >/dev/null 2>&1; then @@ -87,17 +87,17 @@ _cmd_kill() { else # No argument - show active containers local containers=$(docker ps --filter "name=^claudebox-" --format "{{.Names}}") - + if [[ -z "$containers" ]]; then info "No active ClaudeBox containers" exit 0 fi - + logo_small echo cecho "Active ClaudeBox Containers" "$CYAN" echo - + echo "$containers" | while IFS= read -r container; do local slot_hash=${container##*-} local project_part=${container#claudebox-} @@ -105,14 +105,14 @@ _cmd_kill() { echo " $slot_hash - $project_part" done echo - + echo "Usage:" printf " %-25s %s\n" "claudebox kill " "Kill specific container" printf " %-25s %s\n" "claudebox kill " "Kill by partial name" printf " %-25s %s\n" "claudebox kill all" "Kill ALL containers" echo fi - + exit 0 } @@ -123,21 +123,21 @@ _cmd_tmux() { echo cecho "Tmux Integration for ClaudeBox" "$CYAN" echo - + # Check available slots - derive project info like clean does local available_count=0 local authenticated_count=0 - + # Get project folder name for current directory local project_folder_name=$(generate_parent_folder_name "$PROJECT_DIR" 2>/dev/null || echo "") local parent_dir="$HOME/.claudebox/projects/$project_folder_name" - + if [[ -n "$project_folder_name" ]] && [[ -d "$parent_dir" ]]; then local max_slot=$(read_counter "$parent_dir" 2>/dev/null || echo "0") for ((idx=1; idx<=max_slot; idx++)); do local slot_name=$(generate_container_name "$PROJECT_DIR" "$idx") local slot_dir="$parent_dir/$slot_name" - + if [[ -d "$slot_dir" ]]; then ((available_count++)) || true if [[ -f "$slot_dir/.claude/.credentials.json" ]]; then @@ -146,19 +146,19 @@ _cmd_tmux() { fi done fi - + cecho "Available Slots:" "$GREEN" printf " You have %d slot(s) (%d authenticated)\n" "$available_count" "$authenticated_count" echo printf " %-20s %s\n" "claudebox slots" "Manage slots for this project" echo - + echo "Usage:" printf " %-20s %s\n" "tmux " "Launch N panes (e.g., tmux 3 for 3 panes)" printf " %-20s %s\n" "tmux 2 1" "Multiple windows (2 panes in window 1, 1 pane in window 2)" printf " %-20s %s\n" "tmux conf" "Install tmux configuration" echo - + cecho "Tmux Shortcuts (after tmux conf):" "$GREEN" echo " • Ctrl+Alt+Arrow: Navigate panes" echo " • Ctrl+Alt+0: Zoom toggle" @@ -166,27 +166,27 @@ _cmd_tmux() { echo exit 0 fi - + # Handle tmux subcommands if [[ "${1:-}" == "conf" ]]; then _install_tmux_conf exit 0 fi - + if [[ "${1:-}" == "kill" ]]; then # If a session name is provided as an argument, use it # Otherwise, try to use current directory's project name local session_arg="${2:-}" local killed_containers=0 local killed_sessions=0 - + if [[ "$session_arg" == "all" ]]; then # Kill ALL - the dangerous command is explicit info "Killing ALL ClaudeBox tmux sessions and containers..." - + # Get all claudebox tmux sessions local sessions=$(tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^claudebox-" || true) - + if [[ -n "$sessions" ]]; then while IFS= read -r session; do if [[ -n "$session" ]]; then @@ -194,7 +194,7 @@ _cmd_tmux() { fi done <<< "$sessions" fi - + # Kill ALL claudebox containers local containers=$(docker ps --filter "name=^claudebox-" --format "{{.Names}}") if [[ -n "$containers" ]]; then @@ -204,7 +204,7 @@ _cmd_tmux() { fi done <<< "$containers" fi - + if [[ $killed_sessions -gt 0 ]] || [[ $killed_containers -gt 0 ]]; then if [[ $killed_sessions -gt 0 ]]; then success "Killed $killed_sessions tmux session(s)" @@ -221,24 +221,24 @@ _cmd_tmux() { # Show menu of active sessions local sessions=$(tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^claudebox-" || true) local containers=$(docker ps --filter "name=^claudebox-" --format "{{.Names}}") - + if [[ -z "$sessions" ]] && [[ -z "$containers" ]]; then info "No active ClaudeBox sessions or containers" exit 0 fi - + logo_small echo cecho "ClaudeBox Active Sessions" "$CYAN" echo - + if [[ -n "$sessions" ]]; then cecho "Tmux Sessions:" "$GREEN" echo "$sessions" | while IFS= read -r session; do # Extract project name from session local proj_name=${session#claudebox-} echo " $session" - + # Show containers for this session local session_containers=$(echo "$containers" | grep "^$session-" || true) if [[ -n "$session_containers" ]]; then @@ -250,7 +250,7 @@ _cmd_tmux() { done echo fi - + # Show orphaned containers (no tmux session) local orphans="" if [[ -n "$containers" ]] && [[ -n "$sessions" ]]; then @@ -261,7 +261,7 @@ _cmd_tmux() { # No sessions, all containers are orphans orphans="$containers" fi - + if [[ -n "$orphans" ]]; then cecho "Orphaned Containers (no tmux session):" "$YELLOW" echo "$orphans" | while IFS= read -r container; do @@ -269,26 +269,26 @@ _cmd_tmux() { done echo fi - + echo "Usage:" printf " %-30s %s\n" "claudebox tmux kill " "Kill specific session/container" printf " %-30s %s\n" "claudebox tmux kill " "Kill specific container by hash" printf " %-30s %s\n" "claudebox tmux kill all" "Kill ALL sessions and containers" echo - + exit 0 elif [[ -n "$session_arg" ]]; then # Check if this looks like a container hash (8 hex chars) if [[ "$session_arg" =~ ^[a-f0-9]{8}$ ]]; then # This is a container hash - kill just that container (Lost Boys child rule) local matching_container=$(docker ps --filter "name=claudebox-.*-$session_arg$" --format "{{.Names}}" | head -1) - + if [[ -n "$matching_container" ]]; then info "Killing container: $matching_container" if docker stop "$matching_container" >/dev/null 2>&1; then ((killed_containers++)) || true success "Killed container: $matching_container" - + # Find which pane has this container and kill just that pane # This is tricky - for now just kill the container info "Note: Tmux pane may still be visible but container is stopped" @@ -304,7 +304,7 @@ _cmd_tmux() { if [[ -n "$matching_sessions" ]]; then match_count=$(echo "$matching_sessions" | wc -l | tr -d ' ') fi - + if [[ $match_count -eq 0 ]]; then # Try exact match with claudebox- prefix if tmux has-session -t "claudebox-$session_arg" 2>/dev/null; then @@ -324,10 +324,10 @@ _cmd_tmux() { echo "Please be more specific." exit 1 fi - + # Single match - kill parent and all children local session_name="$matching_sessions" - + # Kill ALL containers for this session (all children die with parent) local containers=$(docker ps --filter "name=^$session_name-" --format "{{.Names}}") if [[ -n "$containers" ]]; then @@ -338,7 +338,7 @@ _cmd_tmux() { fi done <<< "$containers" fi - + # Kill the tmux session (parent dies) if tmux has-session -t "$session_name" 2>/dev/null; then tmux kill-session -t "$session_name" @@ -348,7 +348,7 @@ _cmd_tmux() { fi fi # This else block should never be reached now - + # Report results if [[ $killed_sessions -gt 0 ]] || [[ $killed_containers -gt 0 ]]; then if [[ $killed_sessions -gt 0 ]]; then @@ -360,10 +360,10 @@ _cmd_tmux() { else info "No ClaudeBox sessions or containers found" fi - + exit 0 fi - + # Check if tmux is installed on the host if ! command -v tmux >/dev/null 2>&1; then error "tmux is not installed on the host system. @@ -372,14 +372,14 @@ Please install tmux first: macOS: brew install tmux RHEL/CentOS: sudo yum install tmux" fi - + # Let Claude Code manage pane names automatically - + # Parse layout parameter if provided local layout="${1:-}" local total_slots_needed=1 local window_configs=() - + # Collect all numeric arguments for layout local window_panes=() for arg in "$@"; do @@ -390,41 +390,41 @@ Please install tmux first: break fi done - + # Check if we're in a valid project directory for layout commands if [[ ${#window_panes[@]} -gt 0 ]] || [[ -n "$layout" ]]; then # We need slots for layouts, so check if this is a valid project local project_folder_name project_folder_name=$(get_project_folder_name "$PROJECT_DIR" 2>/dev/null || echo "NONE") - + if [[ "$project_folder_name" == "NONE" ]]; then error "Tmux layouts require a valid project directory. Please cd to your project directory first. Current directory: $PWD" fi fi - + # Set up slot variables first - we need PROJECT_PARENT_DIR for slot validation if [[ -z "${IMAGE_NAME:-}" ]]; then local project_folder_name project_folder_name=$(get_project_folder_name "$PROJECT_DIR" 2>/dev/null || echo "NONE") - + if [[ "$project_folder_name" == "NONE" ]]; then show_no_slots_menu # This will exit fi - + IMAGE_NAME=$(get_image_name) PROJECT_SLOT_DIR="$PROJECT_PARENT_DIR/$project_folder_name" export PROJECT_SLOT_DIR fi - + if [[ ${#window_panes[@]} -gt 0 ]]; then # Calculate total slots needed total_slots_needed=0 for panes in "${window_panes[@]}"; do ((total_slots_needed += panes)) || true done - + # Special case: single "1" means just run regular tmux if [[ ${#window_panes[@]} -eq 1 ]] && [[ "${window_panes[0]}" == "1" ]]; then layout="" @@ -435,13 +435,13 @@ Current directory: $PWD" else layout="" fi - - + + # Generate container name local slot_name=$(basename "$PROJECT_SLOT_DIR") local parent_folder_name=$(generate_parent_folder_name "$PROJECT_DIR") local container_name="claudebox-${parent_folder_name}-${slot_name}" - + # Check if we're already in a tmux session if [[ -n "${TMUX:-}" ]]; then info "Already in a tmux session. Running ClaudeBox directly..." @@ -450,30 +450,30 @@ Current directory: $PWD" else # Create new tmux session with layout if specified if [[ -n "$layout" ]]; then - + # Get available slots (not currently running) local available_slots=() local max_slot=$(read_counter "$PROJECT_PARENT_DIR") - + for ((idx=1; idx<=max_slot; idx++)); do local slot_name=$(generate_container_name "$PROJECT_DIR" "$idx") local slot_dir="$PROJECT_PARENT_DIR/$slot_name" - + # Check if slot exists and is not currently running if [[ -d "$slot_dir" ]] && ! docker ps --format "{{.Names}}" | grep -q "^claudebox-.*-${slot_name}$"; then available_slots+=("$idx") fi done - + # Initialize slot_index for all layout types local slot_index=0 - + # Simple layout - use quick tmux without persistent session if [[ "$layout" =~ ^[0-9]+$ ]] && [[ $layout -le 4 ]]; then # For simple layouts (1-4 panes), create non-persistent session local session_name="claudebox-$(basename "$PROJECT_DIR")" local captured_panes=() - + # Create first pane and capture its ID atomically local first_slot="${available_slots[0]}" # new-session doesn't support -P -F, so we create it and get the pane ID immediately after @@ -481,53 +481,53 @@ Current directory: $PWD" error "Failed to create tmux session. Make sure slot $first_slot exists." fi tmux rename-window -t "$session_name" 'ClaudeBox Multi' - + # Get the first pane ID (session creation always creates exactly one pane) local first_pane_id=$(tmux display -t "$session_name:0.0" -p '#{pane_id}') captured_panes+=("$first_pane_id") - + if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] Created first pane: $first_pane_id" >&2 fi - + # Create additional panes one by one and capture their IDs atomically local slot_index=1 for ((i=1; i<$layout; i++)); do local slot="${available_slots[$slot_index]}" - + # Split and create new pane - capture ID atomically with -P -F local new_pane_id=$(tmux split-window -t "$session_name" -e "CLAUDEBOX_SLOT_NUMBER=$slot" -P -F '#{pane_id}' "$SCRIPT_PATH slot $slot") - + captured_panes+=("$new_pane_id") if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] Created pane $((i+1)): $new_pane_id" >&2 fi - + ((slot_index++)) || true done - + # Enable pane border status tmux set-option -t "$session_name" -g pane-border-status top - + # Add tiled layout if more than 2 panes if [[ $layout -gt 2 ]]; then tmux select-layout -t "$session_name" tiled fi - + # Wait for containers and Claude to be ready sleep 8 - + # Send activate command only to authenticated slots if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] Checking authentication for ${#captured_panes[@]} panes" >&2 fi - + for ((i=0; i<${#captured_panes[@]}; i++)); do local pane_id="${captured_panes[$i]}" local slot_num="${available_slots[$i]}" local slot_name=$(generate_container_name "$PROJECT_DIR" "$slot_num") local slot_dir="$PROJECT_PARENT_DIR/$slot_name" - + # Check if this slot is authenticated if [[ -f "$slot_dir/.claude/.credentials.json" ]]; then if [[ "$VERBOSE" == "true" ]]; then @@ -544,10 +544,10 @@ Current directory: $PWD" fi fi done - + # Attach to the session tmux attach-session -t "$session_name" - + exit 0 else @@ -556,55 +556,55 @@ Current directory: $PWD" local captured_panes=() local slot_index=0 local first_created=false - + # Create panes one by one, capturing IDs atomically for panes in "${window_panes[@]}"; do for ((i=0; i&2 fi - + first_created=true else # Split and create new pane - capture ID atomically with -P -F local new_pane_id=$(tmux split-window -t "$session_name" -e "CLAUDEBOX_SLOT_NUMBER=$slot" -P -F '#{pane_id}' "$SCRIPT_PATH slot $slot") - + captured_panes+=("$new_pane_id") if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] Created pane $((${#captured_panes[@]})): $new_pane_id" >&2 fi fi - + ((slot_index++)) || true done done - + # Enable pane border status tmux set-option -t "$session_name" -g pane-border-status top - + # Add tiled layout if more than 2 panes if [[ ${#captured_panes[@]} -gt 2 ]]; then tmux select-layout -t "$session_name" tiled fi - + # Wait for containers to start sleep 2 - + # Wait for all slots to have running containers (like slots command does) local all_ready=false local wait_count=0 @@ -613,14 +613,14 @@ Current directory: $PWD" for ((idx=0; idx<$slot_index; idx++)); do local slot="${available_slots[$idx]}" local slot_name=$(generate_container_name "$PROJECT_DIR" "$slot") - + # Check if container is running - exactly like slots command if ! docker ps --format "{{.Names}}" | grep -q "^claudebox-.*-${slot_name}$"; then all_ready=false break fi done - + if [[ "$all_ready" == "false" ]]; then sleep 0.5 ((wait_count++)) || true @@ -629,18 +629,18 @@ Current directory: $PWD" # Additional wait to ensure Claude is fully started inside containers sleep 6 - + # Send activate command only to authenticated slots if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] Checking authentication for ${#captured_panes[@]} panes" >&2 fi - + for ((i=0; i<${#captured_panes[@]}; i++)); do local pane_id="${captured_panes[$i]}" local slot_num="${available_slots[$i]}" local slot_name=$(generate_container_name "$PROJECT_DIR" "$slot_num") local slot_dir="$PROJECT_PARENT_DIR/$slot_name" - + # Check if this slot is authenticated if [[ -f "$slot_dir/.claude/.credentials.json" ]]; then if [[ "$VERBOSE" == "true" ]]; then @@ -657,10 +657,10 @@ Current directory: $PWD" fi fi done - + # Attach to the session tmux attach-session -t "$session_name" - + exit 0 fi else @@ -668,29 +668,29 @@ Current directory: $PWD" exec tmux new-session "$SCRIPT_PATH" "$@" fi fi - + exit 0 } _cmd_project() { local search="${1:-}" shift || true - + if [[ -z "$search" ]]; then error "Usage: claudebox project [command...]\nExample: claudebox project myproject\nExample: claudebox project cc618e36 shell" fi - + # Convert search to lowercase for case-insensitive matching local search_lower=$(echo "$search" | tr '[:upper:]' '[:lower:]') local matches=() - + # Search through all project directories for parent_dir in "$HOME/.claudebox/projects"/*/ ; do [[ -d "$parent_dir" ]] || continue - + local dir_name=$(basename "$parent_dir") local dir_lower=$(echo "$dir_name" | tr '[:upper:]' '[:lower:]') - + # Check if search matches directory name (partial match) if [[ "$dir_lower" == *"$search_lower"* ]]; then # Read the actual project path @@ -700,7 +700,7 @@ _cmd_project() { fi fi done - + # Handle results if [ ${#matches[@]} -eq 0 ]; then error "No projects found matching '$search'" @@ -708,11 +708,11 @@ _cmd_project() { # Single match - use it local project_path="${matches[0]%%|*}" local project_name="${matches[0]##*|}" - + #info "Opening project: $project_name" #info "Path: $project_path" #echo - + # Just run claudebox with PROJECT_DIR set to the target project # No need to change directories at all! if [[ $# -eq 0 ]]; then @@ -740,19 +740,19 @@ _cmd_project() { _cmd_special() { local cmd="$1" shift - + # Check if image exists first (for non-update commands) if [[ "$cmd" != "update" ]] && ! docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then error "No Docker image found for this project folder: $PROJECT_DIR\nRun 'claudebox' first to build the image, or cd to your project directory." fi - + # Create temporary container local project_folder_name=$(get_project_folder_name "$PROJECT_DIR") local temp_container="claudebox-temp-${project_folder_name}-$$" - + # Run container with all arguments passed through run_claudebox_container "$temp_container" "detached" "$cmd" "$@" >/dev/null - + # Show progress while waiting if [[ "$cmd" == "update" ]]; then # Show hint during update @@ -762,15 +762,15 @@ _cmd_special() { echo fi fillbar - + # Wait for container to finish docker wait "$temp_container" >/dev/null - + fillbar stop - + # Show container output for commands that produce output docker logs "$temp_container" 2>&1 - + # For update command, show version after update if [[ "$cmd" == "update" ]]; then docker exec -u "$DOCKER_USER" "$temp_container" bash -c " @@ -782,33 +782,33 @@ _cmd_special() { docker commit "$temp_container" "$IMAGE_NAME" >/dev/null docker stop "$temp_container" >/dev/null 2>&1 || true docker rm "$temp_container" >/dev/null 2>&1 || true - + exit 0 } _cmd_import() { - local host_commands="$HOME/.claude/commands" + local host_commands="$CLAUDEBOX_GLOBAL_CLAUDE_COMMANDS_DIR" local parent_dir=$(get_parent_dir "$PROJECT_DIR") local project_commands="$parent_dir/commands" - + # Check if host commands directory exists if [[ ! -d "$host_commands" ]]; then warn "No commands found at $host_commands" - info "Create markdown files in ~/.claude/commands to use with Claude" + info "Create markdown files in ~/.claudebox/.claude/commands to use with Claude" return 1 fi - + # List available commands local commands=() while IFS= read -r -d '' file; do commands+=("$(basename "$file")") done < <(find "$host_commands" -maxdepth 1 -name "*.md" -type f -print0 | sort -z) - + if [[ ${#commands[@]} -eq 0 ]]; then warn "No markdown command files found in $host_commands" return 1 fi - + # Show available commands cecho "Available commands to import:" "$CYAN" echo @@ -820,10 +820,10 @@ _cmd_import() { echo printf " %2s. %s\n" "a" "Import all commands" echo - + # Get user selection read -p "Select command(s) to import (number, 'a' for all, or 'q' to quit): " selection - + case "$selection" in q|Q) info "Import cancelled" @@ -856,7 +856,7 @@ _cmd_import() { error "Invalid selection: $selection" ;; esac - + # Show current project commands echo info "Current project commands:" @@ -866,12 +866,12 @@ _cmd_import() { _install_tmux_conf() { local tmux_conf_template="${SCRIPT_DIR}/templates/tmux.conf" local user_tmux_conf="$HOME/.tmux.conf" - + # Check if template exists if [[ ! -f "$tmux_conf_template" ]]; then error "tmux configuration template not found at: $tmux_conf_template" fi - + # Check if tmux is installed if ! command -v tmux >/dev/null 2>&1; then warn "tmux is not installed on your system." @@ -887,12 +887,12 @@ _install_tmux_conf() { return 0 fi fi - + # Backup existing config if it exists if [[ -f "$user_tmux_conf" ]]; then local timestamp=$(date +%Y%m%d_%H%M%S) local backup_file="$user_tmux_conf.backup_$timestamp" - + info "Backing up existing tmux configuration..." if cp "$user_tmux_conf" "$backup_file"; then success "Backed up to: $backup_file" @@ -900,7 +900,7 @@ _install_tmux_conf() { error "Failed to backup existing configuration" fi fi - + # Install new configuration info "Installing ClaudeBox tmux configuration..." if cp "$tmux_conf_template" "$user_tmux_conf"; then @@ -914,7 +914,7 @@ _install_tmux_conf() { echo " • Session persistence with tmux-resurrect" echo " • System clipboard integration" echo - + # Check if TPM is installed if [[ ! -d "$HOME/.tmux/plugins/tpm" ]]; then cecho "Note: Tmux Plugin Manager (TPM) not found." "$YELLOW" @@ -926,7 +926,7 @@ _install_tmux_conf() { else cecho "TPM detected. Press Prefix + I inside tmux to install/update plugins." "$GREEN" fi - + # Reload tmux if running if [[ -n "${TMUX:-}" ]]; then echo diff --git a/lib/config.sh b/lib/config.sh index 640211a..7dbce6d 100755 --- a/lib/config.sh +++ b/lib/config.sh @@ -122,6 +122,7 @@ read_profile_section() { local profile_file="$1" local section="$2" local result=() + local line="" if [[ -f "$profile_file" ]] && grep -q "^\[$section\]" "$profile_file"; then while IFS= read -r line; do @@ -130,7 +131,7 @@ read_profile_section() { done < <(sed -n "/^\[$section\]/,/^\[/p" "$profile_file" | tail -n +2 | grep -v '^\[') fi - printf '%s\n' "${result[@]}" + printf '%s\n' "${result[@]+"${result[@]}"}" } update_profile_section() { @@ -143,13 +144,15 @@ update_profile_section() { readarray -t existing_items < <(read_profile_section "$profile_file" "$section") local all_items=() - for item in "${existing_items[@]}"; do + local item="" + local existing="" + for item in "${existing_items[@]+"${existing_items[@]}"}"; do [[ -n "$item" ]] && all_items+=("$item") done - for item in "${new_items[@]}"; do + for item in "${new_items[@]+"${new_items[@]}"}"; do local found=false - for existing in "${all_items[@]}"; do + for existing in "${all_items[@]+"${all_items[@]}"}"; do [[ "$existing" == "$item" ]] && found=true && break done [[ "$found" == "false" ]] && all_items+=("$item") @@ -169,7 +172,7 @@ update_profile_section() { fi echo "[$section]" - for item in "${all_items[@]}"; do + for item in "${all_items[@]+"${all_items[@]}"}"; do echo "$item" done echo "" @@ -179,14 +182,15 @@ update_profile_section() { get_current_profiles() { local profiles_file="${PROJECT_PARENT_DIR:-$HOME/.claudebox/projects/$(generate_parent_folder_name "$PWD")}/profiles.ini" local current_profiles=() - + local line="" + if [[ -f "$profiles_file" ]]; then while IFS= read -r line; do [[ -n "$line" ]] && current_profiles+=("$line") done < <(read_profile_section "$profiles_file" "profiles") fi - - printf '%s\n' "${current_profiles[@]}" + + printf '%s\n' "${current_profiles[@]+"${current_profiles[@]}"}" } # -------- Profile installation functions for Docker builds ------------------- diff --git a/lib/docker.sh b/lib/docker.sh index 3e3fb50..f8e3a52 100755 --- a/lib/docker.sh +++ b/lib/docker.sh @@ -90,30 +90,30 @@ run_claudebox_container() { local run_mode="$2" # "interactive", "detached", "pipe", or "attached" shift 2 local container_args=("$@") - + # Handle "attached" mode - start detached, wait, then attach if [[ "$run_mode" == "attached" ]]; then # Start detached run_claudebox_container "$container_name" "detached" "${container_args[@]}" >/dev/null - + # Show progress while container initializes fillbar - + # Wait for container to be ready while ! docker exec "$container_name" true ; do sleep 0.1 done - + fillbar stop - + # Attach to ready container docker attach "$container_name" - + return fi - + local docker_args=() - + # Set run mode case "$run_mode" in "interactive") @@ -141,11 +141,11 @@ run_claudebox_container() { docker_args+=("--rm" "--init") ;; esac - + # Always check for tmux socket and mount if available (or create one) local tmux_socket="" local tmux_socket_dir="" - + # If TMUX env var is set, extract socket path from it if [[ -n "${TMUX:-}" ]]; then # TMUX format is typically: /tmp/tmux-1000/default,23456,0 @@ -155,7 +155,7 @@ run_claudebox_container() { # Look for existing tmux socket or determine where to create one local uid=$(id -u) local default_socket_dir="/tmp/tmux-$uid" - + # Check common locations for existing sockets for socket_dir in "$default_socket_dir" "/var/run/tmux-$uid" "$HOME/.tmux"; do if [[ -d "$socket_dir" ]]; then @@ -170,7 +170,7 @@ run_claudebox_container() { [[ -n "$tmux_socket" ]] && break fi done - + # If no socket found, ensure we have a socket directory for potential tmux usage if [[ -z "$tmux_socket" ]]; then tmux_socket_dir="$default_socket_dir" @@ -179,7 +179,7 @@ run_claudebox_container() { mkdir -p "$tmux_socket_dir" chmod 700 "$tmux_socket_dir" fi - + # Check if tmux is installed and create a detached session if so if command -v tmux >/dev/null 2>&1; then # Create a minimal tmux server without attaching @@ -194,7 +194,7 @@ run_claudebox_container() { fi fi fi - + # Mount the socket and directory if we have them if [[ -n "$tmux_socket_dir" ]] && [[ -d "$tmux_socket_dir" ]]; then # Always mount the socket directory @@ -202,46 +202,46 @@ run_claudebox_container() { if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] Mounting tmux socket directory: $tmux_socket_dir" >&2 fi - + # Mount specific socket if it exists if [[ -n "$tmux_socket" ]] && [[ -S "$tmux_socket" ]]; then if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] Tmux socket found at: $tmux_socket" >&2 fi fi - + # Pass TMUX env var if available [[ -n "${TMUX:-}" ]] && docker_args+=(-e "TMUX=$TMUX") fi - + # Standard configuration for ALL containers docker_args+=( -w /workspace -v "$PROJECT_DIR":/workspace -v "$PROJECT_PARENT_DIR":/home/$DOCKER_USER/.claudebox ) - + # Ensure .claude directory exists if [[ ! -d "$PROJECT_SLOT_DIR/.claude" ]]; then mkdir -p "$PROJECT_SLOT_DIR/.claude" fi - + docker_args+=(-v "$PROJECT_SLOT_DIR/.claude":/home/$DOCKER_USER/.claude) - + # Mount .claude.json only if it already exists (from previous session) if [[ -f "$PROJECT_SLOT_DIR/.claude.json" ]]; then docker_args+=(-v "$PROJECT_SLOT_DIR/.claude.json":/home/$DOCKER_USER/.claude.json) fi - + # Mount .config directory docker_args+=(-v "$PROJECT_SLOT_DIR/.config":/home/$DOCKER_USER/.config) - + # Mount .cache directory docker_args+=(-v "$PROJECT_SLOT_DIR/.cache":/home/$DOCKER_USER/.cache) - + # Mount SSH directory docker_args+=(-v "$HOME/.ssh":"/home/$DOCKER_USER/.ssh:ro") - + # Mount .env file if it exists in the project directory if [[ -f "$PROJECT_DIR/.env" ]]; then docker_args+=(-v "$PROJECT_DIR/.env":/workspace/.env:ro) @@ -249,7 +249,7 @@ run_claudebox_container() { echo "[DEBUG] Mounting .env file from project directory" >&2 fi fi - + # Parse and prepare MCP servers for native --mcp-config support # Check for jq dependency first - fail fast with clear error message if ! command -v jq >/dev/null 2>&1; then @@ -260,16 +260,16 @@ run_claudebox_container() { printf " RHEL/CentOS: yum install jq\n" >&2 exit 1 fi - + # Helper function to create and merge MCP config files create_mcp_config_file() { local config_file="$1" local temp_file="$2" - + # Create temporary file with unique name local mcp_file=$(mktemp /tmp/claudebox-mcp-$(date +%s)-$$.json 2>/dev/null || mktemp) mcp_temp_files+=("$mcp_file") - + # Extract mcpServers if they exist if [[ -f "$config_file" ]] && jq -e '.mcpServers' "$config_file" >/dev/null 2>&1; then if [[ -f "$temp_file" ]]; then @@ -286,17 +286,17 @@ run_claudebox_container() { printf "" fi } - + local user_mcp_file="" local project_mcp_file="" - + # Track all temporary MCP files for cleanup declare -a mcp_temp_files=() - - # Set up cleanup trap for temporary MCP config files + + # Clean up temporary MCP config files before leaving this function. cleanup_mcp_files() { local file - for file in "${mcp_temp_files[@]}"; do + for file in "${mcp_temp_files[@]+"${mcp_temp_files[@]}"}"; do if [[ -f "$file" ]]; then rm -f "$file" fi @@ -308,12 +308,11 @@ run_claudebox_container() { rm -f "$project_mcp_file" fi } - trap cleanup_mcp_files EXIT - + # Create user MCP config file from ~/.claude.json if [[ -f "$HOME/.claude.json" ]]; then user_mcp_file=$(create_mcp_config_file "$HOME/.claude.json" "") - + if [[ -n "$user_mcp_file" ]]; then local user_count=$(jq '.mcpServers | length' "$user_mcp_file" 2>/dev/null || echo "0") if [[ "$user_count" -gt 0 ]]; then @@ -330,13 +329,13 @@ run_claudebox_container() { fi fi fi - + # Create project MCP config file by merging project configs # Start with empty config file for merging local temp_project_file=$(mktemp /tmp/claudebox-project-temp-$(date +%s)-$$.json 2>/dev/null || mktemp) mcp_temp_files+=("$temp_project_file") echo '{"mcpServers":{}}' > "$temp_project_file" - + # Merge shared project settings first local merged_file="" if [[ -f "$PROJECT_DIR/.claude/settings.json" ]]; then @@ -345,7 +344,7 @@ run_claudebox_container() { mv "$merged_file" "$temp_project_file" fi fi - + # Merge local project settings (highest priority) if [[ -f "$PROJECT_DIR/.claude/settings.local.json" ]]; then merged_file=$(create_mcp_config_file "$PROJECT_DIR/.claude/settings.local.json" "$temp_project_file") @@ -353,7 +352,7 @@ run_claudebox_container() { mv "$merged_file" "$temp_project_file" fi fi - + # Check if we have any project servers local project_count=$(jq '.mcpServers | length' "$temp_project_file" 2>/dev/null || echo "0") if [[ "$project_count" -gt 0 ]]; then @@ -369,18 +368,18 @@ run_claudebox_container() { rm -f "$temp_project_file" project_mcp_file="" fi - - + + # Add environment variables local project_name=$(basename "$PROJECT_DIR") local slot_name=$(basename "$PROJECT_SLOT_DIR") - + # Calculate slot index for hostname local slot_index=1 # default if we can't determine if [[ -n "$PROJECT_PARENT_DIR" ]] && [[ -n "$slot_name" ]]; then slot_index=$(get_slot_index "$slot_name" "$PROJECT_PARENT_DIR" 2>/dev/null || echo "1") fi - + docker_args+=( -e "NODE_ENV=${NODE_ENV:-production}" -e "ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}" @@ -395,25 +394,30 @@ run_claudebox_container() { --cap-add NET_RAW "$IMAGE_NAME" ) - + # Add any additional arguments if [[ ${#container_args[@]} -gt 0 ]]; then docker_args+=("${container_args[@]}") fi - + # Run the container if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] Docker run command: docker run ${docker_args[*]}" >&2 fi + local exit_code=0 + set +e docker run "${docker_args[@]}" - local exit_code=$? - + exit_code=$? + set -e + + cleanup_mcp_files + return $exit_code } check_container_exists() { local container_name="$1" - + # Check if container exists (running or stopped) if docker ps -a --filter "name=^${container_name}$" --format "{{.Names}}" | grep -q "^${container_name}$"; then # Check if it's running @@ -430,14 +434,14 @@ check_container_exists() { run_docker_build() { info "Running docker build..." export DOCKER_BUILDKIT=1 - + # Check if we need to force rebuild due to template changes local no_cache_flag="" if [[ "${CLAUDEBOX_FORCE_NO_CACHE:-false}" == "true" ]]; then no_cache_flag="--no-cache" info "Forcing full rebuild (templates changed)" fi - + docker build \ $no_cache_flag \ --progress=${BUILDKIT_PROGRESS:-auto} \ diff --git a/lib/env.sh b/lib/env.sh index 36163f5..49d801f 100755 --- a/lib/env.sh +++ b/lib/env.sh @@ -13,6 +13,8 @@ readonly GROUP_ID=$(id -g) PROJECT_DIR="${PROJECT_DIR:-$(pwd)}" readonly LINK_TARGET="$HOME/.local/bin/claudebox" export CLAUDEBOX_HOME="${HOME}/.claudebox" +readonly CLAUDEBOX_GLOBAL_CLAUDE_DIR="${CLAUDEBOX_HOME}/.claude" +readonly CLAUDEBOX_GLOBAL_CLAUDE_COMMANDS_DIR="${CLAUDEBOX_GLOBAL_CLAUDE_DIR}/commands" # Version constants readonly NODE_VERSION="--lts" @@ -24,4 +26,6 @@ readonly DELTA_VERSION="0.17.0" # Export what other modules need export USER_ID export GROUP_ID -export PROJECT_DIR \ No newline at end of file +export PROJECT_DIR +export CLAUDEBOX_GLOBAL_CLAUDE_DIR +export CLAUDEBOX_GLOBAL_CLAUDE_COMMANDS_DIR \ No newline at end of file diff --git a/lib/project.sh b/lib/project.sh index 060ba05..da71f82 100755 --- a/lib/project.sh +++ b/lib/project.sh @@ -80,7 +80,7 @@ init_project_dir() { setup_claude_agent_command "$parent" # Sync commands to project sync_commands_to_project "$parent" - + # Copy common.sh to project parent directory if it doesn't exist local common_sh_target="$parent/common.sh" if [[ ! -f "$common_sh_target" ]]; then @@ -109,7 +109,7 @@ write_counter() { init_slot_dir() { local dir="$1" mkdir -p "$dir" - + # Check if claude/ directory exists in the claudebox root to seed .claude local claude_source="${CLAUDEBOX_SCRIPT_DIR:-${SCRIPT_DIR}}/claude" if [[ -d "$claude_source" ]]; then @@ -119,7 +119,7 @@ init_slot_dir() { # Fall back to creating empty .claude directory mkdir -p "$dir/.claude" fi - + mkdir -p "$dir/.config" mkdir -p "$dir/.cache" # Don't pre-create .claude.json - let Claude create it naturally @@ -186,7 +186,7 @@ determine_next_start_container() { dir="$parent/$name" # Skip non-existent slots - they haven't been created yet [ -d "$dir" ] || continue - + # Check if a container with this slot name is running if ! docker ps --format "{{.Names}}" | grep -q "^claudebox-.*-${name}$"; then echo "$name" @@ -201,24 +201,24 @@ find_ready_slot() { local path="$1" parent max idx name dir parent=$(get_parent_dir "$path") max=$(read_counter "$parent") - + for ((idx=1; idx<=max; idx++)); do name=$(generate_container_name "$path" "$idx") dir="$parent/$name" - + # Skip non-existent slots [ -d "$dir" ] || continue - + # Check if authenticated [ -f "$dir/.claude/.credentials.json" ] || continue - + # Check if not running (inactive) if ! docker ps --format "{{.Names}}" | grep -q "^claudebox-.*-${name}$"; then echo "$name" return 0 fi done - + # No ready slots found return 1 } @@ -228,21 +228,21 @@ find_inactive_slot() { local path="$1" parent max idx name dir parent=$(get_parent_dir "$path") max=$(read_counter "$parent") - + for ((idx=1; idx<=max; idx++)); do name=$(generate_container_name "$path" "$idx") dir="$parent/$name" - + # Skip non-existent slots [ -d "$dir" ] || continue - + # Check if not running (inactive) if ! docker ps --format "{{.Names}}" | grep -q "^claudebox-.*-${name}$"; then echo "$name" return 0 fi done - + # No inactive slots found return 1 } @@ -256,7 +256,7 @@ get_project_folder_name() { local path="$1" # First ensure project is initialized init_project_dir "$path" - + # Find next available slot local slot_name if slot_name=$(determine_next_start_container "$path"); then @@ -282,14 +282,14 @@ _get_project_slug() { get_project_by_path() { local search_path="$1" local abs_path=$(realpath "$search_path" 2>/dev/null || echo "$search_path") - + # Check all parent directories in ~/.claudebox/projects/ for parent_dir in "$HOME/.claudebox/projects"/*/ ; do [[ -d "$parent_dir" ]] || continue - + # Check if profiles.ini exists (indicates valid project) [[ -f "$parent_dir/profiles.ini" ]] || continue - + # For now, we can't easily reverse-lookup the original path # This would need to be stored somewhere # Return empty for now - this function may need redesign @@ -301,41 +301,41 @@ get_project_by_path() { # List all projects - now shows parent directories with slot info list_all_projects() { local projects_found=0 - + # Iterate through parent directories for parent_dir in "$HOME/.claudebox/projects"/*/ ; do [[ -d "$parent_dir" ]] || continue projects_found=1 - + local parent_name=$(basename "$parent_dir") local profiles_file="$parent_dir/profiles.ini" local slot_count=0 local active_slots=0 - + # Count slots if [[ -f "$parent_dir/.project_container_counter" ]]; then slot_count=$(read_counter "$parent_dir") fi - + # Count active slots (with lock files) for slot_dir in "$parent_dir"/*/ ; do [[ -d "$slot_dir" ]] || continue [[ -f "$slot_dir/lock" ]] && ((active_slots++)) done - + # Check if Docker image exists local image_name="claudebox-${parent_name}" local image_status="❌" local image_size="-" - + if docker image inspect "$image_name" >/dev/null 2>&1; then image_status="✅" image_size=$(docker images --filter "reference=$image_name" --format "{{.Size}}") fi - + printf "%10s %s Slots: %d/%d %s\n" "$image_size" "$image_status" "$active_slots" "$slot_count" "$parent_name" done - + [[ $projects_found -eq 0 ]] && return 1 return 0 } @@ -343,13 +343,13 @@ list_all_projects() { # Resolve project path - adapted for new structure resolve_project_path() { local input_path="${1:-$PWD}" - + # Check if it's already a container name if [[ "$input_path" =~ _[a-f0-9]{8}$ ]]; then echo "$input_path" return 0 fi - + # Otherwise, get the parent directory for this path local parent_name=$(get_project_folder_name "$input_path") echo "$parent_name" @@ -365,7 +365,7 @@ prune_slot_counter() { local path="$1" local parent=$(get_parent_dir "$path") local max=$(read_counter "$parent") - + # Find highest existing slot local highest=0 for ((idx=1; idx<=max; idx++)); do @@ -375,7 +375,7 @@ prune_slot_counter() { highest=$idx fi done - + # Update counter if we can prune if [ $highest -lt $max ]; then write_counter "$parent" $highest @@ -388,19 +388,19 @@ prune_slot_counter() { list_project_slots() { local path="${1:-$PWD}" local parent=$(get_parent_dir "$path") - + if [ ! -d "$parent" ]; then echo "No project found for path: $path" return 1 fi - + # Prune counter first prune_slot_counter "$path" local max=$(read_counter "$parent") - + logo_small echo - + if [ $max -eq 0 ]; then echo "Commands:" printf " %-20s %s\n" "claudebox create" "Create new slot" @@ -412,7 +412,7 @@ list_project_slots() { echo return 0 fi - + echo "Commands:" echo printf " %-20s %s\n" "claudebox create" "Create new slot" @@ -420,14 +420,14 @@ list_project_slots() { printf " %-20s %s\n" "claudebox revoke" "Remove highest slot" printf " %-20s %s\n" "claudebox revoke all" "Remove all unused slots" echo - + echo "Slots for $path:" echo - + # Header printf " Slot Authentication Status Folder\n" printf " ──── ───────────────── ───────── ────────\n" - + for ((idx=1; idx<=max; idx++)); do local name=$(generate_container_name "$path" "$idx") local dir="$parent/$name" @@ -435,7 +435,7 @@ list_project_slots() { local auth_text="Removed" local run_icon="" local run_text="N/A" - + if [ -d "$dir" ]; then # Check authentication status if [ -f "$dir/.claude/.credentials.json" ]; then @@ -445,7 +445,7 @@ list_project_slots() { auth_icon="🔒" auth_text="Unauthenticated" fi - + # Check if a container with this slot name is running if docker ps --format "{{.Names}}" | grep -q "^claudebox-.*-${name}$"; then run_icon="🟢" @@ -455,11 +455,11 @@ list_project_slots() { run_text="Inactive" fi fi - + # Format with 2-space indent printf " %-4s %s %-15s %s %-6s %s\n" "$idx" "$auth_icon" "$auth_text" "$run_icon" "$run_text" "$name" done - + echo echo "Parent directory: $parent" echo @@ -480,7 +480,7 @@ get_slot_index() { local parent_dir="$2" local path=$(dirname "$parent_dir") # Get original path from parent local max=$(read_counter "$parent_dir") - + for ((idx=1; idx<=max; idx++)); do local name=$(generate_container_name "$path" "$idx") if [[ "$name" == "$slot_name" ]]; then @@ -498,29 +498,29 @@ sync_commands_to_project() { local commands_dir="$project_parent/commands" local cbox_checksum_file="$project_parent/.commands_cbox_checksum" local user_checksum_file="$project_parent/.commands_user_checksum" - + # Source directories local cbox_source="${CLAUDEBOX_SCRIPT_DIR:-${SCRIPT_DIR}}/commands" - local user_source="$HOME/.claude/commands" - + local user_source="$CLAUDEBOX_GLOBAL_CLAUDE_COMMANDS_DIR" + # Create commands directory if it doesn't exist mkdir -p "$commands_dir" - + # Calculate checksums of source directories local cbox_checksum="" local user_checksum="" - + # Get checksum of cbox commands if directory exists if [[ -d "$cbox_source" ]]; then # Find all files, get their content checksum, sort for consistency cbox_checksum=$(find "$cbox_source" -type f -exec sha256sum {} \; 2>/dev/null | sort | sha256sum | cut -d' ' -f1) fi - + # Get checksum of user commands if directory exists if [[ -d "$user_source" ]]; then user_checksum=$(find "$user_source" -type f -exec sha256sum {} \; 2>/dev/null | sort | sha256sum | cut -d' ' -f1) fi - + # Check if cbox commands need syncing local sync_cbox=false if [[ -d "$cbox_source" ]]; then @@ -533,7 +533,7 @@ sync_commands_to_project() { fi fi fi - + # Check if user commands need syncing local sync_user=false if [[ -d "$user_source" ]]; then @@ -546,17 +546,17 @@ sync_commands_to_project() { fi fi fi - + # Sync cbox commands if [[ "$sync_cbox" == "true" ]] && [[ -d "$cbox_source" ]]; then if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] Syncing cbox commands to $commands_dir/cbox" >&2 fi - + # Remove old cbox commands and recreate rm -rf "$commands_dir/cbox" mkdir -p "$commands_dir/cbox" - + # Copy preserving directory structure # Use find to handle subdirectories properly if cd "$cbox_source"; then @@ -567,21 +567,21 @@ sync_commands_to_project() { done cd - >/dev/null || true fi - + # Save checksum echo "$cbox_checksum" > "$cbox_checksum_file" fi - + # Sync user commands if [[ "$sync_user" == "true" ]] && [[ -d "$user_source" ]]; then if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] Syncing user commands to $commands_dir/user" >&2 fi - + # Remove old user commands and recreate rm -rf "$commands_dir/user" mkdir -p "$commands_dir/user" - + # Copy preserving directory structure if cd "$user_source"; then find . -type f | while read -r file; do @@ -591,17 +591,17 @@ sync_commands_to_project() { done cd - >/dev/null || true fi - + # Save checksum echo "$user_checksum" > "$user_checksum_file" fi - + # Clean up empty directories if sources don't exist if [[ ! -d "$cbox_source" ]] && [[ -d "$commands_dir/cbox" ]]; then rm -rf "$commands_dir/cbox" rm -f "$cbox_checksum_file" fi - + if [[ ! -d "$user_source" ]] && [[ -d "$commands_dir/user" ]]; then rm -rf "$commands_dir/user" rm -f "$user_checksum_file" diff --git a/lib/state.sh b/lib/state.sh index 334fc97..e0ab56f 100755 --- a/lib/state.sh +++ b/lib/state.sh @@ -30,7 +30,7 @@ update_symlink() { # Create new symlink if ln -s "$SCRIPT_PATH" "$LINK_TARGET"; then success "Symlink updated: $LINK_TARGET → $SCRIPT_PATH" - + # Check if the directory is in PATH if [[ ":$PATH:" != *":$(dirname "$LINK_TARGET"):"* ]]; then echo "" @@ -53,18 +53,37 @@ update_symlink() { # Ensure shared commands folder exists and is up to date setup_shared_commands() { - local shared_commands="$HOME/.claude/commands" + local shared_commands="$CLAUDEBOX_GLOBAL_CLAUDE_COMMANDS_DIR" + local legacy_claude_commands="$HOME/.claude/commands" + local legacy_claudebox_commands="$CLAUDEBOX_HOME/commands" # Script is now at root, so SCRIPT_DIR is the root dir local commands_source="$SCRIPT_DIR/commands" - + # Create shared commands directory if it doesn't exist mkdir -p "$shared_commands" - + + # Preserve existing user commands from legacy locations. + local legacy_commands="" + local file="" + for legacy_commands in "$legacy_claude_commands" "$legacy_claudebox_commands"; do + if [[ -d "$legacy_commands" ]] && [[ "$legacy_commands" != "$shared_commands" ]]; then + for file in "$legacy_commands"/*; do + if [[ -f "$file" ]]; then + local basename=$(basename "$file") + local dest_file="$shared_commands/$basename" + if [[ ! -e "$dest_file" ]]; then + cp "$file" "$dest_file" + fi + fi + done + fi + done + # Copy/update commands from script directory if it exists if [[ -d "$commands_source" ]]; then # Copy new or updated files (preserve existing user files) cp -n "$commands_source/"* "$shared_commands/" 2>/dev/null || true - + # For existing files, only update if source is newer for file in "$commands_source"/*; do if [[ -f "$file" ]]; then @@ -78,7 +97,7 @@ setup_shared_commands() { fi fi done - + if [[ "$VERBOSE" == "true" ]]; then info "Synchronized commands to shared folder: $shared_commands" fi @@ -89,24 +108,24 @@ setup_claude_agent_command() { # Takes parent directory as argument local parent_dir="${1:-}" [[ -z "$parent_dir" ]] && return 0 - + # Copy bundled commands to parent folder local bundled_commands="$SCRIPT_DIR/commands" local commands_dest="$parent_dir/commands" - + # Only copy if commands destination doesn't already exist if [[ ! -e "$commands_dest" ]]; then if [[ -d "$bundled_commands" ]]; then # Copy bundled commands cp -r "$bundled_commands" "$commands_dest" - + if [[ "$VERBOSE" == "true" ]]; then info "Copied bundled commands to: $commands_dest" fi else # Create empty commands directory mkdir -p "$commands_dest" - + if [[ "$VERBOSE" == "true" ]]; then info "Created empty commands directory: $commands_dest" fi @@ -122,7 +141,7 @@ calculate_docker_layer_checksums() { if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] calculate_docker_layer_checksums: SCRIPT_PATH=$SCRIPT_PATH, root_dir=$root_dir" >&2 fi - + # Layer 1: Base Dockerfile (rarely changes) local dockerfile_checksum="" if [[ -f "$root_dir/build/Dockerfile" ]]; then @@ -131,7 +150,7 @@ calculate_docker_layer_checksums() { echo "[DEBUG] Dockerfile checksum: $dockerfile_checksum" >&2 fi fi - + # Layer 2: Entrypoint and init scripts (occasional changes) local scripts_checksum="" local combined_content="" @@ -155,7 +174,7 @@ calculate_docker_layer_checksums() { echo "[DEBUG] Combined scripts checksum: $scripts_checksum" >&2 fi fi - + # Layer 3: Profile configuration (frequent changes) local profiles_checksum="" local profiles_ini="$PROJECT_PARENT_DIR/profiles.ini" @@ -165,7 +184,7 @@ calculate_docker_layer_checksums() { echo "[DEBUG] Profiles checksum: $profiles_checksum" >&2 fi fi - + # Return layer checksums (first 8 chars of MD5 hex) echo "dockerfile:${dockerfile_checksum:0:8}" echo "scripts:${scripts_checksum:0:8}" @@ -180,7 +199,7 @@ needs_docker_rebuild() { if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] needs_docker_rebuild called with project_dir=$project_dir, image_name=$image_name" >&2 fi - + # If no image exists, need rebuild if ! docker image inspect "$image_name" >/dev/null 2>&1; then if [[ "$VERBOSE" == "true" ]]; then @@ -188,10 +207,10 @@ needs_docker_rebuild() { fi return 0 fi - + # Calculate current layer checksums local current_checksums=$(calculate_docker_layer_checksums "$project_dir") - + # If no checksum file, need rebuild if [[ ! -f "$checksum_file" ]]; then if [[ "$VERBOSE" == "true" ]]; then @@ -199,17 +218,17 @@ needs_docker_rebuild() { fi return 0 fi - + # Compare layer checksums local stored_checksums=$(cat "$checksum_file" 2>/dev/null || echo "") if [[ "$current_checksums" != "$stored_checksums" ]]; then if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] Layer checksums changed, rebuild needed" >&2 fi - + # Check if templates changed (dockerfile or scripts layers) local templates_changed=false - + # Show which layers changed if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] Changed layers:" >&2 @@ -238,15 +257,15 @@ needs_docker_rebuild() { fi done <<< "$current_checksums" fi - + # If templates changed, we need to force no-cache if [[ "$templates_changed" == "true" ]]; then export CLAUDEBOX_FORCE_NO_CACHE=true fi - + return 0 fi - + if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] All layer checksums match, no rebuild needed" >&2 fi @@ -257,12 +276,12 @@ needs_docker_rebuild() { save_docker_layer_checksums() { local project_dir="${1:-$PROJECT_DIR}" local checksum_file="$PROJECT_PARENT_DIR/.docker_layer_checksums" - + if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] save_docker_layer_checksums called" >&2 fi local checksums=$(calculate_docker_layer_checksums "$project_dir") - + echo "$checksums" > "$checksum_file" if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] Saved layer checksums to $checksum_file:" >&2 diff --git a/main.sh b/main.sh index 0246f35..4686ea2 100755 --- a/main.sh +++ b/main.sh @@ -77,22 +77,22 @@ show_first_time_welcome() { main() { # Save original arguments for later use with saved flags local original_args=("$@") - + # Enable BuildKit for all Docker operations export DOCKER_BUILDKIT=1 - + # Step 1: Update symlink update_symlink - + # Step 2: Parse ALL arguments parse_cli_args "$@" - + # Step 3: Process host flags (sets VERBOSE, REBUILD, CLAUDEBOX_WRAP_TMUX) process_host_flags - + # Step 3a: Handle saved flags based on the first CLI argument local first_arg="${original_args[0]:-}" - + # Check if first arg is a command (no dash) that should skip saved flags case "$first_arg" in save|clean|kill) @@ -105,13 +105,13 @@ main() { while IFS= read -r flag; do [[ -n "$flag" ]] && saved_flags+=("$flag") done < "$HOME/.claudebox/default-flags" - + 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 - parse_cli_args "${original_args[@]}" "${saved_flags[@]}" + parse_cli_args "${original_args[@]+"${original_args[@]}"}" "${saved_flags[@]+"${saved_flags[@]}"}" process_host_flags - + if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] Loaded saved flags: ${saved_flags[*]}" >&2 fi @@ -119,10 +119,19 @@ main() { fi ;; esac - + + local suggested_command="" + if [[ -z "${CLI_SCRIPT_COMMAND:-}" ]] && [[ -n "$first_arg" ]] && [[ "$first_arg" != -* ]]; then + suggested_command=$(get_script_command_suggestion "$first_arg" 2>/dev/null || printf '') + if [[ -n "$suggested_command" ]]; then + error "Unknown ClaudeBox command: $first_arg +Did you mean: claudebox $suggested_command?" + fi + fi + # Step 4: Debug output if verbose debug_parsed_args - + # Step 4a: Check if this command even needs Docker local cmd_requirements="none" if [[ -n "${CLI_SCRIPT_COMMAND}" ]]; then @@ -133,14 +142,14 @@ main() { # No script command means we're running claude - needs Docker cmd_requirements="docker" fi - + # If command doesn't need Docker, skip all Docker setup if [[ "$cmd_requirements" == "none" ]]; then # Dispatch the command directly and exit - dispatch_command "${CLI_SCRIPT_COMMAND}" "${CLI_PASS_THROUGH[@]}" "${CLI_CONTROL_FLAGS[@]}" + dispatch_command "${CLI_SCRIPT_COMMAND}" "${CLI_PASS_THROUGH[@]+"${CLI_PASS_THROUGH[@]}"}" "${CLI_CONTROL_FLAGS[@]+"${CLI_CONTROL_FLAGS[@]}"}" exit $? fi - + # Step 5: Docker checks local docker_status docker_status=$(check_docker; echo $?) @@ -168,16 +177,16 @@ main() { configure_docker_nonroot ;; esac - + # Step 5a: Build core image if it doesn't exist local core_image="claudebox-core" if ! docker image inspect "$core_image" >/dev/null 2>&1; then # Show logo during build logo - + local build_context="$HOME/.claudebox/docker-build-context" mkdir -p "$build_context" - + # Copy build files local root_dir="$SCRIPT_DIR" cp "${root_dir}/build/docker-entrypoint" "$build_context/docker-entrypoint.sh" || error "Failed to copy docker-entrypoint.sh" @@ -186,18 +195,18 @@ main() { cp "${root_dir}/lib/tools-report.sh" "$build_context/tools-report.sh" || error "Failed to copy tools-report.sh" cp "${root_dir}/build/dockerignore" "$build_context/.dockerignore" || error "Failed to copy .dockerignore" chmod +x "$build_context/docker-entrypoint.sh" "$build_context/init-firewall" "$build_context/generate-tools-readme" - + # Create core Dockerfile local core_dockerfile="$build_context/Dockerfile.core" local base_dockerfile=$(cat "${root_dir}/build/Dockerfile") || error "Failed to read base Dockerfile" - + # Remove profile installations and labels placeholders for core local core_dockerfile_content="$base_dockerfile" core_dockerfile_content="${core_dockerfile_content//\{\{PROFILE_INSTALLATIONS\}\}/}" core_dockerfile_content="${core_dockerfile_content//\{\{LABELS\}\}/LABEL claudebox.type=\"core\"}" - + echo "$core_dockerfile_content" > "$core_dockerfile" - + # Build core image docker build \ --progress=${BUILDKIT_PROGRESS:-auto} \ @@ -208,23 +217,23 @@ main() { --build-arg NODE_VERSION="$NODE_VERSION" \ --build-arg DELTA_VERSION="$DELTA_VERSION" \ -f "$core_dockerfile" -t "$core_image" "$build_context" || error "Failed to build core image" - - + + # Check if this is truly a first-time setup (no projects exist) local project_count=$(ls -1d "$HOME/.claudebox/projects"/*/ 2>/dev/null | wc -l) - + if [[ $project_count -eq 0 ]]; then # First-time user - show welcome menu show_first_time_welcome exit 0 fi - + # Existing user - core rebuilt, continue normal flow if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] Core image built, continuing with normal flow..." >&2 fi fi - + # If running from installer, show appropriate message and exit if [[ "${CLAUDEBOX_INSTALLER_RUN:-}" == "true" ]]; then # Check if this is first install or update @@ -255,60 +264,60 @@ main() { fi exit 0 fi - + # Step 6: Initialize project directory (creates parent with profiles.ini) init_project_dir "$PROJECT_DIR" PROJECT_PARENT_DIR=$(get_parent_dir "$PROJECT_DIR") export PROJECT_PARENT_DIR - + # Step 7: Handle rebuild if requested (will use IMAGE_NAME from step 8) local rebuild_requested="${REBUILD:-false}" - + # Step 8: Always set up project variables # Get the actual parent folder name for the project local parent_folder_name=$(generate_parent_folder_name "$PROJECT_DIR") - + # Get the slot to use (might be empty) project_folder_name=$(get_project_folder_name "$PROJECT_DIR") - + # Early exit if command needs Docker but no slots exist if [[ "$project_folder_name" == "NONE" ]] && [[ "$cmd_requirements" == "docker" ]]; then show_no_slots_menu exit 1 fi - + # Always set IMAGE_NAME based on parent folder IMAGE_NAME=$(get_image_name) export IMAGE_NAME - + # Set PROJECT_SLOT_DIR if we have a slot if [[ -n "$project_folder_name" ]] && [[ "$project_folder_name" != "NONE" ]]; then PROJECT_SLOT_DIR="$PROJECT_PARENT_DIR/$project_folder_name" export PROJECT_SLOT_DIR fi - + # Handle rebuild if requested if [[ "$rebuild_requested" == "true" ]]; then warn "Forcing full rebuild of ClaudeBox Docker image..." rm -f "$PROJECT_PARENT_DIR/.docker_layer_checksums" docker rmi -f "$IMAGE_NAME" 2>/dev/null || true fi - + # Step 9: Run pre-flight validation for commands that need Docker if [[ -n "${CLI_SCRIPT_COMMAND}" ]]; then local cmd_req=$(get_command_requirements "${CLI_SCRIPT_COMMAND}") # Only run pre-flight for commands that need Docker or image if [[ "$cmd_req" == "docker" ]] || [[ "$cmd_req" == "image" ]]; then - if ! preflight_check "${CLI_SCRIPT_COMMAND}" "${CLI_PASS_THROUGH[@]}"; then + if ! preflight_check "${CLI_SCRIPT_COMMAND}" "${CLI_PASS_THROUGH[@]+"${CLI_PASS_THROUGH[@]}"}"; then # Pre-flight check failed and printed error exit 1 fi fi fi - + # Step 10: Check command requirements local cmd_requirements="none" - + if [[ -n "${CLI_SCRIPT_COMMAND}" ]]; then # Pass the first pass-through arg as potential subcommand local first_arg="${CLI_PASS_THROUGH[0]:-}" @@ -317,23 +326,23 @@ main() { # No script command means we're running claude - needs Docker cmd_requirements="docker" fi - + if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] Command requirements: $cmd_requirements" >&2 fi - + # Step 10a: Set IMAGE_NAME if needed (for "image" or "docker" requirements) if [[ "$cmd_requirements" != "none" ]]; then # Commands that need image name should have it set even without Docker IMAGE_NAME=$(get_image_name) export IMAGE_NAME fi - + # Step 10b: Build Docker image if needed (only for "docker" requirements) if [[ "$cmd_requirements" == "docker" ]]; then # Check if rebuild needed local need_rebuild=false - + if [[ "${REBUILD:-false}" == "true" ]] || ! docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then need_rebuild=true elif needs_docker_rebuild "$PROJECT_DIR" "$IMAGE_NAME"; then @@ -348,12 +357,12 @@ main() { while IFS= read -r line; do [[ -n "$line" ]] && current_profiles+=("$line") done < <(read_profile_section "$profiles_file" "profiles") - + # Separate Python-only profiles from Docker-affecting profiles local docker_profiles=() local python_only_profiles=("python" "ml" "datascience") - - for profile in "${current_profiles[@]}"; do + + for profile in "${current_profiles[@]+"${current_profiles[@]}"}"; do local is_python_only=false for py_profile in "${python_only_profiles[@]}"; do if [[ "$profile" == "$py_profile" ]]; then @@ -365,15 +374,15 @@ main() { docker_profiles+=("$profile") fi done - + # Calculate hash only for Docker-affecting profiles local docker_profiles_hash="" if [[ ${#docker_profiles[@]} -gt 0 ]]; then docker_profiles_hash=$(printf '%s\n' "${docker_profiles[@]}" | sort | cksum | cut -d' ' -f1) fi - + local image_profiles_hash=$(docker inspect "$IMAGE_NAME" --format '{{index .Config.Labels "claudebox.profiles"}}' 2>/dev/null || echo "") - + if [[ "$docker_profiles_hash" != "$image_profiles_hash" ]]; then info "Docker-affecting profiles changed, rebuilding..." docker rmi -f "$IMAGE_NAME" 2>/dev/null || true @@ -381,7 +390,7 @@ main() { fi fi fi - + if [[ "$need_rebuild" == "true" ]]; then # Set rebuild timestamp to bust Docker cache when templates change export CLAUDEBOX_REBUILD_TIMESTAMP=$(date +%s) @@ -394,11 +403,11 @@ main() { fi fi fi - + # Step 11: Set up shared resources setup_shared_commands setup_claude_agent_command - + # Step 12: Fix permissions if needed if [[ ! -d "$HOME/.claudebox" ]]; then mkdir -p "$HOME/.claudebox" @@ -407,26 +416,26 @@ main() { warn "Fixing .claudebox permissions..." sudo chown -R "$USER:$USER" "$HOME/.claudebox" || true fi - + # Step 13: Create allowlist if needed if [[ -n "${PROJECT_PARENT_DIR:-}" ]]; then local allowlist_file="$PROJECT_PARENT_DIR/allowlist" if [[ ! -f "$allowlist_file" ]]; then # Root directory is where the script is located local root_dir="$SCRIPT_DIR" - + local allowlist_template="${root_dir}/build/allowlist" if [[ -f "$allowlist_template" ]]; then cp "$allowlist_template" "$allowlist_file" || error "Failed to copy allowlist template" fi fi fi - + # Step 14: Single dispatch point if [[ -n "${CLI_SCRIPT_COMMAND}" ]]; then # Script command - dispatch on host # Pass control flags and pass-through args to dispatch_command - dispatch_command "${CLI_SCRIPT_COMMAND}" "${CLI_PASS_THROUGH[@]}" "${CLI_CONTROL_FLAGS[@]}" + dispatch_command "${CLI_SCRIPT_COMMAND}" "${CLI_PASS_THROUGH[@]+"${CLI_PASS_THROUGH[@]}"}" "${CLI_CONTROL_FLAGS[@]+"${CLI_CONTROL_FLAGS[@]}"}" exit $? else # No script command - running Claude interactively @@ -435,50 +444,50 @@ main() { local slot_name=$(basename "$PROJECT_SLOT_DIR") # parent_folder_name already set in step 8 local container_name="claudebox-${parent_folder_name}-${slot_name}" - + if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] PROJECT_SLOT_DIR=$PROJECT_SLOT_DIR" >&2 echo "[DEBUG] slot_name=$slot_name" >&2 echo "[DEBUG] parent_folder_name=$parent_folder_name" >&2 echo "[DEBUG] container_name=$container_name" >&2 fi - + # Sync commands before launching container sync_commands_to_project "$PROJECT_PARENT_DIR" - + # Load saved default flags ONLY for interactive Claude (no command) local saved_flags=() if [[ -f "$HOME/.claudebox/default-flags" ]]; then while IFS= read -r flag; do [[ -n "$flag" ]] && saved_flags+=("$flag") done < "$HOME/.claudebox/default-flags" - + # 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[@]}") - + local all_args=("${original_args[@]+"${original_args[@]}"}" "${saved_flags[@]+"${saved_flags[@]}"}") + # Re-parse to properly sort flags parse_cli_args "${all_args[@]}" process_host_flags - + if [[ "$VERBOSE" == "true" ]]; then echo "[DEBUG] Re-parsed with saved flags" >&2 debug_parsed_args fi fi fi - + # 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 + for arg in "${CLI_PASS_THROUGH[@]+"${CLI_PASS_THROUGH[@]}"}"; do if [[ "$arg" == "-p" ]] || [[ "$arg" == "--print" ]]; then has_print_flag=true break fi done - + if [[ "$VERBOSE" == "true" ]]; then if [[ -t 0 ]]; then echo "[DEBUG] stdin IS a terminal" >&2 @@ -487,7 +496,7 @@ main() { fi echo "[DEBUG] has_print_flag=$has_print_flag" >&2 fi - + if [[ ! -t 0 ]] && [[ "$has_print_flag" == "false" ]]; then # Read piped input and pass as argument to -p if [[ "$VERBOSE" == "true" ]]; then @@ -495,9 +504,9 @@ main() { fi local piped_input piped_input=$(cat) - run_claudebox_container "$container_name" "interactive" "${CLI_CONTROL_FLAGS[@]}" "-p" "$piped_input" "${CLI_PASS_THROUGH[@]}" + run_claudebox_container "$container_name" "interactive" "${CLI_CONTROL_FLAGS[@]+"${CLI_CONTROL_FLAGS[@]}"}" "-p" "$piped_input" "${CLI_PASS_THROUGH[@]+"${CLI_PASS_THROUGH[@]}"}" else - run_claudebox_container "$container_name" "interactive" "${CLI_CONTROL_FLAGS[@]}" "${CLI_PASS_THROUGH[@]}" + run_claudebox_container "$container_name" "interactive" "${CLI_CONTROL_FLAGS[@]+"${CLI_CONTROL_FLAGS[@]}"}" "${CLI_PASS_THROUGH[@]+"${CLI_PASS_THROUGH[@]}"}" fi else show_no_slots_menu @@ -509,50 +518,50 @@ main() { build_docker_image() { local build_context="$HOME/.claudebox/docker-build-context" mkdir -p "$build_context" - + # Copy build files to Docker build context # Root directory is where the script is located local root_dir="$SCRIPT_DIR" - + cp "${root_dir}/build/docker-entrypoint" "$build_context/docker-entrypoint.sh" || error "Failed to copy docker-entrypoint.sh" cp "${root_dir}/build/init-firewall" "$build_context/init-firewall" || error "Failed to copy init-firewall" cp "${root_dir}/build/generate-tools-readme" "$build_context/generate-tools-readme" || error "Failed to copy generate-tools-readme" cp "${root_dir}/lib/tools-report.sh" "$build_context/tools-report.sh" || error "Failed to copy tools-report.sh" cp "${root_dir}/build/dockerignore" "$build_context/.dockerignore" || error "Failed to copy .dockerignore" chmod +x "$build_context/docker-entrypoint.sh" "$build_context/init-firewall" "$build_context/generate-tools-readme" - - + + # Build profile installations local profiles_file="$PROJECT_PARENT_DIR/profiles.ini" local profile_installations="" local profile_hash="" local profiles_file_hash="" - + if [[ -f "$profiles_file" ]]; then profiles_file_hash=$(crc32_file "$profiles_file") - + local current_profiles=() 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 + for profile in "${current_profiles[@]+"${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 - + # Calculate hash only for Docker-affecting profiles local docker_profiles=() local python_only_profiles=("python" "ml" "datascience") - - for profile in "${current_profiles[@]}"; do + + for profile in "${current_profiles[@]+"${current_profiles[@]}"}"; do local is_python_only=false for py_profile in "${python_only_profiles[@]}"; do if [[ "$profile" == "$py_profile" ]]; then @@ -564,19 +573,19 @@ build_docker_image() { docker_profiles+=("$profile") fi done - + if [[ ${#docker_profiles[@]} -gt 0 ]]; then profile_hash=$(printf '%s\n' "${docker_profiles[@]}" | sort | cksum | cut -d' ' -f1) fi fi - + # Create Dockerfile local dockerfile="$build_context/Dockerfile" - + # Use the minimal project Dockerfile template local base_dockerfile base_dockerfile=$(tr -d '\r' < "${root_dir}/build/Dockerfile.project") || error "Failed to read project Dockerfile template" - + # Build labels local project_folder_name project_folder_name=$(generate_parent_folder_name "$PROJECT_DIR") @@ -584,31 +593,32 @@ build_docker_image() { LABEL claudebox.profiles=\"$profile_hash\" 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) - 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" + + # Render placeholders line-by-line so multiline substitutions stay portable on macOS awk. + local final_dockerfile="" + local dockerfile_line="" + while IFS= read -r dockerfile_line || [[ -n "$dockerfile_line" ]]; do + if [[ "$dockerfile_line" =~ ^[[:space:]]*\{\{[[:space:]]*PROFILE_INSTALLATIONS[[:space:]]*\}\}[[:space:]]*$ ]]; then + if [[ -n "$profile_installations" ]]; then + final_dockerfile+="$profile_installations"$'\n' + fi + elif [[ "$dockerfile_line" =~ ^[[:space:]]*\{\{[[:space:]]*LABELS[[:space:]]*\}\}[[:space:]]*$ ]]; then + final_dockerfile+="$labels"$'\n' + else + final_dockerfile+="$dockerfile_line"$'\n' + fi + done <<< "$base_dockerfile" # 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" - + # Build the image run_docker_build "$dockerfile" "$build_context" - + # Save checksums save_docker_layer_checksums "$PROJECT_DIR" }