package claudecode import ( "bufio" "context" "encoding/json" "fmt" "log/slog" "net/http" "os" "os/exec" "path/filepath" "regexp" "sort" "strings" "sync" "time" "unicode/utf8" "github.com/chenhg5/cc-connect/core" ) func init() { core.RegisterAgent("claudecode", New) } // Agent drives Claude Code CLI using --input-format stream-json // and --permission-prompt-tool stdio for bidirectional communication. // // Permission modes (maps to Claude's --permission-mode): // - "default": every tool call requires user approval // - "acceptEdits": auto-approve file edit tools, ask for others // - "plan": plan only, no execution until approved // - "auto": Claude's automatic permission classifier // - "bypassPermissions": auto-approve everything (alias: yolo) type Agent struct { workDir string cliBin string // CLI binary name or path (default: "claude") cliExtraArgs []string // extra args parsed from cli_path (e.g. ["code", "-t", "foo"]) configEnv []string // env vars from [projects.agent.options.env] — persists across SetSessionEnv calls cliArgsFlag string // if set, claude args are passed as a single string via this flag (e.g. "-a") model string reasoningEffort string // "low" | "medium" | "high" | "max" mode string // "default" | "acceptEdits" | "plan" | "auto" | "bypassPermissions" | "dontAsk" allowedTools []string disallowedTools []string maxContextTokens int // optional: passed as --max-context-tokens when > 0 providers []core.ProviderConfig activeIdx int // -1 = no provider set sessionEnv []string routerURL string // Claude Code Router URL (e.g., "http://127.0.0.1:3456") routerAPIKey string // Claude Code Router API key (optional) systemPrompt string // Custom system prompt to pass to Claude CLI providerProxy *core.ProviderProxy // local proxy for third-party providers proxyLocalURL string // local URL of the proxy platformPrompt string // platform-specific formatting instructions // spawnOpts controls OS-user isolation via run_as_user. Zero value // means legacy spawn as the supervisor user. See core/runas.go. spawnOpts core.SpawnOptions mu sync.RWMutex } var claudeProviderManagedEnvVars = map[string]struct{}{ "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST": {}, "CLAUDE_CODE_USE_BEDROCK": {}, "CLAUDE_CODE_USE_VERTEX": {}, "CLAUDE_CODE_USE_FOUNDRY": {}, "ANTHROPIC_BASE_URL": {}, "ANTHROPIC_BEDROCK_BASE_URL": {}, "ANTHROPIC_VERTEX_BASE_URL": {}, "ANTHROPIC_FOUNDRY_BASE_URL": {}, "ANTHROPIC_FOUNDRY_RESOURCE": {}, "ANTHROPIC_VERTEX_PROJECT_ID": {}, "CLOUD_ML_REGION": {}, "ANTHROPIC_API_KEY": {}, "ANTHROPIC_AUTH_TOKEN": {}, "CLAUDE_CODE_OAUTH_TOKEN": {}, "AWS_BEARER_TOKEN_BEDROCK": {}, "ANTHROPIC_FOUNDRY_API_KEY": {}, "CLAUDE_CODE_SKIP_BEDROCK_AUTH": {}, "CLAUDE_CODE_SKIP_VERTEX_AUTH": {}, "CLAUDE_CODE_SKIP_FOUNDRY_AUTH": {}, "ANTHROPIC_MODEL": {}, "ANTHROPIC_DEFAULT_HAIKU_MODEL": {}, "ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION": {}, "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": {}, "ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES": {}, "ANTHROPIC_DEFAULT_OPUS_MODEL": {}, "ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION": {}, "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": {}, "ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES": {}, // Provider-specific base URL env vars for thinking rewrite proxy routing. // These are set by cc-connect when thinking override is needed for // Bedrock/Vertex/Foundry providers that don't use base_url config. "ANTHROPIC_BEDROCK_PROXY_BASE_URL": {}, "ANTHROPIC_VERTEX_PROXY_BASE_URL": {}, "ANTHROPIC_FOUNDRY_PROXY_BASE_URL": {}, "ANTHROPIC_DEFAULT_SONNET_MODEL": {}, "ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION": {}, "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": {}, "ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": {}, "ANTHROPIC_SMALL_FAST_MODEL": {}, "ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION": {}, "CLAUDE_CODE_SUBAGENT_MODEL": {}, } var claudeProviderManagedEnvPrefixes = []string{ "VERTEX_REGION_CLAUDE_", } func New(opts map[string]any) (core.Agent, error) { workDir, _ := opts["work_dir"].(string) if workDir == "" { workDir = "." } cliBin := "claude" var cliExtraArgs []string if cliPath, _ := opts["cli_path"].(string); cliPath != "" { // NOTE: paths containing spaces are not supported because Fields // splits on whitespace. Use a symlink or wrapper script instead. parts := strings.Fields(cliPath) cliBin = parts[0] if len(parts) > 1 { cliExtraArgs = parts[1:] } } cliArgsFlag, _ := opts["cli_args_flag"].(string) model, _ := opts["model"].(string) reasoningEffort, _ := opts["reasoning_effort"].(string) mode, _ := opts["mode"].(string) mode = normalizePermissionMode(mode) systemPrompt, _ := opts["system_prompt"].(string) var allowedTools []string if tools, ok := opts["allowed_tools"].([]any); ok { for _, t := range tools { if s, ok := t.(string); ok { allowedTools = append(allowedTools, s) } } } var disallowedTools []string if tools, ok := opts["disallowed_tools"].([]any); ok { for _, t := range tools { if s, ok := t.(string); ok { disallowedTools = append(disallowedTools, s) } } } maxContextTokens := 0 switch v := opts["max_context_tokens"].(type) { case int: if v > 0 { maxContextTokens = v } case int64: if v > 0 { maxContextTokens = int(v) } case float64: if v > 0 { maxContextTokens = int(v) } } // Claude Code Router support routerURL, _ := opts["router_url"].(string) routerAPIKey, _ := opts["router_api_key"].(string) // run_as_user: optional OS-user isolation. Injected into opts from // the project-level config field by cmd/cc-connect/main.go. spawnOpts := core.SpawnOptions{} spawnOpts.RunAsUser, _ = opts["run_as_user"].(string) if env, ok := opts["run_as_env"].([]any); ok { for _, v := range env { if s, ok := v.(string); ok { spawnOpts.EnvAllowlist = append(spawnOpts.EnvAllowlist, s) } } } else if env, ok := opts["run_as_env"].([]string); ok { spawnOpts.EnvAllowlist = append(spawnOpts.EnvAllowlist, env...) } // When run_as_user is set, the target user's PATH is what matters; // skip the supervisor-side LookPath check and let spawn fail loudly // at runtime if the target doesn't have claude installed. if !spawnOpts.IsolationMode() { if _, err := exec.LookPath(cliBin); err != nil { return nil, fmt.Errorf("claudecode: %q CLI not found in PATH, please install it first", cliBin) } } // Parse project-level env from opts["env"] (set via [projects.agent.options.env] in config.toml). // Stored separately from runtime sessionEnv so SetSessionEnv calls cannot overwrite it. var configEnv []string if envMap, ok := opts["env"].(map[string]string); ok { for k, v := range envMap { configEnv = append(configEnv, k+"="+v) } } else if envMap, ok := opts["env"].(map[string]any); ok { for k, v := range envMap { if s, ok := v.(string); ok { configEnv = append(configEnv, k+"="+s) } } } return &Agent{ workDir: workDir, cliBin: cliBin, cliExtraArgs: cliExtraArgs, cliArgsFlag: cliArgsFlag, model: model, reasoningEffort: normalizeEffort(reasoningEffort), mode: mode, systemPrompt: systemPrompt, allowedTools: allowedTools, disallowedTools: disallowedTools, maxContextTokens: maxContextTokens, configEnv: configEnv, activeIdx: -1, routerURL: routerURL, routerAPIKey: routerAPIKey, spawnOpts: spawnOpts, }, nil } // normalizeEffort maps user-friendly aliases to Claude CLI --effort values. func normalizeEffort(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case "": return "" case "low": return "low" case "medium", "med": return "medium" case "high": return "high" case "max": return "max" default: return "" } } // normalizePermissionMode maps user-friendly aliases to Claude CLI values. func normalizePermissionMode(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case "acceptedits", "accept-edits", "accept_edits", "edit": return "acceptEdits" case "plan": return "plan" case "auto": return "auto" case "bypasspermissions", "bypass-permissions", "bypass_permissions", "yolo": return "bypassPermissions" case "dontask", "dont-ask", "dont_ask": return "dontAsk" default: return "default" } } func (a *Agent) Name() string { return "claudecode" } func (a *Agent) CLIBinaryName() string { return a.cliBin } func (a *Agent) CLIDisplayName() string { return "Claude" } func (a *Agent) SetWorkDir(dir string) { a.mu.Lock() defer a.mu.Unlock() a.workDir = dir slog.Info("claudecode: work_dir changed", "work_dir", dir) } func (a *Agent) GetWorkDir() string { a.mu.Lock() defer a.mu.Unlock() return a.workDir } func (a *Agent) SetModel(model string) { a.mu.Lock() defer a.mu.Unlock() a.model = model slog.Info("claudecode: model changed", "model", model) } func (a *Agent) GetModel() string { a.mu.Lock() defer a.mu.Unlock() return core.GetProviderModel(a.providers, a.activeIdx, a.model) } func (a *Agent) SetReasoningEffort(effort string) { a.mu.Lock() defer a.mu.Unlock() a.reasoningEffort = normalizeEffort(effort) slog.Info("claudecode: reasoning effort changed", "effort", a.reasoningEffort) } func (a *Agent) GetReasoningEffort() string { a.mu.Lock() defer a.mu.Unlock() return a.reasoningEffort } func (a *Agent) AvailableReasoningEfforts() []string { return []string{"low", "medium", "high", "max"} } func (a *Agent) configuredModels() []core.ModelOption { a.mu.RLock() defer a.mu.RUnlock() return core.GetProviderModels(a.providers, a.activeIdx) } func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption { if models := a.configuredModels(); len(models) > 0 { return models } if models := a.fetchModelsFromAPI(ctx); len(models) > 0 { return models } return []core.ModelOption{ {Name: "sonnet", Desc: "Claude Sonnet (balanced)"}, {Name: "opus", Desc: "Claude Opus (most capable)"}, {Name: "opus[1m]", Desc: "Claude Opus (1M context)"}, {Name: "haiku", Desc: "Claude Haiku (fastest)"}, } } func (a *Agent) fetchModelsFromAPI(ctx context.Context) []core.ModelOption { a.mu.Lock() apiKey := "" baseURL := "" if a.activeIdx >= 0 && a.activeIdx < len(a.providers) { apiKey = a.providers[a.activeIdx].APIKey baseURL = a.providers[a.activeIdx].BaseURL } a.mu.Unlock() if apiKey == "" { apiKey = os.Getenv("ANTHROPIC_API_KEY") } if apiKey == "" { return nil } if baseURL == "" { baseURL = os.Getenv("ANTHROPIC_BASE_URL") } if baseURL == "" { baseURL = "https://api.anthropic.com" } baseURL = strings.TrimRight(baseURL, "/") req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/v1/models", nil) if err != nil { return nil } req.Header.Set("x-api-key", apiKey) req.Header.Set("anthropic-version", "2023-06-01") resp, err := http.DefaultClient.Do(req) if err != nil { slog.Debug("claudecode: failed to fetch models", "error", err) return nil } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil } var result struct { Data []struct { ID string `json:"id"` DisplayName string `json:"display_name"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil } var models []core.ModelOption for _, m := range result.Data { models = append(models, core.ModelOption{Name: m.ID, Desc: m.DisplayName}) } return models } func (a *Agent) SetSessionEnv(env []string) { a.mu.Lock() defer a.mu.Unlock() a.sessionEnv = env } func (a *Agent) SetPlatformPrompt(prompt string) { a.mu.Lock() defer a.mu.Unlock() a.platformPrompt = prompt } // StartSession creates a persistent interactive Claude Code session. func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) { a.mu.Lock() tools := make([]string, len(a.allowedTools)) copy(tools, a.allowedTools) disTools := make([]string, len(a.disallowedTools)) copy(disTools, a.disallowedTools) maxTok := a.maxContextTokens model := a.model effort := a.reasoningEffort workDir := a.workDir mode := a.mode extraEnv := a.runtimeEnvLocked() activeIdx := a.activeIdx var activeProviderName string if activeIdx >= 0 && activeIdx < len(a.providers) { activeProviderName = a.providers[activeIdx].Name if m := a.providers[activeIdx].Model; m != "" { model = m } } slog.Debug("claudecode: StartSession provider state", "activeIdx", activeIdx, "activeProvider", activeProviderName, "model", model, "sessionID", sessionID, "providerCount", len(a.providers)) platformPrompt := a.platformPrompt systemPrompt := a.systemPrompt // When router_url is set, --verbose conflicts with --output-format stream-json // (verbose emits non-JSON text to stdout that corrupts the JSON stream). disableVerbose := a.routerURL != "" a.mu.Unlock() return newClaudeSession(ctx, workDir, a.cliBin, a.cliExtraArgs, a.cliArgsFlag, model, effort, sessionID, mode, systemPrompt, tools, disTools, extraEnv, platformPrompt, disableVerbose, a.spawnOpts, maxTok) } func (a *Agent) ListSessions(ctx context.Context) ([]core.AgentSessionInfo, error) { homeDir, err := os.UserHomeDir() if err != nil { return nil, fmt.Errorf("claudecode: cannot determine home dir: %w", err) } a.mu.RLock() workDir := a.workDir a.mu.RUnlock() absWorkDir, err := filepath.Abs(workDir) if err != nil { return nil, fmt.Errorf("claudecode: resolve work_dir: %w", err) } projectDir := findProjectDir(homeDir, absWorkDir) if projectDir == "" { return nil, nil } entries, err := os.ReadDir(projectDir) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("claudecode: read project dir: %w", err) } var sessions []core.AgentSessionInfo for _, entry := range entries { name := entry.Name() if entry.IsDir() || !strings.HasSuffix(name, ".jsonl") { continue } sessionID := strings.TrimSuffix(name, ".jsonl") info, err := entry.Info() if err != nil { continue } summary, msgCount := scanSessionMeta(filepath.Join(projectDir, name)) sessions = append(sessions, core.AgentSessionInfo{ ID: sessionID, Summary: summary, MessageCount: msgCount, ModifiedAt: info.ModTime(), }) } sort.Slice(sessions, func(i, j int) bool { return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt) }) return sessions, nil } func (a *Agent) DeleteSession(_ context.Context, sessionID string) error { homeDir, err := os.UserHomeDir() if err != nil { return fmt.Errorf("claudecode: cannot determine home dir: %w", err) } a.mu.RLock() workDir := a.workDir a.mu.RUnlock() absWorkDir, err := filepath.Abs(workDir) if err != nil { return fmt.Errorf("claudecode: resolve work_dir: %w", err) } projectDir := findProjectDir(homeDir, absWorkDir) if projectDir == "" { return fmt.Errorf("session not found") } path := filepath.Join(projectDir, sessionID+".jsonl") if _, err := os.Stat(path); os.IsNotExist(err) { return fmt.Errorf("session file not found: %s", sessionID) } return os.Remove(path) } // extractStringContent attempts to extract a plain string from a json.RawMessage. // Returns empty string if the raw message is not a JSON string. func extractStringContent(raw json.RawMessage) string { if len(raw) == 0 { return "" } var s string if err := json.Unmarshal(raw, &s); err != nil { return "" } return s } func scanSessionMeta(path string) (string, int) { f, err := os.Open(path) if err != nil { return "", 0 } defer f.Close() scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 256*1024), 256*1024) var summary string var count int for scanner.Scan() { var entry struct { Type string `json:"type"` Message struct { Content json.RawMessage `json:"content"` } `json:"message"` } if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { continue } if entry.Type == "user" || entry.Type == "assistant" { count++ if entry.Type == "user" { if s := extractStringContent(entry.Message.Content); s != "" { summary = s } } } } summary = stripXMLTags(summary) summary = strings.TrimSpace(summary) if utf8.RuneCountInString(summary) > 40 { summary = string([]rune(summary)[:40]) + "..." } return summary, count } var xmlTagRe = regexp.MustCompile(`<[^>]+>`) func stripXMLTags(s string) string { return xmlTagRe.ReplaceAllString(s, "") } // GetSessionHistory reads the Claude Code JSONL transcript and returns user/assistant messages. func (a *Agent) GetSessionHistory(_ context.Context, sessionID string, limit int) ([]core.HistoryEntry, error) { homeDir, err := os.UserHomeDir() if err != nil { return nil, err } a.mu.RLock() workDir := a.workDir a.mu.RUnlock() absWorkDir, _ := filepath.Abs(workDir) projectDir := findProjectDir(homeDir, absWorkDir) if projectDir == "" { return nil, fmt.Errorf("claudecode: project dir not found") } path := filepath.Join(projectDir, sessionID+".jsonl") f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("claudecode: open session file: %w", err) } defer f.Close() var entries []core.HistoryEntry scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 256*1024), 256*1024) for scanner.Scan() { var raw struct { Type string `json:"type"` Timestamp string `json:"timestamp"` Message struct { Role string `json:"role"` Content json.RawMessage `json:"content"` } `json:"message"` } if json.Unmarshal(scanner.Bytes(), &raw) != nil { continue } if raw.Type != "user" && raw.Type != "assistant" { continue } ts, _ := time.Parse(time.RFC3339Nano, raw.Timestamp) text := extractTextContent(raw.Message.Content) if text == "" { continue } entries = append(entries, core.HistoryEntry{ Role: raw.Type, Content: text, Timestamp: ts, }) } if limit > 0 && len(entries) > limit { entries = entries[len(entries)-limit:] } return entries, nil } // extractTextContent extracts readable text from Claude Code message content. // Content can be a plain string or an array of content blocks. func extractTextContent(raw json.RawMessage) string { if len(raw) == 0 { return "" } // Try plain string first var s string if json.Unmarshal(raw, &s) == nil { return s } // Try array of content blocks var blocks []struct { Type string `json:"type"` Text string `json:"text"` Thinking string `json:"thinking"` } if json.Unmarshal(raw, &blocks) != nil { return "" } for _, b := range blocks { if b.Type == "text" && b.Text != "" { return b.Text } } return "" } func (a *Agent) Stop() error { return nil } // SetMode changes the permission mode for future sessions. func (a *Agent) SetMode(mode string) { a.mu.Lock() defer a.mu.Unlock() a.mode = normalizePermissionMode(mode) slog.Info("claudecode: permission mode changed", "mode", a.mode) } // GetMode returns the current permission mode. func (a *Agent) GetMode() string { a.mu.Lock() defer a.mu.Unlock() return a.mode } // GetRunAsUser returns the target user for OS-isolation spawning, or "" // if no isolation is configured. Set at construction from the project-level // run_as_user field (injected into opts by cmd/cc-connect/main.go). // // This accessor exists specifically so multi-workspace mode can propagate // run_as_user from the parent (project-level) agent into per-workspace // agent instances created lazily by core.Engine.getOrCreateWorkspaceAgent. // Without this, workspace agents are constructed with a fresh opts map // that never contained run_as_user, silently dropping back to the legacy // supervisor-user spawn path — which is exactly the leak cc-connect#496 // is designed to prevent. func (a *Agent) GetRunAsUser() string { a.mu.Lock() defer a.mu.Unlock() return a.spawnOpts.RunAsUser } // GetRunAsEnv returns the user-configured env allowlist extension (the // run_as_env project field), which is merged with core.DefaultEnvAllowlist // at spawn time. Returns nil if no extension is configured. // // Used by the multi-workspace propagation path alongside GetRunAsUser. func (a *Agent) GetRunAsEnv() []string { a.mu.Lock() defer a.mu.Unlock() if len(a.spawnOpts.EnvAllowlist) == 0 { return nil } out := make([]string, len(a.spawnOpts.EnvAllowlist)) copy(out, a.spawnOpts.EnvAllowlist) return out } // WorkspaceAgentOptions returns a snapshot of user-configured options that // must propagate to per-workspace agent instances created lazily by // core.Engine.getOrCreateWorkspaceAgent. Without this snapshot, the engine // constructs workspace agents from a fresh opts map and silently drops // every claudecode field except mode/model — so cli_path, allowed_tools, // and friends would only take effect on the project-level agent. // // Runtime-only state (providers, sessionEnv, providerProxy, platformPrompt) // is intentionally omitted: providers are rewired separately by the engine // after construction; the rest is per-session and recomputed. // // configEnv IS included because it comes from the static config file and must // propagate to every workspace agent. sessionEnv is excluded (runtime-only). // // run_as_user / run_as_env are also omitted because the engine has its own // dedicated propagation path via GetRunAsUser/GetRunAsEnv (see cc-connect#496). func (a *Agent) WorkspaceAgentOptions() map[string]any { a.mu.RLock() defer a.mu.RUnlock() opts := map[string]any{ "mode": a.mode, } if len(a.configEnv) > 0 { envMap := make(map[string]string, len(a.configEnv)) for _, kv := range a.configEnv { k, v, _ := strings.Cut(kv, "=") envMap[k] = v } opts["env"] = envMap } if cliPath := snapshotCLIPath(a.cliBin, a.cliExtraArgs); cliPath != "" { opts["cli_path"] = cliPath } if a.cliArgsFlag != "" { opts["cli_args_flag"] = a.cliArgsFlag } if a.model != "" { opts["model"] = a.model } if a.reasoningEffort != "" { opts["reasoning_effort"] = a.reasoningEffort } if len(a.allowedTools) > 0 { opts["allowed_tools"] = stringsToAny(a.allowedTools) } if len(a.disallowedTools) > 0 { opts["disallowed_tools"] = stringsToAny(a.disallowedTools) } if a.maxContextTokens > 0 { opts["max_context_tokens"] = a.maxContextTokens } if a.routerURL != "" { opts["router_url"] = a.routerURL } if a.routerAPIKey != "" { opts["router_api_key"] = a.routerAPIKey } return opts } // snapshotCLIPath rebuilds the cli_path opts string from cliBin and the // extra-args tail captured at construction. Returns "" when only the // default "claude" binary is in use, so we don't pollute the workspace // opts with a redundant default. func snapshotCLIPath(cliBin string, cliExtraArgs []string) string { // Normalise empty to the default binary so we can reason about extra args. if cliBin == "" { cliBin = "claude" } if cliBin == "claude" && len(cliExtraArgs) == 0 { return "" // default binary, no extra args — no need to persist } if len(cliExtraArgs) == 0 { return cliBin } return cliBin + " " + strings.Join(cliExtraArgs, " ") } // stringsToAny copies a []string into a fresh []any so it round-trips // through New()'s opts["..."].([]any) type assertion. func stringsToAny(in []string) []any { out := make([]any, len(in)) for i, s := range in { out[i] = s } return out } // PermissionModes returns all supported permission modes. func (a *Agent) PermissionModes() []core.PermissionModeInfo { return []core.PermissionModeInfo{ {Key: "default", Name: "Default", NameZh: "默认", Desc: "Ask permission for every tool call", DescZh: "每次工具调用都需确认"}, {Key: "acceptEdits", Name: "Accept Edits", NameZh: "接受编辑", Desc: "Auto-approve file edits, ask for others", DescZh: "自动允许文件编辑,其他需确认"}, {Key: "plan", Name: "Plan Mode", NameZh: "计划模式", Desc: "Plan only, no execution until approved", DescZh: "只做规划不执行,审批后再执行"}, {Key: "auto", Name: "Auto", NameZh: "自动模式", Desc: "Claude decides when to ask for permission", DescZh: "由 Claude 自动判断何时需要确认"}, {Key: "bypassPermissions", Name: "YOLO", NameZh: "YOLO 模式", Desc: "Auto-approve everything", DescZh: "全部自动通过"}, {Key: "dontAsk", Name: "Don't Ask", NameZh: "静默拒绝", Desc: "Auto-deny tools unless pre-approved via allowed_tools or settings.json allow rules", DescZh: "未预授权的工具自动拒绝,不弹确认"}, } } // AddAllowedTools adds tools to the pre-allowed list (takes effect on next session). func (a *Agent) AddAllowedTools(tools ...string) error { a.mu.Lock() defer a.mu.Unlock() existing := make(map[string]bool) for _, t := range a.allowedTools { existing[t] = true } for _, tool := range tools { if !existing[tool] { a.allowedTools = append(a.allowedTools, tool) existing[tool] = true } } slog.Info("claudecode: updated allowed tools", "tools", tools, "total", len(a.allowedTools)) return nil } // GetAllowedTools returns the current list of pre-allowed tools. func (a *Agent) GetAllowedTools() []string { a.mu.Lock() defer a.mu.Unlock() result := make([]string, len(a.allowedTools)) copy(result, a.allowedTools) return result } // GetDisallowedTools returns the current list of disallowed tools. func (a *Agent) GetDisallowedTools() []string { a.mu.Lock() defer a.mu.Unlock() result := make([]string, len(a.disallowedTools)) copy(result, a.disallowedTools) return result } // ── CommandProvider implementation ──────────────────────────── func (a *Agent) CommandDirs() []string { a.mu.RLock() workDir := a.workDir a.mu.RUnlock() absDir, err := filepath.Abs(workDir) if err != nil { absDir = workDir } dirs := []string{filepath.Join(absDir, ".claude", "commands")} if home, err := os.UserHomeDir(); err == nil { dirs = append(dirs, filepath.Join(home, ".claude", "commands")) } return dirs } // ── SkillProvider implementation ────────────────────────────── func (a *Agent) SkillDirs() []string { a.mu.RLock() workDir := a.workDir a.mu.RUnlock() absDir, err := filepath.Abs(workDir) if err != nil { absDir = workDir } return appendProjectClaudeSkillDirs(absDir, claudeConfigHomeDir()) } // ── ContextCompressor implementation ────────────────────────── func (a *Agent) CompressCommand() string { return "/compact" } func claudeConfigHomeDir() string { if dir := strings.TrimSpace(os.Getenv("CLAUDE_CONFIG_DIR")); dir != "" { return dir } home, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(home, ".claude") } func appendProjectClaudeSkillDirs(workDir, configHome string) []string { home, _ := os.UserHomeDir() projectDirs := walkUpClaudeSkillDirs(workDir, home) if configHome == "" { return projectDirs } return uniqueSkillDirs(append(projectDirs, filepath.Join(configHome, "skills"))) } func walkUpClaudeSkillDirs(workDir, home string) []string { current := filepath.Clean(workDir) home = filepath.Clean(home) stopAt := findGitRoot(current) var dirs []string for { if home != "" && samePath(current, home) { break } dirs = append(dirs, filepath.Join(current, ".claude", "skills")) if stopAt != "" && samePath(current, stopAt) { break } parent := filepath.Dir(current) if parent == current { break } current = parent } return uniqueSkillDirs(dirs) } func findGitRoot(start string) string { current := filepath.Clean(start) for { gitPath := filepath.Join(current, ".git") if _, err := os.Stat(gitPath); err == nil { return current } parent := filepath.Dir(current) if parent == current { return "" } current = parent } } func samePath(a, b string) bool { if a == "" || b == "" { return false } return filepath.Clean(a) == filepath.Clean(b) } func uniqueSkillDirs(paths []string) []string { seen := make(map[string]struct{}, len(paths)) out := make([]string, 0, len(paths)) for _, path := range paths { if path == "" { continue } clean := filepath.Clean(path) if _, ok := seen[clean]; ok { continue } seen[clean] = struct{}{} out = append(out, clean) } return out } // ── MemoryFileProvider implementation ───────────────────────── func (a *Agent) ProjectMemoryFile() string { a.mu.RLock() workDir := a.workDir a.mu.RUnlock() absDir, err := filepath.Abs(workDir) if err != nil { absDir = workDir } return filepath.Join(absDir, "CLAUDE.md") } func (a *Agent) GlobalMemoryFile() string { homeDir, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(homeDir, ".claude", "CLAUDE.md") } func (a *Agent) HasSystemPromptSupport() bool { return true } // ── ProviderSwitcher implementation ────────────────────────── func (a *Agent) SetProviders(providers []core.ProviderConfig) { a.mu.Lock() defer a.mu.Unlock() a.providers = providers } func (a *Agent) SetActiveProvider(name string) bool { a.mu.Lock() defer a.mu.Unlock() a.stopProviderProxyLocked() if name == "" { a.activeIdx = -1 slog.Info("claudecode: provider cleared") return true } for i, p := range a.providers { if p.Name == name { a.activeIdx = i slog.Info("claudecode: provider switched", "provider", name) return true } } return false } func (a *Agent) GetActiveProvider() *core.ProviderConfig { a.mu.Lock() defer a.mu.Unlock() if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { return nil } p := a.providers[a.activeIdx] return &p } func (a *Agent) ListProviders() []core.ProviderConfig { a.mu.Lock() defer a.mu.Unlock() result := make([]core.ProviderConfig, len(a.providers)) copy(result, a.providers) return result } // providerEnvLocked returns env vars for the active provider. Caller must hold mu. // // When a custom base_url is configured: // 1. We use ANTHROPIC_AUTH_TOKEN (Bearer) instead of ANTHROPIC_API_KEY // (x-api-key). Claude Code validates API keys against api.anthropic.com // which hangs for third-party endpoints; Bearer auth skips that check. // 2. If the provider sets thinking (e.g. "disabled"), a local reverse proxy // rewrites the thinking parameter for compatibility with providers that // don't support adaptive thinking. // // For env-only providers (Bedrock, Vertex, Foundry) that don't set base_url // but use CLAUDE_CODE_USE_BEDROCK/VERTEX/FOUNDRY env vars, the thinking // rewrite proxy routes via ANTHROPIC_*_BASE_URL override env vars. func (a *Agent) providerEnvLocked() []string { if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { a.stopProviderProxyLocked() return nil } p := a.providers[a.activeIdx] var env []string if p.BaseURL != "" { if p.Thinking != "" { if err := a.ensureProviderProxyLocked(p.BaseURL, p.Thinking); err != nil { slog.Error("providerproxy: failed to start", "error", err) env = append(env, "ANTHROPIC_BASE_URL="+p.BaseURL) } else { env = append(env, "ANTHROPIC_BASE_URL="+a.proxyLocalURL) env = append(env, "NO_PROXY=127.0.0.1") } } else { a.stopProviderProxyLocked() env = append(env, "ANTHROPIC_BASE_URL="+p.BaseURL) } if p.APIKey != "" { env = append(env, "ANTHROPIC_AUTH_TOKEN="+p.APIKey) env = append(env, "ANTHROPIC_API_KEY=") } if p.Model != "" { env = append(env, "ANTHROPIC_MODEL="+p.Model) } } else { // Check for env-only providers (Bedrock, Vertex, Foundry) that need thinking rewrite. if p.Thinking != "" { providerType := detectEnvOnlyProviderType(p.Env) if providerType != "" { targetURL := getDefaultEndpointForProviderType(providerType) if targetURL != "" { if err := a.ensureProviderProxyLocked(targetURL, p.Thinking); err != nil { slog.Error("providerproxy: failed to start for "+providerType, "error", err) a.stopProviderProxyLocked() } else { // Route the provider-specific requests through our proxy. baseURLEnvVar := getBaseURLEnvVarForProviderType(providerType) env = append(env, baseURLEnvVar+"="+a.proxyLocalURL) env = append(env, "NO_PROXY=127.0.0.1") slog.Info("claudecode: thinking rewrite proxy enabled for "+providerType, "target", targetURL, "local", a.proxyLocalURL, "thinking", p.Thinking) } } else { a.stopProviderProxyLocked() } } else { a.stopProviderProxyLocked() } } else { a.stopProviderProxyLocked() } if p.APIKey != "" { env = append(env, "ANTHROPIC_API_KEY="+p.APIKey) } } for k, v := range p.Env { env = append(env, k+"="+v) } slog.Debug("claudecode: providerEnv", "provider", p.Name, "model", p.Model, "env", core.RedactEnv(env)) return env } func (a *Agent) runtimeEnvLocked() []string { // configEnv (from config.toml [env]) is lower priority than provider keys or // session-injected vars, but must survive SetSessionEnv calls (which only // overwrite sessionEnv). Prepend it so later entries win on conflict. env := append([]string(nil), a.configEnv...) env = append(env, a.providerEnvLocked()...) env = append(env, a.sessionEnv...) if a.routerURL != "" { env = append(env, "ANTHROPIC_BASE_URL="+a.routerURL) env = append(env, "NO_PROXY=127.0.0.1") env = append(env, "DISABLE_TELEMETRY=true") env = append(env, "DISABLE_COST_WARNINGS=true") } if a.routerAPIKey != "" { env = append(env, "ANTHROPIC_API_KEY="+a.routerAPIKey) } if !claudeEnvManagesProviderRouting(env) { return env } return core.MergeEnv(env, []string{"CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1"}) } func claudeEnvManagesProviderRouting(env []string) bool { for _, entry := range env { key, _, ok := strings.Cut(entry, "=") if !ok { continue } upper := strings.ToUpper(strings.TrimSpace(key)) if _, ok := claudeProviderManagedEnvVars[upper]; ok { return true } for _, prefix := range claudeProviderManagedEnvPrefixes { if strings.HasPrefix(upper, prefix) { return true } } } return false } func (a *Agent) ensureProviderProxyLocked(targetURL, thinkingOverride string) error { if a.providerProxy != nil && a.proxyLocalURL != "" { return nil } a.stopProviderProxyLocked() proxy, localURL, err := core.NewProviderProxy(targetURL, thinkingOverride) if err != nil { return err } a.providerProxy = proxy a.proxyLocalURL = localURL return nil } func (a *Agent) stopProviderProxyLocked() { if a.providerProxy != nil { a.providerProxy.Close() a.providerProxy = nil a.proxyLocalURL = "" } } // detectEnvOnlyProviderType checks if the provider uses Bedrock, Vertex, or Foundry // via environment variables (without base_url). Returns "bedrock", "vertex", "foundry", // or empty string if not detected. func detectEnvOnlyProviderType(env map[string]string) string { if env == nil { return "" } if env["CLAUDE_CODE_USE_BEDROCK"] == "1" { return "bedrock" } if env["CLAUDE_CODE_USE_VERTEX"] == "1" { return "vertex" } if env["CLAUDE_CODE_USE_FOUNDRY"] == "1" { return "foundry" } return "" } // getDefaultEndpointForProviderType returns the default API endpoint for Bedrock/Vertex/Foundry. // Used as the proxy target when thinking rewrite is needed for env-only providers. func getDefaultEndpointForProviderType(providerType string) string { switch providerType { case "bedrock": // Bedrock cross-region inference endpoint; works with AWS SDK auth. // User can override region via AWS_REGION or CLOUD_ML_REGION env var. return "https://bedrock-runtime.us-east-1.amazonaws.com" case "vertex": // Vertex AI endpoint; requires CLOUD_ML_REGION env var for region. return "https://us-east1-aiplatform.googleapis.com" case "foundry": // Anthropic Foundry internal endpoint (rarely used externally). return "https://api.anthropic.com" default: return "" } } // getBaseURLEnvVarForProviderType returns the environment variable name that // Claude Code uses to override the base URL for Bedrock/Vertex/Foundry providers. func getBaseURLEnvVarForProviderType(providerType string) string { switch providerType { case "bedrock": return "ANTHROPIC_BEDROCK_BASE_URL" case "vertex": return "ANTHROPIC_VERTEX_BASE_URL" case "foundry": return "ANTHROPIC_FOUNDRY_BASE_URL" default: return "" } } // summarizeInput produces a short human-readable description of tool input. func summarizeInput(tool string, input any) string { m, ok := input.(map[string]any) if !ok { return "" } switch tool { case "Read", "Edit", "Write": if fp, ok := m["file_path"].(string); ok { return fp } case "Bash": if cmd, ok := m["command"].(string); ok { return cmd } case "Grep": if p, ok := m["pattern"].(string); ok { return p } case "Glob": if p, ok := m["pattern"].(string); ok { return p } if p, ok := m["glob_pattern"].(string); ok { return p } } b, err := json.Marshal(m) if err != nil { return "" } return string(b) } // parseUserQuestions extracts structured questions from AskUserQuestion input. func parseUserQuestions(input map[string]any) []core.UserQuestion { questionsRaw, ok := input["questions"].([]any) if !ok || len(questionsRaw) == 0 { return nil } var questions []core.UserQuestion for _, qRaw := range questionsRaw { qMap, ok := qRaw.(map[string]any) if !ok { continue } q := core.UserQuestion{ Question: strVal(qMap, "question"), Header: strVal(qMap, "header"), MultiSelect: boolVal(qMap, "multiSelect"), } if optsRaw, ok := qMap["options"].([]any); ok { for _, oRaw := range optsRaw { oMap, ok := oRaw.(map[string]any) if !ok { continue } q.Options = append(q.Options, core.UserQuestionOption{ Label: strVal(oMap, "label"), Description: strVal(oMap, "description"), }) } } if q.Question != "" { questions = append(questions, q) } } return questions } func strVal(m map[string]any, key string) string { v, _ := m[key].(string) return v } func boolVal(m map[string]any, key string) bool { v, _ := m[key].(bool) return v } // encodeClaudeProjectKey converts an absolute path to Claude Code's project key format. // Claude Code encodes paths by: // 1. Replacing path separators (/ or \) with "-" // 2. Replacing colons (:) with "-" (Windows drive letters) // 3. Replacing underscores (_) with "-" // 4. Replacing spaces and tildes (~) with "-" (common in macOS iCloud paths like // "/Users/x/Library/Mobile Documents/com~apple~CloudDocs/...") // 5. Replacing all non-ASCII characters with "-" func encodeClaudeProjectKey(absPath string) string { // First, normalize to forward slashes for consistent processing normalized := strings.ReplaceAll(absPath, "\\", "/") // Build the encoded key character by character var result strings.Builder for _, r := range normalized { if r == '/' || r == ':' || r == '_' || r == ' ' || r == '~' { result.WriteRune('-') } else if r < 128 { // ASCII range (0-127) result.WriteRune(r) } else { // Non-ASCII characters become hyphens result.WriteRune('-') } } return result.String() } // findProjectDir locates the Claude Code session directory for a given work dir. // Claude Code stores sessions at ~/.claude/projects/{projectKey}/ where projectKey // is derived from the absolute path. On Windows, the key format may vary (colon // handling, slash direction), so we try multiple key candidates and fall back to // scanning the projects directory. func findProjectDir(homeDir, absWorkDir string) string { projectsBase := filepath.Join(homeDir, ".claude", "projects") // Build candidate keys: different ways Claude Code might encode the path. // Primary encoding: Claude Code's actual algorithm (non-ASCII → "-") candidates := []string{ encodeClaudeProjectKey(absWorkDir), // Legacy candidates for backward compatibility strings.ReplaceAll(absWorkDir, string(filepath.Separator), "-"), strings.NewReplacer("/", "-", "\\", "-", ":", "-").Replace(absWorkDir), strings.NewReplacer("/", "-", "\\", "-", ":", "-", "_", "-").Replace(absWorkDir), } // Also try with forward slashes (config might use forward slashes on Windows) fwd := strings.ReplaceAll(absWorkDir, "\\", "/") candidates = append(candidates, strings.ReplaceAll(fwd, "/", "-")) for _, key := range candidates { dir := filepath.Join(projectsBase, key) if _, err := os.Stat(dir); err == nil { return dir } } // Fallback: scan the projects directory and find a match by // comparing the encoded path (handles variations in encoding). entries, err := os.ReadDir(projectsBase) if err != nil { return "" } // Use the primary encoding for comparison encodedWorkDir := encodeClaudeProjectKey(absWorkDir) for _, entry := range entries { if !entry.IsDir() { continue } // Direct match with encoded key if entry.Name() == encodedWorkDir { return filepath.Join(projectsBase, entry.Name()) } // Case-insensitive match for Windows compatibility if strings.EqualFold(entry.Name(), encodedWorkDir) { return filepath.Join(projectsBase, entry.Name()) } } return "" }