diff --git a/README.md b/README.md index 2587a48..ac36289 100644 --- a/README.md +++ b/README.md @@ -201,9 +201,11 @@ spinloop serve [path] [--dry-run] [-a] # run the PROVIDER's inference server spinloop daemon [--api-addr ] [--loopback] # supervise an engine via the control API — reads # no Spinloop, starts nothing until asked over the API spinloop fleet - # observe and drive the engines in - # fleet.yaml (dashboard is the - # interactive tiled view) + # observe and drive the engines in + # fleet.yaml (dashboard is the + # interactive tiled view) +spinloop up [node… | path] # start what the directory holds: every node of a + # fleet.yaml, else the Spinloop's server spinloop export [--provider ] # print the current config as a Spinloop spinloop init-providers [path] # write the built-in catalogue out to edit spinloop harness [] [-H ] [--spinloop[=]] [args...] @@ -373,6 +375,10 @@ spinloop serve # builds a llama-server command and runs it spinloop serve --dry-run # just print the command — no server ``` +One word does the same: `spinloop up` runs `serve` for the directory's +`Spinloop` — and `fleet start` for the fleet, when a `fleet.yaml` is in the +directory instead. See [docs/commands/up.md](docs/commands/up.md). + For flags a `Spinloop` doesn't model (`-ngl`, `--jinja`, KV-cache types, draft models), point at a llama.cpp preset `.ini` with `PRESET` and `serve` flattens the chosen section into the command instead — with anything the `Spinloop` states diff --git a/cmd/spinloop/commands.go b/cmd/spinloop/commands.go index 52404f4..e54217d 100644 --- a/cmd/spinloop/commands.go +++ b/cmd/spinloop/commands.go @@ -64,6 +64,7 @@ harness could be configured with, spinloop show what it has been.`, aliasCmd(), unaliasCmd(), serveCmd(), + upCmd(), daemonCmd(), exportCmd(), initProvidersCmd(), diff --git a/cmd/spinloop/up.go b/cmd/spinloop/up.go new file mode 100644 index 0000000..7095b1f --- /dev/null +++ b/cmd/spinloop/up.go @@ -0,0 +1,70 @@ +package main + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/spinloop-ai/spinloop/internal/fleet" +) + +// upCmd is `spinloop up`: the one-word way to start the engine for what is in +// the current directory. A fleet.yaml there makes it a fleet start — every +// node, or the ones named — and without one it is serve, resolving the +// Spinloop the same way. It is a dispatcher: each branch runs the other +// command's own body, so up cannot drift from what fleet start and serve do. +func upCmd() *cobra.Command { + c := &cobra.Command{ + Use: "up", + Short: "start the engine this directory holds: the fleet, or the Spinloop's server", + Long: `starts the engine for what is in the current directory. With a +fleet.yaml here it starts the fleet's engines — every node, or the ones named +— as spinloop fleet start does; without one it serves the Spinloop exactly as +spinloop serve does, resolving it the same way (a path, a registered alias, +SPINLOOP_ALIAS, or ./Spinloop). A fleet.yaml wins over a Spinloop. It takes +no flags; for the full options of either, use spinloop fleet start or +spinloop serve directly.`, + Args: cobra.ArbitraryArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(c *cobra.Command, args []string) error { + resolve(c) + return runUp(args) + }, + } + c.ValidArgsFunction = upSlot + return c +} + +// cmdUp runs the up command through the tree — the seam the suite calls +// directly. +func cmdUp(args []string) error { return execCmd(upCmd(), args) } + +// runUp is the body of `spinloop up`: a fleet.yaml in the working directory +// makes it a fleet start, and anything else a serve. +func runUp(args []string) error { + if info, err := os.Stat(fleet.DefaultFile); err == nil && !info.IsDir() { + cfg, err := fleet.Resolve("") + if err != nil { + return err + } + // A bare up starts the whole fleet: fleet start refuses to run bare, + // and picking one node out of several would be a guess. + return runFleetDrive("start", cfg, len(args) == 0, args, fleetStartCall(cfg)) + } + return runServe(args, false, false, "", "") +} + +// upSlot is up's completion: the fleet's node names while a fleet file is in +// the working directory, the Spinloop slot otherwise. Completion must stay +// silent on failure, so a fleet file that cannot be read offers nothing. +func upSlot(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if info, err := os.Stat(fleet.DefaultFile); err == nil && !info.IsDir() { + cfg, err := fleet.Resolve("") + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cfg.Names(), cobra.ShellCompDirectiveNoFileComp + } + return aliasSlot(nil, args, "") +} diff --git a/cmd/spinloop/up_test.go b/cmd/spinloop/up_test.go new file mode 100644 index 0000000..1780f8b --- /dev/null +++ b/cmd/spinloop/up_test.go @@ -0,0 +1,191 @@ +package main + +import ( + "fmt" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/spinloop-ai/spinloop/internal/spinloop" +) + +// upFleet writes a one-node fleet.yaml (and the node's Spinloop) in a temp dir +// pointing at srv, chdirs there, and returns the dir — so up resolves +// ./fleet.yaml the way a user would. +func upFleet(t *testing.T, srv *httptest.Server) string { + t.Helper() + t.Setenv("SPINLOOP_CONFIG_DIR", t.TempDir()) + host, port := hostPort(t, srv) + dir := writeFleetFile(t, fmt.Sprintf( + "nodes:\n - name: one\n host: %s\n port: %d\n file: ./one.Spinloop\n", + host, port)) + mustWrite(t, filepath.Join(dir, "one.Spinloop"), "PROVIDER llamacpp\nMODEL org/m:Q4\n") + return dir +} + +// upServeDir chdirs into an empty temp dir with an isolated spinloop config +// and returns it — a directory with no fleet file and, at first, no Spinloop. +func upServeDir(t *testing.T) string { + t.Helper() + t.Setenv("SPINLOOP_CONFIG_DIR", t.TempDir()) + dir := t.TempDir() + t.Chdir(dir) + return dir +} + +// TestCmdUp_FleetDir_BareStartsEveryNode checks the fleet branch's default: +// a bare up starts the whole fleet, where a bare fleet start refuses to run. +func TestCmdUp_FleetDir_BareStartsEveryNode(t *testing.T) { + upFleet(t, stubNode(t, "stopped")) + out := captureStdout(t, func() { + if err := cmdUp(nil); err != nil { + t.Fatalf("up: %v", err) + } + }) + if !strings.Contains(out, "one: using") || !strings.Contains(out, "one running") { + t.Errorf("the node was not started:\n%s", out) + } +} + +func TestCmdUp_FleetDir_NamedNode(t *testing.T) { + upFleet(t, stubNode(t, "stopped")) + out := captureStdout(t, func() { + if err := cmdUp([]string{"one"}); err != nil { + t.Fatalf("up one: %v", err) + } + }) + if !strings.Contains(out, "one: using") || !strings.Contains(out, "one running") { + t.Errorf("the node was not started:\n%s", out) + } +} + +func TestCmdUp_FleetDir_UnknownNode(t *testing.T) { + upFleet(t, stubNode(t, "stopped")) + err := cmdUp([]string{"nope"}) + if err == nil || !strings.Contains(err.Error(), `no node "nope"`) || + !strings.Contains(err.Error(), "one") { + t.Fatalf("want the unknown-node error naming the known nodes, got %v", err) + } +} + +// TestCmdUp_FleetWinsOverSpinloop checks the dispatch order: a Spinloop beside +// the fleet file is ignored, and the fleet is started. If the serve branch ran +// instead, it would look for a real engine binary and fail. +func TestCmdUp_FleetWinsOverSpinloop(t *testing.T) { + dir := upFleet(t, stubNode(t, "stopped")) + mustWrite(t, filepath.Join(dir, spinloop.DefaultFile), "PROVIDER llamacpp\nMODEL org/m:Q4\n") + out := captureStdout(t, func() { + if err := cmdUp(nil); err != nil { + t.Fatalf("up: %v", err) + } + }) + if !strings.Contains(out, "one: using") { + t.Errorf("the fleet was not started:\n%s", out) + } +} + +// TestCmdUp_ServesTheDirectorySpinloop checks the serve branch from a bare up: +// the engine the Spinloop names is actually launched. +func TestCmdUp_ServesTheDirectorySpinloop(t *testing.T) { + dir := upServeDir(t) + mustWrite(t, filepath.Join(dir, spinloop.DefaultFile), "PROVIDER llamacpp\nMODEL org/m:Q4\n") + argsFile := filepath.Join(t.TempDir(), "args") + stubLlamaServer(t, argsFile) + if err := cmdUp(nil); err != nil { + t.Fatalf("up: %v", err) + } + if _, err := os.Stat(argsFile); err != nil { + t.Fatalf("the engine was not run: %v", err) + } +} + +func TestCmdUp_ServesAPath(t *testing.T) { + upServeDir(t) + spinloopPath := filepath.Join(t.TempDir(), spinloop.DefaultFile) + mustWrite(t, spinloopPath, "PROVIDER llamacpp\nMODEL org/m:Q4\n") + argsFile := filepath.Join(t.TempDir(), "args") + stubLlamaServer(t, argsFile) + if err := cmdUp([]string{spinloopPath}); err != nil { + t.Fatalf("up %s: %v", spinloopPath, err) + } + if _, err := os.Stat(argsFile); err != nil { + t.Fatalf("the engine was not run: %v", err) + } +} + +// TestCmdUp_ResolvesTheEnvironmentAlias checks a directory with no ./Spinloop +// but a SPINLOOP_ALIAS: up resolves it the way serve does, so a directory +// where serve works is a directory where up works. +func TestCmdUp_ResolvesTheEnvironmentAlias(t *testing.T) { + upServeDir(t) + registerSpinloop(t, "PROVIDER llamacpp\nALIAS q3\nMODEL org/m:Q4\n") + t.Setenv("SPINLOOP_ALIAS", "q3") + argsFile := filepath.Join(t.TempDir(), "args") + stubLlamaServer(t, argsFile) + if err := cmdUp(nil); err != nil { + t.Fatalf("up: %v", err) + } + if _, err := os.Stat(argsFile); err != nil { + t.Fatalf("the engine was not run: %v", err) + } +} + +func TestCmdUp_ResolvesARegisteredAlias(t *testing.T) { + upServeDir(t) + registerSpinloop(t, "PROVIDER llamacpp\nALIAS q3\nMODEL org/m:Q4\n") + argsFile := filepath.Join(t.TempDir(), "args") + stubLlamaServer(t, argsFile) + if err := cmdUp([]string{"q3"}); err != nil { + t.Fatalf("up q3: %v", err) + } + if _, err := os.Stat(argsFile); err != nil { + t.Fatalf("the engine was not run: %v", err) + } +} + +// TestCmdUp_FailsWhenNothingResolves checks the neither case: no fleet file and +// no resolvable Spinloop fails with serve's own error, not one of up's own. +func TestCmdUp_FailsWhenNothingResolves(t *testing.T) { + upServeDir(t) + err := cmdUp(nil) + if err == nil || !strings.Contains(err.Error(), "no Spinloop found") { + t.Fatalf("want serve's no-Spinloop error, got %v", err) + } +} + +func TestUpSlot_FleetDirOffersNodeNames(t *testing.T) { + upFleet(t, stubNode(t, "stopped")) + cands, dir := upSlot(nil, nil, "") + if dir != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("directive = %v, want NoFileComp", dir) + } + if !slices.Contains(cands, "one") { + t.Errorf("node name not offered: %v", cands) + } +} + +func TestUpSlot_OutsideFleetOffersTheSpinloopSlot(t *testing.T) { + upServeDir(t) + cands, dir := upSlot(nil, nil, "") + // The Spinloop slot allows paths (Default) and offers no node names. + if dir != cobra.ShellCompDirectiveDefault { + t.Errorf("directive = %v, want Default", dir) + } + if slices.Contains(cands, "one") { + t.Errorf("a node name leaked into the Spinloop slot: %v", cands) + } +} + +func TestUpSlot_UnreadableFleetFileStaysSilent(t *testing.T) { + dir := upServeDir(t) + mustWrite(t, filepath.Join(dir, "fleet.yaml"), "nodes: [") + cands, d := upSlot(nil, nil, "") + if cands != nil || d != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("got (%v, %v), want (nil, NoFileComp)", cands, d) + } +} diff --git a/docs/README.md b/docs/README.md index 0ed0977..2506b4f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,6 +57,7 @@ Four words carry the whole tool: | [`spinloop alias`](commands/alias.md) | Name a `Spinloop` so the name works anywhere a path does | | [`spinloop unalias`](commands/unalias.md) | Drop a registered name | | [`spinloop serve`](commands/serve.md) | Run the inference server for the model a `Spinloop` names | +| [`spinloop up`](commands/up.md) | Start the engine this directory holds: the fleet, or the `Spinloop`'s server | | [`spinloop daemon`](commands/serve.md#the-control-api---api-and-spinloop-daemon) | Supervise an engine over the [control API](http-api.md) | | [`spinloop fleet`](commands/fleet.md) | Observe and drive the engines on every machine you run | | [`spinloop remote`](commands/remote.md) | Run the model on a cloud GPU that stops when you do | diff --git a/docs/commands/alias.md b/docs/commands/alias.md index 48e94b9..b466b54 100644 --- a/docs/commands/alias.md +++ b/docs/commands/alias.md @@ -1,7 +1,7 @@ # spinloop alias Register an [`Spinloop` file](../spinloop-file.md) under a short name. The name -then works wherever a Spinloop path does — `apply`, `unapply`, `serve`, +then works wherever a Spinloop path does — `apply`, `unapply`, `serve`, `up`, `harness` — from any directory. ```sh diff --git a/docs/commands/completion.md b/docs/commands/completion.md index 7d1a377..ed16cfb 100644 --- a/docs/commands/completion.md +++ b/docs/commands/completion.md @@ -19,6 +19,8 @@ them: - harness names after `-H`, `--harness`, or `--set` - your [registered aliases](alias.md) wherever a Spinloop path goes — `spinloop unalias ` offers exactly the names you have +- the fleet's node names after `up` in a fleet directory — the Spinloop slot + elsewhere - the supported shells after `completion` ## See also diff --git a/docs/commands/fleet.md b/docs/commands/fleet.md index 95de66f..6523356 100644 --- a/docs/commands/fleet.md +++ b/docs/commands/fleet.md @@ -454,7 +454,9 @@ spinloop fleet stop --all With neither a node nor `--all` they list the fleet and do nothing, rather than acting on the whole fleet by accident; `--all` together with node names is refused as ambiguous. An unknown name fails before anything is touched, -naming the nodes you could have meant. Several targeted nodes are driven +naming the nodes you could have meant. A fleet directory's +[`spinloop up`](up.md) skips the choice: bare `up` starts every node, +`up ` the named ones. Several targeted nodes are driven independently — one node's failure is reported against it alone and does not stop the others, and the command exits non-zero if any of them failed. The daemon's own rules still hold: starting a node whose engine is already @@ -526,6 +528,7 @@ deploy`](remote.md), applied per node. ## See also +- [`spinloop up`](up.md) — the one-word start, from a fleet directory - [`examples/fleet-local/`](../../examples/fleet-local/) — a fleet of one, on your own machine - [`examples/fleet-docker/`](../../examples/fleet-docker/) — a runnable fleet - [`spinloop daemon`](serve.md) — what runs on each node diff --git a/docs/commands/serve.md b/docs/commands/serve.md index a54d826..b6305ba 100644 --- a/docs/commands/serve.md +++ b/docs/commands/serve.md @@ -390,6 +390,8 @@ output. ## See also +- [`spinloop up`](up.md) — the one-word form: this, from a directory holding + the Spinloop - [`spinloop fleet`](fleet.md) — one spinloop observing the daemons on every machine you run - Worked examples with real models: [`examples/`](../../examples/) diff --git a/docs/commands/up.md b/docs/commands/up.md new file mode 100644 index 0000000..b0aa800 --- /dev/null +++ b/docs/commands/up.md @@ -0,0 +1,37 @@ +# spinloop up + +Start the engine for what is in the current directory — one word for the two +ways an engine gets started: + +```sh +spinloop up # a fleet.yaml here: every node; otherwise ./Spinloop's server +spinloop up gpu-box # a fleet.yaml here: just that node +spinloop up ./Spinloop # no fleet.yaml: serve that file +``` + +## The directory decides + +| What is here | `up` runs | +| ------------ | --------- | +| A `fleet.yaml` | [`spinloop fleet start`](fleet.md) — every node, or the ones named | +| A resolvable `Spinloop`, no fleet file | [`spinloop serve`](serve.md) — same resolution, same output | + +A `fleet.yaml` wins when both are present: a fleet directory is a fleet. + +The branches are the real commands, not copies of them: + +- In a fleet directory, a bare `up` starts **every** node — the `--all` form, + because a bare `fleet start` refuses to guess. `up ` starts the named + node(s); an unknown name fails the way `fleet start` does. +- Without a fleet file, `up` resolves its Spinloop exactly as `serve` does — + a path, a registered [`alias`](alias.md), `SPINLOOP_ALIAS`, then + `./Spinloop` — prints the command, and runs it. A directory with nothing to + resolve fails with serve's own error. + +`up` takes no flags; for the full options of either branch, use +[`spinloop fleet start`](fleet.md) or [`spinloop serve`](serve.md) directly. + +## See also + +- [`spinloop serve`](serve.md) — the local engine, in full +- [`spinloop fleet`](fleet.md) — the fleet, in full diff --git a/docs/getting-started.md b/docs/getting-started.md index b242b2c..18d7231 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -92,6 +92,9 @@ spinloop serve # runs llama-server for it spinloop apply # points the agent at it ``` +Prefer one word? `spinloop up` starts the server here — and the whole fleet, +wherever a `fleet.yaml` lives. + ## 7. Name the ones you keep ```sh diff --git a/docs/internals.md b/docs/internals.md index a763672..934b6b0 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -27,6 +27,8 @@ These are mistakes already made here; each was silent rather than loud, which is **`contextsize.Parse` is decimal.** `128k` is 128000, not 131072 — a `CONTEXT` written that way is not the power-of-two window it looks like. It also *overrides* a preset's `ctx-size` (both in `serve` and in `remote deploy`), so the Spinloop, not the preset, decides the window whenever it states one. +**`up` dispatches by directory and reuses both branches.** `cmd/spinloop/up.go` routes a working-directory `fleet.yaml` to the fleet start path — `runFleetDrive` over the named nodes, or over every node when none are given, since a bare `fleet start` lists and does nothing — and everything else to `runServe`'s own body, so `up` and `serve` resolve and word things identically by construction. The completion slot is the only CWD-dependent one: `upSlot` offers the fleet's node names where `./fleet.yaml` parses, the Spinloop slot elsewhere, and nothing where a fleet file is present but unreadable — `__complete` never errors, whatever the directory holds. + ## Dashboard (`fleet_dashboard.go` and friends) A few Bubble Tea/lipgloss specifics that are easy to break by "simplifying": diff --git a/docs/spinloop-file.md b/docs/spinloop-file.md index a92fbec..182109c 100644 --- a/docs/spinloop-file.md +++ b/docs/spinloop-file.md @@ -35,6 +35,8 @@ One file, several commands: - [`spinloop harness -O`](commands/harness.md) — apply it, then launch the agent - [`spinloop serve`](commands/serve.md) — run `llama-server` for the model it names +- [`spinloop up`](commands/up.md) — the one-word start: its server, or the + fleet's nodes where a `fleet.yaml` is beside it - [`spinloop alias`](commands/alias.md) — register it under a short name - [`spinloop export`](commands/export.md) — write one from your current setup diff --git a/openspec/changes/archive/2026-09-06-add-up-command/.openspec.yaml b/openspec/changes/archive/2026-09-06-add-up-command/.openspec.yaml new file mode 100644 index 0000000..34f54d2 --- /dev/null +++ b/openspec/changes/archive/2026-09-06-add-up-command/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-05 diff --git a/openspec/changes/archive/2026-09-06-add-up-command/design.md b/openspec/changes/archive/2026-09-06-add-up-command/design.md new file mode 100644 index 0000000..a42adff --- /dev/null +++ b/openspec/changes/archive/2026-09-06-add-up-command/design.md @@ -0,0 +1,108 @@ +# Design: the `up` command + +## Context + +`up` starts the engine for what is in the current directory. Two things define +the directory: + +- A `fleet.yaml` in it — a fleet of machines. `spinloop fleet start` drives + those nodes; it resolves its fleet file with `fleet.Resolve` (default + `./fleet.yaml`), and `runFleetDrive` refuses to run bare — it needs a node + name or `--all` (`cmd/spinloop/fleet.go:366`). +- A resolvable Spinloop — a local engine. `spinloop serve` builds and runs it; + it resolves its source with `readSpinloop` (`cmd/spinloop/main.go:311`), + which tries, in order: an explicit path argument, a registered alias + (`resolveAlias`), the `SPINLOOP_ALIAS` environment variable + (`spinloopFromEnv`), then `./Spinloop`. + +`up` is a thin dispatcher over those two existing commands. It owns no engine +logic of its own: whatever it runs, it runs through the command it delegates +to, so the two cannot drift apart. + +## Goals / Non-Goals + +**Goals:** + +- `up` starts the right thing from the directory: the fleet when a + `fleet.yaml` is present, the local engine otherwise. +- `up`'s serve path is `serve` itself — same resolution, same flags' meaning, + same errors — so a directory where `serve` works is a directory where `up` + works. +- The quick path stays quick: one word, no flags. + +**Non-Goals:** + +- No `down` or other counterpart: each gets its own change. +- No flags on `up`, no change to `serve` or `fleet start`, and nothing for the + alias registry. + +## Decisions + +**1. A command of its own, not a Cobra alias.** + +A Cobra `Aliases` entry dispatches to one fixed target; `up`'s target depends +on the working directory, so it needs its own `RunE` to choose. `upCmd()` goes +in a new `cmd/spinloop/up.go`, registered beside `serveCmd()` in +`cmd/spinloop/commands.go`. It is `cobra.NoArgs`-free — it takes the positionals +it forwards and nothing else. + +**2. The dispatch is two checks, fleet first.** + +1. A `fleet.yaml` exists in the current directory → the fleet branch. A + Spinloop is ignored entirely when a fleet file is there: a fleet directory + is a fleet, and the rule is one to state in a sentence. +2. Otherwise → the serve branch. + +The fleet check is the presence of `./fleet.yaml` — the same file +`fleet.Resolve("")` would read — so `up` and `fleet start` agree on what a +fleet directory is without `up` re-parsing the file to decide. + +**3. The serve branch is serve.** + +`up [path]` calls `serve`'s own body with the positional it was given (nothing +when none). There is deliberately no separate "is a Spinloop available?" +pre-check: the availability decision and the run are the same resolution, so +`up` and `serve` cannot diverge. A directory where `serve` works — say via +`SPINLOOP_ALIAS` or a registered alias — is a directory where `up` works, and +a directory where it does not fails with `serve`'s own "no Spinloop found" +error, which already names the repairs. + +**4. The fleet branch is fleet start, with bare meaning all.** + +`up [node…]` runs the fleet start path over the named nodes; with no +positionals it starts every node — the `--all` target — because a bare +`fleet start` refuses to guess. Unknown node names, an unparseable or +node-less `fleet.yaml`, and the per-node result lines are `fleet start`'s own. + +**5. Positionals only, no flags.** + +`up` forwards positionals and nothing else. A flag set shared by both branches +would make one flag mean different things per directory — `--all` to the +fleet, nothing to serve; `-n` to serve, nothing to the fleet. The full options +stay one verb away: `fleet start` and `serve` directly. + +**6. Completion offers what the branch accepts.** + +`up`'s completion slot offers the fleet's node names when `./fleet.yaml` is +present — read from the file, silently when it cannot be, as the completion +protocol requires — and otherwise the usual Spinloop slot, registered alias +names plus paths. `fleet start` itself offers no positional candidates today; +`up` does, because its whole job is to remove a step. The `shell-completion` +spec needs no delta: its coverage requirement is derived from the command +tree, which picks `up` up automatically. + +## Risks / Trade-offs + +- [A broken `fleet.yaml` shadows a valid Spinloop in the same directory] → + by design (fleet wins); the failure is the fleet file's own parse or + validation error, which names the file, so the shadowing is visible. +- [A user in a fleet directory expecting `serve`] → `up`'s help states the + rule in a sentence, and `serve` still does exactly what it always did. +- [A bare `up` in a fleet directory starts every node, which can be expensive] + → the agreed "bring it up" default; starting a subset is `up `, and + the per-node result lines show exactly what started. + +## Migration Plan + +Additive; nothing to migrate. Rollback is removing the command and its +registration. diff --git a/openspec/changes/archive/2026-09-06-add-up-command/proposal.md b/openspec/changes/archive/2026-09-06-add-up-command/proposal.md new file mode 100644 index 0000000..16eb598 --- /dev/null +++ b/openspec/changes/archive/2026-09-06-add-up-command/proposal.md @@ -0,0 +1,54 @@ +# Proposal: the `up` command + +## Why + +Starting the engine for what is in the current directory is the most repeated +operation, and its target depends on the directory: a Spinloop file names a +local engine (`spinloop serve`), while a `fleet.yaml` names a fleet of +machines (`spinloop fleet start`). Two verbs for one gesture. `up` gives the +gesture one word and picks the target from the directory. + +This change originally proposed `up` as an alias of `serve`; it has since been +redesigned as a dispatching command, because an alias has one fixed target and +`up`'s target depends on the working directory. This proposal describes the +current design. + +## What Changes + +- A new top-level command, `spinloop up`, taking positional arguments only. + - In a directory holding a `fleet.yaml`, `up [node…]` starts the fleet's + engines: the named nodes when given, every node when given none — the + `--all` form of `fleet start`, which refuses to run bare. + - Otherwise, `up [path]` is exactly `serve [path]`: the same Spinloop + resolution (path, registered alias, `SPINLOOP_ALIAS`, `./Spinloop`), the + same output, and the same errors — including `serve`'s "no Spinloop + found" failure when nothing resolves. + - A `fleet.yaml` in the directory wins over a Spinloop. +- `serve`, `fleet start`, and everything they call are unchanged: `up` + dispatches to them, so the two cannot drift apart. +- No flags on `up`: it is the quick path, and a flag that means `--all` in one + directory and `--dry-run` in another would be two commands wearing one name. + +No breaking changes; `serve` and `fleet start` keep working exactly as before. + +## Capabilities + +### New Capabilities + +- `up-command`: the `spinloop up` command — how it chooses between starting + the fleet and starting the local engine, what it forwards, and how it fails. + +### Modified Capabilities + +(None. The earlier delta to `local-serving` is retracted: `serve` itself does +not change.) + +## Impact + +- `cmd/spinloop/up.go` (new) — the command and its dispatch, reusing the fleet + resolution and start path and `serve`'s own body; registered in + `cmd/spinloop/commands.go`. +- `cmd/spinloop/up_test.go` (new) — the dispatch rules. +- `docs/commands/up.md` (new) and a row in `docs/README.md`'s command table. +- Not touched: `serve`, `fleet start`, the alias registry, and the existing + completion surface (the command tree picks `up` up automatically). diff --git a/openspec/changes/archive/2026-09-06-add-up-command/specs/up-command/spec.md b/openspec/changes/archive/2026-09-06-add-up-command/specs/up-command/spec.md new file mode 100644 index 0000000..463d8f1 --- /dev/null +++ b/openspec/changes/archive/2026-09-06-add-up-command/specs/up-command/spec.md @@ -0,0 +1,131 @@ +## Purpose + +`spinloop up`: the one-word command that starts the engine for what is in the +current directory — the fleet a `fleet.yaml` names, or the local engine a +Spinloop describes — dispatching to `spinloop fleet start` or `spinloop serve` +rather than reimplementing either. + +## ADDED Requirements + +### Requirement: Choosing the target from the directory + +`spinloop up` SHALL start the fleet when the current directory holds a +`fleet.yaml`, and SHALL start the local engine a Spinloop describes otherwise. +A `fleet.yaml` SHALL win over a Spinloop in the same directory: when both are +present, `up` starts the fleet and the Spinloop is ignored. `up` SHALL take +positional arguments only, and a flag it does not know SHALL be refused as +unknown rather than forwarded or silently ignored. + +#### Scenario: A fleet directory with both starts the fleet + +- **WHEN** the user runs `spinloop up` in a directory holding a `fleet.yaml` + and a `Spinloop` +- **THEN** the fleet's nodes are started and the local `Spinloop` is not read + +#### Scenario: An unknown flag is refused + +- **WHEN** the user runs `spinloop up --all` in a fleet directory +- **THEN** the command fails naming the unknown flag, and no node is started + +### Requirement: Starting the fleet + +In a directory holding a `fleet.yaml`, `spinloop up [node…]` SHALL start the +engines of the fleet's nodes as `spinloop fleet start` does: the named nodes +when any are given, and every node in the fleet when none are — a bare `up` +SHALL start the whole fleet, where a bare `fleet start` refuses to run without +a target. An unknown node name, an unreadable or node-less `fleet.yaml`, and +the per-node result lines SHALL be `fleet start`'s own. + +#### Scenario: A bare up starts every node + +- **WHEN** the user runs `spinloop up` in a fleet directory, with no node + names +- **THEN** the same nodes are started, and reported the same way, as + `spinloop fleet start --all` would start them + +#### Scenario: Named nodes only + +- **WHEN** the user runs `spinloop up gpu-box` in a fleet directory naming + several nodes +- **THEN** only `gpu-box`'s engine is started, as `spinloop fleet start + gpu-box` would do + +#### Scenario: An unknown node name + +- **WHEN** the user runs `spinloop up no-such-node` in a fleet directory +- **THEN** the command fails naming the known nodes, as `fleet start` does, + and no node is started + +#### Scenario: A broken fleet file + +- **WHEN** the directory's `fleet.yaml` is unreadable or names no nodes +- **THEN** `up` fails with the fleet file's own error, as `fleet start` does + +### Requirement: Starting the local engine + +In a directory holding no `fleet.yaml`, `spinloop up [path]` SHALL behave +exactly as `spinloop serve [path]`: it SHALL resolve the Spinloop the same +way — an explicit path, a registered alias, the `SPINLOOP_ALIAS` variable, +then `./Spinloop` — print the resolved command, and run the engine with the +same output and the same errors. A directory where `serve` resolves no +Spinloop SHALL fail `up` with serve's own "no Spinloop found" error, naming +the same repairs. + +#### Scenario: A bare up serves the directory's Spinloop + +- **WHEN** the user runs `spinloop up` in a directory holding a `Spinloop` and + no `fleet.yaml` +- **THEN** the engine is printed and started exactly as `spinloop serve` + would do + +#### Scenario: A path is served + +- **WHEN** the user runs `spinloop up path/to/Spinloop` in a directory with no + `fleet.yaml` +- **THEN** that Spinloop is served, as `spinloop serve path/to/Spinloop` + would do + +#### Scenario: The environment's alias resolves + +- **WHEN** the directory holds no `./Spinloop` but `SPINLOOP_ALIAS` names a + registered alias +- **THEN** that Spinloop is served: a directory where `serve` works is a + directory where `up` works + +#### Scenario: A registered alias resolves + +- **WHEN** the user runs `spinloop up qwen` and `qwen` is a registered alias +- **THEN** the Spinloop it names is served, as `spinloop serve qwen` would do + +#### Scenario: Nothing resolvable + +- **WHEN** the user runs `spinloop up` in a directory with no `Spinloop`, no + `SPINLOOP_ALIAS`, and no argument +- **THEN** the command fails with serve's "no Spinloop found" error, naming a + path, an alias, and `SPINLOOP_ALIAS` as serve does + +### Requirement: Completion offers what the branch accepts + +`up`'s tab completion SHALL offer the fleet's node names when the current +directory holds a `fleet.yaml`, and otherwise the Spinloop slot — registered +alias names plus paths. When the fleet file cannot be read, completion SHALL +stay silent: no candidates, no stderr, no error. + +#### Scenario: Node names in a fleet directory + +- **WHEN** the user completes `spinloop up ` in a directory holding a + `fleet.yaml` +- **THEN** the fleet's node names are offered + +#### Scenario: The Spinloop slot elsewhere + +- **WHEN** the user completes `spinloop up ` in a directory with no + `fleet.yaml` +- **THEN** registered alias names and paths are offered, as on the other + Spinloop commands + +#### Scenario: An unreadable fleet file stays quiet + +- **WHEN** completion is attempted in a directory whose `fleet.yaml` cannot be + read +- **THEN** no candidates are offered and nothing is written to stderr diff --git a/openspec/changes/archive/2026-09-06-add-up-command/tasks.md b/openspec/changes/archive/2026-09-06-add-up-command/tasks.md new file mode 100644 index 0000000..d229a3d --- /dev/null +++ b/openspec/changes/archive/2026-09-06-add-up-command/tasks.md @@ -0,0 +1,43 @@ +## 1. Implementation + +- [x] 1.1 Add `upCmd()` in a new `cmd/spinloop/up.go` — `Use: "up"`, a + lowercase imperative `Short`, a `Long` stating the dispatch rule, + positional arguments only — and register it beside `serveCmd()` in + `cmd/spinloop/commands.go` +- [x] 1.2 Fleet branch: when `./fleet.yaml` exists in the current directory, + run the fleet start path over the named nodes, or over every node when + none are given; reuse `fleet.Resolve` and the existing start call and + result rendering so the output is `fleet start`'s +- [x] 1.3 Serve branch: otherwise run `serve`'s own body with the positional + (or none), so resolution, output, and errors are serve's +- [x] 1.4 Completion slot: the fleet's node names when `./fleet.yaml` is + present, otherwise the Spinloop slot (alias names plus paths), silent on + any failure + +## 2. Tests + +- [x] 2.1 In a fleet directory: bare `up` starts every node; `up ` starts + the named one; an unknown node name fails as `fleet start` does +- [x] 2.2 Outside a fleet directory: `up` serves `./Spinloop`; `up ` + serves the path; a `SPINLOOP_ALIAS` value and a registered alias both + resolve as they do for `serve`; nothing resolvable fails with serve's + "no Spinloop found" error +- [x] 2.3 A `fleet.yaml` and a Spinloop in the same directory: the fleet wins +- [x] 2.4 The completion slot offers node names in a fleet directory and the + Spinloop slot elsewhere, and stays silent on an unreadable fleet file + +## 3. Documentation + +- [x] 3.1 Add `docs/commands/up.md`: the dispatch rule, the two forms, and a + pointer to `serve` and `fleet start` for the full options +- [x] 3.2 Add `spinloop up` to `docs/README.md`'s command table + +## 4. Verification + +- [x] 4.1 `go test ./... -cover` passes with total coverage >= 80% +- [x] 4.2 `go vet ./...` and `gofmt -w ./...` clean +- [x] 4.3 By hand: in a Spinloop directory, `up` prints and runs the serve + command; `up --help` states the rule; where a fleet is available, `up` + in its directory starts the nodes and `fleet status` shows them + (fleet branch verified against stub daemons in 2.1 — no live fleet + available by hand) diff --git a/openspec/specs/up-command/spec.md b/openspec/specs/up-command/spec.md new file mode 100644 index 0000000..93fbfda --- /dev/null +++ b/openspec/specs/up-command/spec.md @@ -0,0 +1,133 @@ +# Up Command Specification + +## Purpose + +`spinloop up`: the one-word command that starts the engine for what is in the +current directory — the fleet a `fleet.yaml` names, or the local engine a +Spinloop describes — dispatching to `spinloop fleet start` or `spinloop serve` +rather than reimplementing either. + +## Requirements + +### Requirement: Choosing the target from the directory + +`spinloop up` SHALL start the fleet when the current directory holds a +`fleet.yaml`, and SHALL start the local engine a Spinloop describes otherwise. +A `fleet.yaml` SHALL win over a Spinloop in the same directory: when both are +present, `up` starts the fleet and the Spinloop is ignored. `up` SHALL take +positional arguments only, and a flag it does not know SHALL be refused as +unknown rather than forwarded or silently ignored. + +#### Scenario: A fleet directory with both starts the fleet + +- **WHEN** the user runs `spinloop up` in a directory holding a `fleet.yaml` + and a `Spinloop` +- **THEN** the fleet's nodes are started and the local `Spinloop` is not read + +#### Scenario: An unknown flag is refused + +- **WHEN** the user runs `spinloop up --all` in a fleet directory +- **THEN** the command fails naming the unknown flag, and no node is started + +### Requirement: Starting the fleet + +In a directory holding a `fleet.yaml`, `spinloop up [node…]` SHALL start the +engines of the fleet's nodes as `spinloop fleet start` does: the named nodes +when any are given, and every node in the fleet when none are — a bare `up` +SHALL start the whole fleet, where a bare `fleet start` refuses to run without +a target. An unknown node name, an unreadable or node-less `fleet.yaml`, and +the per-node result lines SHALL be `fleet start`'s own. + +#### Scenario: A bare up starts every node + +- **WHEN** the user runs `spinloop up` in a fleet directory, with no node + names +- **THEN** the same nodes are started, and reported the same way, as + `spinloop fleet start --all` would start them + +#### Scenario: Named nodes only + +- **WHEN** the user runs `spinloop up gpu-box` in a fleet directory naming + several nodes +- **THEN** only `gpu-box`'s engine is started, as `spinloop fleet start + gpu-box` would do + +#### Scenario: An unknown node name + +- **WHEN** the user runs `spinloop up no-such-node` in a fleet directory +- **THEN** the command fails naming the known nodes, as `fleet start` does, + and no node is started + +#### Scenario: A broken fleet file + +- **WHEN** the directory's `fleet.yaml` is unreadable or names no nodes +- **THEN** `up` fails with the fleet file's own error, as `fleet start` does + +### Requirement: Starting the local engine + +In a directory holding no `fleet.yaml`, `spinloop up [path]` SHALL behave +exactly as `spinloop serve [path]`: it SHALL resolve the Spinloop the same +way — an explicit path, a registered alias, the `SPINLOOP_ALIAS` variable, +then `./Spinloop` — print the resolved command, and run the engine with the +same output and the same errors. A directory where `serve` resolves no +Spinloop SHALL fail `up` with serve's own "no Spinloop found" error, naming +the same repairs. + +#### Scenario: A bare up serves the directory's Spinloop + +- **WHEN** the user runs `spinloop up` in a directory holding a `Spinloop` and + no `fleet.yaml` +- **THEN** the engine is printed and started exactly as `spinloop serve` + would do + +#### Scenario: A path is served + +- **WHEN** the user runs `spinloop up path/to/Spinloop` in a directory with no + `fleet.yaml` +- **THEN** that Spinloop is served, as `spinloop serve path/to/Spinloop` + would do + +#### Scenario: The environment's alias resolves + +- **WHEN** the directory holds no `./Spinloop` but `SPINLOOP_ALIAS` names a + registered alias +- **THEN** that Spinloop is served: a directory where `serve` works is a + directory where `up` works + +#### Scenario: A registered alias resolves + +- **WHEN** the user runs `spinloop up qwen` and `qwen` is a registered alias +- **THEN** the Spinloop it names is served, as `spinloop serve qwen` would do + +#### Scenario: Nothing resolvable + +- **WHEN** the user runs `spinloop up` in a directory with no `Spinloop`, no + `SPINLOOP_ALIAS`, and no argument +- **THEN** the command fails with serve's "no Spinloop found" error, naming a + path, an alias, and `SPINLOOP_ALIAS` as serve does + +### Requirement: Completion offers what the branch accepts + +`up`'s tab completion SHALL offer the fleet's node names when the current +directory holds a `fleet.yaml`, and otherwise the Spinloop slot — registered +alias names plus paths. When the fleet file cannot be read, completion SHALL +stay silent: no candidates, no stderr, no error. + +#### Scenario: Node names in a fleet directory + +- **WHEN** the user completes `spinloop up ` in a directory holding a + `fleet.yaml` +- **THEN** the fleet's node names are offered + +#### Scenario: The Spinloop slot elsewhere + +- **WHEN** the user completes `spinloop up ` in a directory with no + `fleet.yaml` +- **THEN** registered alias names and paths are offered, as on the other + Spinloop commands + +#### Scenario: An unreadable fleet file stays quiet + +- **WHEN** completion is attempted in a directory whose `fleet.yaml` cannot be + read +- **THEN** no candidates are offered and nothing is written to stderr