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
5 changes: 3 additions & 2 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -498,12 +498,13 @@ func cwd() string {
}

// 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
// posture and a per-runtime plugin-version 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.
// host's installed spacedock plugin version (and a best-effort enabled marker)
// 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)
Expand Down
195 changes: 114 additions & 81 deletions internal/cli/host_runtime.go
Original file line number Diff line number Diff line change
@@ -1,89 +1,104 @@
// ABOUTME: Per-runtime install/enablement detection for --version — claude/codex
// ABOUTME: plugin-list enablement and pi readiness, behind an injectable probe seam.
// ABOUTME: Per-runtime plugin-version detection for --version — claude/codex
// ABOUTME: manifest version + best-effort enabled marker and pi readiness, behind a probe.
package cli

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

"github.com/spacedock-dev/spacedock/internal/contract"
)

// 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
// enabledMarker is the best-effort enabled/disabled marker rendered after a host's
// plugin version. The zero value is markerUnknown: the probe could not read the
// host's enabled-state (a failed or unparseable `plugin list`), so the version
// renders bare with no marker rather than claiming a state we did not observe.
// markerEnabled (the normal case) also renders bare — only markerDisabled appends
// the ` (disabled)` marker, since "enabled" is the unremarkable default.
type enabledMarker int

const (
enablementNotEnabled enablement = iota
enablementEnabled
enablementUnknown
markerUnknown enabledMarker = iota
markerEnabled
markerDisabled
)

// 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.
// the installed plugin version (from the resolved manifest; "" when no plugin), a
// best-effort enabled/disabled marker for that plugin, and pi's launch-readiness
// (pi has no marketplace version, so it renders `ready` instead). version, marker,
// and ready are meaningless when installed is false.
type runtimeStatus struct {
installed bool
enablement enablement
installed bool
version string
marker enabledMarker
ready bool
}

// 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.
// runtimeProbe reports a host's install + plugin-version 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`.
// runtimeLine renders one host's --version line from its status, version-forward.
// An absent host binary is `not installed`. A host with a resolved plugin version
// is `spacedock <version>`, appending ` (disabled)` only when the best-effort
// marker confidently read disabled (markerEnabled and markerUnknown both render
// bare — enabled is the normal case, and an unread marker omits rather than
// invents). A host present with no plugin version is `spacedock not installed`;
// pi's launch-ready model has no version, so it renders `spacedock ready`.
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"
if s.version != "" {
line := host + ": spacedock " + s.version
if s.marker == markerDisabled {
line += " (disabled)"
}
return line
}
if s.ready {
return host + ": spacedock ready"
}
return host + ": spacedock not 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) {
// claudeMarker reads a `claude plugin list --json` body and resolves the
// spacedock@spacedock entry's best-effort enabled marker from its `enabled`
// boolean: an entry with `enabled:true` is markerEnabled, `enabled:false` is
// markerDisabled. An absent spacedock entry is markerUnknown (the version comes
// from the resolved manifest, so a missing list entry must not be reported as
// disabled). A body that does not parse is an error so the caller renders the bare
// version (markerUnknown) rather than claiming a state it could not read.
func claudeMarker(body []byte) (enabledMarker, error) {
var entries []pluginListEntry
if err := json.Unmarshal(body, &entries); err != nil {
return enablementUnknown, fmt.Errorf("parse claude plugin list --json: %w", err)
return markerUnknown, 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 markerEnabled, nil
}
return enablementNotEnabled, nil
return markerDisabled, nil
}
}
return enablementNotEnabled, nil
return markerUnknown, nil
}

// codexEntryEnabled reports whether the `codex plugin list` text output marks the
// given plugin id as enabled. It mirrors codexEntryInstalled's field-based parse:
// codexEntryDisabled reports whether the `codex plugin list` text output marks the
// given plugin id as disabled. 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 {
// row, stripped of surrounding `()` and a trailing `,`) must equal `disabled`. An
// absent `disabled` field is not a disabled plugin (an installed row reads
// `installed, enabled`), so the caller treats the unmarked case as enabled.
func codexEntryDisabled(listing, id string) bool {
for _, line := range strings.Split(listing, "\n") {
fields := strings.Fields(line)
idIdx := -1
Expand All @@ -97,7 +112,7 @@ func codexEntryEnabled(listing, id string) bool {
continue
}
for _, f := range fields[idIdx+1:] {
if strings.Trim(f, "(),") == "enabled" {
if strings.Trim(f, "(),") == "disabled" {
return true
}
}
Expand All @@ -111,64 +126,82 @@ 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.
// ProbeRuntime resolves the host binary on PATH, then (when present) reads the
// installed plugin version and a best-effort enabled marker. A binary that does
// not resolve is not installed. The version is read ROBUSTLY from the resolved
// plugin manifest (execHost.ResolveManifest → the manifest's version, the same
// source doctor and the contract gate read), so it renders even when the
// enabled-state probe (`plugin list`) errors — in that case the marker stays
// markerUnknown and the version shows bare. pi has no marketplace version, so it
// reports launch-readiness instead.
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()}
return runtimeStatus{installed: true, version: probeVersion(host), marker: probeClaudeMarker()}
case "codex":
return runtimeStatus{installed: true, enablement: probeCodexEnablement()}
return runtimeStatus{installed: true, version: probeVersion(host), marker: probeCodexMarker()}
case "pi":
return runtimeStatus{installed: true, enablement: probePiEnablement()}
return runtimeStatus{installed: true, ready: probePiReady()}
default:
return runtimeStatus{installed: true, enablement: enablementUnknown}
return runtimeStatus{installed: true}
}
}

// probeVersion resolves the installed plugin manifest for host and reads its
// version, the robust version source `doctor` reads. A resolve error or a missing
// manifest yields "" (no plugin version to show), so the caller renders `spacedock
// not installed`. The marker is read separately and best-effort, so a `plugin
// list` failure never suppresses the version this returns.
func probeVersion(host string) string {
manifestPath, err := execHost{}.ResolveManifest(host)
if err != nil || manifestPath == "" {
return ""
}
version, err := contract.ManifestVersion(manifestPath)
if err != nil {
return ""
}
return version
}

// 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 {
// probeClaudeMarker shells `claude plugin list --json` and reads the spacedock
// entry's `enabled` field into a best-effort marker. A failed command or
// unparseable body is markerUnknown (the version still renders from the manifest;
// only the marker is omitted).
func probeClaudeMarker() enabledMarker {
out, err := exec.Command("claude", "plugin", "list", "--json").Output()
if err != nil {
return enablementUnknown
return markerUnknown
}
state, err := claudeEnablement(out)
marker, err := claudeMarker(out)
if err != nil {
return enablementUnknown
return markerUnknown
}
return state
return marker
}

// 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 {
// probeCodexMarker shells `codex plugin list` and reads the spacedock entry's
// disabled status from the text listing. A failed command (the sandbox-denied
// "Operation not permitted" mode) is markerUnknown; an explicit disabled row is
// markerDisabled; otherwise the installed-and-enabled default is markerEnabled.
func probeCodexMarker() enabledMarker {
out, err := exec.Command("codex", "plugin", "list").CombinedOutput()
if err != nil {
return enablementUnknown
return markerUnknown
}
if codexEntryEnabled(string(out), "spacedock@spacedock") {
return enablementEnabled
if codexEntryDisabled(string(out), "spacedock@spacedock") {
return markerDisabled
}
return enablementNotEnabled
return markerEnabled
}

// 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 {
// probePiReady runs the existing pi-runtime readiness check (pi has no plugin-list
// or marketplace-version model — verified in the spike), so pi's `spacedock ready`
// reuses piRuntimeLaunchReady (skills + extension present), NOT a plugin probe.
func probePiReady() bool {
cfg := piRuntimeConfigFromEnv(nil, cwd(), "")
if piRuntimeLaunchReady(checkPiRuntime(execPiRuntimeOps{}, cfg)) {
return enablementEnabled
}
return enablementNotEnabled
return piRuntimeLaunchReady(checkPiRuntime(execPiRuntimeOps{}, cfg))
}
43 changes: 22 additions & 21 deletions internal/cli/version_claude_enabled_test.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
// ABOUTME: AC-4 oracle — claude spacedock-enablement reflects the plugin list
// ABOUTME: `enabled` boolean, not mere presence; a false entry is not "enabled".
// ABOUTME: AC oracle — claude's best-effort enabled/disabled marker reflects the
// ABOUTME: plugin list `enabled` boolean; an absent entry is unknown, not disabled.
package cli

import "testing"

// TestClaudeEnablementFromListJSON (AC-4) feeds the enablement reader a `claude
// plugin list --json` body with the spacedock entry's `enabled` set true vs false
// (and absent) and asserts the resolved enablement. The fixture JSON — shaped from
// the live spike capture (id + installPath + enabled) — is the independent source
// of truth: an `enabled:false` entry must NOT resolve to enabled.
func TestClaudeEnablementFromListJSON(t *testing.T) {
// TestClaudeMarkerFromListJSON feeds the marker reader a `claude plugin list
// --json` body with the spacedock entry's `enabled` set true vs false (and absent)
// and asserts the resolved best-effort marker. The fixture JSON — shaped from the
// live spike capture (id + installPath + enabled) — is the independent source of
// truth: an `enabled:false` entry resolves to disabled, an absent entry to unknown
// (the version still comes from the manifest, so we never claim it is disabled).
func TestClaudeMarkerFromListJSON(t *testing.T) {
const enabledTrue = `[{"id":"spacedock@spacedock","installPath":"/p/0.19.9","enabled":true},
{"id":"other@market","installPath":"/q","enabled":true}]`
const enabledFalse = `[{"id":"spacedock@spacedock","installPath":"/p/0.19.9","enabled":false}]`
Expand All @@ -18,30 +19,30 @@ func TestClaudeEnablementFromListJSON(t *testing.T) {
cases := []struct {
name string
body string
want enablement
want enabledMarker
}{
{"enabled-true", enabledTrue, enablementEnabled},
{"enabled-false", enabledFalse, enablementNotEnabled},
{"no-spacedock-entry", noSpacedock, enablementNotEnabled},
{"enabled-true", enabledTrue, markerEnabled},
{"enabled-false", enabledFalse, markerDisabled},
{"no-spacedock-entry", noSpacedock, markerUnknown},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := claudeEnablement([]byte(tc.body))
got, err := claudeMarker([]byte(tc.body))
if err != nil {
t.Fatalf("claudeEnablement(%s) err = %v, want nil", tc.name, err)
t.Fatalf("claudeMarker(%s) err = %v, want nil", tc.name, err)
}
if got != tc.want {
t.Fatalf("claudeEnablement(%s) = %v, want %v", tc.name, got, tc.want)
t.Fatalf("claudeMarker(%s) = %v, want %v", tc.name, got, tc.want)
}
})
}
}

// TestClaudeEnablementMalformedJSONErrors (AC-5 feeder) asserts a body that does
// not parse is an error, so the caller renders `enablement unknown` rather than
// silently downgrading to not-enabled.
func TestClaudeEnablementMalformedJSONErrors(t *testing.T) {
if _, err := claudeEnablement([]byte("not json")); err == nil {
t.Fatalf("claudeEnablement(malformed) err = nil, want a parse error so the caller renders enablement unknown")
// TestClaudeMarkerMalformedJSONErrors asserts a body that does not parse is an
// error, so the caller renders the bare version (markerUnknown) rather than
// silently claiming the plugin is disabled.
func TestClaudeMarkerMalformedJSONErrors(t *testing.T) {
if _, err := claudeMarker([]byte("not json")); err == nil {
t.Fatalf("claudeMarker(malformed) err = nil, want a parse error so the caller renders the bare version")
}
}
Loading
Loading