From d700920eeee5da6278833f208afd4b4595225a1d Mon Sep 17 00:00:00 2001 From: lancekrogers Date: Fri, 17 Apr 2026 02:24:37 -0600 Subject: [PATCH 1/6] [OBEY-CAMPAIGN-78887e36] refresh current claude prompt surface for 0.1.1 --- README.md | 79 ++++--- docs/RELEASE_NOTES_v0.1.1.md | 45 ++++ docs/RELEASE_NOTES_v1.0.0.md | 189 --------------- examples/advanced/advanced.go | 1 - examples/budget/main.go | 4 - examples/dangerous_usage/main.go | 11 +- examples/demo/budget/cmd/demo/main.go | 3 - examples/demo/mcp/cmd/demo/main.go | 1 - examples/demo/permissions/cmd/demo/main.go | 1 - examples/demo/plugins/cmd/demo/main.go | 9 - examples/demo/retry/cmd/demo/main.go | 8 +- examples/demo/sessions/cmd/demo/main.go | 1 - examples/enhanced_features/main.go | 25 +- examples/plugins/main.go | 3 - examples/subagents/main.go | 4 +- examples/workflow/main.go | 21 +- pkg/claude/claude.go | 180 ++++++++++----- pkg/claude/claude_test.go | 115 ++++++---- pkg/claude/dangerous/README.md | 3 +- pkg/claude/execution_hooks.go | 206 +++++++++++++++++ pkg/claude/execution_hooks_test.go | 255 +++++++++++++++++++++ pkg/claude/options.go | 170 +++++++++++++- pkg/claude/permissions.go | 5 +- pkg/claude/streaming.go | 43 +++- pkg/claude/subagent.go | 195 ++++++++++++---- pkg/claude/subagent_test.go | 41 +++- 26 files changed, 1175 insertions(+), 443 deletions(-) create mode 100644 docs/RELEASE_NOTES_v0.1.1.md delete mode 100644 docs/RELEASE_NOTES_v1.0.0.md create mode 100644 pkg/claude/execution_hooks.go create mode 100644 pkg/claude/execution_hooks_test.go diff --git a/README.md b/README.md index 02edd0b..2f55829 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 @@ -263,20 +266,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 +353,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 +367,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 +380,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 +500,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_v0.1.1.md](docs/RELEASE_NOTES_v0.1.1.md) ## Contributing diff --git a/docs/RELEASE_NOTES_v0.1.1.md b/docs/RELEASE_NOTES_v0.1.1.md new file mode 100644 index 0000000..e1e0c3a --- /dev/null +++ b/docs/RELEASE_NOTES_v0.1.1.md @@ -0,0 +1,45 @@ +# v0.1.1 - Prompt Surface Refresh + +This release updates `claude-code-go` to match the current Claude Code non-interactive `-p/--print` CLI surface more closely and removes drift from older flags that no longer exist in the upstream binary. + +## 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 release notes 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` + +## Compatibility Notes + +- `PermissionTool`, `MaxTurns`, `ConfigFile`, `DisableAutoUpdate`, `Theme`, and `PermissionCallback` remain in `RunOptions` for source compatibility but are deprecated and ignored by argument construction. +- `PermissionModeDelegate` is now rejected during validation because the current Claude CLI no longer supports delegate permission mode. +- 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 + +- `go test ./pkg/claude/...` 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/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/claude.go b/pkg/claude/claude.go index 4517117..b6fdadb 100644 --- a/pkg/claude/claude.go +++ b/pkg/claude/claude.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os/exec" + "strconv" "strings" "time" ) @@ -70,12 +71,18 @@ func (c *ClaudeClient) RunPromptCtx(ctx context.Context, prompt string, opts *Ru args := BuildArgs(prompt, opts) + cleanupPlugins, err := preparePluginManager(ctx, opts.PluginManager) + if err != nil { + return nil, err + } + defer cleanupPlugins() + cmd := execCommand(ctx, c.BinPath, args...) 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 @@ -91,52 +98,31 @@ func (c *ClaudeClient) RunPromptCtx(ctx context.Context, prompt string, opts *Ru } if opts.Format == JSONOutput { - result, err := parseJSONResponse(stdout.Bytes()) + messages, result, err := parseJSONTranscript(stdout.Bytes()) if err != nil { return nil, err } + if err := applyExecutionHooks(ctx, opts, messages, result); err != nil { + return nil, 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, opts, result); err != nil { + return nil, 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 @@ -164,13 +150,19 @@ func (c *ClaudeClient) RunFromStdinCtx(ctx context.Context, stdin io.Reader, pro args := BuildArgs(prompt, opts) + cleanupPlugins, err := preparePluginManager(ctx, opts.PluginManager) + if err != nil { + return nil, err + } + defer cleanupPlugins() + cmd := execCommand(ctx, c.BinPath, args...) 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 @@ -186,18 +178,25 @@ func (c *ClaudeClient) RunFromStdinCtx(ctx context.Context, stdin io.Reader, pro } if opts.Format == JSONOutput { - result, err := parseJSONResponse(stdout.Bytes()) + messages, result, err := parseJSONTranscript(stdout.Bytes()) if err != nil { return nil, err } + if err := applyExecutionHooks(ctx, opts, messages, result); err != nil { + return nil, 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, opts, result); err != nil { + return nil, err + } + return result, nil } // BuildArgs constructs the command-line arguments for Claude Code @@ -214,6 +213,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) } @@ -234,10 +249,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)) @@ -249,10 +260,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") } @@ -264,9 +271,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 @@ -279,14 +285,50 @@ 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)) } - // Theme - if opts.Theme != "" { - args = append(args, "--theme", opts.Theme) + 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 { + args = append(args, "--betas") + args = append(args, opts.Betas...) + } + + if len(opts.Files) > 0 { + args = append(args, "--file") + args = append(args, opts.Files...) + } + + if opts.ExcludeDynamicSystemPromptSections { + args = append(args, "--exclude-dynamic-system-prompt-sections") + } + + if opts.MaxBudgetUSD > 0 { + args = append(args, "--max-budget-usd", strconv.FormatFloat(opts.MaxBudgetUSD, 'f', -1, 64)) } // Session control flags @@ -317,6 +359,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") @@ -340,6 +402,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 dd2a646..a0816f0 100644 --- a/pkg/claude/claude_test.go +++ b/pkg/claude/claude_test.go @@ -232,31 +232,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", }, }, { @@ -671,50 +677,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"}, }, } 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/execution_hooks.go b/pkg/claude/execution_hooks.go new file mode 100644 index 0000000..858b669 --- /dev/null +++ b/pkg/claude/execution_hooks.go @@ -0,0 +1,206 @@ +package claude + +import ( + "context" + "encoding/json" + "errors" + "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 && !errors.Is(err, ErrBudgetExceeded) { + return err + } + } + + if opts.PluginManager != nil { + if err := opts.PluginManager.OnComplete(ctx, result); err != nil { + return err + } + } + + return nil +} + +func preparePluginManager(ctx context.Context, pm *PluginManager) (func(), error) { + if pm == nil { + return func() {}, nil + } + + pm.mu.RLock() + alreadyInitialized := pm.initialized + pm.mu.RUnlock() + + if alreadyInitialized { + return func() {}, nil + } + + if err := pm.Initialize(ctx); err != nil { + return nil, err + } + + return func() { + _ = pm.Shutdown(ctx) + }, nil +} + +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..ed312e3 --- /dev/null +++ b/pkg/claude/execution_hooks_test.go @@ -0,0 +1,255 @@ +package claude + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "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"},{"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"}]` + execCommand = mockExecCommandContext(t, []string{"-p", "Hook test", "--output-format", "json"}, 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 != 1 { + t.Fatalf("Shutdown count = %d, want 1", 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"}` + execCommand = mockExecCommandContext(t, []string{"-p", "Sticky hooks", "--output-format", "json"}, 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 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 != 1 { + t.Fatalf("Shutdown count = %d, want 1", 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 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 +} diff --git a/pkg/claude/options.go b/pkg/claude/options.go index 67dc5d8..5d6a375 100644 --- a/pkg/claude/options.go +++ b/pkg/claude/options.go @@ -21,6 +21,27 @@ 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 EffortLevel = "low" + EffortMedium EffortLevel = "medium" + EffortHigh EffortLevel = "high" + EffortXHigh EffortLevel = "xhigh" + EffortMax EffortLevel = "max" +) + // RunOptions configures how Claude Code is executed type RunOptions struct { // Format specifies the output format (text, json, stream-json) @@ -37,43 +58,78 @@ 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. This field is ignored and kept only for source + // compatibility. 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. This field is ignored and kept only for source compatibility. 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. This field is ignored and kept only for source compatibility. 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. This field is ignored and kept only for source + // compatibility. DisableAutoUpdate bool - // Theme specifies the UI theme + // Deprecated: Claude Code no longer exposes --theme on the current CLI + // surface. This field is ignored and kept only for source compatibility. 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. This field is retained only for + // source compatibility. PermissionCallback PermissionCallback `json:"-"` // MaxBudgetUSD sets the maximum spending limit in USD @@ -87,6 +143,9 @@ 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 @@ -119,6 +178,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 // PrintMode enables print mode output (required for some flags) PrintMode bool @@ -193,11 +262,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) { @@ -214,6 +317,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) @@ -224,6 +330,19 @@ func PreprocessOptions(opts *RunOptions) error { } } + // Validate stream-specific combinations + if opts.IncludeHookEvents && opts.Format != StreamJSONOutput { + return NewValidationError("IncludeHookEvents requires stream-json output", "IncludeHookEvents", opts.IncludeHookEvents) + } + if opts.IncludePartialMessages && opts.Format != StreamJSONOutput { + return NewValidationError("IncludePartialMessages requires stream-json output", "IncludePartialMessages", opts.IncludePartialMessages) + } + if opts.ReplayUserMessages { + if opts.Format != StreamJSONOutput || opts.InputFormat != StreamJSONInput { + return NewValidationError("ReplayUserMessages requires stream-json input and output", "ReplayUserMessages", opts.ReplayUserMessages) + } + } + return nil } @@ -238,6 +357,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/streaming.go b/pkg/claude/streaming.go index 1cc9363..95bfa23 100644 --- a/pkg/claude/streaming.go +++ b/pkg/claude/streaming.go @@ -47,14 +47,37 @@ func (c *ClaudeClient) StreamPrompt(ctx context.Context, prompt string, opts *Ru // Claude CLI requires --verbose when using --output-format=stream-json with --print streamOpts.Verbose = true + if err := PreprocessOptions(&streamOpts); err != nil { + errCh <- err + close(messageCh) + close(errCh) + return messageCh, errCh + } + args := BuildArgs(prompt, &streamOpts) go func() { defer close(messageCh) defer close(errCh) + 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() + + cleanupPlugins, err := preparePluginManager(runCtx, streamOpts.PluginManager) + if err != nil { + errCh <- err + return + } + defer cleanupPlugins() + // Create a custom command that supports context - cmd := execCommand(ctx, c.BinPath, args...) + cmd := execCommand(runCtx, c.BinPath, args...) stdout, err := cmd.StdoutPipe() if err != nil { @@ -94,12 +117,26 @@ func (c *ClaudeClient) StreamPrompt(ctx context.Context, prompt string, opts *Ru return } + if err := applyMessageHooks(runCtx, &streamOpts, msg); err != nil { + cancel() + errCh <- err + return + } + + if msg.Type == "result" { + if err := applyCompletionHooks(runCtx, &streamOpts, messageToResult(msg)); err != nil { + cancel() + errCh <- err + return + } + } + select { case messageCh <- msg: // Message sent successfully - case <-ctx.Done(): + case <-runCtx.Done(): // Context was canceled - errCh <- ctx.Err() + errCh <- runCtx.Err() return } } diff --git a/pkg/claude/subagent.go b/pkg/claude/subagent.go index 5463c90..e559a75 100644 --- a/pkg/claude/subagent.go +++ b/pkg/claude/subagent.go @@ -53,41 +53,14 @@ func (sc *SubagentConfig) Validate() error { return nil } -// ToRunOptions converts the SubagentConfig to RunOptions for execution +// ToRunOptions converts the SubagentConfig into a CLI-compatible agent +// definition and selects it for execution. func (sc *SubagentConfig) ToRunOptions(parentOpts *RunOptions) *RunOptions { - opts := &RunOptions{ - SystemPrompt: sc.Prompt, - AllowedTools: sc.Tools, - Format: StreamJSONOutput, + agents := map[string]*SubagentConfig{ + "subagent": cloneSubagentConfig(sc), } - // 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 - } - - // 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 - } - - // Use subagent's working directory or inherit from parent - // Note: WorkingDirectory would need to be added to RunOptions if needed - - // 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 + return buildAgentRunOptions("subagent", parentOpts, agents) } // SubagentManager manages the lifecycle and execution of subagents @@ -181,19 +154,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) @@ -202,8 +180,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) @@ -246,14 +254,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 @@ -355,3 +369,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 4851f4a..780c2c1 100644 --- a/pkg/claude/subagent_test.go +++ b/pkg/claude/subagent_test.go @@ -105,17 +105,27 @@ 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) @@ -162,11 +172,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 opts.MaxTurns != 3 { - t.Errorf("MaxTurns = %d, want subagent's %d", opts.MaxTurns, 3) + if selected.Model != "haiku" { + t.Errorf("Model = %q, want subagent's %q", selected.Model, "haiku") + } + 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") } }) } From f911ab213a6e5c66855f52f5f3e296dbf683a365 Mon Sep 17 00:00:00 2001 From: lancekrogers Date: Fri, 17 Apr 2026 04:38:38 -0600 Subject: [PATCH 2/6] [OBEY-CAMPAIGN-78887e36] Fix plugin hook execution and budget enforcement --- README.md | 8 +- pkg/claude/claude.go | 28 +++-- pkg/claude/execution_hooks.go | 23 +--- pkg/claude/execution_hooks_test.go | 179 +++++++++++++++++++++++++++-- pkg/claude/plugin.go | 5 +- pkg/claude/streaming.go | 111 ++---------------- pkg/claude/structured_run.go | 148 ++++++++++++++++++++++++ 7 files changed, 360 insertions(+), 142 deletions(-) create mode 100644 pkg/claude/structured_run.go diff --git a/README.md b/README.md index 2f55829..03bf292 100644 --- a/README.md +++ b/README.md @@ -221,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, }) diff --git a/pkg/claude/claude.go b/pkg/claude/claude.go index b038274..b5522bd 100644 --- a/pkg/claude/claude.go +++ b/pkg/claude/claude.go @@ -57,6 +57,10 @@ func (c *ClaudeClient) RunPromptCtx(ctx context.Context, prompt string, opts *Ru opts = c.DefaultOptions } + if opts.PluginManager != nil && opts.Format == JSONOutput { + return c.runPromptWithStructuredHooks(ctx, prompt, nil, opts) + } + // Preprocess and validate options if err := PreprocessOptions(opts); err != nil { return nil, err @@ -71,11 +75,9 @@ func (c *ClaudeClient) RunPromptCtx(ctx context.Context, prompt string, opts *Ru args := BuildArgs(prompt, opts) - cleanupPlugins, err := preparePluginManager(ctx, opts.PluginManager) - if err != nil { + if err := ensurePluginManagerInitialized(ctx, opts.PluginManager); err != nil { return nil, err } - defer cleanupPlugins() cmd := execCommand(ctx, c.BinPath, args...) if opts.WorkingDirectory != "" { @@ -85,7 +87,7 @@ func (c *ClaudeClient) RunPromptCtx(ctx context.Context, prompt string, opts *Ru cmd.Stdout = &stdout cmd.Stderr = &stderr - err = cmd.Run() + err := cmd.Run() if err != nil { // Enhanced error parsing var exitCode int @@ -106,7 +108,7 @@ func (c *ClaudeClient) RunPromptCtx(ctx context.Context, prompt string, opts *Ru return nil, err } if err := applyExecutionHooks(ctx, opts, messages, result); err != nil { - return nil, err + return result, err } return result, nil } @@ -117,7 +119,7 @@ func (c *ClaudeClient) RunPromptCtx(ctx context.Context, prompt string, opts *Ru IsError: false, } if err := applyCompletionHooks(ctx, opts, result); err != nil { - return nil, err + return result, err } return result, nil } @@ -139,6 +141,10 @@ func (c *ClaudeClient) RunFromStdinCtx(ctx context.Context, stdin io.Reader, pro opts = c.DefaultOptions } + if opts.PluginManager != nil && opts.Format == JSONOutput { + return c.runPromptWithStructuredHooks(ctx, prompt, stdin, opts) + } + // Preprocess and validate options if err := PreprocessOptions(opts); err != nil { return nil, err @@ -153,11 +159,9 @@ func (c *ClaudeClient) RunFromStdinCtx(ctx context.Context, stdin io.Reader, pro args := BuildArgs(prompt, opts) - cleanupPlugins, err := preparePluginManager(ctx, opts.PluginManager) - if err != nil { + if err := ensurePluginManagerInitialized(ctx, opts.PluginManager); err != nil { return nil, err } - defer cleanupPlugins() cmd := execCommand(ctx, c.BinPath, args...) if opts.WorkingDirectory != "" { @@ -168,7 +172,7 @@ func (c *ClaudeClient) RunFromStdinCtx(ctx context.Context, stdin io.Reader, pro cmd.Stdout = &stdout cmd.Stderr = &stderr - err = cmd.Run() + err := cmd.Run() if err != nil { // Enhanced error parsing var exitCode int @@ -189,7 +193,7 @@ func (c *ClaudeClient) RunFromStdinCtx(ctx context.Context, stdin io.Reader, pro return nil, err } if err := applyExecutionHooks(ctx, opts, messages, result); err != nil { - return nil, err + return result, err } return result, nil } @@ -200,7 +204,7 @@ func (c *ClaudeClient) RunFromStdinCtx(ctx context.Context, stdin io.Reader, pro IsError: false, } if err := applyCompletionHooks(ctx, opts, result); err != nil { - return nil, err + return result, err } return result, nil } diff --git a/pkg/claude/execution_hooks.go b/pkg/claude/execution_hooks.go index 858b669..a042f84 100644 --- a/pkg/claude/execution_hooks.go +++ b/pkg/claude/execution_hooks.go @@ -3,7 +3,6 @@ package claude import ( "context" "encoding/json" - "errors" "fmt" ) @@ -83,7 +82,7 @@ func applyCompletionHooks(ctx context.Context, opts *RunOptions, result *ClaudeR } if tracker := opts.BudgetTracker; tracker != nil && result.CostUSD > 0 { - if err := tracker.AddSpend(result.SessionID, result.CostUSD); err != nil && !errors.Is(err, ErrBudgetExceeded) { + if err := tracker.AddSpend(result.SessionID, result.CostUSD); err != nil { return err } } @@ -97,26 +96,12 @@ func applyCompletionHooks(ctx context.Context, opts *RunOptions, result *ClaudeR return nil } -func preparePluginManager(ctx context.Context, pm *PluginManager) (func(), error) { +func ensurePluginManagerInitialized(ctx context.Context, pm *PluginManager) error { if pm == nil { - return func() {}, nil - } - - pm.mu.RLock() - alreadyInitialized := pm.initialized - pm.mu.RUnlock() - - if alreadyInitialized { - return func() {}, nil - } - - if err := pm.Initialize(ctx); err != nil { - return nil, err + return nil } - return func() { - _ = pm.Shutdown(ctx) - }, nil + return pm.Initialize(ctx) } type toolUseCall struct { diff --git a/pkg/claude/execution_hooks_test.go b/pkg/claude/execution_hooks_test.go index ed312e3..c80b7a0 100644 --- a/pkg/claude/execution_hooks_test.go +++ b/pkg/claude/execution_hooks_test.go @@ -2,9 +2,11 @@ package claude import ( "context" + "errors" "os" "os/exec" "path/filepath" + "strings" "sync" "testing" ) @@ -66,8 +68,9 @@ func TestRunPromptCtx_AppliesLifecycleHooks(t *testing.T) { execCommand = originalExecCommand }() - jsonOutput := `[{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"pwd"}}]},"session_id":"hook-session"},{"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"}]` - execCommand = mockExecCommandContext(t, []string{"-p", "Hook test", "--output-format", "json"}, jsonOutput, 0) + 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{} @@ -103,8 +106,8 @@ func TestRunPromptCtx_AppliesLifecycleHooks(t *testing.T) { if plugin.initCount != 1 { t.Fatalf("Initialize count = %d, want 1", plugin.initCount) } - if plugin.shutdownCount != 1 { - t.Fatalf("Shutdown count = %d, want 1", plugin.shutdownCount) + 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) @@ -129,8 +132,8 @@ func TestRunPromptCtx_DoesNotShutdownPreinitializedPluginManager(t *testing.T) { 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"}` - execCommand = mockExecCommandContext(t, []string{"-p", "Sticky hooks", "--output-format", "json"}, jsonOutput, 0) + 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{} @@ -163,6 +166,123 @@ func TestRunPromptCtx_DoesNotShutdownPreinitializedPluginManager(t *testing.T) { } } +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() { @@ -210,8 +330,8 @@ func TestStreamPrompt_AppliesLifecycleHooks(t *testing.T) { if plugin.initCount != 1 { t.Fatalf("Initialize count = %d, want 1", plugin.initCount) } - if plugin.shutdownCount != 1 { - t.Fatalf("Shutdown count = %d, want 1", plugin.shutdownCount) + 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) @@ -227,6 +347,49 @@ func TestStreamPrompt_AppliesLifecycleHooks(t *testing.T) { } } +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 buildStreamingMockBinary(t *testing.T) string { t.Helper() 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 1092148..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,22 +34,14 @@ 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 - - if err := PreprocessOptions(&streamOpts); err != nil { + streamOpts, err := buildStreamJSONRunOptions(opts) + if err != nil { errCh <- err close(messageCh) close(errCh) return messageCh, errCh } - args := BuildArgs(prompt, &streamOpts) - go func() { defer close(messageCh) defer close(errCh) @@ -69,99 +55,24 @@ func (c *ClaudeClient) StreamPrompt(ctx context.Context, prompt string, opts *Ru } defer cancel() - cleanupPlugins, err := preparePluginManager(runCtx, streamOpts.PluginManager) - if err != nil { - errCh <- err - return - } - defer cleanupPlugins() - - // Create a custom command that supports context - cmd := execCommand(runCtx, 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 - } - - 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 := applyMessageHooks(runCtx, &streamOpts, msg); err != nil { - cancel() - errCh <- err - return - } - - if msg.Type == "result" { - if err := applyCompletionHooks(runCtx, &streamOpts, messageToResult(msg)); err != nil { - cancel() - errCh <- 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 <-runCtx.Done(): - // Context was canceled - errCh <- runCtx.Err() - return + 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..fc5f05a --- /dev/null +++ b/pkg/claude/structured_run.go @@ -0,0 +1,148 @@ +package claude + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "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 fmt.Errorf("failed to get stdout pipe: %w", err) + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to get stderr pipe: %w", err) + } + + stderrBuf := new(bytes.Buffer) + go func() { + _, _ = io.Copy(stderrBuf, stderr) + }() + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start command: %w", err) + } + + scanner := bufio.NewScanner(stdout) + 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() + return fmt.Errorf("failed to parse JSON message: %w", err) + } + + if onMessage == nil { + continue + } + + if err := onMessage(msg); err != nil { + cancel() + _ = cmd.Wait() + return err + } + } + + if err := scanner.Err(); err != nil { + cancel() + _ = cmd.Wait() + return fmt.Errorf("scanner error: %w", err) + } + + if err := cmd.Wait(); err != nil { + 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 + } + + return nil +} From ee766856194f4aed58cf5df3fd4a179f8c544f07 Mon Sep 17 00:00:00 2001 From: lancekrogers Date: Fri, 17 Apr 2026 05:59:16 -0600 Subject: [PATCH 3/6] [OBEY-CAMPAIGN-78887e36] Address inline review feedback --- README.md | 2 +- ...0.1.1.md => PROMPT_SURFACE_NOTES_0.1.1.md} | 12 ++- pkg/claude/budget.go | 33 +++++--- pkg/claude/budget_test.go | 11 +-- pkg/claude/claude.go | 79 +++++++++++-------- pkg/claude/claude_test.go | 55 +++++++++++++ pkg/claude/enhanced_test.go | 64 +++++++++++++++ pkg/claude/execution_hooks_test.go | 72 +++++++++++++++++ pkg/claude/options.go | 43 +++++++--- pkg/claude/structured_run.go | 18 ++++- pkg/claude/subagent.go | 6 +- pkg/claude/subagent_test.go | 6 ++ 12 files changed, 328 insertions(+), 73 deletions(-) rename docs/{RELEASE_NOTES_v0.1.1.md => PROMPT_SURFACE_NOTES_0.1.1.md} (68%) diff --git a/README.md b/README.md index 03bf292..f2b240b 100644 --- a/README.md +++ b/README.md @@ -506,7 +506,7 @@ just lint - [docs/DEMOS.md](docs/DEMOS.md) - [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) -- [docs/RELEASE_NOTES_v0.1.1.md](docs/RELEASE_NOTES_v0.1.1.md) +- [docs/PROMPT_SURFACE_NOTES_0.1.1.md](docs/PROMPT_SURFACE_NOTES_0.1.1.md) ## Contributing diff --git a/docs/RELEASE_NOTES_v0.1.1.md b/docs/PROMPT_SURFACE_NOTES_0.1.1.md similarity index 68% rename from docs/RELEASE_NOTES_v0.1.1.md rename to docs/PROMPT_SURFACE_NOTES_0.1.1.md index e1e0c3a..f231747 100644 --- a/docs/RELEASE_NOTES_v0.1.1.md +++ b/docs/PROMPT_SURFACE_NOTES_0.1.1.md @@ -1,6 +1,7 @@ -# v0.1.1 - Prompt Surface Refresh +# Prompt Surface Notes (Planning Label 0.1.1) -This release updates `claude-code-go` to match the current Claude Code non-interactive `-p/--print` CLI surface more closely and removes drift from older flags that no longer exist in the upstream binary. +This document tracks the prompt-surface refresh work under the branch planning label `0.1.1`. +It is not a published Go module tag or a promise about the eventual release semver. ## Highlights @@ -34,9 +35,14 @@ This release updates `claude-code-go` to match the current Claude Code non-inter - `Name` - `PluginDirs` +## Behavior Changes + +- `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 flattens the subagent prompt and tool list into top-level `SystemPrompt` and `AllowedTools`. +- Deprecated top-level fields retained for source compatibility now fail validation 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 but are deprecated and ignored by argument construction. +- `PermissionTool`, `MaxTurns`, `ConfigFile`, `DisableAutoUpdate`, `Theme`, and `PermissionCallback` remain in `RunOptions` for source compatibility, but `PreprocessOptions` now rejects them with validation errors when they are set. - `PermissionModeDelegate` is now rejected during validation because the current Claude CLI no longer supports delegate permission mode. - 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. 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 b5522bd..499765d 100644 --- a/pkg/claude/claude.go +++ b/pkg/claude/claude.go @@ -51,43 +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) +} - if opts.PluginManager != nil && opts.Format == JSONOutput { - return c.runPromptWithStructuredHooks(ctx, prompt, nil, 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, opts.PluginManager); err != nil { + 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 @@ -102,12 +113,12 @@ func (c *ClaudeClient) RunPromptCtx(ctx context.Context, prompt string, opts *Ru return nil, claudeErr } - if opts.Format == JSONOutput { + if preparedOpts.Format == JSONOutput { messages, result, err := parseJSONTranscript(stdout.Bytes()) if err != nil { return nil, err } - if err := applyExecutionHooks(ctx, opts, messages, result); err != nil { + if err := applyExecutionHooks(ctx, preparedOpts, messages, result); err != nil { return result, err } return result, nil @@ -118,7 +129,7 @@ func (c *ClaudeClient) RunPromptCtx(ctx context.Context, prompt string, opts *Ru Result: stdout.String(), IsError: false, } - if err := applyCompletionHooks(ctx, opts, result); err != nil { + if err := applyCompletionHooks(ctx, preparedOpts, result); err != nil { return result, err } return result, nil @@ -137,42 +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 - } - - if opts.PluginManager != nil && opts.Format == JSONOutput { - return c.runPromptWithStructuredHooks(ctx, prompt, stdin, opts) + 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, opts.PluginManager); err != nil { + 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 @@ -187,12 +194,12 @@ func (c *ClaudeClient) RunFromStdinCtx(ctx context.Context, stdin io.Reader, pro return nil, claudeErr } - if opts.Format == JSONOutput { + if preparedOpts.Format == JSONOutput { messages, result, err := parseJSONTranscript(stdout.Bytes()) if err != nil { return nil, err } - if err := applyExecutionHooks(ctx, opts, messages, result); err != nil { + if err := applyExecutionHooks(ctx, preparedOpts, messages, result); err != nil { return result, err } return result, nil @@ -203,7 +210,7 @@ func (c *ClaudeClient) RunFromStdinCtx(ctx context.Context, stdin io.Reader, pro Result: stdout.String(), IsError: false, } - if err := applyCompletionHooks(ctx, opts, result); err != nil { + if err := applyCompletionHooks(ctx, preparedOpts, result); err != nil { return result, err } return result, nil @@ -324,11 +331,13 @@ func BuildArgs(prompt string, opts *RunOptions) []string { } 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...) } diff --git a/pkg/claude/claude_test.go b/pkg/claude/claude_test.go index 13e611e..f793ceb 100644 --- a/pkg/claude/claude_test.go +++ b/pkg/claude/claude_test.go @@ -868,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 @@ -972,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/enhanced_test.go b/pkg/claude/enhanced_test.go index 89e0b2b..fb3c5cc 100644 --- a/pkg/claude/enhanced_test.go +++ b/pkg/claude/enhanced_test.go @@ -1,6 +1,7 @@ package claude import ( + "context" "testing" "time" ) @@ -84,6 +85,69 @@ func TestPreprocessOptions(t *testing.T) { } } +func TestPreprocessOptions_RejectsDeprecatedFields(t *testing.T) { + 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) { + err := PreprocessOptions(tt.opts) + if err == nil { + t.Fatal("Expected validation error but got nil") + } + + claudeErr, ok := err.(*ClaudeError) + if !ok { + t.Fatalf("Expected ClaudeError but got %T", err) + } + if claudeErr.Type != ErrorValidation { + t.Fatalf("Expected ErrorValidation, got %v", claudeErr.Type) + } + if !containsSubstring(err.Error(), tt.field) { + t.Fatalf("Expected error to mention %q, got %v", tt.field, err) + } + }) + } +} + func TestIsValidModelAlias(t *testing.T) { tests := []struct { alias string diff --git a/pkg/claude/execution_hooks_test.go b/pkg/claude/execution_hooks_test.go index c80b7a0..f33f2b1 100644 --- a/pkg/claude/execution_hooks_test.go +++ b/pkg/claude/execution_hooks_test.go @@ -3,6 +3,7 @@ package claude import ( "context" "errors" + "fmt" "os" "os/exec" "path/filepath" @@ -390,6 +391,35 @@ func TestStreamPrompt_BudgetExceededReturnsResultBeforeError(t *testing.T) { } } +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() @@ -416,3 +446,45 @@ func main() { 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 45dafe8..babbd7f 100644 --- a/pkg/claude/options.go +++ b/pkg/claude/options.go @@ -59,15 +59,14 @@ type RunOptions struct { // Supports both legacy format ("Bash") and enhanced format ("Bash(git log:*)") DisallowedTools []string // Deprecated: Claude Code no longer exposes --permission-prompt-tool on the - // current CLI surface. This field is ignored and kept only for source - // compatibility. + // current CLI surface. Setting this field returns a validation error. PermissionTool string // ResumeID is the session ID to resume ResumeID string // Continue indicates whether to continue the most recent conversation Continue bool // Deprecated: Claude Code no longer exposes --max-turns on the current CLI - // surface. This field is ignored and kept only for source compatibility. + // surface. Setting this field returns a validation error. MaxTurns int // Verbose enables verbose logging Verbose bool @@ -84,18 +83,17 @@ type RunOptions struct { // Timeout specifies the maximum duration for command execution Timeout time.Duration // Deprecated: Claude Code no longer exposes --config on the current CLI - // surface. This field is ignored and kept only for source compatibility. + // surface. Setting this field returns a validation error. ConfigFile string // Help shows help information Help bool // Version shows version information Version bool // Deprecated: Claude Code no longer exposes --disable-autoupdate on the - // current CLI surface. This field is ignored and kept only for source - // compatibility. + // current CLI surface. Setting this field returns a validation error. DisableAutoUpdate bool // Deprecated: Claude Code no longer exposes --theme on the current CLI - // surface. This field is ignored and kept only for source compatibility. + // surface. Setting this field returns a validation error. Theme string // InputFormat specifies stdin input mode for print runs InputFormat InputFormat @@ -128,8 +126,8 @@ type RunOptions struct { PermissionMode PermissionMode // PermissionCallback is called before each tool use to determine permission // Deprecated: the current Claude CLI no longer exposes a wrapper-safe - // permission callback injection point. This field is retained only for - // source compatibility. + // permission callback injection point. Setting this field returns a + // validation error. PermissionCallback PermissionCallback `json:"-"` // MaxBudgetUSD sets the maximum spending limit in USD @@ -151,8 +149,8 @@ type RunOptions struct { // 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:"-"` @@ -234,6 +232,10 @@ func PreprocessOptions(opts *RunOptions) error { return nil } + if err := validateDeprecatedOptions(opts); err != nil { + return err + } + // Validate and parse allowed tools if len(opts.AllowedTools) > 0 { parsed, err := ParseToolPermissions(opts.AllowedTools) @@ -353,6 +355,25 @@ func PreprocessOptions(opts *RunOptions) error { return nil } +func validateDeprecatedOptions(opts *RunOptions) error { + switch { + case opts.PermissionTool != "": + return NewValidationError("PermissionTool is deprecated and no longer supported by the Claude CLI", "PermissionTool", opts.PermissionTool) + case opts.MaxTurns != 0: + return NewValidationError("MaxTurns is deprecated and no longer supported by the Claude CLI", "MaxTurns", opts.MaxTurns) + case opts.ConfigFile != "": + return NewValidationError("ConfigFile is deprecated and no longer supported by the Claude CLI", "ConfigFile", opts.ConfigFile) + case opts.DisableAutoUpdate: + return NewValidationError("DisableAutoUpdate is deprecated and no longer supported by the Claude CLI", "DisableAutoUpdate", opts.DisableAutoUpdate) + case opts.Theme != "": + return NewValidationError("Theme is deprecated and no longer supported by the Claude CLI", "Theme", opts.Theme) + case opts.PermissionCallback != nil: + return NewValidationError("PermissionCallback is deprecated and no longer supported by the Claude CLI", "PermissionCallback", "set") + default: + return nil + } +} + // isValidModelAlias checks if the model alias is supported func isValidModelAlias(alias string) bool { validAliases := []string{"sonnet", "opus", "haiku"} diff --git a/pkg/claude/structured_run.go b/pkg/claude/structured_run.go index fc5f05a..c739a64 100644 --- a/pkg/claude/structured_run.go +++ b/pkg/claude/structured_run.go @@ -92,15 +92,19 @@ func (c *ClaudeClient) executeStreamJSON(ctx context.Context, prompt string, std } stderrBuf := new(bytes.Buffer) - go func() { - _, _ = io.Copy(stderrBuf, stderr) - }() - if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start command: %w", 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) == "" { @@ -111,6 +115,7 @@ func (c *ClaudeClient) executeStreamJSON(ctx context.Context, prompt string, std if err := json.Unmarshal([]byte(line), &msg); err != nil { cancel() _ = cmd.Wait() + <-stderrDone return fmt.Errorf("failed to parse JSON message: %w", err) } @@ -121,6 +126,7 @@ func (c *ClaudeClient) executeStreamJSON(ctx context.Context, prompt string, std if err := onMessage(msg); err != nil { cancel() _ = cmd.Wait() + <-stderrDone return err } } @@ -128,10 +134,12 @@ func (c *ClaudeClient) executeStreamJSON(ctx context.Context, prompt string, std if err := scanner.Err(); err != nil { cancel() _ = cmd.Wait() + <-stderrDone return fmt.Errorf("scanner error: %w", err) } if err := cmd.Wait(); err != nil { + <-stderrDone var exitCode int if exitError, ok := err.(*exec.ExitError); ok { exitCode = exitError.ExitCode() @@ -144,5 +152,7 @@ func (c *ClaudeClient) executeStreamJSON(ctx context.Context, prompt string, std return claudeErr } + <-stderrDone + return nil } diff --git a/pkg/claude/subagent.go b/pkg/claude/subagent.go index cdf75f8..ed2fef3 100644 --- a/pkg/claude/subagent.go +++ b/pkg/claude/subagent.go @@ -53,8 +53,10 @@ func (sc *SubagentConfig) Validate() error { return nil } -// ToRunOptions converts the SubagentConfig into a CLI-compatible agent -// definition and selects it 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. func (sc *SubagentConfig) ToRunOptions(parentOpts *RunOptions) *RunOptions { agents := map[string]*SubagentConfig{ "subagent": cloneSubagentConfig(sc), diff --git a/pkg/claude/subagent_test.go b/pkg/claude/subagent_test.go index 70bf4a8..1d6e41a 100644 --- a/pkg/claude/subagent_test.go +++ b/pkg/claude/subagent_test.go @@ -130,6 +130,12 @@ func TestSubagentConfig_ToRunOptions(t *testing.T) { 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("inherits from parent", func(t *testing.T) { From b70f12947414625d28397018574445b0c2964226 Mon Sep 17 00:00:00 2001 From: lancekrogers Date: Fri, 17 Apr 2026 06:56:00 -0600 Subject: [PATCH 4/6] [OBEY-CAMPAIGN-78887e36] Address second review round --- docs/PROMPT_SURFACE_NOTES_0.1.1.md | 4 +- pkg/claude/enhanced_test.go | 84 +++++++++++++++++++++++---- pkg/claude/options.go | 92 +++++++++++++++++++----------- pkg/claude/structured_run.go | 17 ++++-- pkg/claude/subagent.go | 19 +++++- pkg/claude/subagent_test.go | 19 ++++++ 6 files changed, 181 insertions(+), 54 deletions(-) diff --git a/docs/PROMPT_SURFACE_NOTES_0.1.1.md b/docs/PROMPT_SURFACE_NOTES_0.1.1.md index f231747..9a0503f 100644 --- a/docs/PROMPT_SURFACE_NOTES_0.1.1.md +++ b/docs/PROMPT_SURFACE_NOTES_0.1.1.md @@ -38,11 +38,11 @@ It is not a published Go module tag or a promise about the eventual release semv ## Behavior Changes - `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 flattens the subagent prompt and tool list into top-level `SystemPrompt` and `AllowedTools`. -- Deprecated top-level fields retained for source compatibility now fail validation when set, instead of being silently ignored at argv construction time. +- 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, but `PreprocessOptions` now rejects them with validation errors when they are set. +- `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. - 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. diff --git a/pkg/claude/enhanced_test.go b/pkg/claude/enhanced_test.go index fb3c5cc..bca0d36 100644 --- a/pkg/claude/enhanced_test.go +++ b/pkg/claude/enhanced_test.go @@ -2,6 +2,8 @@ package claude import ( "context" + "fmt" + "sync" "testing" "time" ) @@ -85,7 +87,14 @@ func TestPreprocessOptions(t *testing.T) { } } -func TestPreprocessOptions_RejectsDeprecatedFields(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 @@ -129,20 +138,75 @@ func TestPreprocessOptions_RejectsDeprecatedFields(t *testing.T) { 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.Fatal("Expected validation error but got nil") + 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]) } - claudeErr, ok := err.(*ClaudeError) - if !ok { - t.Fatalf("Expected ClaudeError but got %T", err) + // 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 claudeErr.Type != ErrorValidation { - t.Fatalf("Expected ErrorValidation, got %v", claudeErr.Type) + if len(warnings) != 1 { + t.Fatalf("Expected warning count to stay at 1, got %d: %v", len(warnings), warnings) } - if !containsSubstring(err.Error(), tt.field) { - t.Fatalf("Expected error to mention %q, got %v", tt.field, err) + }) + } +} + +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) } }) } diff --git a/pkg/claude/options.go b/pkg/claude/options.go index babbd7f..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" ) @@ -35,13 +37,21 @@ const ( type EffortLevel string const ( - EffortLow EffortLevel = "low" + // EffortLow uses the least reasoning budget. + EffortLow EffortLevel = "low" + // EffortMedium uses the default reasoning budget. EffortMedium EffortLevel = "medium" - EffortHigh EffortLevel = "high" - EffortXHigh EffortLevel = "xhigh" - EffortMax EffortLevel = "max" + // 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) @@ -59,14 +69,16 @@ type RunOptions struct { // Supports both legacy format ("Bash") and enhanced format ("Bash(git log:*)") DisallowedTools []string // Deprecated: Claude Code no longer exposes --permission-prompt-tool on the - // current CLI surface. Setting this field returns a validation error. + // 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 // Deprecated: Claude Code no longer exposes --max-turns on the current CLI - // surface. Setting this field returns a validation error. + // surface. Setting this field emits a one-time warning and is ignored during + // argument construction. MaxTurns int // Verbose enables verbose logging Verbose bool @@ -83,17 +95,20 @@ type RunOptions struct { // Timeout specifies the maximum duration for command execution Timeout time.Duration // Deprecated: Claude Code no longer exposes --config on the current CLI - // surface. Setting this field returns a validation error. + // 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 // Deprecated: Claude Code no longer exposes --disable-autoupdate on the - // current CLI surface. Setting this field returns a validation error. + // current CLI surface. Setting this field emits a one-time warning and is + // ignored during argument construction. DisableAutoUpdate bool // Deprecated: Claude Code no longer exposes --theme on the current CLI - // surface. Setting this field returns a validation error. + // 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 @@ -126,8 +141,8 @@ type RunOptions struct { PermissionMode PermissionMode // PermissionCallback is called before each tool use to determine permission // Deprecated: the current Claude CLI no longer exposes a wrapper-safe - // permission callback injection point. Setting this field returns a - // validation error. + // 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 @@ -232,9 +247,7 @@ func PreprocessOptions(opts *RunOptions) error { return nil } - if err := validateDeprecatedOptions(opts); err != nil { - return err - } + warnDeprecatedOptions(opts) // Validate and parse allowed tools if len(opts.AllowedTools) > 0 { @@ -340,14 +353,14 @@ func PreprocessOptions(opts *RunOptions) error { } // Validate stream-specific combinations - if opts.IncludeHookEvents && opts.Format != StreamJSONOutput { + if opts.IncludeHookEvents && !supportsStreamJSONOutput(opts) { return NewValidationError("IncludeHookEvents requires stream-json output", "IncludeHookEvents", opts.IncludeHookEvents) } - if opts.IncludePartialMessages && opts.Format != StreamJSONOutput { + if opts.IncludePartialMessages && !supportsStreamJSONOutput(opts) { return NewValidationError("IncludePartialMessages requires stream-json output", "IncludePartialMessages", opts.IncludePartialMessages) } if opts.ReplayUserMessages { - if opts.Format != StreamJSONOutput || opts.InputFormat != StreamJSONInput { + if !supportsStreamJSONOutput(opts) || opts.InputFormat != StreamJSONInput { return NewValidationError("ReplayUserMessages requires stream-json input and output", "ReplayUserMessages", opts.ReplayUserMessages) } } @@ -355,23 +368,36 @@ func PreprocessOptions(opts *RunOptions) error { return nil } -func validateDeprecatedOptions(opts *RunOptions) error { - switch { - case opts.PermissionTool != "": - return NewValidationError("PermissionTool is deprecated and no longer supported by the Claude CLI", "PermissionTool", opts.PermissionTool) - case opts.MaxTurns != 0: - return NewValidationError("MaxTurns is deprecated and no longer supported by the Claude CLI", "MaxTurns", opts.MaxTurns) - case opts.ConfigFile != "": - return NewValidationError("ConfigFile is deprecated and no longer supported by the Claude CLI", "ConfigFile", opts.ConfigFile) - case opts.DisableAutoUpdate: - return NewValidationError("DisableAutoUpdate is deprecated and no longer supported by the Claude CLI", "DisableAutoUpdate", opts.DisableAutoUpdate) - case opts.Theme != "": - return NewValidationError("Theme is deprecated and no longer supported by the Claude CLI", "Theme", opts.Theme) - case opts.PermissionCallback != nil: - return NewValidationError("PermissionCallback is deprecated and no longer supported by the Claude CLI", "PermissionCallback", "set") - default: - 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 diff --git a/pkg/claude/structured_run.go b/pkg/claude/structured_run.go index c739a64..d6782b2 100644 --- a/pkg/claude/structured_run.go +++ b/pkg/claude/structured_run.go @@ -5,7 +5,6 @@ import ( "bytes" "context" "encoding/json" - "fmt" "io" "os/exec" "strings" @@ -83,17 +82,17 @@ func (c *ClaudeClient) executeStreamJSON(ctx context.Context, prompt string, std stdout, err := cmd.StdoutPipe() if err != nil { - return fmt.Errorf("failed to get stdout pipe: %w", err) + return newWrappedClaudeError(ErrorCommand, "failed to get stdout pipe", err) } stderr, err := cmd.StderrPipe() if err != nil { - return fmt.Errorf("failed to get stderr pipe: %w", err) + return newWrappedClaudeError(ErrorCommand, "failed to get stderr pipe", err) } stderrBuf := new(bytes.Buffer) if err := cmd.Start(); err != nil { - return fmt.Errorf("failed to start command: %w", err) + return newWrappedClaudeError(ErrorCommand, "failed to start command", err) } stderrDone := make(chan struct{}) @@ -116,7 +115,7 @@ func (c *ClaudeClient) executeStreamJSON(ctx context.Context, prompt string, std cancel() _ = cmd.Wait() <-stderrDone - return fmt.Errorf("failed to parse JSON message: %w", err) + return newWrappedClaudeError(ErrorValidation, "failed to parse JSON message", err) } if onMessage == nil { @@ -135,7 +134,7 @@ func (c *ClaudeClient) executeStreamJSON(ctx context.Context, prompt string, std cancel() _ = cmd.Wait() <-stderrDone - return fmt.Errorf("scanner error: %w", err) + return newWrappedClaudeError(ErrorCommand, "failed to scan stream output", err) } if err := cmd.Wait(); err != nil { @@ -156,3 +155,9 @@ func (c *ClaudeClient) executeStreamJSON(ctx context.Context, prompt string, std 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 ed2fef3..3c2a3b0 100644 --- a/pkg/claude/subagent.go +++ b/pkg/claude/subagent.go @@ -56,13 +56,26 @@ func (sc *SubagentConfig) Validate() error { // 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. +// 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 { + return sc.ToNamedRunOptions("subagent", parentOpts) +} + +// 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" + } + agents := map[string]*SubagentConfig{ - "subagent": cloneSubagentConfig(sc), + agentName: cloneSubagentConfig(sc), } - opts := buildAgentRunOptions("subagent", parentOpts, agents) + opts := buildAgentRunOptions(agentName, parentOpts, agents) if sc.WorkingDirectory != "" { opts.WorkingDirectory = sc.WorkingDirectory } diff --git a/pkg/claude/subagent_test.go b/pkg/claude/subagent_test.go index 1d6e41a..c69f9db 100644 --- a/pkg/claude/subagent_test.go +++ b/pkg/claude/subagent_test.go @@ -138,6 +138,25 @@ func TestSubagentConfig_ToRunOptions(t *testing.T) { } }) + 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) { config := &SubagentConfig{ Description: "Test agent", From e6981c0714d93a5679e86d4ae704f0cf4a5ea8cf Mon Sep 17 00:00:00 2001 From: lancekrogers Date: Fri, 17 Apr 2026 15:31:29 -0600 Subject: [PATCH 5/6] [OBEY-CAMPAIGN-78887e36] Correct release numbering to v1.2.0 --- README.md | 2 +- ...RFACE_NOTES_0.1.1.md => RELEASE_NOTES_v1.2.0.md} | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) rename docs/{PROMPT_SURFACE_NOTES_0.1.1.md => RELEASE_NOTES_v1.2.0.md} (87%) diff --git a/README.md b/README.md index f2b240b..76a17f5 100644 --- a/README.md +++ b/README.md @@ -506,7 +506,7 @@ just lint - [docs/DEMOS.md](docs/DEMOS.md) - [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) -- [docs/PROMPT_SURFACE_NOTES_0.1.1.md](docs/PROMPT_SURFACE_NOTES_0.1.1.md) +- [docs/RELEASE_NOTES_v1.2.0.md](docs/RELEASE_NOTES_v1.2.0.md) ## Contributing diff --git a/docs/PROMPT_SURFACE_NOTES_0.1.1.md b/docs/RELEASE_NOTES_v1.2.0.md similarity index 87% rename from docs/PROMPT_SURFACE_NOTES_0.1.1.md rename to docs/RELEASE_NOTES_v1.2.0.md index 9a0503f..0e6b748 100644 --- a/docs/PROMPT_SURFACE_NOTES_0.1.1.md +++ b/docs/RELEASE_NOTES_v1.2.0.md @@ -1,7 +1,6 @@ -# Prompt Surface Notes (Planning Label 0.1.1) +# v1.2.0 - Prompt Surface Refresh -This document tracks the prompt-surface refresh work under the branch planning label `0.1.1`. -It is not a published Go module tag or a promise about the eventual release semver. +This release aligns the SDK with the current wrapper-safe `claude -p` surface. ## Highlights @@ -9,7 +8,7 @@ It is not a published Go module tag or a promise about the eventual release semv - 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 release notes to scope the SDK honestly to `claude -p`. +- Rewrote the README and examples to scope the SDK honestly to `claude -p`. ## Added Prompt Flags @@ -48,4 +47,8 @@ It is not a published Go module tag or a promise about the eventual release semv ## Verification -- `go test ./pkg/claude/...` +- `just release check` + +## Full Changelog + +**[v1.1.0...v1.2.0](https://github.com/lancekrogers/claude-code-go/compare/v1.1.0...v1.2.0)** From 403667d179772832aee85af1ff94771bc1d4e292 Mon Sep 17 00:00:00 2001 From: lancekrogers Date: Sat, 18 Apr 2026 10:59:55 -0600 Subject: [PATCH 6/6] [OBEY-CAMPAIGN-78887e36] Polish v1.2.0 release notes --- docs/RELEASE_NOTES_v1.2.0.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/RELEASE_NOTES_v1.2.0.md b/docs/RELEASE_NOTES_v1.2.0.md index 0e6b748..439fa9a 100644 --- a/docs/RELEASE_NOTES_v1.2.0.md +++ b/docs/RELEASE_NOTES_v1.2.0.md @@ -36,18 +36,23 @@ This release aligns the SDK with the current wrapper-safe `claude -p` surface. ## Behavior Changes -- `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 flattens the subagent prompt and tool list into top-level `SystemPrompt` and `AllowedTools`. +- 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. +- `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