Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
"rune": {
"command": "${CLAUDE_PLUGIN_ROOT}/bin/rune",
"args": ["mcp-server"],
"env": { "RUNE_NO_AUTO_UPDATE": "1" },
"timeout": 30000,
"description": "Rune MCP server (talks to Console + embedder). The command is the plugin's bash wrapper, which always exists at session start; on first run it self-install rune-mcp, then exec - MCP server comes online in the same session with no restart."
}
Expand Down
48 changes: 47 additions & 1 deletion .github/workflows/release-rune-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,15 @@ jobs:
GOOS: ${{ matrix.os }}
GOARCH: ${{ matrix.arch }}
MANIFEST_URL: https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/manifest.json
UPDATE_MANIFEST_URL: https://github.com/${{ github.repository }}/releases/latest/download/manifest.json
run: |
# EXT=""
# if [ "${{ matrix.os }}" = "windows" ]; then EXT=".exe"; fi
# ASSET="rune-${{ matrix.os }}-${{ matrix.arch }}${EXT}"
ASSET="rune-${{ matrix.os }}-${{ matrix.arch }}"
mkdir -p dist
go build -trimpath \
-ldflags "-s -w -X main.runeVersion=${VERSION} -X main.manifestURL=${MANIFEST_URL}" \
-ldflags "-s -w -X main.runeVersion=${VERSION} -X main.manifestURL=${MANIFEST_URL} -X main.updateManifestURL=${UPDATE_MANIFEST_URL}" \
-o "dist/${ASSET}" \
./cmd/rune
ls -lh "dist/${ASSET}"
Expand Down Expand Up @@ -227,6 +228,21 @@ jobs:
fi
echo "Pinning rune-mcp=${RUNE_MCP_VERSION}, runed=${RUNED_VERSION}"

PLUGIN_VERSION=$(jq -r '.version // empty' .claude-plugin/plugin.json)
MIN_PLUGIN_VERSION=$(awk '/^min_plugin_version:/ {print $2}' .release-pins.yaml)
if [ -z "${PLUGIN_VERSION:-}" ] || [ -z "${MIN_PLUGIN_VERSION:-}" ]; then
echo "::error::missing plugin version (plugin.json version=${PLUGIN_VERSION}, .release-pins.yaml min_plugin_version=${MIN_PLUGIN_VERSION})" >&2
exit 1
fi

if [[ "${MIN_PLUGIN_VERSION#v}" =~ ^[0-9]+(\.[0-9]+)*$ && "${PLUGIN_VERSION#v}" =~ ^[0-9]+(\.[0-9]+)*$ ]]; then
if [ "$(printf '%s\n%s\n' "${MIN_PLUGIN_VERSION#v}" "${PLUGIN_VERSION#v}" | sort -V | head -1)" != "${MIN_PLUGIN_VERSION#v}" ]; then
echo "::error::min_plugin_version ${MIN_PLUGIN_VERSION} is above the shipped plugin version ${PLUGIN_VERSION}" >&2
exit 1
fi
fi
echo "Plugin: plugin_version=${PLUGIN_VERSION}, min_plugin_version=${MIN_PLUGIN_VERSION}"

mkdir -p _downstream/runed _downstream/rune-mcp

# runed
Expand Down Expand Up @@ -291,12 +307,30 @@ jobs:
RUNED_BASE="https://github.com/CryptoLabInc/runed/releases/download/${RUNED_VERSION}"
MCP_BASE="https://github.com/CryptoLabInc/rune-mcp/releases/download/${RUNE_MCP_VERSION}"

# rune CLI self-update artifacts
CLI_BASE="https://github.com/${{ github.repository }}/releases/download/${VERSION}"
cli_sha() {
local sha
sha=$(awk -v f="$1" '$2 == f {print $1}' dist/checksums.txt)
if [ -z "$sha" ]; then
echo "::error::no checksum for $1 in dist/checksums.txt" >&2
return 1
fi
echo "$sha"
}
CLI_DARWIN_ARM64_SHA=$(cli_sha rune-darwin-arm64)
CLI_LINUX_AMD64_SHA=$(cli_sha rune-linux-amd64)
CLI_LINUX_ARM64_SHA=$(cli_sha rune-linux-arm64)

mkdir -p dist
cat > dist/manifest.json <<EOF
{
"version": 1,
"rune_mcp_version": "${RUNE_MCP_VERSION}",
"runed_version": "${RUNED_VERSION}",
"plugin_version": "${PLUGIN_VERSION}",
"min_plugin_version": "${MIN_PLUGIN_VERSION}",
"cli_version": "${VERSION}",
"platforms": {
"darwin-arm64": {
"runed": {
Expand All @@ -307,6 +341,10 @@ jobs:
"rune_mcp": {
"url": "${MCP_BASE}/${MCP_DARWIN_ARM64}",
"sha256": "${MCP_DARWIN_ARM64_SHA}"
},
"rune_cli": {
"url": "${CLI_BASE}/rune-darwin-arm64",
"sha256": "${CLI_DARWIN_ARM64_SHA}"
}
},
"linux-amd64": {
Expand All @@ -318,6 +356,10 @@ jobs:
"rune_mcp": {
"url": "${MCP_BASE}/${MCP_LINUX_AMD64}",
"sha256": "${MCP_LINUX_AMD64_SHA}"
},
"rune_cli": {
"url": "${CLI_BASE}/rune-linux-amd64",
"sha256": "${CLI_LINUX_AMD64_SHA}"
}
},
"linux-arm64": {
Expand All @@ -329,6 +371,10 @@ jobs:
"rune_mcp": {
"url": "${MCP_BASE}/${MCP_LINUX_ARM64}",
"sha256": "${MCP_LINUX_ARM64_SHA}"
},
"rune_cli": {
"url": "${CLI_BASE}/rune-linux-arm64",
"sha256": "${CLI_LINUX_ARM64_SHA}"
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions .release-pins.yaml
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
rune_mcp_version: v1.0.0
runed_version: v1.0.0

# Hard floor for `rune update`
min_plugin_version: 1.0.0
72 changes: 67 additions & 5 deletions bin/rune
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,62 @@ set -euo pipefail
RUNE_HOME="${RUNE_HOME:-$HOME/.rune}"
TARGET="$RUNE_HOME/bin/rune"

# Delegate to ~/.rune/bin/rune immediately
# Minimum CLI version rune plugin require
RUNE_CLI_MIN="${RUNE_CLI_MIN-v1.0.0}"

# Installed CLI version: "" when absent or unparseable (treat as old)
installed_cli_version() {
[ -x "$TARGET" ] || return 0
"$TARGET" version 2>/dev/null | head -1 | awk '$1 == "rune" {print $2}'
}

# True when semver(A) > semver(B)
ver_lt() {
local a="${1#v}" b="${2#v}"
if [ "$a" = "$b" ]; then
return 1
fi

local ar="${a%%-*}" br="${b%%-*}"
local ap="" bp=""
case "$a" in *-*) ap="${a#*-}" ;; esac
case "$b" in *-*) bp="${b#*-}" ;; esac

if [ "$ar" != "$br" ]; then
[ "$(printf '%s\n%s\n' "$ar" "$br" | sort -V | head -1)" = "$ar" ]
return
fi
if [ -z "$ap" ]; then
return 1 # A: release > B: pre-release
fi
if [ -z "$bp" ]; then
return 0 # A: pre-release < B: release
fi

[ "$(printf '%s\n%s\n' "$ap" "$bp" | sort -V | head -1)" = "$ap" ]
}

target_ready() {
[ -x "$TARGET" ] || return 1
[ -n "$RUNE_CLI_MIN" ] || return 0
local v
v="$(installed_cli_version || true)"
[ -n "$v" ] && ! ver_lt "$v" "$RUNE_CLI_MIN"
}

# ~/.rune/bin/rune exist
if [ -x "$TARGET" ]; then
exec "$TARGET" "$@"
case "${1:-}" in
install|mcp-server)
if target_ready; then
exec "$TARGET" "$@"
fi
echo "rune: installed CLI $(installed_cli_version || true) is older than this plugin needs (min $RUNE_CLI_MIN); upgrading..." >&2
;;
*)
exec "$TARGET" "$@"
;;
esac
fi

# Bootstrap path (~/.rune/bin/rune missing)
Expand Down Expand Up @@ -122,7 +175,7 @@ while true; do
fi

# Wait for another process as we failed to claim lock
if [ -x "$TARGET" ]; then
if target_ready; then
exec "$TARGET" "$@" # bootstrap finished
fi

Expand Down Expand Up @@ -173,7 +226,7 @@ while true; do
done

# Double-check: install is completed right before we won the lock
if [ -x "$TARGET" ]; then
if target_ready; then
cleanup
trap - EXIT INT TERM
exec "$TARGET" "$@"
Expand Down Expand Up @@ -209,8 +262,9 @@ if [ -z "$RUNE_VERSION" ]; then
fi

RUNE_VERSION="$(printf '%s' "$body" \
| grep -m1 '"tag_name"' \
| grep -o '"tag_name":[[:space:]]*"[^"]*"' \
| sed 's/.*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/' \
| grep -m1 '^v[0-9]' \
|| true)"
fi

Expand All @@ -220,6 +274,14 @@ if [ -z "$RUNE_VERSION" ]; then
exit 1
fi

INSTALLED_VERSION="$(installed_cli_version || true)"
if [ -x "$TARGET" ] && [ -n "$INSTALLED_VERSION" ] && ! ver_lt "$INSTALLED_VERSION" "$RUNE_VERSION"; then
echo "rune: no new release after $INSTALLED_VERSION (latest $RUNE_VERSION)" >&2
cleanup
trap - EXIT INT TERM
exec "$TARGET" "$@"
fi

# Map (OS, arch) to release asset name
case "$(uname -s)-$(uname -m)" in
Linux-x86_64) ASSET="rune-linux-amd64" ;;
Expand Down
2 changes: 1 addition & 1 deletion cmd/rune/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
"io"
"os"

"github.com/CryptoLabInc/rune-cli/internal/bootstrap"
"github.com/CryptoLabInc/rune/internal/bootstrap"
)

func runInstall(ctx context.Context, args []string, stdout, stderr io.Writer) int {
Expand Down
2 changes: 1 addition & 1 deletion cmd/rune/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"path/filepath"
"testing"

"github.com/CryptoLabInc/rune-cli/internal/bootstrap"
"github.com/CryptoLabInc/rune/internal/bootstrap"
)

func TestRunInstall_JSONHappyPath(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion cmd/rune/launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"syscall"
"time"

"github.com/CryptoLabInc/rune-cli/internal/bootstrap"
"github.com/CryptoLabInc/rune/internal/bootstrap"
)

const gracefulShutdownGrace = 5 * time.Second
Expand Down
11 changes: 11 additions & 0 deletions cmd/rune/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ var runeVersion = "v0.4.0-dev"
// Configurable at build time via `-ldflags -X main.manifestURL=...`
var manifestURL = ""

// Configurable at build time via `-ldflags -X main.updateManifestURL=...`
var updateManifestURL = ""

func updateChannel() string {
if updateManifestURL != "" {
return updateManifestURL
}

return manifestURL
}

func main() {
if len(os.Args) < 2 {
printHelp(os.Stderr)
Expand Down
2 changes: 1 addition & 1 deletion cmd/rune/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
"strings"
"testing"

"github.com/CryptoLabInc/rune-cli/internal/bootstrap"
"github.com/CryptoLabInc/rune/internal/bootstrap"
)

func TestRunVersion_PrintConstants(t *testing.T) {
Expand Down
16 changes: 12 additions & 4 deletions cmd/rune/mcpserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"path/filepath"
"time"

"github.com/CryptoLabInc/rune-cli/internal/bootstrap"
"github.com/CryptoLabInc/rune/internal/bootstrap"
)

const mcpSelfhealBudget = 25 * time.Second // (download:25s + (exec + MCP handshake):5s) < plugin manifest 30s MCP connection timeout
Expand Down Expand Up @@ -76,7 +76,7 @@ var spawnUpdateFn = spawnDetachedUpdate // background update launcher
var errBackgroundUnsupported = errors.New("detached background update not supported on this platform")

func resolvedManifest() string {
m := manifestURL
m := updateChannel()
if env := os.Getenv("RUNE_MANIFEST"); env != "" {
m = env
}
Expand Down Expand Up @@ -105,6 +105,15 @@ func tryAutoCheck(paths *bootstrap.Paths, stderr io.Writer) {
}
}

// Background update check argv
func detachedUpdateArgs(manifest string) []string {
return []string{
"update",
"--only", bootstrap.StepRuneCLI,
"--manifest-url", manifest,
}
}

func spawnDetachedUpdate(paths *bootstrap.Paths, manifest string) error {
exe, err := os.Executable()
if err != nil {
Expand All @@ -120,8 +129,7 @@ func spawnDetachedUpdate(paths *bootstrap.Paths, manifest string) error {
}
defer logFile.Close()

// Runed is excluded since mcp server does not handle its lifecycle
cmd := exec.Command(exe, "update", "--only", bootstrap.StepRuneMCP, "--manifest-url", manifest)
cmd := exec.Command(exe, detachedUpdateArgs(manifest)...)
cmd.Stdin = nil
cmd.Stdout = logFile
cmd.Stderr = logFile
Expand Down
62 changes: 61 additions & 1 deletion cmd/rune/mcpserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"testing"
"time"

"github.com/CryptoLabInc/rune-cli/internal/bootstrap"
"github.com/CryptoLabInc/rune/internal/bootstrap"
)

func TestRunMCPServer_InstallErrorFailFast(t *testing.T) {
Expand Down Expand Up @@ -168,6 +168,66 @@ func stubSpawn(t *testing.T) *int {
return &n
}

// The background check is the only path that carries a CLI update to an
// installed fleet unattended, so it must carry rune_cli - and ONLY rune_cli:
// rune-mcp/runed swaps change what a live session is talking to and must stay
// behind an explicit `rune update`.
func TestDetachedUpdateArgs_CLIOnly(t *testing.T) {
args := detachedUpdateArgs("http://example.test/manifest.json")

joined := strings.Join(args, " ")
if !strings.Contains(joined, "update --only ") {
t.Fatalf("argv is not an `update --only` invocation: %q", joined)
}

var only string
for i, a := range args {
if a == "--only" && i+1 < len(args) {
only = args[i+1]
}
}
set, err := parseOnly(only)
if err != nil {
t.Fatalf("--only %q does not parse: %v", only, err)
}
if !set[bootstrap.StepRuneCLI] {
t.Errorf("--only %q must include %s (unattended CLI self-update)", only, bootstrap.StepRuneCLI)
}
for _, step := range []string{bootstrap.StepRuneMCP, bootstrap.StepRuned} {
if set[step] {
t.Errorf("--only %q must not include %s: swapping it disrupts a live session", only, step)
}
}
if !strings.Contains(joined, "--manifest-url http://example.test/manifest.json") {
t.Errorf("manifest URL not forwarded: %q", joined)
}
}

func TestResolvedManifest_PrefersChannelThenEnv(t *testing.T) {
savedPinned, savedChannel := manifestURL, updateManifestURL
t.Cleanup(func() { manifestURL, updateManifestURL = savedPinned, savedChannel })

manifestURL = "http://pinned.test/manifest.json"
updateManifestURL = "http://channel.test/manifest.json"

t.Setenv("RUNE_MANIFEST", "")
if got := resolvedManifest(); got != updateManifestURL {
t.Errorf("auto-check must poll the channel, not the pinned manifest: got %q", got)
}

t.Setenv("RUNE_MANIFEST", "http://env.test/manifest.json")
if got := resolvedManifest(); got != "http://env.test/manifest.json" {
t.Errorf("RUNE_MANIFEST must win: got %q", got)
}

// Dev build: no channel baked, fall back to the pinned manifest
t.Setenv("RUNE_MANIFEST", "")
updateManifestURL = ""
if got := resolvedManifest(); got != manifestURL {
t.Errorf("without a baked channel, fall back to pinned: got %q", got)
}
}

func TestTryAutoCheck_Due(t *testing.T) {
paths := setTestEnv(t)
t.Setenv("RUNE_MANIFEST", "http://example.invalid/manifest.json")
Expand Down
Loading