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
21 changes: 15 additions & 6 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/spacedock-dev/spacedock/internal/claudeteam"
"github.com/spacedock-dev/spacedock/internal/contract"
"github.com/spacedock-dev/spacedock/internal/dispatch"
"github.com/spacedock-dev/spacedock/internal/safehouse"
"github.com/spacedock-dev/spacedock/internal/status"
)

Expand Down Expand Up @@ -105,7 +106,7 @@ func newRootCommand(ctx context.Context, rawArgs []string, env []string, dir str
CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
RunE: func(cmd *cobra.Command, args []string) error {
if versionFlag {
printVersion(stdout)
printVersion(stdout, dir, execRuntimeProbe{}, exec.LookPath)
return nil
}
// No subcommand and no recognized flag: an unknown command token
Expand Down Expand Up @@ -496,12 +497,20 @@ func cwd() string {
return dir
}

// printVersion emits the version line with the contract token. The token is
// load-bearing: the FO/ensign skills read `(contract N)` from `spacedock
// --version`, so cobra's auto version-flag (a bare version string, plus a command
// row in help) is deliberately NOT used.
func printVersion(w io.Writer) {
// printVersion emits the version line with the contract token, then the sandbox
// posture and a per-runtime install/enablement block. The FIRST line is unchanged
// — `spacedock <ver> (contract <N>)` — and load-bearing: the FO/ensign skills read
// `(contract N)` from it, so everything new is appended BELOW line 1 (cobra's auto
// version-flag, a bare version string, is deliberately NOT used). The Sandbox line
// renders the shared three-way state for dir; the per-runtime block reports each
// host's binary install and spacedock-plugin enablement from the injected probe.
func printVersion(w io.Writer, dir string, probe runtimeProbe, lookPath func(string) (string, error)) {
fmt.Fprintf(w, "spacedock %s (contract %d)\n", Version, contract.CONTRACT_VERSION)
available, _ := safehouse.Available(lookPath)
fmt.Fprintf(w, "Sandbox: %s\n", safehouse.State(safehouse.Present(dir), available))
for _, host := range []string{"claude", "codex", "pi"} {
fmt.Fprintln(w, runtimeLine(host, probe.ProbeRuntime(host)))
}
}

// runCompletion emits a static shell-completion script for bash or zsh to
Expand Down
6 changes: 4 additions & 2 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,11 @@ func TestVersion(t *testing.T) {
if code != 0 {
t.Fatalf("Run returned %d, want 0", code)
}
// The FIRST line is the load-bearing, FO-parsed version+contract line; the
// sandbox + per-runtime block follows it (asserted in version_runtime_test.go).
want := "spacedock " + Version + " (contract " + strconv.Itoa(contract.CONTRACT_VERSION) + ")"
if got := strings.TrimSpace(stdout.String()); got != want {
t.Fatalf("version output = %q, want %q", got, want)
if got := strings.SplitN(stdout.String(), "\n", 2)[0]; got != want {
t.Fatalf("version first line = %q, want %q", got, want)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
Expand Down
17 changes: 11 additions & 6 deletions internal/cli/frontdoor.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,18 @@ func noPluginRemedy(host string) string {
}

// launchBanner writes a short pre-launch orientation banner to w before the host
// is handed control: the spacedock version, the workflow detected from dir, and a
// one-line orientation pointer. Callers suppress it on a resume (the operator is
// continuing a session, not starting one).
func launchBanner(host, dir string, w io.Writer) {
// is handed control: the spacedock version, the workflow detected from dir, the
// sandbox posture, and a one-line orientation pointer. The Sandbox: line renders
// the shared three-way state from `selected` (whether this launch would be wrapped
// — a .safehouse profile or a --safehouse* flag) and whether the safehouse binary
// resolves via lookPath (injected so tests pin it). Callers suppress the banner on
// a resume (the operator is continuing a session, not starting one).
func launchBanner(host, dir string, selected bool, lookPath func(string) (string, error), w io.Writer) {
label, value := detectedWorkflow(dir)
available, _ := safehouse.Available(lookPath)
fmt.Fprintf(w, "spacedock %s · launching %s as your first officer\n", Version, host)
fmt.Fprintf(w, "%s: %s\n", label, value)
fmt.Fprintf(w, "Sandbox: %s\n", safehouse.State(selected, available))
fmt.Fprintf(w, "%s is your first officer — ask it for the queue and next steps.\n", host)
}

Expand Down Expand Up @@ -294,7 +299,7 @@ func runClaude(ctx context.Context, args []string, dir string, ops hostOps, look
wrap := safehouse.Present(dir) || fd.forceSafehouse || len(fd.safehouseFlags) > 0
resume := containsResume(fd.passthrough)
if !resume {
launchBanner("claude", dir, stderr)
launchBanner("claude", dir, wrap, lookPath, stderr)
}
inner := []string{"claude"}
if wrap {
Expand Down Expand Up @@ -452,7 +457,7 @@ func runCodex(ctx context.Context, args []string, dir string, ops hostOps, lookP
wrap := safehouse.Present(dir) || fd.forceSafehouse || len(fd.safehouseFlags) > 0
resume := codexResume(fd.passthrough)
if !resume {
launchBanner("codex", dir, stderr)
launchBanner("codex", dir, wrap, lookPath, stderr)
}
inner := []string{"codex"}
if wrap {
Expand Down
7 changes: 5 additions & 2 deletions internal/cli/host_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,14 @@ type execHost struct{}
var _ hostOps = execHost{}

// pluginListEntry is the subset of `<host> plugin list --json` this binary
// reads: the `plugin@marketplace` id and the resolved install path. (Observed
// schema: the entry carries `id`, not separate name/marketplace fields.)
// reads: the `plugin@marketplace` id, the resolved install path, and whether the
// plugin is enabled in the host. (Observed schema: the entry carries `id`, not
// separate name/marketplace fields; `enabled` is distinct from `installPath` —
// an installed plugin can be present-but-disabled.)
type pluginListEntry struct {
ID string `json:"id"`
InstallPath string `json:"installPath"`
Enabled bool `json:"enabled"`
}

// ResolveManifest returns the installed spacedock@spacedock plugin manifest path
Expand Down
174 changes: 174 additions & 0 deletions internal/cli/host_runtime.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// ABOUTME: Per-runtime install/enablement detection for --version — claude/codex
// ABOUTME: plugin-list enablement and pi readiness, behind an injectable probe seam.
package cli

import (
"encoding/json"
"fmt"
"os/exec"
"strings"
)

// enablement is a host's spacedock-plugin enablement posture. The zero value is
// notEnabled (the safe default: a host we could read but found no enabled
// spacedock entry is not enabled). enablementUnknown is reserved for the probe
// that could not determine enablement — a binary that resolves but whose
// enablement read errored (the sandboxed `codex plugin list` "Operation not
// permitted" mode), distinct from a confidently not-enabled host.
type enablement int

const (
enablementNotEnabled enablement = iota
enablementEnabled
enablementUnknown
)

// runtimeStatus is a host's --version posture: whether the host binary resolves,
// and (when it does) its spacedock-plugin enablement. enablement is meaningless
// when installed is false.
type runtimeStatus struct {
installed bool
enablement enablement
}

// runtimeProbe reports a host's install + enablement posture. Production backs it
// with the real host CLIs (execRuntimeProbe); tests back it with a fake that pins
// each host's outcome, so --version never shells a live host CLI in the test path.
type runtimeProbe interface {
ProbeRuntime(host string) runtimeStatus
}

// runtimeLine renders one host's --version line from its status. An absent binary
// is `not installed`; a probe that could not read enablement is `installed,
// enablement unknown` (never silently not installed); an enabled plugin is
// `installed, spacedock enabled`; otherwise the bare `installed`.
func runtimeLine(host string, s runtimeStatus) string {
if !s.installed {
return host + ": not installed"
}
switch s.enablement {
case enablementEnabled:
return host + ": installed, spacedock enabled"
case enablementUnknown:
return host + ": installed, enablement unknown"
default:
return host + ": installed"
}
}

// claudeEnablement reads a `claude plugin list --json` body and resolves the
// spacedock@spacedock entry's enablement from its `enabled` boolean (AC-4): an
// entry with `enabled:true` is enabled, `enabled:false` (or no spacedock entry at
// all) is not enabled. A body that does not parse is an error so the caller renders
// `enablement unknown` rather than silently downgrading to not-enabled.
func claudeEnablement(body []byte) (enablement, error) {
var entries []pluginListEntry
if err := json.Unmarshal(body, &entries); err != nil {
return enablementUnknown, fmt.Errorf("parse claude plugin list --json: %w", err)
}
for _, e := range entries {
if e.ID == "spacedock@spacedock" {
if e.Enabled {
return enablementEnabled, nil
}
return enablementNotEnabled, nil
}
}
return enablementNotEnabled, nil
}

// codexEntryEnabled reports whether the `codex plugin list` text output marks the
// given plugin id as enabled. It mirrors codexEntryInstalled's field-based parse:
// the id must be a whitespace-delimited field, and a following field (within the
// row, stripped of surrounding `()` and a trailing `,`) must equal `enabled`. The
// codex status renders as `installed, enabled` (table form) or `(installed,
// enabled)` (legacy paren form), so `enabled` is the field after `installed`.
func codexEntryEnabled(listing, id string) bool {
for _, line := range strings.Split(listing, "\n") {
fields := strings.Fields(line)
idIdx := -1
for i, f := range fields {
if f == id {
idIdx = i
break
}
}
if idIdx < 0 {
continue
}
for _, f := range fields[idIdx+1:] {
if strings.Trim(f, "(),") == "enabled" {
return true
}
}
}
return false
}

// execRuntimeProbe backs runtimeProbe with the real host CLIs and exec. It is the
// production seam --version uses; the test path injects a fake instead.
type execRuntimeProbe struct{}

var _ runtimeProbe = execRuntimeProbe{}

// ProbeRuntime resolves the host binary on PATH, then (when present) reads its
// spacedock-plugin enablement. A binary that does not resolve is not installed; a
// binary that resolves but whose enablement read errors is `enablement unknown`
// (the sandbox-denied probe), never silently downgraded to not-installed.
func (execRuntimeProbe) ProbeRuntime(host string) runtimeStatus {
if _, err := exec.LookPath(host); err != nil {
return runtimeStatus{installed: false}
}
switch host {
case "claude":
return runtimeStatus{installed: true, enablement: probeClaudeEnablement()}
case "codex":
return runtimeStatus{installed: true, enablement: probeCodexEnablement()}
case "pi":
return runtimeStatus{installed: true, enablement: probePiEnablement()}
default:
return runtimeStatus{installed: true, enablement: enablementUnknown}
}
}

// probeClaudeEnablement shells `claude plugin list --json` and reads the spacedock
// entry's `enabled` field. A failed command or unparseable body is `enablement
// unknown` (the host resolved, but enablement could not be determined).
func probeClaudeEnablement() enablement {
out, err := exec.Command("claude", "plugin", "list", "--json").Output()
if err != nil {
return enablementUnknown
}
state, err := claudeEnablement(out)
if err != nil {
return enablementUnknown
}
return state
}

// probeCodexEnablement shells `codex plugin list` and reads the spacedock entry's
// `enabled` status from the text listing. A failed command (the sandbox-denied
// "Operation not permitted" mode) is `enablement unknown`; an installed-but-not-
// enabled entry is not enabled.
func probeCodexEnablement() enablement {
out, err := exec.Command("codex", "plugin", "list").CombinedOutput()
if err != nil {
return enablementUnknown
}
if codexEntryEnabled(string(out), "spacedock@spacedock") {
return enablementEnabled
}
return enablementNotEnabled
}

// probePiEnablement runs the existing pi-runtime readiness check (pi has no
// plugin-list model — verified in the spike), so pi's "spacedock enabled" reuses
// piRuntimeLaunchReady (skills + extension present), NOT a plugin probe. Pi
// readiness is a boolean, so it never resolves to `enablement unknown`.
func probePiEnablement() enablement {
cfg := piRuntimeConfigFromEnv(nil, cwd(), "")
if piRuntimeLaunchReady(checkPiRuntime(execPiRuntimeOps{}, cfg)) {
return enablementEnabled
}
return enablementNotEnabled
}
45 changes: 45 additions & 0 deletions internal/cli/launch_banner_sandbox_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// ABOUTME: AC-1 oracle — the launcher banner carries a Sandbox: line whose text
// ABOUTME: matches the three-way state from (profile-present, binary-available).
package cli

import (
"bytes"
"testing"
)

// renderBannerSandbox exercises the real launchBanner for host from dir against a
// pinned lookPath stub (so the safehouse-binary availability is controlled, not
// read from the machine PATH) and returns the rendered stderr bytes. `selected`
// mirrors the launcher's wrap decision (a profile present, or a --safehouse* flag).
func renderBannerSandbox(host, dir string, selected bool, lookPath func(string) (string, error)) string {
var buf bytes.Buffer
launchBanner(host, dir, selected, lookPath, &buf)
return buf.String()
}

// TestLaunchBannerSandboxLine (AC-1) drives launchBanner over each of the three
// (selected, available) input combinations and asserts the exact rendered Sandbox:
// line. The expected state strings are written here independently of the source,
// so a reverted-wording edit fails this assertion. `selected` is the launcher's
// own wrap decision; availability is pinned via lookFound / lookMissing.
func TestLaunchBannerSandboxLine(t *testing.T) {
cases := []struct {
name string
selected bool
lookPath func(string) (string, error)
want string
}{
{"enabled", true, lookFound, "Sandbox: enabled (safehouse)"},
{"available-not-enabled", false, lookFound, "Sandbox: available, not enabled (no .safehouse profile)"},
{"unavailable", false, lookMissing, "Sandbox: unavailable (safehouse not on PATH)"},
{"unavailable-even-when-selected", true, lookMissing, "Sandbox: unavailable (safehouse not on PATH)"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out := renderBannerSandbox("claude", t.TempDir(), tc.selected, tc.lookPath)
if !lineEquals(out, tc.want) {
t.Fatalf("banner sandbox line = %q, want a whole line %q", out, tc.want)
}
})
}
}
10 changes: 5 additions & 5 deletions internal/cli/launch_banner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func TestLaunchBannerNamesDetectedWorkflow(t *testing.T) {
t.Run("from the repo root names the real workflow, not the .claude/.worktrees noise", func(t *testing.T) {
repo, _ := repoWithRealWorkflowAndNoise(t)
var buf bytes.Buffer
launchBanner("claude", repo, &buf)
launchBanner("claude", repo, false, lookMissing, &buf)

out := buf.String()
if !strings.Contains(out, "spacedock "+Version) {
Expand All @@ -105,7 +105,7 @@ func TestLaunchBannerNamesDetectedWorkflow(t *testing.T) {
t.Fatal(err)
}
var buf bytes.Buffer
launchBanner("codex", sub, &buf)
launchBanner("codex", sub, false, lookMissing, &buf)

out := buf.String()
if !strings.Contains(out, "Workflow: "+filepath.Join("docs", "dev")+"\n") {
Expand All @@ -118,7 +118,7 @@ func TestLaunchBannerNamesDetectedWorkflow(t *testing.T) {
commissionWorkflowAt(t, filepath.Join(repo, "docs", "dev"))
commissionWorkflowAt(t, filepath.Join(repo, "ops", "release"))
var buf bytes.Buffer
launchBanner("claude", repo, &buf)
launchBanner("claude", repo, false, lookMissing, &buf)

out := buf.String()
wantLine := "Workflows: " + filepath.Join("docs", "dev") + " " + filepath.Join("ops", "release") + "\n"
Expand All @@ -136,7 +136,7 @@ func TestLaunchBannerNamesDetectedWorkflow(t *testing.T) {
workflow := t.TempDir()
commissionWorkflowAt(t, workflow)
var buf bytes.Buffer
launchBanner("codex", workflow, &buf)
launchBanner("codex", workflow, false, lookMissing, &buf)

out := buf.String()
if !strings.Contains(out, "Workflow: "+filepath.Base(workflow)+"\n") {
Expand All @@ -153,7 +153,7 @@ func TestLaunchBannerNamesDetectedWorkflow(t *testing.T) {
t.Run("outside any workflow", func(t *testing.T) {
bare := t.TempDir() // no commissioned README in or above this temp dir
var buf bytes.Buffer
launchBanner("codex", bare, &buf)
launchBanner("codex", bare, false, lookMissing, &buf)

out := buf.String()
if !strings.Contains(out, "spacedock "+Version) {
Expand Down
Loading
Loading