From 490ea66ae3523c75f28653c3f68f68c918b11884 Mon Sep 17 00:00:00 2001 From: Patrick Marsceill Date: Wed, 21 Jan 2026 15:45:43 -0500 Subject: [PATCH 1/3] feat(release): add automated binary distribution via GitHub - Add --version flag to CLI with build-time version injection via ldflags - Add GitHub Actions release workflow triggered on version tags (v*) - Build cross-platform binaries for linux/darwin on amd64/arm64 - Create install.sh script for curl-pipe-bash installation - Add tests for version flag and install script validation Co-Authored-By: Claude Opus 4.5 --- .github/workflows/release.yml | 86 +++++++++++++++++++++ install.sh | 136 ++++++++++++++++++++++++++++++++++ install_test.go | 87 ++++++++++++++++++++++ internal/cli/root.go | 10 ++- internal/cli/root_test.go | 72 ++++++++++++++++++ 5 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100755 install.sh create mode 100644 install_test.go create mode 100644 internal/cli/root_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..87e4d27 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,86 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build: + name: Build (${{ matrix.goos }}/${{ matrix.goarch }}) + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + cache: true + + - name: Get version from tag + id: version + run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + + - name: Build binaries + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + VERSION=${{ steps.version.outputs.VERSION }} + LDFLAGS="-X github.com/pmarsceill/mapcli/internal/cli.Version=${VERSION}" + + mkdir -p dist + go build -ldflags "${LDFLAGS}" -o dist/map ./cmd/map + go build -ldflags "${LDFLAGS}" -o dist/mapd ./cmd/mapd + + - name: Create tarball + run: | + PLATFORM="${{ matrix.goos }}-${{ matrix.goarch }}" + tar -czvf "map-${PLATFORM}.tar.gz" -C dist map mapd + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: map-${{ matrix.goos }}-${{ matrix.goarch }} + path: map-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz + + release: + name: Create Release + needs: build + runs-on: ubuntu-latest + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Collect tarballs + run: | + mkdir -p release + find artifacts -name "*.tar.gz" -exec mv {} release/ \; + ls -la release/ + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: release/*.tar.gz + generate_release_notes: true diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..708995e --- /dev/null +++ b/install.sh @@ -0,0 +1,136 @@ +#!/bin/bash +# +# MAP CLI Installer +# Usage: curl -fsSL https://raw.githubusercontent.com/pmarsceill/mapcli/main/install.sh | bash +# + +set -e + +REPO="pmarsceill/mapcli" +INSTALL_DIR="${MAP_INSTALL_DIR:-$HOME/.local/bin}" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +error() { + echo -e "${RED}[ERROR]${NC} $1" + exit 1 +} + +# Detect OS +detect_os() { + local os + os=$(uname -s) + case "$os" in + Linux) + echo "linux" + ;; + Darwin) + echo "darwin" + ;; + *) + error "Unsupported operating system: $os" + ;; + esac +} + +# Detect architecture +detect_arch() { + local arch + arch=$(uname -m) + case "$arch" in + x86_64|amd64) + echo "amd64" + ;; + arm64|aarch64) + echo "arm64" + ;; + *) + error "Unsupported architecture: $arch" + ;; + esac +} + +# Get latest release version from GitHub API +get_latest_version() { + local version + version=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + if [ -z "$version" ]; then + error "Failed to fetch latest version" + fi + echo "$version" +} + +main() { + info "Installing MAP CLI..." + + # Detect platform + local os arch platform + os=$(detect_os) + arch=$(detect_arch) + platform="${os}-${arch}" + info "Detected platform: $platform" + + # Get latest version + local version + version=$(get_latest_version) + info "Latest version: $version" + + # Construct download URL + local download_url="https://github.com/${REPO}/releases/download/${version}/map-${platform}.tar.gz" + info "Downloading from: $download_url" + + # Create install directory + mkdir -p "$INSTALL_DIR" + + # Download and extract + local tmp_dir + tmp_dir=$(mktemp -d) + trap 'rm -rf "$tmp_dir"' EXIT + + if ! curl -fsSL "$download_url" -o "$tmp_dir/map.tar.gz"; then + error "Failed to download release" + fi + + if ! tar -xzf "$tmp_dir/map.tar.gz" -C "$tmp_dir"; then + error "Failed to extract archive" + fi + + # Install binaries + mv "$tmp_dir/map" "$INSTALL_DIR/map" + mv "$tmp_dir/mapd" "$INSTALL_DIR/mapd" + chmod +x "$INSTALL_DIR/map" "$INSTALL_DIR/mapd" + + info "Installed map and mapd to $INSTALL_DIR" + + # Verify installation + if "$INSTALL_DIR/map" --version > /dev/null 2>&1; then + info "Installation verified: $($INSTALL_DIR/map --version)" + else + warn "Installation completed but verification failed" + fi + + # Check if install dir is in PATH + if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then + warn "$INSTALL_DIR is not in your PATH" + echo "" + echo "Add it to your shell profile:" + echo " export PATH=\"\$PATH:$INSTALL_DIR\"" + echo "" + fi + + info "Installation complete!" +} + +main "$@" diff --git a/install_test.go b/install_test.go new file mode 100644 index 0000000..dd5bfe0 --- /dev/null +++ b/install_test.go @@ -0,0 +1,87 @@ +package mapcli + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +func TestInstallScriptExists(t *testing.T) { + _, err := os.Stat("install.sh") + if os.IsNotExist(err) { + t.Fatal("install.sh does not exist") + } + if err != nil { + t.Fatalf("failed to stat install.sh: %v", err) + } +} + +func TestInstallScriptExecutable(t *testing.T) { + info, err := os.Stat("install.sh") + if err != nil { + t.Fatalf("failed to stat install.sh: %v", err) + } + + // Check if executable bit is set for owner + if info.Mode()&0100 == 0 { + t.Error("install.sh should be executable") + } +} + +func TestInstallScriptSyntax(t *testing.T) { + cmd := exec.Command("bash", "-n", "install.sh") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("install.sh has syntax errors: %v\n%s", err, output) + } +} + +func TestInstallScriptContent(t *testing.T) { + content, err := os.ReadFile("install.sh") + if err != nil { + t.Fatalf("failed to read install.sh: %v", err) + } + + script := string(content) + + // Check for required elements + checks := []struct { + name string + contains string + }{ + {"shebang", "#!/bin/bash"}, + {"repo reference", "pmarsceill/mapcli"}, + {"OS detection", "uname -s"}, + {"arch detection", "uname -m"}, + {"GitHub API", "api.github.com"}, + {"install dir", "INSTALL_DIR"}, + {"error handling", "set -e"}, + {"linux support", "linux"}, + {"darwin support", "darwin"}, + {"amd64 support", "amd64"}, + {"arm64 support", "arm64"}, + } + + for _, check := range checks { + t.Run(check.name, func(t *testing.T) { + if !strings.Contains(script, check.contains) { + t.Errorf("install.sh should contain %q", check.contains) + } + }) + } +} + +func TestInstallScriptShellcheck(t *testing.T) { + // Skip if shellcheck is not installed + _, err := exec.LookPath("shellcheck") + if err != nil { + t.Skip("shellcheck not installed") + } + + cmd := exec.Command("shellcheck", "install.sh") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("shellcheck found issues:\n%s", output) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index f2982c2..149d3b1 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -7,13 +7,17 @@ import ( "github.com/spf13/cobra" ) +// Version is set via -ldflags at build time +var Version = "dev" + var socketPath string // rootCmd is the base command var rootCmd = &cobra.Command{ - Use: "map", - Short: "Multi-agent coordination CLI", - Long: `map is a CLI for coordinating multiple agents through the mapd daemon.`, + Use: "map", + Short: "Multi-agent coordination CLI", + Long: `map is a CLI for coordinating multiple agents through the mapd daemon.`, + Version: Version, } // Execute runs the CLI diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 0000000..e48764c --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,72 @@ +package cli + +import ( + "bytes" + "testing" +) + +func TestVersionDefault(t *testing.T) { + // Version should have a default value + if Version == "" { + t.Error("Version should not be empty") + } +} + +func TestRootCmdHasVersion(t *testing.T) { + // Root command should have version set + if rootCmd.Version == "" { + t.Error("rootCmd.Version should not be empty") + } + + // Version on rootCmd should match the package Version variable + if rootCmd.Version != Version { + t.Errorf("rootCmd.Version (%q) should match Version (%q)", rootCmd.Version, Version) + } +} + +func TestVersionFlag(t *testing.T) { + // Capture output when running with --version flag + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs([]string{"--version"}) + + err := rootCmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + if output == "" { + t.Error("--version should produce output") + } + + // Output should contain the version + if !containsString(output, Version) { + t.Errorf("version output %q should contain version %q", output, Version) + } + + // Reset args for other tests + rootCmd.SetArgs([]string{}) +} + +func TestVersionFlagShort(t *testing.T) { + // Capture output when running with -v flag + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs([]string{"-v"}) + + err := rootCmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + if output == "" { + t.Error("-v should produce output") + } + + // Reset args for other tests + rootCmd.SetArgs([]string{}) +} From 78a812c99bd27558d38d7bd7f0a966b6b1ba3526 Mon Sep 17 00:00:00 2001 From: Patrick Marsceill Date: Wed, 21 Jan 2026 15:46:24 -0500 Subject: [PATCH 2/3] docs: add installation instructions to README Co-Authored-By: Claude Opus 4.5 --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 1de86ff..124c488 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,29 @@ A tool for spawning and managing multiple AI coding agents (Claude Code and Open https://github.com/user-attachments/assets/1e0f02fe-fdbb-4cf7-bff4-a2161662b7a2 +## Installation + +**Quick install (macOS and Linux):** + +```bash +curl -fsSL https://raw.githubusercontent.com/pmarsceill/mapcli/main/install.sh | bash +``` + +This installs both `map` and `mapd` to `~/.local/bin`. Make sure this directory is in your PATH. + +**Manual installation:** + +Download the latest release from the [releases page](https://github.com/pmarsceill/mapcli/releases) and extract the binaries to a directory in your PATH. + +**Build from source:** + +```bash +git clone https://github.com/pmarsceill/mapcli.git +cd mapcli +make build +# Binaries are in bin/ +``` + ## Overview MAP (Multi-Agent Platform) provides infrastructure for spawning and coordinating multiple AI coding agents. It supports both **Claude Code** and **OpenAI Codex** agents. The architecture separates concerns: From eca882d843d519f4e40eaad649cb37a7cd6a0825 Mon Sep 17 00:00:00 2001 From: Patrick Marsceill Date: Wed, 21 Jan 2026 15:49:56 -0500 Subject: [PATCH 3/3] fix(install): quote variable to satisfy shellcheck SC2086 Co-Authored-By: Claude Opus 4.5 --- install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 708995e..f23a3f3 100755 --- a/install.sh +++ b/install.sh @@ -116,7 +116,7 @@ main() { # Verify installation if "$INSTALL_DIR/map" --version > /dev/null 2>&1; then - info "Installation verified: $($INSTALL_DIR/map --version)" + info "Installation verified: $("$INSTALL_DIR"/map --version)" else warn "Installation completed but verification failed" fi