From 5358416bd9aff892e9c190f8d9f8e34406e3b715 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Fri, 4 Sep 2026 00:32:24 +0100 Subject: [PATCH 1/2] docs(openspec): propose the -f short form for --fleet --- .../changes/fleet-short-arg/.openspec.yaml | 2 + openspec/changes/fleet-short-arg/design.md | 115 ++++++++++++++++++ openspec/changes/fleet-short-arg/proposal.md | 56 +++++++++ .../specs/fleet-client/spec.md | 38 ++++++ .../specs/fleet-config/spec.md | 40 ++++++ .../specs/fleet-routing/spec.md | 69 +++++++++++ openspec/changes/fleet-short-arg/tasks.md | 53 ++++++++ 7 files changed, 373 insertions(+) create mode 100644 openspec/changes/fleet-short-arg/.openspec.yaml create mode 100644 openspec/changes/fleet-short-arg/design.md create mode 100644 openspec/changes/fleet-short-arg/proposal.md create mode 100644 openspec/changes/fleet-short-arg/specs/fleet-client/spec.md create mode 100644 openspec/changes/fleet-short-arg/specs/fleet-config/spec.md create mode 100644 openspec/changes/fleet-short-arg/specs/fleet-routing/spec.md create mode 100644 openspec/changes/fleet-short-arg/tasks.md diff --git a/openspec/changes/fleet-short-arg/.openspec.yaml b/openspec/changes/fleet-short-arg/.openspec.yaml new file mode 100644 index 00000000..1d9aeef9 --- /dev/null +++ b/openspec/changes/fleet-short-arg/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-04 diff --git a/openspec/changes/fleet-short-arg/design.md b/openspec/changes/fleet-short-arg/design.md new file mode 100644 index 00000000..ffc8786a --- /dev/null +++ b/openspec/changes/fleet-short-arg/design.md @@ -0,0 +1,115 @@ +## Context + +See proposal.md for motivation. The relevant current state: + +- Eight commands register a value-taking `--fleet` flag: the six `fleet` + subcommands `status`, `metrics`, `start`, `stop`, `deploy`, and `route` + (all in `cmd/spinloop/fleet.go`), `fleet dashboard` + (`cmd/spinloop/fleet_dashboard.go`), and `harness` + (`cmd/spinloop/commands.go`). +- `fleet logs` is the ninth `--fleet`-bearing command, but its `-f` is + already the short form of `--follow` (`cmd/spinloop/fleet_logs.go`). `remote + logs` uses the same `-f`/`--follow` pairing but has no `--fleet` flag, so + it is unaffected. +- The flag framework (pflag, via Cobra) forbids two flags on one command + sharing a short name; a second registration of `-f` on `fleet logs` panics + at command-tree construction. +- Completion is derived from the registered tree, so flag *names* in both + forms complete without any table. The one custom table is + `harnessValueFlags` in `cmd/spinloop/complete.go`, which the `harness` + command's hand-rolled slot logic uses to know which flags consume a + following word, plus the detached-flag value case in `harnessSlot`. + +## Goals / Non-Goals + +**Goals:** + +- `-f` means the fleet file on every `--fleet`-bearing command except + `fleet logs`, where it keeps meaning `--follow`. +- `--fleet` keeps working everywhere, unchanged, so no script or muscle + memory breaks. +- Completion keeps working: `-f` completes as a flag name (automatic) and + completes its value as a file path (the one table that needs to learn it). + +**Non-Goals:** + +- No reassignment of `--follow` on `fleet logs`; the exception is the + design, not a gap to close. +- No other flag renames or new short forms. +- No change to how the fleet file is resolved once the flag is read; + `fleet.Resolve` and its callers are untouched. + +## Decisions + +**1. The exception list is exactly `fleet logs`, and the short form is not +added there.** + +Alternative considered: give `--fleet` the `-f` short form on `fleet logs` +too, by moving `--follow` to another short letter. Rejected: following is +the defining verb of `logs`, `-f`/`--follow` is already written into specs, +docs, and examples, and changing it would be a breaking change to buy +nothing — the other eight commands cover the typing this issue is about. +Leaving `logs`'s `--fleet` long-only is additive-only on every command. + +**2. Register with `StringVarP(..., "f", ...)` at each existing call site, +not a shared helper.** + +Each command already registers its own `--fleet` with its own help string +(`fleetFileUsage` shared by the fleet subcommands; a fleet-routing-specific +string on `harness`). A helper would save nothing — the short name is a +constant argument on an existing one-line call — and it would centralise the +one place a future flag collision could hide. The six fleet subcommands, +`dashboard`, and `harness` each change one line from `StringVar` to +`StringVarP` with `"f"`; `fleet logs` keeps `StringVar`. + +**3. Completion: teach only `harnessSlot` about `-f`.** + +For the fleet subcommands, nothing custom sees the flag: the value +completion is `compFiles` registered per command (file paths, no static +candidates), and flag names come from the tree. `harness` is different — +Cobra flag parsing is off for it (`SetInterspersed(false)` plus manual +positional handling), so `harnessSlot` counts words itself and needs `-f` in +`harnessValueFlags` to know `-f ` consumes that word; its +detached-flag case must offer the file-path directive for `-f` just as it +does for `--fleet`. + +**4. Tests mirror the spec's scenarios.** + +- A fleet subcommand with `-f ` uses that file (one representative + command through the existing stub-node harness, since all eight share + `fleet.Resolve`; the flag wiring itself is checked for every command by + the flag-surface walk the suite already does, and by a direct + "does this command accept `-f`" check on each of the eight). +- `fleet logs -f` is follow, not a fleet file: `logs -f --fleet ` runs + against that file in follow mode, and a bare `-f ` treats + `` as a node name (unknown-node error), which is the + specification's observable consequence. +- `harness -f ` routes through that fleet file — the existing + route-test harness already drives `harness` with `--fleet`; the `-f` + variant is the same test with the short flag. +- Completion: `harness -f ` offers file paths (directive default); + `-f` appears in each command's offered flag names via the existing + tree-walk guard. + +## Risks / Trade-offs + +- [`logs` is the odd one out] → An operator who types `fleet logs -f + ./other.yaml` gets an unknown-node error naming `other.yaml`, which is the + correct interpretation of what they typed; the docs' flags table and the + `fleet logs` help state the exception, and the specs pin it with a + scenario. +- [A future command could silently re-collide on `-f`] → pflag panics at + tree construction, so the failure is loud and at startup, never at the + user's keypress; the build and the existing command-construction tests + catch it. +- [Help text drift: `harness --help` and the fleet subcommands' help now + show `-f, --fleet`] → The help strings are the existing ones; pflag renders + the short form automatically. No hand-written help text names the flag, + so there is nothing to keep in sync in code; the docs tables are updated + in the same change. + +## Migration Plan + +Nothing to migrate: the change is purely additive on seven commands plus one +(`harness`) and leaves `fleet logs` byte-identical. Rollback is reverting +the change; no state, config file, or on-disk format is touched. diff --git a/openspec/changes/fleet-short-arg/proposal.md b/openspec/changes/fleet-short-arg/proposal.md new file mode 100644 index 00000000..09c377ef --- /dev/null +++ b/openspec/changes/fleet-short-arg/proposal.md @@ -0,0 +1,56 @@ +## Why + +`--fleet` is the flag that names the fleet file on eight commands, but it has +no short form, while its siblings already do (`--watch/-w` on `metrics`, +`--dry-run/-n` on `deploy`, `--follow/-f` on `logs`, `--spinloop/-O` and +`--harness/-H` on `harness`). Pointing a command at a non-default fleet file is +the most repeated typing of the group, so it should get the same one-keystroke +treatment: `-f`. + +## What Changes + +- Add `-f` as the short form of `--fleet` on every command that carries the + flag, one exception: `fleet status`, `fleet metrics`, `fleet start`, + `fleet stop`, `fleet deploy`, `fleet route`, `fleet dashboard`, and + `harness`. +- `fleet logs` keeps `-f` as the short form of `--follow`; its `--fleet` stays + long-form only. Reassigning `-f` there would break the command's most + characteristic flag (and existing muscle memory and scripts), while adding + the short form everywhere else is purely additive. +- Tab completion: the `harness` command's custom value-completion logic learns + `-f` as a value-taking fleet-file flag; flag-name completion needs no + change because it is derived from the registered tree. + +Not breaking: `--fleet` behaves exactly as before everywhere, and no command +loses or repurposes a flag it already had. + +## Capabilities + +### New Capabilities + +(none) + +### Modified Capabilities + +- `openspec/specs/fleet-config`: the "Fleet file resolution" requirement gains + the `-f` short form for the fleet subcommands, with the `fleet logs` + exception made explicit. +- `openspec/specs/fleet-routing`: the "A fleet-routed launch" requirement + names `-f` alongside `--fleet` as the way to override the Spinloop's + `FLEET` instruction. +- `openspec/specs/fleet-client`: the "Fleet logs" requirement states that + `fleet logs` takes its fleet file only as `--fleet`, because `-f` is that + command's `--follow` short form. + +## Impact + +- `cmd/spinloop/fleet.go` — the six fleet subcommands register `--fleet` via + `StringVar`; they move to `StringVarP` with short form `f`. +- `cmd/spinloop/fleet_dashboard.go` — same one-line change for + `fleet dashboard`. +- `cmd/spinloop/commands.go` — `harness`'s `--fleet` flag gains `-f`. +- `cmd/spinloop/complete.go` — `harnessValueFlags` and the detached-flag + value case in `harnessSlot` learn `-f`. +- `cmd/spinloop/fleet_logs.go` — unchanged. +- Docs: the flags tables in `docs/commands/fleet.md` and + `docs/commands/harness.md` list the new short form. diff --git a/openspec/changes/fleet-short-arg/specs/fleet-client/spec.md b/openspec/changes/fleet-short-arg/specs/fleet-client/spec.md new file mode 100644 index 00000000..5936b264 --- /dev/null +++ b/openspec/changes/fleet-short-arg/specs/fleet-client/spec.md @@ -0,0 +1,38 @@ +## MODIFIED Requirements + +### Requirement: Fleet logs + +`spinloop fleet logs` SHALL read the engine output of the fleet's nodes through +each node's daemon, so "what did that engine say?" is answerable from the same +place as "what is it doing?" — without shell access to any machine. With no node +named it SHALL read every node in the fleet; naming a node SHALL restrict it to +that one. Nodes SHALL be read concurrently, so the command's latency is that of +the slowest reachable node rather than their sum. + +The fleet file SHALL be named by the long form `--fleet` only: unlike the other +`spinloop fleet` commands, `logs` SHALL NOT accept `-f` for it, because `-f` is +that command's `--follow` short form and a flag cannot carry two meanings on one +command line. + +#### Scenario: Reading the whole fleet + +- **WHEN** the operator runs `spinloop fleet logs` with no node named +- **THEN** every node's engine output is read and printed + +#### Scenario: Reading one node + +- **WHEN** the operator names a node +- **THEN** only that node's output is printed, and the other nodes are not + contacted + +#### Scenario: A crashed node's output is readable + +- **WHEN** a node's engine has crashed, as `spinloop fleet status` reports +- **THEN** its output up to the crash is printed, explaining what status can + only report + +#### Scenario: The fleet file has no short flag here + +- **WHEN** the operator runs `spinloop fleet logs -f --fleet ./cluster.yaml` +- **THEN** the flag is accepted as follow mode plus the fleet file, with `-f` + not treated as a fleet-file flag diff --git a/openspec/changes/fleet-short-arg/specs/fleet-config/spec.md b/openspec/changes/fleet-short-arg/specs/fleet-config/spec.md new file mode 100644 index 00000000..30f97c4f --- /dev/null +++ b/openspec/changes/fleet-short-arg/specs/fleet-config/spec.md @@ -0,0 +1,40 @@ +## MODIFIED Requirements + +### Requirement: Fleet file resolution + +The `spinloop fleet` commands SHALL resolve the fleet file from an explicit +`--fleet ` when given, otherwise `./fleet.yaml` in the working +directory. A missing file when one is required SHALL fail with a message +naming the expected path and how to create one. + +`--fleet` SHALL carry a `-f` short form on every `spinloop fleet` subcommand +except `fleet logs`, where `-f` is the short form of `--follow`; on `fleet +logs` the fleet file SHALL be named by the long form `--fleet` only. + +#### Scenario: Default resolution + +- **WHEN** a `spinloop fleet` command runs in a directory containing + `fleet.yaml` with no `--fleet` flag +- **THEN** that file is used + +#### Scenario: Explicit path + +- **WHEN** `spinloop fleet status --fleet ./cluster.yaml` runs +- **THEN** that file is used + +#### Scenario: Short form + +- **WHEN** `spinloop fleet status -f ./cluster.yaml` runs +- **THEN** `./cluster.yaml` is used, exactly as with `--fleet` + +#### Scenario: logs keeps -f for follow + +- **WHEN** the operator runs `spinloop fleet logs -f` +- **THEN** that is the command's follow flag, not a fleet-file flag, and + `logs` takes its fleet file only as `--fleet` + +#### Scenario: Missing file + +- **WHEN** a `spinloop fleet` command runs with no fleet file at the resolved + path +- **THEN** it fails, naming the expected path diff --git a/openspec/changes/fleet-short-arg/specs/fleet-routing/spec.md b/openspec/changes/fleet-short-arg/specs/fleet-routing/spec.md new file mode 100644 index 00000000..32173823 --- /dev/null +++ b/openspec/changes/fleet-short-arg/specs/fleet-routing/spec.md @@ -0,0 +1,69 @@ +## MODIFIED Requirements + +### Requirement: A fleet-routed launch + +`spinloop harness` SHALL route through a fleet when the Spinloop it wears names one +with a `FLEET` instruction, or when `--fleet ` — or its `-f ` +short form — is given; the flag SHALL +override the instruction, and a launch with neither SHALL behave exactly as it +does today. Routing SHALL choose one node and give the launched agent that +node's engine as its OpenAI-compatible endpoint: the chosen base URL SHALL be +written as the applied provider's base URL, in the same place a `REMOTE` +endpoint's address is written, and SHALL also be placed in the launched agent's +environment as `OPENAI_BASE_URL`. + +A variable already set in spinloop's environment SHALL win, as it does on the +remote path — routing fills what is unset, it does not override an explicit +choice. + +A Spinloop that pins a `BASEURL` SHALL NOT be routed: the pinned address wins and +spinloop SHALL say it is not routing through the fleet, rather than silently +selecting a node whose address it then discards. + +The chosen node and the reason it was chosen SHALL be reported on stderr before +the agent launches, so a launch that lands somewhere unexpected says so at the +time rather than at the first request. + +#### Scenario: A running node becomes the agent's endpoint + +- **WHEN** the user runs `spinloop harness` with a Spinloop naming a `FLEET`, and a + node in that fleet is running the model the Spinloop names +- **THEN** the launched agent's environment carries `OPENAI_BASE_URL` pointing + at that node's engine, and the applied provider's base URL is the same address + +#### Scenario: The flag overrides the instruction + +- **WHEN** the user runs `spinloop harness --fleet=./cluster.yaml` with a Spinloop + whose `FLEET` names a different file +- **THEN** the nodes in `./cluster.yaml` are the candidates + +#### Scenario: The short form overrides the instruction + +- **WHEN** the user runs `spinloop harness -f ./cluster.yaml` with a Spinloop + whose `FLEET` names a different file +- **THEN** the nodes in `./cluster.yaml` are the candidates + +#### Scenario: A Spinloop with no FLEET is unaffected + +- **WHEN** the user runs `spinloop harness` with a Spinloop naming no `FLEET` and + passes no `--fleet` +- **THEN** no fleet file is read, no node is contacted, and the launch behaves + as it did before + +#### Scenario: A pinned BASEURL is not routed + +- **WHEN** a Spinloop names both a `FLEET` and a `BASEURL` +- **THEN** the `BASEURL` is used, no node is selected, and spinloop reports that + it is not routing through the fleet + +#### Scenario: An exported base URL wins + +- **WHEN** `OPENAI_BASE_URL` is already set in the user's environment and a + fleet-routed launch runs +- **THEN** the existing value reaches the agent unchanged + +#### Scenario: The choice is announced + +- **WHEN** a fleet-routed launch selects a node +- **THEN** the node's name, the resolved endpoint, and why it was chosen are + written to stderr before the harness is launched diff --git a/openspec/changes/fleet-short-arg/tasks.md b/openspec/changes/fleet-short-arg/tasks.md new file mode 100644 index 00000000..c9f25aa7 --- /dev/null +++ b/openspec/changes/fleet-short-arg/tasks.md @@ -0,0 +1,53 @@ +## 1. Flag registration + +- [ ] 1.1 Add the `-f` short form to the `--fleet` flag of the six fleet + subcommands (`status`, `metrics`, `start`, `stop`, `deploy`, `route`) in + `cmd/spinloop/fleet.go`, changing each registration from `StringVar` to + `StringVarP` with shorthand `f`. +- [ ] 1.2 Add the `-f` short form to `fleet dashboard`'s `--fleet` flag in + `cmd/spinloop/fleet_dashboard.go` the same way. +- [ ] 1.3 Add the `-f` short form to `harness`'s `--fleet` flag in + `cmd/spinloop/commands.go`. +- [ ] 1.4 Leave `fleet logs` (`cmd/spinloop/fleet_logs.go`) on `StringVar` + for `--fleet` — no shorthand, since `-f` is that command's `--follow` — + with a comment saying the short form is unavailable there and why. + +## 2. Completion + +- [ ] 2.1 In `cmd/spinloop/complete.go`, add `"-f"` to `harnessValueFlags` so + the harness slot logic treats `-f ` as consuming the path word. +- [ ] 2.2 In `harnessSlot`'s detached-flag value case, offer the file-path + directive for `"-f"` alongside `--providers`/`--fleet`. + +## 3. Tests + +- [ ] 3.1 Test that the `fleet` flag on each of the eight commands carries + shorthand `f` (walk the command tree's flag sets and assert + `Lookup("fleet").Shorthand`), and that `fleet logs`'s `fleet` flag has + no shorthand while its `follow` flag keeps `-f`. +- [ ] 3.2 Test `-f ` end to end on a representative subcommand: + `fleet status -f ` (through the existing stub-node fixture) reads + the named file rather than `./fleet.yaml`. +- [ ] 3.3 Test that `fleet logs -f --fleet ` runs in follow mode + against the named file, and that `fleet logs -f ` treats `` + as a node (unknown-node error for a name not in the file). +- [ ] 3.4 Test that `harness -f ` routes through the named fleet file, + mirroring the existing `--fleet` routing test. +- [ ] 3.5 Test that `harness -f ` completes the fleet-file value as file + paths (the default directive), through the existing `complete` test + seam. + +## 4. Docs + +- [ ] 4.1 Update the flags table in `docs/commands/fleet.md`: `--fleet` + becomes `-f, --fleet `, with the table's `-f, --follow` row noting + that `logs` takes its fleet file long-form only because `-f` is its + follow flag. +- [ ] 4.2 Update the `--fleet` row in the flags table in + `docs/commands/harness.md` to show `-f, --fleet`. + +## 5. Verification + +- [ ] 5.1 `go build ./...`, `go vet ./...`, `gofmt -l .`, and + `go test ./...` are all clean. +- [ ] 5.2 `go test ./... -cover` keeps total coverage at or above 80%. From d68b68845ec22e700c2efcab7369428b225d5e9d Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Fri, 4 Sep 2026 01:06:00 +0100 Subject: [PATCH 2/2] feat: add -f as the short form of the --fleet flag --- cmd/spinloop/commands.go | 2 +- cmd/spinloop/complete.go | 3 +- cmd/spinloop/complete_test.go | 4 + cmd/spinloop/fleet.go | 12 +-- cmd/spinloop/fleet_dashboard.go | 2 +- cmd/spinloop/fleet_logs.go | 3 + cmd/spinloop/fleet_logs_test.go | 76 +++++++++++++++++++ cmd/spinloop/fleet_test.go | 69 +++++++++++++++++ cmd/spinloop/route_test.go | 28 +++++++ docs/commands/fleet.md | 2 +- docs/commands/harness.md | 2 +- .../.openspec.yaml | 0 .../2026-09-04-fleet-short-arg}/design.md | 0 .../2026-09-04-fleet-short-arg}/proposal.md | 0 .../specs/fleet-client/spec.md | 0 .../specs/fleet-config/spec.md | 0 .../specs/fleet-routing/spec.md | 0 .../2026-09-04-fleet-short-arg}/tasks.md | 30 ++++---- openspec/specs/fleet-client/spec.md | 11 +++ openspec/specs/fleet-config/spec.md | 15 ++++ openspec/specs/fleet-routing/spec.md | 9 ++- 21 files changed, 241 insertions(+), 27 deletions(-) rename openspec/changes/{fleet-short-arg => archive/2026-09-04-fleet-short-arg}/.openspec.yaml (100%) rename openspec/changes/{fleet-short-arg => archive/2026-09-04-fleet-short-arg}/design.md (100%) rename openspec/changes/{fleet-short-arg => archive/2026-09-04-fleet-short-arg}/proposal.md (100%) rename openspec/changes/{fleet-short-arg => archive/2026-09-04-fleet-short-arg}/specs/fleet-client/spec.md (100%) rename openspec/changes/{fleet-short-arg => archive/2026-09-04-fleet-short-arg}/specs/fleet-config/spec.md (100%) rename openspec/changes/{fleet-short-arg => archive/2026-09-04-fleet-short-arg}/specs/fleet-routing/spec.md (100%) rename openspec/changes/{fleet-short-arg => archive/2026-09-04-fleet-short-arg}/tasks.md (63%) diff --git a/cmd/spinloop/commands.go b/cmd/spinloop/commands.go index 56bd9e6a..52404f44 100644 --- a/cmd/spinloop/commands.go +++ b/cmd/spinloop/commands.go @@ -265,7 +265,7 @@ exits. Honours -H/--harness and SPINLOOP_HARNESS.`, // path readSpinloop resolves as SPINLOOP_ALIAS > ./Spinloop. fs.Lookup("spinloop").NoOptDefVal = "true" fs.StringVar(&providers, "providers", "", "path to a providers.yaml override") - fs.StringVar(&route.fleetPath, "fleet", "", "route through this fleet file (overrides the Spinloop's FLEET)") + fs.StringVarP(&route.fleetPath, "fleet", "f", "", "route through this fleet file (overrides the Spinloop's FLEET)") fs.StringVar(&route.node, "node", "", "pin the launch to this fleet node") fs.StringVar(&route.prefer, "prefer", "", "rank fleet nodes by `idle` or `active` (overrides the fleet file)") fs.BoolVar(&route.noWake, "no-wake", false, "fail rather than starting an engine on an idle fleet node") diff --git a/cmd/spinloop/complete.go b/cmd/spinloop/complete.go index fff5f379..b47e5f6a 100644 --- a/cmd/spinloop/complete.go +++ b/cmd/spinloop/complete.go @@ -243,6 +243,7 @@ var harnessValueFlags = map[string]bool{ "-H": true, "--providers": true, "--fleet": true, + "-f": true, "--node": true, "--prefer": true, "--wake-timeout": true, @@ -275,7 +276,7 @@ func harnessSlot(_ *cobra.Command, args []string, toComplete string) ([]string, switch last { case "--set", "--harness", "-H": return harness.Names(), cobra.ShellCompDirectiveNoFileComp - case "--providers", "--fleet": + case "--providers", "--fleet", "-f": return nil, cobra.ShellCompDirectiveDefault case "--node", "--prefer", "--wake-timeout": return nil, cobra.ShellCompDirectiveNoFileComp diff --git a/cmd/spinloop/complete_test.go b/cmd/spinloop/complete_test.go index 4ee5ea16..e0b80c17 100644 --- a/cmd/spinloop/complete_test.go +++ b/cmd/spinloop/complete_test.go @@ -309,6 +309,10 @@ func TestComplete_FlagValues(t *testing.T) { if _, directive := complete(t, "apply", "--providers", ""); directive != directiveFile { t.Errorf("--providers should complete paths, got %q", directive) } + // -f is the short form of the fleet file on harness: it completes paths. + if _, directive := complete(t, "harness", "-f", ""); directive != directiveFile { + t.Errorf("harness -f should complete paths, got %q", directive) + } if got, _ := complete(t, "completion", ""); !hasAll(got, "bash", "zsh", "powershell") { t.Errorf("shells missing from %v", got) } diff --git a/cmd/spinloop/fleet.go b/cmd/spinloop/fleet.go index 1a05aeb6..193a1d9a 100644 --- a/cmd/spinloop/fleet.go +++ b/cmd/spinloop/fleet.go @@ -59,7 +59,7 @@ func fleetStatusCmd() *cobra.Command { return nil }, } - c.Flags().StringVar(&path, "fleet", "", fleetFileUsage) + c.Flags().StringVarP(&path, "fleet", "f", "", fleetFileUsage) c.ValidArgsFunction = noPositionals compRegister(c, "fleet", compFiles) return c @@ -133,7 +133,7 @@ func fleetMetricsCmd() *cobra.Command { }, } fs := c.Flags() - fs.StringVar(&path, "fleet", "", fleetFileUsage) + fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage) fs.StringVar(&format, "format", "bar", "output format: bar (default), table or json") fs.BoolVarP(&watch, "watch", "w", false, "redraw the fleet every 60 seconds") c.ValidArgsFunction = noPositionals @@ -278,7 +278,7 @@ func fleetStartCmd() *cobra.Command { }, } fs := c.Flags() - fs.StringVar(&path, "fleet", "", fleetFileUsage) + fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage) fs.BoolVar(&all, "all", false, "start every node in the fleet") c.ValidArgsFunction = noPositionals compRegister(c, "fleet", compFiles) @@ -313,7 +313,7 @@ func fleetStopCmd() *cobra.Command { }, } fs := c.Flags() - fs.StringVar(&path, "fleet", "", fleetFileUsage) + fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage) fs.BoolVar(&all, "all", false, "stop every node in the fleet") c.ValidArgsFunction = noPositionals compRegister(c, "fleet", compFiles) @@ -440,7 +440,7 @@ derivation, consent, and registration behavior as "spinloop remote deploy".`, }, } fs := c.Flags() - fs.StringVar(&path, "fleet", "", fleetFileUsage) + fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage) fs.BoolVar(&all, "all", false, "deploy every kind: remote node in the fleet") fs.BoolVarP(&dryRun, "dry-run", "n", false, "print the config that would be deployed, without sending it") fs.BoolVar(&overwrite, "overwrite", false, "proceed against an already-registered or live environment") @@ -741,7 +741,7 @@ func fleetRouteCmd() *cobra.Command { }, } fs := c.Flags() - fs.StringVar(&path, "fleet", "", fleetFileUsage) + fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage) fs.StringVar(&node, "node", "", "report this node rather than choosing one") fs.StringVar(&prefer, "prefer", "", "rank nodes by `idle` or `active` (overrides the fleet file)") c.ValidArgsFunction = aliasSlot diff --git a/cmd/spinloop/fleet_dashboard.go b/cmd/spinloop/fleet_dashboard.go index b5099621..45cfef62 100644 --- a/cmd/spinloop/fleet_dashboard.go +++ b/cmd/spinloop/fleet_dashboard.go @@ -45,7 +45,7 @@ metrics --watch instead.`, }, } fs := c.Flags() - fs.StringVar(&path, "fleet", "", fleetFileUsage) + fs.StringVarP(&path, "fleet", "f", "", fleetFileUsage) c.ValidArgsFunction = noPositionals compRegister(c, "fleet", compFiles) return c diff --git a/cmd/spinloop/fleet_logs.go b/cmd/spinloop/fleet_logs.go index 940b4ed6..b1eabec3 100644 --- a/cmd/spinloop/fleet_logs.go +++ b/cmd/spinloop/fleet_logs.go @@ -48,6 +48,9 @@ func fleetLogsCmd() *cobra.Command { }, } fs := c.Flags() + // --fleet takes no short form here: -f is already --follow, and a flag + // cannot carry two meanings on one command line. Every other fleet + // subcommand offers -f for the fleet file. fs.StringVar(&path, "fleet", "", fleetFileUsage) fs.BoolVarP(&follow, "follow", "f", false, followUsage) fs.IntVar(&limit, "limit", 200, "lines of backlog to print per node") diff --git a/cmd/spinloop/fleet_logs_test.go b/cmd/spinloop/fleet_logs_test.go index ddddc571..3b341500 100644 --- a/cmd/spinloop/fleet_logs_test.go +++ b/cmd/spinloop/fleet_logs_test.go @@ -7,8 +7,11 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "sync" + "syscall" "testing" "time" @@ -79,6 +82,79 @@ func TestCmdFleetLogsLabelsSeveralNodes(t *testing.T) { } } +// -f is the follow flag on logs, so it sits on one line with --fleet, which +// alone names the fleet file. +func TestCmdFleetLogsFollowTakesTheFleetFile(t *testing.T) { + prev := fleetLogsInterval + fleetLogsInterval = 50 * time.Millisecond + t.Cleanup(func() { fleetLogsInterval = prev }) + + var mu sync.Mutex + polls := 0 + mux := http.NewServeMux() + mux.HandleFunc("GET /v1/logs", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + polls++ + mu.Unlock() + json.NewEncoder(w).Encode(daemon.LogsResponse{Content: "hello\n", NextOffset: 6, Size: 6}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + host, port := hostPort(t, srv) + dir := t.TempDir() + path := filepath.Join(dir, "cluster.yaml") + mustWrite(t, path, fmt.Sprintf("nodes:\n - name: box\n host: %s\n port: %d\n", host, port)) + // Somewhere else entirely, so only --fleet can find it. + t.Chdir(t.TempDir()) + + done := make(chan error, 1) + go func() { done <- cmdFleet([]string{"logs", "-f", "--fleet", path}) }() + + // The first poll is the proof of the parse: if -f had been the fleet-file + // flag it would have taken --fleet as its value and failed before + // contacting anything. + deadline := time.Now().Add(10 * time.Second) + for { + mu.Lock() + n := polls + mu.Unlock() + if n > 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("no poll arrived; the command did not enter follow mode") + } + select { + case err := <-done: + t.Fatalf("the command exited before any poll: %v", err) + case <-time.After(10 * time.Millisecond): + } + } + if err := syscall.Kill(os.Getpid(), syscall.SIGINT); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Errorf("an interrupted follow is a clean exit, got %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("the follow did not stop on interrupt") + } +} + +// -f is follow on logs, so the word after it is a node name, not a fleet file. +func TestCmdFleetLogsShortFlagIsNotTheFleetFile(t *testing.T) { + oneLogFleet(t, "hello\n") + err := cmdFleet([]string{"logs", "-f", "nope"}) + if err == nil || !strings.Contains(err.Error(), `no node "nope"`) { + t.Fatalf("want the unknown-node error for the word after -f, got %v", err) + } + if strings.Contains(err.Error(), "no fleet file") { + t.Errorf("-f took its value as a fleet file: %v", err) + } +} + func TestCmdFleetLogsReadsOneNamedNode(t *testing.T) { a := logNode(t, "from a\n", true) b := logNode(t, "from b\n", true) diff --git a/cmd/spinloop/fleet_test.go b/cmd/spinloop/fleet_test.go index c54546ab..bd5c3bef 100644 --- a/cmd/spinloop/fleet_test.go +++ b/cmd/spinloop/fleet_test.go @@ -15,6 +15,7 @@ import ( "testing" "time" + "github.com/spf13/cobra" "github.com/spinloop-ai/spinloop/internal/daemon" "github.com/spinloop-ai/spinloop/internal/fleet" "github.com/spinloop-ai/spinloop/internal/metrics" @@ -559,6 +560,74 @@ func TestCmdFleetExplicitPath(t *testing.T) { } } +// The short form is the flag: -f names the fleet file the same way --fleet +// does. +func TestCmdFleetExplicitPathShortForm(t *testing.T) { + up := stubNode(t, "running") + host, port := hostPort(t, up) + dir := t.TempDir() + path := filepath.Join(dir, "cluster.yaml") + body := fmt.Sprintf("nodes:\n - name: solo\n host: %s\n port: %d\n", host, port) + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + // Somewhere else entirely, so only -f can find it. + t.Chdir(t.TempDir()) + out := captureStdout(t, func() { + if err := cmdFleet([]string{"status", "-f", path}); err != nil { + t.Error(err) + } + }) + if !strings.Contains(out, "solo") { + t.Errorf("explicit -f not used:\n%s", out) + } +} + +// TestFleetFlagShortForm pins the -f shorthand on every --fleet-carrying +// command, and its absence on logs, where -f is --follow. +func TestFleetFlagShortForm(t *testing.T) { + isolateConfig(t) + root := newRootCmd() + fleet := commandUnder(t, root, "fleet") + for _, name := range []string{"status", "metrics", "start", "stop", "deploy", "route", "dashboard"} { + sub := commandUnder(t, fleet, name) + f := sub.Flags().Lookup("fleet") + if f == nil { + t.Errorf("fleet %s: no --fleet flag", name) + continue + } + if f.Shorthand != "f" { + t.Errorf("fleet %s: shorthand = %q, want \"f\"", name, f.Shorthand) + } + } + if f := commandUnder(t, root, "harness").Flags().Lookup("fleet"); f == nil || f.Shorthand != "f" { + t.Errorf("harness: --fleet lacks the -f shorthand") + } + logs := commandUnder(t, fleet, "logs") + if f := logs.Flags().Lookup("fleet"); f == nil { + t.Error("fleet logs: no --fleet flag") + } else if f.Shorthand != "" { + t.Errorf("fleet logs: --fleet carries shorthand %q, want none", f.Shorthand) + } + if f := logs.Flags().Lookup("follow"); f == nil { + t.Error("fleet logs: no --follow flag") + } else if f.Shorthand != "f" { + t.Errorf("fleet logs: --follow shorthand = %q, want \"f\"", f.Shorthand) + } +} + +// commandUnder finds a named subcommand in a parent's tree. +func commandUnder(t *testing.T, parent *cobra.Command, name string) *cobra.Command { + t.Helper() + for _, c := range parent.Commands() { + if c.Name() == name { + return c + } + } + t.Fatalf("command %s not found under %s", name, parent.Name()) + return nil +} + func TestCmdFleetUnknownSubcommand(t *testing.T) { if err := cmdFleet([]string{"wat"}); err == nil || !strings.Contains(err.Error(), "unknown command") { t.Fatalf("unknown subcommand should error, got %v", err) diff --git a/cmd/spinloop/route_test.go b/cmd/spinloop/route_test.go index 979cd75d..cd3134c4 100644 --- a/cmd/spinloop/route_test.go +++ b/cmd/spinloop/route_test.go @@ -160,6 +160,34 @@ func TestFleetFlagOverridesTheInstruction(t *testing.T) { }) } +// The short form is the flag: -f names the fleet a launch routes through, +// overriding the Spinloop's own FLEET. +func TestHarnessFleetFlagShortForm(t *testing.T) { + isolateConfig(t) + node := newRoutableNode(t, "qwen3-27b", true, 10) + dir := t.TempDir() + flagFleet := fleetFileIn(t, dir, "nodes:\n"+node.entry("from-flag")) + // The Spinloop names a fleet that does not exist, so a launch that + // succeeds has parsed -f as the fleet file. + spinloopDir := routedSpinloop(t, "qwen3-27b", filepath.Join(dir, "nonexistent.yaml")) + + argsFile := filepath.Join(t.TempDir(), "args") + stubHarnessBinary(t, "opencode", argsFile) + stderr := captureStderr(t, func() { + captureStdout(t, func() { + if err := cmdHarness([]string{"--spinloop=" + spinloopDir, "-f", flagFleet, "--", "run"}); err != nil { + t.Fatalf("cmdHarness -f: %v", err) + } + }) + }) + if _, err := os.ReadFile(argsFile); err != nil { + t.Fatalf("harness was not launched: %v", err) + } + if !strings.Contains(stderr, "from-flag") { + t.Errorf("the route announcement should name the flag's node, got:\n%s", stderr) + } +} + // A pinned BASEURL is the explicit answer, so nothing is selected. func TestPinnedBaseURLSkipsRouting(t *testing.T) { node := newRoutableNode(t, "qwen3-27b", true, 10) diff --git a/docs/commands/fleet.md b/docs/commands/fleet.md index 6877bf2b..95de66f4 100644 --- a/docs/commands/fleet.md +++ b/docs/commands/fleet.md @@ -509,7 +509,7 @@ deploy`](remote.md), applied per node. | Flag | Meaning | | ---- | ------- | -| `--fleet ` | The fleet file (default `./fleet.yaml`) | +| `-f`, `--fleet ` | The fleet file (default `./fleet.yaml`) — `logs` takes it long-form only, since `-f` is its follow flag | | `--all` | `start`/`stop`/`deploy`: act on every node (or every `kind: remote` node, for `deploy`) instead of named ones | | `--node ` | `route` only: report this node rather than choosing one | | `--prefer` | `route` only: rank by `idle` or `active`, overriding the file | diff --git a/docs/commands/harness.md b/docs/commands/harness.md index 7a181e7c..9ad31fcb 100644 --- a/docs/commands/harness.md +++ b/docs/commands/harness.md @@ -61,7 +61,7 @@ variable chooses which Spinloop, never whether you are configured. See | `--set` | Store the default harness and exit | | `--get` | Print the active harness instead of launching | | `--providers` | Path to a custom catalogue, for the applied Spinloop | -| `--fleet` | Route through this fleet file (overrides the Spinloop's `FLEET`) | +| `-f`, `--fleet` | Route through this fleet file (overrides the Spinloop's `FLEET`) | | `--node` | Pin the launch to one fleet node | | `--prefer` | Rank fleet nodes by `idle` or `active` (overrides the fleet file) | | `--no-wake` | Fail rather than starting an engine on an idle fleet node | diff --git a/openspec/changes/fleet-short-arg/.openspec.yaml b/openspec/changes/archive/2026-09-04-fleet-short-arg/.openspec.yaml similarity index 100% rename from openspec/changes/fleet-short-arg/.openspec.yaml rename to openspec/changes/archive/2026-09-04-fleet-short-arg/.openspec.yaml diff --git a/openspec/changes/fleet-short-arg/design.md b/openspec/changes/archive/2026-09-04-fleet-short-arg/design.md similarity index 100% rename from openspec/changes/fleet-short-arg/design.md rename to openspec/changes/archive/2026-09-04-fleet-short-arg/design.md diff --git a/openspec/changes/fleet-short-arg/proposal.md b/openspec/changes/archive/2026-09-04-fleet-short-arg/proposal.md similarity index 100% rename from openspec/changes/fleet-short-arg/proposal.md rename to openspec/changes/archive/2026-09-04-fleet-short-arg/proposal.md diff --git a/openspec/changes/fleet-short-arg/specs/fleet-client/spec.md b/openspec/changes/archive/2026-09-04-fleet-short-arg/specs/fleet-client/spec.md similarity index 100% rename from openspec/changes/fleet-short-arg/specs/fleet-client/spec.md rename to openspec/changes/archive/2026-09-04-fleet-short-arg/specs/fleet-client/spec.md diff --git a/openspec/changes/fleet-short-arg/specs/fleet-config/spec.md b/openspec/changes/archive/2026-09-04-fleet-short-arg/specs/fleet-config/spec.md similarity index 100% rename from openspec/changes/fleet-short-arg/specs/fleet-config/spec.md rename to openspec/changes/archive/2026-09-04-fleet-short-arg/specs/fleet-config/spec.md diff --git a/openspec/changes/fleet-short-arg/specs/fleet-routing/spec.md b/openspec/changes/archive/2026-09-04-fleet-short-arg/specs/fleet-routing/spec.md similarity index 100% rename from openspec/changes/fleet-short-arg/specs/fleet-routing/spec.md rename to openspec/changes/archive/2026-09-04-fleet-short-arg/specs/fleet-routing/spec.md diff --git a/openspec/changes/fleet-short-arg/tasks.md b/openspec/changes/archive/2026-09-04-fleet-short-arg/tasks.md similarity index 63% rename from openspec/changes/fleet-short-arg/tasks.md rename to openspec/changes/archive/2026-09-04-fleet-short-arg/tasks.md index c9f25aa7..8084f315 100644 --- a/openspec/changes/fleet-short-arg/tasks.md +++ b/openspec/changes/archive/2026-09-04-fleet-short-arg/tasks.md @@ -1,53 +1,53 @@ ## 1. Flag registration -- [ ] 1.1 Add the `-f` short form to the `--fleet` flag of the six fleet +- [x] 1.1 Add the `-f` short form to the `--fleet` flag of the six fleet subcommands (`status`, `metrics`, `start`, `stop`, `deploy`, `route`) in `cmd/spinloop/fleet.go`, changing each registration from `StringVar` to `StringVarP` with shorthand `f`. -- [ ] 1.2 Add the `-f` short form to `fleet dashboard`'s `--fleet` flag in +- [x] 1.2 Add the `-f` short form to `fleet dashboard`'s `--fleet` flag in `cmd/spinloop/fleet_dashboard.go` the same way. -- [ ] 1.3 Add the `-f` short form to `harness`'s `--fleet` flag in +- [x] 1.3 Add the `-f` short form to `harness`'s `--fleet` flag in `cmd/spinloop/commands.go`. -- [ ] 1.4 Leave `fleet logs` (`cmd/spinloop/fleet_logs.go`) on `StringVar` +- [x] 1.4 Leave `fleet logs` (`cmd/spinloop/fleet_logs.go`) on `StringVar` for `--fleet` — no shorthand, since `-f` is that command's `--follow` — with a comment saying the short form is unavailable there and why. ## 2. Completion -- [ ] 2.1 In `cmd/spinloop/complete.go`, add `"-f"` to `harnessValueFlags` so +- [x] 2.1 In `cmd/spinloop/complete.go`, add `"-f"` to `harnessValueFlags` so the harness slot logic treats `-f ` as consuming the path word. -- [ ] 2.2 In `harnessSlot`'s detached-flag value case, offer the file-path +- [x] 2.2 In `harnessSlot`'s detached-flag value case, offer the file-path directive for `"-f"` alongside `--providers`/`--fleet`. ## 3. Tests -- [ ] 3.1 Test that the `fleet` flag on each of the eight commands carries +- [x] 3.1 Test that the `fleet` flag on each of the eight commands carries shorthand `f` (walk the command tree's flag sets and assert `Lookup("fleet").Shorthand`), and that `fleet logs`'s `fleet` flag has no shorthand while its `follow` flag keeps `-f`. -- [ ] 3.2 Test `-f ` end to end on a representative subcommand: +- [x] 3.2 Test `-f ` end to end on a representative subcommand: `fleet status -f ` (through the existing stub-node fixture) reads the named file rather than `./fleet.yaml`. -- [ ] 3.3 Test that `fleet logs -f --fleet ` runs in follow mode +- [x] 3.3 Test that `fleet logs -f --fleet ` runs in follow mode against the named file, and that `fleet logs -f ` treats `` as a node (unknown-node error for a name not in the file). -- [ ] 3.4 Test that `harness -f ` routes through the named fleet file, +- [x] 3.4 Test that `harness -f ` routes through the named fleet file, mirroring the existing `--fleet` routing test. -- [ ] 3.5 Test that `harness -f ` completes the fleet-file value as file +- [x] 3.5 Test that `harness -f ` completes the fleet-file value as file paths (the default directive), through the existing `complete` test seam. ## 4. Docs -- [ ] 4.1 Update the flags table in `docs/commands/fleet.md`: `--fleet` +- [x] 4.1 Update the flags table in `docs/commands/fleet.md`: `--fleet` becomes `-f, --fleet `, with the table's `-f, --follow` row noting that `logs` takes its fleet file long-form only because `-f` is its follow flag. -- [ ] 4.2 Update the `--fleet` row in the flags table in +- [x] 4.2 Update the `--fleet` row in the flags table in `docs/commands/harness.md` to show `-f, --fleet`. ## 5. Verification -- [ ] 5.1 `go build ./...`, `go vet ./...`, `gofmt -l .`, and +- [x] 5.1 `go build ./...`, `go vet ./...`, `gofmt -l .`, and `go test ./...` are all clean. -- [ ] 5.2 `go test ./... -cover` keeps total coverage at or above 80%. +- [x] 5.2 `go test ./... -cover` keeps total coverage at or above 80%. diff --git a/openspec/specs/fleet-client/spec.md b/openspec/specs/fleet-client/spec.md index 5b6913c0..72798e42 100644 --- a/openspec/specs/fleet-client/spec.md +++ b/openspec/specs/fleet-client/spec.md @@ -403,6 +403,11 @@ named it SHALL read every node in the fleet; naming a node SHALL restrict it to that one. Nodes SHALL be read concurrently, so the command's latency is that of the slowest reachable node rather than their sum. +The fleet file SHALL be named by the long form `--fleet` only: unlike the other +`spinloop fleet` commands, `logs` SHALL NOT accept `-f` for it, because `-f` is +that command's `--follow` short form and a flag cannot carry two meanings on one +command line. + #### Scenario: Reading the whole fleet - **WHEN** the operator runs `spinloop fleet logs` with no node named @@ -420,6 +425,12 @@ the slowest reachable node rather than their sum. - **THEN** its output up to the crash is printed, explaining what status can only report +#### Scenario: The fleet file has no short flag here + +- **WHEN** the operator runs `spinloop fleet logs -f --fleet ./cluster.yaml` +- **THEN** the flag is accepted as follow mode plus the fleet file, with `-f` + not treated as a fleet-file flag + ### Requirement: Fleet log lines are attributed to their node When output from more than one node is printed, every line SHALL identify the diff --git a/openspec/specs/fleet-config/spec.md b/openspec/specs/fleet-config/spec.md index c12551ea..dc2af4df 100644 --- a/openspec/specs/fleet-config/spec.md +++ b/openspec/specs/fleet-config/spec.md @@ -52,6 +52,10 @@ The `spinloop fleet` commands SHALL resolve the fleet file from an explicit directory. A missing file when one is required SHALL fail with a message naming the expected path and how to create one. +`--fleet` SHALL carry a `-f` short form on every `spinloop fleet` subcommand +except `fleet logs`, where `-f` is the short form of `--follow`; on `fleet +logs` the fleet file SHALL be named by the long form `--fleet` only. + #### Scenario: Default resolution - **WHEN** a `spinloop fleet` command runs in a directory containing @@ -63,6 +67,17 @@ naming the expected path and how to create one. - **WHEN** `spinloop fleet status --fleet ./cluster.yaml` runs - **THEN** that file is used +#### Scenario: Short form + +- **WHEN** `spinloop fleet status -f ./cluster.yaml` runs +- **THEN** `./cluster.yaml` is used, exactly as with `--fleet` + +#### Scenario: logs keeps -f for follow + +- **WHEN** the operator runs `spinloop fleet logs -f` +- **THEN** that is the command's follow flag, not a fleet-file flag, and + `logs` takes its fleet file only as `--fleet` + #### Scenario: Missing file - **WHEN** a `spinloop fleet` command runs with no fleet file at the resolved diff --git a/openspec/specs/fleet-routing/spec.md b/openspec/specs/fleet-routing/spec.md index 9a41f0ec..ec251f87 100644 --- a/openspec/specs/fleet-routing/spec.md +++ b/openspec/specs/fleet-routing/spec.md @@ -9,7 +9,8 @@ with — so a machine that can reach the fleet needs no addresses of its own. ### Requirement: A fleet-routed launch `spinloop harness` SHALL route through a fleet when the Spinloop it wears names one -with a `FLEET` instruction, or when `--fleet ` is given; `--fleet` SHALL +with a `FLEET` instruction, or when `--fleet ` — or its `-f ` +short form — is given; the flag SHALL override the instruction, and a launch with neither SHALL behave exactly as it does today. Routing SHALL choose one node and give the launched agent that node's engine as its OpenAI-compatible endpoint: the chosen base URL SHALL be @@ -42,6 +43,12 @@ time rather than at the first request. whose `FLEET` names a different file - **THEN** the nodes in `./cluster.yaml` are the candidates +#### Scenario: The short form overrides the instruction + +- **WHEN** the user runs `spinloop harness -f ./cluster.yaml` with a Spinloop + whose `FLEET` names a different file +- **THEN** the nodes in `./cluster.yaml` are the candidates + #### Scenario: A Spinloop with no FLEET is unaffected - **WHEN** the user runs `spinloop harness` with a Spinloop naming no `FLEET` and