Files
beads/cmd/bd/tips.go
Steve Yegge 6789e4fd9a feat: add hooks to plugin.json, eliminating need for global install
The beads plugin now provides SessionStart and PreCompact hooks directly
via plugin.json. Users no longer need to run 'bd setup claude' when using
the plugin - hooks are automatically available.

Changes:
- Add hooks section to .claude-plugin/plugin.json with SessionStart and
  PreCompact hooks that run 'bd prime'
- Update doctor/claude.go to recognize plugin-provided hooks as valid
- Update tips.go to check for plugin installation when determining if
  Claude integration is complete
- Update messaging to recommend plugin installation as primary option

The 'bd setup claude' command remains available for CLI-only users who
do not want to install the plugin.

Closes #462

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 22:44:42 -08:00

358 lines
9.8 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"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.
// Returns true if the beads plugin is installed (provides hooks via plugin.json),
// or if hooks were manually installed via 'bd setup claude'.
func isClaudeSetupComplete() bool {
home, err := os.UserHomeDir()
if err != nil {
return false
}
// Check if beads plugin is installed - plugin now provides hooks automatically
settingsPath := filepath.Join(home, ".claude", "settings.json")
if data, err := os.ReadFile(settingsPath); err == nil {
var settings map[string]interface{}
if err := json.Unmarshal(data, &settings); err == nil {
if enabledPlugins, ok := settings["enabledPlugins"].(map[string]interface{}); ok {
for key, value := range enabledPlugins {
if strings.Contains(strings.ToLower(key), "beads") {
if enabled, ok := value.(bool); ok && enabled {
return true // Plugin installed - provides hooks
}
}
}
}
}
}
// Check for manual hooks installation via 'bd setup claude'
// Global hooks in settings.json
if hasBeadsPrimeHooks(settingsPath) {
return true
}
// Project-level hooks in .claude/settings.local.json
if hasBeadsPrimeHooks(".claude/settings.local.json") {
return true
}
return false
}
// hasBeadsPrimeHooks checks if a settings file has bd prime hooks configured
func hasBeadsPrimeHooks(settingsPath string) bool {
data, err := os.ReadFile(settingsPath)
if err != nil {
return false
}
var settings map[string]interface{}
if err := json.Unmarshal(data, &settings); err != nil {
return false
}
hooks, ok := settings["hooks"].(map[string]interface{})
if !ok {
return false
}
// Check SessionStart and PreCompact for "bd prime"
for _, event := range []string{"SessionStart", "PreCompact"} {
eventHooks, ok := hooks[event].([]interface{})
if !ok {
continue
}
for _, hook := range eventHooks {
hookMap, ok := hook.(map[string]interface{})
if !ok {
continue
}
commands, ok := hookMap["hooks"].([]interface{})
if !ok {
continue
}
for _, cmd := range commands {
cmdMap, ok := cmd.(map[string]interface{})
if !ok {
continue
}
cmdStr, _ := cmdMap["command"].(string)
if cmdStr == "bd prime" || cmdStr == "bd prime --stealth" {
return true
}
}
}
}
return false
}
// initDefaultTips registers the built-in tips.
// Called during initialization to populate the tip registry.
func initDefaultTips() {
// Claude setup tip - suggest installing the beads plugin when Claude is detected
// but the integration is not configured
InjectTip(
"claude_setup",
"Install the beads plugin for automatic workflow context, or run 'bd setup claude' for CLI-only mode",
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()
}