From 34452233116171391f770e8079002620d0a9fd7a Mon Sep 17 00:00:00 2001 From: Parth Sareen Date: Fri, 20 Feb 2026 19:08:38 -0800 Subject: [PATCH] cmd: openclaw onboarding (#14344) --- cmd/cmd.go | 4 + cmd/config/config.go | 51 ++- cmd/config/integrations.go | 29 ++ cmd/config/openclaw.go | 681 +++++++++++++++++++++++++++--- cmd/config/openclaw_test.go | 729 ++++++++++++++++++++++++++++++++- cmd/config/selector.go | 9 +- cmd/tui/tui.go | 18 +- docs/integrations/openclaw.mdx | 78 ++-- 8 files changed, 1478 insertions(+), 121 deletions(-) diff --git a/cmd/cmd.go b/cmd/cmd.go index 92e62ca7f..8c3131593 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -1956,6 +1956,10 @@ func runInteractiveTUI(cmd *cobra.Command) { } launchIntegration := func(name string) bool { + if err := config.EnsureInstalled(name); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return true + } // If not configured or model no longer exists, prompt for model selection configuredModel := config.IntegrationModel(name) if configuredModel == "" || !config.ModelExists(cmd.Context(), configuredModel) || config.IsCloudModelDisabled(cmd.Context(), configuredModel) { diff --git a/cmd/config/config.go b/cmd/config/config.go index ce9374ce5..8eb41f4ae 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -15,8 +15,9 @@ import ( ) type integration struct { - Models []string `json:"models"` - Aliases map[string]string `json:"aliases,omitempty"` + Models []string `json:"models"` + Aliases map[string]string `json:"aliases,omitempty"` + Onboarded bool `json:"onboarded,omitempty"` } type config struct { @@ -139,34 +140,54 @@ func SaveIntegration(appName string, models []string) error { key := strings.ToLower(appName) existing := cfg.Integrations[key] var aliases map[string]string - if existing != nil && existing.Aliases != nil { + var onboarded bool + if existing != nil { aliases = existing.Aliases + onboarded = existing.Onboarded } cfg.Integrations[key] = &integration{ - Models: models, - Aliases: aliases, + Models: models, + Aliases: aliases, + Onboarded: onboarded, } return save(cfg) } +// integrationOnboarded marks an integration as onboarded in ollama's config. +func integrationOnboarded(appName string) error { + cfg, err := load() + if err != nil { + return err + } + + key := strings.ToLower(appName) + existing := cfg.Integrations[key] + if existing == nil { + existing = &integration{} + } + existing.Onboarded = true + cfg.Integrations[key] = existing + return save(cfg) +} + // IntegrationModel returns the first configured model for an integration, or empty string if not configured. func IntegrationModel(appName string) string { - ic, err := loadIntegration(appName) - if err != nil || len(ic.Models) == 0 { + integrationConfig, err := loadIntegration(appName) + if err != nil || len(integrationConfig.Models) == 0 { return "" } - return ic.Models[0] + return integrationConfig.Models[0] } // IntegrationModels returns all configured models for an integration, or nil. func IntegrationModels(appName string) []string { - ic, err := loadIntegration(appName) - if err != nil || len(ic.Models) == 0 { + integrationConfig, err := loadIntegration(appName) + if err != nil || len(integrationConfig.Models) == 0 { return nil } - return ic.Models + return integrationConfig.Models } // LastModel returns the last model that was run, or empty string if none. @@ -234,12 +255,12 @@ func loadIntegration(appName string) (*integration, error) { return nil, err } - ic, ok := cfg.Integrations[strings.ToLower(appName)] + integrationConfig, ok := cfg.Integrations[strings.ToLower(appName)] if !ok { return nil, os.ErrNotExist } - return ic, nil + return integrationConfig, nil } func saveAliases(appName string, aliases map[string]string) error { @@ -272,8 +293,8 @@ func listIntegrations() ([]integration, error) { } result := make([]integration, 0, len(cfg.Integrations)) - for _, ic := range cfg.Integrations { - result = append(result, *ic) + for _, integrationConfig := range cfg.Integrations { + result = append(result, *integrationConfig) } return result, nil diff --git a/cmd/config/integrations.go b/cmd/config/integrations.go index 1480de5f3..acf458abe 100644 --- a/cmd/config/integrations.go +++ b/cmd/config/integrations.go @@ -228,6 +228,31 @@ func IsIntegrationInstalled(name string) bool { } } +// AutoInstallable returns true if the integration can be automatically +// installed when not found (e.g. via npm). +func AutoInstallable(name string) bool { + switch strings.ToLower(name) { + case "openclaw", "clawdbot", "moltbot": + return true + default: + return false + } +} + +// EnsureInstalled checks if an auto-installable integration is present and +// offers to install it if missing. Returns nil for non-auto-installable +// integrations or when the binary is already on PATH. +func EnsureInstalled(name string) error { + if !AutoInstallable(name) { + return nil + } + if IsIntegrationInstalled(name) { + return nil + } + _, err := ensureOpenclawInstalled() + return err +} + // IsEditorIntegration returns true if the named integration uses multi-model // selection (implements the Editor interface). func IsEditorIntegration(name string) bool { @@ -926,6 +951,10 @@ Examples: return fmt.Errorf("unknown integration: %s", name) } + if err := EnsureInstalled(name); err != nil { + return err + } + if modelFlag != "" && IsCloudModelDisabled(cmd.Context(), modelFlag) { modelFlag = "" } diff --git a/cmd/config/openclaw.go b/cmd/config/openclaw.go index c64c2630e..b922271c6 100644 --- a/cmd/config/openclaw.go +++ b/cmd/config/openclaw.go @@ -1,81 +1,287 @@ package config import ( - "bytes" "context" "encoding/json" - "errors" "fmt" - "io" + "net" + "net/url" "os" "os/exec" "path/filepath" + "runtime" + "slices" "strings" + "time" + "github.com/ollama/ollama/api" "github.com/ollama/ollama/envconfig" + "github.com/ollama/ollama/types/model" ) +const defaultGatewayPort = 18789 + +// Bound model capability probing so launch/config cannot hang on slow/unreachable API calls. +var openclawModelShowTimeout = 5 * time.Second + type Openclaw struct{} func (c *Openclaw) String() string { return "OpenClaw" } func (c *Openclaw) Run(model string, args []string) error { - bin := "openclaw" - if _, err := exec.LookPath(bin); err != nil { - bin = "clawdbot" - if _, err := exec.LookPath(bin); err != nil { - return fmt.Errorf("openclaw is not installed, install from https://docs.openclaw.ai") - } - } - - models := []string{model} - if config, err := loadIntegration("openclaw"); err == nil && len(config.Models) > 0 { - models = config.Models - } else if config, err := loadIntegration("clawdbot"); err == nil && len(config.Models) > 0 { - models = config.Models - } - var err error - models, err = resolveEditorModels("openclaw", models, func() ([]string, error) { - return selectModels(context.Background(), "openclaw", "") - }) - if errors.Is(err, errCancelled) { - return nil - } + bin, err := ensureOpenclawInstalled() if err != nil { return err } - if err := c.Edit(models); err != nil { - return fmt.Errorf("setup failed: %w", err) + + firstLaunch := true + if integrationConfig, err := loadIntegration("openclaw"); err == nil { + firstLaunch = !integrationConfig.Onboarded + } + + if firstLaunch { + fmt.Fprintf(os.Stderr, "\n%sSecurity%s\n\n", ansiBold, ansiReset) + fmt.Fprintf(os.Stderr, " OpenClaw can read files and run actions when tools are enabled.\n") + fmt.Fprintf(os.Stderr, " A bad prompt can trick it into doing unsafe things.\n\n") + fmt.Fprintf(os.Stderr, "%s Learn more: https://docs.openclaw.ai/gateway/security%s\n\n", ansiGray, ansiReset) + + ok, err := confirmPrompt("I understand the risks. Continue?") + if err != nil { + return err + } + if !ok { + return nil + } } if !c.onboarded() { - // Onboarding not completed: run it (model already set via Edit) - // Use "ollama" as gateway token for simple local access + fmt.Fprintf(os.Stderr, "\n%sSetting up OpenClaw with Ollama...%s\n", ansiGreen, ansiReset) + fmt.Fprintf(os.Stderr, "%s Model: %s%s\n\n", ansiGray, model, ansiReset) + cmd := exec.Command(bin, "onboard", + "--non-interactive", + "--accept-risk", "--auth-choice", "skip", "--gateway-token", "ollama", + "--install-daemon", + "--skip-channels", + "--skip-skills", ) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - return cmd.Run() + if err := cmd.Run(); err != nil { + return windowsHint(fmt.Errorf("openclaw onboarding failed: %w\n\nTry running: openclaw onboard", err)) + } + + patchDeviceScopes() + + // Onboarding overwrites openclaw.json, so re-apply the model config + // that Edit() wrote before Run() was called. + if err := c.Edit([]string{model}); err != nil { + fmt.Fprintf(os.Stderr, "%s Warning: could not re-apply model config: %v%s\n", ansiYellow, err, ansiReset) + } } - // Onboarding completed: run gateway - cmd := exec.Command(bin, append([]string{"gateway"}, args...)...) - cmd.Stdin = os.Stdin + if strings.HasSuffix(model, ":cloud") || strings.HasSuffix(model, "-cloud") { + if ensureWebSearchPlugin() { + registerWebSearchPlugin() + } + } - // Capture output to detect "already running" message - var outputBuf bytes.Buffer - cmd.Stdout = io.MultiWriter(os.Stdout, &outputBuf) - cmd.Stderr = io.MultiWriter(os.Stderr, &outputBuf) + if firstLaunch { + fmt.Fprintf(os.Stderr, "\n%sPreparing your assistant — this may take a moment...%s\n\n", ansiGray, ansiReset) + } else { + fmt.Fprintf(os.Stderr, "\n%sStarting your assistant — this may take a moment...%s\n\n", ansiGray, ansiReset) + } - err = cmd.Run() - if err != nil && strings.Contains(outputBuf.String(), "Gateway already running") { - fmt.Fprintf(os.Stderr, "%sOpenClaw has been configured with Ollama. Gateway is already running.%s\n", ansiGreen, ansiReset) + // When extra args are passed through, run exactly what the user asked for + // after setup and skip the built-in gateway+TUI convenience flow. + if len(args) > 0 { + cmd := exec.Command(bin, args...) + cmd.Env = openclawEnv() + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return windowsHint(err) + } + if firstLaunch { + if err := integrationOnboarded("openclaw"); err != nil { + return fmt.Errorf("failed to save onboarding state: %w", err) + } + } return nil } - return err + + token, port := c.gatewayInfo() + addr := fmt.Sprintf("localhost:%d", port) + + // If the gateway is already running (e.g. via the daemon), restart it + // so it picks up any config changes from Edit() above (model, provider, etc.). + if portOpen(addr) { + restart := exec.Command(bin, "daemon", "restart") + restart.Env = openclawEnv() + if err := restart.Run(); err != nil { + fmt.Fprintf(os.Stderr, "%s Warning: daemon restart failed: %v%s\n", ansiYellow, err, ansiReset) + } + if !waitForPort(addr, 10*time.Second) { + fmt.Fprintf(os.Stderr, "%s Warning: gateway did not come back after restart%s\n", ansiYellow, ansiReset) + } + } + + // If the gateway isn't running, start it as a background child process. + if !portOpen(addr) { + gw := exec.Command(bin, "gateway", "run", "--force") + gw.Env = openclawEnv() + if err := gw.Start(); err != nil { + return windowsHint(fmt.Errorf("failed to start gateway: %w", err)) + } + defer func() { + if gw.Process != nil { + _ = gw.Process.Kill() + _ = gw.Wait() + } + }() + } + + fmt.Fprintf(os.Stderr, "%sStarting gateway...%s\n", ansiGray, ansiReset) + if !waitForPort(addr, 30*time.Second) { + return windowsHint(fmt.Errorf("gateway did not start on %s", addr)) + } + + printOpenclawReady(bin, token, port, firstLaunch) + + tuiArgs := []string{"tui"} + if firstLaunch { + tuiArgs = append(tuiArgs, "--message", "Wake up, my friend!") + } + tui := exec.Command(bin, tuiArgs...) + tui.Env = openclawEnv() + tui.Stdin = os.Stdin + tui.Stdout = os.Stdout + tui.Stderr = os.Stderr + if err := tui.Run(); err != nil { + return windowsHint(err) + } + + if firstLaunch { + if err := integrationOnboarded("openclaw"); err != nil { + return fmt.Errorf("failed to save onboarding state: %w", err) + } + } + return nil +} + +// gatewayInfo reads the gateway auth token and port from the OpenClaw config. +func (c *Openclaw) gatewayInfo() (token string, port int) { + port = defaultGatewayPort + home, err := os.UserHomeDir() + if err != nil { + return "", port + } + + for _, path := range []string{ + filepath.Join(home, ".openclaw", "openclaw.json"), + filepath.Join(home, ".clawdbot", "clawdbot.json"), + } { + data, err := os.ReadFile(path) + if err != nil { + continue + } + var config map[string]any + if json.Unmarshal(data, &config) != nil { + continue + } + gw, _ := config["gateway"].(map[string]any) + if p, ok := gw["port"].(float64); ok && p > 0 { + port = int(p) + } + auth, _ := gw["auth"].(map[string]any) + if t, _ := auth["token"].(string); t != "" { + token = t + } + return token, port + } + return "", port +} + +func printOpenclawReady(bin, token string, port int, firstLaunch bool) { + u := fmt.Sprintf("http://localhost:%d", port) + if token != "" { + u += "/#token=" + url.QueryEscape(token) + } + + fmt.Fprintf(os.Stderr, "\n%s✓ OpenClaw is running%s\n\n", ansiGreen, ansiReset) + fmt.Fprintf(os.Stderr, " Open the Web UI:\n") + fmt.Fprintf(os.Stderr, " %s\n\n", hyperlink(u, u)) + + if firstLaunch { + fmt.Fprintf(os.Stderr, "%s Quick start:%s\n", ansiBold, ansiReset) + fmt.Fprintf(os.Stderr, "%s /help see all commands%s\n", ansiGray, ansiReset) + fmt.Fprintf(os.Stderr, "%s %s configure --section channels connect WhatsApp, Telegram, etc.%s\n", ansiGray, bin, ansiReset) + fmt.Fprintf(os.Stderr, "%s %s skills browse and install skills%s\n\n", ansiGray, bin, ansiReset) + fmt.Fprintf(os.Stderr, "%s The OpenClaw gateway is running in the background.%s\n", ansiYellow, ansiReset) + fmt.Fprintf(os.Stderr, "%s Stop it with: %s gateway stop%s\n\n", ansiYellow, bin, ansiReset) + } else { + fmt.Fprintf(os.Stderr, "%sTip: connect WhatsApp, Telegram, and more with: %s configure --section channels%s\n", ansiGray, bin, ansiReset) + } +} + +// openclawEnv returns the current environment with provider API keys cleared +// so openclaw only uses the Ollama gateway, not keys from the user's shell. +func openclawEnv() []string { + clear := map[string]bool{ + "ANTHROPIC_API_KEY": true, + "ANTHROPIC_OAUTH_TOKEN": true, + "OPENAI_API_KEY": true, + "GEMINI_API_KEY": true, + "MISTRAL_API_KEY": true, + "GROQ_API_KEY": true, + "XAI_API_KEY": true, + "OPENROUTER_API_KEY": true, + } + var env []string + for _, e := range os.Environ() { + key, _, _ := strings.Cut(e, "=") + if !clear[key] { + env = append(env, e) + } + } + return env +} + +// portOpen checks if a TCP port is currently accepting connections. +func portOpen(addr string) bool { + conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond) + if err != nil { + return false + } + conn.Close() + return true +} + +func waitForPort(addr string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond) + if err == nil { + conn.Close() + return true + } + time.Sleep(250 * time.Millisecond) + } + return false +} + +func windowsHint(err error) error { + if runtime.GOOS != "windows" { + return err + } + return fmt.Errorf("%w\n\n"+ + "OpenClaw runs best on WSL2.\n"+ + "Quick setup: wsl --install\n"+ + "Guide: https://docs.openclaw.ai/windows", err) } // onboarded checks if OpenClaw onboarding wizard was completed @@ -107,6 +313,144 @@ func (c *Openclaw) onboarded() bool { return lastRunAt != "" } +// patchDeviceScopes upgrades the local CLI device's paired scopes to include +// operator.admin. Only patches the local device, not remote ones. +// Best-effort: silently returns on any error. +func patchDeviceScopes() { + home, err := os.UserHomeDir() + if err != nil { + return + } + + deviceID := readLocalDeviceID(home) + if deviceID == "" { + return + } + + path := filepath.Join(home, ".openclaw", "devices", "paired.json") + data, err := os.ReadFile(path) + if err != nil { + return + } + + var devices map[string]map[string]any + if err := json.Unmarshal(data, &devices); err != nil { + return + } + + dev, ok := devices[deviceID] + if !ok { + return + } + + required := []string{ + "operator.read", + "operator.admin", + "operator.approvals", + "operator.pairing", + } + + changed := patchScopes(dev, "scopes", required) + if tokens, ok := dev["tokens"].(map[string]any); ok { + for _, tok := range tokens { + if tokenMap, ok := tok.(map[string]any); ok { + if patchScopes(tokenMap, "scopes", required) { + changed = true + } + } + } + } + + if !changed { + return + } + + out, err := json.MarshalIndent(devices, "", " ") + if err != nil { + return + } + _ = os.WriteFile(path, out, 0o600) +} + +// readLocalDeviceID reads the local device ID from openclaw's identity file. +func readLocalDeviceID(home string) string { + data, err := os.ReadFile(filepath.Join(home, ".openclaw", "identity", "device-auth.json")) + if err != nil { + return "" + } + var auth map[string]any + if err := json.Unmarshal(data, &auth); err != nil { + return "" + } + id, _ := auth["deviceId"].(string) + return id +} + +// patchScopes ensures obj[key] contains all required scopes. Returns true if +// any scopes were added. +func patchScopes(obj map[string]any, key string, required []string) bool { + existing, _ := obj[key].([]any) + have := make(map[string]bool, len(existing)) + for _, s := range existing { + if str, ok := s.(string); ok { + have[str] = true + } + } + added := false + for _, s := range required { + if !have[s] { + existing = append(existing, s) + added = true + } + } + if added { + obj[key] = existing + } + return added +} + +func ensureOpenclawInstalled() (string, error) { + if _, err := exec.LookPath("openclaw"); err == nil { + return "openclaw", nil + } + if _, err := exec.LookPath("clawdbot"); err == nil { + return "clawdbot", nil + } + + if _, err := exec.LookPath("npm"); err != nil { + return "", fmt.Errorf("openclaw is not installed and npm was not found\n\n" + + "Install Node.js first:\n" + + " https://nodejs.org/\n\n" + + "Then rerun:\n" + + " ollama launch\n" + + "and select OpenClaw") + } + + ok, err := confirmPrompt("OpenClaw is not installed. Install with npm?") + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("openclaw installation cancelled") + } + + fmt.Fprintf(os.Stderr, "\nInstalling OpenClaw...\n") + cmd := exec.Command("npm", "install", "-g", "openclaw@latest") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("failed to install openclaw: %w", err) + } + + if _, err := exec.LookPath("openclaw"); err != nil { + return "", fmt.Errorf("openclaw was installed but the binary was not found on PATH\n\nYou may need to restart your shell") + } + + fmt.Fprintf(os.Stderr, "%sOpenClaw installed successfully%s\n\n", ansiGreen, ansiReset) + return "openclaw", nil +} + func (c *Openclaw) Paths() []string { home, _ := os.UserHomeDir() p := filepath.Join(home, ".openclaw", "openclaw.json") @@ -161,8 +505,7 @@ func (c *Openclaw) Edit(models []string) error { ollama["baseUrl"] = envconfig.Host().String() + "/v1" // needed to register provider ollama["apiKey"] = "ollama-local" - // TODO(parthsareen): potentially move to responses - ollama["api"] = "openai-completions" + ollama["api"] = "ollama" // Build map of existing models to preserve user customizations existingModels, _ := ollama["models"].([]any) @@ -175,25 +518,13 @@ func (c *Openclaw) Edit(models []string) error { } } + client, _ := api.ClientFromEnvironment() + var newModels []any - for _, model := range models { - entry := map[string]any{ - "id": model, - "name": model, - "reasoning": false, - "input": []any{"text"}, - "cost": map[string]any{ - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0, - }, - // TODO(parthsareen): get these values from API - "contextWindow": 131072, - "maxTokens": 16384, - } + for _, m := range models { + entry, _ := openclawModelConfig(context.Background(), client, m) // Merge existing fields (user customizations) - if existing, ok := existingByID[model]; ok { + if existing, ok := existingByID[m]; ok { for k, v := range existing { if _, isNew := entry[k]; !isNew { entry[k] = v @@ -230,7 +561,237 @@ func (c *Openclaw) Edit(models []string) error { if err != nil { return err } - return writeWithBackup(configPath, data) + if err := writeWithBackup(configPath, data); err != nil { + return err + } + + // Clear any per-session model overrides so the new primary takes effect + // immediately rather than being shadowed by a cached modelOverride. + clearSessionModelOverride(models[0]) + return nil +} + +// clearSessionModelOverride removes per-session model overrides from the main +// agent session so the global primary model takes effect on the next TUI launch. +func clearSessionModelOverride(primary string) { + home, err := os.UserHomeDir() + if err != nil { + return + } + path := filepath.Join(home, ".openclaw", "agents", "main", "sessions", "sessions.json") + data, err := os.ReadFile(path) + if err != nil { + return + } + var sessions map[string]map[string]any + if json.Unmarshal(data, &sessions) != nil { + return + } + changed := false + for _, sess := range sessions { + if override, _ := sess["modelOverride"].(string); override != "" && override != primary { + delete(sess, "modelOverride") + delete(sess, "providerOverride") + sess["model"] = primary + changed = true + } + } + if !changed { + return + } + out, err := json.MarshalIndent(sessions, "", " ") + if err != nil { + return + } + _ = os.WriteFile(path, out, 0o600) +} + +const webSearchNpmPackage = "@ollama/openclaw-web-search" + +// ensureWebSearchPlugin installs the openclaw-web-search extension into the OpenClaw +// extensions directory if it isn't already present. Returns true if the extension +// is available (either already installed or just installed). +func ensureWebSearchPlugin() bool { + extDir := openclawExtensionsDir() + if extDir == "" { + return false + } + + pluginDir := filepath.Join(extDir, "openclaw-web-search") + if _, err := os.Stat(filepath.Join(pluginDir, "index.ts")); err == nil { + return true // already installed + } + + npmBin, err := exec.LookPath("npm") + if err != nil { + return false + } + + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + return false + } + + // Download the tarball via `npm pack`, extract it flat into the plugin dir. + pack := exec.Command(npmBin, "pack", webSearchNpmPackage, "--pack-destination", pluginDir) + out, err := pack.Output() + if err != nil { + fmt.Fprintf(os.Stderr, "%s Warning: could not download web search plugin: %v%s\n", ansiYellow, err, ansiReset) + return false + } + + tgzName := strings.TrimSpace(string(out)) + tgzPath := filepath.Join(pluginDir, tgzName) + defer os.Remove(tgzPath) + + tar := exec.Command("tar", "xzf", tgzPath, "--strip-components=1", "-C", pluginDir) + if err := tar.Run(); err != nil { + fmt.Fprintf(os.Stderr, "%s Warning: could not extract web search plugin: %v%s\n", ansiYellow, err, ansiReset) + return false + } + + fmt.Fprintf(os.Stderr, "%s ✓ Installed web search plugin%s\n", ansiGreen, ansiReset) + return true +} + +// registerWebSearchPlugin adds plugins.entries.openclaw-web-search to the OpenClaw +// config so the gateway activates it on next start. Best-effort; silently returns +// on any error. +func registerWebSearchPlugin() { + home, err := os.UserHomeDir() + if err != nil { + return + } + configPath := filepath.Join(home, ".openclaw", "openclaw.json") + data, err := os.ReadFile(configPath) + if err != nil { + return + } + var config map[string]any + if json.Unmarshal(data, &config) != nil { + return + } + + plugins, _ := config["plugins"].(map[string]any) + if plugins == nil { + plugins = make(map[string]any) + } + entries, _ := plugins["entries"].(map[string]any) + if entries == nil { + entries = make(map[string]any) + } + if _, ok := entries["openclaw-web-search"]; ok { + return // already registered + } + entries["openclaw-web-search"] = map[string]any{"enabled": true} + plugins["entries"] = entries + config["plugins"] = plugins + + // Disable the built-in web search since our plugin replaces it. + tools, _ := config["tools"].(map[string]any) + if tools == nil { + tools = make(map[string]any) + } + web, _ := tools["web"].(map[string]any) + if web == nil { + web = make(map[string]any) + } + web["search"] = map[string]any{"enabled": false} + tools["web"] = web + config["tools"] = tools + + out, err := json.MarshalIndent(config, "", " ") + if err != nil { + return + } + _ = os.WriteFile(configPath, out, 0o600) +} + +// openclawExtensionsDir resolves the extensions directory inside the openclaw +// npm package. Returns "" if the binary or path cannot be resolved. +func openclawExtensionsDir() string { + bin, err := exec.LookPath("openclaw") + if err != nil { + bin, err = exec.LookPath("clawdbot") + if err != nil { + return "" + } + } + binPath, err := filepath.EvalSymlinks(bin) + if err != nil { + return "" + } + // The binary symlink resolves to /openclaw.mjs (package root). + // Extensions live at /extensions/. + pkgDir := filepath.Dir(binPath) + extDir := filepath.Join(pkgDir, "extensions") + if info, err := os.Stat(extDir); err == nil && info.IsDir() { + return extDir + } + return "" +} + +// openclawModelConfig builds an OpenClaw model config entry with capability detection. +// The second return value indicates whether the model is a cloud (remote) model. +func openclawModelConfig(ctx context.Context, client *api.Client, modelID string) (map[string]any, bool) { + entry := map[string]any{ + "id": modelID, + "name": modelID, + "input": []any{"text"}, + "cost": map[string]any{ + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0, + }, + } + + if client == nil { + return entry, false + } + + showCtx := ctx + if _, hasDeadline := ctx.Deadline(); !hasDeadline { + var cancel context.CancelFunc + showCtx, cancel = context.WithTimeout(ctx, openclawModelShowTimeout) + defer cancel() + } + + resp, err := client.Show(showCtx, &api.ShowRequest{Model: modelID}) + if err != nil { + return entry, false + } + + // Set input types based on vision capability + if slices.Contains(resp.Capabilities, model.CapabilityVision) { + entry["input"] = []any{"text", "image"} + } + + // Set reasoning based on thinking capability + if slices.Contains(resp.Capabilities, model.CapabilityThinking) { + entry["reasoning"] = true + } + + // Cloud models: use hardcoded limits for context/output tokens. + // Capability detection above still applies (vision, thinking). + if resp.RemoteModel != "" { + if l, ok := lookupCloudModelLimit(modelID); ok { + entry["contextWindow"] = l.Context + entry["maxTokens"] = l.Output + } + return entry, true + } + + // Extract context window from ModelInfo (local models only) + for key, val := range resp.ModelInfo { + if strings.HasSuffix(key, ".context_length") { + if ctxLen, ok := val.(float64); ok && ctxLen > 0 { + entry["contextWindow"] = int(ctxLen) + } + break + } + } + + return entry, false } func (c *Openclaw) Models() []string { diff --git a/cmd/config/openclaw_test.go b/cmd/config/openclaw_test.go index 439f51a35..bcc4b17b7 100644 --- a/cmd/config/openclaw_test.go +++ b/cmd/config/openclaw_test.go @@ -1,11 +1,21 @@ package config import ( + "bytes" + "context" "encoding/json" "fmt" + "net/http" + "net/http/httptest" + "net/url" "os" "path/filepath" + "runtime" + "strings" "testing" + "time" + + "github.com/ollama/ollama/api" ) func TestOpenclawIntegration(t *testing.T) { @@ -26,6 +36,124 @@ func TestOpenclawIntegration(t *testing.T) { }) } +func TestOpenclawRunPassthroughArgs(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + + if err := integrationOnboarded("openclaw"); err != nil { + t.Fatal(err) + } + + configDir := filepath.Join(tmpDir, ".openclaw") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{ + "wizard": {"lastRunAt": "2026-01-01T00:00:00Z"} + }`), 0o644); err != nil { + t.Fatal(err) + } + + bin := filepath.Join(tmpDir, "openclaw") + if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil { + t.Fatal(err) + } + + c := &Openclaw{} + if err := c.Run("llama3.2", []string{"gateway", "--someflag"}); err != nil { + t.Fatalf("Run() error = %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 1 { + t.Fatalf("expected exactly 1 invocation, got %d: %v", len(lines), lines) + } + if lines[0] != "gateway --someflag" { + t.Fatalf("invocation = %q, want %q", lines[0], "gateway --someflag") + } +} + +func TestOpenclawRunFirstLaunchPersistence(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + oldHook := DefaultConfirmPrompt + DefaultConfirmPrompt = func(prompt string) (bool, error) { + return true, nil + } + defer func() { DefaultConfirmPrompt = oldHook }() + + t.Run("success persists onboarding flag", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + + configDir := filepath.Join(tmpDir, ".openclaw") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + // Mark OpenClaw onboarding complete so Run takes passthrough path directly. + if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{ + "wizard": {"lastRunAt": "2026-01-01T00:00:00Z"} + }`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "openclaw"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + + c := &Openclaw{} + if err := c.Run("llama3.2", []string{"gateway", "--status"}); err != nil { + t.Fatalf("Run() error = %v", err) + } + integrationConfig, err := loadIntegration("openclaw") + if err != nil { + t.Fatalf("loadIntegration() error = %v", err) + } + if !integrationConfig.Onboarded { + t.Fatal("expected onboarding flag to be persisted after successful run") + } + }) + + t.Run("failure does not persist onboarding flag", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + + configDir := filepath.Join(tmpDir, ".openclaw") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{ + "wizard": {"lastRunAt": "2026-01-01T00:00:00Z"} + }`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "openclaw"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + t.Fatal(err) + } + + c := &Openclaw{} + if err := c.Run("llama3.2", []string{"gateway", "--status"}); err == nil { + t.Fatal("expected run failure") + } + integrationConfig, err := loadIntegration("openclaw") + if err == nil && integrationConfig.Onboarded { + t.Fatal("expected onboarding flag to remain unset after failed run") + } + }) +} + func TestOpenclawEdit(t *testing.T) { c := &Openclaw{} tmpDir := t.TempDir() @@ -359,19 +487,16 @@ func TestOpenclawEditSchemaFields(t *testing.T) { modelList := ollama["models"].([]any) entry := modelList[0].(map[string]any) - // Verify required schema fields - if entry["reasoning"] != false { - t.Error("reasoning should be false") + // Verify base schema fields (always set regardless of API availability) + if entry["id"] != "llama3.2" { + t.Errorf("id = %v, want llama3.2", entry["id"]) + } + if entry["name"] != "llama3.2" { + t.Errorf("name = %v, want llama3.2", entry["name"]) } if entry["input"] == nil { t.Error("input should be set") } - if entry["contextWindow"] == nil { - t.Error("contextWindow should be set") - } - if entry["maxTokens"] == nil { - t.Error("maxTokens should be set") - } cost := entry["cost"].(map[string]any) if cost["cacheRead"] == nil { t.Error("cost.cacheRead should be set") @@ -876,3 +1001,589 @@ func TestOpenclawOnboarded(t *testing.T) { } }) } + +func TestOpenclawGatewayInfo(t *testing.T) { + c := &Openclaw{} + + t.Run("returns defaults when no config exists", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + token, port := c.gatewayInfo() + if token != "" { + t.Errorf("expected empty token, got %q", token) + } + if port != defaultGatewayPort { + t.Errorf("expected default port %d, got %d", defaultGatewayPort, port) + } + }) + + t.Run("reads token and port from config", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configDir := filepath.Join(tmpDir, ".openclaw") + os.MkdirAll(configDir, 0o755) + os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{ + "gateway": { + "port": 9999, + "auth": {"mode": "token", "token": "my-secret"} + } + }`), 0o644) + + token, port := c.gatewayInfo() + if token != "my-secret" { + t.Errorf("expected token %q, got %q", "my-secret", token) + } + if port != 9999 { + t.Errorf("expected port 9999, got %d", port) + } + }) + + t.Run("uses default port when not in config", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configDir := filepath.Join(tmpDir, ".openclaw") + os.MkdirAll(configDir, 0o755) + os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{ + "gateway": {"auth": {"token": "tok"}} + }`), 0o644) + + token, port := c.gatewayInfo() + if token != "tok" { + t.Errorf("expected token %q, got %q", "tok", token) + } + if port != defaultGatewayPort { + t.Errorf("expected default port %d, got %d", defaultGatewayPort, port) + } + }) + + t.Run("falls back to legacy clawdbot config", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + legacyDir := filepath.Join(tmpDir, ".clawdbot") + os.MkdirAll(legacyDir, 0o755) + os.WriteFile(filepath.Join(legacyDir, "clawdbot.json"), []byte(`{ + "gateway": {"port": 12345, "auth": {"token": "legacy-token"}} + }`), 0o644) + + token, port := c.gatewayInfo() + if token != "legacy-token" { + t.Errorf("expected token %q, got %q", "legacy-token", token) + } + if port != 12345 { + t.Errorf("expected port 12345, got %d", port) + } + }) + + t.Run("handles corrupted JSON gracefully", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configDir := filepath.Join(tmpDir, ".openclaw") + os.MkdirAll(configDir, 0o755) + os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{corrupted`), 0o644) + + token, port := c.gatewayInfo() + if token != "" { + t.Errorf("expected empty token, got %q", token) + } + if port != defaultGatewayPort { + t.Errorf("expected default port, got %d", port) + } + }) + + t.Run("handles missing gateway section", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configDir := filepath.Join(tmpDir, ".openclaw") + os.MkdirAll(configDir, 0o755) + os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{"theme":"dark"}`), 0o644) + + token, port := c.gatewayInfo() + if token != "" { + t.Errorf("expected empty token, got %q", token) + } + if port != defaultGatewayPort { + t.Errorf("expected default port, got %d", port) + } + }) +} + +func TestPrintOpenclawReady(t *testing.T) { + t.Run("includes port in URL", func(t *testing.T) { + var buf bytes.Buffer + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + printOpenclawReady("openclaw", "", 9999, false) + + w.Close() + os.Stderr = old + buf.ReadFrom(r) + + output := buf.String() + if !strings.Contains(output, "localhost:9999") { + t.Errorf("expected port 9999 in output, got:\n%s", output) + } + if strings.Contains(output, "#token=") { + t.Error("should not include token fragment when token is empty") + } + }) + + t.Run("URL-escapes token", func(t *testing.T) { + var buf bytes.Buffer + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + printOpenclawReady("openclaw", "my token&special=chars", defaultGatewayPort, false) + + w.Close() + os.Stderr = old + buf.ReadFrom(r) + + output := buf.String() + escaped := url.QueryEscape("my token&special=chars") + if !strings.Contains(output, "#token="+escaped) { + t.Errorf("expected URL-escaped token %q in output, got:\n%s", escaped, output) + } + }) + + t.Run("simple token is not mangled", func(t *testing.T) { + var buf bytes.Buffer + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + printOpenclawReady("openclaw", "ollama", defaultGatewayPort, false) + + w.Close() + os.Stderr = old + buf.ReadFrom(r) + + output := buf.String() + if !strings.Contains(output, "#token=ollama") { + t.Errorf("expected #token=ollama in output, got:\n%s", output) + } + }) + + t.Run("includes web UI hint", func(t *testing.T) { + var buf bytes.Buffer + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + printOpenclawReady("openclaw", "", defaultGatewayPort, false) + + w.Close() + os.Stderr = old + buf.ReadFrom(r) + + output := buf.String() + if !strings.Contains(output, "Open the Web UI") { + t.Errorf("expected web UI hint in output, got:\n%s", output) + } + }) + + t.Run("first launch shows quick start tips", func(t *testing.T) { + var buf bytes.Buffer + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + printOpenclawReady("openclaw", "ollama", defaultGatewayPort, true) + + w.Close() + os.Stderr = old + buf.ReadFrom(r) + + output := buf.String() + for _, want := range []string{"/help", "channels", "skills", "gateway"} { + if !strings.Contains(output, want) { + t.Errorf("expected %q in first-launch output, got:\n%s", want, output) + } + } + }) + + t.Run("subsequent launch shows single tip", func(t *testing.T) { + var buf bytes.Buffer + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + printOpenclawReady("openclaw", "ollama", defaultGatewayPort, false) + + w.Close() + os.Stderr = old + buf.ReadFrom(r) + + output := buf.String() + if !strings.Contains(output, "Tip:") { + t.Errorf("expected single tip line, got:\n%s", output) + } + if strings.Contains(output, "Quick start") { + t.Errorf("should not show quick start on subsequent launch") + } + }) +} + +func TestOpenclawModelConfig(t *testing.T) { + t.Run("nil client returns base config", func(t *testing.T) { + cfg, _ := openclawModelConfig(context.Background(), nil, "llama3.2") + + if cfg["id"] != "llama3.2" { + t.Errorf("id = %v, want llama3.2", cfg["id"]) + } + if cfg["name"] != "llama3.2" { + t.Errorf("name = %v, want llama3.2", cfg["name"]) + } + if cfg["cost"] == nil { + t.Error("cost should be set") + } + // Should not have capability fields without API + if _, ok := cfg["reasoning"]; ok { + t.Error("reasoning should not be set without API") + } + if _, ok := cfg["contextWindow"]; ok { + t.Error("contextWindow should not be set without API") + } + }) + + t.Run("sets vision input when model has vision capability", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/show" { + fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{"llama.context_length":4096}}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + u, _ := url.Parse(srv.URL) + client := api.NewClient(u, srv.Client()) + + cfg, _ := openclawModelConfig(context.Background(), client, "llava:7b") + + input, ok := cfg["input"].([]any) + if !ok || len(input) != 2 { + t.Errorf("input = %v, want [text image]", cfg["input"]) + } + }) + + t.Run("sets text-only input when model lacks vision", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/show" { + fmt.Fprintf(w, `{"capabilities":["completion"],"model_info":{}}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + u, _ := url.Parse(srv.URL) + client := api.NewClient(u, srv.Client()) + + cfg, _ := openclawModelConfig(context.Background(), client, "llama3.2") + + input, ok := cfg["input"].([]any) + if !ok || len(input) != 1 { + t.Errorf("input = %v, want [text]", cfg["input"]) + } + if _, ok := cfg["reasoning"]; ok { + t.Error("reasoning should not be set for non-thinking model") + } + }) + + t.Run("sets reasoning when model has thinking capability", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/show" { + fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{}}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + u, _ := url.Parse(srv.URL) + client := api.NewClient(u, srv.Client()) + + cfg, _ := openclawModelConfig(context.Background(), client, "qwq") + + if cfg["reasoning"] != true { + t.Error("expected reasoning = true for thinking model") + } + }) + + t.Run("extracts context window from model info", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/show" { + fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":131072}}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + u, _ := url.Parse(srv.URL) + client := api.NewClient(u, srv.Client()) + + cfg, _ := openclawModelConfig(context.Background(), client, "llama3.2") + + if cfg["contextWindow"] != 131072 { + t.Errorf("contextWindow = %v, want 131072", cfg["contextWindow"]) + } + }) + + t.Run("handles all capabilities together", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/show" { + fmt.Fprintf(w, `{"capabilities":["vision","thinking"],"model_info":{"qwen3.context_length":32768}}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + u, _ := url.Parse(srv.URL) + client := api.NewClient(u, srv.Client()) + + cfg, _ := openclawModelConfig(context.Background(), client, "qwen3-vision") + + input, ok := cfg["input"].([]any) + if !ok || len(input) != 2 { + t.Errorf("input = %v, want [text image]", cfg["input"]) + } + if cfg["reasoning"] != true { + t.Error("expected reasoning = true") + } + if cfg["contextWindow"] != 32768 { + t.Errorf("contextWindow = %v, want 32768", cfg["contextWindow"]) + } + }) + + t.Run("returns base config when show fails", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, `{"error":"model not found"}`) + })) + defer srv.Close() + + u, _ := url.Parse(srv.URL) + client := api.NewClient(u, srv.Client()) + + cfg, _ := openclawModelConfig(context.Background(), client, "missing-model") + + if cfg["id"] != "missing-model" { + t.Errorf("id = %v, want missing-model", cfg["id"]) + } + // Should still have input (default) + if cfg["input"] == nil { + t.Error("input should always be set") + } + if _, ok := cfg["reasoning"]; ok { + t.Error("reasoning should not be set when show fails") + } + if _, ok := cfg["contextWindow"]; ok { + t.Error("contextWindow should not be set when show fails") + } + }) + + t.Run("times out slow show and returns base config", func(t *testing.T) { + oldTimeout := openclawModelShowTimeout + openclawModelShowTimeout = 50 * time.Millisecond + t.Cleanup(func() { openclawModelShowTimeout = oldTimeout }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/show" { + time.Sleep(300 * time.Millisecond) + fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{"llama.context_length":4096}}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + u, _ := url.Parse(srv.URL) + client := api.NewClient(u, srv.Client()) + + start := time.Now() + cfg, _ := openclawModelConfig(context.Background(), client, "slow-model") + elapsed := time.Since(start) + if elapsed >= 250*time.Millisecond { + t.Fatalf("openclawModelConfig took too long: %v", elapsed) + } + if cfg["id"] != "slow-model" { + t.Errorf("id = %v, want slow-model", cfg["id"]) + } + if _, ok := cfg["reasoning"]; ok { + t.Error("reasoning should not be set on timeout") + } + if _, ok := cfg["contextWindow"]; ok { + t.Error("contextWindow should not be set on timeout") + } + }) + + t.Run("skips zero context length", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/show" { + fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":0}}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + u, _ := url.Parse(srv.URL) + client := api.NewClient(u, srv.Client()) + + cfg, _ := openclawModelConfig(context.Background(), client, "test-model") + + if _, ok := cfg["contextWindow"]; ok { + t.Error("contextWindow should not be set for zero value") + } + }) + + t.Run("cloud model uses hardcoded limits", func(t *testing.T) { + // Use a model name that's in cloudModelLimits and make the server + // report it as a remote/cloud model + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/show" { + fmt.Fprintf(w, `{"capabilities":[],"model_info":{},"remote_model":"minimax-m2.5"}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + u, _ := url.Parse(srv.URL) + client := api.NewClient(u, srv.Client()) + + cfg, isCloud := openclawModelConfig(context.Background(), client, "minimax-m2.5:cloud") + + if !isCloud { + t.Error("expected isCloud = true for cloud model") + } + if cfg["contextWindow"] != 204_800 { + t.Errorf("contextWindow = %v, want 204800", cfg["contextWindow"]) + } + if cfg["maxTokens"] != 128_000 { + t.Errorf("maxTokens = %v, want 128000", cfg["maxTokens"]) + } + }) + + t.Run("cloud model with vision capability gets image input", func(t *testing.T) { + // Regression test: cloud models must not skip capability detection. + // A cloud model that reports vision capability should have input: [text, image]. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/show" { + fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{},"remote_model":"qwen3-vl"}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + u, _ := url.Parse(srv.URL) + client := api.NewClient(u, srv.Client()) + + cfg, isCloud := openclawModelConfig(context.Background(), client, "qwen3-vl:235b-cloud") + + if !isCloud { + t.Error("expected isCloud = true for cloud vision model") + } + input, ok := cfg["input"].([]any) + if !ok || len(input) != 2 { + t.Errorf("input = %v, want [text image] for cloud vision model", cfg["input"]) + } + }) + + t.Run("cloud model with thinking capability gets reasoning flag", func(t *testing.T) { + // Regression test: cloud models must not skip capability detection. + // A cloud model that reports thinking capability should have reasoning: true. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/show" { + fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{},"remote_model":"qwq-cloud"}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + u, _ := url.Parse(srv.URL) + client := api.NewClient(u, srv.Client()) + + cfg, isCloud := openclawModelConfig(context.Background(), client, "qwq:cloud") + + if !isCloud { + t.Error("expected isCloud = true for cloud thinking model") + } + if cfg["reasoning"] != true { + t.Error("expected reasoning = true for cloud thinking model") + } + }) +} + +func TestIntegrationOnboarded(t *testing.T) { + t.Run("returns false when not set", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + integrationConfig, err := loadIntegration("openclaw") + if err == nil && integrationConfig.Onboarded { + t.Error("expected false for fresh config") + } + }) + + t.Run("returns true after integrationOnboarded", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + os.MkdirAll(filepath.Join(tmpDir, ".ollama"), 0o755) + + if err := integrationOnboarded("openclaw"); err != nil { + t.Fatal(err) + } + integrationConfig, err := loadIntegration("openclaw") + if err != nil || !integrationConfig.Onboarded { + t.Error("expected true after integrationOnboarded") + } + }) + + t.Run("is case insensitive", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + os.MkdirAll(filepath.Join(tmpDir, ".ollama"), 0o755) + + if err := integrationOnboarded("OpenClaw"); err != nil { + t.Fatal(err) + } + integrationConfig, err := loadIntegration("openclaw") + if err != nil || !integrationConfig.Onboarded { + t.Error("expected true when set with different case") + } + }) + + t.Run("preserves existing integration data", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + os.MkdirAll(filepath.Join(tmpDir, ".ollama"), 0o755) + + if err := SaveIntegration("openclaw", []string{"llama3.2", "mistral"}); err != nil { + t.Fatal(err) + } + if err := integrationOnboarded("openclaw"); err != nil { + t.Fatal(err) + } + + // Verify onboarded is set + integrationConfig, err := loadIntegration("openclaw") + if err != nil || !integrationConfig.Onboarded { + t.Error("expected true after integrationOnboarded") + } + + // Verify models are preserved + model := IntegrationModel("openclaw") + if model != "llama3.2" { + t.Errorf("expected first model llama3.2, got %q", model) + } + }) +} diff --git a/cmd/config/selector.go b/cmd/config/selector.go index bcd0b749f..e94f3bffd 100644 --- a/cmd/config/selector.go +++ b/cmd/config/selector.go @@ -10,10 +10,11 @@ import ( // ANSI escape sequences for terminal formatting. const ( - ansiBold = "\033[1m" - ansiReset = "\033[0m" - ansiGray = "\033[37m" - ansiGreen = "\033[32m" + ansiBold = "\033[1m" + ansiReset = "\033[0m" + ansiGray = "\033[37m" + ansiGreen = "\033[32m" + ansiYellow = "\033[33m" ) // ErrCancelled is returned when the user cancels a selection. diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index b9f1ef7b1..389c875a9 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -524,7 +524,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter", " ": item := m.items[m.cursor] - if item.integration != "" && !config.IsIntegrationInstalled(item.integration) { + if item.integration != "" && !config.IsIntegrationInstalled(item.integration) && !config.AutoInstallable(item.integration) { return m, nil } @@ -555,6 +555,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { item := m.items[m.cursor] if item.integration != "" || item.isRunModel { if item.integration != "" && !config.IsIntegrationInstalled(item.integration) { + if config.AutoInstallable(item.integration) { + // Auto-installable: select to trigger install flow + m.selected = true + m.quitting = true + return m, tea.Quit + } return m, nil } if item.integration != "" && config.IsEditorIntegration(item.integration) { @@ -618,7 +624,11 @@ func (m model) View() string { var modelSuffix string if item.integration != "" { if !isInstalled { - title += " " + notInstalledStyle.Render("(not installed)") + if config.AutoInstallable(item.integration) { + title += " " + notInstalledStyle.Render("(install)") + } else { + title += " " + notInstalledStyle.Render("(not installed)") + } } else if m.cursor == i { if mdl := config.IntegrationModel(item.integration); mdl != "" && m.modelExists(mdl) { modelSuffix = " " + modelStyle.Render("("+mdl+")") @@ -634,7 +644,9 @@ func (m model) View() string { desc := item.description if !isInstalled && item.integration != "" && m.cursor == i { - if hint := config.IntegrationInstallHint(item.integration); hint != "" { + if config.AutoInstallable(item.integration) { + desc = "Press enter to install" + } else if hint := config.IntegrationInstallHint(item.integration); hint != "" { desc = hint } else { desc = "not installed" diff --git a/docs/integrations/openclaw.mdx b/docs/integrations/openclaw.mdx index 1a4a79905..957f1c25a 100644 --- a/docs/integrations/openclaw.mdx +++ b/docs/integrations/openclaw.mdx @@ -4,47 +4,65 @@ title: OpenClaw OpenClaw is a personal AI assistant that runs on your own devices. It bridges messaging services (WhatsApp, Telegram, Slack, Discord, iMessage, and more) to AI coding agents through a centralized gateway. -## Install - -Install [OpenClaw](https://openclaw.ai/) - -```bash -npm install -g openclaw@latest -``` - -Then run the onboarding wizard: - -```bash -openclaw onboard --install-daemon -``` - -OpenClaw requires a larger context window. It is recommended to use a context window of at least 64k tokens. See [Context length](/context-length) for more information. - -## Usage with Ollama - -### Quick setup +## Quick start ```bash ollama launch openclaw ``` +Ollama handles everything automatically: + +1. **Install** — If OpenClaw isn't installed, Ollama prompts to install it via npm +2. **Security** — On the first launch, a security notice explains the risks of tool access +3. **Model** — Pick a model from the selector (local or cloud) +4. **Onboarding** — Ollama configures the provider, installs the gateway daemon, and sets your model as the primary +5. **Gateway** — Starts in the background and opens the OpenClaw TUI + +OpenClaw requires a larger context window. It is recommended to use a context window of at least 64k tokens if using local models. See [Context length](/context-length) for more information. + Previously known as Clawdbot. `ollama launch clawdbot` still works as an alias. -This configures OpenClaw to use Ollama and starts the gateway. -If the gateway is already running, no changes need to be made as the gateway will auto-reload the changes. +## Configure without launching +To change the model without starting the gateway and TUI: -To configure without launching: - -```shell +```bash ollama launch openclaw --config ``` -## Recommended Models +To use a specific model directly: -- `qwen3-coder` -- `glm-4.7` -- `gpt-oss:20b` -- `gpt-oss:120b` +```bash +ollama launch openclaw --model kimi-k2.5:cloud +``` + +If the gateway is already running, it restarts automatically to pick up the new model. + +## Recommended models + +**Cloud models**: + +- `kimi-k2.5:cloud` — Multimodal reasoning with subagents +- `minimax-m2.5:cloud` — Fast, efficient coding and real-world productivity +- `glm-5:cloud` — Reasoning and code generation + +**Local models:** + +- `glm-4.7-flash` — Reasoning and code generation locally (~25 GB VRAM) + +More models at [ollama.com/search](https://ollama.com/search?c=cloud). + +## Connect messaging apps + +```bash +openclaw configure --section channels +``` + +Link WhatsApp, Telegram, Slack, Discord, or iMessage to chat with your local models from anywhere. + +## Stopping the gateway + +```bash +openclaw gateway stop +``` -Cloud models are also available at [ollama.com/search?c=cloud](https://ollama.com/search?c=cloud).