mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-05 02:30:26 +08:00
修复 PR #53 中发现的问题,实现完整的多后端功能: **多后端功能完整性** - Claude/Gemini 后端支持 workdir (-C) 和 resume (--session-id) 参数 - 并行模式支持全局 --backend 参数和任务级 backend 配置 - 后端参数映射统一,支持 new/resume 两种模式 **安全控制** - Claude 后端默认启用 --dangerously-skip-permissions 以支持自动化 - 通过 CODEAGENT_SKIP_PERMISSIONS 环境变量控制权限检查 - 不同后端行为区分:Claude 默认跳过,Codex/Gemini 默认启用 **并发控制** - 新增 CODEAGENT_MAX_PARALLEL_WORKERS 环境变量限制并发数 - 实现 fail-fast context 取消机制 - Worker pool 防止资源耗尽,支持并发监控日志 **向后兼容** - 版本号统一管理,提供 codex-wrapper 兼容脚本 - 所有默认行为保持不变 - 支持渐进式迁移 **测试覆盖** - 总体覆盖率 93.4%(超过 90% 要求) - 新增后端参数、并行模式、并发控制测试用例 - 核心模块覆盖率:backend.go 100%, config.go 97.8%, executor.go 96.4% **文档更新** - 更新 skills/codeagent/SKILL.md 反映多后端和安全控制 - 添加 CHANGELOG.md 记录重要变更 - 更新 README 版本说明和安装脚本 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
264 lines
6.3 KiB
Go
264 lines
6.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Config holds CLI configuration
|
|
type Config struct {
|
|
Mode string // "new" or "resume"
|
|
Task string
|
|
SessionID string
|
|
WorkDir string
|
|
ExplicitStdin bool
|
|
Timeout int
|
|
Backend string
|
|
SkipPermissions bool
|
|
MaxParallelWorkers int
|
|
}
|
|
|
|
// ParallelConfig defines the JSON schema for parallel execution
|
|
type ParallelConfig struct {
|
|
Tasks []TaskSpec `json:"tasks"`
|
|
GlobalBackend string `json:"backend,omitempty"`
|
|
}
|
|
|
|
// TaskSpec describes an individual task entry in the parallel config
|
|
type TaskSpec struct {
|
|
ID string `json:"id"`
|
|
Task string `json:"task"`
|
|
WorkDir string `json:"workdir,omitempty"`
|
|
Dependencies []string `json:"dependencies,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
Backend string `json:"backend,omitempty"`
|
|
Mode string `json:"-"`
|
|
UseStdin bool `json:"-"`
|
|
Context context.Context `json:"-"`
|
|
}
|
|
|
|
// TaskResult captures the execution outcome of a task
|
|
type TaskResult struct {
|
|
TaskID string `json:"task_id"`
|
|
ExitCode int `json:"exit_code"`
|
|
Message string `json:"message"`
|
|
SessionID string `json:"session_id"`
|
|
Error string `json:"error"`
|
|
LogPath string `json:"log_path"`
|
|
}
|
|
|
|
var backendRegistry = map[string]Backend{
|
|
"codex": CodexBackend{},
|
|
"claude": ClaudeBackend{},
|
|
"gemini": GeminiBackend{},
|
|
}
|
|
|
|
func selectBackend(name string) (Backend, error) {
|
|
key := strings.ToLower(strings.TrimSpace(name))
|
|
if key == "" {
|
|
key = defaultBackendName
|
|
}
|
|
if backend, ok := backendRegistry[key]; ok {
|
|
return backend, nil
|
|
}
|
|
return nil, fmt.Errorf("unsupported backend %q", name)
|
|
}
|
|
|
|
func envFlagEnabled(key string) bool {
|
|
val, ok := os.LookupEnv(key)
|
|
if !ok {
|
|
return false
|
|
}
|
|
val = strings.TrimSpace(strings.ToLower(val))
|
|
switch val {
|
|
case "", "0", "false", "no", "off":
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func parseBoolFlag(val string, defaultValue bool) bool {
|
|
val = strings.TrimSpace(strings.ToLower(val))
|
|
switch val {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
case "0", "false", "no", "off":
|
|
return false
|
|
default:
|
|
return defaultValue
|
|
}
|
|
}
|
|
|
|
func parseParallelConfig(data []byte) (*ParallelConfig, error) {
|
|
trimmed := bytes.TrimSpace(data)
|
|
if len(trimmed) == 0 {
|
|
return nil, fmt.Errorf("parallel config is empty")
|
|
}
|
|
|
|
tasks := strings.Split(string(trimmed), "---TASK---")
|
|
var cfg ParallelConfig
|
|
seen := make(map[string]struct{})
|
|
|
|
for _, taskBlock := range tasks {
|
|
taskBlock = strings.TrimSpace(taskBlock)
|
|
if taskBlock == "" {
|
|
continue
|
|
}
|
|
|
|
parts := strings.SplitN(taskBlock, "---CONTENT---", 2)
|
|
if len(parts) != 2 {
|
|
return nil, fmt.Errorf("task block missing ---CONTENT--- separator")
|
|
}
|
|
|
|
meta := strings.TrimSpace(parts[0])
|
|
content := strings.TrimSpace(parts[1])
|
|
|
|
task := TaskSpec{WorkDir: defaultWorkdir}
|
|
for _, line := range strings.Split(meta, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
kv := strings.SplitN(line, ":", 2)
|
|
if len(kv) != 2 {
|
|
continue
|
|
}
|
|
key := strings.TrimSpace(kv[0])
|
|
value := strings.TrimSpace(kv[1])
|
|
|
|
switch key {
|
|
case "id":
|
|
task.ID = value
|
|
case "workdir":
|
|
task.WorkDir = value
|
|
case "session_id":
|
|
task.SessionID = value
|
|
task.Mode = "resume"
|
|
case "backend":
|
|
task.Backend = value
|
|
case "dependencies":
|
|
for _, dep := range strings.Split(value, ",") {
|
|
dep = strings.TrimSpace(dep)
|
|
if dep != "" {
|
|
task.Dependencies = append(task.Dependencies, dep)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if task.Mode == "" {
|
|
task.Mode = "new"
|
|
}
|
|
|
|
if task.ID == "" {
|
|
return nil, fmt.Errorf("task missing id field")
|
|
}
|
|
if content == "" {
|
|
return nil, fmt.Errorf("task %q missing content", task.ID)
|
|
}
|
|
if _, exists := seen[task.ID]; exists {
|
|
return nil, fmt.Errorf("duplicate task id: %s", task.ID)
|
|
}
|
|
|
|
task.Task = content
|
|
cfg.Tasks = append(cfg.Tasks, task)
|
|
seen[task.ID] = struct{}{}
|
|
}
|
|
|
|
if len(cfg.Tasks) == 0 {
|
|
return nil, fmt.Errorf("no tasks found")
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
func parseArgs() (*Config, error) {
|
|
args := os.Args[1:]
|
|
if len(args) == 0 {
|
|
return nil, fmt.Errorf("task required")
|
|
}
|
|
|
|
backendName := defaultBackendName
|
|
skipPermissions := envFlagEnabled("CODEAGENT_SKIP_PERMISSIONS")
|
|
filtered := make([]string, 0, len(args))
|
|
for i := 0; i < len(args); i++ {
|
|
arg := args[i]
|
|
switch {
|
|
case arg == "--backend":
|
|
if i+1 >= len(args) {
|
|
return nil, fmt.Errorf("--backend flag requires a value")
|
|
}
|
|
backendName = args[i+1]
|
|
i++
|
|
continue
|
|
case strings.HasPrefix(arg, "--backend="):
|
|
value := strings.TrimPrefix(arg, "--backend=")
|
|
if value == "" {
|
|
return nil, fmt.Errorf("--backend flag requires a value")
|
|
}
|
|
backendName = value
|
|
continue
|
|
case arg == "--skip-permissions", arg == "--dangerously-skip-permissions":
|
|
skipPermissions = true
|
|
continue
|
|
case strings.HasPrefix(arg, "--skip-permissions="):
|
|
skipPermissions = parseBoolFlag(strings.TrimPrefix(arg, "--skip-permissions="), skipPermissions)
|
|
continue
|
|
case strings.HasPrefix(arg, "--dangerously-skip-permissions="):
|
|
skipPermissions = parseBoolFlag(strings.TrimPrefix(arg, "--dangerously-skip-permissions="), skipPermissions)
|
|
continue
|
|
}
|
|
filtered = append(filtered, arg)
|
|
}
|
|
|
|
if len(filtered) == 0 {
|
|
return nil, fmt.Errorf("task required")
|
|
}
|
|
args = filtered
|
|
|
|
cfg := &Config{WorkDir: defaultWorkdir, Backend: backendName, SkipPermissions: skipPermissions}
|
|
cfg.MaxParallelWorkers = resolveMaxParallelWorkers()
|
|
|
|
if args[0] == "resume" {
|
|
if len(args) < 3 {
|
|
return nil, fmt.Errorf("resume mode requires: resume <session_id> <task>")
|
|
}
|
|
cfg.Mode = "resume"
|
|
cfg.SessionID = args[1]
|
|
cfg.Task = args[2]
|
|
cfg.ExplicitStdin = (args[2] == "-")
|
|
if len(args) > 3 {
|
|
cfg.WorkDir = args[3]
|
|
}
|
|
} else {
|
|
cfg.Mode = "new"
|
|
cfg.Task = args[0]
|
|
cfg.ExplicitStdin = (args[0] == "-")
|
|
if len(args) > 1 {
|
|
cfg.WorkDir = args[1]
|
|
}
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
func resolveMaxParallelWorkers() int {
|
|
raw := strings.TrimSpace(os.Getenv("CODEAGENT_MAX_PARALLEL_WORKERS"))
|
|
if raw == "" {
|
|
return 0
|
|
}
|
|
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 0 {
|
|
logWarn(fmt.Sprintf("Invalid CODEAGENT_MAX_PARALLEL_WORKERS=%q, falling back to unlimited", raw))
|
|
return 0
|
|
}
|
|
|
|
return value
|
|
}
|