From b7fcc3b5a0d7b44e398204a04f939457acc2dc83 Mon Sep 17 00:00:00 2001 From: Anshul Sao Date: Wed, 5 Aug 2026 00:28:17 +0530 Subject: [PATCH 1/3] feat(status): make an unusable raptor obvious and actionable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #68 added a `raptor:` line to `praxis status`, which fixed the silent case. But when raptor is absent that line reads only "not installed" — it names no consequence and no next step, and `logged in: yes` prints directly beneath it, so the output as a whole still scans as healthy. Nothing in this repo told a user where to get raptor either. That matters because raptor is not optional. Facets projects, resources, environments and releases all go through it, and 450 of the shell commands inside the skills praxis installs are `raptor ...`. A user who stops after `praxis login` has a working praxis and cannot do the work. Three changes: - `raptor: not installed` now points at the releases page. raptor ships no Homebrew formula or cask today (only Casks/praxis.rb exists in Facets-cloud/homebrew-tap), so the releases page is the install path we can honestly name. - A closing `⚠ setup incomplete` notice prints last, after the skills and agents listings, so an unfinished setup isn't buried above them. It distinguishes not-installed (install + login) from installed-but- not-logged-in (login only) — telling someone who already has raptor to go install it sends them down the wrong path, so that case is pinned by its own test. - `setup_complete` in the JSON, so an AI host can branch on one field instead of re-deriving usability from installed/found/logged_in. The skills' raptor preflight currently dead-ends at "ask the user to install it"; it can now point at `praxis status` and follow it. Behavior change: the not-installed string is no longer exactly "not installed", so TestRaptorStatusLine's case was updated. Part 1 of #72. Whether `praxis login` should offer to install raptor, and whether raptor should get a Homebrew cask, is still open there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LpVxm5k6cuVWnz5pKsFUdT --- cmd/status.go | 43 ++++++++++++++++++++++++- cmd/status_test.go | 80 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/cmd/status.go b/cmd/status.go index 2bbef11..6dca936 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -87,6 +87,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,6 +162,9 @@ 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 }, } @@ -186,6 +194,11 @@ func raptorStatusBlock(st raptorstate.State, praxisURL string) map[string]any { return block } +// raptorInstallURL is where a user gets raptor. raptor ships no Homebrew +// formula or cask today (unlike praxis), so the releases page is the install +// path we can honestly point at. +const raptorInstallURL = "https://github.com/Facets-cloud/raptor-releases/releases/latest" + // raptorStatusLine renders the human one-liner for the raptor auth state. func raptorStatusLine(st raptorstate.State, praxisURL string) string { switch { @@ -202,12 +215,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..be579f7 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,79 @@ 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) + } +} From 1d8e1541d451a95101643853bd7b69de9598c90d Mon Sep 17 00:00:00 2001 From: Anshul Sao Date: Wed, 5 Aug 2026 08:45:54 +0530 Subject: [PATCH 2/3] feat(status): give AI hosts runnable steps to install and sign in raptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends #68 rather than sitting beside it. That PR taught hosts to act on the `raptor` block in `praxis status --json`, but its cases all assume raptor is already present: `pinned`, `matches_praxis_url`, `found: false`. The absent case had no bullet, and `found: false` told the host to "ask the user to run raptor login" — which dead-ended, since nothing in this repo said how to install raptor in the first place. - `raptor.install_hint` (inside #68's block, not a new top-level key, so the "act on the raptor block" contract keeps holding). Carries the asset URL and shell commands already resolved for this machine's OS/arch — praxis is the only party that knows those, skill text can't. Installs to ~/.local/bin: `sudo` prompts for a password and would hang a non-interactive host. Omitted once raptor is installed. Platforms raptor publishes no build for get `docs` and no fabricated URL. - Meta-skill: new `installed: false` bullet in #68's own list, in its style; `found: false` amended from "ask the user" to RUN `raptor login` for them — the same treatment `praxis login` already gets in this file (browser opens, user completes it, host waits for exit 0). - Preflight section updated to match; it still said "don't install it yourself", which now contradicts the bullets below it. Credential guardrails are unchanged and pinned by test: never ask for a token in chat, never write ~/.facets/credentials. Running a browser login is not the same as handling the secret. Asset names verified against the real v0.1.91 release; the generated darwin-arm64 URL was fetched and returns 200. Part 1 of #72. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LpVxm5k6cuVWnz5pKsFUdT --- cmd/status.go | 51 +++++++++++++++++++ cmd/status_test.go | 79 +++++++++++++++++++++++++++++ internal/skillinstall/dummy.go | 33 +++++++++--- internal/skillinstall/dummy_test.go | 40 +++++++++++++++ 4 files changed, 195 insertions(+), 8 deletions(-) diff --git a/cmd/status.go b/cmd/status.go index 6dca936..35d93f8 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "runtime" "slices" "time" @@ -169,15 +170,65 @@ current staleness.`, }, } +// 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 tells an AI host exactly how to get raptor onto THIS +// machine. praxis is the only party that knows the OS/arch, so it names the +// build; skill text can't. Installs into ~/.local/bin deliberately — `sudo` +// prompts for a password and would hang a non-interactive host. +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 — point at the page rather + // than fabricate a download URL that 404s. + hint["commands"] = []string{} + return hint + } + url := "https://github.com/Facets-cloud/raptor-releases/releases/latest/download/" + asset + hint["url"] = url + hint["commands"] = []string{ + "mkdir -p ~/.local/bin", + "curl -fsSL " + url + " -o ~/.local/bin/raptor", + "chmod +x ~/.local/bin/raptor", + } + hint["note"] = "installs to ~/.local/bin (no sudo). If that isn't on PATH, add it." + 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 } diff --git a/cmd/status_test.go b/cmd/status_test.go index be579f7..aaa3a2e 100644 --- a/cmd/status_test.go +++ b/cmd/status_test.go @@ -559,3 +559,82 @@ func TestSetupNotice_InstalledDoesNotSuggestInstalling(t *testing.T) { 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. praxis is the only party that knows this +// machine's OS/arch, so it names the exact build; the skill text can't. +func TestRaptorStatusBlock_InstallHint(t *testing.T) { + t.Run("absent: hint with this machine's asset, no sudo", 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") + } + if !strings.Contains(hint["url"].(string), "raptor-darwin-arm64") { + t.Errorf("url must name this machine's build, got %v", hint["url"]) + } + cmds := strings.Join(toStrings(hint["commands"]), "\n") + // sudo prompts for a password and hangs a non-interactive AI host. + if strings.Contains(cmds, "sudo") { + t.Errorf("install commands must not need 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["url"]; has { + t.Error("must not fabricate a download URL for a platform raptor doesn't publish") + } + if hint["docs"] == nil { + t.Error("must still point at the releases page") + } + }) + + // #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..9c7b1ca 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,22 @@ 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`" + ` whose ` + "`commands`" + ` are already resolved for + this OS/arch — run them for the user. They install to + ` + "`~/.local/bin`" + ` and need no sudo. Then continue to + ` + "`raptor login`" + ` below. If ` + "`install_hint.url`" + ` is absent, + raptor publishes no build for this platform; send the user to + ` + "`install_hint.docs`" + `. + - ` + "`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) + } + } +} From eecfd7be36260ca7c86b5fb25d3e2f7ca351c0ab Mon Sep 17 00:00:00 2001 From: Anshul Sao Date: Wed, 5 Aug 2026 10:19:03 +0530 Subject: [PATCH 3/3] refactor(status): point at raptor's README, keep no-sudo as an escape hatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit restated raptor's install steps inside praxis, with ~/.local/bin swapped in for the documented `sudo mv … /usr/local/bin`. Two problems with that: - It forks instructions raptor owns. That README already drifts from reality (it documents Windows binaries the releases don't publish), and a second copy in praxis would drift further. - The ~/.local/bin substitution was mine, not sourced from any doc, and the output presented it as if it were the official path. So `docs` now points at the README (#installation) and is the primary answer, and the no-sudo path is demoted to an explicitly-labelled hatch: install_hint.docs raptor's own instructions — prefer these install_hint.asset_url exact build for this OS/arch install_hint.no_sudo_commands hatch for hosts that can't answer a sudo password prompt (it would hang, not fail) install_hint.note says the hatch deviates, and that ~/.local/bin must be on PATH The meta-skill bullet mirrors that order: docs first, hatch only when the user can't use them or asks the host to do it. Unpublished platforms get docs and nothing else — no fabricated asset URL, and no hatch we can't stand behind. Both URLs verified reachable (200). Asset names verified against v0.1.91. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LpVxm5k6cuVWnz5pKsFUdT --- cmd/status.go | 50 ++++++++++++++++++++++++---------- cmd/status_test.go | 40 ++++++++++++++++++++------- internal/skillinstall/dummy.go | 16 +++++++---- 3 files changed, 75 insertions(+), 31 deletions(-) diff --git a/cmd/status.go b/cmd/status.go index 35d93f8..55c4620 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -187,27 +187,40 @@ func raptorAssetName(goos, goarch string) string { return fmt.Sprintf("raptor-%s-%s", goos, goarch) } -// raptorInstallHint tells an AI host exactly how to get raptor onto THIS -// machine. praxis is the only party that knows the OS/arch, so it names the -// build; skill text can't. Installs into ~/.local/bin deliberately — `sudo` -// prompts for a password and would hang a non-interactive host. +// 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 — point at the page rather - // than fabricate a download URL that 404s. - hint["commands"] = []string{} + // 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 := "https://github.com/Facets-cloud/raptor-releases/releases/latest/download/" + asset - hint["url"] = url - hint["commands"] = []string{ + 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"] = "installs to ~/.local/bin (no sudo). If that isn't on PATH, add it." + 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 } @@ -245,10 +258,17 @@ func raptorStatusBlockFor(st raptorstate.State, praxisURL, goos, goarch string) return block } -// raptorInstallURL is where a user gets raptor. raptor ships no Homebrew -// formula or cask today (unlike praxis), so the releases page is the install -// path we can honestly point at. -const raptorInstallURL = "https://github.com/Facets-cloud/raptor-releases/releases/latest" +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 { diff --git a/cmd/status_test.go b/cmd/status_test.go index aaa3a2e..d03fa86 100644 --- a/cmd/status_test.go +++ b/cmd/status_test.go @@ -579,22 +579,39 @@ func TestRaptorAssetName(t *testing.T) { // 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. praxis is the only party that knows this -// machine's OS/arch, so it names the exact build; the skill text can't. +// 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: hint with this machine's asset, no sudo", func(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") } - if !strings.Contains(hint["url"].(string), "raptor-darwin-arm64") { - t.Errorf("url must name this machine's build, got %v", hint["url"]) + docs, _ := hint["docs"].(string) + if !strings.Contains(docs, "raptor-releases") { + t.Errorf("docs must point at raptor's own install instructions, got %q", docs) } - cmds := strings.Join(toStrings(hint["commands"]), "\n") + 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("install commands must not need sudo:\n%s", cmds) + 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) @@ -614,11 +631,14 @@ func TestRaptorStatusBlock_InstallHint(t *testing.T) { if hint == nil { t.Fatal("install_hint missing") } - if _, has := hint["url"]; has { - t.Error("must not fabricate a download URL for a platform raptor doesn't publish") + 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("must still point at the releases page") + t.Error("docs must always be present") } }) diff --git a/internal/skillinstall/dummy.go b/internal/skillinstall/dummy.go index 9c7b1ca..031081a 100644 --- a/internal/skillinstall/dummy.go +++ b/internal/skillinstall/dummy.go @@ -236,12 +236,16 @@ Act on it: read-only exploration may proceed with a note. - ` + "`installed: false`" + ` — raptor isn't on this machine at all, so every control-plane command will fail. The block carries an - ` + "`install_hint`" + ` whose ` + "`commands`" + ` are already resolved for - this OS/arch — run them for the user. They install to - ` + "`~/.local/bin`" + ` and need no sudo. Then continue to - ` + "`raptor login`" + ` below. If ` + "`install_hint.url`" + ` is absent, - raptor publishes no build for this platform; send the user to - ` + "`install_hint.docs`" + `. + ` + "`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