diff --git a/cmd/gateway.go b/cmd/gateway.go
index 58bf7b69..8c3ead74 100644
--- a/cmd/gateway.go
+++ b/cmd/gateway.go
@@ -370,7 +370,8 @@ func runGateway() {
// Register providers from DB (overrides config providers).
if pgStores.Providers != nil {
- registerProvidersFromDB(providerRegistry, pgStores.Providers, pgStores.ConfigSecrets)
+ dbGatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port)
+ registerProvidersFromDB(providerRegistry, pgStores.Providers, pgStores.ConfigSecrets, dbGatewayAddr, cfg.Gateway.Token)
}
// Wire embedding provider to PGMemoryStore so IndexDocument generates vectors.
@@ -580,7 +581,8 @@ func runGateway() {
}
contextFileInterceptor, delegateMgr = wireExtras(pgStores, agentRouter, providerRegistry, msgBus, pgStores.Sessions, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, dynamicLoader)
- agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH := wireHTTP(pgStores, cfg.Gateway.Token, msgBus, toolsReg, providerRegistry, permPE.IsOwner)
+ gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port)
+ agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH := wireHTTP(pgStores, cfg.Gateway.Token, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr)
if agentsH != nil {
server.SetAgentsHandler(agentsH)
}
@@ -955,6 +957,9 @@ func runGateway() {
channelMgr.StopAll(context.Background())
pgStores.Cron.Stop()
+ // Close provider resources (e.g. Claude CLI temp files)
+ providerRegistry.Close()
+
// Stop sandbox pruning + release containers
if sandboxMgr != nil {
sandboxMgr.Stop()
diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go
index dfee694c..3a0b855b 100644
--- a/cmd/gateway_managed.go
+++ b/cmd/gateway_managed.go
@@ -474,7 +474,7 @@ func wireExtras(
}
// wireHTTP creates HTTP handlers (agents + skills + traces + MCP + custom tools + channel instances + providers + delegations + builtin tools).
-func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.DelegationsHandler, *httpapi.BuiltinToolsHandler) {
+func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.DelegationsHandler, *httpapi.BuiltinToolsHandler) {
var agentsH *httpapi.AgentsHandler
var skillsH *httpapi.SkillsHandler
var tracesH *httpapi.TracesHandler
@@ -519,7 +519,7 @@ func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsR
}
if stores != nil && stores.Providers != nil {
- providersH = httpapi.NewProvidersHandler(stores.Providers, stores.ConfigSecrets, token, providerReg)
+ providersH = httpapi.NewProvidersHandler(stores.Providers, stores.ConfigSecrets, token, providerReg, gatewayAddr)
}
if stores != nil && stores.Teams != nil {
diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go
index 11e95dba..c5a41e19 100644
--- a/cmd/gateway_providers.go
+++ b/cmd/gateway_providers.go
@@ -3,6 +3,10 @@ package cmd
import (
"context"
"log/slog"
+ "net"
+ "os/exec"
+ "path/filepath"
+ "strconv"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/oauth"
@@ -10,6 +14,15 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/store"
)
+// loopbackAddr normalizes a gateway address for local connections.
+// CLI processes on the same machine can't connect to 0.0.0.0 on some OSes.
+func loopbackAddr(host string, port int) string {
+ if host == "" || host == "0.0.0.0" || host == "::" {
+ host = "127.0.0.1"
+ }
+ return net.JoinHostPort(host, strconv.Itoa(port))
+}
+
func registerProviders(registry *providers.Registry, cfg *config.Config) {
if cfg.Providers.Anthropic.APIKey != "" {
registry.Register(providers.NewAnthropicProvider(cfg.Providers.Anthropic.APIKey,
@@ -81,11 +94,40 @@ func registerProviders(registry *providers.Registry, cfg *config.Config) {
registry.Register(providers.NewOpenAIProvider("bailian", cfg.Providers.Bailian.APIKey, base, "qwen3.5-plus"))
slog.Info("registered provider", "name", "bailian")
}
+
+ // Claude CLI provider (subscription-based, no API key needed)
+ if cfg.Providers.ClaudeCLI.CLIPath != "" {
+ cliPath := cfg.Providers.ClaudeCLI.CLIPath
+ var opts []providers.ClaudeCLIOption
+ if cfg.Providers.ClaudeCLI.Model != "" {
+ opts = append(opts, providers.WithClaudeCLIModel(cfg.Providers.ClaudeCLI.Model))
+ }
+ if cfg.Providers.ClaudeCLI.BaseWorkDir != "" {
+ opts = append(opts, providers.WithClaudeCLIWorkDir(cfg.Providers.ClaudeCLI.BaseWorkDir))
+ }
+ if cfg.Providers.ClaudeCLI.PermMode != "" {
+ opts = append(opts, providers.WithClaudeCLIPermMode(cfg.Providers.ClaudeCLI.PermMode))
+ }
+ // Build MCP config: external MCP servers + GoClaw bridge (built-in tools via streamable-http)
+ gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port)
+ mcpPath, mcpCleanup, err := providers.BuildCLIMCPConfig(cfg.Tools.McpServers, gatewayAddr, cfg.Gateway.Token)
+ if err != nil {
+ slog.Warn("failed to build MCP config for claude-cli", "error", err)
+ } else if mcpPath != "" {
+ opts = append(opts, providers.WithClaudeCLIMCPConfig(mcpPath, mcpCleanup))
+ }
+ // Enable GoClaw security hooks (shell deny patterns, path restrictions)
+ opts = append(opts, providers.WithClaudeCLISecurityHooks(
+ cfg.Providers.ClaudeCLI.BaseWorkDir, true))
+ registry.Register(providers.NewClaudeCLIProvider(cliPath, opts...))
+ slog.Info("registered provider", "name", "claude-cli")
+ }
}
// registerProvidersFromDB loads providers from Postgres and registers them.
// DB providers are registered after config providers, so they take precedence (overwrite).
-func registerProvidersFromDB(registry *providers.Registry, provStore store.ProviderStore, secretStore store.ConfigSecretsStore) {
+// gatewayAddr is used to inject GoClaw MCP bridge for Claude CLI providers.
+func registerProvidersFromDB(registry *providers.Registry, provStore store.ProviderStore, secretStore store.ConfigSecretsStore, gatewayAddr, gatewayToken string) {
ctx := context.Background()
dbProviders, err := provStore.ListProviders(ctx)
if err != nil {
@@ -93,7 +135,39 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
return
}
for _, p := range dbProviders {
- if !p.Enabled || p.APIKey == "" {
+ // Claude CLI doesn't need API key
+ if !p.Enabled {
+ continue
+ }
+ if p.ProviderType == store.ProviderClaudeCLI {
+ cliPath := p.APIBase // reuse APIBase field for CLI path
+ if cliPath == "" {
+ cliPath = "claude"
+ }
+ // Validate: only accept "claude" or absolute path
+ if cliPath != "claude" && !filepath.IsAbs(cliPath) {
+ slog.Warn("security.claude_cli: invalid path from DB, using default", "path", cliPath)
+ cliPath = "claude"
+ }
+ if _, err := exec.LookPath(cliPath); err != nil {
+ slog.Warn("claude-cli: binary not found, skipping", "path", cliPath, "error", err)
+ continue
+ }
+ var cliOpts []providers.ClaudeCLIOption
+ cliOpts = append(cliOpts, providers.WithClaudeCLISecurityHooks("", true))
+ if gatewayAddr != "" {
+ mcpPath, mcpCleanup, mcpErr := providers.BuildCLIMCPConfig(nil, gatewayAddr, gatewayToken)
+ if mcpErr != nil {
+ slog.Warn("failed to build MCP config for db claude-cli", "error", mcpErr)
+ } else if mcpPath != "" {
+ cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfig(mcpPath, mcpCleanup))
+ }
+ }
+ registry.Register(providers.NewClaudeCLIProvider(cliPath, cliOpts...))
+ slog.Info("registered provider from DB", "name", p.Name)
+ continue
+ }
+ if p.APIKey == "" {
continue
}
if p.ProviderType == store.ProviderChatGPTOAuth {
diff --git a/cmd/onboard.go b/cmd/onboard.go
index cc4ee67a..cda3e4cb 100644
--- a/cmd/onboard.go
+++ b/cmd/onboard.go
@@ -1,14 +1,17 @@
package cmd
import (
+ "context"
"fmt"
"os"
+ "os/exec"
"path/filepath"
"strconv"
"github.com/spf13/cobra"
"github.com/nextlevelbuilder/goclaw/internal/config"
+ "github.com/nextlevelbuilder/goclaw/internal/providers"
)
func onboardCmd() *cobra.Command {
@@ -39,6 +42,7 @@ var providerMap = map[string]providerInfo{
"minimax": {"minimax", "GOCLAW_MINIMAX_API_KEY", "MiniMax-M2.5"},
"cohere": {"cohere", "GOCLAW_COHERE_API_KEY", "command-a"},
"perplexity": {"perplexity", "GOCLAW_PERPLEXITY_API_KEY", "sonar-pro"},
+ "claude_cli": {"claude-cli", "", "sonnet"},
"custom": {"custom", "", ""},
}
@@ -185,6 +189,7 @@ func runOnboard() {
{"MiniMax (MiniMax models)", "minimax"},
{"Cohere (Command models)", "cohere"},
{"Perplexity (Sonar search models)", "perplexity"},
+ {"Claude CLI (use local claude CLI — no API key needed)", "claude_cli"},
{"Custom (any OpenAI-compatible endpoint)", "custom"},
}
@@ -195,8 +200,76 @@ func runOnboard() {
return
}
+ // Claude CLI variables
+ var cliPath, cliModel string
+ if cfg.Providers.ClaudeCLI.CLIPath != "" {
+ cliPath = cfg.Providers.ClaudeCLI.CLIPath
+ cliModel = cfg.Providers.ClaudeCLI.Model
+ }
+
// Provider-specific prompts
switch providerChoice {
+ case "claude_cli":
+ if cliPath == "" {
+ cliPath = "claude"
+ }
+ cliPath, err = promptString("Claude CLI Path", "Path to the claude binary", cliPath)
+ if err != nil {
+ fmt.Println("Cancelled.")
+ return
+ }
+
+ // Verify CLI binary exists
+ if _, lookErr := exec.LookPath(cliPath); lookErr != nil {
+ fmt.Printf(" ✗ Claude CLI binary not found at %q\n", cliPath)
+ fmt.Println(" Install Claude CLI first: https://docs.anthropic.com/en/docs/claude-cli")
+ return
+ }
+
+ // Check authentication status
+ fmt.Print(" Checking Claude CLI authentication... ")
+ authStatus, authErr := providers.CheckClaudeAuthStatus(context.Background(), cliPath)
+ if authErr != nil || !authStatus.LoggedIn {
+ if authErr != nil {
+ fmt.Println("FAILED")
+ fmt.Printf(" %v\n", authErr)
+ } else {
+ fmt.Println("NOT LOGGED IN")
+ }
+ fmt.Println()
+ runLogin, confirmErr := promptConfirm("Claude CLI requires authentication. Run login now?", true)
+ if confirmErr != nil {
+ fmt.Println("Cancelled.")
+ return
+ }
+ if runLogin {
+ if loginErr := runClaudeAuthLogin(cliPath); loginErr != nil {
+ fmt.Printf(" Login failed: %v\n", loginErr)
+ fmt.Println(" You can try manually: claude auth login")
+ return
+ }
+ } else {
+ fmt.Println(" Skipping login. Run 'claude auth login' before starting the gateway.")
+ }
+ } else {
+ sub := authStatus.SubscriptionType
+ if sub == "" {
+ sub = "unknown"
+ }
+ fmt.Printf("OK\n")
+ fmt.Printf(" ✓ Authenticated as %s (subscription: %s)\n", authStatus.Email, sub)
+ }
+ fmt.Println()
+
+ if cliModel == "" {
+ cliModel = "sonnet"
+ }
+ cliModel, err = promptString("Default Model", "Model alias: sonnet, opus, haiku", cliModel)
+ if err != nil {
+ fmt.Println("Cancelled.")
+ return
+ }
+
case "custom":
customAPIBase, err = promptString("API Base URL", "OpenAI-compatible endpoint (e.g. Ollama, vLLM, LiteLLM)", customAPIBase)
if err != nil {
@@ -360,6 +433,10 @@ func runOnboard() {
if customModel == "" {
errors = append(errors, "Model ID is required for custom provider")
}
+ } else if providerChoice == "claude_cli" {
+ if cliPath == "" {
+ errors = append(errors, "Claude CLI path is required")
+ }
} else if apiKey == "" {
errors = append(errors, fmt.Sprintf("API key is required for %s", providerChoice))
}
@@ -400,7 +477,12 @@ func runOnboard() {
// --- Apply collected values to config ---
// Provider & model
- if providerChoice == "custom" {
+ if providerChoice == "claude_cli" {
+ cfg.Agents.Defaults.Provider = "claude-cli"
+ cfg.Agents.Defaults.Model = cliModel
+ cfg.Providers.ClaudeCLI.CLIPath = cliPath
+ cfg.Providers.ClaudeCLI.Model = cliModel
+ } else if providerChoice == "custom" {
cfg.Agents.Defaults.Provider = "openai"
cfg.Providers.OpenAI.APIBase = customAPIBase
cfg.Providers.OpenAI.APIKey = apiKey
@@ -562,7 +644,10 @@ func runOnboard() {
savedTtsElevenLabsKey := cfg.Tts.ElevenLabs.APIKey
savedTtsMiniMaxKey := cfg.Tts.MiniMax.APIKey
- cfg.Providers = config.ProvidersConfig{}
+ // Clear secrets from providers but keep non-secret configs (like ClaudeCLI)
+ cfg.Providers = config.ProvidersConfig{
+ ClaudeCLI: savedProviders.ClaudeCLI, // no secrets, safe to save in config
+ }
cfg.Gateway.Token = ""
cfg.Channels.Telegram.Token = ""
cfg.Channels.Zalo.Token = ""
diff --git a/cmd/onboard_claude_cli.go b/cmd/onboard_claude_cli.go
new file mode 100644
index 00000000..f2d740b8
--- /dev/null
+++ b/cmd/onboard_claude_cli.go
@@ -0,0 +1,18 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+)
+
+// runClaudeAuthLogin runs `claude auth login` interactively,
+// connecting stdin/stdout/stderr to the terminal for the browser OAuth flow.
+func runClaudeAuthLogin(cliPath string) error {
+ fmt.Println(" Opening browser for Claude authentication...")
+ cmd := exec.Command(cliPath, "auth", "login")
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ return cmd.Run()
+}
diff --git a/cmd/onboard_helpers.go b/cmd/onboard_helpers.go
index eb045aa9..19d363e8 100644
--- a/cmd/onboard_helpers.go
+++ b/cmd/onboard_helpers.go
@@ -26,6 +26,14 @@ func onboardWriteEnvFile(path string, cfg *config.Config, primaryKey, primaryEnv
lines = append(lines, fmt.Sprintf("export %s=%s", primaryEnvKey, primaryKey))
}
+ // Claude CLI config (no API key, just path/model)
+ if cfg.Providers.ClaudeCLI.CLIPath != "" {
+ lines = append(lines, fmt.Sprintf("export GOCLAW_CLAUDE_CLI_PATH=%s", cfg.Providers.ClaudeCLI.CLIPath))
+ if cfg.Providers.ClaudeCLI.Model != "" {
+ lines = append(lines, fmt.Sprintf("export GOCLAW_CLAUDE_CLI_MODEL=%s", cfg.Providers.ClaudeCLI.Model))
+ }
+ }
+
// Add other configured provider keys (skip if same as primary)
addIfSet := func(envKey, value string) {
if value != "" && (primaryEnvKey == "" || envKey != primaryEnvKey) {
diff --git a/internal/agent/loop.go b/internal/agent/loop.go
index 59aa9d7d..97f83c0a 100644
--- a/internal/agent/loop.go
+++ b/internal/agent/loop.go
@@ -426,6 +426,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
Options: map[string]interface{}{
providers.OptMaxTokens: 8192,
providers.OptTemperature: 0.7,
+ providers.OptSessionKey: req.SessionKey,
},
}
if l.thinkingLevel != "" && l.thinkingLevel != "off" {
diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go
index 5b927066..fd9a5845 100644
--- a/internal/config/config_channels.go
+++ b/internal/config/config_channels.go
@@ -147,19 +147,28 @@ type FeishuConfig struct {
// ProvidersConfig maps provider name to its config.
type ProvidersConfig struct {
- Anthropic ProviderConfig `json:"anthropic"`
- OpenAI ProviderConfig `json:"openai"`
- OpenRouter ProviderConfig `json:"openrouter"`
- Groq ProviderConfig `json:"groq"`
- Gemini ProviderConfig `json:"gemini"`
- DeepSeek ProviderConfig `json:"deepseek"`
- Mistral ProviderConfig `json:"mistral"`
- XAI ProviderConfig `json:"xai"`
- MiniMax ProviderConfig `json:"minimax"`
- Cohere ProviderConfig `json:"cohere"`
- Perplexity ProviderConfig `json:"perplexity"`
- DashScope ProviderConfig `json:"dashscope"`
- Bailian ProviderConfig `json:"bailian"`
+ Anthropic ProviderConfig `json:"anthropic"`
+ OpenAI ProviderConfig `json:"openai"`
+ OpenRouter ProviderConfig `json:"openrouter"`
+ Groq ProviderConfig `json:"groq"`
+ Gemini ProviderConfig `json:"gemini"`
+ DeepSeek ProviderConfig `json:"deepseek"`
+ Mistral ProviderConfig `json:"mistral"`
+ XAI ProviderConfig `json:"xai"`
+ MiniMax ProviderConfig `json:"minimax"`
+ Cohere ProviderConfig `json:"cohere"`
+ Perplexity ProviderConfig `json:"perplexity"`
+ DashScope ProviderConfig `json:"dashscope"`
+ Bailian ProviderConfig `json:"bailian"`
+ ClaudeCLI ClaudeCLIConfig `json:"claude_cli"`
+}
+
+// ClaudeCLIConfig configures the Claude CLI provider (uses subscription, not API key).
+type ClaudeCLIConfig struct {
+ CLIPath string `json:"cli_path" yaml:"cli_path"` // path to claude binary (default: "claude")
+ Model string `json:"model" yaml:"model"` // default model alias (default: "sonnet")
+ BaseWorkDir string `json:"base_work_dir" yaml:"base_work_dir"` // base dir for agent workspaces
+ PermMode string `json:"perm_mode" yaml:"perm_mode"` // permission mode (default: "bypassPermissions")
}
type ProviderConfig struct {
@@ -167,7 +176,7 @@ type ProviderConfig struct {
APIBase string `json:"api_base,omitempty"`
}
-// HasAnyProvider returns true if at least one provider has an API key configured.
+// HasAnyProvider returns true if at least one provider has an API key or CLI configured.
func (c *Config) HasAnyProvider() bool {
p := c.Providers
return p.Anthropic.APIKey != "" ||
@@ -182,7 +191,8 @@ func (c *Config) HasAnyProvider() bool {
p.Cohere.APIKey != "" ||
p.Perplexity.APIKey != "" ||
p.DashScope.APIKey != "" ||
- p.Bailian.APIKey != ""
+ p.Bailian.APIKey != "" ||
+ p.ClaudeCLI.CLIPath != ""
}
// QuotaWindow defines request limits per time window. Zero means unlimited.
diff --git a/internal/config/config_load.go b/internal/config/config_load.go
index 19143c0b..dccbc1e2 100644
--- a/internal/config/config_load.go
+++ b/internal/config/config_load.go
@@ -143,6 +143,11 @@ func (c *Config) applyEnvOverrides() {
c.Channels.WhatsApp.Enabled = true
}
+ // Claude CLI provider
+ envStr("GOCLAW_CLAUDE_CLI_PATH", &c.Providers.ClaudeCLI.CLIPath)
+ envStr("GOCLAW_CLAUDE_CLI_MODEL", &c.Providers.ClaudeCLI.Model)
+ envStr("GOCLAW_CLAUDE_CLI_WORK_DIR", &c.Providers.ClaudeCLI.BaseWorkDir)
+
// Default provider/model: env overrides config.
envStr("GOCLAW_PROVIDER", &c.Agents.Defaults.Provider)
envStr("GOCLAW_MODEL", &c.Agents.Defaults.Model)
diff --git a/internal/gateway/server.go b/internal/gateway/server.go
index 28c04eb8..6e53c2ef 100644
--- a/internal/gateway/server.go
+++ b/internal/gateway/server.go
@@ -16,6 +16,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
+ mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
@@ -198,10 +199,35 @@ func (s *Server) BuildMux() *http.ServeMux {
s.oauthHandler.RegisterRoutes(mux)
}
+ // MCP bridge: expose GoClaw tools to Claude CLI via streamable-http.
+ // Only listens on localhost (CLI runs on the same machine).
+ // Protected by gateway token when configured.
+ if s.tools != nil {
+ bridgeHandler := mcpbridge.NewBridgeServer(s.tools, "1.0.0")
+ if s.cfg.Gateway.Token != "" {
+ mux.Handle("/mcp/bridge", tokenAuthMiddleware(s.cfg.Gateway.Token, bridgeHandler))
+ } else {
+ slog.Warn("security.mcp_bridge: no gateway token configured, MCP bridge tools are unauthenticated")
+ mux.Handle("/mcp/bridge", bridgeHandler)
+ }
+ }
+
s.mux = mux
return mux
}
+// tokenAuthMiddleware wraps an http.Handler with Bearer token authentication.
+func tokenAuthMiddleware(token string, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ auth := r.Header.Get("Authorization")
+ if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != token {
+ http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
// Start begins listening for WebSocket and HTTP connections.
func (s *Server) Start(ctx context.Context) error {
mux := s.BuildMux()
diff --git a/internal/http/agents.go b/internal/http/agents.go
index d674cd5d..582f9ff6 100644
--- a/internal/http/agents.go
+++ b/internal/http/agents.go
@@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"net/http"
+ "strings"
"github.com/google/uuid"
@@ -117,6 +118,12 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
return
}
+ // Check for duplicate agent_key before creating
+ if existing, _ := h.agents.GetByKey(r.Context(), req.AgentKey); existing != nil {
+ writeJSON(w, http.StatusConflict, map[string]string{"error": "An agent with key \"" + req.AgentKey + "\" already exists"})
+ return
+ }
+
req.OwnerID = userID
if req.AgentType == "" {
req.AgentType = store.AgentTypeOpen
@@ -149,7 +156,11 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
}
if err := h.agents.Create(r.Context(), &req); err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "23505") {
+ writeJSON(w, http.StatusConflict, map[string]string{"error": "An agent with this key already exists"})
+ } else {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ }
return
}
diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go
index a693d9ae..9e3ad66b 100644
--- a/internal/http/provider_models.go
+++ b/internal/http/provider_models.go
@@ -11,6 +11,7 @@ import (
"time"
"github.com/google/uuid"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
)
// ModelInfo is a normalized model entry returned by the list-models endpoint.
@@ -36,6 +37,12 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht
return
}
+ // Claude CLI doesn't need an API key — return hardcoded models
+ if p.ProviderType == store.ProviderClaudeCLI {
+ writeJSON(w, http.StatusOK, map[string]interface{}{"models": claudeCLIModels()})
+ return
+ }
+
if p.APIKey == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider has no API key configured"})
return
@@ -175,6 +182,15 @@ func minimaxModels() []ModelInfo {
}
}
+// claudeCLIModels returns the model aliases accepted by the Claude CLI.
+func claudeCLIModels() []ModelInfo {
+ return []ModelInfo{
+ {ID: "sonnet", Name: "Sonnet"},
+ {ID: "opus", Name: "Opus"},
+ {ID: "haiku", Name: "Haiku"},
+ }
+}
+
// fetchOpenAIModels calls an OpenAI-compatible /models endpoint.
func fetchOpenAIModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo, error) {
req, err := http.NewRequestWithContext(ctx, "GET", apiBase+"/models", nil)
diff --git a/internal/http/provider_verify.go b/internal/http/provider_verify.go
index 303c8dab..20cccba3 100644
--- a/internal/http/provider_verify.go
+++ b/internal/http/provider_verify.go
@@ -42,6 +42,17 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R
return
}
+ // Claude CLI: validate model alias locally (no LLM call needed)
+ if p.ProviderType == "claude_cli" {
+ validModels := map[string]bool{"sonnet": true, "opus": true, "haiku": true}
+ if validModels[req.Model] {
+ writeJSON(w, http.StatusOK, map[string]interface{}{"valid": true})
+ } else {
+ writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "Invalid model. Use: sonnet, opus, or haiku"})
+ }
+ return
+ }
+
if h.providerReg == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "no provider registry available"})
return
@@ -73,6 +84,42 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": true})
}
+// handleClaudeCLIAuthStatus checks whether the Claude CLI is authenticated on the server.
+//
+// GET /v1/providers/claude-cli/auth-status
+// Response: {"logged_in": true, "email": "...", "subscription_type": "max"}
+// or: {"logged_in": false, "error": "..."}
+func (h *ProvidersHandler) handleClaudeCLIAuthStatus(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
+ defer cancel()
+
+ // Try to find CLI path from existing Claude CLI provider in DB
+ cliPath := "claude"
+ if existing, err := h.store.ListProviders(r.Context()); err == nil {
+ for _, p := range existing {
+ if p.ProviderType == "claude_cli" && p.APIBase != "" {
+ cliPath = p.APIBase
+ break
+ }
+ }
+ }
+
+ status, err := providers.CheckClaudeAuthStatus(ctx, cliPath)
+ if err != nil {
+ writeJSON(w, http.StatusOK, map[string]interface{}{
+ "logged_in": false,
+ "error": err.Error(),
+ })
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]interface{}{
+ "logged_in": status.LoggedIn,
+ "email": status.Email,
+ "subscription_type": status.SubscriptionType,
+ })
+}
+
// friendlyVerifyError extracts a human-readable message from provider errors.
// Raw errors often contain JSON blobs like: `HTTP 400: minimax: {"type":"error","error":{"type":"bad_request_error","message":"unknown model ..."}}`
func friendlyVerifyError(err error) string {
diff --git a/internal/http/providers.go b/internal/http/providers.go
index 7f4d8d5d..d07aedf4 100644
--- a/internal/http/providers.go
+++ b/internal/http/providers.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"log/slog"
"net/http"
+ "sync"
"github.com/google/uuid"
@@ -18,11 +19,13 @@ type ProvidersHandler struct {
secretStore store.ConfigSecretsStore
token string
providerReg *providers.Registry
+ gatewayAddr string // for injecting MCP bridge into Claude CLI providers
+ cliMu sync.Mutex // serializes Claude CLI provider create to prevent duplicates
}
// NewProvidersHandler creates a handler for provider management endpoints.
-func NewProvidersHandler(s store.ProviderStore, secretStore store.ConfigSecretsStore, token string, providerReg *providers.Registry) *ProvidersHandler {
- return &ProvidersHandler{store: s, secretStore: secretStore, token: token, providerReg: providerReg}
+func NewProvidersHandler(s store.ProviderStore, secretStore store.ConfigSecretsStore, token string, providerReg *providers.Registry, gatewayAddr string) *ProvidersHandler {
+ return &ProvidersHandler{store: s, secretStore: secretStore, token: token, providerReg: providerReg, gatewayAddr: gatewayAddr}
}
// RegisterRoutes registers all provider management routes on the given mux.
@@ -39,6 +42,9 @@ func (h *ProvidersHandler) RegisterRoutes(mux *http.ServeMux) {
// Provider + model verification (pre-flight check)
mux.HandleFunc("POST /v1/providers/{id}/verify", h.auth(h.handleVerifyProvider))
+
+ // Claude CLI auth status (global — not per-provider)
+ mux.HandleFunc("GET /v1/providers/claude-cli/auth-status", h.auth(h.handleClaudeCLIAuthStatus))
}
func (h *ProvidersHandler) auth(next http.HandlerFunc) http.HandlerFunc {
@@ -63,7 +69,29 @@ func maskAPIKey(p *store.LLMProviderData) {
// registerInMemory adds (or replaces) a provider in the in-memory registry
// so it's immediately usable for verify/chat without a gateway restart.
func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) {
- if h.providerReg == nil || !p.Enabled || p.APIKey == "" {
+ if h.providerReg == nil || !p.Enabled {
+ return
+ }
+ // Claude CLI doesn't need an API key — register immediately
+ if p.ProviderType == store.ProviderClaudeCLI {
+ cliPath := p.APIBase // reuse APIBase field for CLI path
+ if cliPath == "" {
+ cliPath = "claude"
+ }
+ var cliOpts []providers.ClaudeCLIOption
+ cliOpts = append(cliOpts, providers.WithClaudeCLISecurityHooks("", true))
+ if h.gatewayAddr != "" {
+ mcpPath, mcpCleanup, mcpErr := providers.BuildCLIMCPConfig(nil, h.gatewayAddr, h.token)
+ if mcpErr != nil {
+ slog.Warn("failed to build MCP config for in-memory claude-cli", "error", mcpErr)
+ } else if mcpPath != "" {
+ cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfig(mcpPath, mcpCleanup))
+ }
+ }
+ h.providerReg.Register(providers.NewClaudeCLIProvider(cliPath, cliOpts...))
+ return
+ }
+ if p.APIKey == "" {
return
}
if p.ProviderType == store.ProviderChatGPTOAuth {
@@ -126,6 +154,23 @@ func (h *ProvidersHandler) handleCreateProvider(w http.ResponseWriter, r *http.R
return
}
+ // Only one Claude CLI provider is allowed per instance (1 machine = 1 auth session).
+ // Mutex serializes check+create to prevent TOCTOU race.
+ if p.ProviderType == store.ProviderClaudeCLI {
+ h.cliMu.Lock()
+ defer h.cliMu.Unlock()
+
+ existing, _ := h.store.ListProviders(r.Context())
+ for _, ep := range existing {
+ if ep.ProviderType == store.ProviderClaudeCLI {
+ writeJSON(w, http.StatusConflict, map[string]string{
+ "error": "A Claude CLI provider already exists. Only one is allowed per instance.",
+ })
+ return
+ }
+ }
+ }
+
if err := h.store.CreateProvider(r.Context(), &p); err != nil {
slog.Error("providers.create", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
diff --git a/internal/http/summoner.go b/internal/http/summoner.go
index ef350cc4..8c1d6f0c 100644
--- a/internal/http/summoner.go
+++ b/internal/http/summoner.go
@@ -307,23 +307,32 @@ func (s *AgentSummoner) generateFiles(ctx context.Context, providerName, model,
return nil, fmt.Errorf("resolve provider: %w", err)
}
+ // Use a unique session key so CLI-based providers get an isolated workdir
+ // (prevents polluting/reading CLAUDE.md from active chat sessions).
+ summonSessionKey := "summon-" + uuid.New().String()
+
slog.Info("summoning: calling LLM", "provider", providerName, "model", model, "prompt_len", len(prompt))
resp, err := provider.Chat(ctx, providers.ChatRequest{
Messages: []providers.Message{
+ {Role: "system", Content: "You are a file generator. Output ONLY the requested XML-tagged files. No extra commentary."},
{Role: "user", Content: prompt},
},
Model: model,
Options: map[string]interface{}{
- "max_tokens": 8192,
- "temperature": 0.7,
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ providers.OptSessionKey: summonSessionKey,
+ providers.OptDisableTools: true,
},
})
if err != nil {
return nil, fmt.Errorf("%s: %w", providerName, err)
}
- slog.Info("summoning: LLM responded", "provider", providerName, "content_len", len(resp.Content))
+ slog.Info("summoning: raw LLM response", "provider", providerName, "length", len(resp.Content),
+ "preview_start", truncateUTF8(resp.Content, 500),
+ "preview_end", truncateUTF8(suffixString(resp.Content, 500), 500))
files := parseFileResponse(resp.Content)
if len(files) == 0 {
@@ -672,6 +681,15 @@ func extractIdentityName(content string) string {
return strings.TrimSpace(m[1])
}
+// suffixString returns the last n runes of s.
+func suffixString(s string, n int) string {
+ runes := []rune(s)
+ if len(runes) <= n {
+ return s
+ }
+ return string(runes[len(runes)-n:])
+}
+
// truncateUTF8 truncates s to at most maxLen runes, appending "…" if truncated.
func truncateUTF8(s string, maxLen int) string {
runes := []rune(s)
diff --git a/internal/mcp/bridge_server.go b/internal/mcp/bridge_server.go
new file mode 100644
index 00000000..106fc389
--- /dev/null
+++ b/internal/mcp/bridge_server.go
@@ -0,0 +1,98 @@
+package mcp
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+
+ mcpgo "github.com/mark3labs/mcp-go/mcp"
+ mcpserver "github.com/mark3labs/mcp-go/server"
+
+ "github.com/nextlevelbuilder/goclaw/internal/tools"
+)
+
+// BridgeToolNames is the subset of GoClaw tools exposed via the MCP bridge.
+// Excluded: spawn (agent loop), message/create_forum_topic (channels),
+// handoff/delegate_search/evaluate_loop/team_* (managed mode stores).
+var BridgeToolNames = map[string]bool{
+ // Filesystem
+ "read_file": true,
+ "write_file": true,
+ "list_files": true,
+ "edit": true,
+ "exec": true,
+ // Web
+ "web_search": true,
+ "web_fetch": true,
+ // Memory & knowledge
+ "memory_search": true,
+ "memory_get": true,
+ "skill_search": true,
+ // Media
+ "read_image": true,
+ "create_image": true,
+ "tts": true,
+ // Browser automation
+ "browser": true,
+ // Scheduler
+ "cron": true,
+ // Sessions (read + send)
+ "sessions_list": true,
+ "session_status": true,
+ "sessions_history": true,
+ "sessions_send": true,
+}
+
+// NewBridgeServer creates a StreamableHTTPServer that exposes GoClaw tools as MCP tools.
+// It reads tools from the registry, filters to BridgeToolNames, and serves them
+// over streamable-http transport (stateless mode).
+func NewBridgeServer(reg *tools.Registry, version string) *mcpserver.StreamableHTTPServer {
+ srv := mcpserver.NewMCPServer("goclaw-bridge", version,
+ mcpserver.WithToolCapabilities(false),
+ )
+
+ // Register each safe tool from the GoClaw registry
+ var registered int
+ for name := range BridgeToolNames {
+ t, ok := reg.Get(name)
+ if !ok {
+ continue
+ }
+
+ mcpTool := convertToMCPTool(t)
+ handler := makeToolHandler(reg, name)
+ srv.AddTool(mcpTool, handler)
+ registered++
+ }
+
+ slog.Info("mcp.bridge: tools registered", "count", registered)
+
+ return mcpserver.NewStreamableHTTPServer(srv,
+ mcpserver.WithStateLess(true),
+ )
+}
+
+// convertToMCPTool converts a GoClaw tools.Tool into an mcp-go Tool.
+func convertToMCPTool(t tools.Tool) mcpgo.Tool {
+ schema, err := json.Marshal(t.Parameters())
+ if err != nil {
+ // Fallback: empty object schema
+ schema = []byte(`{"type":"object"}`)
+ }
+ return mcpgo.NewToolWithRawSchema(t.Name(), t.Description(), schema)
+}
+
+// makeToolHandler creates a ToolHandlerFunc that delegates to the GoClaw tool registry.
+func makeToolHandler(reg *tools.Registry, toolName string) mcpserver.ToolHandlerFunc {
+ return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
+ args := req.GetArguments()
+
+ result := reg.Execute(ctx, toolName, args)
+
+ if result.IsError {
+ return mcpgo.NewToolResultError(result.ForLLM), nil
+ }
+
+ return mcpgo.NewToolResultText(result.ForLLM), nil
+ }
+}
diff --git a/internal/providers/claude_cli.go b/internal/providers/claude_cli.go
new file mode 100644
index 00000000..e42c70b8
--- /dev/null
+++ b/internal/providers/claude_cli.go
@@ -0,0 +1,124 @@
+package providers
+
+import (
+ "log/slog"
+ "sync"
+)
+
+// Options key for passing session key from agent loop to CLI provider.
+const OptSessionKey = "session_key"
+
+// OptDisableTools disables all built-in CLI tools when set to true.
+// Useful for pure text generation (e.g. summoning) where tool use is unwanted.
+const OptDisableTools = "disable_tools"
+
+// ClaudeCLIProvider implements Provider by shelling out to the `claude` CLI binary.
+// It acts as a thin proxy: CLI manages session history, tool execution, and context.
+// GoClaw only forwards the latest user message and streams back the response.
+type ClaudeCLIProvider struct {
+ cliPath string // path to claude binary (default: "claude")
+ defaultModel string // default: "sonnet"
+ baseWorkDir string // base dir for agent workspaces
+ mcpConfigPath string // pre-built MCP config file path (empty = no MCP)
+ permMode string // permission mode (default: "bypassPermissions")
+ hooksSettingsPath string // generated settings.json with security hooks (empty = no hooks)
+ hooksCleanup func() // cleanup function for hooks temp files
+ mcpCleanup func() // cleanup function for MCP config temp file
+ mu sync.Mutex // protects workdir creation
+ sessionMu sync.Map // key: string, value: *sync.Mutex — per-session lock
+}
+
+// ClaudeCLIOption configures the provider.
+type ClaudeCLIOption func(*ClaudeCLIProvider)
+
+// WithClaudeCLIModel sets the default model alias.
+func WithClaudeCLIModel(model string) ClaudeCLIOption {
+ return func(p *ClaudeCLIProvider) {
+ if model != "" {
+ p.defaultModel = model
+ }
+ }
+}
+
+// WithClaudeCLIWorkDir sets the base work directory.
+func WithClaudeCLIWorkDir(dir string) ClaudeCLIOption {
+ return func(p *ClaudeCLIProvider) {
+ if dir != "" {
+ p.baseWorkDir = dir
+ }
+ }
+}
+
+// WithClaudeCLIMCPConfig sets the MCP config file path.
+func WithClaudeCLIMCPConfig(path string, cleanup ...func()) ClaudeCLIOption {
+ return func(p *ClaudeCLIProvider) {
+ p.mcpConfigPath = path
+ if len(cleanup) > 0 && cleanup[0] != nil {
+ p.mcpCleanup = cleanup[0]
+ }
+ }
+}
+
+// WithClaudeCLIPermMode sets the permission mode.
+func WithClaudeCLIPermMode(mode string) ClaudeCLIOption {
+ return func(p *ClaudeCLIProvider) {
+ if mode != "" {
+ p.permMode = mode
+ }
+ }
+}
+
+// WithClaudeCLISecurityHooks enables GoClaw security hooks for CLI tool calls.
+// Generates a settings file with PreToolUse hooks that enforce shell deny patterns
+// and workspace path restrictions.
+func WithClaudeCLISecurityHooks(workspace string, restrictToWorkspace bool) ClaudeCLIOption {
+ return func(p *ClaudeCLIProvider) {
+ settingsPath, cleanup, err := BuildCLIHooksConfig(workspace, restrictToWorkspace)
+ if err != nil {
+ slog.Warn("claude-cli: failed to build security hooks", "error", err)
+ return
+ }
+ p.hooksSettingsPath = settingsPath
+ p.hooksCleanup = cleanup
+ }
+}
+
+// NewClaudeCLIProvider creates a provider that invokes the claude CLI.
+func NewClaudeCLIProvider(cliPath string, opts ...ClaudeCLIOption) *ClaudeCLIProvider {
+ if cliPath == "" {
+ cliPath = "claude"
+ }
+ p := &ClaudeCLIProvider{
+ cliPath: cliPath,
+ defaultModel: "sonnet",
+ baseWorkDir: defaultCLIWorkDir(),
+ permMode: "bypassPermissions",
+ // sessionMu is zero-value ready (sync.Map)
+ }
+ for _, opt := range opts {
+ opt(p)
+ }
+ return p
+}
+
+func (p *ClaudeCLIProvider) Name() string { return "claude-cli" }
+func (p *ClaudeCLIProvider) DefaultModel() string { return p.defaultModel }
+
+// Close cleans up temp files (MCP config, hooks settings). Implements io.Closer.
+func (p *ClaudeCLIProvider) Close() error {
+ if p.mcpCleanup != nil {
+ p.mcpCleanup()
+ }
+ if p.hooksCleanup != nil {
+ p.hooksCleanup()
+ }
+ return nil
+}
+
+// lockSession acquires a per-session mutex to prevent concurrent CLI calls on the same session.
+func (p *ClaudeCLIProvider) lockSession(sessionKey string) func() {
+ actual, _ := p.sessionMu.LoadOrStore(sessionKey, &sync.Mutex{})
+ m := actual.(*sync.Mutex)
+ m.Lock()
+ return m.Unlock
+}
diff --git a/internal/providers/claude_cli_auth.go b/internal/providers/claude_cli_auth.go
new file mode 100644
index 00000000..e0d1a4ca
--- /dev/null
+++ b/internal/providers/claude_cli_auth.go
@@ -0,0 +1,40 @@
+package providers
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os/exec"
+)
+
+// ClaudeAuthStatus holds the parsed result of `claude auth status --json`.
+type ClaudeAuthStatus struct {
+ LoggedIn bool `json:"loggedIn"`
+ Email string `json:"email,omitempty"`
+ SubscriptionType string `json:"subscriptionType,omitempty"`
+}
+
+// CheckClaudeAuthStatus runs `claude auth status --json` using the given CLI
+// path and returns the parsed authentication status.
+func CheckClaudeAuthStatus(ctx context.Context, cliPath string) (*ClaudeAuthStatus, error) {
+ if cliPath == "" {
+ cliPath = "claude"
+ }
+
+ resolvedPath, err := exec.LookPath(cliPath)
+ if err != nil {
+ return nil, fmt.Errorf("claude CLI binary not found at %q: %w", cliPath, err)
+ }
+
+ cmd := exec.CommandContext(ctx, resolvedPath, "auth", "status", "--json")
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("claude auth status failed: %w", err)
+ }
+
+ var status ClaudeAuthStatus
+ if err := json.Unmarshal(output, &status); err != nil {
+ return nil, fmt.Errorf("failed to parse auth status: %w", err)
+ }
+ return &status, nil
+}
diff --git a/internal/providers/claude_cli_chat.go b/internal/providers/claude_cli_chat.go
new file mode 100644
index 00000000..8327eaab
--- /dev/null
+++ b/internal/providers/claude_cli_chat.go
@@ -0,0 +1,215 @@
+package providers
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// Chat runs the CLI synchronously and returns the final response.
+func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
+ systemPrompt, userMsg, images := extractFromMessages(req.Messages)
+ sessionKey := extractSessionKey(req.Options)
+ model := req.Model
+ if model == "" {
+ model = p.defaultModel
+ }
+ if err := validateCLIModel(model); err != nil {
+ return nil, err
+ }
+
+ unlock := p.lockSession(sessionKey)
+ defer unlock()
+
+ workDir := p.ensureWorkDir(sessionKey)
+ if systemPrompt != "" {
+ p.writeClaudeMD(workDir, systemPrompt)
+ }
+
+ cliSessionID := deriveSessionUUID(sessionKey)
+ disableTools := extractDisableTools(req.Options)
+ args := p.buildArgs(model, workDir, cliSessionID, "json", len(images) > 0, disableTools)
+
+ var stdin *bytes.Reader
+ if len(images) > 0 {
+ stdin = buildStreamJSONInput(userMsg, images)
+ } else {
+ args = append(args, "--", userMsg)
+ }
+
+ cmd := exec.CommandContext(ctx, p.cliPath, args...)
+ cmd.Dir = workDir
+ cmd.Env = filterCLIEnv(os.Environ())
+ if stdin != nil {
+ cmd.Stdin = stdin
+ }
+
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+
+ slog.Debug("claude-cli exec", "cmd", fmt.Sprintf("%s %s", p.cliPath, strings.Join(args, " ")), "workdir", workDir)
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("claude-cli: %w (stderr: %s)", err, stderr.String())
+ }
+
+ return parseJSONResponse(output)
+}
+
+// ChatStream runs the CLI with stream-json output, calling onChunk for each text delta.
+func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) {
+ systemPrompt, userMsg, images := extractFromMessages(req.Messages)
+ sessionKey := extractSessionKey(req.Options)
+ model := req.Model
+ if model == "" {
+ model = p.defaultModel
+ }
+ if err := validateCLIModel(model); err != nil {
+ return nil, err
+ }
+
+ slog.Debug("claude-cli: acquiring session lock", "session_key", sessionKey)
+ unlock := p.lockSession(sessionKey)
+ slog.Debug("claude-cli: session lock acquired", "session_key", sessionKey)
+ defer func() {
+ unlock()
+ slog.Debug("claude-cli: session lock released", "session_key", sessionKey)
+ }()
+
+ workDir := p.ensureWorkDir(sessionKey)
+ if systemPrompt != "" {
+ p.writeClaudeMD(workDir, systemPrompt)
+ }
+
+ cliSessionID := deriveSessionUUID(sessionKey)
+ disableToolsStream := extractDisableTools(req.Options)
+ args := p.buildArgs(model, workDir, cliSessionID, "stream-json", len(images) > 0, disableToolsStream)
+
+ var stdin *bytes.Reader
+ if len(images) > 0 {
+ stdin = buildStreamJSONInput(userMsg, images)
+ } else {
+ args = append(args, "--", userMsg)
+ }
+
+ cmd := exec.CommandContext(ctx, p.cliPath, args...)
+ cmd.Dir = workDir
+ cmd.Env = filterCLIEnv(os.Environ())
+ if stdin != nil {
+ cmd.Stdin = stdin
+ }
+
+ var stderrBuf bytes.Buffer
+ cmd.Stderr = &stderrBuf
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, fmt.Errorf("claude-cli stdout pipe: %w", err)
+ }
+
+ fullCmd := fmt.Sprintf("%s %s", p.cliPath, strings.Join(args, " "))
+ slog.Debug("claude-cli stream exec", "cmd", fullCmd, "workdir", workDir)
+ if err := cmd.Start(); err != nil {
+ return nil, fmt.Errorf("claude-cli start: %w", err)
+ }
+
+ // Debug log file: only enabled when GOCLAW_DEBUG=1
+ var debugFile *os.File
+ if os.Getenv("GOCLAW_DEBUG") == "1" {
+ debugLogPath := filepath.Join(workDir, "cli-debug.log")
+ debugFile, _ = os.OpenFile(debugLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
+ if debugFile != nil {
+ fmt.Fprintf(debugFile, "=== CMD: %s\n=== WORKDIR: %s\n=== TIME: %s\n\n", fullCmd, workDir, time.Now().Format(time.RFC3339))
+ defer debugFile.Close()
+ }
+ }
+
+ // Parse stream-json line-by-line
+ scanner := bufio.NewScanner(stdout)
+ scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024) // 256KB initial, 1MB max
+
+ var finalResp ChatResponse
+ var contentBuf strings.Builder
+
+ for scanner.Scan() {
+ line := scanner.Bytes()
+ if len(line) == 0 {
+ continue
+ }
+
+ // Write raw line to debug log
+ if debugFile != nil {
+ fmt.Fprintf(debugFile, "%s\n", line)
+ }
+
+ var ev cliStreamEvent
+ if err := json.Unmarshal(line, &ev); err != nil {
+ slog.Debug("claude-cli: skip malformed stream line", "error", err)
+ continue
+ }
+
+ switch ev.Type {
+ case "assistant":
+ if ev.Message == nil {
+ continue
+ }
+ text, thinking := extractStreamContent(ev.Message)
+ if text != "" {
+ contentBuf.WriteString(text)
+ onChunk(StreamChunk{Content: text})
+ }
+ if thinking != "" {
+ onChunk(StreamChunk{Thinking: thinking})
+ }
+
+ case "result":
+ if ev.Result != "" {
+ finalResp.Content = ev.Result
+ } else {
+ finalResp.Content = contentBuf.String()
+ }
+ finalResp.FinishReason = "stop"
+ if ev.Subtype == "error" {
+ finalResp.FinishReason = "error"
+ }
+ if ev.Usage != nil {
+ finalResp.Usage = &Usage{
+ PromptTokens: ev.Usage.InputTokens,
+ CompletionTokens: ev.Usage.OutputTokens,
+ TotalTokens: ev.Usage.InputTokens + ev.Usage.OutputTokens,
+ }
+ }
+ }
+ }
+
+ if err := cmd.Wait(); err != nil {
+ if debugFile != nil {
+ fmt.Fprintf(debugFile, "\n=== STDERR:\n%s\n=== EXIT ERROR: %v\n", stderrBuf.String(), err)
+ }
+ // If we got partial content, return it with the error
+ if finalResp.Content != "" {
+ return &finalResp, nil
+ }
+ return nil, fmt.Errorf("claude-cli: %w (stderr: %s)", err, stderrBuf.String())
+ }
+ if debugFile != nil && stderrBuf.Len() > 0 {
+ fmt.Fprintf(debugFile, "\n=== STDERR:\n%s\n", stderrBuf.String())
+ }
+
+ // Fallback if no "result" event was received
+ if finalResp.Content == "" {
+ finalResp.Content = contentBuf.String()
+ finalResp.FinishReason = "stop"
+ }
+
+ onChunk(StreamChunk{Done: true})
+ return &finalResp, nil
+}
diff --git a/internal/providers/claude_cli_deny_patterns.go b/internal/providers/claude_cli_deny_patterns.go
new file mode 100644
index 00000000..8eed2566
--- /dev/null
+++ b/internal/providers/claude_cli_deny_patterns.go
@@ -0,0 +1,109 @@
+package providers
+
+// ShellDenyPatterns are regex patterns for dangerous shell commands.
+// Mirrors internal/tools/shell.go defaultDenyPatterns for CLI hook enforcement.
+var ShellDenyPatterns = []string{
+ // Destructive file operations
+ `\brm\s+-[rf]{1,2}\b`,
+ `\brm\s+.*--recursive`,
+ `\brm\s+.*--force`,
+ `\bdel\s+/[fq]\b`,
+ `\brmdir\s+/s\b`,
+ `\b(mkfs|diskpart)\b|\bformat\s`,
+ `\bdd\s+if=`,
+ `>\s*/dev/sd[a-z]\b`,
+ `\b(shutdown|reboot|poweroff)\b`,
+ `:\(\)\s*\{.*\};\s*:`,
+
+ // Data exfiltration
+ `\bcurl\b.*\|\s*(ba)?sh\b`,
+ `\bcurl\b.*(-d\b|-F\b|--data|--upload|--form|-T\b|-X\s*P(UT|OST|ATCH))`,
+ `\bwget\b.*-O\s*-\s*\|\s*(ba)?sh\b`,
+ `\bwget\b.*--post-(data|file)`,
+ `\b(nslookup|dig|host)\b`,
+ `/dev/tcp/`,
+
+ // Reverse shells
+ `\b(nc|ncat|netcat)\b.*-[el]\b`,
+ `\bsocat\b`,
+ `\bopenssl\b.*s_client`,
+ `\btelnet\b.*[0-9]+`,
+ `\bpython[23]?\b.*\bimport\s+(socket|http\.client|urllib|requests)\b`,
+ `\bperl\b.*-e\s*.*\b[Ss]ocket\b`,
+ `\bruby\b.*-e\s*.*\b(TCPSocket|Socket)\b`,
+ `\bnode\b.*-e\s*.*\b(net\.connect|child_process)\b`,
+ `\bawk\b.*/inet/`,
+ `\bmkfifo\b`,
+
+ // Dangerous eval / code injection
+ `\beval\s*\$`,
+ `\bbase64\s+-d\b.*\|\s*(ba)?sh\b`,
+
+ // Privilege escalation
+ `\bsudo\b`,
+ `\bsu\s+-`,
+ `\bnsenter\b`,
+ `\bunshare\b`,
+ `\b(mount|umount)\b`,
+ `\b(capsh|setcap|getcap)\b`,
+
+ // Dangerous path operations
+ `\bchmod\s+[0-7]{3,4}\s+/`,
+ `\bchown\b.*\s+/`,
+ `\bchmod\b.*\+x.*/tmp/`,
+ `\bchmod\b.*\+x.*/var/tmp/`,
+ `\bchmod\b.*\+x.*/dev/shm/`,
+
+ // Environment variable injection
+ `\bLD_PRELOAD\s*=`,
+ `\bDYLD_INSERT_LIBRARIES\s*=`,
+ `\bLD_LIBRARY_PATH\s*=`,
+ `/etc/ld\.so\.preload`,
+ `\bGIT_EXTERNAL_DIFF\s*=`,
+ `\bGIT_DIFF_OPTS\s*=`,
+ `\bBASH_ENV\s*=`,
+ `\bENV\s*=.*\bsh\b`,
+
+ // Container escape
+ `/var/run/docker\.sock|docker\.(sock|socket)`,
+ `/proc/sys/(kernel|fs|net)/`,
+ `/sys/(kernel|fs|class|devices)/`,
+
+ // Crypto mining
+ `\b(xmrig|cpuminer|minerd|cgminer|bfgminer|ethminer|nbminer|t-rex|phoenixminer|lolminer|gminer|claymore)\b`,
+ `stratum\+tcp://|stratum\+ssl://`,
+
+ // Filter bypass (CVE-2025-66032)
+ `\bsed\b.*['"]/e\b`,
+ `\bsort\b.*--compress-program`,
+ `\bgit\b.*(--upload-pack|--receive-pack|--exec)=`,
+ `\b(rg|grep)\b.*--pre=`,
+ `\bman\b.*--html=`,
+ `\bhistory\b.*-[saw]\b`,
+ `\$\{[^}]*@[PpEeAaKk]\}`,
+
+ // Network abuse / reconnaissance
+ `\b(nmap|masscan|zmap|rustscan)\b`,
+ `\b(ssh|scp|sftp)\b.*@`,
+ `\b(chisel|frp|ngrok|cloudflared|bore|localtunnel)\b`,
+
+ // Persistence
+ `\bcrontab\b`,
+ `>\s*~/?\.(bashrc|bash_profile|profile|zshrc)`,
+ `\btee\b.*\.(bashrc|bash_profile|profile|zshrc)`,
+
+ // Process manipulation
+ `\bkill\s+-9\s`,
+ `\b(killall|pkill)\b`,
+
+ // Environment variable dumping
+ `^\s*env\s*$`,
+ `^\s*env\s*\|`,
+ `^\s*env\s*>\s`,
+ `\bprintenv\b`,
+ `^\s*(set|export\s+-p|declare\s+-x)\s*($|\|)`,
+ `\bcompgen\s+-e\b`,
+ `/proc/[^/]+/environ`,
+ `/proc/self/environ`,
+ `(?i)\bstrings\b.*/proc/`,
+}
diff --git a/internal/providers/claude_cli_hooks.go b/internal/providers/claude_cli_hooks.go
new file mode 100644
index 00000000..83c986fc
--- /dev/null
+++ b/internal/providers/claude_cli_hooks.go
@@ -0,0 +1,207 @@
+package providers
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/google/uuid"
+)
+
+// BuildCLIHooksConfig generates a Claude CLI settings file with PreToolUse hooks
+// that enforce GoClaw's security policies (shell deny patterns, path restrictions).
+// Returns settings file path and a cleanup function.
+func BuildCLIHooksConfig(workspace string, restrictToWorkspace bool) (string, func(), error) {
+ tmpDir := filepath.Join(os.TempDir(), "goclaw-cli-hooks")
+ if err := os.MkdirAll(tmpDir, 0755); err != nil {
+ return "", nil, fmt.Errorf("create hooks dir: %w", err)
+ }
+
+ id := uuid.New().String()[:8]
+
+ // Write the hook script
+ hookScript := generateHookScript(workspace, restrictToWorkspace)
+ hookPath := filepath.Join(tmpDir, fmt.Sprintf("hook-%s.sh", id))
+ if err := os.WriteFile(hookPath, []byte(hookScript), 0755); err != nil {
+ return "", nil, fmt.Errorf("write hook script: %w", err)
+ }
+
+ // Write settings JSON
+ settings := generateSettingsJSON(hookPath)
+ settingsPath := filepath.Join(tmpDir, fmt.Sprintf("settings-%s.json", id))
+ if err := os.WriteFile(settingsPath, settings, 0600); err != nil {
+ os.Remove(hookPath)
+ return "", nil, fmt.Errorf("write settings: %w", err)
+ }
+
+ cleanup := func() {
+ os.Remove(hookPath)
+ os.Remove(settingsPath)
+ }
+
+ return settingsPath, cleanup, nil
+}
+
+// generateSettingsJSON creates Claude CLI settings with PreToolUse hooks.
+func generateSettingsJSON(hookPath string) []byte {
+ settings := map[string]interface{}{
+ "hooks": map[string]interface{}{
+ "PreToolUse": []map[string]interface{}{
+ {
+ "matcher": "Bash",
+ "hooks": []map[string]interface{}{
+ {"type": "command", "command": hookPath},
+ },
+ },
+ {
+ "matcher": "Write",
+ "hooks": []map[string]interface{}{
+ {"type": "command", "command": hookPath},
+ },
+ },
+ {
+ "matcher": "Edit",
+ "hooks": []map[string]interface{}{
+ {"type": "command", "command": hookPath},
+ },
+ },
+ {
+ "matcher": "Read",
+ "hooks": []map[string]interface{}{
+ {"type": "command", "command": hookPath},
+ },
+ },
+ },
+ },
+ }
+
+ data, _ := json.MarshalIndent(settings, "", " ")
+ return data
+}
+
+// generateHookScript creates a bash script that enforces GoClaw security policies.
+func generateHookScript(workspace string, restrictToWorkspace bool) string {
+ var sb strings.Builder
+
+ sb.WriteString(`#!/bin/bash
+set -euo pipefail
+
+# GoClaw security hook for Claude CLI PreToolUse.
+# Checks shell deny patterns and workspace path restrictions.
+
+INPUT=$(cat)
+TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
+TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}')
+
+allow() {
+ echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
+ exit 0
+}
+
+deny() {
+ local reason="$1"
+ echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"$reason\"}}"
+ exit 0
+}
+
+`)
+
+ // Shell deny patterns check
+ sb.WriteString(`# === Shell command deny patterns ===
+check_shell_deny() {
+ local cmd="$1"
+ local patterns=(
+`)
+
+ for _, p := range ShellDenyPatterns {
+ // Escape single quotes for bash
+ escaped := strings.ReplaceAll(p, `'`, `'\''`)
+ fmt.Fprintf(&sb, " '%s'\n", escaped)
+ }
+
+ sb.WriteString(` )
+
+ for pattern in "${patterns[@]}"; do
+ if echo "$cmd" | grep -qE "$pattern" 2>/dev/null; then
+ deny "security: shell command blocked by deny pattern"
+ fi
+ done
+}
+
+`)
+
+ // Path restriction check
+ if restrictToWorkspace && workspace != "" {
+ // Escape workspace path for safe bash embedding (single quotes + quote escaping)
+ safeWorkspace := strings.ReplaceAll(workspace, `'`, `'\''`)
+ fmt.Fprintf(&sb, `# === Workspace path restriction ===
+WORKSPACE='%s'
+
+check_path_restriction() {
+ local file_path="$1"
+ # Resolve all paths (including relative) to absolute for proper checking
+ local resolved
+ resolved=$(realpath -m "$file_path" 2>/dev/null || echo "$file_path")
+ if [[ "$resolved" != "$WORKSPACE"* ]]; then
+ deny "security: path outside workspace boundary"
+ fi
+}
+
+`, safeWorkspace)
+ }
+
+ // Main dispatch
+ sb.WriteString(`# === Main ===
+case "$TOOL_NAME" in
+ Bash)
+ CMD=$(echo "$TOOL_INPUT" | jq -r '.command // empty')
+ if [ -n "$CMD" ]; then
+ check_shell_deny "$CMD"
+ fi
+ ;;
+ Write)
+ FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // empty')
+`)
+
+ if restrictToWorkspace && workspace != "" {
+ sb.WriteString(` if [ -n "$FILE_PATH" ]; then
+ check_path_restriction "$FILE_PATH"
+ fi
+`)
+ }
+
+ sb.WriteString(` ;;
+ Edit)
+ FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // empty')
+`)
+
+ if restrictToWorkspace && workspace != "" {
+ sb.WriteString(` if [ -n "$FILE_PATH" ]; then
+ check_path_restriction "$FILE_PATH"
+ fi
+`)
+ }
+
+ sb.WriteString(` ;;
+ Read)
+ FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // empty')
+`)
+
+ if restrictToWorkspace && workspace != "" {
+ sb.WriteString(` if [ -n "$FILE_PATH" ]; then
+ check_path_restriction "$FILE_PATH"
+ fi
+`)
+ }
+
+ sb.WriteString(` ;;
+esac
+
+# Default: allow
+allow
+`)
+
+ return sb.String()
+}
diff --git a/internal/providers/claude_cli_mcp.go b/internal/providers/claude_cli_mcp.go
new file mode 100644
index 00000000..68db68fa
--- /dev/null
+++ b/internal/providers/claude_cli_mcp.go
@@ -0,0 +1,110 @@
+package providers
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/google/uuid"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+)
+
+// BuildCLIMCPConfig exports GoClaw's MCP server configs into a temp JSON file
+// compatible with Claude CLI's --mcp-config flag.
+// When gatewayAddr is non-empty, a "goclaw-bridge" entry is injected so CLI
+// can call GoClaw's built-in tools via streamable-http MCP transport.
+// Returns the file path and a cleanup function. Caller must call cleanup when done.
+func BuildCLIMCPConfig(servers map[string]*config.MCPServerConfig, gatewayAddr string, gatewayToken ...string) (string, func(), error) {
+ mcpServers := make(map[string]interface{}, len(servers)+1)
+
+ for name, srv := range servers {
+ if !srv.IsEnabled() {
+ continue
+ }
+
+ entry := make(map[string]interface{})
+
+ switch srv.Transport {
+ case "stdio":
+ if srv.Command != "" {
+ entry["command"] = srv.Command
+ }
+ if len(srv.Args) > 0 {
+ entry["args"] = srv.Args
+ }
+ if len(srv.Env) > 0 {
+ entry["env"] = srv.Env
+ }
+
+ case "sse":
+ if srv.URL != "" {
+ entry["url"] = srv.URL
+ entry["type"] = "sse"
+ }
+ if len(srv.Headers) > 0 {
+ entry["headers"] = srv.Headers
+ }
+
+ case "streamable-http":
+ if srv.URL != "" {
+ entry["url"] = srv.URL
+ entry["type"] = "http"
+ }
+ if len(srv.Headers) > 0 {
+ entry["headers"] = srv.Headers
+ }
+
+ default:
+ continue
+ }
+
+ if len(entry) > 0 {
+ mcpServers[name] = entry
+ }
+ }
+
+ // Inject GoClaw bridge entry so CLI can access built-in tools via MCP
+ if gatewayAddr != "" {
+ bridgeEntry := map[string]interface{}{
+ "url": fmt.Sprintf("http://%s/mcp/bridge", gatewayAddr),
+ "type": "http",
+ }
+ if len(gatewayToken) > 0 && gatewayToken[0] != "" {
+ bridgeEntry["headers"] = map[string]string{
+ "Authorization": "Bearer " + gatewayToken[0],
+ }
+ }
+ mcpServers["goclaw-bridge"] = bridgeEntry
+ }
+
+ if len(mcpServers) == 0 {
+ return "", func() {}, nil
+ }
+
+ cfg := map[string]interface{}{
+ "mcpServers": mcpServers,
+ }
+
+ data, err := json.MarshalIndent(cfg, "", " ")
+ if err != nil {
+ return "", nil, fmt.Errorf("marshal mcp config: %w", err)
+ }
+
+ // Write to a temp file with restricted permissions
+ tmpDir := filepath.Join(os.TempDir(), "goclaw-mcp-configs")
+ if err := os.MkdirAll(tmpDir, 0755); err != nil {
+ return "", nil, fmt.Errorf("create mcp config dir: %w", err)
+ }
+
+ tmpFile := filepath.Join(tmpDir, fmt.Sprintf("mcp-%s.json", uuid.New().String()[:8]))
+ if err := os.WriteFile(tmpFile, data, 0600); err != nil {
+ return "", nil, fmt.Errorf("write mcp config: %w", err)
+ }
+
+ cleanup := func() {
+ os.Remove(tmpFile)
+ }
+
+ return tmpFile, cleanup, nil
+}
diff --git a/internal/providers/claude_cli_parse.go b/internal/providers/claude_cli_parse.go
new file mode 100644
index 00000000..9caa07e1
--- /dev/null
+++ b/internal/providers/claude_cli_parse.go
@@ -0,0 +1,152 @@
+package providers
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+// parseJSONResponse parses the CLI JSON output into a ChatResponse.
+func parseJSONResponse(data []byte) (*ChatResponse, error) {
+ // Try parsing as JSON array first (CLI may output all events as a single array).
+ if resp := parseJSONArray(data); resp != nil {
+ return resp, nil
+ }
+
+ // Fallback: CLI may output one JSON object per line.
+ for _, line := range bytes.Split(data, []byte("\n")) {
+ line = bytes.TrimSpace(line)
+ if len(line) == 0 {
+ continue
+ }
+ if resp := parseSingleJSONResult(line); resp != nil {
+ return resp, nil
+ }
+ }
+
+ // Last resort: treat entire output as text response
+ trimmed := strings.TrimSpace(string(data))
+ if trimmed == "" {
+ return nil, fmt.Errorf("claude-cli: empty response")
+ }
+ return &ChatResponse{
+ Content: trimmed,
+ FinishReason: "stop",
+ }, nil
+}
+
+// parseJSONArray tries to parse data as a JSON array of CLI events, extracting
+// the "result" event's text content and "assistant" event's text blocks.
+func parseJSONArray(data []byte) *ChatResponse {
+ trimmed := bytes.TrimSpace(data)
+ if len(trimmed) == 0 || trimmed[0] != '[' {
+ return nil
+ }
+
+ var events []json.RawMessage
+ if err := json.Unmarshal(trimmed, &events); err != nil {
+ return nil
+ }
+
+ var resultText string
+ var assistantText strings.Builder
+ var usage *Usage
+ finishReason := "stop"
+
+ for _, raw := range events {
+ var ev struct {
+ Type string `json:"type"`
+ Subtype string `json:"subtype,omitempty"`
+ Result string `json:"result,omitempty"`
+ Message json.RawMessage `json:"message,omitempty"`
+ Usage *cliUsage `json:"usage,omitempty"`
+ }
+ if err := json.Unmarshal(raw, &ev); err != nil {
+ continue
+ }
+
+ switch ev.Type {
+ case "result":
+ resultText = ev.Result
+ if ev.Subtype == "error" {
+ finishReason = "error"
+ }
+ if ev.Usage != nil {
+ usage = &Usage{
+ PromptTokens: ev.Usage.InputTokens,
+ CompletionTokens: ev.Usage.OutputTokens,
+ TotalTokens: ev.Usage.InputTokens + ev.Usage.OutputTokens,
+ }
+ }
+
+ case "assistant":
+ // Extract text from content blocks
+ if ev.Message != nil {
+ var msg cliStreamMsg
+ if err := json.Unmarshal(ev.Message, &msg); err == nil {
+ for _, block := range msg.Content {
+ if block.Type == "text" {
+ assistantText.WriteString(block.Text)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Prefer "result" text, fall back to concatenated assistant text blocks
+ content := resultText
+ if content == "" {
+ content = assistantText.String()
+ }
+ if content == "" {
+ return nil
+ }
+
+ return &ChatResponse{
+ Content: content,
+ FinishReason: finishReason,
+ Usage: usage,
+ }
+}
+
+// parseSingleJSONResult tries to parse a single JSON line as a "result" event.
+func parseSingleJSONResult(line []byte) *ChatResponse {
+ var resp cliJSONResponse
+ if err := json.Unmarshal(line, &resp); err != nil {
+ return nil
+ }
+ if resp.Type != "result" {
+ return nil
+ }
+ cr := &ChatResponse{
+ Content: resp.Result,
+ FinishReason: "stop",
+ }
+ if resp.Subtype == "error" {
+ cr.FinishReason = "error"
+ }
+ if resp.Usage != nil {
+ cr.Usage = &Usage{
+ PromptTokens: resp.Usage.InputTokens,
+ CompletionTokens: resp.Usage.OutputTokens,
+ TotalTokens: resp.Usage.InputTokens + resp.Usage.OutputTokens,
+ }
+ }
+ return cr
+}
+
+// extractStreamContent extracts text and thinking from a stream message.
+func extractStreamContent(msg *cliStreamMsg) (text, thinking string) {
+ var textBuf, thinkBuf strings.Builder
+ for _, block := range msg.Content {
+ switch block.Type {
+ case "text":
+ textBuf.WriteString(block.Text)
+ case "thinking":
+ thinkBuf.WriteString(block.Thinking)
+ }
+ }
+ return textBuf.String(), thinkBuf.String()
+}
diff --git a/internal/providers/claude_cli_session.go b/internal/providers/claude_cli_session.go
new file mode 100644
index 00000000..dac803ce
--- /dev/null
+++ b/internal/providers/claude_cli_session.go
@@ -0,0 +1,237 @@
+package providers
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/google/uuid"
+)
+
+// validCLIModels lists accepted model aliases for the Claude CLI.
+var validCLIModels = map[string]bool{
+ "sonnet": true, "opus": true, "haiku": true,
+}
+
+// validateCLIModel checks if a model alias is supported by the Claude CLI.
+func validateCLIModel(model string) error {
+ if !validCLIModels[model] {
+ return fmt.Errorf("claude-cli: unsupported model %q (valid: sonnet, opus, haiku)", model)
+ }
+ return nil
+}
+
+// buildArgs constructs CLI arguments.
+func (p *ClaudeCLIProvider) buildArgs(model, workDir string, cliSessionID uuid.UUID, outputFormat string, hasImages, disableTools bool) []string {
+ args := []string{
+ "-p",
+ "--output-format", outputFormat,
+ "--model", model,
+ "--permission-mode", p.permMode,
+ "--verbose",
+ }
+
+ if p.mcpConfigPath != "" {
+ args = append(args, "--mcp-config", p.mcpConfigPath)
+ }
+
+ // Session persistence: check if CLI session file exists on disk.
+ // If exists → --resume (continue conversation). If not → --session-id (create new).
+ // Session files live at ~/.claude/projects/
{error}
+ )}