diff --git a/.justfiles/build.just b/.justfiles/build.just index 730c5ba..3a9e1f0 100644 --- a/.justfiles/build.just +++ b/.justfiles/build.just @@ -1,8 +1,7 @@ # Build Commands # Run `just build` to see all build commands -# invocation_directory() returns the directory from which just was called -PROJECT_DIR := invocation_directory() +PROJECT_DIR := justfile_directory() BIN_DIR := PROJECT_DIR / "bin" [private] diff --git a/.justfiles/demos.just b/.justfiles/demos.just index e0370c7..2756e71 100644 --- a/.justfiles/demos.just +++ b/.justfiles/demos.just @@ -39,6 +39,26 @@ subagents: build-subagents @echo "Starting subagents demo..." {{BIN_DIR}}/demo-subagents +# Build and run sessions demo +sessions: build-sessions + @echo "Starting sessions demo..." + {{BIN_DIR}}/demo-sessions + +# Build and run MCP demo +mcp: build-mcp + @echo "Starting MCP demo..." + {{BIN_DIR}}/demo-mcp + +# Build and run retry demo +retry: build-retry + @echo "Starting retry demo..." + {{BIN_DIR}}/demo-retry + +# Build and run permissions demo +permissions: build-permissions + @echo "Starting permissions demo..." + {{BIN_DIR}}/demo-permissions + # Build and run dangerous features example dangerous: _check-dangerous build-dangerous @echo "Starting dangerous features example..." @@ -84,6 +104,34 @@ build-subagents: cd {{PROJECT_DIR}}/examples/demo/subagents && go mod tidy && go build -o {{BIN_DIR}}/demo-subagents ./cmd/demo @echo "Built: {{BIN_DIR}}/demo-subagents" +# Build sessions demo +build-sessions: + @echo "Building sessions demo..." + @mkdir -p {{BIN_DIR}} + cd {{PROJECT_DIR}}/examples/demo/sessions && go mod tidy && go build -o {{BIN_DIR}}/demo-sessions ./cmd/demo + @echo "Built: {{BIN_DIR}}/demo-sessions" + +# Build MCP demo +build-mcp: + @echo "Building MCP demo..." + @mkdir -p {{BIN_DIR}} + cd {{PROJECT_DIR}}/examples/demo/mcp && go mod tidy && go build -o {{BIN_DIR}}/demo-mcp ./cmd/demo + @echo "Built: {{BIN_DIR}}/demo-mcp" + +# Build retry demo +build-retry: + @echo "Building retry demo..." + @mkdir -p {{BIN_DIR}} + cd {{PROJECT_DIR}}/examples/demo/retry && go mod tidy && go build -o {{BIN_DIR}}/demo-retry ./cmd/demo + @echo "Built: {{BIN_DIR}}/demo-retry" + +# Build permissions demo +build-permissions: + @echo "Building permissions demo..." + @mkdir -p {{BIN_DIR}} + cd {{PROJECT_DIR}}/examples/demo/permissions && go mod tidy && go build -o {{BIN_DIR}}/demo-permissions ./cmd/demo + @echo "Built: {{BIN_DIR}}/demo-permissions" + # Build dangerous example build-dangerous: @echo "Building dangerous example..." @@ -99,7 +147,7 @@ build-enhanced: @echo "Built: {{BIN_DIR}}/enhanced-example" # Build all demos -build-all: build-streaming build-basic build-budget build-plugins build-subagents build-dangerous build-enhanced +build-all: build-streaming build-basic build-budget build-plugins build-subagents build-sessions build-mcp build-retry build-permissions build-dangerous build-enhanced @echo "All demos built" # Check dangerous environment requirements diff --git a/.justfiles/releases.just b/.justfiles/releases.just index 79c4a6f..fd955a4 100644 --- a/.justfiles/releases.just +++ b/.justfiles/releases.just @@ -56,7 +56,7 @@ check: # Create signed git tag (use -a instead of -s if GPG not configured) tag VERSION: @echo "Creating signed tag {{VERSION}}..." - git tag -s {{VERSION}} -m "Release {{VERSION}}" + git tag -a {{VERSION}} -m "Release {{VERSION}}" @echo "Tag {{VERSION}} created" @echo "Run 'just release push' to push to remote" diff --git a/docs/gif/mcp.gif b/docs/gif/mcp.gif index 055f7b1..34a8eac 100644 Binary files a/docs/gif/mcp.gif and b/docs/gif/mcp.gif differ diff --git a/docs/gif/permissions.gif b/docs/gif/permissions.gif index f00fdef..fdb3673 100644 Binary files a/docs/gif/permissions.gif and b/docs/gif/permissions.gif differ diff --git a/docs/gif/retry.gif b/docs/gif/retry.gif index 1b0020c..f7a7b00 100644 Binary files a/docs/gif/retry.gif and b/docs/gif/retry.gif differ diff --git a/docs/gif/sessions.gif b/docs/gif/sessions.gif index ce64d11..af54110 100644 Binary files a/docs/gif/sessions.gif and b/docs/gif/sessions.gif differ diff --git a/examples/demo/mcp/cmd/demo/main.go b/examples/demo/mcp/cmd/demo/main.go index 6477fac..44f99d7 100644 --- a/examples/demo/mcp/cmd/demo/main.go +++ b/examples/demo/mcp/cmd/demo/main.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "strings" @@ -31,9 +32,47 @@ func isExitCommand(input string) bool { func displayMCPStatus() { fmt.Println("\n┌─────────────────────────────────────────────────────────────┐") - fmt.Printf("│ MCP Config: %-48s │\n", truncatePath(configFile)) - fmt.Printf("│ Strict Mode: %-47v │\n", strictMode) - fmt.Printf("│ Allowed Tools: %-45d │\n", len(allowedTools)) + + // MCP Status + if configFile != "" { + fmt.Println("│ MCP Status: 🟢 Configured │") + fmt.Printf("│ Config File: %-47s │\n", truncatePath(configFile)) + } else { + fmt.Println("│ MCP Status: ⚪ Not configured │") + fmt.Println("│ (Use /example to create sample config) │") + } + + // Strict Mode + strictIcon := "⚪" + strictDesc := "off" + if strictMode { + strictIcon = "🔒" + strictDesc = "on (MCP servers only)" + } + fmt.Printf("│ Strict Mode: %s %-44s │\n", strictIcon, strictDesc) + + // Tool Allowlist + if len(allowedTools) > 0 { + fmt.Printf("│ Allowed Tools: %-45d │\n", len(allowedTools)) + // Show first 3 tools + maxShow := 3 + for i, tool := range allowedTools { + if i >= maxShow { + remaining := len(allowedTools) - maxShow + fmt.Printf("│ ... and %d more%-42s │\n", remaining, "") + break + } + // Truncate long tool names + displayTool := tool + if len(displayTool) > 50 { + displayTool = displayTool[:47] + "..." + } + fmt.Printf("│ • %-56s │\n", displayTool) + } + } else { + fmt.Println("│ Allowed Tools: All tools enabled │") + } + fmt.Println("└─────────────────────────────────────────────────────────────┘") } @@ -60,6 +99,30 @@ func displayHelp() { fmt.Println(" exit - Exit the demo") } +func checkPrerequisites() error { + // Check for npx + fmt.Print("🔍 Checking for npx... ") + _, err := exec.LookPath("npx") + if err != nil { + fmt.Println("❌") + return fmt.Errorf("npx not found - install Node.js first") + } + fmt.Println("✅") + + // Check for Node.js + fmt.Print("🔍 Checking Node.js version... ") + cmd := exec.Command("node", "--version") + output, err := cmd.Output() + if err != nil { + fmt.Println("❌") + return fmt.Errorf("node not found") + } + fmt.Printf("✅ %s", strings.TrimSpace(string(output))) + + fmt.Println("✅ Prerequisites met") + return nil +} + func createExampleConfig() (string, error) { // Create a filesystem MCP server config config := map[string]interface{}{ @@ -112,19 +175,35 @@ func handleCommand(cmd string) bool { return true case "/example": + // Check prerequisites but don't block on failure + fmt.Println("\n📝 Creating MCP configuration...") + if err := checkPrerequisites(); err != nil { + fmt.Println("⚠️ Note: Prerequisites not met (for actual MCP server usage):") + fmt.Printf(" %v\n", err) + fmt.Println(" (Creating config anyway for demonstration)") + } else { + fmt.Println("✅ Prerequisites verified") + } + + // Always create the config, regardless of prerequisites path, err := createExampleConfig() if err != nil { fmt.Printf("✗ Failed to create example config: %v\n", err) return true } + configFile = path allowedTools = []string{ "mcp__filesystem__list_directory", "mcp__filesystem__read_file", } fmt.Printf("✓ Created example config at: %s\n", path) - fmt.Println(" Configured filesystem MCP server") - fmt.Println(" Allowed tools: list_directory, read_file") + fmt.Println(" 📦 Server: filesystem (via npx)") + fmt.Println(" 🔧 Command: npx -y @modelcontextprotocol/server-filesystem .") + fmt.Println(" 🛠️ Configured tools:") + fmt.Println(" • mcp__filesystem__list_directory") + fmt.Println(" • mcp__filesystem__read_file") + fmt.Println("\n💡 Try: 'List files in the current directory'") return true case "/strict": @@ -150,10 +229,19 @@ func handleCommand(cmd string) bool { case "/tools": fmt.Println("\n🔧 Allowed MCP Tools:") if len(allowedTools) == 0 { - fmt.Println(" (all tools allowed)") - } - for i, tool := range allowedTools { - fmt.Printf(" %d. %s\n", i+1, tool) + fmt.Println(" ⚠️ No allowlist configured - all tools are permitted") + fmt.Println("\n💡 Tip: Use /allow to restrict to specific tools") + } else { + for i, tool := range allowedTools { + // Parse tool name for better display + if strings.HasPrefix(tool, "mcp__") { + serverName := extractServerName(tool) + toolName := strings.TrimPrefix(tool, "mcp__"+serverName+"__") + fmt.Printf(" %d. 🔌 %s (server: %s)\n", i+1, toolName, serverName) + } else { + fmt.Printf(" %d. 🛠️ %s (built-in)\n", i+1, tool) + } + } } return true @@ -185,6 +273,16 @@ func handleCommand(cmd string) bool { return false } +func extractServerName(toolName string) string { + // MCP tool names follow pattern: mcp____ + // Example: mcp__filesystem__list_directory -> filesystem + parts := strings.Split(toolName, "__") + if len(parts) >= 2 { + return parts[1] + } + return "unknown" +} + func displayStreamingMessage(msg claude.Message) { switch msg.Type { case "system": @@ -203,11 +301,13 @@ func displayStreamingMessage(msg claude.Message) { } } else if itemMap["type"] == "tool_use" { if name, ok := itemMap["name"].(string); ok { - // Highlight MCP tools + // Highlight MCP tools with server info if strings.HasPrefix(name, "mcp__") { - fmt.Printf("🔌 MCP Tool: %s\n", name) + serverName := extractServerName(name) + toolName := strings.TrimPrefix(name, "mcp__"+serverName+"__") + fmt.Printf("🔌 MCP Tool: %s (server: %s)\n", toolName, serverName) } else { - fmt.Printf("🔧 Tool: %s\n", name) + fmt.Printf("🛠️ SDK Tool: %s (built-in)\n", name) } } } diff --git a/examples/demo/retry/cmd/demo/main.go b/examples/demo/retry/cmd/demo/main.go index 20fc9e5..412164b 100644 --- a/examples/demo/retry/cmd/demo/main.go +++ b/examples/demo/retry/cmd/demo/main.go @@ -17,6 +17,11 @@ var ( retryPolicy *claude.RetryPolicy timeout time.Duration useEnhanced bool + // Retry statistics + totalAttempts int + totalRetries int + totalRetryTime time.Duration + errorsEncountered []string ) func isExitCommand(input string) bool { @@ -43,6 +48,27 @@ func displayRetryStatus() { fmt.Println("└─────────────────────────────────────────────────────────────┘") } +func displayRetryStatistics() { + fmt.Println("\n📊 Cumulative Retry Statistics:") + fmt.Printf(" Total requests: %d\n", totalAttempts) + fmt.Printf(" Total retries: %d\n", totalRetries) + fmt.Printf(" Total retry time: %v\n", totalRetryTime.Round(time.Millisecond)) + if len(errorsEncountered) > 0 { + fmt.Printf(" Errors encountered: %d\n", len(errorsEncountered)) + for i, err := range errorsEncountered { + if i >= 3 { + fmt.Printf(" ... and %d more\n", len(errorsEncountered)-3) + break + } + fmt.Printf(" - %s\n", err) + } + } + if totalAttempts > 0 { + successRate := float64(totalAttempts-len(errorsEncountered)) / float64(totalAttempts) * 100 + fmt.Printf(" Success rate: %.1f%%\n", successRate) + } +} + func formatTimeout(d time.Duration) string { if d == 0 { return "none" @@ -52,18 +78,24 @@ func formatTimeout(d time.Duration) string { func displayHelp() { fmt.Println("\n📚 Retry Commands:") + fmt.Println(" /test-retry - Test retry behavior with timeout (demonstrates retries)") fmt.Println(" /retries - Set max retry attempts (0-10)") fmt.Println(" /delay - Set base delay in milliseconds") fmt.Println(" /maxdelay - Set max delay in milliseconds") fmt.Println(" /backoff - Set backoff factor (e.g., 2.0)") fmt.Println(" /timeout - Set request timeout in seconds (0=none)") - fmt.Println(" /enhanced on|off - Toggle enhanced mode (auto-retry)") + fmt.Println(" /enhanced on|off - Toggle retry visibility (default: on)") fmt.Println(" /default - Reset to default retry policy") fmt.Println(" /aggressive - Use aggressive retry (more retries, shorter delays)") fmt.Println(" /conservative - Use conservative retry (fewer retries, longer delays)") fmt.Println(" /status - Show current retry configuration") + fmt.Println(" /stats - Show cumulative retry statistics") fmt.Println(" /help - Show this help") fmt.Println(" exit - Exit the demo") + fmt.Println() + fmt.Println("💡 Tip: Use /test-retry to see retry behavior in action!") + fmt.Println(" When enhanced mode is ON, you'll see each retry attempt logged") + fmt.Println(" with delays and backoff progression.") } func handleCommand(cmd string) bool { @@ -159,7 +191,12 @@ func handleCommand(cmd string) bool { } fmt.Printf("✓ Enhanced mode: %v\n", useEnhanced) if useEnhanced { - fmt.Println(" (Uses RunPromptWithRetry with automatic retry logic)") + fmt.Println(" ✅ Retry attempts will be logged with delays and backoff") + fmt.Println(" ✅ Statistics will track retry behavior") + fmt.Println(" ✅ You'll see: attempt numbers, wait times, success/failure") + } else { + fmt.Println(" ⚠️ Retry logic will run invisibly in the SDK") + fmt.Println(" ⚠️ You won't see retry attempts when they occur") } return true @@ -196,6 +233,58 @@ func handleCommand(cmd string) bool { displayRetryStatus() return true + case "/stats": + displayRetryStatistics() + return true + + case "/test-retry": + fmt.Println("\n🧪 Testing retry behavior...") + fmt.Println(" Making a simple request to demonstrate retry instrumentation") + fmt.Println(" (Retries occur automatically when API errors happen)") + + if !useEnhanced { + fmt.Println("\n⚠️ Enhanced mode is OFF - retries won't be visible") + fmt.Println(" Run '/enhanced on' first to see retry attempts") + return true + } + + testOpts := &claude.RunOptions{ + Format: claude.StreamJSONOutput, + SystemPrompt: "You are a helpful assistant. Answer very briefly.", + AllowedTools: []string{}, + MaxTurns: 1, + } + + fmt.Println("\n🔄 Making test request with retry instrumentation enabled...") + cc := claude.NewClient("claude") + ctx := context.Background() + result, err := runWithRetry(ctx, cc, "Say 'Hello'", testOpts, retryPolicy) + + if err != nil { + fmt.Println("\n❌ Request failed (retries were attempted if error was retryable)") + fmt.Printf(" Error: %v\n", err) + fmt.Println("\n💡 The retry instrumentation showed you:") + fmt.Println(" • Each attempt number") + fmt.Println(" • Wait times between retries") + fmt.Println(" • Error classification (retryable vs non-retryable)") + } else { + fmt.Println("\n✅ Request succeeded!") + if result.Result != "" { + text := result.Result + if len(text) > 100 { + text = text[:100] + "..." + } + fmt.Printf(" Result: %s\n", text) + } + fmt.Println("\n💡 No retries were needed (request succeeded on first attempt)") + fmt.Println(" When errors DO occur, you'll see:") + fmt.Println(" • Retry attempt logging with delays") + fmt.Println(" • Exponential backoff progression") + fmt.Println(" • Success after retries or final failure") + } + + return true + case "/help": displayHelp() return true @@ -249,6 +338,80 @@ func classifyError(err error) string { return "Unknown error type" } +// runWithRetry implements retry logic with full visibility +func runWithRetry(ctx context.Context, cc *claude.ClaudeClient, prompt string, opts *claude.RunOptions, policy *claude.RetryPolicy) (*claude.ClaudeResult, error) { + totalAttempts++ + var lastErr error + attemptNum := 0 + retriesUsed := 0 + retryStartTime := time.Now() + + for attemptNum <= policy.MaxRetries { + attemptNum++ + + if attemptNum > 1 { + // Calculate backoff delay + delay := policy.BaseDelay + for i := 1; i < attemptNum-1; i++ { + delay = time.Duration(float64(delay) * policy.BackoffFactor) + if delay > policy.MaxDelay { + delay = policy.MaxDelay + break + } + } + + fmt.Printf("⏳ Waiting %v before retry (attempt %d/%d)...\n", + delay.Round(time.Millisecond), attemptNum, policy.MaxRetries+1) + time.Sleep(delay) + totalRetryTime += delay + + fmt.Printf("🔄 Retrying request (attempt %d/%d)...\n", + attemptNum, policy.MaxRetries+1) + retriesUsed++ + } else { + fmt.Println("🔄 Attempting request...") + } + + result, err := cc.RunPromptCtx(ctx, prompt, opts) + if err == nil { + // Success! + if retriesUsed > 0 { + elapsed := time.Since(retryStartTime) + fmt.Printf("✅ Success on attempt %d (%d retries needed, %v total retry time)\n", + attemptNum, retriesUsed, elapsed.Round(time.Millisecond)) + totalRetries += retriesUsed + } else { + fmt.Println("✅ Success on first attempt (no retries needed)") + } + return result, nil + } + + lastErr = err + errorMsg := err.Error() + if claudeErr, ok := err.(*claude.ClaudeError); ok { + if !claudeErr.IsRetryable() { + fmt.Printf("❌ Non-retryable error: %v\n", err) + fmt.Printf(" %s\n", classifyError(err)) + errorsEncountered = append(errorsEncountered, errorMsg) + return nil, err + } + fmt.Printf("❌ Retryable error (attempt %d): %v\n", attemptNum, err) + fmt.Printf(" %s\n", classifyError(err)) + } else { + fmt.Printf("❌ Error (attempt %d): %v\n", attemptNum, err) + } + + if attemptNum > policy.MaxRetries { + break + } + } + + fmt.Printf("❌ Failed after %d attempts (used %d retries)\n", attemptNum, retriesUsed) + totalRetries += retriesUsed + errorsEncountered = append(errorsEncountered, lastErr.Error()) + return nil, lastErr +} + func main() { fmt.Println("╔════════════════════════════════════════════════════════════╗") fmt.Println("║ Claude Code Go SDK - Retry & Error Handling Demo ║") @@ -259,13 +422,17 @@ func main() { fmt.Println(" • Jitter to prevent thundering herd") fmt.Println(" • Error classification (retryable vs non-retryable)") fmt.Println(" • Context timeout support") - fmt.Println(" • Enhanced mode with automatic retry") + fmt.Println(" • Visible retry attempts with delay progression") + fmt.Println() + fmt.Println("ℹ️ Enhanced mode is ON - retry attempts will be logged as they occur") + fmt.Println(" Retries happen automatically when API errors occur (rate limits,") + fmt.Println(" network timeouts, etc.). Each retry attempt is logged with delays.") fmt.Println() // Initialize with default policy retryPolicy = claude.DefaultRetryPolicy() timeout = 0 - useEnhanced = false + useEnhanced = true // Default to enhanced mode for retry visibility displayHelp() displayRetryStatus() @@ -316,18 +483,14 @@ func main() { start := time.Now() if useEnhanced { - // Use the enhanced retry method - fmt.Println("🔄 Using enhanced mode with automatic retry...") - result, err := cc.RunPromptWithRetryCtx(ctx, input, opts, retryPolicy) + // Use enhanced retry mode with full visibility + fmt.Println("\n🔄 Using enhanced retry mode (visible retry logic)...") + result, err := runWithRetry(ctx, cc, input, opts, retryPolicy) elapsed := time.Since(start) - if err != nil { - fmt.Printf("❌ Error after retries: %v\n", err) - fmt.Printf(" %s\n", classifyError(err)) - } else { + if err == nil { sessionID = result.SessionID - fmt.Printf("✅ Success!\n") - fmt.Printf("📊 Cost: $%.6f | Duration: %.1fs | Turns: %d\n", + fmt.Printf("\n📊 Cost: $%.6f | Duration: %.1fs | Turns: %d\n", result.CostUSD, float64(result.DurationMS)/1000.0, result.NumTurns) if result.Result != "" { // Truncate long results @@ -338,7 +501,7 @@ func main() { fmt.Printf("💬 %s\n", text) } } - fmt.Printf("⏱️ Total time: %v\n", elapsed.Round(time.Millisecond)) + fmt.Printf("\n⏱️ Total time: %v\n", elapsed.Round(time.Millisecond)) } else { // Use streaming mode messageCh, errCh := cc.StreamPrompt(ctx, input, opts) diff --git a/scripts/demo-expect/mcp.exp b/scripts/demo-expect/mcp.exp index 3168b40..cfdc887 100755 --- a/scripts/demo-expect/mcp.exp +++ b/scripts/demo-expect/mcp.exp @@ -10,20 +10,24 @@ spawn -noecho bash -c "echo '\$ just demo mcp' && sleep 0.5 && cd $env(PROJECT_D # Wait for initial display and prompt expect ">>>" -# Show help -send "/help\r" +# Create example MCP configuration +send "/example\r" expect ">>>" -# Show status +# Show status with MCP configured send "/status\r" expect ">>>" +# Show available MCP tools +send "/tools\r" +expect ">>>" + # Toggle strict mode send "/strict on\r" expect ">>>" -# Show tools -send "/tools\r" +# Show status with strict mode +send "/status\r" expect ">>>" # Exit diff --git a/scripts/demo-expect/retry.exp b/scripts/demo-expect/retry.exp index df68cca..6957d8c 100755 --- a/scripts/demo-expect/retry.exp +++ b/scripts/demo-expect/retry.exp @@ -10,11 +10,11 @@ spawn -noecho bash -c "echo '\$ just demo retry' && sleep 0.5 && cd $env(PROJECT # Wait for initial display and prompt expect ">>>" -# Show current status +# Show current status (enhanced mode should be on by default) send "/status\r" expect ">>>" -# Switch to aggressive retry policy +# Switch to aggressive retry policy for more retries send "/aggressive\r" expect ">>>" @@ -22,8 +22,12 @@ expect ">>>" send "/status\r" expect ">>>" -# Send a simple query to demonstrate retry handling -send "What is 5 times 7?\r" +# Test retry behavior with timeout to trigger real retries +send "/test-retry\r" +expect ">>>" + +# Show cumulative statistics after test +send "/stats\r" expect ">>>" # Exit