mirror of
https://github.com/ollama/ollama.git
synced 2026-07-23 09:10:53 -05:00
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:
@@ -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?")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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{},
|
||||
|
||||
Reference in New Issue
Block a user