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
114 changes: 113 additions & 1 deletion cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"runtime"
"slices"
"time"

Expand Down Expand Up @@ -87,6 +88,11 @@ current staleness.`,
raptorSt := raptorstate.Resolve(active.Profile.RaptorProfile)
state["raptor"] = raptorStatusBlock(raptorSt, active.Profile.URL)

// One field an AI host can branch on instead of re-deriving "is this
// machine actually usable?" from installed/found/logged_in. The skills'
// raptor preflight reads this.
state["setup_complete"] = loggedIn && raptorReady(raptorSt)

Comment on lines +91 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Test the public setup_complete contract.

The added tests cover helper output. They do not execute status and assert setup_complete. Add table-driven command-level cases for logged-out, raptor-missing, raptor-unresolved, and ready states.

As per coding guidelines, tests must cover exported APIs and main failure paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/status.go` around lines 91 - 95, Add table-driven command-level tests
that execute the status command and assert the public state["setup_complete"]
value for logged-out, raptor-missing, raptor-unresolved, and fully ready states.
Exercise the existing status flow and its raptorReady integration rather than
testing only the helper, while preserving the expected false/true contract for
each failure and success path.

Source: Coding guidelines

if asJSON {
if statusFull {
// Same shaped schema as `list-skills --json` and
Expand Down Expand Up @@ -157,19 +163,85 @@ current staleness.`,
for _, a := range agents {
fmt.Fprintf(out, " - %-30s %-9s %-12s @ %s\n", a.AgentName, a.Kind, a.Harness, a.Path)
}
// Last thing on screen, so an unfinished setup isn't buried above the
// skills/agents listings.
fmt.Fprint(out, setupNotice(raptorSt))
return nil
},
}

// raptorAssetName is the release asset for a platform, or "" when raptor
// publishes no build for it. Names match the assets actually on
// Facets-cloud/raptor-releases (darwin/linux, amd64/arm64).
func raptorAssetName(goos, goarch string) string {
switch goos {
case "darwin", "linux":
default:
return ""
}
switch goarch {
case "amd64", "arm64":
default:
return ""
}
return fmt.Sprintf("raptor-%s-%s", goos, goarch)
}

// raptorInstallHint points at raptor's own install instructions, plus an
// escape hatch for hosts that can't use them.
//
// `docs` is the primary answer. raptor owns its install steps and we must not
// fork them into praxis — that README already drifts from reality (it documents
// Windows binaries the releases don't publish), and a second copy here would
// drift further. Those documented steps end in `sudo mv … /usr/local/bin`.
//
// `no_sudo_commands` is the hatch: `sudo` prompts for a password, which a
// non-interactive AI host cannot answer, so it would hang rather than fail.
// The hatch installs to ~/.local/bin instead. That deviates from the README on
// purpose, and the note says so — ~/.local/bin is not on every PATH.
//
// praxis is the only party that knows this machine's OS/arch, so it resolves
// the asset; skill text can't.
func raptorInstallHint(goos, goarch string) map[string]any {
hint := map[string]any{"docs": raptorInstallURL}
asset := raptorAssetName(goos, goarch)
if asset == "" {
// No published build for this platform — docs only. Never fabricate a
// download URL that 404s, and offer no hatch we can't stand behind.
hint["note"] = "raptor publishes no build for this platform; follow docs."
return hint
}
url := raptorDownloadURL + asset
hint["asset_url"] = url
hint["no_sudo_commands"] = []string{
"mkdir -p ~/.local/bin",
"curl -fsSL " + url + " -o ~/.local/bin/raptor",
"chmod +x ~/.local/bin/raptor",
}
hint["note"] = "Prefer docs — raptor's own steps install to /usr/local/bin via sudo. " +
"no_sudo_commands is an escape hatch for non-interactive hosts that can't answer a " +
"sudo password prompt; it installs to ~/.local/bin, which must be on PATH."
return hint
}

// raptorStatusBlock shapes a raptorstate.State for JSON output. `installed`
// and `found` are always present; resolution detail only when it exists, and
// the praxis-URL comparison only when a control plane actually resolved.
func raptorStatusBlock(st raptorstate.State, praxisURL string) map[string]any {
return raptorStatusBlockFor(st, praxisURL, runtime.GOOS, runtime.GOARCH)
}

// raptorStatusBlockFor is raptorStatusBlock with the platform injected so the
// install hint is testable across OS/arch.
func raptorStatusBlockFor(st raptorstate.State, praxisURL, goos, goarch string) map[string]any {
block := map[string]any{
"installed": st.Installed,
"found": st.Found,
"pinned": st.Pinned,
}
if !st.Installed {
block["install_hint"] = raptorInstallHint(goos, goarch)
}
if st.Profile != "" {
block["profile"] = st.Profile
}
Expand All @@ -186,6 +258,18 @@ func raptorStatusBlock(st raptorstate.State, praxisURL string) map[string]any {
return block
}

const (
// raptorInstallURL is raptor's OWN install instructions — the single place
// those steps are maintained. praxis points at it rather than restating
// them, so the two can't drift. raptor ships no Homebrew formula or cask
// today (unlike praxis), so this README is the canonical path.
raptorInstallURL = "https://github.com/Facets-cloud/raptor-releases#installation"

// raptorDownloadURL is the release-asset prefix, used only to resolve the
// exact build for this machine.
raptorDownloadURL = "https://github.com/Facets-cloud/raptor-releases/releases/latest/download/"
)

// raptorStatusLine renders the human one-liner for the raptor auth state.
func raptorStatusLine(st raptorstate.State, praxisURL string) string {
switch {
Expand All @@ -202,12 +286,40 @@ func raptorStatusLine(st raptorstate.State, praxisURL string) string {
// FACETS_PROFILE names a profile raptor doesn't have.
return fmt.Sprintf("profile %q (%s) not found in ~/.facets/credentials", st.Profile, st.Source)
case !st.Installed:
return "not installed"
// State the fact AND the next step. "not installed" alone names no
// consequence, and nothing else in the repo tells a user where to get it.
return "not installed — get it at " + raptorInstallURL
default:
return "no profile resolved — run `raptor login`"
}
}

// raptorReady reports whether raptor can actually run a control-plane command:
// on PATH and resolved to a control plane it holds credentials for.
func raptorReady(st raptorstate.State) bool { return st.Installed && st.Found }

// setupNotice is the closing summary printed when raptor isn't usable yet.
//
// Without it the per-field `raptor:` line is followed by `logged in: yes`, so
// the output as a whole still scans as healthy — a user has no reason to look
// closer. praxis login succeeding is only half of setup: raptor is what reaches
// projects, resources, environments and releases, so every praxis user needs it
// working. Returns "" when there is nothing to say.
func setupNotice(st raptorstate.State) string {
if raptorReady(st) {
return ""
}
if !st.Installed {
return "\n⚠ setup incomplete: raptor is not installed.\n" +
" Facets projects, resources and releases all run through raptor.\n" +
" Install: " + raptorInstallURL + "\n" +
" Then: raptor login\n"
}
// Installed but no usable profile — don't send them back to the install page.
return "\n⚠ setup incomplete: raptor is installed but not logged in.\n" +
" Run: raptor login\n"
}

// summarizeInstalls collapses the per-(name, harness) receipt entries into
// deduped, sorted name lists. Slices are always non-nil so JSON marshals
// `[]`, never `null`.
Expand Down
179 changes: 178 additions & 1 deletion cmd/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -463,9 +463,11 @@ func TestRaptorStatusLine(t *testing.T) {
want: "profile \"ghost\" (env-profile) not found in ~/.facets/credentials",
},
{
// States the fact AND the next step — nothing else in the repo
// tells a user where to get raptor.
name: "not installed",
st: raptorstate.State{},
want: "not installed",
want: "not installed — get it at " + raptorInstallURL,
},
{
name: "installed, nothing resolved",
Expand All @@ -481,3 +483,178 @@ func TestRaptorStatusLine(t *testing.T) {
})
}
}

// A new user installs praxis, runs `praxis login`, and sees a clean result —
// but raptor is the CLI that actually reaches the Facets control plane
// (projects, resources, environments, releases). #68 made status say
// "not installed", which is the right fact but not an actionable one: it names
// no consequence and no next step. These tests pin the actionable form.
func TestRaptorStatusLine_NotInstalledPointsAtTheInstall(t *testing.T) {
got := raptorStatusLine(raptorstate.State{}, "https://root.test")
if !strings.Contains(got, "not installed") {
t.Errorf("line must still state the fact; got %q", got)
}
if !strings.Contains(got, raptorInstallURL) {
t.Errorf("line must point at where to get raptor; got %q", got)
}
}

// setupNotice is the closing summary. Without it the `raptor: not installed`
// line is followed by `logged in: yes`, so the output as a whole still reads
// healthy and the user has no reason to look closer.
func TestSetupNotice(t *testing.T) {
tests := []struct {
name string
st raptorstate.State
wantEmpty bool
must []string
}{
{
name: "not installed — needs install AND login",
st: raptorstate.State{},
must: []string{"setup incomplete", "not installed", raptorInstallURL, "raptor login"},
},
{
name: "installed but nothing resolved — needs login only",
st: raptorstate.State{Installed: true},
must: []string{"setup incomplete", "raptor login"},
},
{
name: "installed, pinned profile missing — needs login",
st: raptorstate.State{Installed: true, Pinned: true, Profile: "ghost", Source: raptorstate.SourcePin},
must: []string{"setup incomplete", "raptor login"},
},
{
name: "fully set up — stay quiet",
st: raptorstate.State{Installed: true, Found: true, Profile: "default", ControlPlaneURL: "https://root.test"},
wantEmpty: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := setupNotice(tt.st)
if tt.wantEmpty {
if got != "" {
t.Errorf("want no notice when setup is complete; got %q", got)
}
return
}
if got == "" {
t.Fatal("want a notice, got none")
}
for _, want := range tt.must {
if !strings.Contains(got, want) {
t.Errorf("notice missing %q; got:\n%s", want, got)
}
}
})
}
}

// An installed-but-not-logged-in raptor must NOT be told to install again —
// that sends the user down the wrong path.
func TestSetupNotice_InstalledDoesNotSuggestInstalling(t *testing.T) {
got := setupNotice(raptorstate.State{Installed: true})
if strings.Contains(got, raptorInstallURL) {
t.Errorf("raptor is already installed; notice must not point at the install URL:\n%s", got)
}
}

func TestRaptorAssetName(t *testing.T) {
// Verified against the real assets on Facets-cloud/raptor-releases
// (v0.1.91 publishes darwin/linux, amd64/arm64 only).
for _, tt := range []struct{ goos, goarch, want string }{
{"darwin", "arm64", "raptor-darwin-arm64"},
{"darwin", "amd64", "raptor-darwin-amd64"},
{"linux", "amd64", "raptor-linux-amd64"},
{"linux", "arm64", "raptor-linux-arm64"},
{"windows", "amd64", ""}, // not published — must not invent a URL
{"linux", "386", ""},
} {
if got := raptorAssetName(tt.goos, tt.goarch); got != tt.want {
t.Errorf("raptorAssetName(%q,%q) = %q, want %q", tt.goos, tt.goarch, got, tt.want)
}
}
}

// The install hint rides inside the existing `raptor` block from #68 rather
// than as a parallel top-level key, so the meta-skill's "act on the raptor
// block" contract keeps working.
//
// `docs` is the PRIMARY answer: raptor's own README owns the install steps and
// we must not fork them (it already drifts — it documents Windows binaries the
// releases don't publish). `no_sudo_commands` is an explicit escape hatch for
// non-interactive hosts that cannot answer raptor's documented `sudo mv`.
func TestRaptorStatusBlock_InstallHint(t *testing.T) {
t.Run("absent: README is the primary pointer", func(t *testing.T) {
b := raptorStatusBlockFor(raptorstate.State{}, "https://x.test", "darwin", "arm64")
hint, _ := b["install_hint"].(map[string]any)
if hint == nil {
t.Fatal("install_hint missing when raptor is not installed")
}
docs, _ := hint["docs"].(string)
if !strings.Contains(docs, "raptor-releases") {
t.Errorf("docs must point at raptor's own install instructions, got %q", docs)
}
note, _ := hint["note"].(string)
if !strings.Contains(note, "sudo") || !strings.Contains(note, "PATH") {
t.Errorf("note must say the official steps use sudo and that ~/.local/bin needs to be on PATH; got %q", note)
}
})

t.Run("hatch names this machine's asset and needs no sudo", func(t *testing.T) {
b := raptorStatusBlockFor(raptorstate.State{}, "https://x.test", "darwin", "arm64")
hint, _ := b["install_hint"].(map[string]any)
if !strings.Contains(hint["asset_url"].(string), "raptor-darwin-arm64") {
t.Errorf("asset_url must name this machine's build, got %v", hint["asset_url"])
}
cmds := strings.Join(toStrings(hint["no_sudo_commands"]), "\n")
// sudo prompts for a password and hangs a non-interactive AI host.
if strings.Contains(cmds, "sudo") {
t.Errorf("the hatch exists to avoid sudo:\n%s", cmds)
}
if !strings.Contains(cmds, "chmod +x") {
t.Errorf("downloaded binary must be made executable:\n%s", cmds)
}
})

t.Run("installed: no hint", func(t *testing.T) {
b := raptorStatusBlockFor(raptorstate.State{Installed: true}, "https://x.test", "darwin", "arm64")
if _, has := b["install_hint"]; has {
t.Error("install_hint must be omitted once raptor is installed")
}
})

t.Run("unpublished platform: docs only, no fabricated url", func(t *testing.T) {
b := raptorStatusBlockFor(raptorstate.State{}, "https://x.test", "windows", "amd64")
hint, _ := b["install_hint"].(map[string]any)
if hint == nil {
t.Fatal("install_hint missing")
}
if _, has := hint["asset_url"]; has {
t.Error("must not fabricate a download URL for a platform the releases don't publish")
}
if _, has := hint["no_sudo_commands"]; has {
t.Error("no hatch without a real asset — send them to docs")
}
if hint["docs"] == nil {
t.Error("docs must always be present")
}
})

// #68's fields must survive untouched.
t.Run("preserves the #68 block", func(t *testing.T) {
b := raptorStatusBlockFor(raptorstate.State{Installed: true, Found: true,
Profile: "default", ControlPlaneURL: "https://x.test"}, "https://x.test", "darwin", "arm64")
for _, k := range []string{"installed", "found", "pinned", "control_plane_url", "matches_praxis_url"} {
if _, has := b[k]; !has {
t.Errorf("#68 field %q went missing", k)
}
}
})
}

func toStrings(v any) []string {
out, _ := v.([]string)
return out
}
Loading