diff --git a/cmd/launch/integrations_test.go b/cmd/launch/integrations_test.go index 2779928d2..81465ce68 100644 --- a/cmd/launch/integrations_test.go +++ b/cmd/launch/integrations_test.go @@ -65,6 +65,7 @@ func TestIntegrationLookup(t *testing.T) { {"kimi", "kimi", true, "Kimi Code CLI"}, {"droid", "droid", true, "Droid"}, {"opencode", "opencode", true, "OpenCode"}, + {"omp", "omp", true, "OMP"}, {"pool", "pool", true, "Pool"}, {"unknown integration", "unknown", false, ""}, {"empty string", "", false, ""}, @@ -84,7 +85,7 @@ func TestIntegrationLookup(t *testing.T) { } func TestIntegrationRegistry(t *testing.T) { - expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "codex-app", "kimi", "droid", "opencode", "hermes", "hermes-desktop", "pool"} + expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "codex-app", "kimi", "droid", "opencode", "omp", "hermes", "hermes-desktop", "pool", "qwen"} for _, name := range expectedIntegrations { t.Run(name, func(t *testing.T) { r, ok := integrations[name] @@ -1871,7 +1872,7 @@ func TestListIntegrationInfos(t *testing.T) { }) t.Run("includes known integrations", func(t *testing.T) { - known := map[string]bool{"claude": false, "cline": false, "codex": false, "opencode": false} + known := map[string]bool{"claude": false, "cline": false, "codex": false, "opencode": false, "omp": false} if codexAppSupported() == nil { known["codex-app"] = false } @@ -2006,6 +2007,7 @@ func TestIntegration_Editor(t *testing.T) { {"claude", false}, {"claude-desktop", false}, {"codex", false}, + {"omp", false}, {"nonexistent", false}, } for _, tt := range tests { @@ -2037,6 +2039,7 @@ func TestIntegration_AutoInstallable(t *testing.T) { {"claude-desktop", false}, {"codex", false}, {"opencode", false}, + {"omp", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cmd/launch/launch.go b/cmd/launch/launch.go index 82c084e4e..5e4b01420 100644 --- a/cmd/launch/launch.go +++ b/cmd/launch/launch.go @@ -291,6 +291,7 @@ Supported integrations: hermes Hermes Agent openclaw OpenClaw (aliases: clawdbot, moltbot) opencode OpenCode + omp OMP codex Codex hermes-desktop Hermes Desktop copilot Copilot CLI (aliases: copilot-cli) @@ -310,6 +311,7 @@ Examples: ollama launch codex-app --restore ollama launch hermes ollama launch hermes-desktop + ollama launch omp ollama launch droid --config (does not auto-launch) ollama launch codex --restore ollama launch codex -- --sandbox workspace-write`, diff --git a/cmd/launch/omp.go b/cmd/launch/omp.go new file mode 100644 index 000000000..c443e3dce --- /dev/null +++ b/cmd/launch/omp.go @@ -0,0 +1,411 @@ +package launch + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + + "github.com/ollama/ollama/cmd/config" + "github.com/ollama/ollama/cmd/internal/fileutil" + "github.com/ollama/ollama/envconfig" + "github.com/ollama/ollama/types/model" + "gopkg.in/yaml.v3" +) + +const ( + ompIntegrationName = "omp" + ompProviderName = "ollama" + ompSetupVersion = 1 + ompWebSearchPlugin = "@ollama/pi-web-search" +) + +// OMP implements Runner for the OMP coding-agent integration. +type OMP struct{} + +func (o *OMP) String() string { return "OMP" } + +func (o *OMP) Paths() []string { + var paths []string + for _, pathFn := range []func() (string, error){ompModelsPath, ompConfigPath} { + path, err := pathFn() + if err != nil { + continue + } + if _, err := os.Stat(path); err == nil { + paths = append(paths, path) + } + } + return paths +} + +func (o *OMP) Configure(model string) error { + return o.ConfigureWithModels(model, []LaunchModel{fallbackLaunchModel(model)}) +} + +func (o *OMP) ConfigureWithModels(primary string, models []LaunchModel) error { + if primary == "" { + return nil + } + if len(models) == 0 { + models = []LaunchModel{fallbackLaunchModel(primary)} + } + if err := writeOMPModelsConfig(primary, models); err != nil { + return err + } + return writeOMPAgentConfig() +} + +func (o *OMP) CurrentModel() string { + cfg, err := readOMPModelsConfig() + if err != nil { + return "" + } + provider, ok := ompProvider(cfg) + if !ok { + return "" + } + models, _ := provider["models"].([]any) + for _, raw := range models { + entry, ok := raw.(map[string]any) + if !ok { + continue + } + if id, _ := entry["id"].(string); id != "" { + return id + } + } + return "" +} + +func (o *OMP) Onboard() error { + return config.MarkIntegrationOnboarded(ompIntegrationName) +} + +func (o *OMP) RequiresInteractiveOnboarding() bool { return false } + +func (o *OMP) args(model string, extra []string) []string { + var args []string + if model != "" { + args = append(args, "--model", ompModelName(model)) + } + args = append(args, extra...) + return args +} + +func ompModelName(model string) string { + if strings.HasPrefix(model, "ollama/") { + return model + } + return "ollama/" + model +} + +func (o *OMP) findPath() (string, error) { + if p, err := exec.LookPath("omp"); err == nil { + return p, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + for _, dir := range []string{ + filepath.Join(home, ".local", "bin"), + filepath.Join(home, ".bun", "bin"), + } { + for _, name := range ompExecutableNames() { + fallback := filepath.Join(dir, name) + if _, err := os.Stat(fallback); err == nil { + return fallback, nil + } + } + } + return "", exec.ErrNotFound +} + +func ompExecutableNames() []string { + if runtime.GOOS == "windows" { + return []string{"omp.exe", "omp.cmd", "omp.bat"} + } + return []string{"omp"} +} + +func (o *OMP) Run(model string, _ []LaunchModel, args []string) error { + ompPath, err := o.findPath() + if err != nil { + return fmt.Errorf("omp is not installed, install from https://omp.sh") + } + + ensureOMPWebSearchPlugin(ompPath) + + cmd := exec.Command(ompPath, o.args(model, args)...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + return cmd.Run() +} + +func ensureOMPWebSearchPlugin(bin string) { + if !shouldManageOllamaWebSearch() { + fmt.Fprintf(os.Stderr, "%sCloud is disabled; skipping %s setup.%s\n", ansiGray, ompWebSearchPlugin, ansiReset) + return + } + + fmt.Fprintf(os.Stderr, "%sChecking OMP web search plugin...%s\n", ansiGray, ansiReset) + + installed, err := ompPluginInstalled(bin, ompWebSearchPlugin) + if err != nil { + fmt.Fprintf(os.Stderr, "%s Warning: could not check %s installation: %v%s\n", ansiYellow, ompWebSearchPlugin, err, ansiReset) + return + } + + verb := "Installing" + warnVerb := "install" + doneVerb := "Installed" + if installed { + verb = "Updating" + warnVerb = "update" + doneVerb = "Updated" + } + + fmt.Fprintf(os.Stderr, "%s%s %s...%s\n", ansiGray, verb, ompWebSearchPlugin, ansiReset) + cmd := exec.Command(bin, "plugin", "install", ompWebSearchPlugin) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "%s Warning: could not %s %s: %v%s\n", ansiYellow, warnVerb, ompWebSearchPlugin, err, ansiReset) + return + } + + fmt.Fprintf(os.Stderr, "%s āœ“ %s %s%s\n", ansiGreen, doneVerb, ompWebSearchPlugin, ansiReset) +} + +func ompPluginInstalled(bin, plugin string) (bool, error) { + cmd := exec.Command(bin, "plugin", "list") + out, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if msg == "" { + return false, err + } + return false, fmt.Errorf("%w: %s", err, msg) + } + + versioned := plugin + "@" + for _, line := range strings.Split(string(out), "\n") { + trimmed := strings.TrimSpace(line) + if strings.Contains(trimmed, versioned) || trimmed == plugin { + return true, nil + } + } + return false, nil +} + +func ompModelsPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".omp", "agent", "models.yml"), nil +} + +func ompConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".omp", "agent", "config.yml"), nil +} + +func readOMPModelsConfig() (map[string]any, error) { + path, err := ompModelsPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + if cfg == nil { + cfg = make(map[string]any) + } + return cfg, nil +} + +func writeOMPModelsConfig(primary string, models []LaunchModel) error { + path, err := ompModelsPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + cfg := make(map[string]any) + if existing, err := readOMPModelsConfig(); err == nil { + cfg = existing + } + + provider := ensureOMPProvider(cfg) + existingByID := ompModelEntriesByID(provider) + ordered := append([]LaunchModel(nil), models...) + if model, ok := findLaunchModel(ordered, primary); ok { + ordered = append([]LaunchModel{model}, removeLaunchModel(ordered, primary)...) + } else { + ordered = append([]LaunchModel{fallbackLaunchModel(primary)}, ordered...) + } + + var merged []any + seen := make(map[string]bool, len(ordered)) + for _, model := range ordered { + if model.Name == "" || seen[model.Name] { + continue + } + seen[model.Name] = true + entry := ompModelConfig(model) + if existing, ok := existingByID[model.Name]; ok { + for key, value := range existing { + if _, overridden := entry[key]; !overridden { + entry[key] = value + } + } + } + merged = append(merged, entry) + } + + for _, raw := range ompProviderModels(provider) { + entry, ok := raw.(map[string]any) + if !ok { + merged = append(merged, raw) + continue + } + id, _ := entry["id"].(string) + if id == "" || seen[id] { + continue + } + merged = append(merged, entry) + } + provider["models"] = merged + + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + return fileutil.WriteWithBackup(path, data, ompIntegrationName) +} + +func writeOMPAgentConfig() error { + path, err := ompConfigPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + cfg := make(map[string]any) + if data, err := os.ReadFile(path); err == nil { + if err := yaml.Unmarshal(data, &cfg); err != nil { + return err + } + if cfg == nil { + cfg = make(map[string]any) + } + } + cfg["setupVersion"] = ompSetupVersion + + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + return fileutil.WriteWithBackup(path, data, ompIntegrationName) +} + +func ensureOMPProvider(cfg map[string]any) map[string]any { + providers, _ := cfg["providers"].(map[string]any) + if providers == nil { + providers = make(map[string]any) + cfg["providers"] = providers + } + provider, _ := providers[ompProviderName].(map[string]any) + if provider == nil { + provider = make(map[string]any) + providers[ompProviderName] = provider + } + + provider["baseUrl"] = ompBaseURL() + provider["api"] = "openai-responses" + provider["auth"] = "none" + provider["discovery"] = map[string]any{"type": "ollama"} + return provider +} + +func ompBaseURL() string { + return strings.TrimRight(envconfig.ConnectableHost().String(), "/") + "/v1" +} + +func ompProvider(cfg map[string]any) (map[string]any, bool) { + providers, ok := cfg["providers"].(map[string]any) + if !ok { + return nil, false + } + provider, ok := providers[ompProviderName].(map[string]any) + return provider, ok +} + +func ompProviderModels(provider map[string]any) []any { + models, _ := provider["models"].([]any) + return models +} + +func ompModelEntriesByID(provider map[string]any) map[string]map[string]any { + out := make(map[string]map[string]any) + for _, raw := range ompProviderModels(provider) { + entry, ok := raw.(map[string]any) + if !ok { + continue + } + if id, _ := entry["id"].(string); id != "" { + out[id] = entry + } + } + return out +} + +func ompModelConfig(modelInfo LaunchModel) map[string]any { + entry := map[string]any{ + "id": modelInfo.Name, + "name": modelInfo.Name, + } + input := []string{"text"} + if slices.Contains(modelInfo.Capabilities, model.CapabilityVision) { + input = append(input, "image") + } + entry["input"] = input + + if modelInfo.ContextLength > 0 { + entry["contextWindow"] = modelInfo.ContextLength + } + if modelInfo.MaxOutputTokens > 0 { + entry["maxTokens"] = modelInfo.MaxOutputTokens + } + return entry +} + +func removeLaunchModel(models []LaunchModel, name string) []LaunchModel { + out := make([]LaunchModel, 0, len(models)) + for _, model := range models { + if launchModelMatches(model.Name, name) || launchModelMatches(name, model.Name) { + continue + } + out = append(out, model) + } + return out +} diff --git a/cmd/launch/omp_test.go b/cmd/launch/omp_test.go new file mode 100644 index 000000000..e1cbf53bc --- /dev/null +++ b/cmd/launch/omp_test.go @@ -0,0 +1,559 @@ +package launch + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + + modelpkg "github.com/ollama/ollama/types/model" + "gopkg.in/yaml.v3" +) + +func TestMain(m *testing.M) { + if os.Getenv("OLLAMA_LAUNCH_OMP_TEST_HELPER") == "1" { + runOMPTestHelper() + return + } + os.Exit(m.Run()) +} + +func runOMPTestHelper() { + logPath := os.Getenv("OLLAMA_LAUNCH_OMP_TEST_LOG") + if logPath != "" { + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err == nil { + _, _ = fmt.Fprintln(f, strings.Join(os.Args[1:], " ")) + _ = f.Close() + } + } + + if len(os.Args) >= 3 && os.Args[1] == "plugin" && os.Args[2] == "list" { + fmt.Print(os.Getenv("OLLAMA_LAUNCH_OMP_TEST_PLUGIN_LIST")) + os.Exit(0) + } + if len(os.Args) >= 4 && os.Args[1] == "plugin" && os.Args[2] == "install" { + if os.Getenv("OLLAMA_LAUNCH_OMP_TEST_FAIL_INSTALL") == "1" { + _, _ = fmt.Fprintln(os.Stderr, "install failed") + os.Exit(1) + } + os.Exit(0) + } + os.Exit(0) +} + +func TestOMPIntegration(t *testing.T) { + o := &OMP{} + + t.Run("String", func(t *testing.T) { + if got := o.String(); got != "OMP" { + t.Errorf("String() = %q, want %q", got, "OMP") + } + }) + + t.Run("implements Runner", func(t *testing.T) { + var _ Runner = o + }) + + t.Run("implements ManagedSingleModel", func(t *testing.T) { + var _ ManagedSingleModel = o + }) + + t.Run("implements ManagedModelListConfigurer", func(t *testing.T) { + var _ ManagedModelListConfigurer = o + }) + + t.Run("does not require interactive onboarding", func(t *testing.T) { + var _ ManagedInteractiveOnboarding = o + if o.RequiresInteractiveOnboarding() { + t.Fatal("OMP onboarding should not require an interactive terminal") + } + }) +} + +func TestOMPArgs(t *testing.T) { + o := &OMP{} + + tests := []struct { + name string + model string + args []string + want []string + }{ + {"with model", "gemma4", nil, []string{"--model", "ollama/gemma4"}}, + {"with cloud model", "kimi-k2.6:cloud", nil, []string{"--model", "ollama/kimi-k2.6:cloud"}}, + {"empty model", "", nil, nil}, + {"with model and extra", "gemma4", []string{"--help"}, []string{"--model", "ollama/gemma4", "--help"}}, + {"already qualified", "ollama/gemma4", nil, []string{"--model", "ollama/gemma4"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := o.args(tt.model, tt.args) + if !slices.Equal(got, tt.want) { + t.Errorf("args(%q, %v) = %v, want %v", tt.model, tt.args, got, tt.want) + } + }) + } +} + +func TestOMPRun_WebSearchPluginLifecycle(t *testing.T) { + seedOMPHelperBinary := func(t *testing.T, dir string) { + t.Helper() + src, err := os.Executable() + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(src) + if err != nil { + t.Fatal(err) + } + dst := filepath.Join(dir, ompExecutableNames()[0]) + if err := os.WriteFile(dst, data, 0o755); err != nil { + t.Fatal(err) + } + } + + setCloudStatus := func(t *testing.T, disabled bool) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/status" { + fmt.Fprintf(w, `{"cloud":{"disabled":%t,"source":"config"}}`, disabled) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + t.Setenv("OLLAMA_HOST", srv.URL) + } + + setup := func(t *testing.T, pluginList string, cloudDisabled bool) (string, *OMP) { + t.Helper() + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + t.Setenv("OLLAMA_LAUNCH_OMP_TEST_HELPER", "1") + t.Setenv("OLLAMA_LAUNCH_OMP_TEST_PLUGIN_LIST", pluginList) + logPath := filepath.Join(tmpDir, "omp.log") + t.Setenv("OLLAMA_LAUNCH_OMP_TEST_LOG", logPath) + setCloudStatus(t, cloudDisabled) + seedOMPHelperBinary(t, tmpDir) + return logPath, &OMP{} + } + + t.Run("web search missing installs before launch", func(t *testing.T) { + logPath, o := setup(t, "No plugins installed\n", false) + + if err := o.Run("kimi-k2.6:cloud", nil, []string{"session"}); err != nil { + t.Fatalf("Run() error = %v", err) + } + + calls, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + got := string(calls) + if !strings.Contains(got, "plugin list\n") { + t.Fatalf("expected plugin list call, got:\n%s", got) + } + if !strings.Contains(got, "plugin install "+ompWebSearchPlugin+"\n") { + t.Fatalf("expected plugin install call, got:\n%s", got) + } + if !strings.Contains(got, "--model ollama/kimi-k2.6:cloud session\n") { + t.Fatalf("expected final omp launch call, got:\n%s", got) + } + }) + + t.Run("web search present refreshes before launch", func(t *testing.T) { + logPath, o := setup(t, "npm Plugins:\n\nā— "+ompWebSearchPlugin+"@0.0.5\n", false) + + if err := o.Run("gemma4", nil, []string{"chat"}); err != nil { + t.Fatalf("Run() error = %v", err) + } + + calls, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + got := string(calls) + if !strings.Contains(got, "plugin install "+ompWebSearchPlugin+"\n") { + t.Fatalf("expected plugin refresh install call, got:\n%s", got) + } + if !strings.Contains(got, "--model ollama/gemma4 chat\n") { + t.Fatalf("expected final omp launch call, got:\n%s", got) + } + }) + + t.Run("web search install failure warns and continues", func(t *testing.T) { + logPath, o := setup(t, "No plugins installed\n", false) + t.Setenv("OLLAMA_LAUNCH_OMP_TEST_FAIL_INSTALL", "1") + + stderr := captureStderr(t, func() { + if err := o.Run("gemma4", nil, []string{"chat"}); err != nil { + t.Fatalf("Run() should continue after plugin install failure, got %v", err) + } + }) + if !strings.Contains(stderr, "Warning: could not install "+ompWebSearchPlugin) { + t.Fatalf("expected install warning, got:\n%s", stderr) + } + + calls, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(calls), "--model ollama/gemma4 chat\n") { + t.Fatalf("expected final omp launch call, got:\n%s", calls) + } + }) + + t.Run("cloud disabled skips web search plugin management", func(t *testing.T) { + logPath, o := setup(t, "No plugins installed\n", true) + + stderr := captureStderr(t, func() { + if err := o.Run("gemma4", nil, []string{"chat"}); err != nil { + t.Fatalf("Run() error = %v", err) + } + }) + if !strings.Contains(stderr, "Cloud is disabled; skipping "+ompWebSearchPlugin+" setup.") { + t.Fatalf("expected cloud-disabled skip message, got:\n%s", stderr) + } + + calls, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + got := string(calls) + if strings.Contains(got, "plugin list\n") || strings.Contains(got, "plugin install "+ompWebSearchPlugin+"\n") { + t.Fatalf("did not expect plugin management calls, got:\n%s", got) + } + if !strings.Contains(got, "--model ollama/gemma4 chat\n") { + t.Fatalf("expected final omp launch call, got:\n%s", got) + } + }) +} + +func TestOMPFindPath(t *testing.T) { + o := &OMP{} + + t.Run("finds omp in PATH", func(t *testing.T) { + tmpDir := t.TempDir() + name := "omp" + if runtime.GOOS == "windows" { + name = "omp.exe" + } + fakeBin := filepath.Join(tmpDir, name) + os.WriteFile(fakeBin, []byte("#!/bin/sh\n"), 0o755) + t.Setenv("PATH", tmpDir) + + got, err := o.findPath() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != fakeBin { + t.Errorf("findPath() = %q, want %q", got, fakeBin) + } + }) + + t.Run("falls back to ~/.local/bin/omp", func(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + t.Setenv("PATH", t.TempDir()) + + fallback := filepath.Join(home, ".local", "bin", ompExecutableNames()[0]) + os.MkdirAll(filepath.Dir(fallback), 0o755) + os.WriteFile(fallback, []byte("#!/bin/sh\n"), 0o755) + + got, err := o.findPath() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != fallback { + t.Errorf("findPath() = %q, want %q", got, fallback) + } + }) + + t.Run("falls back to ~/.bun/bin/omp", func(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + t.Setenv("PATH", t.TempDir()) + + fallback := filepath.Join(home, ".bun", "bin", ompExecutableNames()[0]) + os.MkdirAll(filepath.Dir(fallback), 0o755) + os.WriteFile(fallback, []byte("#!/bin/sh\n"), 0o755) + + got, err := o.findPath() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != fallback { + t.Errorf("findPath() = %q, want %q", got, fallback) + } + }) + + t.Run("returns error when not found", func(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + t.Setenv("PATH", t.TempDir()) + + if _, err := o.findPath(); err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestOMPConfigureWithModelsWritesModelsYML(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + t.Setenv("OLLAMA_HOST", "http://0.0.0.0:11434") + + o := &OMP{} + models := []LaunchModel{ + { + Name: "glm-5.1:cloud", + ContextLength: 202_752, + MaxOutputTokens: 131_072, + }, + { + Name: "qwen3.6", + Capabilities: []modelpkg.Capability{modelpkg.CapabilityVision}, + }, + } + if err := o.ConfigureWithModels("glm-5.1:cloud", models); err != nil { + t.Fatalf("ConfigureWithModels returned error: %v", err) + } + + path := filepath.Join(home, ".omp", "agent", "models.yml") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read models.yml: %v", err) + } + + cfg := parseOMPConfigYAML(t, data) + provider := ompProviderFromYAML(t, cfg) + if provider["baseUrl"] != "http://127.0.0.1:11434/v1" { + t.Fatalf("baseUrl = %v, want connectable OpenAI-compatible host", provider["baseUrl"]) + } + if provider["api"] != "openai-responses" { + t.Fatalf("api = %v, want openai-responses", provider["api"]) + } + if provider["auth"] != "none" { + t.Fatalf("auth = %v, want none", provider["auth"]) + } + discovery, _ := provider["discovery"].(map[string]any) + if discovery["type"] != "ollama" { + t.Fatalf("discovery = %v, want type ollama", discovery) + } + + entries := ompModelEntriesFromYAML(t, provider) + if len(entries) != 2 { + t.Fatalf("models length = %d, want 2", len(entries)) + } + if entries[0]["id"] != "glm-5.1:cloud" { + t.Fatalf("first model id = %v, want primary first", entries[0]["id"]) + } + if got := numericYAMLValue(entries[0]["contextWindow"]); got != 202_752 { + t.Fatalf("contextWindow = %d, want 202752", got) + } + if got := numericYAMLValue(entries[0]["maxTokens"]); got != 131_072 { + t.Fatalf("maxTokens = %d, want 131072", got) + } + if input := stringSliceYAMLValue(entries[1]["input"]); !slices.Equal(input, []string{"text", "image"}) { + t.Fatalf("vision input = %v, want [text image]", input) + } + if got := o.CurrentModel(); got != "glm-5.1:cloud" { + t.Fatalf("CurrentModel = %q, want glm-5.1:cloud", got) + } + + configPath := filepath.Join(home, ".omp", "agent", "config.yml") + configData, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read config.yml: %v", err) + } + config := parseOMPConfigYAML(t, configData) + if got := numericYAMLValue(config["setupVersion"]); got != ompSetupVersion { + t.Fatalf("setupVersion = %d, want %d", got, ompSetupVersion) + } + if paths := o.Paths(); !slices.Equal(paths, []string{path, configPath}) { + t.Fatalf("Paths = %v, want [%s %s]", paths, path, configPath) + } +} + +func TestOMPConfigureWithModelsPreservesExistingConfig(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + + modelsPath := filepath.Join(home, ".omp", "agent", "models.yml") + if err := os.MkdirAll(filepath.Dir(modelsPath), 0o755); err != nil { + t.Fatal(err) + } + existing := []byte(` +providers: + anthropic: + baseUrl: https://example.com/anthropic + ollama: + baseUrl: http://old-host:11434 + api: openai-responses + auth: none + models: + - id: old-model + name: Old Model + customField: keep-me +`) + if err := os.WriteFile(modelsPath, existing, 0o644); err != nil { + t.Fatal(err) + } + + configPath := filepath.Join(home, ".omp", "agent", "config.yml") + existingConfig := []byte(` +lastChangelogVersion: 15.7.6 +setupVersion: 0 +theme: monochrome +`) + if err := os.WriteFile(configPath, existingConfig, 0o644); err != nil { + t.Fatal(err) + } + + o := &OMP{} + if err := o.ConfigureWithModels("new-model", []LaunchModel{{Name: "new-model"}, {Name: "old-model"}}); err != nil { + t.Fatalf("ConfigureWithModels returned error: %v", err) + } + + data, err := os.ReadFile(modelsPath) + if err != nil { + t.Fatal(err) + } + cfg := parseOMPConfigYAML(t, data) + providers, _ := cfg["providers"].(map[string]any) + if _, ok := providers["anthropic"]; !ok { + t.Fatalf("expected non-Ollama provider to be preserved: %v", providers) + } + + provider := ompProviderFromYAML(t, cfg) + if provider["baseUrl"] != "http://127.0.0.1:11434/v1" { + t.Fatalf("baseUrl = %v, want repaired OpenAI-compatible host", provider["baseUrl"]) + } + + entries := ompModelEntriesFromYAML(t, provider) + if len(entries) != 2 { + t.Fatalf("models length = %d, want 2", len(entries)) + } + if entries[0]["id"] != "new-model" { + t.Fatalf("first model id = %v, want new-model", entries[0]["id"]) + } + if entries[1]["id"] != "old-model" { + t.Fatalf("second model id = %v, want old-model", entries[1]["id"]) + } + if entries[1]["customField"] != "keep-me" { + t.Fatalf("custom field was not preserved: %v", entries[1]) + } + + configData, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + config := parseOMPConfigYAML(t, configData) + if got := numericYAMLValue(config["setupVersion"]); got != ompSetupVersion { + t.Fatalf("setupVersion = %d, want %d", got, ompSetupVersion) + } + if config["theme"] != "monochrome" { + t.Fatalf("theme was not preserved: %v", config) + } + if config["lastChangelogVersion"] != "15.7.6" { + t.Fatalf("lastChangelogVersion was not preserved: %v", config) + } +} + +func TestOMPConfigureWithModelsAlwaysMarksSetupComplete(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + + configPath := filepath.Join(home, ".omp", "agent", "config.yml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte("setupVersion: 2\n"), 0o644); err != nil { + t.Fatal(err) + } + + o := &OMP{} + if err := o.ConfigureWithModels("new-model", []LaunchModel{{Name: "new-model"}}); err != nil { + t.Fatalf("ConfigureWithModels returned error: %v", err) + } + + configData, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + config := parseOMPConfigYAML(t, configData) + if got := numericYAMLValue(config["setupVersion"]); got != ompSetupVersion { + t.Fatalf("setupVersion = %d, want %d", got, ompSetupVersion) + } +} + +func parseOMPConfigYAML(t *testing.T, data []byte) map[string]any { + t.Helper() + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("generated YAML did not parse: %v\n%s", err, data) + } + return cfg +} + +func ompProviderFromYAML(t *testing.T, cfg map[string]any) map[string]any { + t.Helper() + providers, ok := cfg["providers"].(map[string]any) + if !ok { + t.Fatalf("providers missing from config: %v", cfg) + } + provider, ok := providers["ollama"].(map[string]any) + if !ok { + t.Fatalf("ollama provider missing from config: %v", providers) + } + return provider +} + +func ompModelEntriesFromYAML(t *testing.T, provider map[string]any) []map[string]any { + t.Helper() + rawModels, ok := provider["models"].([]any) + if !ok { + t.Fatalf("provider models missing: %v", provider) + } + models := make([]map[string]any, 0, len(rawModels)) + for _, raw := range rawModels { + entry, ok := raw.(map[string]any) + if !ok { + t.Fatalf("model entry has unexpected type %T: %v", raw, raw) + } + models = append(models, entry) + } + return models +} + +func numericYAMLValue(value any) int { + switch v := value.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + default: + return 0 + } +} + +func stringSliceYAMLValue(value any) []string { + raw, _ := value.([]any) + out := make([]string, 0, len(raw)) + for _, item := range raw { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out +} diff --git a/cmd/launch/pi.go b/cmd/launch/pi.go index 2854d6b06..ee8d783bb 100644 --- a/cmd/launch/pi.go +++ b/cmd/launch/pi.go @@ -351,7 +351,7 @@ func npmArgs(prefix string, args ...string) []string { } func ensurePiWebSearchPackage(bin string) { - if !shouldManagePiWebSearch() { + if !shouldManageOllamaWebSearch() { fmt.Fprintf(os.Stderr, "%sCloud is disabled; skipping %s setup.%s\n", ansiGray, piWebSearchPkg, ansiReset) return } @@ -395,7 +395,7 @@ func ensurePiWebSearchPackage(bin string) { fmt.Fprintf(os.Stderr, "%s āœ“ Updated %s%s\n", ansiGreen, piWebSearchPkg, ansiReset) } -func shouldManagePiWebSearch() bool { +func shouldManageOllamaWebSearch() bool { client, err := api.ClientFromEnvironment() if err != nil { return true diff --git a/cmd/launch/registry.go b/cmd/launch/registry.go index 143926128..250e0026c 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", "hermes-desktop", "codex", "copilot", "cline", "droid", "pi", "pool", "qwen"} +var launcherIntegrationOrder = []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "omp", "hermes-desktop", "codex", "copilot", "cline", "droid", "pi", "pool", "qwen"} var integrationSpecs = []*IntegrationSpec{ { @@ -156,6 +156,18 @@ var integrationSpecs = []*IntegrationSpec{ URL: "https://opencode.ai", }, }, + { + Name: "omp", + Runner: &OMP{}, + Description: "AI coding agent with IDE integration", + Install: IntegrationInstallSpec{ + CheckInstalled: func() bool { + _, err := (&OMP{}).findPath() + return err == nil + }, + URL: "https://omp.sh", + }, + }, { Name: "openclaw", Runner: &Openclaw{}, diff --git a/cmd/launch/runner_exec_only_test.go b/cmd/launch/runner_exec_only_test.go index 06a5d6554..fb8c6e918 100644 --- a/cmd/launch/runner_exec_only_test.go +++ b/cmd/launch/runner_exec_only_test.go @@ -61,6 +61,14 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) { return filepath.Join(home, ".kimi", "config.toml") }, }, + { + name: "omp", + binary: "omp", + runner: &OMP{}, + checkPath: func(home string) string { + return filepath.Join(home, ".omp", "agent", "models.yml") + }, + }, } for _, tt := range tests { diff --git a/docs/docs.json b/docs/docs.json index aa1ef31b3..a58e637f3 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -128,6 +128,7 @@ "/integrations/copilot-cli", "/integrations/cline-cli", "/integrations/opencode", + "/integrations/omp", "/integrations/droid", "/integrations/goose", "/integrations/pi", diff --git a/docs/integrations/index.mdx b/docs/integrations/index.mdx index b5db49ecf..b7b4473fc 100644 --- a/docs/integrations/index.mdx +++ b/docs/integrations/index.mdx @@ -14,6 +14,7 @@ Coding assistants that can read, modify, and execute code in your projects. - [Copilot CLI](/integrations/copilot-cli) - [Cline CLI](/integrations/cline-cli) - [OpenCode](/integrations/opencode) +- [OMP](/integrations/omp) - [Droid](/integrations/droid) - [Goose](/integrations/goose) - [Pi](/integrations/pi) diff --git a/docs/integrations/omp.mdx b/docs/integrations/omp.mdx new file mode 100644 index 000000000..a13fc50cf --- /dev/null +++ b/docs/integrations/omp.mdx @@ -0,0 +1,59 @@ +--- +title: OMP +--- + +OMP is an AI coding agent with IDE integration that runs in your terminal. + +## Install + +Install [OMP](https://omp.sh): + +```bash +curl -fsSL https://omp.sh/install | sh +``` + +On Windows, install from PowerShell: + +```powershell +irm https://omp.sh/install.ps1 | iex +``` + +## Usage with Ollama + +### Quick setup + +```bash +ollama launch omp +``` + +To run directly with a model: + +```bash +ollama launch omp --model glm-5.1:cloud +``` + +OMP discovers Ollama automatically when the Ollama server is running locally. + +Start Ollama if it is not already running, then pull a model: + +```bash +ollama pull qwen3.6 +``` + +Then launch OMP: + +```bash +omp +``` + +Use `/model` in OMP and select the Ollama model, such as `ollama/qwen3.6` and set it as the default. + +To launch OMP with a specific Ollama model: + +```bash +omp --model ollama/qwen3.6 +``` + +For coding agents, larger context windows work best. See [Context length](/context-length) for more information. + +For more OMP model configuration options, see the [custom models documentation](https://omp.sh/docs/custom-models).