Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions cmd/login_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 +
Expand Down
10 changes: 5 additions & 5 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
case <-time.After(updateCheckMaxWait):
// Cold fetch still in flight — skip the notice for this run.
Expand Down
21 changes: 16 additions & 5 deletions cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if asJSON {
if statusFull {
// Same shaped schema as `list-skills --json` and
Expand Down
64 changes: 64 additions & 0 deletions cmd/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)")
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestStatusCmd_NotLoggedIn_DefaultProfile(t *testing.T) {
t.Setenv("HOME", t.TempDir())
resetStatusFlags()
Expand Down
7 changes: 7 additions & 0 deletions cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading