diff --git a/cmd/login_setup.go b/cmd/login_setup.go index 0953461..a8127de 100644 --- a/cmd/login_setup.go +++ b/cmd/login_setup.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "time" "github.com/Facets-cloud/praxis-cli/internal/agentinstall" "github.com/Facets-cloud/praxis-cli/internal/claudehooks" @@ -32,6 +33,9 @@ type postAuthState struct { // was detected. The hooks nudge toward use-ig when cwd is an ig catalog // repo. AI hosts read this to know the nudge is live. hooksWired string + // staleTools lists tools (praxis, raptor) found behind their latest release + // at login, so the agent can offer an upgrade. + staleTools []Freshness // projectScoped is the *effective* install scope after resolving the // active root — not the requested flag. It's false when a forced // project scope couldn't be enabled (e.g. cwd unresolvable or outside @@ -254,9 +258,32 @@ func runPostAuthSetup(out io.Writer, asJSON bool, baseURL, token string) postAut state.hooksWired = wirePraxisHooks(out, asJSON, hosts) } + // Step 6: tool-freshness notice (praxis + raptor) via the shared engine. + // Login already does network, so a live-if-stale check here also warms the + // cache for later `praxis status` reads. Best-effort; never fatal. + state.staleTools = noticeFreshness(out, asJSON) + return state } +// noticeFreshness checks tool freshness (concurrently + bounded, so an offline +// login isn't stalled by slow release lookups) and, for each tool behind its +// latest release, prints a one-line notice (non-JSON) and collects it. Uses the +// shared engine (freshCachedOrFetch), so it warms the cache too. +func noticeFreshness(out io.Writer, asJSON bool) []Freshness { + var stale []Freshness + for _, f := range checkToolsBounded(time.Now(), freshCachedOrFetch) { + if !f.Stale { + continue + } + stale = append(stale, f) + if !asJSON { + fmt.Fprintf(out, "! %s %s is behind %s — %s\n", f.Tool, f.Current, f.Latest, nagActionForTool(f.Tool)) + } + } + return stale +} + // wirePraxisHooks installs praxis's SessionStart + CwdChanged hooks into the // claude-code host's USER-level settings.json so a session inside an ig catalog // repo gets nudged toward the use-ig skill (see internal/claudehooks + diff --git a/cmd/root.go b/cmd/root.go index 4f88da9..8dfbb2c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -49,13 +49,13 @@ func Execute() { // cold network fetch waits, bounded by updateCheckMaxWait. var notify func() if render.IsTTY(os.Stderr) && !skipUpdateCheck(os.Args[1:]) { - ch := make(chan string, 1) - go func() { ch <- checkForUpdate() }() + ch := make(chan []staleNag, 1) + go func() { ch <- collectStaleNags() }() notify = func() { select { - case latest := <-ch: - if latest != "" { - printUpdateNotification(latest, os.Stderr) + case nags := <-ch: + for _, n := range nags { + printFreshnessBox(n.Freshness, n.Action, os.Stderr) } case <-time.After(updateCheckMaxWait): // Cold fetch still in flight — skip the notice for this run. diff --git a/cmd/status.go b/cmd/status.go index 8e47d97..ceb8c91 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "slices" + "time" "github.com/Facets-cloud/praxis-cli/internal/agentinstall" "github.com/Facets-cloud/praxis-cli/internal/credentials" @@ -21,7 +22,7 @@ var ( func init() { statusCmd.Flags().BoolVar(&statusJSON, "json", false, "JSON output") statusCmd.Flags().BoolVar(&statusRefresh, "refresh", false, - "also call /ai-api/auth/me to verify the token is still valid") + "live checks: verify the token via /ai-api/auth/me AND re-fetch tool (praxis/raptor) latest versions") statusCmd.Flags().BoolVar(&statusFull, "full", false, "include per-harness install detail (paths) in JSON output") rootCmd.AddCommand(statusCmd) @@ -33,10 +34,11 @@ var statusCmd = &cobra.Command{ Long: `Read-only snapshot for AI hosts to inspect: which profile is active, whether it has credentials, and which skills are installed. -By default this is a LOCAL-ONLY snapshot (no network calls). Pass ---refresh to also hit /ai-api/auth/me, which catches expired/revoked -tokens. The JSON output gains an "auth_check" field describing the -verification result.`, +By default this is a LOCAL-ONLY snapshot (no network calls): the "tools" +freshness block is served from cache. Pass --refresh to make live calls — +/ai-api/auth/me (catches expired/revoked tokens; adds an "auth_check" +field) AND a re-fetch of each tool's latest release so "tools" reflects +current staleness.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { out := cmd.OutOrStdout() @@ -66,6 +68,15 @@ verification result.`, state["project_root"] = root } } + + // Tool freshness (praxis + raptor) from the shared engine. Plain status + // is a local-only snapshot → cached latest, no network; --refresh forces + // a live re-check alongside the token check below. + freshnessMode := freshCached + if statusRefresh { + freshnessMode = freshLive + } + state["tools"] = toolsFreshness(time.Now(), freshnessMode) if asJSON { if statusFull { // Same shaped schema as `list-skills --json` and diff --git a/cmd/status_test.go b/cmd/status_test.go index 900fc1f..6dd73a0 100644 --- a/cmd/status_test.go +++ b/cmd/status_test.go @@ -8,6 +8,7 @@ import ( "slices" "strings" "testing" + "time" "github.com/Facets-cloud/praxis-cli/internal/credentials" "github.com/Facets-cloud/praxis-cli/internal/paths" @@ -48,6 +49,69 @@ func TestStatusCmd_LocalMode_ReportsProjectRootAndSource(t *testing.T) { } } +func TestStatusCmd_IncludesToolsFreshness(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + resetStatusFlags() + withVersion(t, "dev") // praxis not checkable → no network in freshCached + origV := raptorLocalVersion + t.Cleanup(func() { raptorLocalVersion = origV }) + raptorLocalVersion = func() (string, bool) { return "0.1.0", true } + + var buf bytes.Buffer + statusCmd.SetOut(&buf) + if err := statusCmd.RunE(statusCmd, nil); err != nil { + t.Fatalf("RunE err = %v", err) + } + var s map[string]any + if err := json.Unmarshal(buf.Bytes(), &s); err != nil { + t.Fatalf("status not JSON: %v\n%s", err, buf.String()) + } + tools, ok := s["tools"].([]any) + if !ok || len(tools) != 2 { + t.Fatalf("tools block missing or wrong size: %v", s["tools"]) + } + names := map[string]bool{} + for _, tv := range tools { + names[tv.(map[string]any)["tool"].(string)] = true + } + if !names["praxis"] || !names["raptor"] { + t.Errorf("tools must include praxis + raptor, got %v", names) + } +} + +func TestStatusCmd_RefreshDoesLiveFreshness(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + resetStatusFlags() + statusRefresh = true + t.Cleanup(func() { statusRefresh = false }) + origDelay := updateCheckRetryDelay + updateCheckRetryDelay = 0 + t.Cleanup(func() { updateCheckRetryDelay = origDelay }) + withVersion(t, "dev") // praxis not checkable → only raptor fetches + origV, origF := raptorLocalVersion, fetchRaptorTag + t.Cleanup(func() { raptorLocalVersion, fetchRaptorTag = origV, origF }) + raptorLocalVersion = func() (string, bool) { return "0.1.0", true } + // Seed a FRESH raptor cache entry: without this, freshCachedOrFetch would + // also fetch, so the test would pass even if --refresh stopped using + // freshLive. A fresh entry is only bypassed by a genuine live check. + if err := saveFreshnessCache(freshnessCache{ + "raptor": {CheckedAt: time.Now(), LatestVersion: "v0.1.0"}, + }); err != nil { + t.Fatal(err) + } + fetched := false + fetchRaptorTag = func() (string, error) { fetched = true; return "v0.2.0", nil } + + var buf bytes.Buffer + statusCmd.SetOut(&buf) + if err := statusCmd.RunE(statusCmd, nil); err != nil { + t.Fatalf("RunE err = %v", err) + } + if !fetched { + t.Error("status --refresh must trigger a live raptor freshness fetch (freshLive bypasses a fresh cache)") + } +} + func TestStatusCmd_NotLoggedIn_DefaultProfile(t *testing.T) { t.Setenv("HOME", t.TempDir()) resetStatusFlags() diff --git a/cmd/update.go b/cmd/update.go index 956c85b..a828131 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -25,6 +25,13 @@ var ( verifyChecksum = selfupdate.VerifyChecksum parseChecksums = selfupdate.ParseChecksums atomicReplace = selfupdate.AtomicReplace + + // Freshness-engine seams (see cmd/update_check.go). raptor is a second tool + // the same engine tracks; these let tests stub its release + local version. + fetchRaptorTag = func() (string, error) { + return selfupdate.LatestReleaseTagFor("Facets-cloud/raptor-releases") + } + raptorLocalVersion = execRaptorVersion // (version, installed) from `raptor --version` ) func init() { diff --git a/cmd/update_check.go b/cmd/update_check.go index b324701..b4cb740 100644 --- a/cmd/update_check.go +++ b/cmd/update_check.go @@ -1,10 +1,12 @@ package cmd import ( + "context" "encoding/json" "fmt" "io" "os" + "os/exec" "regexp" "strconv" "strings" @@ -12,7 +14,6 @@ import ( "unicode" "github.com/Facets-cloud/praxis-cli/internal/paths" - "github.com/Facets-cloud/praxis-cli/internal/selfupdate" ) // updateCheckInterval throttles how often the background check hits GitHub. @@ -31,56 +32,132 @@ const updateCheckMaxWait = 3 * time.Second // a const) so tests can zero it out and not sleep. var updateCheckRetryDelay = 2 * time.Second -// updateCheckCache is the on-disk throttle state at paths.UpdateCheckCache(). -type updateCheckCache struct { +// toolCacheEntry is the per-tool throttle state persisted at +// paths.UpdateCheckCache(). freshnessCache maps tool name → entry: ONE file for +// all tools, so praxis and raptor never fragment into separate caches. +type toolCacheEntry struct { CheckedAt time.Time `json:"checked_at"` LatestVersion string `json:"latest_version"` } +type freshnessCache map[string]toolCacheEntry -// checkForUpdate returns the latest release tag when a newer version is -// available, otherwise an empty string. It is best-effort: any error (network, -// disk, API) yields "" so the calling command is never disturbed. A 24h file -// cache avoids hammering GitHub; on a cold/stale cache it performs one live -// fetch with one silent retry. -// -// The live fetch reuses cmd/update.go's fetchLatestRelease seam -// (= selfupdate.LatestRelease), so tests stub both flows the same way. -func checkForUpdate() string { - if os.Getenv("PRAXIS_NO_UPDATE_CHECK") != "" { - return "" +// toolSpec describes a tool whose freshness praxis tracks. praxis and raptor +// differ ONLY here — the cache, comparator, renderer, and throttle are all +// shared. UpgradeHint is the command the user runs to upgrade; praxis's +// `praxis update` self-replaces the binary, raptor's `raptor upgrade` is +// nudge-only (praxis never runs it). +type toolSpec struct { + Name string + UpgradeHint string + // current returns (localVersion, installed, checkable): praxis is always + // installed and checkable unless it's a dev build; raptor is installed iff + // `raptor` is on PATH, and checkable iff installed. + current func() (version string, installed, checkable bool) + // fetchTag returns the tool's latest published tag (a network call routed + // through a package-var seam so tests stub it). + fetchTag func() (string, error) +} + +func praxisSpec() toolSpec { + return toolSpec{ + Name: "praxis", UpgradeHint: "praxis update", + current: func() (string, bool, bool) { return version, true, !isDevBuild(version) }, + fetchTag: func() (string, error) { + r, err := fetchLatestRelease() + if err != nil || r == nil { + return "", err + } + return r.TagName, nil + }, } +} - // Skip development builds. Released binaries are stamped with a clean - // semver by goreleaser (e.g. "1.0.0"); local `make build` uses - // `git describe --tags --always --dirty`, which yields "dev" (no tag), - // a "-dirty" suffix on a modified tree, or a "--g" suffix when - // ahead of the last tag. Nagging those would be noise. - if isDevBuild(version) { - return "" +func raptorSpec() toolSpec { + return toolSpec{ + Name: "raptor", UpgradeHint: "raptor upgrade", + current: func() (string, bool, bool) { v, ok := raptorLocalVersion(); return v, ok, ok }, + fetchTag: fetchRaptorTag, } +} + +// freshnessTools is the registry every surface (nag, status, login) iterates. +func freshnessTools() []toolSpec { return []toolSpec{praxisSpec(), raptorSpec()} } - // Serve from cache while fresh. - if cached, err := readUpdateCache(); err == nil && time.Since(cached.CheckedAt) < updateCheckInterval { - return newerThan(version, cached.LatestVersion) +// Freshness is one tool's freshness result, surfaced to status/login/nag. +type Freshness struct { + Tool string `json:"tool"` + Installed bool `json:"installed"` + Current string `json:"current,omitempty"` + Latest string `json:"latest,omitempty"` + Stale bool `json:"stale"` +} + +// freshMode controls whether checkTool may hit the network. +type freshMode int + +const ( + // freshCached reads the cache only — NO network. Used by `praxis status`, + // which is a local-only snapshot; a cache miss yields "" (not stale). + freshCached freshMode = iota + // freshCachedOrFetch serves a <24h cache entry, else fetches once. Used by + // the Execute nag and login. + freshCachedOrFetch + // freshLive always fetches. Used by `praxis status --refresh`. + freshLive +) + +// checkTool resolves one tool's freshness: best-effort, never fatal. The +// kill-switch and non-checkable tools (praxis dev build / raptor absent) +// short-circuit to "not stale". +func checkTool(spec toolSpec, now time.Time, mode freshMode) Freshness { + cur, installed, checkable := spec.current() + f := Freshness{Tool: spec.Name, Installed: installed, Current: cur} + if os.Getenv("PRAXIS_NO_UPDATE_CHECK") != "" || !checkable { + return f } + f.Latest = latestTagFor(spec, now, mode) + f.Stale = f.Latest != "" && compareSemver(cur, f.Latest) < 0 + return f +} - // Live fetch with one silent retry on any error. - rel := fetchLatestReleaseWithRetry() - if rel == nil { - // Record the attempt (empty LatestVersion) so an offline/API outage - // honors the 24h throttle instead of re-fetching on every invocation. - // The fresh-cache branch above treats an empty LatestVersion as "no - // update" via newerThan, so this stays silent until the cache expires. - _ = saveUpdateCache(updateCheckCache{CheckedAt: time.Now()}) - return "" // best-effort — stay silent +// checkForUpdate preserves praxis's original nag entry point: the latest tag +// when praxis itself is behind, else "". Now a thin call over the shared engine. +func checkForUpdate() string { + if f := checkTool(praxisSpec(), time.Now(), freshCachedOrFetch); f.Stale { + return f.Latest } + return "" +} - _ = saveUpdateCache(updateCheckCache{ - CheckedAt: time.Now(), - LatestVersion: rel.TagName, - }) +// toolsFreshness reports every registered tool's freshness in one pass — the +// source for `praxis status`'s "tools" block. mode is freshCached for a plain +// status (local-only) and freshLive for `--refresh`. +func toolsFreshness(now time.Time, mode freshMode) []Freshness { + specs := freshnessTools() + out := make([]Freshness, 0, len(specs)) + for _, spec := range specs { + out = append(out, checkTool(spec, now, mode)) + } + return out +} - return newerThan(version, rel.TagName) +// latestTagFor returns a tool's latest tag per mode. A failed fetch caches an +// empty tag so an offline/API outage honors the 24h throttle (compareSemver +// treats "" as not-stale) instead of re-fetching every run. +func latestTagFor(spec toolSpec, now time.Time, mode freshMode) string { + if mode != freshLive { + if c, err := readFreshnessCache(); err == nil { + if e, ok := c[spec.Name]; ok && (mode == freshCached || now.Sub(e.CheckedAt) < updateCheckInterval) { + return e.LatestVersion + } + } + } + if mode == freshCached { + return "" // status default: local-only, never fetch + } + tag := fetchTagWithRetry(spec.fetchTag) + putCacheEntry(spec.Name, toolCacheEntry{CheckedAt: now, LatestVersion: tag}) + return tag } // gitDescribeAhead matches the "--g" suffix git describe appends when @@ -169,37 +246,73 @@ func splitVersion(v string) ([3]int, string) { return nums, pre } -// fetchLatestReleaseWithRetry calls the fetchLatestRelease seam up to twice, -// pausing briefly between attempts. Returns nil if both attempts fail. -func fetchLatestReleaseWithRetry() *selfupdate.Release { +// fetchTagWithRetry calls a tool's fetchTag seam up to twice, pausing briefly +// between attempts. Returns "" if both attempts fail or yield an empty tag. +func fetchTagWithRetry(fetch func() (string, error)) string { for attempt := 1; attempt <= 2; attempt++ { - rel, err := fetchLatestRelease() - if err == nil && rel != nil { - return rel + if tag, err := fetch(); err == nil && tag != "" { + return tag } if attempt < 2 { time.Sleep(updateCheckRetryDelay) } } - return nil + return "" } -// readUpdateCache reads the throttle cache from disk. -func readUpdateCache() (updateCheckCache, error) { +// raptorSemver extracts the numeric version from `raptor --version` output +// (e.g. "raptor version 0.1.81"). +var raptorSemver = regexp.MustCompile(`\d+\.\d+\.\d+`) + +// raptorVersionTimeout bounds the local `raptor --version` call so a wedged +// raptor binary can never hang a login/nag freshness check. +var raptorVersionTimeout = 2 * time.Second + +// execRaptorVersion returns raptor's local version and whether raptor is +// installed, by running `raptor --version` under a short timeout. Not on PATH +// (the command errors), timed out, or unparsable → ("", false), so the engine +// reports it not-installed and never stale. +func execRaptorVersion() (string, bool) { + ctx, cancel := context.WithTimeout(context.Background(), raptorVersionTimeout) + defer cancel() + out, err := raptorVersionCmd(ctx) + if err != nil { + return "", false + } + v := raptorSemver.FindString(string(out)) + return v, v != "" +} + +// raptorVersionCmd runs `raptor --version` under ctx — a seam so tests can +// simulate a hung binary. +var raptorVersionCmd = func(ctx context.Context) ([]byte, error) { + return exec.CommandContext(ctx, "raptor", "--version").Output() +} + +// readFreshnessCache reads the per-tool throttle cache from disk. +func readFreshnessCache() (freshnessCache, error) { path, err := paths.UpdateCheckCache() if err != nil { - return updateCheckCache{}, err + return nil, err } data, err := os.ReadFile(path) if err != nil { - return updateCheckCache{}, err + return nil, err } - var c updateCheckCache - return c, json.Unmarshal(data, &c) + var c freshnessCache + if err := json.Unmarshal(data, &c); err != nil { + return nil, err + } + return c, nil } -// saveUpdateCache persists the throttle cache, creating ~/.praxis if needed. -func saveUpdateCache(c updateCheckCache) error { +// saveFreshnessCache persists the whole cache ATOMICALLY (temp file + rename), +// creating ~/.praxis if needed. The atomic rename means a concurrent praxis +// process never reads a torn/half-written cache. Concurrent merges are +// last-writer-wins, which is benign for a throttle cache: the worst case is one +// process's fresh entry being overwritten, costing at most one extra GitHub +// call before the next write settles — no corruption, no lost correctness. +func saveFreshnessCache(c freshnessCache) error { dir, err := paths.Dir() if err != nil { return err @@ -215,15 +328,119 @@ func saveUpdateCache(c updateCheckCache) error { if err != nil { return err } - return os.WriteFile(path, data, 0o600) + tmp, err := os.CreateTemp(dir, ".freshness-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) // no-op after a successful rename + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +// putCacheEntry merges one tool's entry into the shared cache (best-effort). +func putCacheEntry(tool string, e toolCacheEntry) { + c, err := readFreshnessCache() + if err != nil || c == nil { + c = freshnessCache{} + } + c[tool] = e + _ = saveFreshnessCache(c) +} + +// staleNag pairs a stale tool's freshness with its rendered upgrade line. +type staleNag struct { + Freshness Freshness + Action string +} + +// freshnessDeadline bounds a freshness pass (nag/login): a tool whose check +// doesn't finish in time is simply omitted, so a slow raptor lookup never +// suppresses a fast/cached praxis nag nor stalls login. +var freshnessDeadline = 4 * time.Second + +// checkToolsBounded runs each tool's check CONCURRENTLY and returns those that +// finish within freshnessDeadline. This bounds aggregate latency and stops one +// slow tool from blocking the others (findings: login latency, nag suppression). +// A tool that misses the deadline is omitted (best-effort); its goroutine +// finishes on its own (and warms the cache for next time). +func checkToolsBounded(now time.Time, mode freshMode) []Freshness { + specs := freshnessTools() + ch := make(chan Freshness, len(specs)) + for _, spec := range specs { + go func(s toolSpec) { ch <- checkTool(s, now, mode) }(spec) + } + out := make([]Freshness, 0, len(specs)) + timer := time.NewTimer(freshnessDeadline) + defer timer.Stop() + for range specs { + select { + case f := <-ch: + out = append(out, f) + case <-timer.C: + return out // partial — return whatever completed in time + } + } + return out +} + +// collectStaleNags returns the stale tools (concurrent, bounded) with their +// upgrade instruction — the Execute-time TTY nag source. +func collectStaleNags() []staleNag { + var out []staleNag + for _, f := range checkToolsBounded(time.Now(), freshCachedOrFetch) { + if f.Stale { + out = append(out, staleNag{Freshness: f, Action: nagActionForTool(f.Tool)}) + } + } + return out +} + +// specByName looks up a tool's spec by name. +func specByName(name string) (toolSpec, bool) { + for _, s := range freshnessTools() { + if s.Name == name { + return s, true + } + } + return toolSpec{}, false +} + +// nagAction is the upgrade instruction per tool: praxis self-updates; raptor is +// nudge-only — praxis surfaces staleness but never runs `raptor upgrade`. +func nagAction(spec toolSpec) string { + if spec.Name == "praxis" { + return "Run `praxis update` to install the latest version" + } + return fmt.Sprintf("Ask your user, then run `%s` (praxis won't run it for you)", spec.UpgradeHint) +} + +// nagActionForTool is nagAction keyed by tool name (post-bounded, where we hold +// a Freshness rather than a spec). +func nagActionForTool(name string) string { + if s, ok := specByName(name); ok { + return nagAction(s) + } + return "" } -// printUpdateNotification writes the "update available" box to w (os.Stderr in -// production). Kept on stderr so it never pollutes a command's parseable -// stdout when an AI host spawns praxis as a subprocess. -func printUpdateNotification(latestVersion string, w io.Writer) { - line1 := fmt.Sprintf(" ⚡ Update available: %s → %s", version, latestVersion) - line2 := " Run `praxis update` to install the latest version" +// printFreshnessBox writes the "update available" box for a stale tool to w +// (os.Stderr in production, so it never pollutes a command's parseable stdout +// when an AI host spawns praxis as a subprocess). actionLine is the tool's +// upgrade instruction. +func printFreshnessBox(f Freshness, actionLine string, w io.Writer) { + line1 := fmt.Sprintf(" ⚡ %s update available: %s → %s", f.Tool, f.Current, f.Latest) + line2 := " " + actionLine line3 := " Set PRAXIS_NO_UPDATE_CHECK=1 to silence this notice" // Use display-column width, not byte length, so wide characters (⚡, →) diff --git a/cmd/update_check_test.go b/cmd/update_check_test.go index da11a37..4e1a193 100644 --- a/cmd/update_check_test.go +++ b/cmd/update_check_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "context" "encoding/json" "errors" "os" @@ -30,14 +31,13 @@ func fakeHome(t *testing.T) string { return home } -// writeCache writes a throttle cache with the given age and latest version. +// writeCache seeds praxis's throttle entry with the given age and latest version. func writeCache(t *testing.T, age time.Duration, latest string) { t.Helper() - if err := saveUpdateCache(updateCheckCache{ - CheckedAt: time.Now().Add(-age), - LatestVersion: latest, + if err := saveFreshnessCache(freshnessCache{ + "praxis": {CheckedAt: time.Now().Add(-age), LatestVersion: latest}, }); err != nil { - t.Fatalf("saveUpdateCache: %v", err) + t.Fatalf("saveFreshnessCache: %v", err) } } @@ -219,15 +219,15 @@ func TestCheckForUpdate_PersistsCache(t *testing.T) { if err != nil { t.Fatalf("cache not written: %v", err) } - var c updateCheckCache + var c freshnessCache if err := json.Unmarshal(data, &c); err != nil { t.Fatalf("cache not valid JSON: %v", err) } - if c.LatestVersion != "v4.0.0" { - t.Errorf("cached LatestVersion = %q, want v4.0.0", c.LatestVersion) + if c["praxis"].LatestVersion != "v4.0.0" { + t.Errorf("cached praxis LatestVersion = %q, want v4.0.0", c["praxis"].LatestVersion) } - if time.Since(c.CheckedAt) > time.Minute { - t.Errorf("cached CheckedAt = %v, want ~now", c.CheckedAt) + if time.Since(c["praxis"].CheckedAt) > time.Minute { + t.Errorf("cached CheckedAt = %v, want ~now", c["praxis"].CheckedAt) } } @@ -260,12 +260,15 @@ func TestCheckForUpdate_ThrottlesFailures(t *testing.T) { t.Fatal("expected the first call to attempt a live fetch") } - c, err := readUpdateCache() + c, err := readFreshnessCache() if err != nil { t.Fatalf("throttle marker not written: %v", err) } - if c.LatestVersion != "" { - t.Errorf("LatestVersion = %q, want empty on a failed fetch", c.LatestVersion) + if _, ok := c["praxis"]; !ok { + t.Fatal("praxis throttle entry not written on a failed fetch") + } + if c["praxis"].LatestVersion != "" { + t.Errorf("LatestVersion = %q, want empty on a failed fetch", c["praxis"].LatestVersion) } // Second call within the interval: must be served from cache, no re-fetch. @@ -277,6 +280,115 @@ func TestCheckForUpdate_ThrottlesFailures(t *testing.T) { } } +// TestExecRaptorVersionTimeout proves a wedged `raptor --version` can't hang the +// freshness check — the context timeout yields ("", false). +func TestExecRaptorVersionTimeout(t *testing.T) { + origCmd := raptorVersionCmd + origTO := raptorVersionTimeout + t.Cleanup(func() { raptorVersionCmd = origCmd; raptorVersionTimeout = origTO }) + raptorVersionTimeout = 10 * time.Millisecond + raptorVersionCmd = func(ctx context.Context) ([]byte, error) { + <-ctx.Done() // simulate a hung binary: block until the timeout fires + return nil, ctx.Err() + } + if v, ok := execRaptorVersion(); ok || v != "" { + t.Errorf("a hung raptor must yield (\"\", false), got (%q, %v)", v, ok) + } +} + +func TestRaptorSemverParse(t *testing.T) { + for in, want := range map[string]string{ + "raptor version 0.1.81": "0.1.81", + "0.2.0": "0.2.0", + "no version here": "", + } { + if got := raptorSemver.FindString(in); got != want { + t.Errorf("parse(%q) = %q, want %q", in, got, want) + } + } +} + +// TestCheckTool_Raptor exercises the shared engine for the raptor toolSpec via +// stubbed seams — the same engine praxis's own check runs through. +func TestCheckTool_Raptor(t *testing.T) { + origDelay := updateCheckRetryDelay + updateCheckRetryDelay = 0 + t.Cleanup(func() { updateCheckRetryDelay = origDelay }) + origV, origF := raptorLocalVersion, fetchRaptorTag + t.Cleanup(func() { raptorLocalVersion, fetchRaptorTag = origV, origF }) + t.Setenv("PRAXIS_NO_UPDATE_CHECK", "") + + cases := []struct { + name string + local string + installed bool + latest string + latestErr error + wantInstalled, wantStale bool + }{ + {"stale", "0.1.81", true, "v0.2.0", nil, true, true}, + {"current", "0.2.0", true, "v0.2.0", nil, true, false}, + {"ahead", "0.3.0", true, "v0.2.0", nil, true, false}, + {"not-installed", "", false, "v9.9.9", nil, false, false}, + {"fetch-error", "0.1.0", true, "", errors.New("offline"), true, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fakeHome(t) // fresh cache per case + raptorLocalVersion = func() (string, bool) { return tc.local, tc.installed } + fetched := false + fetchRaptorTag = func() (string, error) { fetched = true; return tc.latest, tc.latestErr } + f := checkTool(raptorSpec(), time.Now(), freshLive) // bypass cache + if f.Installed != tc.wantInstalled { + t.Errorf("Installed = %v, want %v", f.Installed, tc.wantInstalled) + } + if f.Stale != tc.wantStale { + t.Errorf("Stale = %v, want %v (latest=%q)", f.Stale, tc.wantStale, f.Latest) + } + if !tc.installed && fetched { + t.Error("must not fetch latest when raptor is not installed") + } + }) + } +} + +// TestCheckTool_PerToolCacheIsolation proves praxis and raptor share ONE cache +// file without interfering: a fresh praxis entry doesn't suppress a raptor fetch. +func TestCheckTool_PerToolCacheIsolation(t *testing.T) { + origDelay := updateCheckRetryDelay + updateCheckRetryDelay = 0 + t.Cleanup(func() { updateCheckRetryDelay = origDelay }) + origV, origF := raptorLocalVersion, fetchRaptorTag + t.Cleanup(func() { raptorLocalVersion, fetchRaptorTag = origV, origF }) + fakeHome(t) + t.Setenv("PRAXIS_NO_UPDATE_CHECK", "") + + // A FRESH praxis entry is in the cache. + if err := saveFreshnessCache(freshnessCache{ + "praxis": {CheckedAt: time.Now(), LatestVersion: "v9.9.9"}, + }); err != nil { + t.Fatal(err) + } + raptorLocalVersion = func() (string, bool) { return "0.1.0", true } + fetched := false + fetchRaptorTag = func() (string, error) { fetched = true; return "v0.2.0", nil } + + f := checkTool(raptorSpec(), time.Now(), freshCachedOrFetch) // cache-first + if !fetched { + t.Error("raptor must fetch when only praxis is cached (per-tool isolation)") + } + if !f.Stale { + t.Error("raptor 0.1.0 < 0.2.0 must be stale") + } + c, _ := readFreshnessCache() + if c["praxis"].LatestVersion != "v9.9.9" { + t.Error("praxis cache entry was clobbered by the raptor check") + } + if _, ok := c["raptor"]; !ok { + t.Error("raptor entry not persisted to the shared cache") + } +} + func TestCompareSemver(t *testing.T) { tests := []struct { a, b string @@ -318,28 +430,80 @@ func TestNewerThan(t *testing.T) { } } -func TestPrintUpdateNotification(t *testing.T) { - withVersion(t, "1.0.0") +func TestPrintFreshnessBox(t *testing.T) { + cases := []struct { + name string + f Freshness + action string + want []string + }{ + {"praxis", Freshness{Tool: "praxis", Current: "1.0.0", Latest: "v1.2.0"}, + nagAction(praxisSpec()), []string{"praxis", "1.0.0", "v1.2.0", "praxis update", "PRAXIS_NO_UPDATE_CHECK"}}, + {"raptor", Freshness{Tool: "raptor", Current: "0.1.0", Latest: "v0.2.0"}, + nagAction(raptorSpec()), []string{"raptor", "0.1.0", "v0.2.0", "raptor upgrade", "PRAXIS_NO_UPDATE_CHECK"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + printFreshnessBox(tc.f, tc.action, &buf) + out := buf.String() + for _, want := range tc.want { + if !strings.Contains(out, want) { + t.Errorf("box missing %q\n%s", want, out) + } + } + // Every box line must be the same display width so the right border + // aligns (⚡ and → are wide runes). + var widths []int + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + widths = append(widths, displayWidth(line)) + } + for i, w := range widths { + if w != widths[0] { + t.Errorf("line %d width = %d, want %d (misaligned)\n%s", i, w, widths[0], out) + } + } + }) + } +} + +func TestNoticeFreshness(t *testing.T) { + fakeHome(t) + origDelay := updateCheckRetryDelay + updateCheckRetryDelay = 0 + t.Cleanup(func() { updateCheckRetryDelay = origDelay }) + withVersion(t, "dev") // praxis not checkable → only raptor can be stale + t.Setenv("PRAXIS_NO_UPDATE_CHECK", "") + origV, origF := raptorLocalVersion, fetchRaptorTag + t.Cleanup(func() { raptorLocalVersion, fetchRaptorTag = origV, origF }) + raptorLocalVersion = func() (string, bool) { return "0.1.0", true } + fetchRaptorTag = func() (string, error) { return "v0.2.0", nil } + var buf bytes.Buffer - printUpdateNotification("v1.2.0", &buf) + stale := noticeFreshness(&buf, false) + if len(stale) != 1 || stale[0].Tool != "raptor" { + t.Fatalf("want [raptor] stale, got %+v", stale) + } out := buf.String() - - for _, want := range []string{"1.0.0", "v1.2.0", "praxis update", "PRAXIS_NO_UPDATE_CHECK"} { - if !strings.Contains(out, want) { - t.Errorf("notification missing %q\n%s", want, out) - } + if !strings.Contains(out, "raptor") || !strings.Contains(out, "raptor upgrade") { + t.Errorf("notice missing raptor upgrade prompt: %q", out) } - // Every box line must be the same display width so the right border lines - // up — this is what the wide-rune handling exists for (⚡ and → are wide). - var widths []int - for _, line := range strings.Split(strings.TrimSpace(out), "\n") { - widths = append(widths, displayWidth(line)) + // JSON mode prints nothing (envelope carries state.staleTools instead). + buf.Reset() + if got := noticeFreshness(&buf, true); len(got) != 1 { + t.Errorf("JSON mode should still return staleTools, got %+v", got) } - for i, w := range widths { - if w != widths[0] { - t.Errorf("line %d display width = %d, want %d (misaligned box)\n%s", i, w, widths[0], out) - } + if buf.Len() != 0 { + t.Errorf("JSON mode must not print a human notice, got %q", buf.String()) + } +} + +// raptor's nag line must NOT tell the user praxis will run the upgrade. +func TestNagActionRaptorIsNudgeOnly(t *testing.T) { + a := nagAction(raptorSpec()) + if !strings.Contains(a, "raptor upgrade") || !strings.Contains(a, "won't run it") { + t.Errorf("raptor nag must be nudge-only: %q", a) } } diff --git a/docs/superpowers/plans/2026-07-16-raptor-praxis-freshness.md b/docs/superpowers/plans/2026-07-16-raptor-praxis-freshness.md new file mode 100644 index 0000000..90c4c6c --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-raptor-praxis-freshness.md @@ -0,0 +1,150 @@ +# Tool freshness (praxis + raptor) — Implementation Plan + +> **For agentic workers:** implement task-by-task (TDD). Steps use `- [ ]` checkboxes. + +**Goal:** praxis keeps the user aware when **raptor** *or praxis itself* is behind +its latest release, and nudges toward an upgrade — through ONE unified freshness +engine, not a raptor path bolted beside the existing praxis self-update path. + +**Architecture:** Generalize the existing praxis update-check +(`cmd/update_check.go`) into a small **multi-tool freshness engine** driven by a +tool registry. praxis becomes one entry, raptor another. Everything shared — +`compareSemver`, the 24h file cache, fetch-with-retry, the nag renderer, the +throttle, the skip-list. praxis's own self-update nag is **migrated onto this +engine** (no separate path). Four surfaces read the same engine for both tools: +`praxis status --json`, `praxis login`, the Execute-time TTY nag, and the +driver/raptor skill copy. Nudge-only: praxis never runs `raptor upgrade`. + +**Tech stack:** Go 1.24, cobra. Reuse `cmd/update_check.go` + +`internal/selfupdate`. NO new duplicate semver/cache/render code. + +## Global Constraints + +- **Unify, don't fragment.** One engine, one cache file, one comparator, one + renderer, one throttle. praxis and raptor differ ONLY in their `toolSpec` + (current-version resolver, releases repo, applicability gate, upgrade hint). + praxis's existing nag MUST route through the generalized engine, not stay + parallel. +- **Best-effort, never fatal.** Any network/exec/parse error → "not stale", + Latest=""; never block a command, never nag on uncertainty. +- **Nudge-only for raptor.** praxis MUST NOT run `raptor upgrade`. It surfaces + staleness; the agent asks the user, then runs it (raptor's "ask before + upgrading"). `praxis update` remains praxis-only (self-replaces its binary). +- **Preserve existing praxis behavior exactly:** `isDevBuild` skip, + `PRAXIS_NO_UPDATE_CHECK` kill-switch, 24h throttle, TTY-stderr nag, 3s maxWait, + `skipUpdateCheck` list, box format. Existing update-check tests stay green. +- **Throttle:** ≤ one GitHub call per tool per 24h; cache 0600 under `~/.praxis`. +- **raptor:** version = first `\d+\.\d+\.\d+` from `raptor --version` + (`raptor version 0.1.81` locally); absent (`command -v raptor` empty) → + Installed=false, never stale. Latest = newest tag from public + `Facets-cloud/raptor-releases`. + +--- + +## Task 1: Parameterize latest-release fetch by repo + +**Files:** `internal/selfupdate/selfupdate.go`, `internal/selfupdate/selfupdate_test.go` + +`latestReleaseURL()` hardcodes praxis-cli. Add `LatestReleaseTagFor(repo string) +(string, error)` (reuses `fetchRelease`) returning just the tag. `LatestRelease()` +keeps returning the full `*Release` (assets needed by `praxis update`) but is +re-expressed over the shared URL builder so there is one code path. + +- [ ] Test: `LatestReleaseTagFor("owner/repo")` vs httptest → tag; 404/500/bad-JSON → error. +- [ ] Run `go test ./internal/selfupdate/ -run LatestReleaseTagFor` → FAIL → implement → PASS. Commit. + +## Task 2: Generalize the engine to a tool registry (the core refactor) + +**Files:** `cmd/update_check.go`, `cmd/update_check_test.go` + +The refactor that prevents fragmentation. Introduce: + +```go +type toolSpec struct { + Name string + Current func() (version string, applicable bool) // praxis: (version, !isDevBuild); raptor: parse (v, installed) + Repo string // "Facets-cloud/praxis-cli" | "Facets-cloud/raptor-releases" + UpgradeHint string // "praxis update" | "raptor upgrade" +} + +type Freshness struct { + Tool, Current, Latest string + Installed, Stale bool +} + +func tools() []toolSpec { return []toolSpec{praxisSpec(), raptorSpec()} } +func checkTool(spec toolSpec, now time.Time) Freshness // uses the SHARED cache + fetch + compareSemver +``` + +- Generalize the cache: `updateCheckCache{CheckedAt, LatestVersion}` → + `map[string]toolCacheEntry` keyed by tool name (one file, both tools). An old + single-tool cache simply fails to unmarshal → treated as cold (harmless). +- `fetchLatestReleaseWithRetry` takes the repo (via Task 1). The praxis + `fetchLatestRelease` seam is preserved for existing tests. +- **Migrate praxis:** rewrite `checkForUpdate()` as `checkTool(praxisSpec(), now)` + → nag tag. praxisSpec().Current returns `(version, !isDevBuild(version))`. +- raptorSpec().Current: `command -v raptor` then parse `raptor --version`. +- Keep `compareSemver`/`splitVersion`/`newerThan` as-is (already shared). + +- [ ] Test: praxis path unchanged — dev build → applicable=false → no nag; older current + newer latest (cached) → nag tag; kill-switch honored. +- [ ] Test: `checkTool(raptorSpec)` with injected latest > current → Stale; absent raptor → Installed=false; fetch error → not stale. +- [ ] Test: cache keyed per tool — a fresh praxis entry doesn't suppress a raptor fetch and vice-versa; 24h TTL respected. +- [ ] Run → FAIL → refactor → PASS (all pre-existing update-check tests still green). Commit. + +## Task 3: Execute-time nag over ALL tools + +**Files:** `cmd/root.go`, `cmd/update_check.go`, tests + +Generalize `printUpdateNotification(latest)` → `printToolNotification(Freshness)` +(parameterized name / current→latest / hint line: "Run praxis update" vs "Ask me +to run raptor upgrade"). Execute() loops the tool registry, runs the shared check +in the existing background/TTY/maxWait pattern, prints one box per stale tool. +`skipUpdateCheck` and the kill-switch unchanged. + +- [ ] Test: `printToolNotification` renders praxis box identical to today (golden), and a raptor box with the `raptor upgrade` hint. +- [ ] Test: Execute nag path emits raptor box when raptor stale (injected), nothing when fresh, skipped non-TTY. +- [ ] Run → FAIL → implement → PASS. Commit. + +## Task 4: `praxis status --json` freshness block + +**Files:** `cmd/status.go`, `cmd/status_test.go` + +Add `"tools": [Freshness{praxis}, Freshness{raptor}]` to the status snapshot from +the SAME engine, CACHED (no network on a plain status). `status --refresh` forces +a live re-check alongside the token check. + +- [ ] Test: `status --json` includes `tools` with both entries (injected engine); `--refresh` triggers a live check. +- [ ] Run → FAIL → implement → PASS. Commit. + +## Task 5: Login-time stale notice + +**Files:** `cmd/login_setup.go`, tests + +After post-auth setup, one line per stale tool via the shared renderer (non-JSON); +JSON login envelope gains `tools`. Best-effort, non-fatal. + +- [ ] Test: stale injected tool prints a notice; fresh prints nothing; JSON carries `tools`. +- [ ] Run → FAIL → implement → PASS. Commit. + +## Task 6: Driver skill + raptor preamble freshness step + +**Files:** `internal/skillinstall/dummy.go`, `internal/render/preamble.go`, guard tests + +Short step: "Glance at `praxis status --json` → `tools`. If `raptor.stale` (or +praxis's own), tell the user and offer to run `raptor upgrade` (ask first — never +auto-run). praxis surfaces versions; you + the user decide." + +- [ ] Test (embedded guard): copy mentions raptor freshness + `raptor upgrade` + "ask". +- [ ] Run → FAIL → add copy → PASS. Commit. + +## Self-review checklist + +- praxis's own nag routes through the generalized engine (no parallel path left). +- One cache file, one comparator, one renderer, one throttle — praxis/raptor differ only in `toolSpec`. +- All four surfaces read the same engine, both tools. +- Best-effort everywhere; no path blocks a command; no code runs `raptor upgrade`. +- Every pre-existing update-check test still passes. + +## Sequencing / release + +New branch off main (post-#61), own PR → `v1.4.4`. diff --git a/internal/render/preamble.go b/internal/render/preamble.go index 14cdc21..65cc387 100644 --- a/internal/render/preamble.go +++ b/internal/render/preamble.go @@ -35,7 +35,10 @@ const ExecutionPreamble = "" + "> Run `raptor …` commands directly in your shell; never route them\n" + "> through `praxis mcp` (there is no `raptor_cli` gateway tool). If\n" + "> `command -v raptor` finds nothing, ask the user to install it; if\n" + - "> `raptor whoami` fails, ask the user to run `raptor login` first.\n" + + "> `raptor whoami` fails, ask the user to run `raptor login` first. In\n" + + "> `praxis status --json`, `tools` is an ARRAY — find the entry whose\n" + + "> `tool` is `raptor`; if that entry's `stale` is true, offer to run\n" + + "> `raptor upgrade` (ask first — never auto-run it).\n" + ">\n" + "> **Discovering what's available** — to see every MCP and function the\n" + "> gateway exposes, run `praxis mcp --json` (live fetch). A snapshot\n" + diff --git a/internal/render/preamble_test.go b/internal/render/preamble_test.go index f04a843..55ee9a5 100644 --- a/internal/render/preamble_test.go +++ b/internal/render/preamble_test.go @@ -25,6 +25,14 @@ func TestExecutionPreambleShape(t *testing.T) { if !strings.Contains(p, "raptor") || !strings.Contains(p, "raptor login") { t.Fatal("preamble should explain raptor is a local CLI run directly (with install/login fallback)") } + // Freshness: offer `raptor upgrade` when status reports raptor stale, and + // describe `tools` correctly as an ARRAY (not an object path raptor.stale). + if !strings.Contains(p, "raptor upgrade") { + t.Fatal("preamble should offer `raptor upgrade` when raptor is stale") + } + if strings.Contains(p, "raptor.stale") { + t.Fatal("preamble uses the wrong shape `raptor.stale`; `tools` is an array — find the raptor entry") + } if !strings.HasSuffix(p, "\n") { t.Fatal("preamble should end with a trailing newline so callers can concatenate cleanly") } diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go index aa34d8f..4efae6e 100644 --- a/internal/selfupdate/selfupdate.go +++ b/internal/selfupdate/selfupdate.go @@ -39,13 +39,36 @@ type Asset struct { Size int64 `json:"size"` } -// LatestRelease returns metadata for the most recent published release. +// LatestRelease returns full metadata (incl. assets) for the most recent +// praxis-cli release — used by `praxis update` to self-replace the binary. func LatestRelease() (*Release, error) { return fetchRelease(latestReleaseURL()) } +// LatestReleaseTagFor returns just the newest release tag for any GitHub repo +// ("owner/name"). Used by the freshness engine to compare a tool's local +// version against its latest release, without needing the release assets. +func LatestReleaseTagFor(repo string) (string, error) { + return releaseTagFrom(releaseURL(repo)) +} + +// releaseURL builds the "latest release" API URL for a "owner/name" repo — the +// single URL builder both praxis self-update and tool-freshness go through. +func releaseURL(repo string) string { + return fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) +} + func latestReleaseURL() string { - return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", repoOwner, repoName) + return releaseURL(repoOwner + "/" + repoName) +} + +// releaseTagFrom fetches url and returns the release's tag (testable seam). +func releaseTagFrom(url string) (string, error) { + r, err := fetchRelease(url) + if err != nil { + return "", err + } + return r.TagName, nil } // fetchRelease is the testable seam for LatestRelease — pass a httptest URL diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go index 65aa16e..96e9d6c 100644 --- a/internal/selfupdate/selfupdate_test.go +++ b/internal/selfupdate/selfupdate_test.go @@ -53,6 +53,43 @@ func TestFetchRelease_Success(t *testing.T) { } } +func TestReleaseURL(t *testing.T) { + if got := releaseURL("Facets-cloud/raptor-releases"); got != "https://api.github.com/repos/Facets-cloud/raptor-releases/releases/latest" { + t.Errorf("releaseURL = %q", got) + } + // praxis's own URL must still route through the shared builder. + if latestReleaseURL() != releaseURL("Facets-cloud/praxis-cli") { + t.Errorf("latestReleaseURL not built from releaseURL: %q", latestReleaseURL()) + } +} + +func TestReleaseTagFrom(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + _, _ = w.Write([]byte(fakeReleaseJSON("v2.3.4"))) + })) + defer srv.Close() + tag, err := releaseTagFrom(srv.URL) + if err != nil { + t.Fatalf("releaseTagFrom err = %v", err) + } + if tag != "v2.3.4" { + t.Errorf("tag = %q, want v2.3.4", tag) + } + + // Errors propagate (so the freshness engine treats it as "not stale"), and + // the 404 "no releases published yet" contract is preserved. + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(404) })) + defer bad.Close() + _, err = releaseTagFrom(bad.URL) + if err == nil { + t.Fatal("expected error on 404") + } + if !strings.Contains(err.Error(), "no releases") { + t.Errorf("404 error = %v, want substring 'no releases'", err) + } +} + func TestFetchRelease_404(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(404) diff --git a/internal/skillinstall/dummy.go b/internal/skillinstall/dummy.go index f3c0995..555427a 100644 --- a/internal/skillinstall/dummy.go +++ b/internal/skillinstall/dummy.go @@ -163,6 +163,12 @@ Preflight — once per session, before the first raptor command: ` + "`raptor login`" + ` (a browser flow that stores a PAT in ` + "`~/.facets/credentials`" + `). 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 + ` + "`raptor`" + ` (or ` + "`praxis`" + `); if its ` + "`stale`" + ` is true, tell + the user and offer to run ` + "`raptor upgrade`" + ` — ask first, never auto-run + it. praxis surfaces the versions; you and the user decide. So when the user asks about projects / resources / environments / releases / cloud accounts, reach for ` + "`raptor`" + `, not ` + "`praxis mcp`" + `. diff --git a/internal/skillinstall/dummy_test.go b/internal/skillinstall/dummy_test.go index 77e021f..9304a48 100644 --- a/internal/skillinstall/dummy_test.go +++ b/internal/skillinstall/dummy_test.go @@ -36,4 +36,16 @@ func TestPraxisMetaSkill_RaptorIsLocalNotGateway(t *testing.T) { if strings.Contains(body, "`catalog_ops`, `raptor_cli`") { t.Error("meta-skill still lists `raptor_cli` as a gateway MCP namespace; it was removed from the gateway and is a local CLI") } + + // Must teach the freshness step: check status tools, offer `raptor upgrade`, + // ask first (nudge-only). + for _, want := range []string{"raptor upgrade", "stale", "ask first"} { + if !strings.Contains(body, want) { + t.Errorf("meta-skill missing raptor-freshness guidance %q", want) + } + } + // `tools` is a JSON array, so the object path `raptor.stale` is wrong. + if strings.Contains(body, "raptor.stale") { + t.Error("meta-skill uses the wrong shape `raptor.stale`; tools is an array — find the raptor entry") + } }