diff --git a/README.md b/README.md index 02edd0b..76a17f5 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,18 @@ Go Reference

-A comprehensive Go library for programmatically integrating the Claude Code CLI into Go applications. Build AI-powered coding assistants, automated workflows, and intelligent agents with full control over Claude Code's capabilities. +A comprehensive Go library for programmatically integrating the Claude Code CLI into Go applications. Build AI-powered coding assistants, automated workflows, and intelligent agents on top of Claude Code's non-interactive `-p/--print` surface. First Claude Code SDK, released before any official SDKs existed. +This SDK intentionally wraps the prompt-oriented `claude -p` workflow. Interactive sessions and management subcommands such as `auth`, `mcp`, `plugins`, `install`, and `update` remain out of scope. + ## Highlights -- Full CLI wrapper with text/json/stream-json outputs +- Current `claude -p` wrapper with text/json/stream-json outputs - Streaming, sessions (resume/fork), and context-aware APIs - MCP integration with fine-grained tool permissions +- Current prompt flags including agents, effort, settings, tools, and budget controls - Subagents, plugins, retries, and budget tracking for production workflows - 9 interactive demos and comprehensive tests @@ -23,7 +26,7 @@ First Claude Code SDK, released before any official SDKs existed. ### Core Capabilities -- **Full CLI Wrapper**: Complete access to all Claude Code features +- **Prompt Surface Wrapper**: Accurate coverage of the current non-interactive `claude -p` flag surface - **Streaming Support**: Real-time response streaming with context cancellation - **Session Management**: Multi-turn conversations with custom IDs, forking, and persistence control - **MCP Integration**: Model Context Protocol support for extending Claude with external tools @@ -218,8 +221,14 @@ pm.Register(filter, nil) audit := claude.NewAuditPlugin(1000) // Keep last 1000 records pm.Register(audit, nil) +ctx := context.Background() +if err := pm.Initialize(ctx); err != nil { + log.Fatal(err) +} +defer pm.Shutdown(ctx) + // Use with client -result, err := cc.RunPrompt("Do something", &claude.RunOptions{ +result, err := cc.RunPromptCtx(ctx, "Do something", &claude.RunOptions{ PluginManager: pm, }) @@ -263,20 +272,21 @@ fmt.Printf("Spent: $%.4f, Remaining: $%.4f\n", // Define specialized agents agents := map[string]*claude.SubagentConfig{ "security": { - Description: "Security analysis and vulnerability detection", - SystemPrompt: "You are a security expert. Analyze code for vulnerabilities.", - AllowedTools: []string{"Read(*)", "Grep(*)"}, - Model: "opus", + Description: "Security analysis and vulnerability detection", + Prompt: "You are a security expert. Analyze code for vulnerabilities.", + Tools: []string{"Read(*)", "Grep(*)"}, + Model: "opus", }, "testing": { - Description: "Test generation and coverage analysis", - SystemPrompt: "You are a testing expert. Generate comprehensive tests.", - AllowedTools: []string{"Read(*)", "Write(*)", "Bash(go test*)"}, + Description: "Test generation and coverage analysis", + Prompt: "You are a testing expert. Generate comprehensive tests.", + Tools: []string{"Read(*)", "Write(*)", "Bash(go test*)"}, }, } -// Use agents +// Define agents and select one for this run result, err := cc.RunPrompt("Analyze this code", &claude.RunOptions{ + Agent: "security", Agents: agents, }) ``` @@ -349,7 +359,8 @@ result, err = cc.RunPrompt("Safe operations only", &claude.RunOptions{ ```go type RunOptions struct { // Output format - Format OutputFormat // text, json, stream-json + Format OutputFormat // text, json, stream-json + InputFormat InputFormat // text, stream-json (stdin with --print) // Prompts SystemPrompt string // Override default system prompt @@ -362,6 +373,11 @@ type RunOptions struct { ForkSession bool // Fork from resumed session NoSessionPersistence bool // Don't save to disk + // Agent selection + Agent string // Select a named agent for this run + Agents map[string]*SubagentConfig // Inline agent definitions for --agents + AgentsJSON string // Raw JSON passed directly to --agents + // MCP configuration MCPConfigPath string // Single MCP config path MCPConfigs []string // Multiple MCP configs @@ -370,26 +386,41 @@ type RunOptions struct { // Tool permissions AllowedTools []string // Tools Claude can use DisallowedTools []string // Tools Claude cannot use - PermissionMode PermissionMode // default, acceptEdits, bypassPermissions + PermissionMode PermissionMode // default, acceptEdits, auto, bypassPermissions, dontAsk, plan // Model selection - Model string // Full model name - ModelAlias string // sonnet, opus, haiku - - // Execution control - MaxTurns int // Limit agentic turns - Timeout time.Duration // Request timeout - - // Budget control - MaxBudgetUSD float64 // Spending limit + Model string // Full model name + ModelAlias string // sonnet, opus, haiku + Effort EffortLevel // low, medium, high, xhigh, max + + // CLI prompt surface + MaxBudgetUSD float64 + Settings string + SettingSources []string + Tools []string + Name string + PluginDirs []string + Bare bool + Brief bool + Betas []string + Files []string + Debug string + DebugFile string + IncludeHookEvents bool + IncludePartialMessages bool + ReplayUserMessages bool + ExcludeDynamicSystemPromptSections bool + AllowDangerouslySkipPermissions bool + Timeout time.Duration + + // Lifecycle extensions BudgetTracker *BudgetTracker // Shared tracker - - // Extensions - Agents map[string]*SubagentConfig // Specialized agents - PluginManager *PluginManager // Plugin system + PluginManager *PluginManager // Plugin hooks } ``` +Deprecated compatibility fields remain in `RunOptions` for now, but the SDK no longer emits removed CLI flags such as `--max-turns`, `--config`, `--disable-autoupdate`, `--theme`, or `--permission-prompt-tool`. + ### Core Methods ```go @@ -475,7 +506,7 @@ just lint - [docs/DEMOS.md](docs/DEMOS.md) - [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) -- [docs/RELEASE_NOTES_v1.0.0.md](docs/RELEASE_NOTES_v1.0.0.md) +- [docs/RELEASE_NOTES_v1.2.0.md](docs/RELEASE_NOTES_v1.2.0.md) ## Contributing diff --git a/docs/RELEASE_NOTES_v1.0.0.md b/docs/RELEASE_NOTES_v1.0.0.md deleted file mode 100644 index 571deb6..0000000 --- a/docs/RELEASE_NOTES_v1.0.0.md +++ /dev/null @@ -1,189 +0,0 @@ -# v1.0.0 - CLI Feature Parity Release - -This major release brings the Go SDK to full feature parity with the Claude Code CLI, adding comprehensive SDK-level abstractions for building production applications. - -## Highlights - -- **Plugin System** - Extensible hooks for logging, metrics, filtering, and auditing -- **Budget Tracking** - Spending limits with warnings and callbacks -- **Session Management** - Multi-turn conversation state -- **Streaming Support** - Real-time response processing -- **Subagent Orchestration** - Spawn and manage multiple Claude instances -- **MCP Integration** - Full Model Context Protocol support -- **Structured Output** - JSON Schema validation for reliable outputs -- **Model Fallback** - Automatic fallback when primary model is overloaded -- **9 Example Programs** - Complete working examples for all features - -## New Features - -### Plugin System - -Build extensible applications with lifecycle hooks: - -```go -cc := claude.NewClient("claude") - -// Add logging plugin -logging := claude.NewLoggingPlugin(log.Printf) -cc.AddPlugin(logging) - -// Add metrics collection -metrics := claude.NewMetricsPlugin() -cc.AddPlugin(metrics) - -result, _ := cc.RunPrompt(ctx, "Hello", nil) -fmt.Printf("Total cost: $%.4f\n", metrics.TotalCost) -``` - -**Built-in Plugins:** - -- `LoggingPlugin` - Configurable logging with optional secret sanitization -- `MetricsPlugin` - Collect tool call counts, message counts, and costs -- `ToolFilterPlugin` - Block specific tools from being executed -- `AuditPlugin` - Record all tool calls for compliance auditing - -### Budget Tracking - -Control spending with configurable limits: - -```go -budget := claude.NewBudgetTracker(&claude.BudgetConfig{ - MaxBudgetUSD: 10.0, - WarningThreshold: 0.8, - OnBudgetWarning: func(current, max float64) { - log.Printf("Warning: %.0f%% of budget used", (current/max)*100) - }, -}) - -// Check before expensive operations -if budget.CanSpend(0.50) { - result, _ := cc.RunPrompt(ctx, prompt, nil) - budget.AddSpend("session1", result.CostUSD) -} -``` - -### Session Management - -Maintain conversation state across multiple turns: - -```go -sessions := claude.NewSessionManager() - -// First turn -result1, _ := cc.RunPrompt(ctx, "My name is Alice", nil) -sessions.SaveSession("chat1", result1.SessionID) - -// Later turn - resume the conversation -opts := &claude.RunOptions{ - ResumeID: sessions.GetSession("chat1"), -} -result2, _ := cc.RunPrompt(ctx, "What's my name?", opts) -// Claude remembers: "Your name is Alice" -``` - -### Streaming Support - -Process responses in real-time: - -```go -msgChan, errChan := cc.StreamPrompt(ctx, "Write a story", nil) - -for msg := range msgChan { - switch msg.Type { - case "assistant": - fmt.Print(msg.Message) // Print as it arrives - case "result": - fmt.Printf("\nCost: $%.4f\n", msg.CostUSD) - } -} -``` - -### Subagent Orchestration - -Spawn and coordinate multiple Claude instances: - -```go -manager := claude.NewSubagentManager(client, &claude.SubagentConfig{ - MaxConcurrent: 3, -}) - -tasks := []claude.SubagentTask{ - {ID: "research", Prompt: "Research topic A"}, - {ID: "analyze", Prompt: "Analyze topic B"}, - {ID: "summarize", Prompt: "Summarize topic C"}, -} - -results := manager.ExecuteAll(ctx, tasks) -for _, r := range results { - fmt.Printf("%s: %s\n", r.TaskID, r.Result) -} -``` - -### MCP Integration - -Use Model Context Protocol tools: - -```go -opts := &claude.RunOptions{ - MCPConfigPath: "./mcp-config.json", - AllowedTools: []string{ - "mcp__filesystem__read_file", - "mcp__filesystem__write_file", - }, -} - -result, _ := cc.RunPrompt(ctx, "Read config.json", opts) -``` - -### Structured Output & Reliability - -New options for production reliability: - -```go -// Structured output with JSON Schema validation -result, err := cc.RunPrompt(ctx, "Generate user profile", &claude.RunOptions{ - Format: claude.JSONOutput, - JSONSchema: `{"type":"object","properties":{"name":{"type":"string"},"age":{"type":"integer"}},"required":["name"]}`, -}) - -// Automatic fallback when primary model is overloaded -result, err := cc.RunPrompt(ctx, "Analyze code", &claude.RunOptions{ - Model: "opus", - FallbackModel: "sonnet", // Use sonnet if opus is overloaded -}) - -// Debug mode with category filtering -result, err := cc.RunPrompt(ctx, "Test", &claude.RunOptions{ - Debug: "api,mcp", // Only debug api and mcp categories -}) -``` - -## Examples - -Complete working examples are included in `examples/`: - -| Example | Description | -| -------------- | --------------------------------------------------- | -| `budget/` | Budget tracking with warnings | -| `mcp/` | MCP server integration | -| `permissions/` | Permission modes (default, accept-edits, full-auto) | -| `plugins/` | Plugin system with all built-ins | -| `retry/` | Exponential backoff with jitter | -| `sessions/` | Multi-turn conversation management | -| `streaming/` | Real-time response streaming | -| `subagents/` | Parallel agent execution | -| `workflow/` | Complex multi-step workflows | - -## Installation - -```bash -go get github.com/lancekrogers/claude-code-go@v1.0.0 -``` - -## Upgrade Notes - -This release is **fully backward compatible** with v0.1.x. No code changes required to upgrade. - -## Full Changelog - -**[v0.1.3...v1.0.0](https://github.com/lancekrogers/claude-code-go/compare/v0.1.3...v2.0.0)** diff --git a/docs/RELEASE_NOTES_v1.2.0.md b/docs/RELEASE_NOTES_v1.2.0.md new file mode 100644 index 0000000..439fa9a --- /dev/null +++ b/docs/RELEASE_NOTES_v1.2.0.md @@ -0,0 +1,59 @@ +# v1.2.0 - Prompt Surface Refresh + +This release aligns the SDK with the current wrapper-safe `claude -p` surface. + +## Highlights + +- Added current prompt-surface flags for agents, effort, settings, tools, plugin directories, budget limits, input format, hook events, partial messages, replayed user messages, debug files, bare mode, brief mode, beta headers, file resources, display names, and dynamic prompt exclusion. +- Stopped emitting removed flags such as `--permission-prompt-tool`, `--max-turns`, `--config`, `--disable-autoupdate`, and `--theme`. +- Wired plugin lifecycle hooks, tool-use callbacks, and shared budget tracking into both JSON and stream-json execution paths. +- Updated `SubagentManager` to execute through the real `--agent` and `--agents` prompt surface instead of a separate SDK-only shim. +- Rewrote the README and examples to scope the SDK honestly to `claude -p`. + +## Added Prompt Flags + +`RunOptions` now covers the current wrapper-safe prompt flags below: + +- `Agent`, `Agents`, `AgentsJSON` +- `Effort` +- `InputFormat` +- `IncludeHookEvents` +- `IncludePartialMessages` +- `ReplayUserMessages` +- `DebugFile` +- `Bare` +- `Brief` +- `Betas` +- `Files` +- `ExcludeDynamicSystemPromptSections` +- `AllowDangerouslySkipPermissions` +- `MaxBudgetUSD` +- `SettingSources` +- `Settings` +- `Tools` +- `Name` +- `PluginDirs` + +## Behavior Changes + +- Behavioral change, not source-breaking: `SubagentConfig.ToRunOptions` now targets Claude's native `--agent` and `--agents` prompt surface. The returned `RunOptions` keeps the subagent definition in `Agents` and selects it with `Agent`; it no longer populates top-level `SystemPrompt` and `AllowedTools`. Integrators upgrading from `v1.1.0` should audit call sites that inspected those top-level fields on the returned value. +- Deprecated top-level fields retained for source compatibility now emit one-time warnings when set, instead of being silently ignored at argv construction time. + +## Compatibility Notes + +- `PermissionTool`, `MaxTurns`, `ConfigFile`, `DisableAutoUpdate`, `Theme`, and `PermissionCallback` remain in `RunOptions` for source compatibility. `PreprocessOptions` emits one-time warnings when they are set, and argv construction still ignores them because the current Claude CLI no longer supports them. +- `PermissionModeDelegate` is now rejected during validation because the current Claude CLI no longer supports delegate permission mode. Unlike the deprecated top-level flags above, there is no safe warn-and-ignore fallback for a permission mode selection, so the SDK fails fast instead. +- The SDK still wraps the prompt-oriented `claude -p` workflow. Interactive sessions and management commands such as `auth`, `mcp`, `plugins`, `install`, and `update` are intentionally not wrapped here. + +## Verification + +- `just release check` +- `go test ./...` + +## Release Process + +- Publish this release with `just release publish v1.2.0` after the merge commit is on `main`. + +## Full Changelog + +**[v1.1.0...v1.2.0](https://github.com/lancekrogers/claude-code-go/compare/v1.1.0...v1.2.0)** diff --git a/examples/advanced/advanced.go b/examples/advanced/advanced.go index 669c808..57fce44 100644 --- a/examples/advanced/advanced.go +++ b/examples/advanced/advanced.go @@ -83,7 +83,6 @@ func main() { Format: claude.StreamJSONOutput, MCPConfigPath: mcpFile.Name(), AllowedTools: allowedTools, - MaxTurns: 5, }, ) diff --git a/examples/budget/main.go b/examples/budget/main.go index 457c160..abff8c8 100644 --- a/examples/budget/main.go +++ b/examples/budget/main.go @@ -31,7 +31,6 @@ func main() { opts := &claude.RunOptions{ Format: claude.JSONOutput, BudgetTracker: tracker, - MaxTurns: 1, } // Run a few prompts and track spending @@ -60,9 +59,6 @@ func main() { continue } - // Manually track spending (in production, integrate into execution) - tracker.AddSpend(result.SessionID, result.CostUSD) - fmt.Printf("Cost: $%.6f | Total: $%.6f | Remaining: $%.6f\n", result.CostUSD, tracker.TotalSpent(), tracker.RemainingBudget()) } diff --git a/examples/dangerous_usage/main.go b/examples/dangerous_usage/main.go index 08c5efb..bdedbd1 100644 --- a/examples/dangerous_usage/main.go +++ b/examples/dangerous_usage/main.go @@ -83,8 +83,8 @@ func isDevelopmentEnvironment() bool { goEnv := os.Getenv("GO_ENV") env := os.Getenv("ENVIRONMENT") - return nodeEnv != "production" && goEnv != "production" && - env != "production" && env != "prod" + return nodeEnv != "production" && goEnv != "production" && + env != "production" && env != "prod" } func createDangerousClient() (*dangerous.DangerousClient, error) { @@ -127,7 +127,7 @@ func demonstrateEnvironmentInjection(cc *dangerous.DangerousClient) error { func automatedDeploymentExample() error { // SECURITY REVIEW REQUIRED: Using dangerous Claude client for deployment // JUSTIFICATION: Automated deployment pipeline requires permission bypass - // RISK ASSESSMENT: + // RISK ASSESSMENT: // - Deployment runs in isolated container with limited network access // - Input is validated and comes from trusted CI/CD system // - Output is logged and audited @@ -135,7 +135,7 @@ func automatedDeploymentExample() error { // - Container has read-only filesystem except for deployment directories // - Network access restricted to deployment targets only // - All operations logged to security audit system - + cc, err := dangerous.NewDangerousClient("claude") if err != nil { return fmt.Errorf("deployment client creation failed: %w", err) @@ -155,9 +155,8 @@ func automatedDeploymentExample() error { // In a real deployment, you would execute the deployment prompt here // result, err := client.BYPASS_ALL_PERMISSIONS(deploymentPrompt, &claude.RunOptions{ // Format: claude.JSONOutput, - // MaxTurns: 10, // }) fmt.Println("Deployment configuration completed (actual deployment not executed in example)") return nil -} \ No newline at end of file +} diff --git a/examples/demo/budget/cmd/demo/main.go b/examples/demo/budget/cmd/demo/main.go index a64b2ec..f5c6a25 100644 --- a/examples/demo/budget/cmd/demo/main.go +++ b/examples/demo/budget/cmd/demo/main.go @@ -82,8 +82,6 @@ func displayStreamingMessage(msg claude.Message) { if msg.IsError { fmt.Printf("āŒ Error: %s\n", msg.Result) } else { - // Track the cost - budgetTracker.AddSpend(msg.SessionID, msg.CostUSD) fmt.Printf("šŸ“Š Cost: $%.6f | Duration: %.1fs | Turns: %d\n", msg.CostUSD, float64(msg.DurationMS)/1000.0, msg.NumTurns) displayBudgetStatus() @@ -134,7 +132,6 @@ func main() { "Bash(pwd)", "Bash(echo*)", }, - MaxTurns: 3, } var sessionID string diff --git a/examples/demo/mcp/cmd/demo/main.go b/examples/demo/mcp/cmd/demo/main.go index 44f99d7..57ee5cd 100644 --- a/examples/demo/mcp/cmd/demo/main.go +++ b/examples/demo/mcp/cmd/demo/main.go @@ -371,7 +371,6 @@ func main() { opts := &claude.RunOptions{ Format: claude.StreamJSONOutput, SystemPrompt: "You are a helpful assistant with access to MCP tools. Use them when appropriate.", - MaxTurns: 5, } // Add MCP configuration diff --git a/examples/demo/permissions/cmd/demo/main.go b/examples/demo/permissions/cmd/demo/main.go index 03df05f..ef2d6a4 100644 --- a/examples/demo/permissions/cmd/demo/main.go +++ b/examples/demo/permissions/cmd/demo/main.go @@ -378,7 +378,6 @@ func main() { opts := &claude.RunOptions{ Format: claude.StreamJSONOutput, SystemPrompt: "You are a helpful assistant. Keep responses concise.", - MaxTurns: 3, PermissionMode: permissionMode, } diff --git a/examples/demo/plugins/cmd/demo/main.go b/examples/demo/plugins/cmd/demo/main.go index efbe1f6..69793ef 100644 --- a/examples/demo/plugins/cmd/demo/main.go +++ b/examples/demo/plugins/cmd/demo/main.go @@ -76,25 +76,17 @@ func displayStreamingMessage(msg claude.Message) { } else if itemMap["type"] == "tool_use" { if name, ok := itemMap["name"].(string); ok { fmt.Printf("šŸ”§ Tool: %s\n", name) - // Simulate plugin hook - pluginManager.OnToolCall(context.Background(), name, claude.ToolInput{}) } } } } } } - pluginManager.OnMessage(context.Background(), msg) case "result": if msg.IsError { fmt.Printf("āŒ Error: %s\n", msg.Result) } else { fmt.Printf("āœ… Complete - Cost: $%.6f | Turns: %d\n", msg.CostUSD, msg.NumTurns) - // Trigger plugin completion hooks - pluginManager.OnComplete(context.Background(), &claude.ClaudeResult{ - CostUSD: msg.CostUSD, - NumTurns: msg.NumTurns, - }) } } } @@ -172,7 +164,6 @@ func main() { "Bash(cat*)", "Glob(*)", }, - MaxTurns: 3, } var sessionID string diff --git a/examples/demo/retry/cmd/demo/main.go b/examples/demo/retry/cmd/demo/main.go index 412164b..2b9f825 100644 --- a/examples/demo/retry/cmd/demo/main.go +++ b/examples/demo/retry/cmd/demo/main.go @@ -18,9 +18,9 @@ var ( timeout time.Duration useEnhanced bool // Retry statistics - totalAttempts int - totalRetries int - totalRetryTime time.Duration + totalAttempts int + totalRetries int + totalRetryTime time.Duration errorsEncountered []string ) @@ -252,7 +252,6 @@ func handleCommand(cmd string) bool { 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...") @@ -466,7 +465,6 @@ func main() { Format: claude.StreamJSONOutput, SystemPrompt: "You are a helpful assistant. Keep responses concise.", AllowedTools: []string{"Read(*)", "Bash(ls*)", "Bash(pwd)"}, - MaxTurns: 3, } if timeout > 0 { diff --git a/examples/demo/sessions/cmd/demo/main.go b/examples/demo/sessions/cmd/demo/main.go index 28e203e..daee444 100644 --- a/examples/demo/sessions/cmd/demo/main.go +++ b/examples/demo/sessions/cmd/demo/main.go @@ -197,7 +197,6 @@ func main() { Format: claude.StreamJSONOutput, SystemPrompt: "You are a helpful assistant. Keep responses concise.", AllowedTools: []string{"Read(*)", "Bash(ls*)", "Bash(pwd)"}, - MaxTurns: 3, } switch currentMode { diff --git a/examples/enhanced_features/main.go b/examples/enhanced_features/main.go index 9f9b79d..ff47ded 100644 --- a/examples/enhanced_features/main.go +++ b/examples/enhanced_features/main.go @@ -18,15 +18,15 @@ func main() { opts := &claude.RunOptions{ Format: claude.JSONOutput, AllowedTools: []string{ - "Bash(git log:*)", // Allow git log with any arguments - "Bash(git status)", // Allow only git status - "Read", // Allow all file reading - "Write(src/**)", // Allow writing only to src directory + "Bash(git log:*)", // Allow git log with any arguments + "Bash(git status)", // Allow only git status + "Read", // Allow all file reading + "Write(src/**)", // Allow writing only to src directory }, DisallowedTools: []string{ - "Bash(rm:*)", // Explicitly block rm commands + "Bash(rm:*)", // Explicitly block rm commands }, - ModelAlias: "sonnet", // Use model alias instead of full name + ModelAlias: "sonnet", // Use model alias instead of full name Timeout: 30 * time.Second, Verbose: true, } @@ -80,9 +80,9 @@ func main() { fmt.Println("šŸ” Example 3: Input Validation") invalidOpts := &claude.RunOptions{ AllowedTools: []string{ - "InvalidTool()", // This will trigger validation error + "InvalidTool()", // This will trigger validation error }, - ModelAlias: "invalid-model", // This will also fail validation + ModelAlias: "invalid-model", // This will also fail validation Timeout: -5 * time.Second, // Negative timeout } @@ -106,12 +106,11 @@ func main() { "Bash(git log:--oneline)", // Limited git log }, DisallowedTools: []string{ - "Bash(rm:*)", // Block destructive operations - "Bash(sudo:*)", // Block privilege escalation - "Write", // Block all writes (could be more specific) + "Bash(rm:*)", // Block destructive operations + "Bash(sudo:*)", // Block privilege escalation + "Write", // Block all writes (could be more specific) }, ModelAlias: "sonnet", - MaxTurns: 5, // Limit agentic behavior Timeout: 60 * time.Second, } @@ -121,4 +120,4 @@ func main() { } else { fmt.Println("šŸ”’ Configuration is production-ready") } -} \ No newline at end of file +} diff --git a/examples/plugins/main.go b/examples/plugins/main.go index 739cf2c..a0cfc6c 100644 --- a/examples/plugins/main.go +++ b/examples/plugins/main.go @@ -76,15 +76,12 @@ func main() { opts := &claude.RunOptions{ Format: claude.JSONOutput, PluginManager: pm, - MaxTurns: 1, } result, err := cc.RunPrompt("What is 5+5?", opts) if err != nil { log.Printf("Error: %v", err) } else { - // Manually trigger OnComplete for demonstration - pm.OnComplete(ctx, result) fmt.Printf("Result: %s\n", result.Result) } diff --git a/examples/subagents/main.go b/examples/subagents/main.go index f48f430..a8ee449 100644 --- a/examples/subagents/main.go +++ b/examples/subagents/main.go @@ -192,7 +192,6 @@ Provide practical examples and explain the "Go way" of solving problems.`, // Create parent options with budget tracking and permissions parentOpts := &claude.RunOptions{ - MaxTurns: 3, BudgetTracker: claude.NewBudgetTracker(&claude.BudgetConfig{MaxBudgetUSD: 1.00}), PermissionMode: claude.PermissionModeDefault, } @@ -202,8 +201,7 @@ Provide practical examples and explain the "Go way" of solving problems.`, fmt.Println(" - Budget tracking (from parent)") fmt.Println(" - Permission mode (from parent)") fmt.Println(" - MCP config path (from parent)") - fmt.Println(" - Max turns (uses subagent's if specified, otherwise parent's)") - fmt.Println(" - Model (uses subagent's if specified, otherwise parent's)") + fmt.Println(" - Top-level model settings (when the selected agent does not override them)") // This would run with inherited settings: // result, err := manager.RunAgent(ctx, "security", "Review this code", parentOpts) diff --git a/examples/workflow/main.go b/examples/workflow/main.go index 59f7cff..3b93940 100644 --- a/examples/workflow/main.go +++ b/examples/workflow/main.go @@ -66,14 +66,12 @@ func main() { "Bash(sudo:*)", "Bash(curl|sh:*)", }, - MaxTurns: 10, // Limit iterations - Timeout: 5 * time.Minute, // Prevent runaway tasks + Timeout: 5 * time.Minute, // Prevent runaway tasks } fmt.Println("CI/CD Configuration:") fmt.Printf(" Permission Mode: %s\n", ciOpts.PermissionMode) fmt.Printf(" Allowed Tools: %v\n", ciOpts.AllowedTools) - fmt.Printf(" Max Turns: %d\n", ciOpts.MaxTurns) fmt.Printf(" Timeout: %v\n", ciOpts.Timeout) fmt.Println() @@ -200,7 +198,6 @@ Batch processing pattern for CI: for i, task := range tasks { result, err := client.RunPromptCtx(ctx, task, &claude.RunOptions{ Format: claude.JSONOutput, - MaxTurns: 5, Timeout: 2 * time.Minute, }) @@ -385,7 +382,6 @@ Budget control for CI pipelines: Format: claude.JSONOutput, PermissionMode: claude.PermissionModeAcceptEdits, MaxBudgetUSD: *maxCost, - MaxTurns: 10, AllowedTools: []string{ "Read", "Grep", "Glob", "Bash(go:*)", "Bash(git:*)", @@ -465,12 +461,11 @@ Exit Codes: - 5: Rate limit Best Practices: -1. Always set MaxTurns to prevent infinite loops -2. Always set Timeout for pipeline predictability -3. Use BudgetTracker to control costs -4. Use AllowedTools to restrict operations -5. Log all costs for billing visibility -6. Handle errors with proper exit codes -7. Output structured data for downstream tools -8. Use ephemeral sessions (NoSessionPersistence) for stateless CI`) +1. Always set Timeout for pipeline predictability +2. Use BudgetTracker to control costs +3. Use AllowedTools to restrict operations +4. Log all costs for billing visibility +5. Handle errors with proper exit codes +6. Output structured data for downstream tools +7. Use ephemeral sessions (NoSessionPersistence) for stateless CI`) } diff --git a/pkg/claude/budget.go b/pkg/claude/budget.go index 6702d92..0631e00 100644 --- a/pkg/claude/budget.go +++ b/pkg/claude/budget.go @@ -81,32 +81,45 @@ func (bt *BudgetTracker) CanSpend(amount float64) bool { // AddSpend adds spending to the tracker and returns an error if budget is exceeded func (bt *BudgetTracker) AddSpend(sessionID string, amount float64) error { bt.mu.Lock() - defer bt.mu.Unlock() bt.totalSpent += amount bt.sessionSpent[sessionID] += amount + var warningCallback func(current, max float64) + var warningCurrent, warningMax float64 + var exceededCallback func(current, max float64) + var exceededCurrent, exceededMax float64 + var resultErr error + // Check warning threshold if bt.config.MaxBudgetUSD > 0 && bt.config.WarningThreshold > 0 && !bt.warningEmitted { warningAmount := bt.config.MaxBudgetUSD * bt.config.WarningThreshold if bt.totalSpent >= warningAmount { bt.warningEmitted = true - if bt.config.OnBudgetWarning != nil { - // Call callback outside of lock to prevent deadlocks - go bt.config.OnBudgetWarning(bt.totalSpent, bt.config.MaxBudgetUSD) - } + warningCallback = bt.config.OnBudgetWarning + warningCurrent = bt.totalSpent + warningMax = bt.config.MaxBudgetUSD } } // Check if budget exceeded if bt.config.MaxBudgetUSD > 0 && bt.totalSpent > bt.config.MaxBudgetUSD { - if bt.config.OnBudgetExceeded != nil { - go bt.config.OnBudgetExceeded(bt.totalSpent, bt.config.MaxBudgetUSD) - } - return ErrBudgetExceeded + exceededCallback = bt.config.OnBudgetExceeded + exceededCurrent = bt.totalSpent + exceededMax = bt.config.MaxBudgetUSD + resultErr = ErrBudgetExceeded + } + + bt.mu.Unlock() + + if warningCallback != nil { + warningCallback(warningCurrent, warningMax) + } + if exceededCallback != nil { + exceededCallback(exceededCurrent, exceededMax) } - return nil + return resultErr } // Reset resets the tracker to zero spending diff --git a/pkg/claude/budget_test.go b/pkg/claude/budget_test.go index dacf4b9..e46c930 100644 --- a/pkg/claude/budget_test.go +++ b/pkg/claude/budget_test.go @@ -73,11 +73,9 @@ func TestBudgetTracker_AddSpend(t *testing.T) { }) _ = bt.AddSpend("session1", 6.0) // 60% of budget, exceeds 50% threshold - // Give goroutine time to execute - for i := 0; i < 100 && !warningCalled; i++ { - // Small busy wait for callback + if !warningCalled { + t.Fatal("expected warning callback to be invoked") } - // Note: In real tests we'd use channels or sync primitives }) t.Run("exceeded callback", func(t *testing.T) { @@ -90,9 +88,8 @@ func TestBudgetTracker_AddSpend(t *testing.T) { }) _ = bt.AddSpend("session1", 6.0) - // Give goroutine time to execute - for i := 0; i < 100 && !exceededCalled; i++ { - // Small busy wait for callback + if !exceededCalled { + t.Fatal("expected exceeded callback to be invoked") } }) } diff --git a/pkg/claude/claude.go b/pkg/claude/claude.go index 11f1c86..499765d 100644 --- a/pkg/claude/claude.go +++ b/pkg/claude/claude.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os/exec" + "strconv" "strings" "time" ) @@ -50,35 +51,54 @@ func (c *ClaudeClient) RunPrompt(prompt string, opts *RunOptions) (*ClaudeResult return c.RunPromptCtx(context.Background(), prompt, opts) } -// RunPromptCtx executes a prompt with Claude Code and returns the result with context support -func (c *ClaudeClient) RunPromptCtx(ctx context.Context, prompt string, opts *RunOptions) (*ClaudeResult, error) { +func (c *ClaudeClient) resolveRunOptions(opts *RunOptions) *RunOptions { if opts == nil { opts = c.DefaultOptions } + return cloneRunOptions(opts) +} + +func (c *ClaudeClient) prepareRunOptions(opts *RunOptions) (*RunOptions, error) { + prepared := c.resolveRunOptions(opts) + if err := PreprocessOptions(prepared); err != nil { + return nil, err + } + return prepared, nil +} - // Preprocess and validate options - if err := PreprocessOptions(opts); err != nil { +// RunPromptCtx executes a prompt with Claude Code and returns the result with context support +func (c *ClaudeClient) RunPromptCtx(ctx context.Context, prompt string, opts *RunOptions) (*ClaudeResult, error) { + preparedOpts, err := c.prepareRunOptions(opts) + if err != nil { return nil, err } + if preparedOpts.PluginManager != nil && preparedOpts.Format == JSONOutput { + return c.runPromptWithStructuredHooks(ctx, prompt, nil, preparedOpts) + } + // Add timeout support if specified - if opts.Timeout > 0 { + if preparedOpts.Timeout > 0 { var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, opts.Timeout) + ctx, cancel = context.WithTimeout(ctx, preparedOpts.Timeout) defer cancel() } - args := BuildArgs(prompt, opts) + args := BuildArgs(prompt, preparedOpts) + + if err := ensurePluginManagerInitialized(ctx, preparedOpts.PluginManager); err != nil { + return nil, err + } cmd := execCommand(ctx, c.BinPath, args...) - if opts.WorkingDirectory != "" { - cmd.Dir = opts.WorkingDirectory + if preparedOpts.WorkingDirectory != "" { + cmd.Dir = preparedOpts.WorkingDirectory } var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr - err := cmd.Run() + err = cmd.Run() if err != nil { // Enhanced error parsing var exitCode int @@ -93,53 +113,32 @@ func (c *ClaudeClient) RunPromptCtx(ctx context.Context, prompt string, opts *Ru return nil, claudeErr } - if opts.Format == JSONOutput { - result, err := parseJSONResponse(stdout.Bytes()) + if preparedOpts.Format == JSONOutput { + messages, result, err := parseJSONTranscript(stdout.Bytes()) if err != nil { return nil, err } + if err := applyExecutionHooks(ctx, preparedOpts, messages, result); err != nil { + return result, err + } return result, nil } // For text output, just return the raw text - return &ClaudeResult{ + result := &ClaudeResult{ Result: stdout.String(), IsError: false, - }, nil + } + if err := applyCompletionHooks(ctx, preparedOpts, result); err != nil { + return result, err + } + return result, nil } // parseJSONResponse handles both array and single-object JSON formats from Claude CLI func parseJSONResponse(data []byte) (*ClaudeResult, error) { - // Claude CLI now returns a JSON array of messages - // We need to find the "result" type message - var messages []Message - if err := json.Unmarshal(data, &messages); err != nil { - // Try single object for backwards compatibility - var res ClaudeResult - if err2 := json.Unmarshal(data, &res); err2 != nil { - return nil, NewClaudeError(ErrorValidation, fmt.Sprintf("failed to parse JSON response: %v", err)) - } - return &res, nil - } - - // Find the result message in the array - for _, msg := range messages { - if msg.Type == "result" { - return &ClaudeResult{ - Type: msg.Type, - Subtype: msg.Subtype, - Result: msg.Result, - CostUSD: msg.CostUSD, - DurationMS: msg.DurationMS, - DurationAPIMS: msg.DurationAPIMS, - IsError: msg.IsError, - NumTurns: msg.NumTurns, - SessionID: msg.SessionID, - }, nil - } - } - - return nil, NewClaudeError(ErrorValidation, "no result message found in JSON response") + _, result, err := parseJSONTranscript(data) + return result, err } // RunFromStdin runs Claude Code with input from stdin @@ -149,34 +148,38 @@ func (c *ClaudeClient) RunFromStdin(stdin io.Reader, prompt string, opts *RunOpt // RunFromStdinCtx runs Claude Code with input from stdin with context support func (c *ClaudeClient) RunFromStdinCtx(ctx context.Context, stdin io.Reader, prompt string, opts *RunOptions) (*ClaudeResult, error) { - if opts == nil { - opts = c.DefaultOptions + preparedOpts, err := c.prepareRunOptions(opts) + if err != nil { + return nil, err } - // Preprocess and validate options - if err := PreprocessOptions(opts); err != nil { - return nil, err + if preparedOpts.PluginManager != nil && preparedOpts.Format == JSONOutput { + return c.runPromptWithStructuredHooks(ctx, prompt, stdin, preparedOpts) } // Add timeout support if specified - if opts.Timeout > 0 { + if preparedOpts.Timeout > 0 { var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, opts.Timeout) + ctx, cancel = context.WithTimeout(ctx, preparedOpts.Timeout) defer cancel() } - args := BuildArgs(prompt, opts) + args := BuildArgs(prompt, preparedOpts) + + if err := ensurePluginManagerInitialized(ctx, preparedOpts.PluginManager); err != nil { + return nil, err + } cmd := execCommand(ctx, c.BinPath, args...) - if opts.WorkingDirectory != "" { - cmd.Dir = opts.WorkingDirectory + if preparedOpts.WorkingDirectory != "" { + cmd.Dir = preparedOpts.WorkingDirectory } cmd.Stdin = stdin var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr - err := cmd.Run() + err = cmd.Run() if err != nil { // Enhanced error parsing var exitCode int @@ -191,19 +194,26 @@ func (c *ClaudeClient) RunFromStdinCtx(ctx context.Context, stdin io.Reader, pro return nil, claudeErr } - if opts.Format == JSONOutput { - result, err := parseJSONResponse(stdout.Bytes()) + if preparedOpts.Format == JSONOutput { + messages, result, err := parseJSONTranscript(stdout.Bytes()) if err != nil { return nil, err } + if err := applyExecutionHooks(ctx, preparedOpts, messages, result); err != nil { + return result, err + } return result, nil } // For text output, just return the raw text - return &ClaudeResult{ + result := &ClaudeResult{ Result: stdout.String(), IsError: false, - }, nil + } + if err := applyCompletionHooks(ctx, preparedOpts, result); err != nil { + return result, err + } + return result, nil } // BuildArgs constructs the command-line arguments for Claude Code @@ -220,6 +230,22 @@ func BuildArgs(prompt string, opts *RunOptions) []string { args = append(args, "--output-format", string(opts.Format)) } + if opts.Agent != "" { + args = append(args, "--agent", opts.Agent) + } + + if opts.AgentsJSON != "" { + args = append(args, "--agents", opts.AgentsJSON) + } else if len(opts.Agents) > 0 { + if data, err := jsonMarshalAgents(opts.Agents); err == nil { + args = append(args, "--agents", data) + } + } + + if opts.AllowDangerouslySkipPermissions { + args = append(args, "--allow-dangerously-skip-permissions") + } + if opts.SystemPrompt != "" { args = append(args, "--system-prompt", opts.SystemPrompt) } @@ -240,10 +266,6 @@ func BuildArgs(prompt string, opts *RunOptions) []string { args = append(args, "--disallowedTools", strings.Join(opts.DisallowedTools, ",")) } - if opts.PermissionTool != "" { - args = append(args, "--permission-prompt-tool", opts.PermissionTool) - } - // Permission mode if opts.PermissionMode != "" { args = append(args, "--permission-mode", string(opts.PermissionMode)) @@ -255,10 +277,6 @@ func BuildArgs(prompt string, opts *RunOptions) []string { args = append(args, "--continue") } - if opts.MaxTurns > 0 { - args = append(args, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns)) - } - if opts.Verbose { args = append(args, "--verbose") } @@ -270,9 +288,8 @@ func BuildArgs(prompt string, opts *RunOptions) []string { args = append(args, "--model", opts.Model) } - // Configuration file - if opts.ConfigFile != "" { - args = append(args, "--config", opts.ConfigFile) + if opts.Effort != "" { + args = append(args, "--effort", string(opts.Effort)) } // Help flag @@ -285,14 +302,52 @@ func BuildArgs(prompt string, opts *RunOptions) []string { args = append(args, "--version") } - // Disable autoupdate - if opts.DisableAutoUpdate { - args = append(args, "--disable-autoupdate") + if opts.InputFormat != "" { + args = append(args, "--input-format", string(opts.InputFormat)) + } + + if opts.IncludeHookEvents { + args = append(args, "--include-hook-events") + } + + if opts.IncludePartialMessages { + args = append(args, "--include-partial-messages") + } + + if opts.ReplayUserMessages { + args = append(args, "--replay-user-messages") + } + + if opts.DebugFile != "" { + args = append(args, "--debug-file", opts.DebugFile) + } + + if opts.Bare { + args = append(args, "--bare") + } + + if opts.Brief { + args = append(args, "--brief") + } + + if len(opts.Betas) > 0 { + // Variadic Cobra flag: one --betas flag followed by one or more values. + args = append(args, "--betas") + args = append(args, opts.Betas...) + } + + if len(opts.Files) > 0 { + // Variadic Cobra flag: one --file flag followed by one or more specs. + args = append(args, "--file") + args = append(args, opts.Files...) + } + + if opts.ExcludeDynamicSystemPromptSections { + args = append(args, "--exclude-dynamic-system-prompt-sections") } - // Theme - if opts.Theme != "" { - args = append(args, "--theme", opts.Theme) + if opts.MaxBudgetUSD > 0 { + args = append(args, "--max-budget-usd", strconv.FormatFloat(opts.MaxBudgetUSD, 'f', -1, 64)) } // Session control flags @@ -323,6 +378,26 @@ func BuildArgs(prompt string, opts *RunOptions) []string { args = append(args, "--add-dir", dir) } + if len(opts.SettingSources) > 0 { + args = append(args, "--setting-sources", strings.Join(opts.SettingSources, ",")) + } + + if opts.Settings != "" { + args = append(args, "--settings", opts.Settings) + } + + if len(opts.Tools) > 0 { + args = append(args, "--tools", strings.Join(opts.Tools, ",")) + } + + if opts.Name != "" { + args = append(args, "--name", opts.Name) + } + + for _, dir := range opts.PluginDirs { + args = append(args, "--plugin-dir", dir) + } + // Print mode if opts.PrintMode { args = append(args, "--print") @@ -346,6 +421,14 @@ func BuildArgs(prompt string, opts *RunOptions) []string { return args } +func jsonMarshalAgents(agents map[string]*SubagentConfig) (string, error) { + data, err := json.Marshal(agents) + if err != nil { + return "", err + } + return string(data), nil +} + // RunWithMCP is a convenience method for running Claude with MCP configuration func (c *ClaudeClient) RunWithMCP(prompt string, mcpConfigPath string, allowedTools []string) (*ClaudeResult, error) { return c.RunWithMCPCtx(context.Background(), prompt, mcpConfigPath, allowedTools) diff --git a/pkg/claude/claude_test.go b/pkg/claude/claude_test.go index f14efdb..f793ceb 100644 --- a/pkg/claude/claude_test.go +++ b/pkg/claude/claude_test.go @@ -430,31 +430,37 @@ func TestBuildArgs(t *testing.T) { name: "All options", prompt: "Complete test", opts: &RunOptions{ - Format: JSONOutput, - SystemPrompt: "Custom system prompt", - AppendPrompt: "Additional instructions", - MCPConfigPath: "/path/to/mcp.json", - AllowedTools: []string{"tool1", "tool2"}, - DisallowedTools: []string{"bad1", "bad2"}, - PermissionTool: "permit_tool", - ResumeID: "session123", - MaxTurns: 5, - Verbose: true, - Model: "claude-3-5-sonnet-20240620", + Format: JSONOutput, + Agent: "reviewer", + AgentsJSON: `{"reviewer":{"description":"Reviews code","prompt":"You are a reviewer"}}`, + SystemPrompt: "Custom system prompt", + AppendPrompt: "Additional instructions", + MCPConfigPath: "/path/to/mcp.json", + AllowedTools: []string{"tool1", "tool2"}, + DisallowedTools: []string{ + "bad1", "bad2", + }, + ResumeID: "session123", + Verbose: true, + Model: "claude-3-5-sonnet-20240620", + Effort: EffortHigh, + Name: "review-session", }, expected: []string{ "-p", "Complete test", "--output-format", "json", + "--agent", "reviewer", + "--agents", `{"reviewer":{"description":"Reviews code","prompt":"You are a reviewer"}}`, "--system-prompt", "Custom system prompt", "--append-system-prompt", "Additional instructions", "--mcp-config", "/path/to/mcp.json", "--allowedTools", "tool1,tool2", "--disallowedTools", "bad1,bad2", - "--permission-prompt-tool", "permit_tool", "--resume", "session123", - "--max-turns", "5", "--verbose", "--model", "claude-3-5-sonnet-20240620", + "--effort", "high", + "--name", "review-session", }, }, { @@ -862,6 +868,28 @@ func TestBuildArgs_EdgeCases(t *testing.T) { } } +func TestBuildArgs_VariadicFlagShape(t *testing.T) { + args := BuildArgs("test", &RunOptions{ + Betas: []string{"beta-a", "beta-b"}, + Files: []string{"file_abc:doc.txt", "file_def:img.png"}, + }) + + expected := []string{ + "-p", "test", + "--betas", "beta-a", "beta-b", + "--file", "file_abc:doc.txt", "file_def:img.png", + } + + if len(args) != len(expected) { + t.Fatalf("Expected %d arguments, got %d: %v", len(expected), len(args), args) + } + for i, arg := range expected { + if args[i] != arg { + t.Fatalf("Expected arg[%d] = %q, got %q", i, arg, args[i]) + } + } +} + func TestBuildArgs_NewFlags(t *testing.T) { tests := []struct { name string @@ -869,50 +897,79 @@ func TestBuildArgs_NewFlags(t *testing.T) { expected []string }{ { - name: "Config file flag", + name: "Current print surface flags", opts: &RunOptions{ - ConfigFile: "/path/to/config.json", + Agent: "security", + AgentsJSON: `{"security":{"description":"Security review","prompt":"Review for vulnerabilities"}}`, + AllowDangerouslySkipPermissions: true, + Effort: EffortXHigh, + InputFormat: StreamJSONInput, + IncludeHookEvents: true, + IncludePartialMessages: true, + ReplayUserMessages: true, + DebugFile: "/tmp/claude-debug.log", }, - expected: []string{"-p", "test", "--config", "/path/to/config.json"}, - }, - { - name: "Help flag", - opts: &RunOptions{ - Help: true, + expected: []string{ + "-p", "test", + "--agent", "security", + "--agents", `{"security":{"description":"Security review","prompt":"Review for vulnerabilities"}}`, + "--allow-dangerously-skip-permissions", + "--effort", "xhigh", + "--input-format", "stream-json", + "--include-hook-events", + "--include-partial-messages", + "--replay-user-messages", + "--debug-file", "/tmp/claude-debug.log", }, - expected: []string{"-p", "test", "--help"}, }, { - name: "Version flag", + name: "Help and version flags", opts: &RunOptions{ + Help: true, Version: true, }, - expected: []string{"-p", "test", "--version"}, + expected: []string{"-p", "test", "--help", "--version"}, }, { - name: "Disable autoupdate flag", + name: "Context shaping flags", opts: &RunOptions{ - DisableAutoUpdate: true, + Bare: true, + Brief: true, + Betas: []string{"beta-a", "beta-b"}, + Files: []string{"file_abc:doc.txt", "file_def:img.png"}, + ExcludeDynamicSystemPromptSections: true, }, - expected: []string{"-p", "test", "--disable-autoupdate"}, - }, - { - name: "Theme flag", - opts: &RunOptions{ - Theme: "dark", + expected: []string{ + "-p", "test", + "--bare", + "--brief", + "--betas", "beta-a", "beta-b", + "--file", "file_abc:doc.txt", "file_def:img.png", + "--exclude-dynamic-system-prompt-sections", }, - expected: []string{"-p", "test", "--theme", "dark"}, }, { - name: "All new flags combined", + name: "Settings and tool surface flags", opts: &RunOptions{ - ConfigFile: "/config.json", - Help: true, - Version: true, - DisableAutoUpdate: true, - Theme: "light", + MaxBudgetUSD: 12.5, + SettingSources: []string{ + "user", "project", + }, + Settings: `{"env":{"FOO":"bar"}}`, + Tools: []string{"Bash", "Read", "Edit"}, + Name: "release-0-1-1", + PluginDirs: []string{"/plugins/a", "/plugins/b"}, + }, + expected: []string{ + "-p", "test", + "--max-budget-usd", "12.5", + "--setting-sources", "user,project", + "--settings", `{"env":{"FOO":"bar"}}`, + "--tools", "Bash,Read,Edit", + "--name", "release-0-1-1", + "--plugin-dir", "/plugins/a", + "--plugin-dir", "/plugins/b", }, - expected: []string{"-p", "test", "--config", "/config.json", "--help", "--version", "--disable-autoupdate", "--theme", "light"}, }, } @@ -937,6 +994,39 @@ func TestBuildArgs_NewFlags(t *testing.T) { } } +func TestRunPromptCtx_DoesNotMutateDefaultOptions(t *testing.T) { + originalExecCommand := execCommand + defer func() { + execCommand = originalExecCommand + }() + + jsonOutput := `{"type":"result","subtype":"success","total_cost_usd":0.001,"duration_ms":1,"duration_api_ms":1,"is_error":false,"num_turns":1,"result":"ok","session_id":"opts-session"}` + execCommand = mockExecCommandContext(t, []string{ + "-p", "Mutation test", "--output-format", "json", "--allowedTools", "Read", "--disallowedTools", "Write", + }, jsonOutput, 0) + + defaultOpts := &RunOptions{ + Format: JSONOutput, + AllowedTools: []string{"Read"}, + DisallowedTools: []string{"Write"}, + } + client := &ClaudeClient{ + BinPath: "claude", + DefaultOptions: defaultOpts, + } + + if _, err := client.RunPromptCtx(context.Background(), "Mutation test", nil); err != nil { + t.Fatalf("RunPromptCtx() error = %v", err) + } + + if defaultOpts.ParsedAllowedTools != nil { + t.Fatalf("ParsedAllowedTools mutated on caller options: %v", defaultOpts.ParsedAllowedTools) + } + if defaultOpts.ParsedDisallowedTools != nil { + t.Fatalf("ParsedDisallowedTools mutated on caller options: %v", defaultOpts.ParsedDisallowedTools) + } +} + // Helper for command failure tests func TestHelperProcessError(t *testing.T) { if os.Getenv("GO_WANT_HELPER_PROCESS_ERROR") != "1" { diff --git a/pkg/claude/dangerous/README.md b/pkg/claude/dangerous/README.md index c079a87..43a0080 100644 --- a/pkg/claude/dangerous/README.md +++ b/pkg/claude/dangerous/README.md @@ -146,8 +146,7 @@ func deployApplication() error { // Execute deployment with bypassed permissions return cc.BYPASS_ALL_PERMISSIONS("Deploy the application", &claude.RunOptions{ - Format: claude.JSONOutput, - MaxTurns: 5, + Format: claude.JSONOutput, }) } ``` diff --git a/pkg/claude/enhanced_test.go b/pkg/claude/enhanced_test.go index 89e0b2b..bca0d36 100644 --- a/pkg/claude/enhanced_test.go +++ b/pkg/claude/enhanced_test.go @@ -1,6 +1,9 @@ package claude import ( + "context" + "fmt" + "sync" "testing" "time" ) @@ -84,6 +87,131 @@ func TestPreprocessOptions(t *testing.T) { } } +func TestPreprocessOptions_WarnsOnDeprecatedFields(t *testing.T) { + originalWarningf := deprecatedOptionWarningf + deprecatedOptionWarningf = func(format string, args ...interface{}) {} + defer func() { + deprecatedOptionWarningf = originalWarningf + deprecatedOptionWarned = sync.Map{} + }() + + tests := []struct { + name string + opts *RunOptions + field string + }{ + { + name: "PermissionTool", + opts: &RunOptions{PermissionTool: "perm-tool"}, + field: "PermissionTool", + }, + { + name: "MaxTurns", + opts: &RunOptions{MaxTurns: 3}, + field: "MaxTurns", + }, + { + name: "ConfigFile", + opts: &RunOptions{ConfigFile: "config.json"}, + field: "ConfigFile", + }, + { + name: "DisableAutoUpdate", + opts: &RunOptions{DisableAutoUpdate: true}, + field: "DisableAutoUpdate", + }, + { + name: "Theme", + opts: &RunOptions{Theme: "dark"}, + field: "Theme", + }, + { + name: "PermissionCallback", + opts: &RunOptions{ + PermissionCallback: func(ctx context.Context, toolName string, input ToolInput) (PermissionResult, error) { + return Allow(), nil + }, + }, + field: "PermissionCallback", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var warnings []string + deprecatedOptionWarned = sync.Map{} + deprecatedOptionWarningf = func(format string, args ...interface{}) { + warnings = append(warnings, fmt.Sprintf(format, args...)) + } + + err := PreprocessOptions(tt.opts) + if err != nil { + t.Fatalf("Expected no validation error but got %v", err) + } + if len(warnings) != 1 { + t.Fatalf("Expected 1 warning, got %d: %v", len(warnings), warnings) + } + if !containsSubstring(warnings[0], tt.field) { + t.Fatalf("Expected warning to mention %q, got %v", tt.field, warnings[0]) + } + + // Log-once behavior for repeated use of the same deprecated field. + if err := PreprocessOptions(tt.opts); err != nil { + t.Fatalf("Expected no validation error on repeat but got %v", err) + } + if len(warnings) != 1 { + t.Fatalf("Expected warning count to stay at 1, got %d: %v", len(warnings), warnings) + } + }) + } +} + +func TestPreprocessOptions_AllowsInternalStructuredStreamFlags(t *testing.T) { + pm := NewPluginManager() + if err := pm.Register(&BasePlugin{PluginName: "noop", PluginVersion: "test"}, nil); err != nil { + t.Fatalf("Register() error = %v", err) + } + + tests := []struct { + name string + opts *RunOptions + }{ + { + name: "IncludeHookEvents", + opts: &RunOptions{ + Format: JSONOutput, + PluginManager: pm, + IncludeHookEvents: true, + }, + }, + { + name: "IncludePartialMessages", + opts: &RunOptions{ + Format: JSONOutput, + PluginManager: pm, + IncludePartialMessages: true, + }, + }, + { + name: "ReplayUserMessages", + opts: &RunOptions{ + Format: JSONOutput, + PluginManager: pm, + InputFormat: StreamJSONInput, + ReplayUserMessages: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := PreprocessOptions(tt.opts); err != nil { + t.Fatalf("PreprocessOptions() error = %v", err) + } + }) + } +} + func TestIsValidModelAlias(t *testing.T) { tests := []struct { alias string diff --git a/pkg/claude/execution_hooks.go b/pkg/claude/execution_hooks.go new file mode 100644 index 0000000..a042f84 --- /dev/null +++ b/pkg/claude/execution_hooks.go @@ -0,0 +1,191 @@ +package claude + +import ( + "context" + "encoding/json" + "fmt" +) + +func parseJSONTranscript(data []byte) ([]Message, *ClaudeResult, error) { + var messages []Message + if err := json.Unmarshal(data, &messages); err == nil { + result, resultErr := extractResultFromMessages(messages) + if resultErr != nil { + return nil, nil, resultErr + } + return messages, result, nil + } + + var result ClaudeResult + if err := json.Unmarshal(data, &result); err != nil { + return nil, nil, NewClaudeError(ErrorValidation, fmt.Sprintf("failed to parse JSON response: %v", err)) + } + + return nil, &result, nil +} + +func extractResultFromMessages(messages []Message) (*ClaudeResult, error) { + for _, msg := range messages { + if msg.Type != "result" { + continue + } + + return messageToResult(msg), nil + } + + return nil, NewClaudeError(ErrorValidation, "no result message found in JSON response") +} + +func messageToResult(msg Message) *ClaudeResult { + return &ClaudeResult{ + Type: msg.Type, + Subtype: msg.Subtype, + Result: msg.Result, + CostUSD: msg.CostUSD, + DurationMS: msg.DurationMS, + DurationAPIMS: msg.DurationAPIMS, + IsError: msg.IsError, + NumTurns: msg.NumTurns, + SessionID: msg.SessionID, + } +} + +func applyExecutionHooks(ctx context.Context, opts *RunOptions, messages []Message, result *ClaudeResult) error { + if opts == nil { + return nil + } + + for _, msg := range messages { + if err := applyMessageHooks(ctx, opts, msg); err != nil { + return err + } + } + + return applyCompletionHooks(ctx, opts, result) +} + +func applyMessageHooks(ctx context.Context, opts *RunOptions, msg Message) error { + if opts == nil || opts.PluginManager == nil { + return nil + } + + if err := opts.PluginManager.OnMessage(ctx, msg); err != nil { + return err + } + + return emitToolUseHooks(ctx, opts.PluginManager, msg) +} + +func applyCompletionHooks(ctx context.Context, opts *RunOptions, result *ClaudeResult) error { + if opts == nil || result == nil { + return nil + } + + if tracker := opts.BudgetTracker; tracker != nil && result.CostUSD > 0 { + if err := tracker.AddSpend(result.SessionID, result.CostUSD); err != nil { + return err + } + } + + if opts.PluginManager != nil { + if err := opts.PluginManager.OnComplete(ctx, result); err != nil { + return err + } + } + + return nil +} + +func ensurePluginManagerInitialized(ctx context.Context, pm *PluginManager) error { + if pm == nil { + return nil + } + + return pm.Initialize(ctx) +} + +type toolUseCall struct { + Name string + Input ToolInput +} + +func emitToolUseHooks(ctx context.Context, pm *PluginManager, msg Message) error { + if pm == nil || len(msg.Message) == 0 { + return nil + } + + toolCalls, err := extractToolUses(msg.Message) + if err != nil { + return err + } + + for _, call := range toolCalls { + if err := pm.OnToolCall(ctx, call.Name, call.Input); err != nil { + return err + } + } + + return nil +} + +func extractToolUses(raw json.RawMessage) ([]toolUseCall, error) { + var envelope map[string]interface{} + if err := json.Unmarshal(raw, &envelope); err != nil { + return nil, nil + } + + content, ok := envelope["content"].([]interface{}) + if !ok { + return nil, nil + } + + toolCalls := make([]toolUseCall, 0) + for _, item := range content { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + if itemType, _ := itemMap["type"].(string); itemType != "tool_use" { + continue + } + + name, _ := itemMap["name"].(string) + inputMap, _ := itemMap["input"].(map[string]interface{}) + toolCalls = append(toolCalls, toolUseCall{ + Name: name, + Input: decodeToolInput(inputMap), + }) + } + + return toolCalls, nil +} + +func decodeToolInput(raw map[string]interface{}) ToolInput { + if raw == nil { + return ToolInput{} + } + + input := ToolInput{Raw: raw} + input.Command = firstString(raw, "command") + input.FilePath = firstString(raw, "file_path", "filePath", "path") + input.Pattern = firstString(raw, "pattern") + input.Content = firstString(raw, "content") + input.OldString = firstString(raw, "old_string", "oldString") + input.NewString = firstString(raw, "new_string", "newString") + + return input +} + +func firstString(raw map[string]interface{}, keys ...string) string { + for _, key := range keys { + value, ok := raw[key] + if !ok { + continue + } + if s, ok := value.(string); ok { + return s + } + } + + return "" +} diff --git a/pkg/claude/execution_hooks_test.go b/pkg/claude/execution_hooks_test.go new file mode 100644 index 0000000..f33f2b1 --- /dev/null +++ b/pkg/claude/execution_hooks_test.go @@ -0,0 +1,490 @@ +package claude + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" +) + +type lifecyclePlugin struct { + mu sync.Mutex + initCount int + shutdownCount int + messageCount int + completeCount int + toolCalls []string + lastToolInput ToolInput + lastResultCost float64 +} + +func (p *lifecyclePlugin) Name() string { return "lifecycle" } +func (p *lifecyclePlugin) Version() string { return "test" } + +func (p *lifecyclePlugin) Initialize(ctx context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + p.initCount++ + return nil +} + +func (p *lifecyclePlugin) OnToolCall(ctx context.Context, toolName string, input ToolInput) error { + p.mu.Lock() + defer p.mu.Unlock() + p.toolCalls = append(p.toolCalls, toolName) + p.lastToolInput = input + return nil +} + +func (p *lifecyclePlugin) OnMessage(ctx context.Context, msg Message) error { + p.mu.Lock() + defer p.mu.Unlock() + p.messageCount++ + return nil +} + +func (p *lifecyclePlugin) OnComplete(ctx context.Context, result *ClaudeResult) error { + p.mu.Lock() + defer p.mu.Unlock() + p.completeCount++ + p.lastResultCost = result.CostUSD + return nil +} + +func (p *lifecyclePlugin) Shutdown(ctx context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + p.shutdownCount++ + return nil +} + +func TestRunPromptCtx_AppliesLifecycleHooks(t *testing.T) { + originalExecCommand := execCommand + defer func() { + execCommand = originalExecCommand + }() + + jsonOutput := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"pwd"}}]},"session_id":"hook-session"}` + "\n" + + `{"type":"result","subtype":"success","total_cost_usd":0.25,"duration_ms":12,"duration_api_ms":8,"is_error":false,"num_turns":1,"result":"done","session_id":"hook-session"}` + "\n" + execCommand = mockExecCommandContext(t, []string{"-p", "Hook test", "--output-format", "stream-json", "--verbose"}, jsonOutput, 0) + + pm := NewPluginManager() + plugin := &lifecyclePlugin{} + if err := pm.Register(plugin, nil); err != nil { + t.Fatalf("Register() error = %v", err) + } + + tracker := NewBudgetTracker(&BudgetConfig{MaxBudgetUSD: 1.0}) + client := NewClient("claude") + + result, err := client.RunPromptCtx(context.Background(), "Hook test", &RunOptions{ + Format: JSONOutput, + PluginManager: pm, + BudgetTracker: tracker, + }) + if err != nil { + t.Fatalf("RunPromptCtx() error = %v", err) + } + + if result.Result != "done" { + t.Fatalf("Result = %q, want %q", result.Result, "done") + } + if tracker.TotalSpent() != 0.25 { + t.Fatalf("TotalSpent = %f, want %f", tracker.TotalSpent(), 0.25) + } + if tracker.SessionSpent("hook-session") != 0.25 { + t.Fatalf("SessionSpent = %f, want %f", tracker.SessionSpent("hook-session"), 0.25) + } + + plugin.mu.Lock() + defer plugin.mu.Unlock() + + if plugin.initCount != 1 { + t.Fatalf("Initialize count = %d, want 1", plugin.initCount) + } + if plugin.shutdownCount != 0 { + t.Fatalf("Shutdown count = %d, want 0", plugin.shutdownCount) + } + if plugin.messageCount != 2 { + t.Fatalf("Message count = %d, want 2", plugin.messageCount) + } + if plugin.completeCount != 1 { + t.Fatalf("Complete count = %d, want 1", plugin.completeCount) + } + if len(plugin.toolCalls) != 1 || plugin.toolCalls[0] != "Bash" { + t.Fatalf("Tool calls = %v, want [Bash]", plugin.toolCalls) + } + if plugin.lastToolInput.Command != "pwd" { + t.Fatalf("Tool command = %q, want %q", plugin.lastToolInput.Command, "pwd") + } + if plugin.lastResultCost != 0.25 { + t.Fatalf("Result cost = %f, want %f", plugin.lastResultCost, 0.25) + } +} + +func TestRunPromptCtx_DoesNotShutdownPreinitializedPluginManager(t *testing.T) { + originalExecCommand := execCommand + defer func() { + execCommand = originalExecCommand + }() + + jsonOutput := `{"type":"result","subtype":"success","total_cost_usd":0.01,"duration_ms":5,"duration_api_ms":5,"is_error":false,"num_turns":1,"result":"ok","session_id":"sticky-session"}` + "\n" + execCommand = mockExecCommandContext(t, []string{"-p", "Sticky hooks", "--output-format", "stream-json", "--verbose"}, jsonOutput, 0) + + pm := NewPluginManager() + plugin := &lifecyclePlugin{} + if err := pm.Register(plugin, nil); err != nil { + t.Fatalf("Register() error = %v", err) + } + if err := pm.Initialize(context.Background()); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + defer func() { + _ = pm.Shutdown(context.Background()) + }() + + client := NewClient("claude") + if _, err := client.RunPromptCtx(context.Background(), "Sticky hooks", &RunOptions{ + Format: JSONOutput, + PluginManager: pm, + }); err != nil { + t.Fatalf("RunPromptCtx() error = %v", err) + } + + plugin.mu.Lock() + defer plugin.mu.Unlock() + + if plugin.initCount != 1 { + t.Fatalf("Initialize count = %d, want 1", plugin.initCount) + } + if plugin.shutdownCount != 0 { + t.Fatalf("Shutdown count = %d, want 0", plugin.shutdownCount) + } +} + +func TestRunPromptCtx_ReusesLazyInitializedPluginManager(t *testing.T) { + originalExecCommand := execCommand + defer func() { + execCommand = originalExecCommand + }() + + jsonOutput := `{"type":"result","subtype":"success","total_cost_usd":0.01,"duration_ms":5,"duration_api_ms":5,"is_error":false,"num_turns":1,"result":"ok","session_id":"sticky-session"}` + "\n" + execCommand = mockExecCommandContext(t, []string{"-p", "Sticky hooks", "--output-format", "stream-json", "--verbose"}, jsonOutput, 0) + + pm := NewPluginManager() + plugin := &lifecyclePlugin{} + if err := pm.Register(plugin, nil); err != nil { + t.Fatalf("Register() error = %v", err) + } + + client := NewClient("claude") + for i := 0; i < 2; i++ { + if _, err := client.RunPromptCtx(context.Background(), "Sticky hooks", &RunOptions{ + Format: JSONOutput, + PluginManager: pm, + }); err != nil { + t.Fatalf("RunPromptCtx() error = %v", err) + } + } + + plugin.mu.Lock() + defer plugin.mu.Unlock() + + if plugin.initCount != 1 { + t.Fatalf("Initialize count = %d, want 1", plugin.initCount) + } + if plugin.shutdownCount != 0 { + t.Fatalf("Shutdown count = %d, want 0", plugin.shutdownCount) + } +} + +func TestRunFromStdinCtx_AppliesLifecycleHooks(t *testing.T) { + originalExecCommand := execCommand + defer func() { + execCommand = originalExecCommand + }() + + jsonOutput := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"README.md"}}]},"session_id":"stdin-hook-session"}` + "\n" + + `{"type":"result","subtype":"success","total_cost_usd":0.05,"duration_ms":7,"duration_api_ms":5,"is_error":false,"num_turns":1,"result":"stdin-done","session_id":"stdin-hook-session"}` + "\n" + execCommand = mockExecCommandContext(t, []string{"-p", "Hook stdin", "--output-format", "stream-json", "--verbose"}, jsonOutput, 0) + + pm := NewPluginManager() + plugin := &lifecyclePlugin{} + if err := pm.Register(plugin, nil); err != nil { + t.Fatalf("Register() error = %v", err) + } + + client := NewClient("claude") + result, err := client.RunFromStdinCtx(context.Background(), strings.NewReader("stdin body"), "Hook stdin", &RunOptions{ + Format: JSONOutput, + PluginManager: pm, + }) + if err != nil { + t.Fatalf("RunFromStdinCtx() error = %v", err) + } + if result.Result != "stdin-done" { + t.Fatalf("Result = %q, want %q", result.Result, "stdin-done") + } + + plugin.mu.Lock() + defer plugin.mu.Unlock() + + if plugin.initCount != 1 { + t.Fatalf("Initialize count = %d, want 1", plugin.initCount) + } + if plugin.shutdownCount != 0 { + t.Fatalf("Shutdown count = %d, want 0", plugin.shutdownCount) + } + if plugin.messageCount != 2 { + t.Fatalf("Message count = %d, want 2", plugin.messageCount) + } + if plugin.completeCount != 1 { + t.Fatalf("Complete count = %d, want 1", plugin.completeCount) + } + if len(plugin.toolCalls) != 1 || plugin.toolCalls[0] != "Read" { + t.Fatalf("Tool calls = %v, want [Read]", plugin.toolCalls) + } + if plugin.lastToolInput.FilePath != "README.md" { + t.Fatalf("Tool file path = %q, want %q", plugin.lastToolInput.FilePath, "README.md") + } +} + +func TestRunPromptCtx_BudgetExceededReturnsResultAndError(t *testing.T) { + originalExecCommand := execCommand + defer func() { + execCommand = originalExecCommand + }() + + jsonOutput := `{"type":"result","subtype":"success","total_cost_usd":0.25,"duration_ms":12,"duration_api_ms":8,"is_error":false,"num_turns":1,"result":"done","session_id":"budget-session"}` + execCommand = mockExecCommandContext(t, []string{"-p", "Budget test", "--output-format", "json"}, jsonOutput, 0) + + tracker := NewBudgetTracker(&BudgetConfig{MaxBudgetUSD: 0.10}) + client := NewClient("claude") + + result, err := client.RunPromptCtx(context.Background(), "Budget test", &RunOptions{ + Format: JSONOutput, + BudgetTracker: tracker, + }) + if !errors.Is(err, ErrBudgetExceeded) { + t.Fatalf("RunPromptCtx() error = %v, want ErrBudgetExceeded", err) + } + if result == nil { + t.Fatal("RunPromptCtx() result = nil, want result") + } + if result.Result != "done" { + t.Fatalf("Result = %q, want %q", result.Result, "done") + } + if tracker.TotalSpent() != 0.25 { + t.Fatalf("TotalSpent = %f, want %f", tracker.TotalSpent(), 0.25) + } +} + +func TestStreamPrompt_AppliesLifecycleHooks(t *testing.T) { + originalExecCommand := execCommand + defer func() { + execCommand = originalExecCommand + }() + + mockBinary := buildStreamingMockBinary(t) + execCommand = func(ctx context.Context, name string, arg ...string) *exec.Cmd { + return exec.Command(mockBinary) + } + + pm := NewPluginManager() + plugin := &lifecyclePlugin{} + if err := pm.Register(plugin, nil); err != nil { + t.Fatalf("Register() error = %v", err) + } + + tracker := NewBudgetTracker(&BudgetConfig{MaxBudgetUSD: 1.0}) + client := NewClient("claude") + messageCh, errCh := client.StreamPrompt(context.Background(), "Stream hooks", &RunOptions{ + PluginManager: pm, + BudgetTracker: tracker, + }) + + var gotMessages []Message + for msg := range messageCh { + gotMessages = append(gotMessages, msg) + } + for err := range errCh { + if err != nil { + t.Fatalf("StreamPrompt() error = %v", err) + } + } + + if len(gotMessages) != 2 { + t.Fatalf("Expected 2 streamed messages, got %d", len(gotMessages)) + } + if tracker.TotalSpent() != 0.4 { + t.Fatalf("TotalSpent = %f, want %f", tracker.TotalSpent(), 0.4) + } + + plugin.mu.Lock() + defer plugin.mu.Unlock() + + if plugin.initCount != 1 { + t.Fatalf("Initialize count = %d, want 1", plugin.initCount) + } + if plugin.shutdownCount != 0 { + t.Fatalf("Shutdown count = %d, want 0", plugin.shutdownCount) + } + if plugin.messageCount != 2 { + t.Fatalf("Message count = %d, want 2", plugin.messageCount) + } + if plugin.completeCount != 1 { + t.Fatalf("Complete count = %d, want 1", plugin.completeCount) + } + if len(plugin.toolCalls) != 1 || plugin.toolCalls[0] != "Read" { + t.Fatalf("Tool calls = %v, want [Read]", plugin.toolCalls) + } + if plugin.lastToolInput.FilePath != "README.md" { + t.Fatalf("Tool file path = %q, want %q", plugin.lastToolInput.FilePath, "README.md") + } +} + +func TestStreamPrompt_BudgetExceededReturnsResultBeforeError(t *testing.T) { + originalExecCommand := execCommand + defer func() { + execCommand = originalExecCommand + }() + + mockBinary := buildStreamingMockBinary(t) + execCommand = func(ctx context.Context, name string, arg ...string) *exec.Cmd { + return exec.Command(mockBinary) + } + + tracker := NewBudgetTracker(&BudgetConfig{MaxBudgetUSD: 0.10}) + client := NewClient("claude") + messageCh, errCh := client.StreamPrompt(context.Background(), "Stream hooks", &RunOptions{ + BudgetTracker: tracker, + }) + + var gotMessages []Message + for msg := range messageCh { + gotMessages = append(gotMessages, msg) + } + + var gotErr error + for err := range errCh { + if err != nil { + gotErr = err + } + } + + if !errors.Is(gotErr, ErrBudgetExceeded) { + t.Fatalf("StreamPrompt() error = %v, want ErrBudgetExceeded", gotErr) + } + if len(gotMessages) != 2 { + t.Fatalf("Expected 2 streamed messages, got %d", len(gotMessages)) + } + if gotMessages[len(gotMessages)-1].Type != "result" { + t.Fatalf("Last streamed message type = %q, want %q", gotMessages[len(gotMessages)-1].Type, "result") + } + if tracker.TotalSpent() != 0.4 { + t.Fatalf("TotalSpent = %f, want %f", tracker.TotalSpent(), 0.4) + } +} + +func TestRunPromptCtx_HandlesLargeStreamJSONMessages(t *testing.T) { + originalExecCommand := execCommand + defer func() { + execCommand = originalExecCommand + }() + + mockBinary := buildLargeResultMockBinary(t, 128*1024) + execCommand = func(ctx context.Context, name string, arg ...string) *exec.Cmd { + return exec.Command(mockBinary) + } + + pm := NewPluginManager() + if err := pm.Register(&BasePlugin{PluginName: "noop", PluginVersion: "test"}, nil); err != nil { + t.Fatalf("Register() error = %v", err) + } + + client := NewClient("claude") + result, err := client.RunPromptCtx(context.Background(), "Large message", &RunOptions{ + Format: JSONOutput, + PluginManager: pm, + }) + if err != nil { + t.Fatalf("RunPromptCtx() error = %v", err) + } + if len(result.Result) != 128*1024 { + t.Fatalf("Result length = %d, want %d", len(result.Result), 128*1024) + } +} + +func buildStreamingMockBinary(t *testing.T) string { + t.Helper() + + tempDir := t.TempDir() + mockSource := filepath.Join(tempDir, "mock_stream.go") + mockBinary := filepath.Join(tempDir, "mock_stream") + + source := `package main +import "fmt" +func main() { + fmt.Println(` + "`" + `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"README.md"}}]},"session_id":"stream-session"}` + "`" + `) + fmt.Println(` + "`" + `{"type":"result","subtype":"success","total_cost_usd":0.4,"duration_ms":20,"duration_api_ms":15,"is_error":false,"num_turns":1,"result":"stream-done","session_id":"stream-session"}` + "`" + `) +} +` + + if err := os.WriteFile(mockSource, []byte(source), 0o755); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + build := exec.Command("go", "build", "-o", mockBinary, mockSource) + if err := build.Run(); err != nil { + t.Fatalf("go build error = %v", err) + } + + return mockBinary +} + +func buildLargeResultMockBinary(t *testing.T, size int) string { + t.Helper() + + tempDir := t.TempDir() + mockSource := filepath.Join(tempDir, "mock_large_stream.go") + mockBinary := filepath.Join(tempDir, "mock_large_stream") + + source := `package main +import ( + "encoding/json" + "os" + "strings" +) +func main() { + payload := strings.Repeat("a", ` + fmt.Sprintf("%d", size) + `) + msg := map[string]any{ + "type": "result", + "subtype": "success", + "total_cost_usd": 0.01, + "duration_ms": 1, + "duration_api_ms": 1, + "is_error": false, + "num_turns": 1, + "result": payload, + "session_id": "large-session", + } + _ = json.NewEncoder(os.Stdout).Encode(msg) +} +` + + if err := os.WriteFile(mockSource, []byte(source), 0o755); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + build := exec.Command("go", "build", "-o", mockBinary, mockSource) + if err := build.Run(); err != nil { + t.Fatalf("go build error = %v", err) + } + + return mockBinary +} diff --git a/pkg/claude/options.go b/pkg/claude/options.go index d659304..ec4cbe7 100644 --- a/pkg/claude/options.go +++ b/pkg/claude/options.go @@ -3,9 +3,11 @@ package claude import ( cryptorand "crypto/rand" "fmt" + "log" "math" "math/rand" "strings" + "sync" "time" ) @@ -21,6 +23,35 @@ const ( StreamJSONOutput OutputFormat = "stream-json" ) +// InputFormat defines the input format for Claude Code requests. +type InputFormat string + +const ( + // TextInput sends plain text on stdin. + TextInput InputFormat = "text" + // StreamJSONInput sends streaming JSON events on stdin. + StreamJSONInput InputFormat = "stream-json" +) + +// EffortLevel defines the Claude reasoning effort for the current session. +type EffortLevel string + +const ( + // EffortLow uses the least reasoning budget. + EffortLow EffortLevel = "low" + // EffortMedium uses the default reasoning budget. + EffortMedium EffortLevel = "medium" + // EffortHigh uses an elevated reasoning budget. + EffortHigh EffortLevel = "high" + // EffortXHigh uses a very high reasoning budget. + EffortXHigh EffortLevel = "xhigh" + // EffortMax uses the highest available reasoning budget. + EffortMax EffortLevel = "max" +) + +var deprecatedOptionWarningf = log.Printf +var deprecatedOptionWarned sync.Map + // RunOptions configures how Claude Code is executed type RunOptions struct { // Format specifies the output format (text, json, stream-json) @@ -37,43 +68,81 @@ type RunOptions struct { // DisallowedTools is a list of tools that Claude is not allowed to use // Supports both legacy format ("Bash") and enhanced format ("Bash(git log:*)") DisallowedTools []string - // PermissionTool is the MCP tool for handling permission prompts + // Deprecated: Claude Code no longer exposes --permission-prompt-tool on the + // current CLI surface. Setting this field emits a one-time warning and is + // ignored during argument construction. PermissionTool string // ResumeID is the session ID to resume ResumeID string // Continue indicates whether to continue the most recent conversation Continue bool - // MaxTurns limits the number of agentic turns in non-interactive mode + // Deprecated: Claude Code no longer exposes --max-turns on the current CLI + // surface. Setting this field emits a one-time warning and is ignored during + // argument construction. MaxTurns int // Verbose enables verbose logging Verbose bool // Model specifies the model to use (full model name) Model string + // Agent selects the active named agent for the current request + Agent string // Enhanced options for 100% CLI support // ModelAlias specifies model using alias ("sonnet", "opus", "haiku") ModelAlias string + // Effort specifies the reasoning effort level for the current session + Effort EffortLevel // Timeout specifies the maximum duration for command execution Timeout time.Duration - // ConfigFile specifies path to Claude configuration file + // Deprecated: Claude Code no longer exposes --config on the current CLI + // surface. Setting this field emits a one-time warning and is ignored during + // argument construction. ConfigFile string // Help shows help information Help bool // Version shows version information Version bool - // DisableAutoUpdate disables automatic updates + // Deprecated: Claude Code no longer exposes --disable-autoupdate on the + // current CLI surface. Setting this field emits a one-time warning and is + // ignored during argument construction. DisableAutoUpdate bool - // Theme specifies the UI theme + // Deprecated: Claude Code no longer exposes --theme on the current CLI + // surface. Setting this field emits a one-time warning and is ignored during + // argument construction. Theme string + // InputFormat specifies stdin input mode for print runs + InputFormat InputFormat + // IncludeHookEvents includes hook lifecycle events in stream-json output + IncludeHookEvents bool // IncludePartialMessages enables streaming of partial message chunks as they arrive // Only works with --print and --output-format=stream-json IncludePartialMessages bool + // ReplayUserMessages re-emits user messages on stdout when using stream-json IO + ReplayUserMessages bool + // DebugFile writes debug logs to a specific file path + DebugFile string + // Bare enables Claude's minimal mode + Bare bool + // Brief enables the SendUserMessage tool for agent-to-user communication + Brief bool + // Betas includes beta headers in API requests + Betas []string + // Files downloads file resources at startup in file_id:path form + Files []string + // ExcludeDynamicSystemPromptSections moves machine-specific system sections + // into the first user message for better cache reuse + ExcludeDynamicSystemPromptSections bool + // AllowDangerouslySkipPermissions enables the bypass option without enabling + // it by default for the session + AllowDangerouslySkipPermissions bool // PermissionMode controls default permission handling - // "default" - standard checks, "acceptEdits" - auto-approve edits, "bypassPermissions" - skip all + // Supported values: "default", "acceptEdits", "auto", "bypassPermissions", "dontAsk", and "plan" PermissionMode PermissionMode // PermissionCallback is called before each tool use to determine permission - // If nil, default behavior based on PermissionMode is used + // Deprecated: the current Claude CLI no longer exposes a wrapper-safe + // permission callback injection point. Setting this field emits a one-time + // warning and is ignored during argument construction. PermissionCallback PermissionCallback `json:"-"` // MaxBudgetUSD sets the maximum spending limit in USD @@ -87,13 +156,16 @@ type RunOptions struct { // Each agent has its own description, prompt, allowed tools, and model // The main agent uses descriptions to decide which subagent to invoke Agents map[string]*SubagentConfig `json:"-"` + // AgentsJSON provides a raw JSON string for --agents. If set, it takes + // precedence over Agents. + AgentsJSON string // PluginManager manages plugins that hook into the execution lifecycle // Plugins can intercept tool calls, messages, and completion events PluginManager *PluginManager `json:"-"` - // Parsed tool permissions (computed from AllowedTools/DisallowedTools) - // This field is populated automatically and should not be set directly + // Parsed tool permissions (computed on internal processed copies of + // RunOptions used during execution). Callers should not set these directly. ParsedAllowedTools []ToolPermission `json:"-"` ParsedDisallowedTools []ToolPermission `json:"-"` @@ -119,6 +191,16 @@ type RunOptions struct { // Additional CLI flags // AddDirectories specifies additional directories to include in context AddDirectories []string + // SettingSources controls which setting sources Claude loads + SettingSources []string + // Settings specifies a settings file path or inline JSON string + Settings string + // Tools limits the available built-in tool set + Tools []string + // Name sets a display name for the current session + Name string + // PluginDirs loads plugins from one or more directories + PluginDirs []string // WorkingDirectory sets the process working directory (cmd.Dir) for the // Claude CLI subprocess. If empty, the subprocess inherits the parent // process's current directory (backward compatible). Must be an @@ -165,6 +247,8 @@ func PreprocessOptions(opts *RunOptions) error { return nil } + warnDeprecatedOptions(opts) + // Validate and parse allowed tools if len(opts.AllowedTools) > 0 { parsed, err := ParseToolPermissions(opts.AllowedTools) @@ -200,11 +284,45 @@ func PreprocessOptions(opts *RunOptions) error { } } + // Validate effort level + if opts.Effort != "" && !isValidEffortLevel(opts.Effort) { + return NewValidationError("Invalid effort level", "Effort", opts.Effort) + } + + // Validate input format + if opts.InputFormat != "" && !isValidInputFormat(opts.InputFormat) { + return NewValidationError("Invalid input format", "InputFormat", opts.InputFormat) + } + + // Validate permission mode + if opts.PermissionMode != "" { + if opts.PermissionMode == PermissionModeDelegate { + return NewValidationError("PermissionModeDelegate is deprecated and no longer supported by the Claude CLI", "PermissionMode", opts.PermissionMode) + } + if !isValidPermissionMode(opts.PermissionMode) { + return NewValidationError("Invalid permission mode", "PermissionMode", opts.PermissionMode) + } + } + + // Validate settings sources + if len(opts.SettingSources) > 0 { + for _, source := range opts.SettingSources { + if !isValidSettingSource(source) { + return NewValidationError("Invalid setting source", "SettingSources", opts.SettingSources) + } + } + } + // Validate timeout if opts.Timeout < 0 { return NewValidationError("Timeout cannot be negative", "Timeout", opts.Timeout) } + // Validate budget limit + if opts.MaxBudgetUSD < 0 { + return NewValidationError("MaxBudgetUSD cannot be negative", "MaxBudgetUSD", opts.MaxBudgetUSD) + } + // Validate session ID format if provided if opts.ResumeID != "" { if !isValidSessionID(opts.ResumeID) { @@ -221,6 +339,9 @@ func PreprocessOptions(opts *RunOptions) error { // Validate subagent configurations if len(opts.Agents) > 0 { + if opts.AgentsJSON != "" { + return NewValidationError("Agents and AgentsJSON are mutually exclusive", "AgentsJSON", opts.AgentsJSON) + } for name, config := range opts.Agents { if config == nil { return NewValidationError("Subagent config cannot be nil", "Agents", name) @@ -231,9 +352,54 @@ func PreprocessOptions(opts *RunOptions) error { } } + // Validate stream-specific combinations + if opts.IncludeHookEvents && !supportsStreamJSONOutput(opts) { + return NewValidationError("IncludeHookEvents requires stream-json output", "IncludeHookEvents", opts.IncludeHookEvents) + } + if opts.IncludePartialMessages && !supportsStreamJSONOutput(opts) { + return NewValidationError("IncludePartialMessages requires stream-json output", "IncludePartialMessages", opts.IncludePartialMessages) + } + if opts.ReplayUserMessages { + if !supportsStreamJSONOutput(opts) || opts.InputFormat != StreamJSONInput { + return NewValidationError("ReplayUserMessages requires stream-json input and output", "ReplayUserMessages", opts.ReplayUserMessages) + } + } + return nil } +func supportsStreamJSONOutput(opts *RunOptions) bool { + return opts.Format == StreamJSONOutput || (opts.Format == JSONOutput && opts.PluginManager != nil) +} + +func warnDeprecatedOptions(opts *RunOptions) { + if opts.PermissionTool != "" { + warnDeprecatedOption("PermissionTool") + } + if opts.MaxTurns != 0 { + warnDeprecatedOption("MaxTurns") + } + if opts.ConfigFile != "" { + warnDeprecatedOption("ConfigFile") + } + if opts.DisableAutoUpdate { + warnDeprecatedOption("DisableAutoUpdate") + } + if opts.Theme != "" { + warnDeprecatedOption("Theme") + } + if opts.PermissionCallback != nil { + warnDeprecatedOption("PermissionCallback") + } +} + +func warnDeprecatedOption(field string) { + if _, loaded := deprecatedOptionWarned.LoadOrStore(field, true); loaded { + return + } + deprecatedOptionWarningf("claude: RunOptions.%s is deprecated, ignored by argv construction, and no longer supported by the current Claude CLI", field) +} + // isValidModelAlias checks if the model alias is supported func isValidModelAlias(alias string) bool { validAliases := []string{"sonnet", "opus", "haiku"} @@ -245,6 +411,43 @@ func isValidModelAlias(alias string) bool { return false } +func isValidEffortLevel(level EffortLevel) bool { + validLevels := []EffortLevel{EffortLow, EffortMedium, EffortHigh, EffortXHigh, EffortMax} + for _, valid := range validLevels { + if level == valid { + return true + } + } + return false +} + +func isValidInputFormat(format InputFormat) bool { + return format == TextInput || format == StreamJSONInput +} + +func isValidPermissionMode(mode PermissionMode) bool { + switch mode { + case PermissionModeDefault, + PermissionModeAcceptEdits, + PermissionModeAuto, + PermissionModeBypassPermissions, + PermissionModeDontAsk, + PermissionModePlan: + return true + default: + return false + } +} + +func isValidSettingSource(source string) bool { + switch source { + case "user", "project", "local": + return true + default: + return false + } +} + // isValidSessionID validates session ID format (should be UUID-like) func isValidSessionID(sessionID string) bool { // Be more lenient with session ID validation to avoid breaking existing usage diff --git a/pkg/claude/permissions.go b/pkg/claude/permissions.go index e4ede8c..d5f24c3 100644 --- a/pkg/claude/permissions.go +++ b/pkg/claude/permissions.go @@ -59,9 +59,12 @@ const ( PermissionModeDefault PermissionMode = "default" // PermissionModeAcceptEdits auto-approves file edit operations PermissionModeAcceptEdits PermissionMode = "acceptEdits" + // PermissionModeAuto enables Claude's current automatic permission behavior. + PermissionModeAuto PermissionMode = "auto" // PermissionModeBypassPermissions skips all permission checks (use with caution) PermissionModeBypassPermissions PermissionMode = "bypassPermissions" - // PermissionModeDelegate delegates permission decisions to external handler + // Deprecated: Claude Code no longer supports delegate permission mode on the + // current CLI surface. PermissionModeDelegate PermissionMode = "delegate" // PermissionModeDontAsk doesn't ask for permissions, proceeds automatically PermissionModeDontAsk PermissionMode = "dontAsk" diff --git a/pkg/claude/plugin.go b/pkg/claude/plugin.go index 1a1d86b..52dff76 100644 --- a/pkg/claude/plugin.go +++ b/pkg/claude/plugin.go @@ -18,7 +18,8 @@ type Plugin interface { Name() string // Version returns the plugin version string Version() string - // Initialize is called once when the plugin is registered + // Initialize is called once when the plugin manager is initialized, + // either explicitly or lazily on first use. Initialize(ctx context.Context) error // OnToolCall is called before each tool execution // Return an error to abort the tool call @@ -27,7 +28,7 @@ type Plugin interface { OnMessage(ctx context.Context, msg Message) error // OnComplete is called when execution finishes successfully OnComplete(ctx context.Context, result *ClaudeResult) error - // Shutdown is called when the plugin manager is closed + // Shutdown is called when the plugin manager is explicitly closed. Shutdown(ctx context.Context) error } diff --git a/pkg/claude/streaming.go b/pkg/claude/streaming.go index 4d4f6f4..0a851db 100644 --- a/pkg/claude/streaming.go +++ b/pkg/claude/streaming.go @@ -1,14 +1,8 @@ package claude import ( - "bufio" - "bytes" "context" "encoding/json" - "fmt" - "io" - "os/exec" - "strings" ) // Message represents a message from Claude Code in streaming mode @@ -40,91 +34,45 @@ func (c *ClaudeClient) StreamPrompt(ctx context.Context, prompt string, opts *Ru opts = c.DefaultOptions } - // Force stream-json format for streaming - streamOpts := *opts - streamOpts.Format = StreamJSONOutput - - // Claude CLI requires --verbose when using --output-format=stream-json with --print - streamOpts.Verbose = true - - args := BuildArgs(prompt, &streamOpts) + streamOpts, err := buildStreamJSONRunOptions(opts) + if err != nil { + errCh <- err + close(messageCh) + close(errCh) + return messageCh, errCh + } go func() { defer close(messageCh) defer close(errCh) - // Create a custom command that supports context - cmd := execCommand(ctx, c.BinPath, args...) - if streamOpts.WorkingDirectory != "" { - cmd.Dir = streamOpts.WorkingDirectory - } - - stdout, err := cmd.StdoutPipe() - if err != nil { - errCh <- fmt.Errorf("failed to get stdout pipe: %w", err) - return - } - - stderr, err := cmd.StderrPipe() - if err != nil { - errCh <- fmt.Errorf("failed to get stderr pipe: %w", err) - return - } - - // Start capturing stderr in a goroutine - stderrBuf := new(bytes.Buffer) - go func() { - _, _ = io.Copy(stderrBuf, stderr) - }() - - if err := cmd.Start(); err != nil { - errCh <- fmt.Errorf("failed to start command: %w", err) - return + runCtx := ctx + var cancel context.CancelFunc + if streamOpts.Timeout > 0 { + runCtx, cancel = context.WithTimeout(ctx, streamOpts.Timeout) + } else { + runCtx, cancel = context.WithCancel(ctx) } + defer cancel() - scanner := bufio.NewScanner(stdout) - for scanner.Scan() { - line := scanner.Text() - - // Skip empty lines - if strings.TrimSpace(line) == "" { - continue - } - - var msg Message - if err := json.Unmarshal([]byte(line), &msg); err != nil { - errCh <- fmt.Errorf("failed to parse JSON message: %w", err) - return + if err := c.executeStreamJSON(runCtx, prompt, nil, streamOpts, func(msg Message) error { + if err := applyMessageHooks(runCtx, streamOpts, msg); err != nil { + return err } select { case messageCh <- msg: - // Message sent successfully - case <-ctx.Done(): - // Context was canceled - errCh <- ctx.Err() - return + case <-runCtx.Done(): + return runCtx.Err() } - } - - if err := scanner.Err(); err != nil { - errCh <- fmt.Errorf("scanner error: %w", err) - return - } - if err := cmd.Wait(); err != nil { - // Enhanced error parsing for streaming - var exitCode int - if exitError, ok := err.(*exec.ExitError); ok { - exitCode = exitError.ExitCode() - } else { - exitCode = 1 + if msg.Type == "result" { + return applyCompletionHooks(runCtx, streamOpts, messageToResult(msg)) } - claudeErr := ParseError(stderrBuf.String(), exitCode) - claudeErr.Original = err - errCh <- claudeErr - return + return nil + }); err != nil { + errCh <- err } }() diff --git a/pkg/claude/structured_run.go b/pkg/claude/structured_run.go new file mode 100644 index 0000000..d6782b2 --- /dev/null +++ b/pkg/claude/structured_run.go @@ -0,0 +1,163 @@ +package claude + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "io" + "os/exec" + "strings" +) + +func buildStreamJSONRunOptions(opts *RunOptions) (*RunOptions, error) { + streamOpts := cloneRunOptions(opts) + streamOpts.Format = StreamJSONOutput + + // Claude CLI requires --verbose when using --output-format=stream-json with --print. + streamOpts.Verbose = true + + if err := PreprocessOptions(streamOpts); err != nil { + return nil, err + } + + return streamOpts, nil +} + +func (c *ClaudeClient) runPromptWithStructuredHooks(ctx context.Context, prompt string, stdin io.Reader, opts *RunOptions) (*ClaudeResult, error) { + streamOpts, err := buildStreamJSONRunOptions(opts) + if err != nil { + return nil, err + } + + if streamOpts.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, streamOpts.Timeout) + defer cancel() + } + + var result *ClaudeResult + err = c.executeStreamJSON(ctx, prompt, stdin, streamOpts, func(msg Message) error { + if err := applyMessageHooks(ctx, streamOpts, msg); err != nil { + return err + } + + if msg.Type != "result" { + return nil + } + + result = messageToResult(msg) + return applyCompletionHooks(ctx, streamOpts, result) + }) + if err != nil { + if result != nil { + return result, err + } + return nil, err + } + + if result == nil { + return nil, NewClaudeError(ErrorValidation, "no result message found in JSON response") + } + + return result, nil +} + +func (c *ClaudeClient) executeStreamJSON(ctx context.Context, prompt string, stdin io.Reader, opts *RunOptions, onMessage func(Message) error) error { + if err := ensurePluginManagerInitialized(ctx, opts.PluginManager); err != nil { + return err + } + + args := BuildArgs(prompt, opts) + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + + cmd := execCommand(runCtx, c.BinPath, args...) + if opts.WorkingDirectory != "" { + cmd.Dir = opts.WorkingDirectory + } + if stdin != nil { + cmd.Stdin = stdin + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return newWrappedClaudeError(ErrorCommand, "failed to get stdout pipe", err) + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return newWrappedClaudeError(ErrorCommand, "failed to get stderr pipe", err) + } + + stderrBuf := new(bytes.Buffer) + if err := cmd.Start(); err != nil { + return newWrappedClaudeError(ErrorCommand, "failed to start command", err) + } + + stderrDone := make(chan struct{}) + go func() { + defer close(stderrDone) + _, _ = io.Copy(stderrBuf, stderr) + }() + + scanner := bufio.NewScanner(stdout) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 4*1024*1024) + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "" { + continue + } + + var msg Message + if err := json.Unmarshal([]byte(line), &msg); err != nil { + cancel() + _ = cmd.Wait() + <-stderrDone + return newWrappedClaudeError(ErrorValidation, "failed to parse JSON message", err) + } + + if onMessage == nil { + continue + } + + if err := onMessage(msg); err != nil { + cancel() + _ = cmd.Wait() + <-stderrDone + return err + } + } + + if err := scanner.Err(); err != nil { + cancel() + _ = cmd.Wait() + <-stderrDone + return newWrappedClaudeError(ErrorCommand, "failed to scan stream output", err) + } + + if err := cmd.Wait(); err != nil { + <-stderrDone + var exitCode int + if exitError, ok := err.(*exec.ExitError); ok { + exitCode = exitError.ExitCode() + } else { + exitCode = 1 + } + + claudeErr := ParseError(stderrBuf.String(), exitCode) + claudeErr.Original = err + return claudeErr + } + + <-stderrDone + + return nil +} + +func newWrappedClaudeError(errorType ErrorType, message string, err error) *ClaudeError { + claudeErr := NewClaudeError(errorType, message) + claudeErr.Original = err + return claudeErr +} diff --git a/pkg/claude/subagent.go b/pkg/claude/subagent.go index 94a3c56..3c2a3b0 100644 --- a/pkg/claude/subagent.go +++ b/pkg/claude/subagent.go @@ -53,44 +53,32 @@ func (sc *SubagentConfig) Validate() error { return nil } -// ToRunOptions converts the SubagentConfig to RunOptions for execution +// ToRunOptions converts the SubagentConfig into a native Claude CLI +// --agent/--agents selection and returns the corresponding RunOptions. +// It preserves the subagent definition in Agents rather than flattening +// Prompt/Tools into top-level SystemPrompt/AllowedTools fields. The returned +// options select the synthetic agent name "subagent"; use ToNamedRunOptions +// when the caller needs a stable custom agent key. func (sc *SubagentConfig) ToRunOptions(parentOpts *RunOptions) *RunOptions { - opts := &RunOptions{ - SystemPrompt: sc.Prompt, - AllowedTools: sc.Tools, - Format: StreamJSONOutput, - } + return sc.ToNamedRunOptions("subagent", parentOpts) +} - // Use subagent's model or inherit from parent - if sc.Model != "" { - opts.ModelAlias = sc.Model - } else if parentOpts != nil { - opts.ModelAlias = parentOpts.ModelAlias - opts.Model = parentOpts.Model +// ToNamedRunOptions converts the SubagentConfig into native Claude CLI +// --agent/--agents selection using the provided agent name. If agentName is +// empty, it falls back to "subagent". +func (sc *SubagentConfig) ToNamedRunOptions(agentName string, parentOpts *RunOptions) *RunOptions { + if agentName == "" { + agentName = "subagent" } - // Use subagent's max turns or inherit from parent - if sc.MaxTurns > 0 { - opts.MaxTurns = sc.MaxTurns - } else if parentOpts != nil { - opts.MaxTurns = parentOpts.MaxTurns + agents := map[string]*SubagentConfig{ + agentName: cloneSubagentConfig(sc), } - // Use subagent's working directory or inherit from parent + opts := buildAgentRunOptions(agentName, parentOpts, agents) if sc.WorkingDirectory != "" { opts.WorkingDirectory = sc.WorkingDirectory - } else if parentOpts != nil { - opts.WorkingDirectory = parentOpts.WorkingDirectory - } - - // Inherit MCP config from parent - if parentOpts != nil { - opts.MCPConfigPath = parentOpts.MCPConfigPath - opts.PermissionMode = parentOpts.PermissionMode - opts.PermissionCallback = parentOpts.PermissionCallback - opts.BudgetTracker = parentOpts.BudgetTracker } - return opts } @@ -185,19 +173,24 @@ func (sm *SubagentManager) GetAgentDescriptions() map[string]string { // RunAgent executes a subagent with the given prompt func (sm *SubagentManager) RunAgent(ctx context.Context, agentName string, prompt string, parentOpts *RunOptions) (*ClaudeResult, error) { - config, ok := sm.GetAgent(agentName) - if !ok { + if _, ok := sm.GetAgent(agentName); !ok { return nil, fmt.Errorf("unknown agent: %s", agentName) } - opts := config.ToRunOptions(parentOpts) - return sm.client.RunPromptCtx(ctx, prompt, opts) + opts := sm.buildRunOptions(agentName, parentOpts) + result, err := sm.client.RunPromptCtx(ctx, prompt, opts) + if err != nil { + return nil, err + } + if result.SessionID != "" { + sm.SetSession(agentName, result.SessionID) + } + return result, nil } // StreamAgent executes a subagent and streams the results func (sm *SubagentManager) StreamAgent(ctx context.Context, agentName string, prompt string, parentOpts *RunOptions) (<-chan Message, <-chan error) { - config, ok := sm.GetAgent(agentName) - if !ok { + if _, ok := sm.GetAgent(agentName); !ok { errCh := make(chan error, 1) errCh <- fmt.Errorf("unknown agent: %s", agentName) close(errCh) @@ -206,8 +199,38 @@ func (sm *SubagentManager) StreamAgent(ctx context.Context, agentName string, pr return msgCh, errCh } - opts := config.ToRunOptions(parentOpts) - return sm.client.StreamPrompt(ctx, prompt, opts) + opts := sm.buildRunOptions(agentName, parentOpts) + innerMsgCh, innerErrCh := sm.client.StreamPrompt(ctx, prompt, opts) + + msgCh := make(chan Message) + errCh := make(chan error, 1) + + go func() { + defer close(msgCh) + defer close(errCh) + + for innerMsgCh != nil || innerErrCh != nil { + select { + case msg, ok := <-innerMsgCh: + if !ok { + innerMsgCh = nil + continue + } + if msg.SessionID != "" { + sm.SetSession(agentName, msg.SessionID) + } + msgCh <- msg + case err, ok := <-innerErrCh: + if !ok { + innerErrCh = nil + continue + } + errCh <- err + } + } + }() + + return msgCh, errCh } // SetSession stores a session ID for a subagent (for conversation continuity) @@ -250,14 +273,20 @@ func (sm *SubagentManager) ResumeAgent(ctx context.Context, agentName string, pr return nil, fmt.Errorf("no session found for agent: %s", agentName) } - config, configOk := sm.GetAgent(agentName) - if !configOk { + if _, configOk := sm.GetAgent(agentName); !configOk { return nil, fmt.Errorf("unknown agent: %s", agentName) } - opts := config.ToRunOptions(parentOpts) + opts := sm.buildRunOptions(agentName, parentOpts) opts.ResumeID = sessionID - return sm.client.RunPromptCtx(ctx, prompt, opts) + result, err := sm.client.RunPromptCtx(ctx, prompt, opts) + if err != nil { + return nil, err + } + if result.SessionID != "" { + sm.SetSession(agentName, result.SessionID) + } + return result, nil } // AgentCount returns the number of registered subagents @@ -359,3 +388,96 @@ Generate well-structured, comprehensive documentation.`, Model: "sonnet", } } + +func (sm *SubagentManager) buildRunOptions(agentName string, parentOpts *RunOptions) *RunOptions { + sm.mu.RLock() + agents := make(map[string]*SubagentConfig, len(sm.agents)) + for name, config := range sm.agents { + agents[name] = cloneSubagentConfig(config) + } + sm.mu.RUnlock() + + return buildAgentRunOptions(agentName, parentOpts, agents) +} + +func buildAgentRunOptions(agentName string, parentOpts *RunOptions, agents map[string]*SubagentConfig) *RunOptions { + opts := cloneRunOptions(parentOpts) + if opts.Format == "" { + opts.Format = StreamJSONOutput + } + opts.Agent = agentName + opts.AgentsJSON = "" + opts.Agents = agents + return opts +} + +func cloneRunOptions(opts *RunOptions) *RunOptions { + if opts == nil { + return &RunOptions{} + } + + cloned := *opts + + if opts.AllowedTools != nil { + cloned.AllowedTools = append([]string(nil), opts.AllowedTools...) + } + if opts.DisallowedTools != nil { + cloned.DisallowedTools = append([]string(nil), opts.DisallowedTools...) + } + if opts.MCPConfigs != nil { + cloned.MCPConfigs = append([]string(nil), opts.MCPConfigs...) + } + if opts.AddDirectories != nil { + cloned.AddDirectories = append([]string(nil), opts.AddDirectories...) + } + if opts.Betas != nil { + cloned.Betas = append([]string(nil), opts.Betas...) + } + if opts.Files != nil { + cloned.Files = append([]string(nil), opts.Files...) + } + if opts.SettingSources != nil { + cloned.SettingSources = append([]string(nil), opts.SettingSources...) + } + if opts.Tools != nil { + cloned.Tools = append([]string(nil), opts.Tools...) + } + if opts.PluginDirs != nil { + cloned.PluginDirs = append([]string(nil), opts.PluginDirs...) + } + if opts.ParsedAllowedTools != nil { + cloned.ParsedAllowedTools = append([]ToolPermission(nil), opts.ParsedAllowedTools...) + } + if opts.ParsedDisallowedTools != nil { + cloned.ParsedDisallowedTools = append([]ToolPermission(nil), opts.ParsedDisallowedTools...) + } + if opts.Agents != nil { + cloned.Agents = copySubagentConfigs(opts.Agents) + } + + return &cloned +} + +func copySubagentConfigs(agents map[string]*SubagentConfig) map[string]*SubagentConfig { + if len(agents) == 0 { + return nil + } + + copied := make(map[string]*SubagentConfig, len(agents)) + for name, config := range agents { + copied[name] = cloneSubagentConfig(config) + } + return copied +} + +func cloneSubagentConfig(config *SubagentConfig) *SubagentConfig { + if config == nil { + return nil + } + + cloned := *config + if config.Tools != nil { + cloned.Tools = append([]string(nil), config.Tools...) + } + return &cloned +} diff --git a/pkg/claude/subagent_test.go b/pkg/claude/subagent_test.go index 41ed60a..c69f9db 100644 --- a/pkg/claude/subagent_test.go +++ b/pkg/claude/subagent_test.go @@ -105,21 +105,56 @@ func TestSubagentConfig_ToRunOptions(t *testing.T) { opts := config.ToRunOptions(nil) - if opts.SystemPrompt != config.Prompt { - t.Errorf("SystemPrompt = %q, want %q", opts.SystemPrompt, config.Prompt) + if opts.Agent != "subagent" { + t.Errorf("Agent = %q, want %q", opts.Agent, "subagent") } - if len(opts.AllowedTools) != len(config.Tools) { - t.Errorf("AllowedTools length = %d, want %d", len(opts.AllowedTools), len(config.Tools)) + if opts.Agents == nil { + t.Fatal("Agents should be populated") } - if opts.ModelAlias != "haiku" { - t.Errorf("ModelAlias = %q, want %q", opts.ModelAlias, "haiku") + selected := opts.Agents["subagent"] + if selected == nil { + t.Fatal("selected agent config should be present") } - if opts.MaxTurns != 5 { - t.Errorf("MaxTurns = %d, want %d", opts.MaxTurns, 5) + if selected.Prompt != config.Prompt { + t.Errorf("Prompt = %q, want %q", selected.Prompt, config.Prompt) + } + if len(selected.Tools) != len(config.Tools) { + t.Errorf("Tools length = %d, want %d", len(selected.Tools), len(config.Tools)) + } + if selected.Model != "haiku" { + t.Errorf("Model = %q, want %q", selected.Model, "haiku") + } + if selected.MaxTurns != 5 { + t.Errorf("MaxTurns = %d, want %d", selected.MaxTurns, 5) } if opts.Format != StreamJSONOutput { t.Errorf("Format = %q, want %q", opts.Format, StreamJSONOutput) } + if opts.SystemPrompt != "" { + t.Errorf("SystemPrompt = %q, want empty", opts.SystemPrompt) + } + if len(opts.AllowedTools) != 0 { + t.Errorf("AllowedTools = %v, want empty top-level slice", opts.AllowedTools) + } + }) + + t.Run("named conversion", func(t *testing.T) { + config := &SubagentConfig{ + Description: "Security agent", + Prompt: "You review security issues", + } + + opts := config.ToNamedRunOptions("security", nil) + + if opts.Agent != "security" { + t.Errorf("Agent = %q, want %q", opts.Agent, "security") + } + if opts.Agents["security"] == nil { + t.Fatal("expected security agent entry to be present") + } + if opts.Agents["subagent"] != nil { + t.Fatal("did not expect fallback subagent key when custom name is supplied") + } }) t.Run("inherits from parent", func(t *testing.T) { @@ -162,11 +197,18 @@ func TestSubagentConfig_ToRunOptions(t *testing.T) { opts := config.ToRunOptions(parentOpts) - if opts.ModelAlias != "haiku" { - t.Errorf("ModelAlias = %q, want subagent's %q", opts.ModelAlias, "haiku") + selected := opts.Agents["subagent"] + if selected == nil { + t.Fatal("selected agent config should be present") + } + if selected.Model != "haiku" { + t.Errorf("Model = %q, want subagent's %q", selected.Model, "haiku") } - if opts.MaxTurns != 3 { - t.Errorf("MaxTurns = %d, want subagent's %d", opts.MaxTurns, 3) + if selected.MaxTurns != 3 { + t.Errorf("MaxTurns = %d, want subagent's %d", selected.MaxTurns, 3) + } + if opts.ModelAlias != "opus" { + t.Errorf("ModelAlias = %q, want inherited parent %q", opts.ModelAlias, "opus") } })