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
21 changes: 21 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/basecamp/basecamp-cli/internal/commands"
"github.com/basecamp/basecamp-cli/internal/completion"
"github.com/basecamp/basecamp-cli/internal/config"
"github.com/basecamp/basecamp-cli/internal/harness"
"github.com/basecamp/basecamp-cli/internal/hostutil"
"github.com/basecamp/basecamp-cli/internal/output"
"github.com/basecamp/basecamp-cli/internal/tui"
Expand All @@ -26,6 +27,7 @@ import (
// NewRootCmd creates the root cobra command.
func NewRootCmd() *cobra.Command {
var flags appctx.GlobalFlags
var updateCheck *commands.UpdateCheck

cmd := &cobra.Command{
Use: "basecamp",
Expand All @@ -42,6 +44,9 @@ func NewRootCmd() *cobra.Command {
return nil
}

// Start background update check early so it runs during command execution
updateCheck = commands.StartUpdateCheck()

// Bare root (no subcommand, no args) will show help or
// quickstart depending on mode. Tolerate config/profile
// errors so bad config doesn't block help, while still
Expand Down Expand Up @@ -162,8 +167,24 @@ func NewRootCmd() *cobra.Command {
if commands.RefreshSkillsIfVersionChanged() {
if app == nil || !app.IsMachineOutput() {
fmt.Fprintf(os.Stderr, "Agent skill updated to match CLI %s\n", version.Version)

// One-time hint: if plugin/CLI version mismatch after upgrade, nudge the user
if pv := harness.InstalledPluginVersion(); pv != "" && pv != version.Version && !version.IsDev() {
fmt.Fprintf(os.Stderr, "Basecamp plugin version mismatch (plugin %s, CLI %s) — %s\n", pv, version.Version, harness.AutoUpdateHint)
}
}
}

// Ambient update notice — only for interactive TTY sessions.
// Skip after upgrade (just acted on it) and doctor (has its own version check).
if updateCheck != nil && cmd.Name() != "upgrade" && cmd.Name() != "doctor" {
if notice := updateCheck.Notice(); notice != "" {
if app != nil && app.IsInteractive() && !app.IsMachineOutput() {
fmt.Fprintln(os.Stderr, notice)
}
}
}

return nil
}

Expand Down
12 changes: 12 additions & 0 deletions internal/commands/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,18 @@ func runDoctorChecks(ctx context.Context, app *appctx.App, verbose bool) []Check
}
}

// 13. Claude plugin version (doctor-only, not part of generic agent checks
// which gate setup wizard behavior)
if harness.DetectClaude() {
pvc := harness.CheckClaudePluginVersion()
checks = append(checks, Check{
Name: pvc.Name,
Status: pvc.Status,
Message: pvc.Message,
Hint: pvc.Hint,
})
}

return checks
}

Expand Down
135 changes: 135 additions & 0 deletions internal/commands/update_notice.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package commands

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"

"github.com/basecamp/basecamp-cli/internal/config"
"github.com/basecamp/basecamp-cli/internal/version"
)

// checkInterval is how often we query GitHub for the latest version.
var checkInterval = 24 * time.Hour

// stdoutIsTerminal reports whether stdout is a terminal. Extracted for testability.
var stdoutIsTerminal = func() bool {
fi, err := os.Stdout.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}

// UpdateCheck holds state for a non-blocking version check.
type UpdateCheck struct {
latest string
done chan struct{}
}

// StartUpdateCheck begins a background version check if the cache is stale.
// Returns nil if the check should be skipped (dev build, opted out, etc.).
func StartUpdateCheck() *UpdateCheck {
if version.IsDev() {
return nil
}
if os.Getenv("BASECAMP_NO_UPDATE_CHECK") == "1" {
return nil
}

// Skip for non-interactive sessions — no point fetching if we won't display
if !stdoutIsTerminal() {
return nil
}

uc := &UpdateCheck{done: make(chan struct{})}
cached := readUpdateCache()

if cached != nil {
age := time.Since(cached.CheckedAt)
if age >= 0 && age < checkInterval {
// Cache is fresh — use it directly, no goroutine needed
uc.latest = cached.LatestVersion
close(uc.done)
return uc
}
}

// Cache is stale or missing — fetch in the background
go func() {
defer close(uc.done)
latest, err := versionChecker()
if err != nil || latest == "" {
return
}
uc.latest = latest
writeUpdateCache(latest)
}()

return uc
}

// Notice returns a formatted update notice, or "" if no update is available
// or the check hasn't completed. Never blocks.
func (uc *UpdateCheck) Notice() string {
if uc == nil {
return ""
}

// Non-blocking check: if the goroutine hasn't finished, skip
select {
case <-uc.done:
default:
return ""
}

if uc.latest == "" || uc.latest == version.Version {
return ""
}

return fmt.Sprintf(
"Update available: %s → %s — Run \"basecamp upgrade\" to update",
version.Version, uc.latest,
)
}

// updateCache is the on-disk format for the version check result.
type updateCache struct {
LatestVersion string `json:"latest_version"`
CheckedAt time.Time `json:"checked_at"`
}

func updateCachePath() string {
return filepath.Join(config.GlobalConfigDir(), ".update-check")
}

func readUpdateCache() *updateCache {
data, err := os.ReadFile(updateCachePath())
if err != nil {
return nil
}
var c updateCache
if err := json.Unmarshal(data, &c); err != nil {
return nil
}
if c.LatestVersion == "" || c.CheckedAt.IsZero() {
return nil
}
return &c
}

func writeUpdateCache(latestVersion string) {
c := updateCache{
LatestVersion: latestVersion,
CheckedAt: time.Now().UTC(),
}
data, err := json.Marshal(c)
if err != nil {
return
}
dir := filepath.Dir(updateCachePath())
_ = os.MkdirAll(dir, 0o755) //nolint:gosec // G301: config dir
_ = os.WriteFile(updateCachePath(), data, 0o644) //nolint:gosec // G306: not a secret
}
Loading
Loading