diff --git a/cmd/status.go b/cmd/status.go index 2bbef11..55c4620 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "runtime" "slices" "time" @@ -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) + if asJSON { if statusFull { // Same shaped schema as `list-skills --json` and @@ -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 } @@ -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 { @@ -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`. diff --git a/cmd/status_test.go b/cmd/status_test.go index e31d96b..d03fa86 100644 --- a/cmd/status_test.go +++ b/cmd/status_test.go @@ -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", @@ -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 +} diff --git a/internal/skillinstall/dummy.go b/internal/skillinstall/dummy.go index c4624be..031081a 100644 --- a/internal/skillinstall/dummy.go +++ b/internal/skillinstall/dummy.go @@ -193,12 +193,15 @@ raptor get releases -p -e -o json Preflight — once per session, before the first raptor command: - - **Installed?** ` + "`command -v raptor`" + ` — if missing, ask the user to - install it; don't install it yourself. - - **Logged in?** ` + "`raptor whoami`" + ` — if it errors, ask the user to run - ` + "`raptor login`" + ` (a browser flow that stores a PAT in - ` + "`~/.facets/credentials`" + `). Never ask for a token in chat or write - credentials yourself. + - **Installed?** ` + "`command -v raptor`" + ` — if missing, install it for + the user with the ` + "`raptor.install_hint.commands`" + ` from + ` + "`praxis status --json`" + ` (already resolved for this OS/arch; no + sudo). See "Raptor profile ≠ praxis profile" below. + - **Logged in?** ` + "`raptor whoami`" + ` — if it errors, RUN + ` + "`raptor login`" + ` for the user. It opens their browser and they + complete the sign-in; it stores a PAT in + ` + "`~/.facets/credentials`" + `. Wait for exit 0. Never ask for a token + in chat or write credentials yourself. - **Up to date?** ` + "`praxis status --json`" + ` reports ` + "`tools`" + ` as an ARRAY, one object per tool with its ` + "`current`" + `/` + "`latest`" + ` version and a ` + "`stale`" + ` flag. Find the entry whose ` + "`tool`" + ` is @@ -231,8 +234,26 @@ Act on it: askpraxis.ai (no raptor CP matches it). Otherwise say which two hosts you see and ask the user which is intended BEFORE any raptor write; read-only exploration may proceed with a note. - - ` + "`found: false`" + ` — raptor has no usable profile; ask the user to - run ` + "`raptor login`" + `. + - ` + "`installed: false`" + ` — raptor isn't on this machine at all, so every + control-plane command will fail. The block carries an + ` + "`install_hint`" + `. Point the user at ` + "`install_hint.docs`" + ` + FIRST — that's raptor's own README and the maintained source of truth + (its steps end in ` + "`sudo mv … /usr/local/bin`" + `). If the user + can't run those, or asks you to do it, use + ` + "`install_hint.no_sudo_commands`" + `: already resolved for this + OS/arch and free of sudo, which you can't answer a password prompt + for. It installs to ` + "`~/.local/bin`" + `, so check that's on PATH + afterwards. When ` + "`asset_url`" + ` is absent raptor publishes no + build for this platform — docs only, don't improvise. Then continue + to ` + "`raptor login`" + ` below. + - ` + "`found: false`" + ` — raptor has no usable profile. RUN + ` + "`raptor login`" + ` on the user's behalf, exactly as you do for + ` + "`praxis login`" + `: it opens their browser and they complete the + sign-in themselves. Wait for exit 0. Never ask for a token in chat, + and never write ` + "`~/.facets/credentials`" + ` yourself. + - ` + "`setup_complete`" + ` (top level, not inside the raptor block) — true + only when praxis is logged in AND raptor is installed and resolved. + Check it first; the two bullets above say what to do when it's false. ## Discovering MCP tools diff --git a/internal/skillinstall/dummy_test.go b/internal/skillinstall/dummy_test.go index 6a411ea..66eb91a 100644 --- a/internal/skillinstall/dummy_test.go +++ b/internal/skillinstall/dummy_test.go @@ -104,3 +104,43 @@ func TestPraxisMetaSkill_ProfileManagementSurface(t *testing.T) { } } } + +// #68 taught the meta-skill to act on the `raptor` block, but only covered a +// raptor that is present and mis-aimed. A raptor that is absent, or present +// and logged out, left the host with nothing runnable — the old text said +// "ask the user to install it" and no instructions existed anywhere. +// +// The host now installs and signs raptor in on the user's behalf, matching how +// it already treats `praxis login`. Credentials stay off-limits either way. +func TestPraxisMetaSkill_RaptorSetupIsActionable(t *testing.T) { + body, err := ContentFor("praxis") + if err != nil { + t.Fatalf("ContentFor(praxis): %v", err) + } + for _, want := range []string{ + "installed: false", // the case #68 didn't cover + "install_hint", // where the resolved commands live + "~/.local/bin", // no-sudo install target + "setup_complete", // the single field to branch on + } { + if !strings.Contains(body, want) { + t.Errorf("meta-skill missing raptor-setup guidance %q", want) + } + } + + // The old stance told the host to stop at asking. That dead-ended the user. + if strings.Contains(body, "don't install it yourself") { + t.Error("stale guidance: the host now installs raptor via install_hint") + } + + // Handling raptor's PAT is still forbidden — installing and running a + // browser login is not the same as touching credentials. + for _, want := range []string{ + "Never ask for a token in chat", + "never write `~/.facets/credentials` yourself", + } { + if !strings.Contains(body, want) { + t.Errorf("credential guardrail weakened — missing %q", want) + } + } +}