diff --git a/.icons/cmux.svg b/.icons/cmux.svg new file mode 100644 index 000000000..95b56bb00 --- /dev/null +++ b/.icons/cmux.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/registry/coder/modules/cmux/README.md b/registry/coder/modules/cmux/README.md new file mode 100644 index 000000000..503bbea27 --- /dev/null +++ b/registry/coder/modules/cmux/README.md @@ -0,0 +1,104 @@ +--- +display_name: cmux +description: Coding Agent Multiplexer - Run multiple AI agents in parallel +icon: ../../../../.icons/cmux.svg +verified: false +tags: [ai, agents, development, multiplexer] +--- + +# cmux + +Automatically install and run [cmux](https://github.com/coder/cmux) in a Coder workspace. By default, the module installs `@coder/cmux@latest` from npm (with a fallback to downloading the npm tarball if npm is unavailable). cmux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated cmux workspaces. + +```tf +module "cmux" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/cmux/coder" + version = "1.0.0" + agent_id = coder_agent.example.id +} +``` + +## Features + +- **Parallel Agent Execution**: Run multiple AI agents simultaneously on different tasks +- **Cmux Workspace Isolation**: Each agent works in its own isolated environment +- **Git Divergence Visualization**: Track changes across different cmux agent workspaces +- **Long-Running Processes**: Resume AI work after interruptions +- **Cost Tracking**: Monitor API usage across agents + +## Examples + +### Basic Usage + +```tf +module "cmux" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/cmux/coder" + version = "1.0.0" + agent_id = coder_agent.example.id +} +``` + +### Pin Version + +```tf +module "cmux" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/cmux/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + # Default is "latest"; set to a specific version to pin + install_version = "0.4.0" +} +``` + +### Custom Port + +```tf +module "cmux" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/cmux/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + port = 8080 +} +``` + +### Use Cached Installation + +Run an existing copy of cmux if found, otherwise install from npm: + +```tf +module "cmux" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/cmux/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + use_cached = true +} +``` + +### Skip Install + +Run without installing from the network (requires cmux to be pre-installed): + +```tf +module "cmux" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/cmux/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + install = false +} +``` + +## Supported Platforms + +- Linux (x86_64, aarch64) + +## Notes + +- cmux is currently in preview and you may encounter bugs +- Requires internet connectivity for agent operations (unless `install` is set to false) +- Installs `@coder/cmux` from npm by default (falls back to the npm tarball if npm is unavailable) diff --git a/registry/coder/modules/cmux/cmux.tftest.hcl b/registry/coder/modules/cmux/cmux.tftest.hcl new file mode 100644 index 000000000..3b831b373 --- /dev/null +++ b/registry/coder/modules/cmux/cmux.tftest.hcl @@ -0,0 +1,64 @@ +run "required_vars" { + command = plan + + variables { + agent_id = "foo" + } +} + +run "install_false_and_use_cached_conflict" { + command = plan + + variables { + agent_id = "foo" + use_cached = true + install = false + } + + expect_failures = [ + resource.coder_script.cmux + ] +} + +run "custom_port" { + command = plan + + variables { + agent_id = "foo" + port = 8080 + } + + assert { + condition = resource.coder_app.cmux.url == "http://localhost:8080" + error_message = "coder_app URL must use the configured port" + } +} + +run "custom_version" { + command = plan + + variables { + agent_id = "foo" + install_version = "0.3.0" + } +} + +# install=false should succeed +run "install_false_only_success" { + command = plan + + variables { + agent_id = "foo" + install = false + } +} + +# use_cached-only should succeed +run "use_cached_only_success" { + command = plan + + variables { + agent_id = "foo" + use_cached = true + } +} diff --git a/registry/coder/modules/cmux/main.test.ts b/registry/coder/modules/cmux/main.test.ts new file mode 100644 index 000000000..5ff42c3f0 --- /dev/null +++ b/registry/coder/modules/cmux/main.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("cmux", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("runs with default", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + + const output = await executeScriptInContainer( + state, + "alpine/curl", + "sh", + "apk add --no-cache bash tar gzip ca-certificates findutils nodejs && update-ca-certificates", + ); + if (output.exitCode !== 0) { + console.log("STDOUT:\n" + output.stdout.join("\n")); + console.log("STDERR:\n" + output.stderr.join("\n")); + } + expect(output.exitCode).toBe(0); + const expectedLines = [ + "📥 npm not found; downloading tarball from npm registry...", + "🥳 cmux has been installed in /tmp/cmux", + "🚀 Starting cmux server on port 4000...", + "Check logs at /tmp/cmux.log!", + ]; + for (const line of expectedLines) { + expect(output.stdout).toContain(line); + } + }, 60000); + + it("runs with npm present", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + + const output = await executeScriptInContainer( + state, + "node:20-alpine", + "sh", + "apk add bash", + ); + + expect(output.exitCode).toBe(0); + const expectedLines = [ + "📦 Installing @coder/cmux via npm into /tmp/cmux...", + "🥳 cmux has been installed in /tmp/cmux", + "🚀 Starting cmux server on port 4000...", + "Check logs at /tmp/cmux.log!", + ]; + for (const line of expectedLines) { + expect(output.stdout).toContain(line); + } + }, 60000); +}); diff --git a/registry/coder/modules/cmux/main.tf b/registry/coder/modules/cmux/main.tf new file mode 100644 index 000000000..b1c318d80 --- /dev/null +++ b/registry/coder/modules/cmux/main.tf @@ -0,0 +1,149 @@ +terraform { + # Requires Terraform 1.9+ for cross-variable validation references + required_version = ">= 1.9" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.5" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "port" { + type = number + description = "The port to run cmux on." + default = 4000 +} + +variable "display_name" { + type = string + description = "The display name for the cmux application." + default = "cmux" +} + +variable "slug" { + type = string + description = "The slug for the cmux application." + default = "cmux" +} + +variable "install_prefix" { + type = string + description = "The prefix to install cmux to." + default = "/tmp/cmux" +} + +variable "log_path" { + type = string + description = "The path for cmux logs." + default = "/tmp/cmux.log" +} + +variable "install_version" { + type = string + description = "The version of cmux to install." + default = "latest" +} + +variable "share" { + type = string + default = "owner" + validation { + condition = var.share == "owner" || var.share == "authenticated" || var.share == "public" + error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'." + } +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "install" { + type = bool + description = "Install cmux from the network (npm or tarball). If false, run without installing (requires a pre-installed cmux)." + default = true +} + +variable "use_cached" { + type = bool + description = "Use cached copy of cmux if present; otherwise install from npm" + default = false +} + +variable "subdomain" { + type = bool + description = <<-EOT + Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder. + If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible. + EOT + default = false +} + +variable "open_in" { + type = string + description = <<-EOT + Determines where the app will be opened. Valid values are `"tab"` and `"slim-window" (default)`. + `"tab"` opens in a new tab in the same browser window. + `"slim-window"` opens a new browser window without navigation controls. + EOT + default = "slim-window" + validation { + condition = contains(["tab", "slim-window"], var.open_in) + error_message = "The 'open_in' variable must be one of: 'tab', 'slim-window'." + } +} + +resource "coder_script" "cmux" { + agent_id = var.agent_id + display_name = "cmux" + icon = "/icon/terminal.svg" + script = templatefile("${path.module}/run.sh", { + VERSION : var.install_version, + PORT : var.port, + LOG_PATH : var.log_path, + INSTALL_PREFIX : var.install_prefix, + OFFLINE : !var.install, + USE_CACHED : var.use_cached, + }) + run_on_start = true + + lifecycle { + precondition { + condition = var.install || !var.use_cached + error_message = "Cannot use 'use_cached' when 'install' is false" + } + } +} + +resource "coder_app" "cmux" { + agent_id = var.agent_id + slug = var.slug + display_name = var.display_name + url = "http://localhost:${var.port}" + icon = "/icon/terminal.svg" + subdomain = var.subdomain + share = var.share + order = var.order + group = var.group + open_in = var.open_in + + healthcheck { + url = "http://localhost:${var.port}/health" + interval = 5 + threshold = 6 + } +} diff --git a/registry/coder/modules/cmux/run.sh b/registry/coder/modules/cmux/run.sh new file mode 100644 index 000000000..23656f8f7 --- /dev/null +++ b/registry/coder/modules/cmux/run.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash + +BOLD='\033[0;1m' +RESET='\033[0m' +CMUX_BINARY="${INSTALL_PREFIX}/cmux" + +function run_cmux() { + local port_value + port_value="${PORT}" + if [ -z "$port_value" ]; then + port_value="4000" + fi + echo "🚀 Starting cmux server on port $port_value..." + echo "Check logs at ${LOG_PATH}!" + PORT="$port_value" "$CMUX_BINARY" server --port "$port_value" > "${LOG_PATH}" 2>&1 & +} + +# Check if cmux is already installed for offline mode +if [ "${OFFLINE}" = true ]; then + if [ -f "$CMUX_BINARY" ]; then + echo "🥳 Found a copy of cmux" + run_cmux + exit 0 + fi + echo "❌ Failed to find a copy of cmux" + exit 1 +fi + +# If there is no cached install OR we don't want to use a cached install +if [ ! -f "$CMUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then + printf "$${BOLD}Installing cmux from npm...\n" + + # Clean up from other install (in case install prefix changed). + if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ -e "$CODER_SCRIPT_BIN_DIR/cmux" ]; then + rm "$CODER_SCRIPT_BIN_DIR/cmux" + fi + + mkdir -p "$(dirname "$CMUX_BINARY")" + + if command -v npm > /dev/null 2>&1; then + echo "📦 Installing @coder/cmux via npm into ${INSTALL_PREFIX}..." + NPM_WORKDIR="${INSTALL_PREFIX}/npm" + mkdir -p "$NPM_WORKDIR" + cd "$NPM_WORKDIR" || exit 1 + if [ ! -f package.json ]; then + echo '{}' > package.json + fi + PKG="@coder/cmux" + if [ -z "${VERSION}" ] || [ "${VERSION}" = "latest" ]; then + PKG_SPEC="$PKG@latest" + else + PKG_SPEC="$PKG@${VERSION}" + fi + if ! npm install --no-audit --no-fund --omit=dev "$PKG_SPEC"; then + echo "❌ Failed to install @coder/cmux via npm" + exit 1 + fi + # Determine the installed binary path + BIN_DIR="$NPM_WORKDIR/node_modules/.bin" + CANDIDATE="$BIN_DIR/cmux" + if [ ! -f "$CANDIDATE" ]; then + echo "❌ Could not locate cmux binary after npm install" + exit 1 + fi + chmod +x "$CANDIDATE" || true + ln -sf "$CANDIDATE" "$CMUX_BINARY" + else + echo "📥 npm not found; downloading tarball from npm registry..." + VERSION_TO_USE="${VERSION}" + if [ -z "$VERSION_TO_USE" ] || [ "$VERSION_TO_USE" = "latest" ]; then + # Try to determine the latest version + META_URL="https://registry.npmjs.org/@coder/cmux/latest" + VERSION_TO_USE="$(curl -fsSL "$META_URL" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p' | head -n1)" + if [ -z "$VERSION_TO_USE" ]; then + echo "❌ Could not determine latest version for @coder/cmux" + exit 1 + fi + fi + TARBALL_URL="https://registry.npmjs.org/@coder/cmux/-/cmux-$VERSION_TO_USE.tgz" + TMP_DIR="$(mktemp -d)" + TAR_PATH="$TMP_DIR/cmux.tgz" + if ! curl -fsSL "$TARBALL_URL" -o "$TAR_PATH"; then + echo "❌ Failed to download tarball: $TARBALL_URL" + rm -rf "$TMP_DIR" + exit 1 + fi + if ! tar -xzf "$TAR_PATH" -C "$TMP_DIR"; then + echo "❌ Failed to extract tarball" + rm -rf "$TMP_DIR" + exit 1 + fi + CANDIDATE="" + # Common locations + if [ -f "$TMP_DIR/package/bin/cmux" ]; then + CANDIDATE="$TMP_DIR/package/bin/cmux" + elif [ -f "$TMP_DIR/package/bin/cmux.js" ]; then + CANDIDATE="$TMP_DIR/package/bin/cmux.js" + elif [ -f "$TMP_DIR/package/bin/cmux.mjs" ]; then + CANDIDATE="$TMP_DIR/package/bin/cmux.mjs" + else + # Try to read package.json bin field + if [ -f "$TMP_DIR/package/package.json" ]; then + BIN_PATH=$(sed -n 's/.*"bin"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$TMP_DIR/package/package.json" | head -n1) + if [ -z "$BIN_PATH" ]; then + BIN_PATH=$(sed -n '/"bin"[[:space:]]*:[[:space:]]*{/,/}/p' "$TMP_DIR/package/package.json" | sed -n 's/.*"cmux"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1) + fi + if [ -n "$BIN_PATH" ] && [ -f "$TMP_DIR/package/$BIN_PATH" ]; then + CANDIDATE="$TMP_DIR/package/$BIN_PATH" + fi + fi + # Fallback: search for plausible filenames + if [ -z "$CANDIDATE" ] || [ ! -f "$CANDIDATE" ]; then + CANDIDATE=$(find "$TMP_DIR/package" -maxdepth 4 -type f \( -name "cmux" -o -name "cmux.js" -o -name "cmux.mjs" -o -name "cmux.cjs" \) | head -n1) + fi + fi + if [ -z "$CANDIDATE" ] || [ ! -f "$CANDIDATE" ]; then + echo "❌ Could not locate cmux binary in tarball" + rm -rf "$TMP_DIR" + exit 1 + fi + cp "$CANDIDATE" "$CMUX_BINARY" + chmod +x "$CMUX_BINARY" || true + rm -rf "$TMP_DIR" + fi + + printf "🥳 cmux has been installed in ${INSTALL_PREFIX}\n\n" +fi + +# Make cmux available in PATH if CODER_SCRIPT_BIN_DIR is set +if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ ! -e "$CODER_SCRIPT_BIN_DIR/cmux" ]; then + ln -s "$CMUX_BINARY" "$CODER_SCRIPT_BIN_DIR/cmux" +fi + +# Start cmux +run_cmux