From 43f90def0458d342011fdceb54df3decd37b7f14 Mon Sep 17 00:00:00 2001 From: Parth Sareen Date: Wed, 15 Apr 2026 12:00:23 -0700 Subject: [PATCH] launch: add hermes (#15569) --- cmd/launch/command_test.go | 3 + cmd/launch/hermes.go | 962 ++++++++++++++++++++++++ cmd/launch/hermes_test.go | 1236 +++++++++++++++++++++++++++++++ cmd/launch/integrations_test.go | 51 +- cmd/launch/launch.go | 245 ++++-- cmd/launch/launch_test.go | 491 ++++++++++++ cmd/launch/models.go | 20 +- cmd/launch/registry.go | 20 +- cmd/tui/tui.go | 74 +- cmd/tui/tui_test.go | 42 +- docs/integrations/hermes.mdx | 4 + go.mod | 2 +- 12 files changed, 3038 insertions(+), 112 deletions(-) create mode 100644 cmd/launch/hermes.go create mode 100644 cmd/launch/hermes_test.go diff --git a/cmd/launch/command_test.go b/cmd/launch/command_test.go index da168438b..cc8d0df49 100644 --- a/cmd/launch/command_test.go +++ b/cmd/launch/command_test.go @@ -58,6 +58,9 @@ func TestLaunchCmd(t *testing.T) { if cmd.Long == "" { t.Error("Long description should not be empty") } + if !strings.Contains(cmd.Long, "hermes") { + t.Error("Long description should mention hermes") + } }) t.Run("flags exist", func(t *testing.T) { diff --git a/cmd/launch/hermes.go b/cmd/launch/hermes.go new file mode 100644 index 000000000..02619100e --- /dev/null +++ b/cmd/launch/hermes.go @@ -0,0 +1,962 @@ +package launch + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + pathpkg "path" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "time" + + "gopkg.in/yaml.v3" + + "github.com/ollama/ollama/api" + "github.com/ollama/ollama/cmd/config" + "github.com/ollama/ollama/cmd/internal/fileutil" + "github.com/ollama/ollama/envconfig" +) + +const ( + hermesInstallScript = "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup" + hermesProviderName = "Ollama" + hermesProviderKey = "ollama-launch" + hermesLegacyKey = "ollama" + hermesPlaceholderKey = "ollama" + hermesGatewaySetupHint = "hermes gateway setup" + hermesGatewaySetupTitle = "Connect a messaging app now?" +) + +var ( + hermesGOOS = runtime.GOOS + hermesLookPath = exec.LookPath + hermesCommand = exec.Command + hermesUserHome = os.UserHomeDir + hermesOllamaURL = envconfig.ConnectableHost +) + +var hermesMessagingEnvGroups = [][]string{ + {"TELEGRAM_BOT_TOKEN"}, + {"DISCORD_BOT_TOKEN"}, + {"SLACK_BOT_TOKEN"}, + {"SIGNAL_ACCOUNT"}, + {"EMAIL_ADDRESS"}, + {"TWILIO_ACCOUNT_SID"}, + {"MATRIX_ACCESS_TOKEN", "MATRIX_PASSWORD"}, + {"MATTERMOST_TOKEN"}, + {"WHATSAPP_PHONE_NUMBER_ID"}, + {"DINGTALK_CLIENT_ID"}, + {"FEISHU_APP_ID"}, + {"WECOM_BOT_ID"}, + {"WEIXIN_ACCOUNT_ID"}, + {"BLUEBUBBLES_SERVER_URL"}, + {"WEBHOOK_ENABLED"}, +} + +// Hermes is intentionally not an Editor integration: launch owns one primary +// model and the local Ollama endpoint, while Hermes keeps its own discovery and +// switching UX after startup. +type Hermes struct{} + +type hermesConfigBackend struct { + displayPath string + read func() ([]byte, error) + write func([]byte) error +} + +func (h *Hermes) String() string { return "Hermes Agent" } + +func (h *Hermes) Run(_ string, args []string) error { + // Hermes reads its primary model from config.yaml. launch configures that + // default model ahead of time so we can keep runtime invocation simple and + // still let Hermes discover additional models later via its own UX. + if hermesGOOS == "windows" { + return h.runWindows(args) + } + + bin, err := h.findUnixBinary() + if err != nil { + return err + } + if err := h.runGatewaySetupPreflight(args, func() error { + return hermesAttachedCommand(bin, "gateway", "setup").Run() + }); err != nil { + return err + } + return hermesAttachedCommand(bin, args...).Run() +} + +func (h *Hermes) Paths() []string { + backend, err := h.configBackend() + if err != nil { + return nil + } + return []string{backend.displayPath} +} + +func (h *Hermes) Configure(model string) error { + backend, err := h.configBackend() + if err != nil { + return err + } + + cfg := map[string]any{} + if data, err := backend.read(); err == nil { + if err := yaml.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("parse hermes config: %w", err) + } + } else if !os.IsNotExist(err) { + return err + } + + modelSection, _ := cfg["model"].(map[string]any) + if modelSection == nil { + modelSection = make(map[string]any) + } + models := h.listModels(model) + applyHermesManagedProviders(cfg, hermesBaseURL(), model, models) + + // launch writes the minimum provider/default-model settings needed to + // bootstrap Hermes against Ollama. The active provider stays on a + // launch-owned key so /model stays aligned with the launcher-managed entry, + // and the Ollama endpoint lives in providers: so the picker shows one row. + modelSection["provider"] = hermesProviderKey + modelSection["default"] = model + modelSection["base_url"] = hermesBaseURL() + modelSection["api_key"] = hermesPlaceholderKey + cfg["model"] = modelSection + + // use Hermes' built-in web toolset for now. + // TODO(parthsareen): move this to using Ollama web search + cfg["toolsets"] = mergeHermesToolsets(cfg["toolsets"]) + + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + return backend.write(data) +} + +func (h *Hermes) CurrentModel() string { + backend, err := h.configBackend() + if err != nil { + return "" + } + data, err := backend.read() + if err != nil { + return "" + } + + cfg := map[string]any{} + if yaml.Unmarshal(data, &cfg) != nil { + return "" + } + return hermesManagedCurrentModel(cfg, hermesBaseURL()) +} + +func (h *Hermes) Onboard() error { + return config.MarkIntegrationOnboarded("hermes") +} + +func (h *Hermes) RequiresInteractiveOnboarding() bool { + return false +} + +func (h *Hermes) RefreshRuntimeAfterConfigure() error { + running, err := h.gatewayRunning() + if err != nil { + return fmt.Errorf("check Hermes gateway status: %w", err) + } + if !running { + return nil + } + + fmt.Fprintf(os.Stderr, "%sRefreshing Hermes messaging gateway...%s\n", ansiGray, ansiReset) + if err := h.restartGateway(); err != nil { + return fmt.Errorf("restart Hermes gateway: %w", err) + } + fmt.Fprintln(os.Stderr) + return nil +} + +func (h *Hermes) installed() bool { + if hermesGOOS == "windows" { + if _, err := hermesLookPath("hermes"); err == nil { + return true + } + return h.wslHasHermes() + } + + _, err := h.findUnixBinary() + return err == nil +} + +func (h *Hermes) ensureInstalled() error { + if h.installed() { + return nil + } + + if hermesGOOS == "windows" { + return h.ensureInstalledWindows() + } + + var missing []string + for _, dep := range []string{"bash", "curl", "git"} { + if _, err := hermesLookPath(dep); err != nil { + missing = append(missing, dep) + } + } + if len(missing) > 0 { + return fmt.Errorf("Hermes is not installed and required dependencies are missing\n\nInstall the following first:\n %s\n\nThen re-run:\n ollama launch hermes", strings.Join(missing, "\n ")) + } + + ok, err := ConfirmPrompt("Hermes is not installed. Install now?") + if err != nil { + return err + } + if !ok { + return fmt.Errorf("hermes installation cancelled") + } + + fmt.Fprintf(os.Stderr, "\nInstalling Hermes...\n") + if err := hermesAttachedCommand("bash", "-lc", hermesInstallScript).Run(); err != nil { + return fmt.Errorf("failed to install hermes: %w", err) + } + + if !h.installed() { + return fmt.Errorf("hermes was installed but the binary was not found on PATH\n\nYou may need to restart your shell") + } + + fmt.Fprintf(os.Stderr, "%sHermes installed successfully%s\n\n", ansiGreen, ansiReset) + return nil +} + +func (h *Hermes) ensureInstalledWindows() error { + // Hermes upstream support is WSL-oriented, so Windows launch uses a hybrid + // WSL handoff that stays on the same install path as upstream Hermes. + if _, err := hermesLookPath("hermes"); err == nil { + return nil + } + if !h.wslAvailable() { + return hermesWindowsHint(fmt.Errorf("hermes is not installed")) + } + if h.wslHasHermes() { + return nil + } + + ok, err := ConfirmPromptWithOptions("Hermes runs through WSL2 on Windows. Install it in WSL now?", ConfirmOptions{ + YesLabel: "Use WSL", + NoLabel: "Show manual steps", + }) + if err != nil { + return err + } + if !ok { + return hermesWindowsHint(fmt.Errorf("hermes is not installed")) + } + + fmt.Fprintf(os.Stderr, "\nInstalling Hermes in WSL...\n") + if err := h.runWSL("bash", "-lc", hermesInstallScript); err != nil { + return hermesWindowsHint(fmt.Errorf("failed to install hermes in WSL: %w", err)) + } + if !h.wslHasHermes() { + return hermesWindowsHint(fmt.Errorf("hermes install finished but the WSL binary was not found")) + } + + fmt.Fprintf(os.Stderr, "%sHermes installed successfully in WSL%s\n\n", ansiGreen, ansiReset) + return nil +} + +func (h *Hermes) listModels(defaultModel string) []string { + client := hermesOllamaClient() + resp, err := client.List(context.Background()) + if err != nil { + return []string{defaultModel} + } + + models := make([]string, 0, len(resp.Models)+1) + seen := make(map[string]struct{}, len(resp.Models)+1) + add := func(name string) { + name = strings.TrimSpace(name) + if name == "" { + return + } + if _, ok := seen[name]; ok { + return + } + seen[name] = struct{}{} + models = append(models, name) + } + + add(defaultModel) + for _, entry := range resp.Models { + add(entry.Name) + } + if len(models) == 0 { + return []string{defaultModel} + } + return models +} + +func (h *Hermes) findUnixBinary() (string, error) { + if path, err := hermesLookPath("hermes"); err == nil { + return path, nil + } + + home, err := hermesUserHome() + if err != nil { + return "", err + } + fallback := filepath.Join(home, ".local", "bin", "hermes") + if _, err := os.Stat(fallback); err == nil { + return fallback, nil + } + + return "", fmt.Errorf("hermes is not installed") +} + +func (h *Hermes) runWindows(args []string) error { + if path, err := hermesLookPath("hermes"); err == nil { + if err := h.runGatewaySetupPreflight(args, func() error { + return hermesAttachedCommand(path, "gateway", "setup").Run() + }); err != nil { + return err + } + return hermesAttachedCommand(path, args...).Run() + } + if !h.wslAvailable() { + return hermesWindowsHint(fmt.Errorf("hermes is not installed")) + } + if err := h.runGatewaySetupPreflight(args, func() error { + return h.runWSL("hermes", "gateway", "setup") + }); err != nil { + return err + } + if err := h.runWSL(append([]string{"hermes"}, args...)...); err != nil { + return hermesWindowsHint(err) + } + return nil +} + +func (h *Hermes) runWSL(args ...string) error { + if !h.wslAvailable() { + return fmt.Errorf("wsl.exe is not available") + } + + return hermesAttachedCommand("wsl.exe", "bash", "-lc", shellQuoteArgs(args)).Run() +} + +func (h *Hermes) runWSLCombinedOutput(args ...string) ([]byte, error) { + if !h.wslAvailable() { + return nil, fmt.Errorf("wsl.exe is not available") + } + + return hermesCommand("wsl.exe", "bash", "-lc", shellQuoteArgs(args)).CombinedOutput() +} + +func (h *Hermes) wslAvailable() bool { + _, err := hermesLookPath("wsl.exe") + return err == nil +} + +func (h *Hermes) wslHasHermes() bool { + if !h.wslAvailable() { + return false + } + cmd := hermesCommand("wsl.exe", "bash", "-lc", "command -v hermes >/dev/null 2>&1") + return cmd.Run() == nil +} + +func (h *Hermes) configBackend() (*hermesConfigBackend, error) { + if hermesGOOS == "windows" { + if _, err := hermesLookPath("hermes"); err == nil { + return hermesLocalConfigBackend() + } + if h.wslAvailable() { + return h.wslConfigBackend() + } + } + return hermesLocalConfigBackend() +} + +func hermesConfigPath() (string, error) { + home, err := hermesUserHome() + if err != nil { + return "", err + } + return filepath.Join(home, ".hermes", "config.yaml"), nil +} + +func hermesLocalConfigBackend() (*hermesConfigBackend, error) { + configPath, err := hermesConfigPath() + if err != nil { + return nil, err + } + return &hermesConfigBackend{ + displayPath: configPath, + read: func() ([]byte, error) { + return os.ReadFile(configPath) + }, + write: func(data []byte) error { + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + return err + } + return fileutil.WriteWithBackup(configPath, data) + }, + }, nil +} + +func (h *Hermes) wslConfigBackend() (*hermesConfigBackend, error) { + home, err := h.wslHome() + if err != nil { + return nil, err + } + configPath := pathpkg.Join(home, ".hermes", "config.yaml") + return &hermesConfigBackend{ + displayPath: configPath, + read: func() ([]byte, error) { + return h.readWSLFile(configPath) + }, + write: func(data []byte) error { + return h.writeWSLConfig(configPath, data) + }, + }, nil +} + +func (h *Hermes) wslHome() (string, error) { + if !h.wslAvailable() { + return "", fmt.Errorf("wsl.exe is not available") + } + cmd := hermesCommand("wsl.exe", "bash", "-lc", `printf %s "$HOME"`) + out, err := cmd.Output() + if err != nil { + return "", err + } + home := strings.TrimSpace(string(out)) + if home == "" { + return "", fmt.Errorf("could not resolve WSL home directory") + } + return home, nil +} + +func (h *Hermes) readWSLFile(path string) ([]byte, error) { + pathArg := shellQuoteArgs([]string{path}) + cmd := hermesCommand("wsl.exe", "bash", "-lc", fmt.Sprintf("if [ -f %s ]; then cat %s; else exit 42; fi", pathArg, pathArg)) + out, err := cmd.Output() + if err == nil { + return out, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 42 { + return nil, os.ErrNotExist + } + return nil, err +} + +func (h *Hermes) writeWSLConfig(path string, data []byte) error { + if existing, err := h.readWSLFile(path); err == nil { + if !bytes.Equal(existing, data) { + if err := hermesBackupData(path, existing); err != nil { + return fmt.Errorf("backup failed: %w", err) + } + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("read existing file: %w", err) + } + + dir := pathpkg.Dir(path) + dirArg := shellQuoteArgs([]string{dir}) + pathArg := shellQuoteArgs([]string{path}) + script := fmt.Sprintf( + "dir=%s; path=%s; mkdir -p \"$dir\" && tmp=$(mktemp \"$dir/.tmp-XXXXXX\") && cat > \"$tmp\" && mv \"$tmp\" \"$path\"", + dirArg, + pathArg, + ) + cmd := hermesCommand("wsl.exe", "bash", "-lc", script) + cmd.Stdin = bytes.NewReader(data) + if out, err := cmd.CombinedOutput(); err != nil { + if msg := strings.TrimSpace(string(out)); msg != "" { + return fmt.Errorf("%w: %s", err, msg) + } + return err + } + return nil +} + +func hermesBackupData(path string, data []byte) error { + if err := os.MkdirAll(fileutil.BackupDir(), 0o755); err != nil { + return err + } + backupPath := filepath.Join(fileutil.BackupDir(), fmt.Sprintf("%s.%d", filepath.Base(path), time.Now().Unix())) + return os.WriteFile(backupPath, data, 0o644) +} + +func hermesBaseURL() string { + return strings.TrimRight(hermesOllamaURL().String(), "/") + "/v1" +} + +func hermesEnvPath() (string, error) { + home, err := hermesUserHome() + if err != nil { + return "", err + } + return filepath.Join(home, ".hermes", ".env"), nil +} + +func (h *Hermes) runGatewaySetupPreflight(args []string, runSetup func() error) error { + if len(args) > 0 || !isInteractiveSession() || currentLaunchConfirmPolicy.yes || currentLaunchConfirmPolicy.requireYesMessage { + return nil + } + if h.messagingConfigured() { + return nil + } + + fmt.Fprintf(os.Stderr, "\nHermes can message you on Telegram, Discord, Slack, and more.\n\n") + ok, err := ConfirmPromptWithOptions(hermesGatewaySetupTitle, ConfirmOptions{ + YesLabel: "Yes", + NoLabel: "Set up later", + }) + if err != nil { + return err + } + if !ok { + return nil + } + if err := runSetup(); err != nil { + return fmt.Errorf("hermes messaging setup failed: %w\n\nTry running: %s", err, hermesGatewaySetupHint) + } + return nil +} + +func (h *Hermes) messagingConfigured() bool { + envVars, err := h.gatewayEnvVars() + if err != nil { + return false + } + for _, group := range hermesMessagingEnvGroups { + for _, key := range group { + if strings.TrimSpace(envVars[key]) != "" { + return true + } + } + } + return false +} + +func (h *Hermes) gatewayEnvVars() (map[string]string, error) { + envVars := make(map[string]string) + + data, err := h.readGatewayEnvFile() + switch { + case err == nil: + for key, value := range hermesParseEnvFile(data) { + envVars[key] = value + } + case os.IsNotExist(err): + // nothing persisted yet + default: + return nil, err + } + + if h.usesLocalRuntimeEnv() { + for _, group := range hermesMessagingEnvGroups { + for _, key := range group { + if value, ok := os.LookupEnv(key); ok { + envVars[key] = value + } + } + } + } + + return envVars, nil +} + +func (h *Hermes) readGatewayEnvFile() ([]byte, error) { + if hermesGOOS == "windows" { + if _, err := hermesLookPath("hermes"); err == nil { + path, err := hermesEnvPath() + if err != nil { + return nil, err + } + return os.ReadFile(path) + } + if h.wslAvailable() { + home, err := h.wslHome() + if err != nil { + return nil, err + } + return h.readWSLFile(pathpkg.Join(home, ".hermes", ".env")) + } + } + + path, err := hermesEnvPath() + if err != nil { + return nil, err + } + return os.ReadFile(path) +} + +func (h *Hermes) usesLocalRuntimeEnv() bool { + if hermesGOOS != "windows" { + return true + } + _, err := hermesLookPath("hermes") + return err == nil +} + +func (h *Hermes) gatewayRunning() (bool, error) { + status, err := h.gatewayStatusOutput() + if err != nil { + return false, err + } + return hermesGatewayStatusRunning(status), nil +} + +func (h *Hermes) gatewayStatusOutput() (string, error) { + if hermesGOOS == "windows" { + if path, err := hermesLookPath("hermes"); err == nil { + out, err := hermesCommand(path, "gateway", "status").CombinedOutput() + return string(out), err + } + if !h.wslAvailable() { + return "", hermesWindowsHint(fmt.Errorf("hermes is not installed")) + } + out, err := h.runWSLCombinedOutput("hermes", "gateway", "status") + return string(out), err + } + + bin, err := h.findUnixBinary() + if err != nil { + return "", err + } + out, err := hermesCommand(bin, "gateway", "status").CombinedOutput() + return string(out), err +} + +func (h *Hermes) restartGateway() error { + if hermesGOOS == "windows" { + if path, err := hermesLookPath("hermes"); err == nil { + return hermesAttachedCommand(path, "gateway", "restart").Run() + } + if !h.wslAvailable() { + return hermesWindowsHint(fmt.Errorf("hermes is not installed")) + } + if err := h.runWSL("hermes", "gateway", "restart"); err != nil { + return hermesWindowsHint(err) + } + return nil + } + + bin, err := h.findUnixBinary() + if err != nil { + return err + } + return hermesAttachedCommand(bin, "gateway", "restart").Run() +} + +func hermesGatewayStatusRunning(output string) bool { + status := strings.ToLower(output) + switch { + case strings.Contains(status, "gateway is not running"): + return false + case strings.Contains(status, "gateway service is stopped"): + return false + case strings.Contains(status, "gateway service is not loaded"): + return false + case strings.Contains(status, "gateway is running"): + return true + case strings.Contains(status, "gateway service is running"): + return true + case strings.Contains(status, "gateway service is loaded"): + return true + default: + return false + } +} + +func hermesParseEnvFile(data []byte) map[string]string { + out := make(map[string]string) + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := strings.TrimSpace(strings.TrimPrefix(scanner.Text(), "\ufeff")) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "export ") { + line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) + } + + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + + key = strings.TrimSpace(key) + if key == "" { + continue + } + + value = strings.TrimSpace(value) + if len(value) >= 2 { + switch { + case value[0] == '"' && value[len(value)-1] == '"': + if unquoted, err := strconv.Unquote(value); err == nil { + value = unquoted + } + case value[0] == '\'' && value[len(value)-1] == '\'': + value = value[1 : len(value)-1] + } + } + + out[key] = value + } + return out +} + +func hermesOllamaClient() *api.Client { + // Hermes queries the same launch-resolved Ollama host that launch writes + // into config, so model discovery follows the configured endpoint. + return api.NewClient(hermesOllamaURL(), http.DefaultClient) +} + +func applyHermesManagedProviders(cfg map[string]any, baseURL string, model string, models []string) { + providers := hermesUserProviders(cfg["providers"]) + entry := hermesManagedProviderEntry(providers) + if entry == nil { + entry = make(map[string]any) + } + entry["name"] = hermesProviderName + entry["api"] = baseURL + entry["default_model"] = model + entry["models"] = hermesStringListAny(models) + providers[hermesProviderKey] = entry + delete(providers, hermesLegacyKey) + cfg["providers"] = providers + + customProviders := hermesWithoutManagedCustomProviders(cfg["custom_providers"]) + if len(customProviders) == 0 { + delete(cfg, "custom_providers") + return + } + cfg["custom_providers"] = customProviders +} + +func hermesManagedCurrentModel(cfg map[string]any, baseURL string) string { + modelCfg, _ := cfg["model"].(map[string]any) + if modelCfg == nil { + return "" + } + + provider, _ := modelCfg["provider"].(string) + if strings.TrimSpace(strings.ToLower(provider)) != hermesProviderKey { + return "" + } + + configBaseURL, _ := modelCfg["base_url"].(string) + if hermesNormalizeURL(configBaseURL) != hermesNormalizeURL(baseURL) { + return "" + } + + current, _ := modelCfg["default"].(string) + current = strings.TrimSpace(current) + if current == "" { + return "" + } + + providers := hermesUserProviders(cfg["providers"]) + entry, _ := providers[hermesProviderKey].(map[string]any) + if entry == nil { + return "" + } + if hermesHasManagedCustomProvider(cfg["custom_providers"]) { + return "" + } + + apiURL, _ := entry["api"].(string) + if hermesNormalizeURL(apiURL) != hermesNormalizeURL(baseURL) { + return "" + } + + defaultModel, _ := entry["default_model"].(string) + if strings.TrimSpace(defaultModel) != current { + return "" + } + + return current +} + +func hermesUserProviders(current any) map[string]any { + switch existing := current.(type) { + case map[string]any: + out := make(map[string]any, len(existing)) + for key, value := range existing { + out[key] = value + } + return out + case map[any]any: + out := make(map[string]any, len(existing)) + for key, value := range existing { + if s, ok := key.(string); ok { + out[s] = value + } + } + return out + default: + return make(map[string]any) + } +} + +func hermesCustomProviders(current any) []any { + switch existing := current.(type) { + case []any: + return append([]any(nil), existing...) + case []map[string]any: + out := make([]any, 0, len(existing)) + for _, entry := range existing { + out = append(out, entry) + } + return out + default: + return nil + } +} + +func hermesManagedProviderEntry(providers map[string]any) map[string]any { + for _, key := range []string{hermesProviderKey, hermesLegacyKey} { + if entry, _ := providers[key].(map[string]any); entry != nil { + return entry + } + } + return nil +} + +func hermesWithoutManagedCustomProviders(current any) []any { + customProviders := hermesCustomProviders(current) + preserved := make([]any, 0, len(customProviders)) + + for _, item := range customProviders { + entry, _ := item.(map[string]any) + if entry == nil { + preserved = append(preserved, item) + continue + } + if hermesManagedCustomProvider(entry) { + continue + } + preserved = append(preserved, entry) + } + + return preserved +} + +func hermesHasManagedCustomProvider(current any) bool { + for _, item := range hermesCustomProviders(current) { + entry, _ := item.(map[string]any) + if entry != nil && hermesManagedCustomProvider(entry) { + return true + } + } + return false +} + +func hermesManagedCustomProvider(entry map[string]any) bool { + name, _ := entry["name"].(string) + return strings.EqualFold(strings.TrimSpace(name), hermesProviderName) +} + +func hermesNormalizeURL(raw string) string { + return strings.TrimRight(strings.TrimSpace(raw), "/") +} + +func hermesStringListAny(models []string) []any { + out := make([]any, 0, len(models)) + for _, model := range dedupeModelList(models) { + model = strings.TrimSpace(model) + if model == "" { + continue + } + out = append(out, model) + } + return out +} + +func mergeHermesToolsets(current any) any { + added := false + switch existing := current.(type) { + case []any: + out := make([]any, 0, len(existing)+1) + for _, item := range existing { + out = append(out, item) + if s, _ := item.(string); s == "web" { + added = true + } + } + if !added { + out = append(out, "web") + } + return out + case []string: + out := append([]string(nil), existing...) + if !slices.Contains(out, "web") { + out = append(out, "web") + } + asAny := make([]any, 0, len(out)) + for _, item := range out { + asAny = append(asAny, item) + } + return asAny + case string: + if strings.TrimSpace(existing) == "" { + return []any{"hermes-cli", "web"} + } + parts := strings.Split(existing, ",") + out := make([]any, 0, len(parts)+1) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if part == "web" { + added = true + } + out = append(out, part) + } + if !added { + out = append(out, "web") + } + return out + default: + return []any{"hermes-cli", "web"} + } +} + +func shellQuoteArgs(args []string) string { + quoted := make([]string, 0, len(args)) + for _, arg := range args { + quoted = append(quoted, "'"+strings.ReplaceAll(arg, "'", `'\''`)+"'") + } + return strings.Join(quoted, " ") +} + +func hermesAttachedCommand(name string, args ...string) *exec.Cmd { + cmd := hermesCommand(name, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd +} + +func hermesWindowsHint(err error) error { + if hermesGOOS != "windows" { + return err + } + return fmt.Errorf("%w\n\nHermes runs on Windows through WSL2.\nQuick setup: wsl --install\nInstaller docs: https://hermes-agent.nousresearch.com/docs/getting-started/installation/", err) +} diff --git a/cmd/launch/hermes_test.go b/cmd/launch/hermes_test.go new file mode 100644 index 000000000..a4b2a397d --- /dev/null +++ b/cmd/launch/hermes_test.go @@ -0,0 +1,1236 @@ +package launch + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "gopkg.in/yaml.v3" + + "github.com/ollama/ollama/cmd/config" +) + +func withHermesPlatform(t *testing.T, goos string) { + t.Helper() + old := hermesGOOS + hermesGOOS = goos + t.Cleanup(func() { + hermesGOOS = old + }) +} + +func withHermesOllamaURL(t *testing.T, rawURL string) { + t.Helper() + old := hermesOllamaURL + hermesOllamaURL = func() *url.URL { + u, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("parse test Ollama URL: %v", err) + } + return u + } + t.Cleanup(func() { + hermesOllamaURL = old + }) +} + +func withHermesUserHome(t *testing.T, dir string) { + t.Helper() + old := hermesUserHome + hermesUserHome = func() (string, error) { return dir, nil } + t.Cleanup(func() { + hermesUserHome = old + }) +} + +func withHermesLookPath(t *testing.T, fn func(string) (string, error)) { + t.Helper() + old := hermesLookPath + hermesLookPath = fn + t.Cleanup(func() { + hermesLookPath = old + }) +} + +func clearHermesMessagingEnvVars(t *testing.T) { + t.Helper() + for _, group := range hermesMessagingEnvGroups { + for _, key := range group { + if value, ok := os.LookupEnv(key); ok { + t.Setenv(key, value) + } else { + t.Setenv(key, "") + } + if err := os.Unsetenv(key); err != nil { + t.Fatalf("unset %s: %v", key, err) + } + } + } +} + +func TestHermesIntegration(t *testing.T) { + h := &Hermes{} + + t.Run("implements Runner", func(t *testing.T) { + var _ Runner = h + }) + + t.Run("implements managed single model", func(t *testing.T) { + var _ ManagedSingleModel = h + }) + + t.Run("implements managed runtime refresher", func(t *testing.T) { + var _ ManagedRuntimeRefresher = h + }) +} + +func TestHermesConfigurePreservesExistingConfigAndEnablesWeb(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withHermesPlatform(t, "darwin") + + configPath := filepath.Join(tmpDir, ".hermes", "config.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + existing := "" + + "memory:\n" + + " provider: local\n" + + "toolsets:\n" + + " - terminal\n" + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/show": + fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`) + case "/api/tags": + fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3.5"},{"name":"llama3.3"}]}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + h := &Hermes{} + if err := h.Configure("gemma4"); err != nil { + t.Fatalf("Configure returned error: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("failed to parse rewritten yaml: %v", err) + } + + modelCfg, _ := cfg["model"].(map[string]any) + if got, _ := modelCfg["provider"].(string); got != "ollama-launch" { + t.Fatalf("expected provider ollama-launch, got %q", got) + } + if got, _ := modelCfg["default"].(string); got != "gemma4" { + t.Fatalf("expected default model gemma4, got %q", got) + } + if got, _ := modelCfg["base_url"].(string); got != srv.URL+"/v1" { + t.Fatalf("expected Ollama base_url %q, got %q", srv.URL+"/v1", got) + } + if got, _ := modelCfg["api_key"].(string); got != "ollama" { + t.Fatalf("expected placeholder api_key ollama, got %q", got) + } + if memoryCfg, _ := cfg["memory"].(map[string]any); memoryCfg == nil { + t.Fatal("expected unrelated config to be preserved") + } + if _, ok := cfg["custom_providers"]; ok { + t.Fatal("expected launcher-managed config to avoid custom_providers duplicates") + } + providersCfg, _ := cfg["providers"].(map[string]any) + ollamaProvider, _ := providersCfg["ollama-launch"].(map[string]any) + if ollamaProvider == nil { + t.Fatal("expected ollama-launch provider entry") + } + if got, _ := ollamaProvider["name"].(string); got != "Ollama" { + t.Fatalf("expected providers entry name Ollama, got %q", got) + } + if got, _ := ollamaProvider["api"].(string); got != srv.URL+"/v1" { + t.Fatalf("expected providers entry api %q, got %q", srv.URL+"/v1", got) + } + if got, _ := ollamaProvider["default_model"].(string); got != "gemma4" { + t.Fatalf("expected providers entry default_model gemma4, got %q", got) + } + models, _ := ollamaProvider["models"].([]any) + if len(models) != 3 { + t.Fatalf("expected providers entry to expose 3 models, got %v", models) + } + + toolsets, _ := cfg["toolsets"].([]any) + var gotToolsets []string + for _, item := range toolsets { + if s, _ := item.(string); s != "" { + gotToolsets = append(gotToolsets, s) + } + } + if !strings.Contains(strings.Join(gotToolsets, ","), "terminal") || !strings.Contains(strings.Join(gotToolsets, ","), "web") { + t.Fatalf("expected toolsets to preserve terminal and add web, got %v", gotToolsets) + } +} + +func TestHermesConfigureUpdatesMatchingCustomProviderWithoutDroppingFields(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withHermesPlatform(t, "darwin") + + configPath := filepath.Join(tmpDir, ".hermes", "config.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + existing := "" + + "providers:\n" + + " ollama:\n" + + " name: Ollama\n" + + " api: http://127.0.0.1:11434/v1\n" + + " default_model: old-model\n" + + " models:\n" + + " - old-model\n" + + " - older-model\n" + + " extra_field: keep-me\n" + + "custom_providers:\n" + + " - name: Ollama\n" + + " base_url: http://127.0.0.1:11434/v1\n" + + " model: old-model\n" + + " api_mode: chat_completions\n" + + " models:\n" + + " old-model:\n" + + " context_length: 65536\n" + + " - name: Other Endpoint\n" + + " base_url: https://example.invalid/v1\n" + + " model: untouched\n" + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/show": + fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`) + case "/api/tags": + fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3.5"},{"name":"llama3.3"}]}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + h := &Hermes{} + if err := h.Configure("gemma4"); err != nil { + t.Fatalf("Configure returned error: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("failed to parse rewritten yaml: %v", err) + } + + modelCfg, _ := cfg["model"].(map[string]any) + if got, _ := modelCfg["provider"].(string); got != "ollama-launch" { + t.Fatalf("expected managed providers entry to migrate to ollama-launch, got %q", got) + } + + customProviders, _ := cfg["custom_providers"].([]any) + if len(customProviders) != 1 { + t.Fatalf("expected only unrelated custom providers to remain, got %d", len(customProviders)) + } + + providersCfg, _ := cfg["providers"].(map[string]any) + if _, ok := providersCfg["ollama"]; ok { + t.Fatal("expected legacy providers.ollama entry to be removed") + } + ollamaProvider, _ := providersCfg["ollama-launch"].(map[string]any) + if ollamaProvider == nil { + t.Fatal("expected ollama-launch providers entry to remain") + } + if got, _ := ollamaProvider["api"].(string); got != srv.URL+"/v1" { + t.Fatalf("expected providers entry api to update to %q, got %q", srv.URL+"/v1", got) + } + if got, _ := ollamaProvider["default_model"].(string); got != "gemma4" { + t.Fatalf("expected providers entry default_model gemma4, got %q", got) + } + if got, _ := ollamaProvider["extra_field"].(string); got != "keep-me" { + t.Fatalf("expected providers entry extra_field to be preserved, got %q", got) + } + providerModels, _ := ollamaProvider["models"].([]any) + if len(providerModels) != 3 { + t.Fatalf("expected providers entry to refresh full model catalog, got %v", providerModels) + } + + remaining, _ := customProviders[0].(map[string]any) + if got, _ := remaining["name"].(string); got != "Other Endpoint" { + t.Fatalf("expected unrelated custom provider to be preserved, got %q", got) + } +} + +func TestHermesConfigureUsesLaunchResolvedHostForModelDiscovery(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withHermesPlatform(t, "darwin") + + configPath := filepath.Join(tmpDir, ".hermes", "config.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/show": + fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`) + case "/api/tags": + fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3.5"},{"name":"llama3.3"}]}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + withHermesOllamaURL(t, srv.URL) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:1") + + h := &Hermes{} + if err := h.Configure("gemma4"); err != nil { + t.Fatalf("Configure returned error: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("failed to parse rewritten yaml: %v", err) + } + + providersCfg, _ := cfg["providers"].(map[string]any) + ollamaProvider, _ := providersCfg["ollama-launch"].(map[string]any) + if ollamaProvider == nil { + t.Fatal("expected ollama-launch provider entry") + } + models, _ := ollamaProvider["models"].([]any) + if len(models) != 3 { + t.Fatalf("expected providers entry to expose 3 launch-resolved models, got %v", models) + } +} + +func TestHermesConfigureMigratesLegacyManagedAliases(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withHermesPlatform(t, "darwin") + + configPath := filepath.Join(tmpDir, ".hermes", "config.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + existing := "" + + "model:\n" + + " provider: custom:ollama\n" + + " default: old-model\n" + + "providers:\n" + + " ollama:\n" + + " name: Ollama\n" + + " api: http://127.0.0.1:11434/v1\n" + + " default_model: old-model\n" + + "custom_providers:\n" + + " - name: Ollama\n" + + " base_url: http://127.0.0.1:11434/v1\n" + + " model: old-model\n" + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/tags": + fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3.5"}]}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + h := &Hermes{} + if err := h.Configure("gemma4"); err != nil { + t.Fatalf("Configure returned error: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("failed to parse rewritten yaml: %v", err) + } + + modelCfg, _ := cfg["model"].(map[string]any) + if got, _ := modelCfg["provider"].(string); got != "ollama-launch" { + t.Fatalf("expected migrated provider ollama-launch, got %q", got) + } + + providersCfg, _ := cfg["providers"].(map[string]any) + if _, ok := providersCfg["ollama"]; ok { + t.Fatal("expected legacy providers.ollama entry to be removed") + } + if _, ok := providersCfg["ollama-launch"]; !ok { + t.Fatal("expected providers.ollama-launch entry") + } + if _, ok := cfg["custom_providers"]; ok { + t.Fatal("expected managed custom_providers entry to be removed during migration") + } +} + +func TestHermesPathsUsesLocalConfigPathForNativeWindowsHermes(t *testing.T) { + tmpDir := t.TempDir() + winHome := filepath.Join(tmpDir, "winhome") + setTestHome(t, winHome) + withHermesPlatform(t, "windows") + withHermesUserHome(t, winHome) + t.Setenv("PATH", tmpDir) + writeFakeBinary(t, tmpDir, "hermes") + + got := (&Hermes{}).Paths() + want := filepath.Join(winHome, ".hermes", "config.yaml") + if len(got) != 1 || got[0] != want { + t.Fatalf("expected local config path %q, got %v", want, got) + } +} + +func TestHermesCurrentModelRequiresHealthyManagedConfig(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withHermesPlatform(t, "darwin") + withHermesOllamaURL(t, "http://127.0.0.1:11434") + + configPath := filepath.Join(tmpDir, ".hermes", "config.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + cfg string + }{ + { + name: "wrong provider", + cfg: "" + + "model:\n" + + " provider: openrouter\n" + + " default: gemma4\n" + + " base_url: http://127.0.0.1:11434/v1\n", + }, + { + name: "wrong base url", + cfg: "" + + "model:\n" + + " provider: ollama-launch\n" + + " default: gemma4\n" + + " base_url: http://127.0.0.1:9999/v1\n" + + "providers:\n" + + " ollama-launch:\n" + + " api: http://127.0.0.1:9999/v1\n" + + " default_model: gemma4\n", + }, + { + name: "missing managed provider entry", + cfg: "" + + "model:\n" + + " provider: ollama-launch\n" + + " default: gemma4\n" + + " base_url: http://127.0.0.1:11434/v1\n", + }, + { + name: "inconsistent managed provider entry", + cfg: "" + + "model:\n" + + " provider: ollama-launch\n" + + " default: gemma4\n" + + " base_url: http://127.0.0.1:11434/v1\n" + + "providers:\n" + + " ollama-launch:\n" + + " api: http://127.0.0.1:11434/v1\n" + + " default_model: qwen3.5\n", + }, + { + name: "legacy launch managed config", + cfg: "" + + "model:\n" + + " provider: custom:ollama\n" + + " default: gemma4\n" + + " base_url: http://127.0.0.1:11434/v1\n" + + "providers:\n" + + " ollama:\n" + + " api: http://127.0.0.1:11434/v1\n" + + " default_model: gemma4\n", + }, + { + name: "duplicate managed custom provider", + cfg: "" + + "model:\n" + + " provider: ollama-launch\n" + + " default: gemma4\n" + + " base_url: http://127.0.0.1:11434/v1\n" + + "providers:\n" + + " ollama-launch:\n" + + " api: http://127.0.0.1:11434/v1\n" + + " default_model: gemma4\n" + + "custom_providers:\n" + + " - name: Ollama\n" + + " base_url: http://127.0.0.1:11434/v1\n" + + " model: gemma4\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := os.WriteFile(configPath, []byte(tt.cfg), 0o644); err != nil { + t.Fatal(err) + } + if got := (&Hermes{}).CurrentModel(); got != "" { + t.Fatalf("expected stale config to return empty current model, got %q", got) + } + }) + } +} + +func TestHermesCurrentModelReturnsEmptyWhenConfigMissing(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withHermesPlatform(t, "darwin") + + if got := (&Hermes{}).CurrentModel(); got != "" { + t.Fatalf("expected missing config to return empty current model, got %q", got) + } +} + +func TestHermesRunPassthroughArgs(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withLauncherHooks(t) + withInteractiveSession(t, true) + withHermesPlatform(t, runtime.GOOS) + clearHermesMessagingEnvVars(t) + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + bin := filepath.Join(tmpDir, "hermes") + if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '[%s]\\n' \"$*\" >> \"$HOME/hermes-invocations.log\"\n"), 0o755); err != nil { + t.Fatal(err) + } + + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + t.Fatalf("did not expect messaging prompt during passthrough launch: %s", prompt) + return false, nil + } + + h := &Hermes{} + if err := h.Run("", []string{"--continue"}); err != nil { + t.Fatalf("Run returned error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "hermes-invocations.log")) + if err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(string(data)); got != "[--continue]" { + t.Fatalf("expected passthrough args to reach hermes, got %q", got) + } +} + +func TestHermesRun_PromptsForMessagingSetupBeforeDefaultLaunch(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withLauncherHooks(t) + withInteractiveSession(t, true) + withHermesPlatform(t, runtime.GOOS) + clearHermesMessagingEnvVars(t) + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + bin := filepath.Join(tmpDir, "hermes") + script := `#!/bin/sh +printf '[%s]\n' "$*" >> "$HOME/hermes-invocations.log" +if [ "$1" = "gateway" ] && [ "$2" = "setup" ]; then + /bin/mkdir -p "$HOME/.hermes" + printf 'TELEGRAM_BOT_TOKEN=configured\n' > "$HOME/.hermes/.env" +fi +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + promptCount := 0 + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + promptCount++ + if prompt != hermesGatewaySetupTitle { + t.Fatalf("unexpected prompt %q", prompt) + } + if options.YesLabel != "Yes" || options.NoLabel != "Set up later" { + t.Fatalf("unexpected prompt labels: %+v", options) + } + return true, nil + } + + h := &Hermes{} + if err := h.Run("", nil); err != nil { + t.Fatalf("Run returned error: %v", err) + } + + if promptCount != 1 { + t.Fatalf("expected one messaging prompt, got %d", promptCount) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "hermes-invocations.log")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 2 { + t.Fatalf("expected setup then launch invocations, got %v", lines) + } + if lines[0] != "[gateway setup]" { + t.Fatalf("expected gateway setup first, got %q", lines[0]) + } + if lines[1] != "[]" { + t.Fatalf("expected default hermes launch after setup, got %q", lines[1]) + } +} + +func TestHermesRun_SetUpLaterRepromptsOnLaterLaunches(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withLauncherHooks(t) + withInteractiveSession(t, true) + withHermesPlatform(t, runtime.GOOS) + clearHermesMessagingEnvVars(t) + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + bin := filepath.Join(tmpDir, "hermes") + if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '[%s]\\n' \"$*\" >> \"$HOME/hermes-invocations.log\"\n"), 0o755); err != nil { + t.Fatal(err) + } + + promptCount := 0 + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + promptCount++ + if prompt != hermesGatewaySetupTitle { + t.Fatalf("unexpected prompt %q", prompt) + } + return false, nil + } + + h := &Hermes{} + if err := h.Run("", nil); err != nil { + t.Fatalf("first Run returned error: %v", err) + } + if err := h.Run("", nil); err != nil { + t.Fatalf("second Run returned error: %v", err) + } + + if promptCount != 2 { + t.Fatalf("expected two prompts across two launches, got %d", promptCount) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "hermes-invocations.log")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 2 { + t.Fatalf("expected one default launch per run, got %v", lines) + } + for _, line := range lines { + if line != "[]" { + t.Fatalf("expected only default launches after choosing later, got %v", lines) + } + } +} + +func TestHermesRun_SkipsMessagingPromptWhenConfigured(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withLauncherHooks(t) + withInteractiveSession(t, true) + withHermesPlatform(t, runtime.GOOS) + clearHermesMessagingEnvVars(t) + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + envPath := filepath.Join(tmpDir, ".hermes", ".env") + if err := os.MkdirAll(filepath.Dir(envPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(envPath, []byte("DISCORD_BOT_TOKEN=configured\n"), 0o644); err != nil { + t.Fatal(err) + } + + bin := filepath.Join(tmpDir, "hermes") + if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '[%s]\\n' \"$*\" >> \"$HOME/hermes-invocations.log\"\n"), 0o755); err != nil { + t.Fatal(err) + } + + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + t.Fatalf("did not expect messaging prompt when Hermes gateway is configured: %s", prompt) + return false, nil + } + + h := &Hermes{} + if err := h.Run("", nil); err != nil { + t.Fatalf("Run returned error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "hermes-invocations.log")) + if err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(string(data)); got != "[]" { + t.Fatalf("expected only default launch invocation, got %q", got) + } +} + +func TestHermesRun_SkipsMessagingPromptWithYesPolicy(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withLauncherHooks(t) + withInteractiveSession(t, true) + withHermesPlatform(t, runtime.GOOS) + clearHermesMessagingEnvVars(t) + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + bin := filepath.Join(tmpDir, "hermes") + if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '[%s]\\n' \"$*\" >> \"$HOME/hermes-invocations.log\"\n"), 0o755); err != nil { + t.Fatal(err) + } + + restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) + defer restoreConfirm() + + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + t.Fatalf("did not expect messaging prompt in --yes mode: %s", prompt) + return false, nil + } + + h := &Hermes{} + if err := h.Run("", nil); err != nil { + t.Fatalf("Run returned error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "hermes-invocations.log")) + if err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(string(data)); got != "[]" { + t.Fatalf("expected only default launch invocation, got %q", got) + } +} + +func TestHermesRun_MessagingSetupFailureStopsLaunch(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withLauncherHooks(t) + withInteractiveSession(t, true) + withHermesPlatform(t, runtime.GOOS) + clearHermesMessagingEnvVars(t) + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + bin := filepath.Join(tmpDir, "hermes") + script := `#!/bin/sh +printf '[%s]\n' "$*" >> "$HOME/hermes-invocations.log" +if [ "$1" = "gateway" ] && [ "$2" = "setup" ]; then + exit 23 +fi +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + if prompt != hermesGatewaySetupTitle { + t.Fatalf("unexpected prompt %q", prompt) + } + return true, nil + } + + h := &Hermes{} + err := h.Run("", nil) + if err == nil { + t.Fatal("expected messaging setup failure") + } + if !strings.Contains(err.Error(), "hermes messaging setup failed") { + t.Fatalf("expected helpful messaging setup error, got %v", err) + } + if !strings.Contains(err.Error(), hermesGatewaySetupHint) { + t.Fatalf("expected recovery hint, got %v", err) + } + + data, readErr := os.ReadFile(filepath.Join(tmpDir, "hermes-invocations.log")) + if readErr != nil { + t.Fatal(readErr) + } + if got := strings.TrimSpace(string(data)); got != "[gateway setup]" { + t.Fatalf("expected launch to stop after failed setup, got %q", got) + } +} + +func TestHermesRefreshRuntimeAfterConfigure_RestartsRunningGateway(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withHermesPlatform(t, runtime.GOOS) + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + bin := filepath.Join(tmpDir, "hermes") + script := `#!/bin/sh +printf '[%s]\n' "$*" >> "$HOME/hermes-invocations.log" +if [ "$1" = "gateway" ] && [ "$2" = "status" ]; then + printf '✓ Gateway is running (PID: 123)\n' +fi +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + h := &Hermes{} + if err := h.RefreshRuntimeAfterConfigure(); err != nil { + t.Fatalf("RefreshRuntimeAfterConfigure returned error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "hermes-invocations.log")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 2 { + t.Fatalf("expected status then restart invocations, got %v", lines) + } + if lines[0] != "[gateway status]" { + t.Fatalf("expected gateway status first, got %q", lines[0]) + } + if lines[1] != "[gateway restart]" { + t.Fatalf("expected gateway restart second, got %q", lines[1]) + } +} + +func TestHermesRefreshRuntimeAfterConfigure_SkipsRestartWhenGatewayStopped(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withHermesPlatform(t, runtime.GOOS) + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + bin := filepath.Join(tmpDir, "hermes") + script := `#!/bin/sh +printf '[%s]\n' "$*" >> "$HOME/hermes-invocations.log" +if [ "$1" = "gateway" ] && [ "$2" = "status" ]; then + printf '✗ Gateway is not running\n' +fi +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + h := &Hermes{} + if err := h.RefreshRuntimeAfterConfigure(); err != nil { + t.Fatalf("RefreshRuntimeAfterConfigure returned error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "hermes-invocations.log")) + if err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(string(data)); got != "[gateway status]" { + t.Fatalf("expected only gateway status invocation, got %q", got) + } +} + +func TestHermesRefreshRuntimeAfterConfigure_WindowsWSLRestartsRunningGateway(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses POSIX shell test binaries to simulate WSL") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withHermesPlatform(t, "windows") + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + wslPath := filepath.Join(tmpDir, "wsl.exe") + wslScript := `#!/bin/sh +printf '[%s]\n' "$*" >> "$HOME/wsl-invocations.log" +exec /bin/sh -lc "$3" +` + if err := os.WriteFile(wslPath, []byte(wslScript), 0o755); err != nil { + t.Fatal(err) + } + + hermesBin := filepath.Join(tmpDir, "hermes") + hermesScript := `#!/bin/sh +printf '[%s]\n' "$*" >> "$HOME/hermes-invocations.log" +if [ "$1" = "gateway" ] && [ "$2" = "status" ]; then + printf '✓ Gateway is running (PID: 321)\n' +fi +` + if err := os.WriteFile(hermesBin, []byte(hermesScript), 0o755); err != nil { + t.Fatal(err) + } + + withHermesLookPath(t, func(file string) (string, error) { + if file == "wsl.exe" { + return wslPath, nil + } + return "", os.ErrNotExist + }) + + h := &Hermes{} + if err := h.RefreshRuntimeAfterConfigure(); err != nil { + t.Fatalf("RefreshRuntimeAfterConfigure returned error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "hermes-invocations.log")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 2 { + t.Fatalf("expected WSL status then restart invocations, got %v", lines) + } + if lines[0] != "[gateway status]" { + t.Fatalf("expected WSL gateway status first, got %q", lines[0]) + } + if lines[1] != "[gateway restart]" { + t.Fatalf("expected WSL gateway restart second, got %q", lines[1]) + } +} + +func TestHermesMessagingConfiguredRecognizesSupportedGatewayVars(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withHermesPlatform(t, "darwin") + clearHermesMessagingEnvVars(t) + + envPath := filepath.Join(tmpDir, ".hermes", ".env") + if err := os.MkdirAll(filepath.Dir(envPath), 0o755); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + env string + want bool + }{ + {name: "none", env: "", want: false}, + {name: "telegram", env: "TELEGRAM_BOT_TOKEN=token\n", want: true}, + {name: "discord", env: "DISCORD_BOT_TOKEN=token\n", want: true}, + {name: "slack", env: "SLACK_BOT_TOKEN=token\n", want: true}, + {name: "signal", env: "SIGNAL_ACCOUNT=account\n", want: true}, + {name: "email", env: "EMAIL_ADDRESS=user@example.com\n", want: true}, + {name: "sms", env: "TWILIO_ACCOUNT_SID=sid\n", want: true}, + {name: "matrix token", env: "MATRIX_ACCESS_TOKEN=token\n", want: true}, + {name: "matrix password", env: "MATRIX_PASSWORD=secret\n", want: true}, + {name: "mattermost", env: "MATTERMOST_TOKEN=token\n", want: true}, + {name: "whatsapp", env: "WHATSAPP_PHONE_NUMBER_ID=phone\n", want: true}, + {name: "dingtalk", env: "DINGTALK_CLIENT_ID=client\n", want: true}, + {name: "feishu", env: "FEISHU_APP_ID=app\n", want: true}, + {name: "wecom", env: "WECOM_BOT_ID=bot\n", want: true}, + {name: "weixin", env: "WEIXIN_ACCOUNT_ID=account\n", want: true}, + {name: "bluebubbles", env: "BLUEBUBBLES_SERVER_URL=https://example.invalid\n", want: true}, + {name: "webhooks", env: "WEBHOOK_ENABLED=true\n", want: true}, + } + + h := &Hermes{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := os.WriteFile(envPath, []byte(tt.env), 0o644); err != nil { + t.Fatal(err) + } + if got := h.messagingConfigured(); got != tt.want { + t.Fatalf("messagingConfigured() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHermesRunWindowsWSL_UsesGatewaySetupPreflight(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses POSIX shell test binaries to simulate WSL") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withLauncherHooks(t) + withInteractiveSession(t, true) + withHermesPlatform(t, "windows") + clearHermesMessagingEnvVars(t) + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + wslPath := filepath.Join(tmpDir, "wsl.exe") + wslScript := `#!/bin/sh +printf '[%s]\n' "$*" >> "$HOME/wsl-invocations.log" +exec /bin/sh -lc "$3" +` + if err := os.WriteFile(wslPath, []byte(wslScript), 0o755); err != nil { + t.Fatal(err) + } + + hermesBin := filepath.Join(tmpDir, "hermes") + hermesScript := `#!/bin/sh +printf '[%s]\n' "$*" >> "$HOME/hermes-invocations.log" +if [ "$1" = "gateway" ] && [ "$2" = "setup" ]; then + /bin/mkdir -p "$HOME/.hermes" + printf 'TELEGRAM_BOT_TOKEN=configured\n' > "$HOME/.hermes/.env" +fi +` + if err := os.WriteFile(hermesBin, []byte(hermesScript), 0o755); err != nil { + t.Fatal(err) + } + + withHermesLookPath(t, func(file string) (string, error) { + if file == "wsl.exe" { + return wslPath, nil + } + return "", os.ErrNotExist + }) + + promptCount := 0 + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + promptCount++ + if prompt != hermesGatewaySetupTitle { + t.Fatalf("unexpected prompt %q", prompt) + } + return true, nil + } + + h := &Hermes{} + if err := h.Run("", nil); err != nil { + t.Fatalf("Run returned error: %v", err) + } + + if promptCount != 1 { + t.Fatalf("expected one messaging prompt, got %d", promptCount) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "hermes-invocations.log")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 2 { + t.Fatalf("expected WSL hermes to run setup then launch, got %v", lines) + } + if lines[0] != "[gateway setup]" { + t.Fatalf("expected WSL gateway setup first, got %q", lines[0]) + } + if lines[1] != "[]" { + t.Fatalf("expected WSL default hermes launch second, got %q", lines[1]) + } +} + +func TestHermesEnsureInstalledWindowsWithoutWSLGivesGuidance(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withHermesPlatform(t, "windows") + t.Setenv("PATH", tmpDir) + + h := &Hermes{} + err := h.ensureInstalled() + if err == nil { + t.Fatal("expected missing WSL guidance error") + } + if !strings.Contains(err.Error(), "wsl --install") { + t.Fatalf("expected WSL guidance, got %v", err) + } +} + +func TestHermesEnsureInstalledUnixPromptsBeforeInstall(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses POSIX shell test binaries") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withHermesPlatform(t, "darwin") + withLauncherHooks(t) + t.Setenv("PATH", tmpDir) + + writeScript := func(name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0o755); err != nil { + t.Fatal(err) + } + } + + writeScript("curl", "#!/bin/sh\nexit 0\n") + writeScript("git", "#!/bin/sh\nexit 0\n") + writeScript("bash", fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$*" >> %q +/bin/cat > %q <<'EOS' +#!/bin/sh +exit 0 +EOS +/bin/chmod +x %q +exit 0 +`, filepath.Join(tmpDir, "bash.log"), filepath.Join(tmpDir, "hermes"), filepath.Join(tmpDir, "hermes"))) + + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + if prompt != "Hermes is not installed. Install now?" { + t.Fatalf("unexpected install prompt %q", prompt) + } + return true, nil + } + + h := &Hermes{} + if err := h.ensureInstalled(); err != nil { + t.Fatalf("ensureInstalled returned error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "bash.log")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "--skip-setup") { + t.Fatalf("expected install script to skip upstream setup, got logs:\n%s", data) + } + if !strings.Contains(string(data), "-lc "+hermesInstallScript) { + t.Fatalf("expected official install script invocation, got logs:\n%s", data) + } +} + +func TestHermesEnsureInstalledUnixCanBeDeclined(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses POSIX shell test binaries") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withHermesPlatform(t, "darwin") + withLauncherHooks(t) + t.Setenv("PATH", tmpDir) + + for _, name := range []string{"bash", "curl", "git"} { + if err := os.WriteFile(filepath.Join(tmpDir, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + if prompt != "Hermes is not installed. Install now?" { + t.Fatalf("unexpected install prompt %q", prompt) + } + return false, nil + } + + h := &Hermes{} + err := h.ensureInstalled() + if err == nil || !strings.Contains(err.Error(), "hermes installation cancelled") { + t.Fatalf("expected install cancellation error, got %v", err) + } +} + +func TestHermesOnboardSkipsWhenLaunchConfigAlreadyMarked(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withHermesPlatform(t, runtime.GOOS) + + if err := config.MarkIntegrationOnboarded("hermes"); err != nil { + t.Fatalf("failed to mark Hermes onboarded: %v", err) + } + + h := &Hermes{} + if err := h.Onboard(); err != nil { + t.Fatalf("expected Onboard to no-op when already marked, got %v", err) + } +} + +func TestHermesOnboardMarksLaunchConfig(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withHermesPlatform(t, runtime.GOOS) + + h := &Hermes{} + if err := h.Onboard(); err != nil { + t.Fatalf("Onboard returned error: %v", err) + } + + saved, err := config.LoadIntegration("hermes") + if err != nil { + t.Fatalf("failed to load Hermes integration config: %v", err) + } + if !saved.Onboarded { + t.Fatal("expected Hermes to be marked onboarded") + } +} + +func TestHermesGatewayStatusRunningRecognizesRunningStates(t *testing.T) { + tests := []struct { + name string + output string + want bool + }{ + {name: "manual", output: "✓ Gateway is running (PID: 123)", want: true}, + {name: "systemd", output: "✓ User gateway service is running", want: true}, + {name: "launchd", output: "✓ Gateway service is loaded", want: true}, + {name: "manual stopped", output: "✗ Gateway is not running", want: false}, + {name: "systemd stopped", output: "✗ User gateway service is stopped", want: false}, + {name: "launchd unloaded", output: "✗ Gateway service is not loaded", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hermesGatewayStatusRunning(tt.output); got != tt.want { + t.Fatalf("hermesGatewayStatusRunning(%q) = %v, want %v", tt.output, got, tt.want) + } + }) + } +} diff --git a/cmd/launch/integrations_test.go b/cmd/launch/integrations_test.go index 68f1148f2..0f7313301 100644 --- a/cmd/launch/integrations_test.go +++ b/cmd/launch/integrations_test.go @@ -74,7 +74,7 @@ func TestIntegrationLookup(t *testing.T) { } func TestIntegrationRegistry(t *testing.T) { - expectedIntegrations := []string{"claude", "codex", "droid", "opencode"} + expectedIntegrations := []string{"claude", "codex", "droid", "opencode", "hermes"} for _, name := range expectedIntegrations { t.Run(name, func(t *testing.T) { @@ -1509,27 +1509,13 @@ func TestListIntegrationInfos(t *testing.T) { } }) - t.Run("sorted with custom order at end", func(t *testing.T) { - // integrationOrder entries (cline, opencode) should appear last, in that order. - // All other entries should be sorted alphabetically before them. - orderRank := make(map[string]int) - for i, name := range integrationOrder { - orderRank[name] = i + 1 + t.Run("follows launcher order", func(t *testing.T) { + got := make([]string, 0, len(infos)) + for _, info := range infos { + got = append(got, info.Name) } - for i := 1; i < len(infos); i++ { - aRank, bRank := orderRank[infos[i-1].Name], orderRank[infos[i].Name] - switch { - case aRank == 0 && bRank == 0: - if infos[i-1].Name >= infos[i].Name { - t.Errorf("non-ordered items not sorted: %q >= %q", infos[i-1].Name, infos[i].Name) - } - case aRank > 0 && bRank == 0: - t.Errorf("ordered item %q should come after non-ordered %q", infos[i-1].Name, infos[i].Name) - case aRank > 0 && bRank > 0: - if aRank >= bRank { - t.Errorf("ordered items wrong: %q (rank %d) before %q (rank %d)", infos[i-1].Name, aRank, infos[i].Name, bRank) - } - } + if diff := compareStrings(got, integrationOrder); diff != "" { + t.Fatalf("launcher integration order mismatch: %s", diff) } }) @@ -1557,6 +1543,28 @@ func TestListIntegrationInfos(t *testing.T) { } } }) + + t.Run("includes hermes", func(t *testing.T) { + for _, info := range infos { + if info.Name == "hermes" { + return + } + } + t.Fatal("expected hermes to be included in ListIntegrationInfos") + }) + + t.Run("hermes still resolves explicitly", func(t *testing.T) { + name, runner, err := LookupIntegration("hermes") + if err != nil { + t.Fatalf("expected explicit hermes integration lookup to work, got %v", err) + } + if name != "hermes" { + t.Fatalf("expected canonical name hermes, got %q", name) + } + if runner.String() == "" { + t.Fatal("expected hermes integration runner to be present") + } + }) } func TestBuildModelList_Descriptions(t *testing.T) { @@ -1645,6 +1653,7 @@ func TestIntegration_AutoInstallable(t *testing.T) { }{ {"openclaw", true}, {"pi", true}, + {"hermes", true}, {"claude", false}, {"codex", false}, {"opencode", false}, diff --git a/cmd/launch/launch.go b/cmd/launch/launch.go index 7786c09c7..91196cf1f 100644 --- a/cmd/launch/launch.go +++ b/cmd/launch/launch.go @@ -141,6 +141,36 @@ type Editor interface { Models() []string } +// ManagedSingleModel is the narrow launch-owned config path for integrations +// like Hermes that have one primary model selected by launcher, need launcher +// to persist minimal config, and still keep their own model discovery and +// onboarding UX. This stays separate from Runner-only integrations and the +// multi-model Editor flow so Hermes-specific behavior stays scoped to one path. +type ManagedSingleModel interface { + Paths() []string + Configure(model string) error + CurrentModel() string + Onboard() error +} + +// ManagedRuntimeRefresher lets managed integrations refresh any long-lived +// background runtime after launch rewrites their config. +type ManagedRuntimeRefresher interface { + RefreshRuntimeAfterConfigure() error +} + +// ManagedOnboardingValidator lets managed integrations re-check saved +// onboarding state when launcher needs a stronger live readiness signal. +type ManagedOnboardingValidator interface { + OnboardingComplete() bool +} + +// ManagedInteractiveOnboarding lets a managed integration declare whether its +// onboarding step really requires an interactive terminal. Hermes does not. +type ManagedInteractiveOnboarding interface { + RequiresInteractiveOnboarding() bool +} + type modelInfo struct { Name string Remote bool @@ -177,6 +207,7 @@ Supported integrations: cline Cline codex Codex droid Droid + hermes Hermes Agent opencode OpenCode openclaw OpenClaw (aliases: clawdbot, moltbot) pi Pi @@ -186,6 +217,7 @@ Examples: ollama launch ollama launch claude ollama launch claude --model + ollama launch hermes ollama launch droid --config (does not auto-launch) ollama launch codex -- -p myprofile (pass extra args to integration) ollama launch codex -- --sandbox workspace-write`, @@ -308,36 +340,54 @@ func LaunchIntegration(ctx context.Context, req IntegrationLaunchRequest) error if err != nil { return err } + + policy := launchIntegrationPolicy(req) + if policy.Confirm == LaunchConfirmAutoApprove && !isInteractiveSession() && req.ModelOverride == "" { + return fmt.Errorf("headless --yes launch for %s requires --model ", name) + } + + launchClient, saved, err := prepareIntegrationLaunch(name, policy) + if err != nil { + return err + } + + if managed, ok := runner.(ManagedSingleModel); ok { + if err := EnsureIntegrationInstalled(name, runner); err != nil { + return err + } + return launchClient.launchManagedSingleIntegration(ctx, name, runner, managed, saved, req) + } + if !req.ConfigureOnly { if err := EnsureIntegrationInstalled(name, runner); err != nil { return err } } - var policy LaunchPolicy - // TUI does not set a policy, whereas ollama launch does as it can have flags which change the behavior - if req.Policy == nil { - policy = defaultLaunchPolicy(isInteractiveSession(), false) - } else { - policy = *req.Policy - } - - launchClient, err := newLauncherClient(policy) - if err != nil { - return err - } - saved, _ := loadStoredIntegrationConfig(name) - // In headless --yes mode we cannot prompt, so require an explicit --model. - if policy.Confirm == LaunchConfirmAutoApprove && !isInteractiveSession() && req.ModelOverride == "" { - return fmt.Errorf("headless --yes launch for %s requires --model ", name) - } - if editor, ok := runner.(Editor); ok { return launchClient.launchEditorIntegration(ctx, name, runner, editor, saved, req) } return launchClient.launchSingleIntegration(ctx, name, runner, saved, req) } +func launchIntegrationPolicy(req IntegrationLaunchRequest) LaunchPolicy { + // TUI does not set a policy, whereas ollama launch does as it can + // have flags which change the behavior. + if req.Policy != nil { + return *req.Policy + } + return defaultLaunchPolicy(isInteractiveSession(), false) +} + +func prepareIntegrationLaunch(name string, policy LaunchPolicy) (*launcherClient, *config.IntegrationConfig, error) { + launchClient, err := newLauncherClient(policy) + if err != nil { + return nil, nil, err + } + saved, _ := loadStoredIntegrationConfig(name) + return launchClient, saved, nil +} + func (c *launcherClient) buildLauncherState(ctx context.Context) (*LauncherState, error) { _ = c.loadModelInventoryOnce(ctx) @@ -368,9 +418,18 @@ func (c *launcherClient) buildLauncherIntegrationState(ctx context.Context, info if err != nil { return LauncherIntegrationState{}, err } - currentModel, usable, err := c.launcherModelState(ctx, info.Name, integration.editor) - if err != nil { - return LauncherIntegrationState{}, err + var currentModel string + var usable bool + if managed, ok := integration.spec.Runner.(ManagedSingleModel); ok { + currentModel, usable, err = c.launcherManagedModelState(ctx, info.Name, managed) + if err != nil { + return LauncherIntegrationState{}, err + } + } else { + currentModel, usable, err = c.launcherModelState(ctx, info.Name, integration.editor) + if err != nil { + return LauncherIntegrationState{}, err + } } return LauncherIntegrationState{ @@ -408,6 +467,28 @@ func (c *launcherClient) launcherModelState(ctx context.Context, name string, is return model, usableErr == nil && usable, nil } +func (c *launcherClient) launcherManagedModelState(ctx context.Context, name string, managed ManagedSingleModel) (string, bool, error) { + current := managed.CurrentModel() + if current == "" { + cfg, loadErr := loadStoredIntegrationConfig(name) + if loadErr == nil { + current = primaryModelFromConfig(cfg) + } + if current != "" { + return current, false, nil + } + } + if current == "" { + return "", false, nil + } + + usable, err := c.savedModelUsable(ctx, current) + if err != nil { + return current, false, err + } + return current, usable, nil +} + func (c *launcherClient) resolveRunModel(ctx context.Context, req RunModelRequest) (string, error) { current := config.LastModel() if !req.ForcePicker && current != "" && c.policy.Confirm == LaunchConfirmAutoApprove && !isInteractiveSession() { @@ -444,35 +525,15 @@ func (c *launcherClient) resolveRunModel(ctx context.Context, req RunModelReques } func (c *launcherClient) launchSingleIntegration(ctx context.Context, name string, runner Runner, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error { - current := primaryModelFromConfig(saved) - target := req.ModelOverride - needsConfigure := req.ForceConfigure - - if target == "" { - target = current - usable, err := c.savedModelUsable(ctx, target) - if err != nil { - return err - } - if !usable { - needsConfigure = true - } - } - - if needsConfigure { - selected, err := c.selectSingleModelWithSelector(ctx, fmt.Sprintf("Select model for %s:", runner), target, DefaultSingleSelector) - if err != nil { - return err - } - target = selected - } else if err := c.ensureModelsReady(ctx, []string{target}); err != nil { + target, _, err := c.resolveSingleIntegrationTarget(ctx, runner, primaryModelFromConfig(saved), req) + if err != nil { return err } - if target == "" { return nil } + current := primaryModelFromConfig(saved) if target != current { if err := config.SaveIntegration(name, []string{target}); err != nil { return fmt.Errorf("failed to save: %w", err) @@ -510,6 +571,102 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin return launchAfterConfiguration(name, runner, models[0], req) } +func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, name string, runner Runner, managed ManagedSingleModel, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error { + current := managed.CurrentModel() + selectionCurrent := current + if selectionCurrent == "" { + selectionCurrent = primaryModelFromConfig(saved) + } + + target, needsConfigure, err := c.resolveSingleIntegrationTarget(ctx, runner, selectionCurrent, req) + if err != nil { + return err + } + if target == "" { + return nil + } + + if current == "" || needsConfigure || req.ModelOverride != "" || target != current { + if err := prepareManagedSingleIntegration(name, runner, managed, target); err != nil { + return err + } + if refresher, ok := managed.(ManagedRuntimeRefresher); ok { + if err := refresher.RefreshRuntimeAfterConfigure(); err != nil { + return err + } + } + } + + if !managedIntegrationOnboarded(saved, managed) { + if !isInteractiveSession() && managedRequiresInteractiveOnboarding(managed) { + return fmt.Errorf("%s still needs interactive gateway setup; run 'ollama launch %s' in a terminal to finish onboarding", runner, name) + } + if err := managed.Onboard(); err != nil { + return err + } + } + + if req.ConfigureOnly { + return nil + } + + return runIntegration(runner, target, req.ExtraArgs) +} + +func (c *launcherClient) resolveSingleIntegrationTarget(ctx context.Context, runner Runner, current string, req IntegrationLaunchRequest) (string, bool, error) { + target := req.ModelOverride + needsConfigure := req.ForceConfigure + + if target == "" { + target = current + usable, err := c.savedModelUsable(ctx, target) + if err != nil { + return "", false, err + } + if !usable { + needsConfigure = true + } + } + + if needsConfigure { + selected, err := c.selectSingleModelWithSelector(ctx, fmt.Sprintf("Select model for %s:", runner), target, DefaultSingleSelector) + if err != nil { + return "", false, err + } + target = selected + } else if err := c.ensureModelsReady(ctx, []string{target}); err != nil { + return "", false, err + } + + return target, needsConfigure, nil +} + +func savedIntegrationOnboarded(saved *config.IntegrationConfig) bool { + return saved != nil && saved.Onboarded +} + +func managedIntegrationOnboarded(saved *config.IntegrationConfig, managed ManagedSingleModel) bool { + if !savedIntegrationOnboarded(saved) { + return false + } + validator, ok := managed.(ManagedOnboardingValidator) + if !ok { + return true + } + return validator.OnboardingComplete() +} + +// Most managed integrations treat onboarding as an interactive terminal step. +// Hermes opts out because its launch-owned onboarding is just bookkeeping, so +// headless launches should not be blocked once config is already prepared. +func managedRequiresInteractiveOnboarding(managed ManagedSingleModel) bool { + onboarding, ok := managed.(ManagedInteractiveOnboarding) + if !ok { + return true + } + return onboarding.RequiresInteractiveOnboarding() +} + func (c *launcherClient) selectSingleModelWithSelector(ctx context.Context, title, current string, selector SingleSelector) (string, error) { if selector == nil { return "", fmt.Errorf("no selector configured") diff --git a/cmd/launch/launch_test.go b/cmd/launch/launch_test.go index a9bd53c7b..3e088cc86 100644 --- a/cmd/launch/launch_test.go +++ b/cmd/launch/launch_test.go @@ -49,6 +49,55 @@ func (r *launcherSingleRunner) Run(model string, args []string) error { func (r *launcherSingleRunner) String() string { return "StubSingle" } +type launcherManagedRunner struct { + paths []string + currentModel string + configured []string + ranModel string + onboarded bool + onboardCalls int + onboardingComplete bool + refreshCalls int + refreshErr error +} + +func (r *launcherManagedRunner) Run(model string, args []string) error { + r.ranModel = model + return nil +} + +func (r *launcherManagedRunner) String() string { return "StubManaged" } + +func (r *launcherManagedRunner) Paths() []string { return r.paths } + +func (r *launcherManagedRunner) Configure(model string) error { + r.configured = append(r.configured, model) + r.currentModel = model + return nil +} + +func (r *launcherManagedRunner) CurrentModel() string { return r.currentModel } + +func (r *launcherManagedRunner) Onboard() error { + r.onboardCalls++ + r.onboarded = true + r.onboardingComplete = true + return nil +} + +func (r *launcherManagedRunner) OnboardingComplete() bool { return r.onboardingComplete } + +func (r *launcherManagedRunner) RefreshRuntimeAfterConfigure() error { + r.refreshCalls++ + return r.refreshErr +} + +type launcherHeadlessManagedRunner struct { + launcherManagedRunner +} + +func (r *launcherHeadlessManagedRunner) RequiresInteractiveOnboarding() bool { return false } + func setLaunchTestHome(t *testing.T, dir string) { t.Helper() t.Setenv("HOME", dir) @@ -141,6 +190,448 @@ func TestDefaultLaunchPolicy(t *testing.T) { } } +func TestBuildLauncherState_ManagedSingleIntegrationUsesCurrentModel(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/tags": + fmt.Fprint(w, `{"models":[{"name":"gemma4"}]}`) + case "/api/show": + fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + runner := &launcherManagedRunner{currentModel: "gemma4"} + withIntegrationOverride(t, "pi", runner) + + state, err := BuildLauncherState(context.Background()) + if err != nil { + t.Fatalf("BuildLauncherState returned error: %v", err) + } + + if state.Integrations["pi"].CurrentModel != "gemma4" { + t.Fatalf("expected managed current model from integration config, got %q", state.Integrations["pi"].CurrentModel) + } + if !state.Integrations["pi"].ModelUsable { + t.Fatal("expected managed current model to be usable") + } +} + +func TestBuildLauncherState_ManagedSingleIntegrationShowsSavedModelWhenLiveConfigMissing(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/tags": + fmt.Fprint(w, `{"models":[{"name":"gemma4"}]}`) + case "/api/show": + fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + if err := config.SaveIntegration("pi", []string{"gemma4"}); err != nil { + t.Fatalf("failed to save managed integration config: %v", err) + } + + runner := &launcherManagedRunner{} + withIntegrationOverride(t, "pi", runner) + + state, err := BuildLauncherState(context.Background()) + if err != nil { + t.Fatalf("BuildLauncherState returned error: %v", err) + } + + if state.Integrations["pi"].CurrentModel != "gemma4" { + t.Fatalf("expected saved model to remain visible, got %q", state.Integrations["pi"].CurrentModel) + } + if state.Integrations["pi"].ModelUsable { + t.Fatal("expected missing live config to mark managed model unusable") + } +} + +func TestLaunchIntegration_ManagedSingleIntegrationConfiguresOnboardsAndRuns(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, true) + withLauncherHooks(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/tags": + fmt.Fprint(w, `{"models":[{"name":"gemma4"}]}`) + case "/api/show": + fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + runner := &launcherManagedRunner{ + paths: nil, + } + withIntegrationOverride(t, "stubmanaged", runner) + + DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) { + return "gemma4", nil + } + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + return true, nil + } + + if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil { + t.Fatalf("LaunchIntegration returned error: %v", err) + } + + if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" { + t.Fatalf("configured models mismatch: %s", diff) + } + if runner.refreshCalls != 1 { + t.Fatalf("expected runtime refresh once after configure, got %d", runner.refreshCalls) + } + if runner.onboardCalls != 1 { + t.Fatalf("expected onboarding to run once, got %d", runner.onboardCalls) + } + if runner.ranModel != "gemma4" { + t.Fatalf("expected launch to run configured model, got %q", runner.ranModel) + } + + saved, err := config.LoadIntegration("stubmanaged") + if err != nil { + t.Fatalf("failed to reload managed integration config: %v", err) + } + if diff := compareStrings(saved.Models, []string{"gemma4"}); diff != "" { + t.Fatalf("saved models mismatch: %s", diff) + } +} + +func TestLaunchIntegration_ManagedSingleIntegrationReOnboardsWhenSavedFlagIsStale(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, true) + withLauncherHooks(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/tags": + fmt.Fprint(w, `{"models":[{"name":"gemma4"}]}`) + case "/api/show": + fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + runner := &launcherManagedRunner{ + currentModel: "gemma4", + onboardingComplete: false, + } + withIntegrationOverride(t, "stubmanaged", runner) + + if err := config.SaveIntegration("stubmanaged", []string{"gemma4"}); err != nil { + t.Fatalf("failed to save managed integration config: %v", err) + } + if err := config.MarkIntegrationOnboarded("stubmanaged"); err != nil { + t.Fatalf("failed to mark managed integration onboarded: %v", err) + } + + if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil { + t.Fatalf("LaunchIntegration returned error: %v", err) + } + + if runner.onboardCalls != 1 { + t.Fatalf("expected stale onboarded flag to trigger onboarding, got %d calls", runner.onboardCalls) + } + if runner.refreshCalls != 0 { + t.Fatalf("expected no runtime refresh when config is unchanged, got %d", runner.refreshCalls) + } + if runner.ranModel != "gemma4" { + t.Fatalf("expected launch to run saved model after onboarding repair, got %q", runner.ranModel) + } +} + +func TestLaunchIntegration_ManagedSingleIntegrationConfigOnlySkipsFinalRun(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, true) + withLauncherHooks(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/show": + fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + runner := &launcherManagedRunner{ + paths: nil, + } + withIntegrationOverride(t, "stubmanaged", runner) + + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + return true, nil + } + + if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ + Name: "stubmanaged", + ModelOverride: "gemma4", + ConfigureOnly: true, + }); err != nil { + t.Fatalf("LaunchIntegration returned error: %v", err) + } + + if runner.ranModel != "" { + t.Fatalf("expected configure-only flow to skip final launch, got %q", runner.ranModel) + } + if runner.refreshCalls != 1 { + t.Fatalf("expected configure-only flow to refresh runtime once, got %d", runner.refreshCalls) + } + if runner.onboardCalls != 1 { + t.Fatalf("expected configure-only flow to onboard once, got %d", runner.onboardCalls) + } +} + +func TestLaunchIntegration_ManagedSingleIntegrationRepairsMissingLiveConfigUsingSavedModel(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, true) + withLauncherHooks(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/tags": + fmt.Fprint(w, `{"models":[{"name":"gemma4"}]}`) + case "/api/show": + fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + if err := config.SaveIntegration("stubmanaged", []string{"gemma4"}); err != nil { + t.Fatalf("failed to save managed integration config: %v", err) + } + + runner := &launcherManagedRunner{} + withIntegrationOverride(t, "stubmanaged", runner) + + DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) { + t.Fatal("selector should not be called when saved model is reused for repair") + return "", nil + } + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + return true, nil + } + + if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil { + t.Fatalf("LaunchIntegration returned error: %v", err) + } + + if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" { + t.Fatalf("expected missing live config to be rewritten from saved model: %s", diff) + } + if runner.refreshCalls != 1 { + t.Fatalf("expected repaired config to refresh runtime once, got %d", runner.refreshCalls) + } + if runner.ranModel != "gemma4" { + t.Fatalf("expected launch to use repaired saved model, got %q", runner.ranModel) + } +} + +func TestLaunchIntegration_ManagedSingleIntegrationConfigureOnlyRepairsMissingLiveConfig(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, true) + withLauncherHooks(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/show": + fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + if err := config.SaveIntegration("stubmanaged", []string{"gemma4"}); err != nil { + t.Fatalf("failed to save managed integration config: %v", err) + } + + runner := &launcherManagedRunner{} + withIntegrationOverride(t, "stubmanaged", runner) + + DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) { + t.Fatal("selector should not be called when saved model is reused for repair") + return "", nil + } + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + return true, nil + } + + if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ + Name: "stubmanaged", + ConfigureOnly: true, + }); err != nil { + t.Fatalf("LaunchIntegration returned error: %v", err) + } + + if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" { + t.Fatalf("expected configure-only flow to rewrite missing live config: %s", diff) + } + if runner.refreshCalls != 1 { + t.Fatalf("expected configure-only repair to refresh runtime once, got %d", runner.refreshCalls) + } + if runner.ranModel != "" { + t.Fatalf("expected configure-only flow to skip final launch, got %q", runner.ranModel) + } +} + +func TestLaunchIntegration_ManagedSingleIntegrationStopsWhenRuntimeRefreshFails(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, true) + withLauncherHooks(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/show": + fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + runner := &launcherManagedRunner{ + refreshErr: fmt.Errorf("boom"), + } + withIntegrationOverride(t, "stubmanaged", runner) + + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + return true, nil + } + + err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ + Name: "stubmanaged", + ModelOverride: "gemma4", + }) + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("expected runtime refresh error, got %v", err) + } + if runner.ranModel != "" { + t.Fatalf("expected final launch to stop on runtime refresh failure, got %q", runner.ranModel) + } + if runner.refreshCalls != 1 { + t.Fatalf("expected one runtime refresh attempt, got %d", runner.refreshCalls) + } + if runner.onboardCalls != 0 { + t.Fatalf("expected onboarding to stop after refresh failure, got %d", runner.onboardCalls) + } +} + +func TestLaunchIntegration_ManagedSingleIntegrationHeadlessNeedsInteractiveOnboarding(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, false) + withLauncherHooks(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/show": + fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + runner := &launcherManagedRunner{ + paths: nil, + } + withIntegrationOverride(t, "stubmanaged", runner) + + err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ + Name: "stubmanaged", + ModelOverride: "gemma4", + Policy: &LaunchPolicy{Confirm: LaunchConfirmAutoApprove, MissingModel: LaunchMissingModelAutoPull}, + }) + if err == nil { + t.Fatal("expected headless onboarding requirement to fail") + } + if !strings.Contains(err.Error(), "interactive gateway setup") { + t.Fatalf("expected interactive onboarding guidance, got %v", err) + } + if runner.ranModel != "" { + t.Fatalf("expected no final launch when onboarding is still required, got %q", runner.ranModel) + } + if runner.onboardCalls != 0 { + t.Fatalf("expected no onboarding attempts in headless mode, got %d", runner.onboardCalls) + } +} + +func TestLaunchIntegration_ManagedSingleIntegrationHeadlessAllowsNonInteractiveOnboarding(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, false) + withLauncherHooks(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/show": + fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + runner := &launcherHeadlessManagedRunner{} + withIntegrationOverride(t, "stubmanaged", runner) + + err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ + Name: "stubmanaged", + ModelOverride: "gemma4", + Policy: &LaunchPolicy{Confirm: LaunchConfirmAutoApprove, MissingModel: LaunchMissingModelAutoPull}, + }) + if err != nil { + t.Fatalf("expected non-interactive onboarding to succeed headlessly, got %v", err) + } + if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" { + t.Fatalf("configured models mismatch: %s", diff) + } + if runner.onboardCalls != 1 { + t.Fatalf("expected onboarding to run once, got %d", runner.onboardCalls) + } + if runner.ranModel != "gemma4" { + t.Fatalf("expected launch to run configured model, got %q", runner.ranModel) + } +} + func TestBuildLauncherState_InstalledAndCloudDisabled(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) diff --git a/cmd/launch/models.go b/cmd/launch/models.go index 7c45ddfe0..fb962b4a1 100644 --- a/cmd/launch/models.go +++ b/cmd/launch/models.go @@ -230,7 +230,7 @@ func pullMissingModel(ctx context.Context, client *api.Client, model string) err // prepareEditorIntegration persists models and applies editor-managed config files. func prepareEditorIntegration(name string, runner Runner, editor Editor, models []string) error { - if ok, err := confirmEditorEdit(runner, editor); err != nil { + if ok, err := confirmConfigEdit(runner, editor.Paths()); err != nil { return err } else if !ok { return errCancelled @@ -244,8 +244,22 @@ func prepareEditorIntegration(name string, runner Runner, editor Editor, models return nil } -func confirmEditorEdit(runner Runner, editor Editor) (bool, error) { - paths := editor.Paths() +func prepareManagedSingleIntegration(name string, runner Runner, managed ManagedSingleModel, model string) error { + if ok, err := confirmConfigEdit(runner, managed.Paths()); err != nil { + return err + } else if !ok { + return errCancelled + } + if err := managed.Configure(model); err != nil { + return fmt.Errorf("setup failed: %w", err) + } + if err := config.SaveIntegration(name, []string{model}); err != nil { + return fmt.Errorf("failed to save: %w", err) + } + return nil +} + +func confirmConfigEdit(runner Runner, paths []string) (bool, error) { if len(paths) == 0 { return true, nil } diff --git a/cmd/launch/registry.go b/cmd/launch/registry.go index 8d70b3db1..77c134d77 100644 --- a/cmd/launch/registry.go +++ b/cmd/launch/registry.go @@ -33,7 +33,7 @@ type IntegrationInfo struct { Description string } -var launcherIntegrationOrder = []string{"opencode", "droid", "pi"} +var launcherIntegrationOrder = []string{"openclaw", "claude", "opencode", "hermes", "codex", "droid", "pi"} var integrationSpecs = []*IntegrationSpec{ { @@ -136,6 +136,20 @@ var integrationSpecs = []*IntegrationSpec{ Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent@latest"}, }, }, + { + Name: "hermes", + Runner: &Hermes{}, + Description: "Self-improving AI agent built by Nous Research", + Install: IntegrationInstallSpec{ + CheckInstalled: func() bool { + return (&Hermes{}).installed() + }, + EnsureInstalled: func() error { + return (&Hermes{}).ensureInstalled() + }, + URL: "https://hermes-agent.nousresearch.com/docs/getting-started/installation/", + }, + }, { Name: "vscode", Runner: &VSCode{}, @@ -255,10 +269,10 @@ func ListVisibleIntegrationSpecs() []IntegrationSpec { return aRank - bRank } if aRank > 0 { - return 1 + return -1 } if bRank > 0 { - return -1 + return 1 } return strings.Compare(a.Name, b.Name) }) diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index 15bd9cf4f..a683c1c5a 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -45,21 +45,12 @@ type menuItem struct { isOthers bool } -var mainMenuItems = []menuItem{ - { - title: "Chat with a model", - description: "Start an interactive chat with a model", - isRunModel: true, - }, - { - integration: "openclaw", - }, - { - integration: "claude", - }, - { - integration: "opencode", - }, +const pinnedIntegrationCount = 3 + +var runModelMenuItem = menuItem{ + title: "Chat with a model", + description: "Start an interactive chat with a model", + isRunModel: true, } var othersMenuItem = menuItem{ @@ -102,20 +93,14 @@ func shouldExpandOthers(state *launch.LauncherState) bool { } func buildMenuItems(state *launch.LauncherState, showOthers bool) []menuItem { - items := make([]menuItem, 0, len(mainMenuItems)+1) - for _, item := range mainMenuItems { - if item.integration == "" { - items = append(items, item) - continue - } - if integrationState, ok := state.Integrations[item.integration]; ok { - items = append(items, integrationMenuItem(integrationState)) - } - } + items := []menuItem{runModelMenuItem} + items = append(items, pinnedIntegrationItems(state)...) - if showOthers { - items = append(items, otherIntegrationItems(state)...) - } else { + otherItems := otherIntegrationItems(state) + switch { + case showOthers: + items = append(items, otherItems...) + case len(otherItems) > 0: items = append(items, othersMenuItem) } @@ -135,17 +120,28 @@ func integrationMenuItem(state launch.LauncherIntegrationState) menuItem { } func otherIntegrationItems(state *launch.LauncherState) []menuItem { - pinned := map[string]bool{ - "openclaw": true, - "claude": true, - "opencode": true, + ordered := orderedIntegrationItems(state) + if len(ordered) <= pinnedIntegrationCount { + return nil + } + return ordered[pinnedIntegrationCount:] +} + +func pinnedIntegrationItems(state *launch.LauncherState) []menuItem { + ordered := orderedIntegrationItems(state) + if len(ordered) <= pinnedIntegrationCount { + return ordered + } + return ordered[:pinnedIntegrationCount] +} + +func orderedIntegrationItems(state *launch.LauncherState) []menuItem { + if state == nil { + return nil } - var items []menuItem + items := make([]menuItem, 0, len(state.Integrations)) for _, info := range launch.ListIntegrationInfos() { - if pinned[info.Name] { - continue - } integrationState, ok := state.Integrations[info.Name] if !ok { continue @@ -155,6 +151,10 @@ func otherIntegrationItems(state *launch.LauncherState) []menuItem { return items } +func primaryMenuItemCount(state *launch.LauncherState) int { + return 1 + len(pinnedIntegrationItems(state)) +} + func initialCursor(state *launch.LauncherState, items []menuItem) int { if state == nil || state.LastSelection == "" { return 0 @@ -190,7 +190,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cursor > 0 { m.cursor-- } - if m.showOthers && m.cursor < len(mainMenuItems) { + if m.showOthers && m.cursor < primaryMenuItemCount(m.state) { m.showOthers = false m.items = buildMenuItems(m.state, false) m.cursor = min(m.cursor, len(m.items)-1) diff --git a/cmd/tui/tui_test.go b/cmd/tui/tui_test.go index 1f7720c98..6f5811d50 100644 --- a/cmd/tui/tui_test.go +++ b/cmd/tui/tui_test.go @@ -5,6 +5,7 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" + "github.com/google/go-cmp/cmp" "github.com/ollama/ollama/cmd/launch" ) @@ -43,6 +44,13 @@ func launcherTestState() *launch.LauncherState { Selectable: true, Changeable: true, }, + "hermes": { + Name: "hermes", + DisplayName: "Hermes Agent", + Description: "Self-improving AI agent built by Nous Research", + Selectable: true, + Changeable: true, + }, "droid": { Name: "droid", DisplayName: "Droid", @@ -70,8 +78,28 @@ func findMenuCursorByIntegration(items []menuItem, name string) int { return -1 } +func integrationSequence(items []menuItem) []string { + sequence := make([]string, 0, len(items)) + for _, item := range items { + switch { + case item.isRunModel: + sequence = append(sequence, "run") + case item.isOthers: + sequence = append(sequence, "more") + case item.integration != "": + sequence = append(sequence, item.integration) + } + } + return sequence +} + +func compareStrings(got, want []string) string { + return cmp.Diff(want, got) +} + func TestMenuRendersPinnedItemsAndMore(t *testing.T) { - view := newModel(launcherTestState()).View() + menu := newModel(launcherTestState()) + view := menu.View() for _, want := range []string{"Chat with a model", "Launch OpenClaw", "Launch Claude Code", "Launch OpenCode", "More..."} { if !strings.Contains(view, want) { t.Fatalf("expected menu view to contain %q\n%s", want, view) @@ -80,23 +108,31 @@ func TestMenuRendersPinnedItemsAndMore(t *testing.T) { if strings.Contains(view, "Launch Codex") { t.Fatalf("expected Codex to be under More, not pinned\n%s", view) } + wantOrder := []string{"run", "openclaw", "claude", "opencode", "more"} + if diff := compareStrings(integrationSequence(menu.items), wantOrder); diff != "" { + t.Fatalf("unexpected pinned order: %s", diff) + } } func TestMenuExpandsOthersFromLastSelection(t *testing.T) { state := launcherTestState() - state.LastSelection = "pi" + state.LastSelection = "codex" menu := newModel(state) if !menu.showOthers { t.Fatal("expected others section to expand when last selection is in the overflow list") } view := menu.View() - if !strings.Contains(view, "Launch Pi") { + if !strings.Contains(view, "Launch Codex") { t.Fatalf("expected expanded view to contain overflow integration\n%s", view) } if strings.Contains(view, "More...") { t.Fatalf("expected expanded view to replace More... item\n%s", view) } + wantOrder := []string{"run", "openclaw", "claude", "opencode", "hermes", "codex", "droid", "pi"} + if diff := compareStrings(integrationSequence(menu.items), wantOrder); diff != "" { + t.Fatalf("unexpected expanded order: %s", diff) + } } func TestMenuEnterOnRunSelectsRun(t *testing.T) { diff --git a/docs/integrations/hermes.mdx b/docs/integrations/hermes.mdx index 590f8ec65..3cabe4454 100644 --- a/docs/integrations/hermes.mdx +++ b/docs/integrations/hermes.mdx @@ -6,6 +6,10 @@ Hermes Agent is a self-improving AI agent built by Nous Research. It features au ## Quick start +```bash +ollama launch hermes +``` + ### Pull a model Before running the setup wizard, make sure you have a model available. Hermes will auto-detect models downloaded through Ollama. diff --git a/go.mod b/go.mod index a5bac3028..bf18ae8d3 100644 --- a/go.mod +++ b/go.mod @@ -106,5 +106,5 @@ require ( golang.org/x/term v0.36.0 golang.org/x/text v0.30.0 google.golang.org/protobuf v1.34.1 - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/yaml.v3 v3.0.1 )