Files
beads/cmd/bd/tips.go
Steve Yegge 071fc96206 fix: resolve golangci-lint errors
- Add error check for fmt.Fprintf in tips.go (errcheck)
- Add nolint for safe SQL formatting in transaction.go (gosec G201)
- Fix 'cancelled' -> 'canceled' spelling (misspell)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 22:21:55 -08:00

309 lines
8.9 KiB
Go

package main
import (
"context"
"fmt"
"math/rand"
"os"
"path/filepath"
"sort"
"strconv"
"sync"
"time"
"github.com/steveyegge/beads/internal/storage"
)
// Tip represents a contextual hint that can be shown to users after successful commands
type Tip struct {
ID string
Condition func() bool // Should this tip be eligible?
Message string // The tip message to display
Frequency time.Duration // Minimum gap between showings
Priority int // Higher = shown first when eligible
Probability float64 // 0.0 to 1.0 - chance of showing when eligible
}
var (
// tips is the registry of all available tips
tips []Tip
// tipsMutex protects the tips registry for thread-safe access
tipsMutex sync.RWMutex
// tipRand is the random number generator for probability rolls
// Can be seeded deterministically via BEADS_TIP_SEED for testing
tipRand *rand.Rand
// tipRandOnce ensures we only initialize the RNG once
tipRandOnce sync.Once
)
// initTipRand initializes the random number generator for tip selection
// Uses BEADS_TIP_SEED env var for deterministic testing if set
func initTipRand() {
tipRandOnce.Do(func() {
seed := time.Now().UnixNano()
if seedStr := os.Getenv("BEADS_TIP_SEED"); seedStr != "" {
if parsedSeed, err := strconv.ParseInt(seedStr, 10, 64); err == nil {
seed = parsedSeed
}
}
// Use deprecated rand.NewSource for Go 1.19 compatibility
// nolint:gosec,staticcheck // G404: deterministic seed via env var is intentional for testing
tipRand = rand.New(rand.NewSource(seed))
})
}
// maybeShowTip selects and displays an eligible tip based on priority and probability
// Respects --json and --quiet flags
func maybeShowTip(store storage.Storage) {
// Skip tips in JSON output mode or quiet mode
if jsonOutput || quietFlag {
return
}
// Initialize RNG if needed
initTipRand()
// Select next tip
tip := selectNextTip(store)
if tip == nil {
return
}
// Display tip to stdout (informational, not an error)
_, _ = fmt.Fprintf(os.Stdout, "\n💡 Tip: %s\n", tip.Message)
// Record that we showed this tip
recordTipShown(store, tip.ID)
}
// selectNextTip finds the next tip to show based on conditions, frequency, priority, and probability
// Returns nil if no tip should be shown
func selectNextTip(store storage.Storage) *Tip {
if store == nil {
return nil
}
now := time.Now()
var eligibleTips []Tip
// Lock for reading the tip registry
tipsMutex.RLock()
defer tipsMutex.RUnlock()
// Filter to eligible tips (condition + frequency check)
for _, tip := range tips {
// Check if tip's condition is met
if !tip.Condition() {
continue
}
// Check if enough time has passed since last showing
lastShown := getLastShown(store, tip.ID)
if !lastShown.IsZero() && now.Sub(lastShown) < tip.Frequency {
continue
}
eligibleTips = append(eligibleTips, tip)
}
if len(eligibleTips) == 0 {
return nil
}
// Sort by priority (highest first)
sort.Slice(eligibleTips, func(i, j int) bool {
return eligibleTips[i].Priority > eligibleTips[j].Priority
})
// Apply probability roll (in priority order)
// Higher priority tips get first chance to show
for i := range eligibleTips {
if tipRand.Float64() < eligibleTips[i].Probability {
return &eligibleTips[i]
}
}
return nil // No tips won probability roll
}
// getLastShown retrieves the timestamp when a tip was last shown
// Returns zero time if never shown
func getLastShown(store storage.Storage, tipID string) time.Time {
key := fmt.Sprintf("tip_%s_last_shown", tipID)
value, err := store.GetMetadata(context.Background(), key)
if err != nil || value == "" {
return time.Time{}
}
// Parse RFC3339 timestamp
t, err := time.Parse(time.RFC3339, value)
if err != nil {
return time.Time{}
}
return t
}
// recordTipShown records the timestamp when a tip was shown
func recordTipShown(store storage.Storage, tipID string) {
key := fmt.Sprintf("tip_%s_last_shown", tipID)
value := time.Now().Format(time.RFC3339)
_ = store.SetMetadata(context.Background(), key, value)
}
// InjectTip adds a dynamic tip to the registry at runtime.
// This enables tips to be programmatically added based on detected conditions.
//
// Parameters:
// - id: Unique identifier for the tip (used for frequency tracking)
// - message: The tip message to display to the user
// - priority: Higher values = shown first when eligible (e.g., 100 for critical, 30 for suggestions)
// - frequency: Minimum time between showings (e.g., 24*time.Hour for daily)
// - probability: Chance of showing when eligible (0.0 to 1.0)
// - condition: Function that returns true when tip should be eligible
//
// Example usage:
//
// // Critical security update - always show
// InjectTip("security_update", "CRITICAL: Security update available!", 100, 0, 1.0, func() bool { return true })
//
// // New version available - frequent but not always
// InjectTip("upgrade_available", "New version available", 90, 7*24*time.Hour, 0.8, func() bool { return true })
//
// // Feature suggestion - occasional
// InjectTip("try_filters", "Try using filters", 50, 14*24*time.Hour, 0.4, func() bool { return true })
func InjectTip(id, message string, priority int, frequency time.Duration, probability float64, condition func() bool) {
tipsMutex.Lock()
defer tipsMutex.Unlock()
// Check if tip with this ID already exists - update it if so
for i, tip := range tips {
if tip.ID == id {
tips[i] = Tip{
ID: id,
Condition: condition,
Message: message,
Frequency: frequency,
Priority: priority,
Probability: probability,
}
return
}
}
// Add new tip
tips = append(tips, Tip{
ID: id,
Condition: condition,
Message: message,
Frequency: frequency,
Priority: priority,
Probability: probability,
})
}
// RemoveTip removes a tip from the registry by ID.
// This is useful for removing dynamically injected tips when they are no longer relevant.
// It is safe to call with a non-existent ID (no-op).
func RemoveTip(id string) {
tipsMutex.Lock()
defer tipsMutex.Unlock()
for i, tip := range tips {
if tip.ID == id {
tips = append(tips[:i], tips[i+1:]...)
return
}
}
}
// isClaudeDetected checks if the user is running within a Claude Code environment.
// Detection methods:
// - CLAUDE_CODE environment variable (set by Claude Code)
// - ANTHROPIC_CLI environment variable
// - Presence of ~/.claude directory (Claude Code config)
func isClaudeDetected() bool {
// Check environment variables set by Claude Code
if os.Getenv("CLAUDE_CODE") != "" || os.Getenv("ANTHROPIC_CLI") != "" {
return true
}
// Check if ~/.claude directory exists (Claude Code stores config here)
home, err := os.UserHomeDir()
if err != nil {
return false
}
if _, err := os.Stat(filepath.Join(home, ".claude")); err == nil {
return true
}
return false
}
// isClaudeSetupComplete checks if the beads Claude integration is properly configured.
// Checks for either global or project-level installation of the beads hooks.
func isClaudeSetupComplete() bool {
// Check for global installation
home, err := os.UserHomeDir()
if err == nil {
commandFile := filepath.Join(home, ".claude", "commands", "prime_beads.md")
hooksDir := filepath.Join(home, ".claude", "hooks")
// Check for prime_beads command
if _, err := os.Stat(commandFile); err == nil {
// Check for sessionstart hook (could be a file or directory)
hookPath := filepath.Join(hooksDir, "sessionstart")
if _, err := os.Stat(hookPath); err == nil {
return true // Global hooks installed
}
// Also check PreToolUse hook which is used by beads
preToolUsePath := filepath.Join(hooksDir, "PreToolUse")
if _, err := os.Stat(preToolUsePath); err == nil {
return true // Global hooks installed
}
}
}
// Check for project-level installation
commandFile := ".claude/commands/prime_beads.md"
hooksDir := ".claude/hooks"
if _, err := os.Stat(commandFile); err == nil {
hookPath := filepath.Join(hooksDir, "sessionstart")
if _, err := os.Stat(hookPath); err == nil {
return true // Project hooks installed
}
preToolUsePath := filepath.Join(hooksDir, "PreToolUse")
if _, err := os.Stat(preToolUsePath); err == nil {
return true // Project hooks installed
}
}
return false
}
// initDefaultTips registers the built-in tips.
// Called during initialization to populate the tip registry.
func initDefaultTips() {
// Claude setup tip - suggest running bd setup claude when Claude is detected
// but the integration is not configured
InjectTip(
"claude_setup",
"Run 'bd setup claude' to enable automatic context recovery in Claude Code",
100, // Highest priority - this is important for Claude users
24*time.Hour, // Daily minimum gap
0.6, // 60% chance when eligible (~4 times per week)
func() bool {
return isClaudeDetected() && !isClaudeSetupComplete()
},
)
}
// init initializes the tip system with default tips
func init() {
initDefaultTips()
}