launch: hermes-desktop app (#16516)

Add support to launch the hermes-desktop app alongside the hermes agent from ollama launch. It will go through the install on first run if hermes-desktop is not already installed.
This commit is contained in:
Bruce MacDonald
2026-06-04 11:51:36 -07:00
committed by GitHub
parent 455f57457d
commit 3370ff8b1c
5 changed files with 349 additions and 5 deletions

View File

@@ -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?")

View File

@@ -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")

View File

@@ -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},

View File

@@ -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`,

View File

@@ -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{},