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
9 changes: 8 additions & 1 deletion cmd/rune/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,12 @@ func main() {
os.Exit(runVerify(ctx, args, os.Stdout, os.Stderr))
case "version":
os.Exit(runVersion(os.Stdout))
case "update":
os.Exit(runUpdate(ctx, args, os.Stdout, os.Stderr))
case "mcp-server":
os.Exit(runMCPServer(ctx, args, os.Stderr))
case "runed":
os.Exit(runRuned(ctx, args, os.Stderr))
os.Exit(runRuned(ctx, args, os.Stdout, os.Stderr))
case "-h", "--help", "help":
printHelp(os.Stdout)
os.Exit(0)
Expand All @@ -87,13 +89,18 @@ Usage:
run read-only health checks
rune version
print version and manifest URL
rune update [--check] [--json] [--manifest-url URL]
update installed binaries to the latest version from manifest
(--check skip actual update)
rune mcp-server [args...]
forward stdio to ~/.rune/bin/rune-mcp (plugin-manifest entry point)
rune runed [args...]
forward stdio + args to ~/.runed/bin/runed (foreground)
rune runed --detach [args...]
supervise runed as a background daemon: detach + log to
~/.runed/logs/daemon.log + auto-restart on crash
rune runed --status
query running supervisor

Environment:
RUNE_HOME override ~/.rune/ (rune plugin realm: config + rune-mcp)
Expand Down
34 changes: 33 additions & 1 deletion cmd/rune/runed.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,18 @@ import (
// to become a process group leader, redirect stdio to ~/.runed/logs/daemon.log,
// take ~/.runed/supervisor.lock to prevent race, and watch runed in a restart loop.
// The user-facing invocation returns immediately once supervisor is launched.
func runRuned(ctx context.Context, args []string, stderr io.Writer) int {
func runRuned(ctx context.Context, args []string, stdout, stderr io.Writer) int {
paths, err := bootstrap.Resolve()
if err != nil {
fmt.Fprintf(stderr, "rune: cannot resolve home directories: %v\n", err)
return 1
}

// `rune runed --status`: query running supervisor; read-only
if hasFlag(args, "--status") {
return runedStatus(paths, stdout)
}

// If a llama-server is present in ~/.runed/bin, point runed at it so it skips
// re-download. When it's absent, leave the env UNSET so runed
// self-bootstraps llama-server on first boot. Set on the process env (rather
Expand Down Expand Up @@ -62,6 +67,7 @@ func runRuned(ctx context.Context, args []string, stderr io.Writer) int {
RunedArgs: forwardedArgs,
LogPath: paths.DaemonLog,
LockPath: paths.SupervisorLock,
SocketPath: paths.SupervisorSock,
}
if err := supervisor.RunDetached(ctx, cfg); err != nil {
fmt.Fprintf(stderr, "rune: supervisor: %v\n", err)
Expand All @@ -83,3 +89,29 @@ func extractDetachFlag(args []string) (detach bool, rest []string) {

return detach, rest
}

func hasFlag(args []string, flag string) bool {
for _, a := range args {
if a == flag {
return true
}
}

return false
}

func runedStatus(paths *bootstrap.Paths, stdout io.Writer) int {
resp, err := supervisor.SupervisorRequest(paths.SupervisorSock, supervisor.Request{Cmd: "status"})
if err != nil {
fmt.Fprintln(stdout, "runed supervisor: not running")
return 1
}
if !resp.OK {
fmt.Fprintf(stdout, "runed supervisor: error: %s\n", resp.Error)
return 1
}

fmt.Fprintf(stdout, "runed supervisor: running (pid %d)\n", resp.PID)

return 0
}
68 changes: 68 additions & 0 deletions cmd/rune/runed_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
package main

import (
"bytes"
"context"
"encoding/json"
"net"
"path/filepath"
"reflect"
"strings"
"testing"

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

func TestExtractDetachFlag(t *testing.T) {
Expand Down Expand Up @@ -56,3 +64,63 @@ func TestExtractDetachFlag(t *testing.T) {
})
}
}

func runedEnv(t *testing.T) *bootstrap.Paths {
t.Helper()

dir := t.TempDir()
t.Setenv("RUNE_HOME", filepath.Join(dir, "rune"))
t.Setenv("RUNED_HOME", filepath.Join(dir, "runed"))

paths, err := bootstrap.Resolve()
if err != nil {
t.Fatal(err)
}
if err := paths.EnsureDirs(); err != nil {
t.Fatal(err)
}

return paths
}

func TestRunRuned_StatusNotRunning(t *testing.T) {
runedEnv(t) // no supervisor listening

var stdout, stderr bytes.Buffer
if code := runRuned(context.Background(), []string{"--status"}, &stdout, &stderr); code != 1 {
t.Errorf("exit = %d, want 1 (no supervisor)", code)
}
if !strings.Contains(stdout.String(), "not running") {
t.Errorf("stdout = %q", stdout.String())
}
}

func TestRunRuned_StatusRunning(t *testing.T) {
paths := runedEnv(t)

// Simulate listener
ln, err := net.Listen("unix", paths.SupervisorSock)
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()

go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
var req map[string]any
_ = json.NewDecoder(conn).Decode(&req)
_ = json.NewEncoder(conn).Encode(map[string]any{"ok": true, "pid": 4321})
}()

var stdout, stderr bytes.Buffer
if code := runRuned(context.Background(), []string{"--status"}, &stdout, &stderr); code != 0 {
t.Errorf("exit = %d, want 0 (stderr=%q)", code, stderr.String())
}
if !strings.Contains(stdout.String(), "running") || !strings.Contains(stdout.String(), "4321") {
t.Errorf("stdout = %q, want running + pid 4321", stdout.String())
}
}
135 changes: 135 additions & 0 deletions cmd/rune/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package main

import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"os"

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

func runUpdate(ctx context.Context, args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("update", flag.ContinueOnError)
fs.SetOutput(stderr)

check := fs.Bool("check", false, "report available updates without applying")
jsonOut := fs.Bool("json", false, "emit JSON")
manifest := fs.String("manifest-url", manifestURL, "override manifest URL")
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() > 0 {
fmt.Fprintf(stderr, "rune update: unexpected argument: %v\n", fs.Args())
return 2
}

// Fall-back
if *manifest == "" {
if env := os.Getenv("RUNE_MANIFEST"); env != "" {
*manifest = env
}
}
if *manifest == "" {
fmt.Fprintln(stderr, "rune update: no manifest URL configured (set --manifest-url or RUNE_MANIFEST)")
return 2
}

plan, err := bootstrap.CheckUpdate(ctx, *manifest, nil)
if err != nil {
fmt.Fprintf(stderr, "rune update: %v\n", err)
return 1
}

if *check {
return reportUpdatePlan(stdout, plan, *jsonOut)
}

return applyUpdate(ctx, *manifest, plan, stdout, stderr, *jsonOut)
}

func reportUpdatePlan(w io.Writer, plan *bootstrap.UpdateList, jsonOut bool) int {
if jsonOut {
_ = json.NewEncoder(w).Encode(plan)
return 0
}

if !plan.HasUpdates() {
fmt.Fprintln(w, "rune: all binaries are up to date")
return 0
}

fmt.Fprintln(w, "Available updates:")
for _, a := range plan.Outdated() {
fmt.Fprintf(w, " %s: %s -> %s\n", a.Step, a.Installed, a.Available)
}

return 0
}

type updateSummary struct {
Applied []appliedUpdate `json:"applied"`
Deferred []string `json:"deferred,omitempty"`
Error string `json:"error,omitempty"`
}

type appliedUpdate struct {
Step string `json:"step"`
From string `json:"from"`
To string `json:"to"`
}

func applyUpdate(ctx context.Context, manifest string, plan *bootstrap.UpdateList, stdout, stderr io.Writer, jsonOut bool) int {
out := updateSummary{Applied: []appliedUpdate{}}

logf := func(string, ...any) {}
if !jsonOut {
logf = func(format string, a ...any) { fmt.Fprintf(stderr, format+"\n", a...) }
}

if !plan.HasUpdates() {
if jsonOut {
_ = json.NewEncoder(stdout).Encode(out)
} else {
fmt.Fprintln(stdout, "rune: all binaries are up to date")
}
return 0
}

exit := 0
for _, a := range plan.Outdated() {
switch a.Step {
case bootstrap.StepRuneMCP:
to, err := bootstrap.UpdateArtifact(ctx, manifest, a.Step, logf)
if err != nil {
out.Error = err.Error()
exit = 1

if !jsonOut {
fmt.Fprintf(stderr, "rune update: %s: %v\n", a.Step, err)
}

continue
}

out.Applied = append(out.Applied, appliedUpdate{Step: a.Step, From: a.Installed, To: to})
if !jsonOut {
fmt.Fprintf(stdout, "updated %s: %s -> %s (applies on the next session; run /mcp to reconnect now)\n", a.Step, a.Installed, to)
}
case bootstrap.StepRuned:
// TODO: send reload request to restart updated runed
out.Deferred = append(out.Deferred, a.Step)
if !jsonOut {
fmt.Fprintf(stdout, "%s: %s -> %s available (not applied; live daemon update not yet implemented)\n", a.Step, a.Installed, a.Available)
}
}
}

if jsonOut {
_ = json.NewEncoder(stdout).Encode(out)
}

return exit
}
Loading