From b8e8ef8929629ad91c774415b53cbec233fb54c8 Mon Sep 17 00:00:00 2001 From: Parth Sareen Date: Mon, 26 Jan 2026 16:40:59 -0500 Subject: [PATCH] cmd: ollama launch clawdbot (#13921) --- cmd/config/clawdbot.go | 167 +++++++++++ cmd/config/clawdbot_test.go | 584 ++++++++++++++++++++++++++++++++++++ cmd/config/integrations.go | 2 + 3 files changed, 753 insertions(+) create mode 100644 cmd/config/clawdbot.go create mode 100644 cmd/config/clawdbot_test.go diff --git a/cmd/config/clawdbot.go b/cmd/config/clawdbot.go new file mode 100644 index 000000000..ca53268d6 --- /dev/null +++ b/cmd/config/clawdbot.go @@ -0,0 +1,167 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +type Clawdbot struct{} + +func (c *Clawdbot) String() string { return "Clawdbot" } + +func (c *Clawdbot) Run(model string) error { + if _, err := exec.LookPath("clawdbot"); err != nil { + return fmt.Errorf("clawdbot is not installed, install from https://docs.clawd.bot") + } + + models := []string{model} + if config, err := loadIntegration("clawdbot"); err == nil && len(config.Models) > 0 { + models = config.Models + } + if err := c.Edit(models); err != nil { + return fmt.Errorf("setup failed: %w", err) + } + + cmd := exec.Command("clawdbot", "gateway") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func (c *Clawdbot) Paths() []string { + home, _ := os.UserHomeDir() + p := filepath.Join(home, ".clawdbot", "clawdbot.json") + if _, err := os.Stat(p); err == nil { + return []string{p} + } + return nil +} + +func (c *Clawdbot) Edit(models []string) error { + if len(models) == 0 { + return nil + } + + home, err := os.UserHomeDir() + if err != nil { + return err + } + + configPath := filepath.Join(home, ".clawdbot", "clawdbot.json") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + return err + } + + // Read into map[string]any to preserve unknown fields + config := make(map[string]any) + if data, err := os.ReadFile(configPath); err == nil { + _ = json.Unmarshal(data, &config) + } + + // Navigate/create: models.providers.ollama (preserving other providers) + modelsSection, _ := config["models"].(map[string]any) + if modelsSection == nil { + modelsSection = make(map[string]any) + } + providers, _ := modelsSection["providers"].(map[string]any) + if providers == nil { + providers = make(map[string]any) + } + ollama, _ := providers["ollama"].(map[string]any) + if ollama == nil { + ollama = make(map[string]any) + } + + ollama["baseUrl"] = "http://127.0.0.1:11434/v1" + // TODO(parthsareen): potentially move to responses + ollama["api"] = "openai-completions" + + // Build map of existing models to preserve user customizations + existingModels, _ := ollama["models"].([]any) + existingByID := make(map[string]map[string]any) + for _, m := range existingModels { + if entry, ok := m.(map[string]any); ok { + if id, ok := entry["id"].(string); ok { + existingByID[id] = entry + } + } + } + + var newModels []any + for _, model := range models { + entry := map[string]any{ + "id": model, + "name": model, + "cost": map[string]any{"input": 0, "output": 0}, + } + // Merge existing fields (user customizations) + if existing, ok := existingByID[model]; ok { + for k, v := range existing { + if _, isNew := entry[k]; !isNew { + entry[k] = v + } + } + } + newModels = append(newModels, entry) + } + ollama["models"] = newModels + + providers["ollama"] = ollama + modelsSection["providers"] = providers + config["models"] = modelsSection + + // Update agents.defaults.model.primary (preserving other agent settings) + agents, _ := config["agents"].(map[string]any) + if agents == nil { + agents = make(map[string]any) + } + defaults, _ := agents["defaults"].(map[string]any) + if defaults == nil { + defaults = make(map[string]any) + } + modelConfig, _ := defaults["model"].(map[string]any) + if modelConfig == nil { + modelConfig = make(map[string]any) + } + modelConfig["primary"] = "ollama/" + models[0] + defaults["model"] = modelConfig + agents["defaults"] = defaults + config["agents"] = agents + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + return writeWithBackup(configPath, data) +} + +func (c *Clawdbot) Models() []string { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + + config, err := readJSONFile(filepath.Join(home, ".clawdbot", "clawdbot.json")) + if err != nil { + return nil + } + + modelsSection, _ := config["models"].(map[string]any) + providers, _ := modelsSection["providers"].(map[string]any) + ollama, _ := providers["ollama"].(map[string]any) + modelList, _ := ollama["models"].([]any) + + var result []string + for _, m := range modelList { + if entry, ok := m.(map[string]any); ok { + if id, ok := entry["id"].(string); ok { + result = append(result, id) + } + } + } + return result +} diff --git a/cmd/config/clawdbot_test.go b/cmd/config/clawdbot_test.go new file mode 100644 index 000000000..661a98b1e --- /dev/null +++ b/cmd/config/clawdbot_test.go @@ -0,0 +1,584 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" +) + +func TestClawdbotIntegration(t *testing.T) { + c := &Clawdbot{} + + t.Run("String", func(t *testing.T) { + if got := c.String(); got != "Clawdbot" { + t.Errorf("String() = %q, want %q", got, "Clawdbot") + } + }) + + t.Run("implements Runner", func(t *testing.T) { + var _ Runner = c + }) + + t.Run("implements Editor", func(t *testing.T) { + var _ Editor = c + }) +} + +func TestClawdbotEdit(t *testing.T) { + c := &Clawdbot{} + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + configDir := filepath.Join(tmpDir, ".clawdbot") + configPath := filepath.Join(configDir, "clawdbot.json") + + cleanup := func() { os.RemoveAll(configDir) } + + t.Run("fresh install", func(t *testing.T) { + cleanup() + if err := c.Edit([]string{"llama3.2"}); err != nil { + t.Fatal(err) + } + assertClawdbotModelExists(t, configPath, "llama3.2") + assertClawdbotPrimaryModel(t, configPath, "ollama/llama3.2") + }) + + t.Run("multiple models - first is primary", func(t *testing.T) { + cleanup() + if err := c.Edit([]string{"llama3.2", "mistral"}); err != nil { + t.Fatal(err) + } + assertClawdbotModelExists(t, configPath, "llama3.2") + assertClawdbotModelExists(t, configPath, "mistral") + assertClawdbotPrimaryModel(t, configPath, "ollama/llama3.2") + }) + + t.Run("preserve other providers", func(t *testing.T) { + cleanup() + os.MkdirAll(configDir, 0o755) + os.WriteFile(configPath, []byte(`{"models":{"providers":{"anthropic":{"apiKey":"xxx"}}}}`), 0o644) + if err := c.Edit([]string{"llama3.2"}); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(configPath) + var cfg map[string]any + json.Unmarshal(data, &cfg) + models := cfg["models"].(map[string]any) + providers := models["providers"].(map[string]any) + if providers["anthropic"] == nil { + t.Error("anthropic provider was removed") + } + }) + + t.Run("preserve top-level keys", func(t *testing.T) { + cleanup() + os.MkdirAll(configDir, 0o755) + os.WriteFile(configPath, []byte(`{"theme":"dark","mcp":{"servers":{}}}`), 0o644) + if err := c.Edit([]string{"llama3.2"}); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(configPath) + var cfg map[string]any + json.Unmarshal(data, &cfg) + if cfg["theme"] != "dark" { + t.Error("theme was removed") + } + if cfg["mcp"] == nil { + t.Error("mcp was removed") + } + }) + + t.Run("preserve user customizations on models", func(t *testing.T) { + cleanup() + c.Edit([]string{"llama3.2"}) + + // User adds custom field + data, _ := os.ReadFile(configPath) + var cfg map[string]any + json.Unmarshal(data, &cfg) + models := cfg["models"].(map[string]any) + providers := models["providers"].(map[string]any) + ollama := providers["ollama"].(map[string]any) + modelList := ollama["models"].([]any) + entry := modelList[0].(map[string]any) + entry["customField"] = "user-value" + configData, _ := json.MarshalIndent(cfg, "", " ") + os.WriteFile(configPath, configData, 0o644) + + // Re-run Edit + c.Edit([]string{"llama3.2"}) + + data, _ = os.ReadFile(configPath) + json.Unmarshal(data, &cfg) + models = cfg["models"].(map[string]any) + providers = models["providers"].(map[string]any) + ollama = providers["ollama"].(map[string]any) + modelList = ollama["models"].([]any) + entry = modelList[0].(map[string]any) + if entry["customField"] != "user-value" { + t.Error("custom field was lost") + } + }) + + t.Run("edit replaces models list", func(t *testing.T) { + cleanup() + c.Edit([]string{"llama3.2", "mistral"}) + c.Edit([]string{"llama3.2"}) + + assertClawdbotModelExists(t, configPath, "llama3.2") + assertClawdbotModelNotExists(t, configPath, "mistral") + }) + + t.Run("empty models is no-op", func(t *testing.T) { + cleanup() + os.MkdirAll(configDir, 0o755) + original := `{"existing":"data"}` + os.WriteFile(configPath, []byte(original), 0o644) + + c.Edit([]string{}) + + data, _ := os.ReadFile(configPath) + if string(data) != original { + t.Error("empty models should not modify file") + } + }) + + t.Run("corrupted JSON treated as empty", func(t *testing.T) { + cleanup() + os.MkdirAll(configDir, 0o755) + os.WriteFile(configPath, []byte(`{corrupted`), 0o644) + + if err := c.Edit([]string{"llama3.2"}); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(configPath) + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + t.Error("result should be valid JSON") + } + }) + + t.Run("wrong type models section", func(t *testing.T) { + cleanup() + os.MkdirAll(configDir, 0o755) + os.WriteFile(configPath, []byte(`{"models":"not a map"}`), 0o644) + + if err := c.Edit([]string{"llama3.2"}); err != nil { + t.Fatal(err) + } + assertClawdbotModelExists(t, configPath, "llama3.2") + }) +} + +func TestClawdbotModels(t *testing.T) { + c := &Clawdbot{} + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + t.Run("no config returns nil", func(t *testing.T) { + if models := c.Models(); len(models) > 0 { + t.Errorf("expected nil/empty, got %v", models) + } + }) + + t.Run("returns all ollama models", func(t *testing.T) { + configDir := filepath.Join(tmpDir, ".clawdbot") + os.MkdirAll(configDir, 0o755) + os.WriteFile(filepath.Join(configDir, "clawdbot.json"), []byte(`{ + "models":{"providers":{"ollama":{"models":[ + {"id":"llama3.2"}, + {"id":"mistral"} + ]}}} + }`), 0o644) + + models := c.Models() + if len(models) != 2 { + t.Errorf("expected 2 models, got %v", models) + } + }) +} + +// Helper functions +func assertClawdbotModelExists(t *testing.T, path, model string) { + t.Helper() + data, _ := os.ReadFile(path) + var cfg map[string]any + json.Unmarshal(data, &cfg) + models := cfg["models"].(map[string]any) + providers := models["providers"].(map[string]any) + ollama := providers["ollama"].(map[string]any) + modelList := ollama["models"].([]any) + for _, m := range modelList { + if entry, ok := m.(map[string]any); ok { + if entry["id"] == model { + return + } + } + } + t.Errorf("model %s not found", model) +} + +func assertClawdbotModelNotExists(t *testing.T, path, model string) { + t.Helper() + data, _ := os.ReadFile(path) + var cfg map[string]any + json.Unmarshal(data, &cfg) + models, _ := cfg["models"].(map[string]any) + providers, _ := models["providers"].(map[string]any) + ollama, _ := providers["ollama"].(map[string]any) + modelList, _ := ollama["models"].([]any) + for _, m := range modelList { + if entry, ok := m.(map[string]any); ok { + if entry["id"] == model { + t.Errorf("model %s should not exist", model) + } + } + } +} + +func assertClawdbotPrimaryModel(t *testing.T, path, expected string) { + t.Helper() + data, _ := os.ReadFile(path) + var cfg map[string]any + json.Unmarshal(data, &cfg) + agents := cfg["agents"].(map[string]any) + defaults := agents["defaults"].(map[string]any) + model := defaults["model"].(map[string]any) + if model["primary"] != expected { + t.Errorf("primary model = %v, want %v", model["primary"], expected) + } +} + +func TestClawdbotPaths(t *testing.T) { + c := &Clawdbot{} + + t.Run("returns path when config exists", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configDir := filepath.Join(tmpDir, ".clawdbot") + os.MkdirAll(configDir, 0o755) + os.WriteFile(filepath.Join(configDir, "clawdbot.json"), []byte(`{}`), 0o644) + + paths := c.Paths() + if len(paths) != 1 { + t.Errorf("expected 1 path, got %d", len(paths)) + } + }) + + t.Run("returns nil when config missing", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + if paths := c.Paths(); paths != nil { + t.Errorf("expected nil, got %v", paths) + } + }) +} + +func TestClawdbotModelsEdgeCases(t *testing.T) { + c := &Clawdbot{} + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configDir := filepath.Join(tmpDir, ".clawdbot") + configPath := filepath.Join(configDir, "clawdbot.json") + cleanup := func() { os.RemoveAll(configDir) } + + t.Run("corrupted JSON returns nil", func(t *testing.T) { + cleanup() + os.MkdirAll(configDir, 0o755) + os.WriteFile(configPath, []byte(`{corrupted`), 0o644) + if models := c.Models(); models != nil { + t.Errorf("expected nil, got %v", models) + } + }) + + t.Run("wrong type at models level", func(t *testing.T) { + cleanup() + os.MkdirAll(configDir, 0o755) + os.WriteFile(configPath, []byte(`{"models":"string"}`), 0o644) + if models := c.Models(); models != nil { + t.Errorf("expected nil, got %v", models) + } + }) + + t.Run("wrong type at providers level", func(t *testing.T) { + cleanup() + os.MkdirAll(configDir, 0o755) + os.WriteFile(configPath, []byte(`{"models":{"providers":"string"}}`), 0o644) + if models := c.Models(); models != nil { + t.Errorf("expected nil, got %v", models) + } + }) + + t.Run("wrong type at ollama level", func(t *testing.T) { + cleanup() + os.MkdirAll(configDir, 0o755) + os.WriteFile(configPath, []byte(`{"models":{"providers":{"ollama":"string"}}}`), 0o644) + if models := c.Models(); models != nil { + t.Errorf("expected nil, got %v", models) + } + }) + + t.Run("model entry missing id", func(t *testing.T) { + cleanup() + os.MkdirAll(configDir, 0o755) + os.WriteFile(configPath, []byte(`{"models":{"providers":{"ollama":{"models":[{"name":"test"}]}}}}`), 0o644) + if len(c.Models()) != 0 { + t.Error("expected empty for missing id") + } + }) + + t.Run("model id is not string", func(t *testing.T) { + cleanup() + os.MkdirAll(configDir, 0o755) + os.WriteFile(configPath, []byte(`{"models":{"providers":{"ollama":{"models":[{"id":123}]}}}}`), 0o644) + if len(c.Models()) != 0 { + t.Error("expected empty for non-string id") + } + }) +} + +func TestClawdbotEditModelNames(t *testing.T) { + c := &Clawdbot{} + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configPath := filepath.Join(tmpDir, ".clawdbot", "clawdbot.json") + cleanup := func() { os.RemoveAll(filepath.Join(tmpDir, ".clawdbot")) } + + t.Run("model with colon tag", func(t *testing.T) { + cleanup() + if err := c.Edit([]string{"llama3.2:70b"}); err != nil { + t.Fatal(err) + } + assertClawdbotModelExists(t, configPath, "llama3.2:70b") + assertClawdbotPrimaryModel(t, configPath, "ollama/llama3.2:70b") + }) + + t.Run("model with slash", func(t *testing.T) { + cleanup() + if err := c.Edit([]string{"library/model:tag"}); err != nil { + t.Fatal(err) + } + assertClawdbotModelExists(t, configPath, "library/model:tag") + assertClawdbotPrimaryModel(t, configPath, "ollama/library/model:tag") + }) + + t.Run("model with hyphen", func(t *testing.T) { + cleanup() + if err := c.Edit([]string{"test-model"}); err != nil { + t.Fatal(err) + } + assertClawdbotModelExists(t, configPath, "test-model") + }) +} + +func TestClawdbotEditAgentsPreservation(t *testing.T) { + c := &Clawdbot{} + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configDir := filepath.Join(tmpDir, ".clawdbot") + configPath := filepath.Join(configDir, "clawdbot.json") + cleanup := func() { os.RemoveAll(configDir) } + + t.Run("preserve other agent defaults", func(t *testing.T) { + cleanup() + os.MkdirAll(configDir, 0o755) + os.WriteFile(configPath, []byte(`{"agents":{"defaults":{"model":{"primary":"old"},"temperature":0.7}}}`), 0o644) + + c.Edit([]string{"llama3.2"}) + + data, _ := os.ReadFile(configPath) + var cfg map[string]any + json.Unmarshal(data, &cfg) + agents := cfg["agents"].(map[string]any) + defaults := agents["defaults"].(map[string]any) + if defaults["temperature"] != 0.7 { + t.Error("temperature setting was lost") + } + }) + + t.Run("preserve other agents besides defaults", func(t *testing.T) { + cleanup() + os.MkdirAll(configDir, 0o755) + os.WriteFile(configPath, []byte(`{"agents":{"defaults":{},"custom-agent":{"foo":"bar"}}}`), 0o644) + + c.Edit([]string{"llama3.2"}) + + data, _ := os.ReadFile(configPath) + var cfg map[string]any + json.Unmarshal(data, &cfg) + agents := cfg["agents"].(map[string]any) + if agents["custom-agent"] == nil { + t.Error("custom-agent was lost") + } + }) +} + +const testClawdbotFixture = `{ + "theme": "dark", + "mcp": {"servers": {"custom": {"enabled": true}}}, + "models": { + "providers": { + "anthropic": {"apiKey": "xxx"}, + "ollama": { + "baseUrl": "http://127.0.0.1:11434/v1", + "models": [{"id": "old-model", "customField": "preserved"}] + } + } + }, + "agents": { + "defaults": {"model": {"primary": "old"}, "temperature": 0.7}, + "custom-agent": {"foo": "bar"} + } +}` + +func TestClawdbotEdit_RoundTrip(t *testing.T) { + c := &Clawdbot{} + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configDir := filepath.Join(tmpDir, ".clawdbot") + configPath := filepath.Join(configDir, "clawdbot.json") + + os.MkdirAll(configDir, 0o755) + os.WriteFile(configPath, []byte(testClawdbotFixture), 0o644) + + if err := c.Edit([]string{"llama3.2", "mistral"}); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(configPath) + var cfg map[string]any + json.Unmarshal(data, &cfg) + + // Verify top-level preserved + if cfg["theme"] != "dark" { + t.Error("theme not preserved") + } + mcp := cfg["mcp"].(map[string]any) + servers := mcp["servers"].(map[string]any) + if servers["custom"] == nil { + t.Error("mcp.servers.custom not preserved") + } + + // Verify other providers preserved + models := cfg["models"].(map[string]any) + providers := models["providers"].(map[string]any) + if providers["anthropic"] == nil { + t.Error("anthropic provider not preserved") + } + + // Verify agents preserved + agents := cfg["agents"].(map[string]any) + if agents["custom-agent"] == nil { + t.Error("custom-agent not preserved") + } + defaults := agents["defaults"].(map[string]any) + if defaults["temperature"] != 0.7 { + t.Error("temperature not preserved") + } +} + +func TestClawdbotEdit_Idempotent(t *testing.T) { + c := &Clawdbot{} + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configDir := filepath.Join(tmpDir, ".clawdbot") + configPath := filepath.Join(configDir, "clawdbot.json") + + os.MkdirAll(configDir, 0o755) + os.WriteFile(configPath, []byte(testClawdbotFixture), 0o644) + + c.Edit([]string{"llama3.2", "mistral"}) + firstData, _ := os.ReadFile(configPath) + + c.Edit([]string{"llama3.2", "mistral"}) + secondData, _ := os.ReadFile(configPath) + + if string(firstData) != string(secondData) { + t.Error("repeated edits with same models produced different results") + } +} + +func TestClawdbotEdit_MultipleConsecutiveEdits(t *testing.T) { + c := &Clawdbot{} + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configDir := filepath.Join(tmpDir, ".clawdbot") + configPath := filepath.Join(configDir, "clawdbot.json") + + os.MkdirAll(configDir, 0o755) + os.WriteFile(configPath, []byte(testClawdbotFixture), 0o644) + + for i := range 10 { + models := []string{"model-a", "model-b"} + if i%2 == 0 { + models = []string{"model-x", "model-y", "model-z"} + } + if err := c.Edit(models); err != nil { + t.Fatalf("edit %d failed: %v", i, err) + } + } + + data, _ := os.ReadFile(configPath) + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("file is not valid JSON after multiple edits: %v", err) + } + + if cfg["theme"] != "dark" { + t.Error("theme lost after multiple edits") + } +} + +func TestClawdbotEdit_BackupCreated(t *testing.T) { + c := &Clawdbot{} + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configDir := filepath.Join(tmpDir, ".clawdbot") + configPath := filepath.Join(configDir, "clawdbot.json") + backupDir := filepath.Join(os.TempDir(), "ollama-backups") + + os.MkdirAll(configDir, 0o755) + uniqueMarker := fmt.Sprintf("test-marker-%d", os.Getpid()) + original := fmt.Sprintf(`{"theme": "%s"}`, uniqueMarker) + os.WriteFile(configPath, []byte(original), 0o644) + + if err := c.Edit([]string{"model-a"}); err != nil { + t.Fatal(err) + } + + backups, _ := filepath.Glob(filepath.Join(backupDir, "clawdbot.json.*")) + foundBackup := false + for _, backup := range backups { + data, _ := os.ReadFile(backup) + if string(data) == original { + foundBackup = true + break + } + } + + if !foundBackup { + t.Error("backup with original content not found") + } +} + +func TestClawdbotEdit_CreatesDirectoryIfMissing(t *testing.T) { + c := &Clawdbot{} + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configDir := filepath.Join(tmpDir, ".clawdbot") + + if _, err := os.Stat(configDir); !os.IsNotExist(err) { + t.Fatal("directory should not exist before test") + } + + if err := c.Edit([]string{"model-a"}); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(configDir); os.IsNotExist(err) { + t.Fatal("directory was not created") + } +} diff --git a/cmd/config/integrations.go b/cmd/config/integrations.go index 6b5f2a616..9ef35d814 100644 --- a/cmd/config/integrations.go +++ b/cmd/config/integrations.go @@ -41,6 +41,7 @@ type Editor interface { // integrations is the registry of available integrations. var integrations = map[string]Runner{ "claude": &Claude{}, + "clawdbot": &Clawdbot{}, "codex": &Codex{}, "droid": &Droid{}, "opencode": &OpenCode{}, @@ -242,6 +243,7 @@ func LaunchCmd(checkServerHeartbeat func(cmd *cobra.Command, args []string) erro Supported integrations: claude Claude Code + clawdbot Clawdbot codex Codex droid Droid opencode OpenCode