From 472f030924e58973dea0a41188950540550c125d Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Mon, 4 Aug 2025 09:29:51 -0600 Subject: [PATCH] feat: support for crush (#23) --- CLAUDE.md | 12 ++ README.md | 5 + cubbi/images/crush/Dockerfile | 62 ++++++++++ cubbi/images/crush/README.md | 77 ++++++++++++ cubbi/images/crush/crush_plugin.py | 177 ++++++++++++++++++++++++++++ cubbi/images/crush/cubbi_image.yaml | 54 +++++++++ 6 files changed, 387 insertions(+) create mode 100644 cubbi/images/crush/Dockerfile create mode 100644 cubbi/images/crush/README.md create mode 100644 cubbi/images/crush/crush_plugin.py create mode 100644 cubbi/images/crush/cubbi_image.yaml diff --git a/CLAUDE.md b/CLAUDE.md index b40fdb8..f4d75b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,3 +48,15 @@ Use uv instead: - **Configuration**: Use environment variables with YAML for configuration Refer to SPECIFICATIONS.md for detailed architecture and implementation guidance. + +## Cubbi images + +A cubbi image is a flavored docker image that wrap a tool (let's say goose), and dynamically configure the tool when the image is starting. All cubbi images are defined in `cubbi/images` directory. + +Each image must have (let's take goose image for example): +- `goose/cubbi_image.yaml`, list of persistent paths, etc. +- `goose/Dockerfile`, that is used to build the cubbi image with cubbi tools +- `goose/goose_plugin.py`, a plugin file named of the cubbi image name, that is specific for this image, with the intent to configure dynamically the docker image when starting with the preferences of the user (via environment variable). They all import `cubbi_init.py`, but this file is shared accross all images, so it is normal that execution of the plugin import does not work, because the build system will copy the file in place during the build. +- `goose/README.md`, a tiny readme about the image + +If you are creating a new image, look about existing images (goose, opencode). diff --git a/README.md b/README.md index 80fd950..bc4705a 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Then compile your first image: ```bash cubbi image build goose cubbi image build opencode +cubbi image build crush ``` ### For Developers @@ -83,6 +84,7 @@ cubbi session close SESSION_ID # Create a session with a specific image cubbix --image goose cubbix --image opencode +cubbix --image crush # Create a session with environment variables cubbix -e VAR1=value1 -e VAR2=value2 @@ -138,6 +140,7 @@ Cubbi includes an image management system that allows you to build, manage, and | opencode | no | | claudecode | no | | aider | no | +| crush | no | ```bash # List available images @@ -146,10 +149,12 @@ cubbi image list # Get detailed information about an image cubbi image info goose cubbi image info opencode +cubbi image info crush # Build an image cubbi image build goose cubbi image build opencode +cubbi image build crush ``` Images are defined in the `cubbi/images/` directory, with each subdirectory containing: diff --git a/cubbi/images/crush/Dockerfile b/cubbi/images/crush/Dockerfile new file mode 100644 index 0000000..d1e2983 --- /dev/null +++ b/cubbi/images/crush/Dockerfile @@ -0,0 +1,62 @@ +FROM python:3.12-slim + +LABEL maintainer="team@monadical.com" +LABEL description="Crush AI coding assistant for Cubbi" + +# Install system dependencies including gosu for user switching and shadow for useradd/groupadd +RUN apt-get update && apt-get install -y --no-install-recommends \ + gosu \ + sudo \ + passwd \ + bash \ + curl \ + bzip2 \ + iputils-ping \ + iproute2 \ + libxcb1 \ + libdbus-1-3 \ + nano \ + tmux \ + git-core \ + ripgrep \ + openssh-client \ + vim \ + nodejs \ + npm \ + && rm -rf /var/lib/apt/lists/* + +# Install deps +WORKDIR /tmp +RUN curl -fsSL https://astral.sh/uv/install.sh -o install.sh && \ + sh install.sh && \ + mv /root/.local/bin/uv /usr/local/bin/uv && \ + mv /root/.local/bin/uvx /usr/local/bin/uvx && \ + rm install.sh + +# Install crush via npm +RUN npm install -g @charmland/crush + +# Create app directory +WORKDIR /app + +# Copy initialization system +COPY cubbi_init.py /cubbi/cubbi_init.py +COPY crush_plugin.py /cubbi/crush_plugin.py +COPY cubbi_image.yaml /cubbi/cubbi_image.yaml +COPY init-status.sh /cubbi/init-status.sh +RUN chmod +x /cubbi/cubbi_init.py /cubbi/init-status.sh +RUN echo '[ -x /cubbi/init-status.sh ] && /cubbi/init-status.sh' >> /etc/bash.bashrc + +# Set up environment +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV UV_LINK_MODE=copy + +# Pre-install the cubbi_init +RUN /cubbi/cubbi_init.py --help + +# Set WORKDIR to /app, common practice and expected by cubbi-init.sh +WORKDIR /app + +ENTRYPOINT ["/cubbi/cubbi_init.py"] +CMD ["tail", "-f", "/dev/null"] \ No newline at end of file diff --git a/cubbi/images/crush/README.md b/cubbi/images/crush/README.md new file mode 100644 index 0000000..9a62f39 --- /dev/null +++ b/cubbi/images/crush/README.md @@ -0,0 +1,77 @@ +# Crush Image for Cubbi + +This image provides a containerized environment for running [Crush](https://github.com/charmbracelet/crush), a terminal-based AI coding assistant. + +## Features + +- Pre-configured environment for Crush AI coding assistant +- Multi-model support (OpenAI, Anthropic, Groq) +- JSON-based configuration +- MCP server integration support +- Session preservation across runs + +## Environment Variables + +### AI Provider Configuration + +| Variable | Description | Required | Default | +|----------|-------------|----------|---------| +| `OPENAI_API_KEY` | OpenAI API key for crush | No | - | +| `ANTHROPIC_API_KEY` | Anthropic API key for crush | No | - | +| `GROQ_API_KEY` | Groq API key for crush | No | - | +| `OPENAI_URL` | Custom OpenAI-compatible API URL | No | - | +| `CUBBI_MODEL` | AI model to use with crush | No | - | +| `CUBBI_PROVIDER` | AI provider to use with crush | No | - | + +### Cubbi Core Variables + +| Variable | Description | Required | Default | +|----------|-------------|----------|---------| +| `CUBBI_USER_ID` | UID for the container user | No | `1000` | +| `CUBBI_GROUP_ID` | GID for the container user | No | `1000` | +| `CUBBI_RUN_COMMAND` | Command to execute after initialization | No | - | +| `CUBBI_NO_SHELL` | Exit after command execution | No | `false` | +| `CUBBI_CONFIG_DIR` | Directory for persistent configurations | No | `/cubbi-config` | +| `CUBBI_PERSISTENT_LINKS` | Semicolon-separated list of source:target symlinks | No | - | + +### MCP Integration Variables + +| Variable | Description | Required | +|----------|-------------|----------| +| `MCP_COUNT` | Number of available MCP servers | No | +| `MCP_NAMES` | JSON array of MCP server names | No | +| `MCP_{idx}_NAME` | Name of MCP server at index | No | +| `MCP_{idx}_TYPE` | Type of MCP server | No | +| `MCP_{idx}_HOST` | Hostname of MCP server | No | +| `MCP_{idx}_URL` | Full URL for remote MCP servers | No | + +## Build + +To build this image: + +```bash +cd cubbi/images/crush +docker build -t monadical/cubbi-crush:latest . +``` + +## Usage + +```bash +# Create a new session with this image +cubbix -i crush + +# Run crush with specific provider +cubbix -i crush -e CUBBI_PROVIDER=openai -e CUBBI_MODEL=gpt-4 + +# Test crush installation +cubbix -i crush --no-shell --run "crush --help" +``` + +## Configuration + +Crush uses JSON configuration stored in `/home/cubbi/.config/crush/config.json`. The plugin automatically configures: + +- AI providers based on available API keys +- Default models and providers from environment variables +- Session preservation settings +- MCP server integrations \ No newline at end of file diff --git a/cubbi/images/crush/crush_plugin.py b/cubbi/images/crush/crush_plugin.py new file mode 100644 index 0000000..968404f --- /dev/null +++ b/cubbi/images/crush/crush_plugin.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +Crush-specific plugin for Cubbi initialization +""" + +import json +import os +from pathlib import Path +from typing import Any, Dict + +from cubbi_init import ToolPlugin + + +class CrushPlugin(ToolPlugin): + """Plugin for Crush AI coding assistant initialization""" + + @property + def tool_name(self) -> str: + return "crush" + + def _get_user_ids(self) -> tuple[int, int]: + """Get the cubbi user and group IDs from environment""" + user_id = int(os.environ.get("CUBBI_USER_ID", "1000")) + group_id = int(os.environ.get("CUBBI_GROUP_ID", "1000")) + return user_id, group_id + + def _set_ownership(self, path: Path) -> None: + """Set ownership of a path to the cubbi user""" + user_id, group_id = self._get_user_ids() + try: + os.chown(path, user_id, group_id) + except OSError as e: + self.status.log(f"Failed to set ownership for {path}: {e}", "WARNING") + + def _get_user_config_path(self) -> Path: + """Get the correct config path for the cubbi user""" + return Path("/home/cubbi/.config/crush") + + def _ensure_user_config_dir(self) -> Path: + """Ensure config directory exists with correct ownership""" + config_dir = self._get_user_config_path() + + # Create the full directory path + try: + config_dir.mkdir(parents=True, exist_ok=True) + except FileExistsError: + # Directory already exists, which is fine + pass + except OSError as e: + self.status.log( + f"Failed to create config directory {config_dir}: {e}", "ERROR" + ) + return config_dir + + # Set ownership for the directories + config_parent = config_dir.parent + if config_parent.exists(): + self._set_ownership(config_parent) + + if config_dir.exists(): + self._set_ownership(config_dir) + + return config_dir + + def initialize(self) -> bool: + """Initialize Crush configuration""" + self._ensure_user_config_dir() + return self.setup_tool_configuration() + + def setup_tool_configuration(self) -> bool: + """Set up Crush configuration file""" + # Ensure directory exists before writing + config_dir = self._ensure_user_config_dir() + if not config_dir.exists(): + self.status.log( + f"Config directory {config_dir} does not exist and could not be created", + "ERROR", + ) + return False + + config_file = config_dir / "config.json" + + # Load or initialize configuration + if config_file.exists(): + try: + with config_file.open("r") as f: + config_data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + self.status.log(f"Failed to load existing config: {e}", "WARNING") + config_data = {} + else: + config_data = {} + + # Set default model and provider if specified + # cubbi_model = os.environ.get("CUBBI_MODEL") + # cubbi_provider = os.environ.get("CUBBI_PROVIDER") + # XXX i didn't understood yet the configuration file, tbd later. + + try: + with config_file.open("w") as f: + json.dump(config_data, f, indent=2) + + # Set ownership of the config file to cubbi user + self._set_ownership(config_file) + + self.status.log(f"Updated Crush configuration at {config_file}") + return True + except Exception as e: + self.status.log(f"Failed to write Crush configuration: {e}", "ERROR") + return False + + def integrate_mcp_servers(self, mcp_config: Dict[str, Any]) -> bool: + """Integrate Crush with available MCP servers""" + if mcp_config["count"] == 0: + self.status.log("No MCP servers to integrate") + return True + + # Ensure directory exists before writing + config_dir = self._ensure_user_config_dir() + if not config_dir.exists(): + self.status.log( + f"Config directory {config_dir} does not exist and could not be created", + "ERROR", + ) + return False + + config_file = config_dir / "config.json" + + if config_file.exists(): + try: + with config_file.open("r") as f: + config_data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + self.status.log(f"Failed to load existing config: {e}", "WARNING") + config_data = {} + else: + config_data = {} + + if "mcp_servers" not in config_data: + config_data["mcp_servers"] = {} + + for server in mcp_config["servers"]: + server_name = server["name"] + server_host = server["host"] + server_url = server["url"] + + if server_name and server_host: + mcp_url = f"http://{server_host}:8080/sse" + self.status.log(f"Adding MCP server: {server_name} - {mcp_url}") + + config_data["mcp_servers"][server_name] = { + "uri": mcp_url, + "type": server.get("type", "sse"), + "enabled": True, + } + elif server_name and server_url: + self.status.log( + f"Adding remote MCP server: {server_name} - {server_url}" + ) + + config_data["mcp_servers"][server_name] = { + "uri": server_url, + "type": server.get("type", "sse"), + "enabled": True, + } + + try: + with config_file.open("w") as f: + json.dump(config_data, f, indent=2) + + # Set ownership of the config file to cubbi user + self._set_ownership(config_file) + + return True + except Exception as e: + self.status.log(f"Failed to integrate MCP servers: {e}", "ERROR") + return False diff --git a/cubbi/images/crush/cubbi_image.yaml b/cubbi/images/crush/cubbi_image.yaml new file mode 100644 index 0000000..95e429b --- /dev/null +++ b/cubbi/images/crush/cubbi_image.yaml @@ -0,0 +1,54 @@ +name: crush +description: Crush AI coding assistant environment +version: 1.0.0 +maintainer: team@monadical.com +image: monadical/cubbi-crush:latest + +init: + pre_command: /cubbi-init.sh + command: /entrypoint.sh + +environment: + - name: OPENAI_API_KEY + description: OpenAI API key for crush + required: false + sensitive: true + + - name: ANTHROPIC_API_KEY + description: Anthropic API key for crush + required: false + sensitive: true + + - name: GROQ_API_KEY + description: Groq API key for crush + required: false + sensitive: true + + - name: OPENAI_URL + description: Custom OpenAI-compatible API URL + required: false + + - name: CUBBI_MODEL + description: AI model to use with crush + required: false + + - name: CUBBI_PROVIDER + description: AI provider to use with crush + required: false + +ports: + - 8000 + +volumes: + - mountPath: /app + description: Application directory + +persistent_configs: + - source: "/home/cubbi/.config/crush" + target: "/cubbi-config/crush-config" + type: "directory" + description: "Crush configuration directory" + - source: "/app/.crush" + target: "/cubbi-config/crush-app" + type: "directory" + description: "Crush application data and sessions" \ No newline at end of file