diff --git a/cmd/launch/hermes.go b/cmd/launch/hermes.go index 683b10b75..56c40b9b2 100644 --- a/cmd/launch/hermes.go +++ b/cmd/launch/hermes.go @@ -83,6 +83,138 @@ func (h *Hermes) Run(_ string, _ []LaunchModel, args []string) error { return hermesAttachedCommand(bin, args...).Run() } +type HermesDesktop struct { + Hermes +} + +func (h *HermesDesktop) String() string { return "Hermes Desktop" } + +func (h *HermesDesktop) Run(_ string, _ []LaunchModel, args []string) error { + bin, err := h.binary() + if err != nil { + return err + } + return hermesAttachedCommand(bin, h.launchArgs(args)...).Run() +} + +func (h *HermesDesktop) Onboard() error { + return config.MarkIntegrationOnboarded("hermes-desktop") +} + +func (h *HermesDesktop) launchArgs(args []string) []string { + launchArgs := []string{"desktop"} + if h.shouldSkipDesktopBuild(args) { + launchArgs = append(launchArgs, "--skip-build") + } + return append(launchArgs, args...) +} + +func (h *HermesDesktop) shouldSkipDesktopBuild(args []string) bool { + if hermesDesktopHasFlag(args, "--skip-build", "--source", "--build-only", "--help", "-h") { + return false + } + return h.packagedAppExists() +} + +func (h *HermesDesktop) packagedAppExists() bool { + for _, root := range hermesDesktopReleaseRoots() { + for _, candidate := range hermesDesktopPackagedExecutableCandidates(root) { + if _, err := os.Stat(candidate); err == nil { + return true + } + } + } + return false +} + +// These roots mirror Hermes' own install layout: +// scripts/install.sh uses ~/.hermes/hermes-agent for user installs and +// /usr/local/lib/hermes-agent for new Linux root installs; scripts/install.ps1 +// and the bootstrap installer use %LOCALAPPDATA%\hermes\hermes-agent on +// Windows. HERMES_HOME and HERMES_INSTALL_DIR are installer-supported +// overrides. +func hermesDesktopReleaseRoots() []string { + var installRoots []string + add := func(path string) { + path = strings.TrimSpace(path) + if path == "" { + return + } + installRoots = append(installRoots, filepath.Clean(path)) + } + + if installDir := strings.TrimSpace(os.Getenv("HERMES_INSTALL_DIR")); installDir != "" { + add(installDir) + } + if hermesHome := strings.TrimSpace(os.Getenv("HERMES_HOME")); hermesHome != "" { + add(filepath.Join(hermesHome, "hermes-agent")) + } + + home, err := hermesUserHome() + if err == nil { + switch hermesGOOS { + case "windows": + if localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); localAppData != "" { + add(filepath.Join(localAppData, "hermes", "hermes-agent")) + } + add(filepath.Join(home, ".hermes", "hermes-agent")) + default: + add(filepath.Join(home, ".hermes", "hermes-agent")) + if hermesGOOS == "linux" { + add(filepath.Join(string(filepath.Separator), "usr", "local", "lib", "hermes-agent")) + } + } + } + + seen := make(map[string]bool, len(installRoots)) + releaseRoots := make([]string, 0, len(installRoots)) + for _, root := range installRoots { + releaseRoot := filepath.Join(root, "apps", "desktop", "release") + if seen[releaseRoot] { + continue + } + seen[releaseRoot] = true + releaseRoots = append(releaseRoots, releaseRoot) + } + return releaseRoots +} + +func hermesDesktopPackagedExecutableCandidates(releaseRoot string) []string { + switch hermesGOOS { + case "darwin": + matches, err := filepath.Glob(filepath.Join(releaseRoot, "mac*", "Hermes.app", "Contents", "MacOS", "Hermes")) + if err != nil { + return nil + } + return matches + case "windows": + return []string{ + filepath.Join(releaseRoot, "win-unpacked", "Hermes.exe"), + filepath.Join(releaseRoot, "win-ia32-unpacked", "Hermes.exe"), + filepath.Join(releaseRoot, "win-arm64-unpacked", "Hermes.exe"), + } + default: + return []string{ + filepath.Join(releaseRoot, "linux-unpacked", "hermes"), + filepath.Join(releaseRoot, "linux-unpacked", "Hermes"), + } + } +} + +func hermesDesktopHasFlag(args []string, names ...string) bool { + for _, arg := range args { + if arg == "--" { + return false + } + for _, name := range names { + if arg == name { + return true + } + } + } + return false +} + func (h *Hermes) Paths() []string { configPath, err := hermesConfigPath() if err != nil { @@ -185,6 +317,10 @@ func (h *Hermes) installed() bool { } func (h *Hermes) ensureInstalled() error { + return h.ensureInstalledFor("hermes") +} + +func (h *Hermes) ensureInstalledFor(command string) error { if h.installed() { return nil } @@ -198,7 +334,7 @@ func (h *Hermes) ensureInstalled() error { } } 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 ")) + 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 %s", strings.Join(missing, "\n "), command) } ok, err := ConfirmPrompt("Hermes is not installed. Install now?") diff --git a/cmd/launch/hermes_test.go b/cmd/launch/hermes_test.go index 4c6451951..162b81998 100644 --- a/cmd/launch/hermes_test.go +++ b/cmd/launch/hermes_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strings" "testing" @@ -65,6 +66,20 @@ func clearHermesMessagingEnvVars(t *testing.T) { } } +func clearHermesDesktopPackageEnvVars(t *testing.T) { + t.Helper() + for _, key := range []string{"HERMES_INSTALL_DIR", "HERMES_HOME", "LOCALAPPDATA"} { + 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{} @@ -565,6 +580,172 @@ func TestHermesRunPassthroughArgs(t *testing.T) { } } +func writeHermesDesktopPackage(t *testing.T, home string) { + t.Helper() + writeHermesDesktopExecutable(t, + filepath.Join(home, ".hermes", "hermes-agent", "apps", "desktop", "release"), + hermesDesktopTestExecutableRelativePath(hermesGOOS), + ) +} + +func writeHermesDesktopExecutable(t *testing.T, releaseRoot, relative string) { + t.Helper() + path := filepath.Join(releaseRoot, relative) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } +} + +func hermesDesktopTestExecutableRelativePath(goos string) string { + switch goos { + case "darwin": + return filepath.Join("mac-arm64", "Hermes.app", "Contents", "MacOS", "Hermes") + case "windows": + return filepath.Join("win-unpacked", "Hermes.exe") + default: + return filepath.Join("linux-unpacked", "hermes") + } +} + +func writeHermesDesktopTestBinary(t *testing.T, dir string) { + t.Helper() + bin := filepath.Join(dir, "hermes") + if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '[%s]\\n' \"$*\" >> \"$HOME/hermes-invocations.log\"\n"), 0o755); err != nil { + t.Fatal(err) + } +} + +func readHermesDesktopInvocations(t *testing.T, home string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(home, "hermes-invocations.log")) + if err != nil { + t.Fatal(err) + } + return strings.TrimSpace(string(data)) +} + +func TestHermesDesktopRun(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + tests := []struct { + name string + goos string + args []string + hasPackage bool + clearPkgEnv bool + want string + }{ + { + name: "desktop subcommand", + goos: "darwin", + args: []string{"--foreground"}, + clearPkgEnv: true, + want: "[desktop --foreground]", + }, + { + name: "skip build when packaged app exists", + goos: runtime.GOOS, + args: []string{"--cwd", "/tmp/project"}, + hasPackage: true, + want: "[desktop --skip-build --cwd /tmp/project]", + }, + { + name: "explicit skip build", + goos: runtime.GOOS, + args: []string{"--skip-build"}, + hasPackage: true, + want: "[desktop --skip-build]", + }, + { + name: "source mode", + goos: runtime.GOOS, + args: []string{"--source"}, + hasPackage: true, + want: "[desktop --source]", + }, + { + name: "build only", + goos: runtime.GOOS, + args: []string{"--build-only"}, + hasPackage: true, + want: "[desktop --build-only]", + }, + { + name: "help", + goos: runtime.GOOS, + args: []string{"--help"}, + hasPackage: true, + want: "[desktop --help]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withLauncherHooks(t) + withInteractiveSession(t, true) + withHermesPlatform(t, tt.goos) + clearHermesMessagingEnvVars(t) + if tt.clearPkgEnv { + clearHermesDesktopPackageEnvVars(t) + } + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + if tt.hasPackage { + writeHermesDesktopPackage(t, tmpDir) + } + writeHermesDesktopTestBinary(t, tmpDir) + + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + t.Fatalf("did not expect messaging prompt during desktop launch: %s", prompt) + return false, nil + } + + if err := (&HermesDesktop{}).Run("", nil, tt.args); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if got := readHermesDesktopInvocations(t, tmpDir); got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + }) + } +} + +func TestHermesDesktopRunUsesWindowsLocalAppDataPackage(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withHermesPlatform(t, "windows") + t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData")) + + writeHermesDesktopExecutable(t, + filepath.Join(tmpDir, "LocalAppData", "hermes", "hermes-agent", "apps", "desktop", "release"), + hermesDesktopTestExecutableRelativePath("windows"), + ) + + got := (&HermesDesktop{}).launchArgs([]string{"--cwd", `C:\Users\me\project`}) + want := []string{"desktop", "--skip-build", "--cwd", `C:\Users\me\project`} + if diff := compareStrings(got, want); diff != "" { + t.Fatalf("Hermes Desktop launch args mismatch: %s", diff) + } +} + +func TestHermesDesktopReleaseRootsIncludeLinuxRootInstall(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withHermesPlatform(t, "linux") + + got := hermesDesktopReleaseRoots() + want := filepath.Join(string(filepath.Separator), "usr", "local", "lib", "hermes-agent", "apps", "desktop", "release") + if !slices.Contains(got, want) { + t.Fatalf("expected Linux root install release path %q in %v", want, got) + } +} + func TestHermesRun_PromptsForMessagingSetupBeforeDefaultLaunch(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses a POSIX shell test binary") diff --git a/cmd/launch/integrations_test.go b/cmd/launch/integrations_test.go index 36c5b0aed..2779928d2 100644 --- a/cmd/launch/integrations_test.go +++ b/cmd/launch/integrations_test.go @@ -61,6 +61,7 @@ func TestIntegrationLookup(t *testing.T) { {"codex app", "codex-app", true, "Codex App"}, {"codex app desktop alias", "codex-desktop", true, "Codex App"}, {"codex app gui alias", "codex-gui", true, "Codex App"}, + {"hermes desktop", "hermes-desktop", true, "Hermes Desktop"}, {"kimi", "kimi", true, "Kimi Code CLI"}, {"droid", "droid", true, "Droid"}, {"opencode", "opencode", true, "OpenCode"}, @@ -83,7 +84,7 @@ func TestIntegrationLookup(t *testing.T) { } func TestIntegrationRegistry(t *testing.T) { - expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "codex-app", "kimi", "droid", "opencode", "hermes", "pool"} + expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "codex-app", "kimi", "droid", "opencode", "hermes", "hermes-desktop", "pool"} for _, name := range expectedIntegrations { t.Run(name, func(t *testing.T) { r, ok := integrations[name] @@ -1846,9 +1847,9 @@ func TestListIntegrationInfos(t *testing.T) { for _, info := range infos { got = append(got, info.Name) } - wantPrefix := []string{"claude", "codex-app", "hermes", "openclaw"} + wantPrefix := []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "hermes-desktop", "codex"} if codexAppSupported() != nil { - wantPrefix = []string{"claude", "hermes", "openclaw", "opencode"} + wantPrefix = []string{"claude", "hermes", "openclaw", "opencode", "hermes-desktop", "codex"} } if len(got) < len(wantPrefix) { t.Fatalf("expected at least %d integrations, got %v", len(wantPrefix), got) @@ -1898,6 +1899,15 @@ func TestListIntegrationInfos(t *testing.T) { t.Fatal("expected hermes to be included in ListIntegrationInfos") }) + t.Run("includes hermes desktop", func(t *testing.T) { + for _, info := range infos { + if info.Name == "hermes-desktop" { + return + } + } + t.Fatal("expected hermes-desktop to be included in ListIntegrationInfos") + }) + t.Run("hermes still resolves explicitly", func(t *testing.T) { name, runner, err := LookupIntegration("hermes") if err != nil { @@ -2020,6 +2030,7 @@ func TestIntegration_AutoInstallable(t *testing.T) { {"openclaw", true}, {"pi", true}, {"hermes", true}, + {"hermes-desktop", true}, {"cline", true}, {"qwen", true}, {"claude", false}, diff --git a/cmd/launch/launch.go b/cmd/launch/launch.go index 8d463b4dc..82c084e4e 100644 --- a/cmd/launch/launch.go +++ b/cmd/launch/launch.go @@ -292,6 +292,7 @@ Supported integrations: openclaw OpenClaw (aliases: clawdbot, moltbot) opencode OpenCode codex Codex + hermes-desktop Hermes Desktop copilot Copilot CLI (aliases: copilot-cli) droid Droid kimi Kimi Code CLI @@ -308,6 +309,7 @@ Examples: ollama launch codex-app ollama launch codex-app --restore ollama launch hermes + ollama launch hermes-desktop ollama launch droid --config (does not auto-launch) ollama launch codex --restore ollama launch codex -- --sandbox workspace-write`, diff --git a/cmd/launch/registry.go b/cmd/launch/registry.go index bf1a65234..143926128 100644 --- a/cmd/launch/registry.go +++ b/cmd/launch/registry.go @@ -33,7 +33,7 @@ type IntegrationInfo struct { Description string } -var launcherIntegrationOrder = []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "codex", "copilot", "cline", "droid", "pi", "pool", "qwen"} +var launcherIntegrationOrder = []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "cline", "droid", "pi", "pool", "qwen"} var integrationSpecs = []*IntegrationSpec{ { @@ -220,6 +220,20 @@ var integrationSpecs = []*IntegrationSpec{ URL: "https://hermes-agent.nousresearch.com/docs/getting-started/installation/", }, }, + { + Name: "hermes-desktop", + Runner: &HermesDesktop{}, + Description: "Desktop app for Hermes Agent by Nous Research", + Install: IntegrationInstallSpec{ + CheckInstalled: func() bool { + return (&Hermes{}).installed() + }, + EnsureInstalled: func() error { + return (&Hermes{}).ensureInstalledFor("hermes-desktop") + }, + URL: "https://hermes-agent.nousresearch.com/docs/getting-started/installation/", + }, + }, { Name: "vscode", Runner: &VSCode{},