diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..71c82ff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +# Cancel in-progress runs when a new commit lands on the same branch / +# pr, so we don't waste minutes on stale SHAs. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + + - name: set up go + uses: actions/setup-go@v5 + with: + go-version: "1.23" + cache: true + + - name: go vet + run: go vet ./... + + - name: gofmt check + # Windows' bash is mingw; gofmt outputs paths with slashes so the + # diff check works there too. Skip on windows only if needed. + if: runner.os != 'Windows' + run: | + diff=$(gofmt -l .) + if [ -n "$diff" ]; then + echo "gofmt issues:" + echo "$diff" + exit 1 + fi + + - name: go test + run: go test -race ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7871609 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,43 @@ +name: release + +on: + push: + tags: + - "v*" + +permissions: + # Needed for GoReleaser to create the GitHub Release and upload assets. + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + with: + # GoReleaser needs the full history to build a useful + # changelog from commit messages. + fetch-depth: 0 + + - name: set up go + uses: actions/setup-go@v5 + with: + go-version: "1.23" + cache: true + + - name: run goreleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + # Default token for the release itself (uploads artifacts, + # creates the release page). + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Optional: PAT with `repo` scope for patriceckhart/homebrew-tap + # so we can push a Formula/zot.rb update on every release. + # Create the secret in repo settings → actions → secrets. + # If unset, goreleaser skips the brew step (skip_upload: auto). + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..9d7ca24 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,122 @@ +# GoReleaser config — builds cross-platform binaries and GitHub Release +# artifacts for zot. +# +# Triggered by pushing a tag like `v0.1.0`. The release workflow runs +# `goreleaser release --clean`, which compiles every target, packs +# them into `.tar.gz` (Unix) / `.zip` (Windows) archives, generates a +# checksum file, and uploads everything to a GitHub Release. + +version: 2 + +project_name: zot + +before: + hooks: + - go mod tidy + +builds: + - id: zot + main: ./cmd/zot + binary: zot + env: + - CGO_ENABLED=0 + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + # Windows/arm64 is shipped by Go but rarely used — skip to keep the + # release artifact list tidy. + ignore: + - goos: windows + goarch: arm64 + flags: + - -trimpath + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.Date}} + +archives: + - id: zot + # Human-friendly name: zot_0.1.0_darwin_arm64.tar.gz + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + files: + - LICENSE + - README.md + format_overrides: + - goos: windows + format: zip + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +snapshot: + # Used by `goreleaser release --snapshot`; lets contributors build + # a local release without pushing a tag. + version_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + use: github + filters: + exclude: + - "^docs:" + - "^chore:" + - "^ci:" + - "^test:" + groups: + - title: features + regexp: "^.*feat[(\\w)]*:+.*$" + order: 0 + - title: fixes + regexp: "^.*fix[(\\w)]*:+.*$" + order: 1 + - title: other + order: 999 + +release: + github: + owner: patriceckhart + name: zot + draft: false + prerelease: auto + # Auto-generated release notes include the changelog groups above plus + # an install snippet so the release page itself is a landing page. + header: | + ## zot {{ .Tag }} + + one-liner install: + + ```bash + curl -fsSL https://raw.githubusercontent.com/patriceckhart/zot/main/install.sh | bash + ``` + + homebrew: + + ```bash + brew install patriceckhart/tap/zot + ``` + + or download a binary below, `chmod +x`, and drop it on your `$PATH`. + +# Optional: publish a Homebrew formula to a tap repo on every release. +# Requires the tap repo to exist at github.com/patriceckhart/homebrew-tap +# and a PAT with `repo` scope exported as `HOMEBREW_TAP_TOKEN` in the +# release workflow. Safe to leave enabled even before the tap exists; +# goreleaser skips it when the token isn't set. +brews: + - repository: + owner: patriceckhart + name: homebrew-tap + token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" + name: zot + homepage: https://github.com/patriceckhart/zot + description: "lightweight coding agent harness — anthropic + openai, four tools, tui" + license: MIT + install: | + bin.install "zot" + test: | + system "#{bin}/zot", "--help" + # Only publish the formula when the token is actually set, so + # ordinary tag pushes from forks don't explode. + skip_upload: auto diff --git a/Makefile b/Makefile index c6b664e..7067f27 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ -VERSION ?= dev +# Local / untagged builds ship as 0.0.0. Release builds are driven by +# goreleaser which overrides VERSION from the git tag. +VERSION ?= 0.0.0 LDFLAGS := -s -w -X main.version=$(VERSION) .PHONY: build install test lint fmt clean release diff --git a/README.md b/README.md index f474835..df9ed41 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,39 @@ yet another coding agent harness, lightweight and written (vibe-slopped) in go. ## install +### one-liner (macos + linux) + +```bash +curl -fsSL https://raw.githubusercontent.com/patriceckhart/zot/main/install.sh | bash +``` + +detects your os/arch, downloads the latest release from github, verifies the sha256 against the release's `checksums.txt`, extracts the binary, and drops it in `/usr/local/bin`, `~/.local/bin`, or `~/bin` — whichever is writable first. pass a version or prefix to pin: + +```bash +curl -fsSL https://raw.githubusercontent.com/patriceckhart/zot/main/install.sh | bash -s -- v0.0.1 ~/bin +``` + +### one-liner (windows, powershell) + +```powershell +iwr -useb https://raw.githubusercontent.com/patriceckhart/zot/main/install.ps1 | iex +``` + +drops `zot.exe` into `$HOME\bin` and adds it to the user PATH if missing. open a fresh terminal afterwards. + +### homebrew (macos + linux) + +```bash +brew install patriceckhart/tap/zot +``` + +### go install + ```bash go install github.com/patriceckhart/zot/cmd/zot@latest ``` -or from source: +### from source ```bash git clone https://github.com/patriceckhart/zot @@ -25,6 +53,10 @@ make build # produces ./bin/zot make install # into $GOPATH/bin ``` +### prebuilt binaries + +every release on the [releases page](https://github.com/patriceckhart/zot/releases) ships archives for linux, macos, and windows on amd64 + arm64 (except windows/arm64), plus a `checksums.txt` file. download, verify, `chmod +x`, and drop on your `$PATH`. + ## authenticate the easiest way is to just run `zot` and type `/login`. the tui opens even without credentials and walks you through a browser-based login flow. diff --git a/cmd/zot/main.go b/cmd/zot/main.go index 8676d87..1c2dc90 100644 --- a/cmd/zot/main.go +++ b/cmd/zot/main.go @@ -8,10 +8,33 @@ import ( "github.com/patriceckhart/zot/internal/agent" ) -var version = "dev" +// Injected at build time via -ldflags "-X main.version=... -X main.commit=... -X main.date=...". +// See .goreleaser.yaml for the release build and the Makefile for +// local builds. Defaults make `zot --version` print something sensible +// when built without ldflags. +var ( + // 0.0.0 is the pre-release placeholder for local / untagged + // builds. The first published GitHub release will be tagged + // v0.0.1; everything before that ships as 0.0.0 from source. + version = "0.0.0" + commit = "" + date = "" +) func main() { - if err := agent.Run(os.Args[1:], version); err != nil { + v := version + if commit != "" { + short := commit + if len(short) > 7 { + short = short[:7] + } + v = v + " (" + short + if date != "" { + v = v + ", " + date + } + v = v + ")" + } + if err := agent.Run(os.Args[1:], v); err != nil { fmt.Fprintln(os.Stderr, "zot:", err) os.Exit(1) } diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..e5308da --- /dev/null +++ b/install.ps1 @@ -0,0 +1,115 @@ +# zot installer for Windows (PowerShell). +# +# Usage (in PowerShell): +# iwr -useb https://raw.githubusercontent.com/patriceckhart/zot/main/install.ps1 | iex +# +# Or with arguments: +# $env:ZOT_VERSION = "v0.0.1" +# $env:ZOT_PREFIX = "$HOME\bin" +# iwr -useb https://raw.githubusercontent.com/patriceckhart/zot/main/install.ps1 | iex +# +# Detects architecture, downloads the matching .zip from the GitHub +# release, verifies the sha256 against checksums.txt, extracts zot.exe, +# and moves it into $ZOT_PREFIX (defaults to $HOME\bin, added to PATH +# via the User environment if missing). + +[CmdletBinding()] +param( + [string]$Version = $env:ZOT_VERSION, + [string]$Prefix = $env:ZOT_PREFIX +) + +$ErrorActionPreference = "Stop" + +$owner = "patriceckhart" +$repo = "zot" +$binary = "zot" + +if (-not $Version) { $Version = "latest" } +if (-not $Prefix) { $Prefix = Join-Path $HOME "bin" } + +function Msg($m) { Write-Host "==> $m" -ForegroundColor Cyan } +function Warn($m) { Write-Warning $m } +function Die($m) { Write-Error $m; exit 1 } + +# ---- detect architecture ---- + +switch -wildcard ($env:PROCESSOR_ARCHITECTURE) { + "AMD64" { $arch = "amd64" } + "ARM64" { $arch = "arm64" } + default { Die "unsupported arch: $($env:PROCESSOR_ARCHITECTURE)" } +} + +# ARM64 Windows isn't shipped (see .goreleaser.yaml ignore rule) — fall +# back to amd64 which runs fine under ARM64 emulation. +if ($arch -eq "arm64") { + Warn "windows/arm64 is not published; falling back to amd64" + $arch = "amd64" +} + +# ---- resolve version ---- + +if ($Version -eq "latest") { + $resp = Invoke-WebRequest -UseBasicParsing -MaximumRedirection 5 ` + "https://github.com/$owner/$repo/releases/latest" + if ($resp.BaseResponse.ResponseUri -match '/tag/([^/]+)') { + $Version = $Matches[1] + } else { + Die "could not resolve latest version" + } +} + +if (-not $Version.StartsWith("v")) { $Version = "v$Version" } +$verNum = $Version.TrimStart("v") + +# ---- download + verify + extract ---- + +$archive = "${binary}_${verNum}_windows_${arch}.zip" +$baseUrl = "https://github.com/$owner/$repo/releases/download/$Version" +$archiveUrl = "$baseUrl/$archive" +$checksumUrl = "$baseUrl/checksums.txt" + +$tmp = New-Item -ItemType Directory -Path (Join-Path $env:TEMP ("zot-install-" + [System.Guid]::NewGuid().ToString("N").Substring(0,8))) + +try { + Msg "downloading $archive" + Invoke-WebRequest -UseBasicParsing -Uri $archiveUrl -OutFile (Join-Path $tmp $archive) + + Msg "verifying checksum" + $checksums = Invoke-WebRequest -UseBasicParsing -Uri $checksumUrl | Select-Object -ExpandProperty Content + $expected = ($checksums -split "`n" | Where-Object { $_ -match [regex]::Escape($archive) + "$" } | Select-Object -First 1) + if (-not $expected) { Die "no checksum for $archive in checksums.txt" } + $expectedHash = ($expected -split "\s+")[0] + + $actualHash = (Get-FileHash -Path (Join-Path $tmp $archive) -Algorithm SHA256).Hash.ToLower() + if ($expectedHash.ToLower() -ne $actualHash) { + Die "checksum mismatch: expected $expectedHash, got $actualHash" + } + + Msg "extracting" + Expand-Archive -Path (Join-Path $tmp $archive) -DestinationPath $tmp -Force + + $exe = Join-Path $tmp "$binary.exe" + if (-not (Test-Path $exe)) { Die "archive did not contain $binary.exe" } + + Msg "installing to $Prefix\$binary.exe" + New-Item -ItemType Directory -Path $Prefix -Force | Out-Null + Copy-Item $exe (Join-Path $Prefix "$binary.exe") -Force + + # ---- PATH hint ---- + + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $parts = $userPath -split ";" | Where-Object { $_ } + if (-not ($parts -contains $Prefix)) { + Warn "$Prefix is not on your user PATH" + Warn "adding it for future sessions..." + [Environment]::SetEnvironmentVariable("Path", ($userPath.TrimEnd(";") + ";" + $Prefix), "User") + Warn "open a new terminal to pick up the change, or run:" + Warn " `$env:Path = `"$Prefix;`$env:Path`"" + } + + Msg "installed. run: zot --help" +} +finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..f3d4b99 --- /dev/null +++ b/install.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# +# zot installer. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/patriceckhart/zot/main/install.sh | bash +# curl -fsSL https://raw.githubusercontent.com/patriceckhart/zot/main/install.sh | bash -s -- v0.0.1 ~/bin +# +# Positional arguments: +# $1 version — release tag (e.g. v0.0.1). Defaults to "latest". +# $2 prefix — install directory. Defaults to the first writable +# directory in: /usr/local/bin, $HOME/.local/bin, +# $HOME/bin. Created if missing. Add it to your PATH +# if it isn't already. +# +# Environment overrides: +# ZOT_VERSION same as $1 +# ZOT_PREFIX same as $2 +# +# The script detects your OS and architecture, downloads the matching +# archive from the GitHub release, verifies the sha256 against the +# release's checksums.txt, extracts the binary, and moves it into the +# prefix directory. No sudo unless you explicitly pick a prefix that +# needs it. + +set -euo pipefail + +OWNER="patriceckhart" +REPO="zot" +BINARY="zot" + +VERSION="${1:-${ZOT_VERSION:-latest}}" +PREFIX="${2:-${ZOT_PREFIX:-}}" + +msg() { printf "\033[1m==>\033[0m %s\n" "$*"; } +warn() { printf "\033[33mwarn:\033[0m %s\n" "$*" >&2; } +die() { printf "\033[31merror:\033[0m %s\n" "$*" >&2; exit 1; } + +command -v curl >/dev/null 2>&1 || die "curl is required" +command -v tar >/dev/null 2>&1 || die "tar is required" + +# ---- detect OS + arch ---- + +uname_s=$(uname -s) +uname_m=$(uname -m) + +case "$uname_s" in + Linux) OS=linux ;; + Darwin) OS=darwin ;; + MINGW*|MSYS*|CYGWIN*) + die "windows detected — use install.ps1 from powershell instead" + ;; + *) die "unsupported os: $uname_s" ;; +esac + +case "$uname_m" in + x86_64|amd64) ARCH=amd64 ;; + arm64|aarch64) ARCH=arm64 ;; + *) die "unsupported arch: $uname_m" ;; +esac + +# ---- resolve version ---- + +if [ "$VERSION" = "latest" ]; then + # GitHub's /releases/latest endpoint redirects to /releases/tag/. + # We follow the redirect and grab the tag from the final URL without + # needing jq. + VERSION=$(curl -fsSLI -o /dev/null -w '%{url_effective}' \ + "https://github.com/${OWNER}/${REPO}/releases/latest" \ + | sed -E 's|.*/tag/([^/]+).*|\1|') + [ -n "$VERSION" ] || die "could not resolve latest version" +fi + +case "$VERSION" in v*) ;; *) VERSION="v$VERSION" ;; esac +VER_NUM="${VERSION#v}" + +# ---- pick an install prefix ---- + +pick_prefix() { + local candidates=() + [ -n "$PREFIX" ] && { echo "$PREFIX"; return; } + candidates+=("/usr/local/bin") + [ -n "${HOME:-}" ] && candidates+=("$HOME/.local/bin" "$HOME/bin") + for d in "${candidates[@]}"; do + if [ -d "$d" ] && [ -w "$d" ]; then + echo "$d" + return + fi + done + # Nothing writable yet — create ~/.local/bin and use that. + if [ -n "${HOME:-}" ]; then + mkdir -p "$HOME/.local/bin" + echo "$HOME/.local/bin" + return + fi + die "no writable install prefix found; pass one as the second argument" +} + +PREFIX=$(pick_prefix) +mkdir -p "$PREFIX" + +# ---- download + verify + extract ---- + +ARCHIVE="${BINARY}_${VER_NUM}_${OS}_${ARCH}.tar.gz" +BASE_URL="https://github.com/${OWNER}/${REPO}/releases/download/${VERSION}" +ARCHIVE_URL="${BASE_URL}/${ARCHIVE}" +CHECKSUMS_URL="${BASE_URL}/checksums.txt" + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +msg "downloading ${ARCHIVE}" +curl -fsSL -o "$TMP/$ARCHIVE" "$ARCHIVE_URL" \ + || die "download failed: $ARCHIVE_URL" + +msg "verifying checksum" +curl -fsSL -o "$TMP/checksums.txt" "$CHECKSUMS_URL" \ + || die "download failed: $CHECKSUMS_URL" + +expected=$(grep " ${ARCHIVE}\$" "$TMP/checksums.txt" | awk '{print $1}' || true) +[ -n "$expected" ] || die "no checksum for $ARCHIVE in checksums.txt" + +if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$TMP/$ARCHIVE" | awk '{print $1}') +elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$TMP/$ARCHIVE" | awk '{print $1}') +else + die "no sha256 tool found (sha256sum or shasum)" +fi + +[ "$expected" = "$actual" ] \ + || die "checksum mismatch: expected $expected, got $actual" + +msg "extracting" +tar -xzf "$TMP/$ARCHIVE" -C "$TMP" + +[ -f "$TMP/$BINARY" ] || die "archive did not contain a '$BINARY' binary" + +msg "installing to $PREFIX/$BINARY" +install -m 0755 "$TMP/$BINARY" "$PREFIX/$BINARY" 2>/dev/null \ + || { cp "$TMP/$BINARY" "$PREFIX/$BINARY" && chmod 0755 "$PREFIX/$BINARY"; } + +# ---- PATH hint ---- + +case ":$PATH:" in + *":$PREFIX:"*) ;; + *) + warn "$PREFIX is not on your PATH" + warn "add this to your shell rc file:" + warn " export PATH=\"$PREFIX:\$PATH\"" + ;; +esac + +msg "installed $("$PREFIX/$BINARY" --version 2>/dev/null || echo zot)" +msg "run: zot (interactive tui)" +msg "run: zot --help (all flags and subcommands)"