From ed9945e91665879ad3a49df9b3f68efdb9d72463 Mon Sep 17 00:00:00 2001 From: ParthSareen Date: Tue, 5 May 2026 14:08:31 -0700 Subject: [PATCH 1/4] cmd: add codex app launch integration --- cmd/cmd.go | 2 +- cmd/cmd_launcher_test.go | 2 +- cmd/launch/codex.go | 358 ++++++++++++-- cmd/launch/codex_app.go | 824 ++++++++++++++++++++++++++++++++ cmd/launch/codex_app_test.go | 573 ++++++++++++++++++++++ cmd/launch/codex_test.go | 100 ++++ cmd/launch/integrations_test.go | 18 +- cmd/launch/launch.go | 7 +- cmd/launch/launch_test.go | 83 +++- cmd/launch/registry.go | 14 +- docs/docs.json | 1 + docs/integrations/codex-app.mdx | 41 ++ docs/integrations/codex.mdx | 4 +- docs/integrations/index.mdx | 3 +- go.mod | 2 +- 15 files changed, 1981 insertions(+), 51 deletions(-) create mode 100644 cmd/launch/codex_app.go create mode 100644 cmd/launch/codex_app_test.go create mode 100644 docs/integrations/codex-app.mdx diff --git a/cmd/cmd.go b/cmd/cmd.go index e238b5bd3..d63393fda 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -2231,7 +2231,7 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe func launcherActionExitsLoop(integration string) bool { switch integration { - case "vscode": + case "codex-app", "vscode": return true default: return false diff --git a/cmd/cmd_launcher_test.go b/cmd/cmd_launcher_test.go index 9e5f6dbb0..3a766f4ab 100644 --- a/cmd/cmd_launcher_test.go +++ b/cmd/cmd_launcher_test.go @@ -249,7 +249,7 @@ func TestRunLauncherAction_GUIAppsExitTUILoop(t *testing.T) { cmd := &cobra.Command{} cmd.SetContext(context.Background()) - for _, integration := range []string{"vscode"} { + for _, integration := range []string{"codex-app", "vscode"} { continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: integration}, launcherDeps{ resolveRunModel: unexpectedRunModelResolution(t), launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error { diff --git a/cmd/launch/codex.go b/cmd/launch/codex.go index 4ac24deb9..114159177 100644 --- a/cmd/launch/codex.go +++ b/cmd/launch/codex.go @@ -7,7 +7,9 @@ import ( "path/filepath" "strings" + "github.com/ollama/ollama/cmd/internal/fileutil" "github.com/ollama/ollama/envconfig" + "github.com/pelletier/go-toml/v2" "golang.org/x/mod/semver" ) @@ -17,6 +19,7 @@ type Codex struct{} func (c *Codex) String() string { return "Codex" } const codexProfileName = "ollama-launch" +const codexProviderName = "Ollama" func (c *Codex) args(model string, extra []string) []string { args := []string{"--profile", codexProfileName} @@ -49,76 +52,351 @@ func (c *Codex) Run(model string, args []string) error { // ensureCodexConfig writes a [profiles.ollama-launch] section to ~/.codex/config.toml // with openai_base_url pointing to the local Ollama server. func ensureCodexConfig() error { - home, err := os.UserHomeDir() + configPath, err := codexConfigPath() if err != nil { return err } - codexDir := filepath.Join(home, ".codex") - if err := os.MkdirAll(codexDir, 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { return err } - - configPath := filepath.Join(codexDir, "config.toml") return writeCodexProfile(configPath) } +func codexConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".codex", "config.toml"), nil +} + // writeCodexProfile ensures ~/.codex/config.toml has the ollama-launch profile // and model provider sections with the correct base URL. func writeCodexProfile(configPath string) error { - baseURL := envconfig.Host().String() + "/v1/" + return writeCodexLaunchProfile(configPath, codexLaunchProfileOptions{ + forceAPIAuth: true, + }) +} + +type codexLaunchProfileOptions struct { + activate bool + forceAPIAuth bool + setRootModelConfig bool + model string + modelCatalogPath string +} + +func writeCodexLaunchProfile(configPath string, opts codexLaunchProfileOptions) error { + baseURL := codexBaseURL() + + content, readErr := os.ReadFile(configPath) + text := "" + if readErr == nil { + text = string(content) + } else if !os.IsNotExist(readErr) { + return readErr + } + if err := codexValidateConfigText(text); err != nil { + return err + } + + model := strings.TrimSpace(opts.model) + if model == "" { + model = codexSectionStringValue(text, codexProfileHeader(), "model") + } + modelCatalogPath := strings.TrimSpace(opts.modelCatalogPath) + if modelCatalogPath == "" { + modelCatalogPath = codexSectionStringValue(text, codexProfileHeader(), "model_catalog_json") + } + + profileLines := []string{} + if model != "" { + profileLines = append(profileLines, fmt.Sprintf("model = %q", model)) + } + profileLines = append(profileLines, + fmt.Sprintf("openai_base_url = %q", baseURL), + fmt.Sprintf("model_provider = %q", codexProfileName), + ) + if opts.forceAPIAuth { + profileLines = append(profileLines, `forced_login_method = "api"`) + } + if modelCatalogPath != "" { + profileLines = append(profileLines, fmt.Sprintf("model_catalog_json = %q", modelCatalogPath)) + } sections := []struct { header string lines []string }{ { - header: fmt.Sprintf("[profiles.%s]", codexProfileName), - lines: []string{ - fmt.Sprintf("openai_base_url = %q", baseURL), - `forced_login_method = "api"`, - fmt.Sprintf("model_provider = %q", codexProfileName), - }, + header: codexProfileHeader(), + lines: profileLines, }, { - header: fmt.Sprintf("[model_providers.%s]", codexProfileName), + header: codexProviderHeader(), lines: []string{ - `name = "Ollama"`, + fmt.Sprintf("name = %q", codexProviderName), fmt.Sprintf("base_url = %q", baseURL), + `wire_api = "responses"`, }, }, } - content, readErr := os.ReadFile(configPath) - text := "" - if readErr == nil { - text = string(content) + if opts.activate { + text = codexSetRootStringValue(text, "profile", codexProfileName) } - - for _, s := range sections { - block := strings.Join(append([]string{s.header}, s.lines...), "\n") + "\n" - - if idx := strings.Index(text, s.header); idx >= 0 { - // Replace the existing section up to the next section header. - rest := text[idx+len(s.header):] - if endIdx := strings.Index(rest, "\n["); endIdx >= 0 { - text = text[:idx] + block + rest[endIdx+1:] - } else { - text = text[:idx] + block - } - } else { - // Append the section. - if text != "" && !strings.HasSuffix(text, "\n") { - text += "\n" - } - if text != "" { - text += "\n" - } - text += block + if opts.setRootModelConfig { + if model != "" { + text = codexSetRootStringValue(text, "model", model) + } + text = codexSetRootStringValue(text, "model_provider", codexProfileName) + if modelCatalogPath != "" { + text = codexSetRootStringValue(text, "model_catalog_json", modelCatalogPath) } } - return os.WriteFile(configPath, []byte(text), 0o644) + for _, s := range sections { + text = codexUpsertSection(text, s.header, s.lines) + } + if err := codexValidateConfigText(text); err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + return err + } + return fileutil.WriteWithBackup(configPath, []byte(text)) +} + +func codexBaseURL() string { + return strings.TrimRight(envconfig.Host().String(), "/") + "/v1/" +} + +func codexProfileHeader() string { + return fmt.Sprintf("[profiles.%s]", codexProfileName) +} + +func codexProviderHeader() string { + return fmt.Sprintf("[model_providers.%s]", codexProfileName) +} + +func codexUpsertSection(text, header string, lines []string) string { + block := strings.Join(append([]string{header}, lines...), "\n") + "\n" + + if targetPath, ok := codexTableHeaderPath(header); ok { + if start, end, found := codexSectionRange(text, targetPath); found { + return text[:start] + block + text[end:] + } + } + + if text != "" && !strings.HasSuffix(text, "\n") { + text += "\n" + } + if text != "" { + text += "\n" + } + return text + block +} + +func codexRootStringValue(text, key string) string { + value, _ := codexStringValue(text, key) + return value +} + +func codexRootStringValueOK(text, key string) (string, bool) { + return codexStringValue(text, key) +} + +func codexStringValue(text string, path ...string) (string, bool) { + if len(path) == 0 { + return "", false + } + cfg, err := codexParseConfigText(text) + if err != nil { + return "", false + } + var current any = cfg + for _, part := range path { + table, ok := current.(map[string]any) + if !ok { + return "", false + } + current, ok = table[part] + if !ok { + return "", false + } + } + value, ok := current.(string) + if !ok { + return "", false + } + return value, true +} + +func codexSectionStringValue(text, header, key string) string { + path, ok := codexTableHeaderPath(header) + if !ok { + return "" + } + value, _ := codexStringValue(text, append(path, key)...) + return value +} + +func codexParseConfigText(text string) (map[string]any, error) { + cfg := map[string]any{} + if strings.TrimSpace(text) == "" { + return cfg, nil + } + if err := toml.Unmarshal([]byte(text), &cfg); err != nil { + return nil, fmt.Errorf("invalid Codex config TOML: %w", err) + } + return cfg, nil +} + +func codexValidateConfigText(text string) error { + _, err := codexParseConfigText(text) + return err +} + +func codexSectionRange(text string, targetPath []string) (int, int, bool) { + lines := strings.SplitAfter(text, "\n") + offset := 0 + start := -1 + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "#") { + offset += len(line) + continue + } + if start >= 0 { + return start, offset, true + } + if path, ok := codexTableHeaderPath(trimmed); ok && codexSamePath(path, targetPath) { + start = offset + } + offset += len(line) + } + if start >= 0 { + return start, len(text), true + } + return 0, 0, false +} + +func codexTableHeaderPath(header string) ([]string, bool) { + trimmed := strings.TrimSpace(header) + if !strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "[[") { + return nil, false + } + + const probeKey = "__ollama_launch_probe" + cfg := map[string]any{} + if err := toml.Unmarshal([]byte(trimmed+"\n"+probeKey+" = true\n"), &cfg); err != nil { + return nil, false + } + return codexFindProbePath(cfg, probeKey, nil) +} + +func codexFindProbePath(value any, probeKey string, path []string) ([]string, bool) { + table, ok := value.(map[string]any) + if !ok { + return nil, false + } + if probe, ok := table[probeKey].(bool); ok && probe { + return path, true + } + for key, child := range table { + if key == probeKey { + continue + } + if childPath, ok := codexFindProbePath(child, probeKey, append(path, key)); ok { + return childPath, true + } + } + return nil, false +} + +func codexSamePath(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func codexSetRootStringValue(text, key, value string) string { + lines := strings.SplitAfter(text, "\n") + rootEnd := len(lines) + for i, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "[") { + rootEnd = i + break + } + } + + assignment := fmt.Sprintf("%s = %q", key, value) + for i := 0; i < rootEnd; i++ { + line := lines[i] + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if codexRootLineHasKey(trimmed, key) { + if strings.HasSuffix(line, "\n") { + lines[i] = assignment + "\n" + } else { + lines[i] = assignment + } + return strings.Join(lines, "") + } + } + + insert := assignment + "\n" + root := strings.Join(lines[:rootEnd], "") + rest := strings.Join(lines[rootEnd:], "") + if root != "" && !strings.HasSuffix(root, "\n") { + root += "\n" + } + if rest != "" && !strings.HasSuffix(insert, "\n\n") { + insert += "\n" + } + return root + insert + rest +} + +func codexRemoveRootValue(text, key string) string { + lines := strings.SplitAfter(text, "\n") + rootEnd := len(lines) + for i, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "[") { + rootEnd = i + break + } + } + + out := make([]string, 0, len(lines)) + for i, line := range lines { + if i < rootEnd { + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "#") && codexRootLineHasKey(trimmed, key) { + continue + } + } + out = append(out, line) + } + return strings.Join(out, "") +} + +func codexRootLineHasKey(line, key string) bool { + cfg := map[string]any{} + if err := toml.Unmarshal([]byte(line+"\n"), &cfg); err != nil { + return false + } + _, ok := cfg[key] + return ok } func checkCodexVersion() error { diff --git a/cmd/launch/codex_app.go b/cmd/launch/codex_app.go new file mode 100644 index 000000000..c4f377fc0 --- /dev/null +++ b/cmd/launch/codex_app.go @@ -0,0 +1,824 @@ +package launch + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + "github.com/ollama/ollama/api" + "github.com/ollama/ollama/cmd/config" + "github.com/ollama/ollama/cmd/internal/fileutil" + "github.com/ollama/ollama/envconfig" +) + +const ( + codexAppIntegrationName = "codex-app" + codexAppBundleID = "com.openai.codex" + codexAppModelCatalogFilename = "ollama-launch-models.json" + codexAppRestoreHint = "To restore your usual Codex profile, run: ollama launch codex-app --restore" + codexAppConfigurationSuccess = "Codex App profile changed to Ollama." + codexAppRestoreSuccess = "Codex App restored to your usual profile." +) + +var ( + codexAppGOOS = runtime.GOOS + codexAppStat = os.Stat + codexAppGlob = filepath.Glob + codexAppOpenApp = defaultCodexAppOpenApp + codexAppOpenPath = defaultCodexAppOpenAppPath + codexAppOpenStart = defaultCodexAppOpenStartAppID + codexAppQuitApp = defaultCodexAppQuitApp + codexAppIsRunning = defaultCodexAppIsRunning + codexAppRunPath = defaultCodexAppRunningAppPath + codexAppStartID = defaultCodexAppStartAppID + codexAppSleep = time.Sleep +) + +// CodexApp configures the desktop Codex app with one launch-selected default +// model while leaving model discovery and switching to Codex's Ollama provider. +type CodexApp struct{} + +func (c *CodexApp) String() string { return "Codex App" } + +func (c *CodexApp) Supported() error { return codexAppSupported() } + +func (c *CodexApp) Paths() []string { + configPath, err := codexConfigPath() + if err != nil { + return nil + } + return []string{configPath} +} + +func (c *CodexApp) Configure(model string) error { + return c.ConfigureWithModels(model, []string{model}) +} + +func (c *CodexApp) ConfigureWithModels(primary string, models []string) error { + primary = strings.TrimSpace(primary) + if primary == "" { + return fmt.Errorf("codex-app requires a model") + } + + configPath, err := codexConfigPath() + if err != nil { + return err + } + if err := saveCodexAppRestoreState(configPath); err != nil { + return err + } + catalogPath, err := codexAppModelCatalogPath() + if err != nil { + return err + } + if err := writeCodexAppModelCatalog(catalogPath, codexAppCatalogModelNames(primary, models)); err != nil { + return err + } + return writeCodexLaunchProfile(configPath, codexLaunchProfileOptions{ + activate: true, + setRootModelConfig: true, + model: primary, + modelCatalogPath: catalogPath, + }) +} + +func (c *CodexApp) CurrentModel() string { + configPath, err := codexConfigPath() + if err != nil { + return "" + } + data, err := os.ReadFile(configPath) + if err != nil { + return "" + } + text := string(data) + if codexRootStringValue(text, "model_provider") == codexProfileName { + baseURL := codexSectionStringValue(text, codexProviderHeader(), "base_url") + if codexNormalizeURL(baseURL) == codexNormalizeURL(codexBaseURL()) { + return strings.TrimSpace(codexRootStringValue(text, "model")) + } + } + if codexRootStringValue(text, "profile") != codexProfileName { + return "" + } + if codexSectionStringValue(text, codexProfileHeader(), "model_provider") != codexProfileName { + return "" + } + baseURL := codexSectionStringValue(text, codexProviderHeader(), "base_url") + if codexNormalizeURL(baseURL) != codexNormalizeURL(codexBaseURL()) { + return "" + } + return strings.TrimSpace(codexSectionStringValue(text, codexProfileHeader(), "model")) +} + +func (c *CodexApp) Onboard() error { + return config.MarkIntegrationOnboarded(codexAppIntegrationName) +} + +func (c *CodexApp) RequiresInteractiveOnboarding() bool { + return false +} + +func (c *CodexApp) RestoreHint() string { + return codexAppRestoreHint +} + +func (c *CodexApp) ConfigurationSuccessMessage() string { + return codexAppConfigurationSuccess + "\n" + codexAppRestoreHint +} + +func (c *CodexApp) RestoreSuccessMessage() string { + return codexAppRestoreSuccess +} + +func (c *CodexApp) Run(_ string, args []string) error { + if err := codexAppSupported(); err != nil { + return err + } + if len(args) > 0 { + return fmt.Errorf("codex-app does not accept extra arguments") + } + return codexAppLaunchOrRestart("Restart Codex to use Ollama?") +} + +func (c *CodexApp) Restore() error { + if err := codexAppSupported(); err != nil { + return err + } + configPath, err := codexConfigPath() + if err != nil { + return err + } + + data, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + return codexAppLaunchOrRestart("Restart Codex to use your usual profile?") + } + return err + } + text := string(data) + if err := codexValidateConfigText(text); err != nil { + return err + } + + state, stateErr := loadCodexAppRestoreState() + if stateErr == nil { + text = codexRestoreRootStringValue(text, "profile", state.HadProfile, state.Profile) + text = codexRestoreRootStringValue(text, "model", state.HadModel, state.Model) + text = codexRestoreRootStringValue(text, "model_provider", state.HadModelProvider, state.ModelProvider) + text = codexRestoreRootStringValue(text, "model_catalog_json", state.HadModelCatalogJSON, state.ModelCatalogJSON) + } else if codexRootStringValue(text, "profile") == codexProfileName { + text = codexRemoveRootValue(text, "profile") + if codexRootStringValue(text, "model_provider") == codexProfileName { + text = codexRemoveRootValue(text, "model_provider") + } + if catalogPath, err := codexAppModelCatalogPath(); err == nil && codexRootStringValue(text, "model_catalog_json") == catalogPath { + text = codexRemoveRootValue(text, "model_catalog_json") + } + } + + if err := codexValidateConfigText(text); err != nil { + return err + } + if err := fileutil.WriteWithBackup(configPath, []byte(text)); err != nil { + return err + } + _ = os.Remove(codexAppRestoreStatePath()) + return codexAppLaunchOrRestart("Restart Codex to use your usual profile?") +} + +func codexAppSupported() error { + switch codexAppGOOS { + case "darwin", "windows": + return nil + default: + return fmt.Errorf("Codex App launch is only supported on macOS and Windows") + } +} + +func codexAppInstalled() bool { + if codexAppAppPath() != "" { + return true + } + if codexAppGOOS != "windows" { + return false + } + return codexAppIsRunning() || codexAppStartID() != "" +} + +func codexAppModelCatalogPath() (string, error) { + configPath, err := codexConfigPath() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(configPath), codexAppModelCatalogFilename), nil +} + +func writeCodexAppModelCatalog(path string, models []string) error { + if len(models) == 0 { + return fmt.Errorf("codex-app model catalog cannot be empty") + } + client := api.NewClient(envconfig.Host(), http.DefaultClient) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + baseInstructions := codexAppBaseInstructions() + entries := make([]map[string]any, 0, len(models)) + for i, model := range models { + contextWindow := codexAppModelContextWindow(ctx, client, model) + entries = append(entries, codexAppCatalogEntry(model, contextWindow, i, baseInstructions)) + } + + data, err := json.MarshalIndent(map[string]any{"models": entries}, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return fileutil.WriteWithBackup(path, append(data, '\n')) +} + +func codexAppCatalogModelNames(primary string, fallback []string) []string { + models := codexAppTagModelNames() + if len(models) == 0 { + models = fallback + } + return dedupeModelList(append([]string{primary}, models...)) +} + +func codexAppTagModelNames() []string { + client := api.NewClient(envconfig.Host(), http.DefaultClient) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + resp, err := client.List(ctx) + if err != nil { + return nil + } + + models := make([]string, 0, len(resp.Models)) + for _, model := range resp.Models { + name := strings.TrimSpace(model.Name) + if name != "" { + models = append(models, name) + } + } + return models +} + +func codexAppModelContextWindow(ctx context.Context, client *api.Client, model string) int { + resp, err := client.Show(ctx, &api.ShowRequest{Model: model}) + if err != nil { + return 272_000 + } + if n, ok := modelInfoContextLength(resp.ModelInfo); ok { + return n + } + return 272_000 +} + +func codexAppCatalogEntry(model string, contextWindow, priority int, baseInstructions string) map[string]any { + return map[string]any{ + "slug": model, + "display_name": model, + "description": "Ollama local model", + "default_reasoning_level": nil, + "supported_reasoning_levels": []any{}, + "shell_type": "default", + "visibility": "list", + "supported_in_api": true, + "priority": priority, + "additional_speed_tiers": []any{}, + "availability_nux": nil, + "upgrade": nil, + "base_instructions": baseInstructions, + "model_messages": nil, + "supports_reasoning_summaries": false, + "default_reasoning_summary": "auto", + "support_verbosity": false, + "default_verbosity": nil, + "apply_patch_tool_type": nil, + "web_search_tool_type": "text", + "truncation_policy": map[string]any{"mode": "bytes", "limit": 10_000}, + "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, + "context_window": contextWindow, + "max_context_window": contextWindow, + "auto_compact_token_limit": nil, + "effective_context_window_percent": 95, + "experimental_supported_tools": []any{}, + "input_modalities": []string{"text", "image"}, + "supports_search_tool": false, + } +} + +func codexAppBaseInstructions() string { + path, err := codexModelCachePath() + if err == nil { + var cached struct { + Models []struct { + BaseInstructions string `json:"base_instructions"` + } `json:"models"` + } + if data, readErr := os.ReadFile(path); readErr == nil { + if json.Unmarshal(data, &cached) == nil { + for _, model := range cached.Models { + if strings.TrimSpace(model.BaseInstructions) != "" { + return model.BaseInstructions + } + } + } + } + } + return "You are Codex, a coding agent. You and the user share the same workspace and collaborate to achieve the user's goals." +} + +func codexModelCachePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".codex", "models_cache.json"), nil +} + +func codexAppAppPath() string { + var candidates []string + switch codexAppGOOS { + case "darwin": + candidates = codexAppDarwinAppCandidates() + case "windows": + candidates = codexAppWindowsAppCandidates() + default: + return "" + } + for _, candidate := range candidates { + if info, err := codexAppStat(candidate); err == nil { + if codexAppGOOS == "darwin" && !info.IsDir() { + continue + } + if codexAppGOOS == "windows" && info.IsDir() { + continue + } + return candidate + } + } + return "" +} + +func codexAppDarwinAppCandidates() []string { + candidates := []string{"/Applications/Codex.app"} + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(home, "Applications", "Codex.app")) + } + return candidates +} + +func codexAppWindowsAppCandidates() []string { + local, err := codexAppLocalAppData() + if err != nil { + return nil + } + + candidates := []string{ + filepath.Join(local, "Programs", "Codex", "Codex.exe"), + filepath.Join(local, "Programs", "OpenAI Codex", "Codex.exe"), + filepath.Join(local, "Codex", "Codex.exe"), + filepath.Join(local, "OpenAI Codex", "Codex.exe"), + filepath.Join(local, "OpenAI", "Codex", "Codex.exe"), + filepath.Join(local, "openai-codex-electron", "Codex.exe"), + } + for _, pattern := range []string{ + filepath.Join(local, "Programs", "Codex", "app-*", "Codex.exe"), + filepath.Join(local, "Programs", "OpenAI Codex", "app-*", "Codex.exe"), + filepath.Join(local, "Codex", "app-*", "Codex.exe"), + filepath.Join(local, "OpenAI Codex", "app-*", "Codex.exe"), + filepath.Join(local, "OpenAI", "Codex", "app-*", "Codex.exe"), + filepath.Join(local, "openai-codex-electron", "app-*", "Codex.exe"), + } { + matches, _ := codexAppGlob(pattern) + candidates = append(candidates, matches...) + } + return codexAppDedupePaths(candidates) +} + +func codexAppDedupePaths(paths []string) []string { + out := make([]string, 0, len(paths)) + seen := make(map[string]bool, len(paths)) + for _, path := range paths { + if strings.TrimSpace(path) == "" { + continue + } + key := strings.ToLower(path) + if seen[key] { + continue + } + seen[key] = true + out = append(out, path) + } + return out +} + +func codexAppLocalAppData() (string, error) { + if local := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); local != "" { + return local, nil + } + if home := strings.TrimSpace(os.Getenv("USERPROFILE")); home != "" { + return filepath.Join(home, "AppData", "Local"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "AppData", "Local"), nil +} + +func codexAppLaunchOrRestart(prompt string) error { + if !codexAppIsRunning() { + return codexAppOpenApp() + } + restartAppID := "" + restartAppPath := "" + if codexAppGOOS == "windows" { + restartAppID = codexAppStartID() + if restartAppID == "" { + restartAppPath = codexAppRunPath() + } + } + + restart, err := ConfirmPrompt(prompt) + if err != nil { + return err + } + if !restart { + fmt.Fprintln(os.Stderr, "\nQuit and reopen Codex when you're ready for the profile change to take effect.") + return nil + } + + if err := codexAppQuitApp(); err != nil { + return fmt.Errorf("quit Codex: %w", err) + } + if err := waitForCodexAppExit(30 * time.Second); err != nil { + return err + } + if restartAppID != "" { + return codexAppOpenStart(restartAppID) + } + if restartAppPath != "" { + return codexAppOpenPath(restartAppPath) + } + return codexAppOpenApp() +} + +func waitForCodexAppExit(timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if !codexAppIsRunning() { + return nil + } + codexAppSleep(200 * time.Millisecond) + } + return fmt.Errorf("Codex did not quit; quit it manually and re-run the command") +} + +func defaultCodexAppOpenApp() error { + switch codexAppGOOS { + case "windows": + if path := codexAppAppPath(); path != "" { + return codexAppOpenPath(path) + } + if path := codexAppRunPath(); path != "" { + return codexAppOpenPath(path) + } + if appID := codexAppStartID(); appID != "" { + return codexAppOpenStart(appID) + } + return fmt.Errorf("Codex executable was not found; open Codex manually once and re-run 'ollama launch codex-app'") + case "darwin": + if path := codexAppAppPath(); path != "" { + cmd := exec.Command("open", path) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + cmd := exec.Command("open", "-b", codexAppBundleID) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + default: + return codexAppSupported() + } +} + +func defaultCodexAppOpenAppPath(path string) error { + switch codexAppGOOS { + case "windows": + return exec.Command("powershell.exe", "-NoProfile", "-Command", "Start-Process -FilePath "+quotePowerShellString(path)).Run() + case "darwin": + cmd := exec.Command("open", path) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + default: + return codexAppSupported() + } +} + +func defaultCodexAppOpenStartAppID(appID string) error { + return exec.Command("powershell.exe", "-NoProfile", "-Command", "Start-Process "+quotePowerShellString(`shell:AppsFolder\`+appID)).Run() +} + +func defaultCodexAppQuitApp() error { + if codexAppGOOS == "windows" { + script := `Get-Process Codex -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | ForEach-Object { [void]$_.CloseMainWindow() }` + scriptErr := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Run() + codexAppSleep(500 * time.Millisecond) + if err := defaultCodexAppTerminateProcesses(); err != nil { + if scriptErr != nil { + return fmt.Errorf("quit script failed: %v; terminate failed: %w", scriptErr, err) + } + return err + } + return nil + } + + scriptErr := exec.Command("osascript", "-e", `tell application "Codex" to quit`).Run() + if scriptErr != nil { + scriptErr = exec.Command("osascript", "-e", `tell application id "`+codexAppBundleID+`" to quit`).Run() + } + codexAppSleep(500 * time.Millisecond) + if err := defaultCodexAppTerminateProcesses(); err != nil { + if scriptErr != nil { + return fmt.Errorf("quit script failed: %v; terminate failed: %w", scriptErr, err) + } + return err + } + return nil +} + +func defaultCodexAppIsRunning() bool { + switch codexAppGOOS { + case "windows": + return len(codexAppMatchingProcessIDs()) > 0 + case "darwin": + out, err := exec.Command("osascript", "-e", `tell application "System Events" to exists process "Codex"`).Output() + if err == nil && strings.TrimSpace(string(out)) == "true" { + return true + } + return len(codexAppMatchingProcessIDs()) > 0 + default: + return false + } +} + +func defaultCodexAppTerminateProcesses() error { + pids := codexAppMatchingProcessIDs() + if codexAppGOOS == "windows" { + if len(pids) == 0 { + return nil + } + ids := make([]string, 0, len(pids)) + for _, pid := range pids { + ids = append(ids, strconv.Itoa(pid)) + } + script := "Stop-Process -Id " + strings.Join(ids, ",") + " -ErrorAction SilentlyContinue" + return exec.Command("powershell.exe", "-NoProfile", "-Command", script).Run() + } + + var failures []string + for _, pid := range pids { + process, err := os.FindProcess(pid) + if err != nil { + failures = append(failures, fmt.Sprintf("%d: %v", pid, err)) + continue + } + if err := process.Signal(syscall.SIGTERM); err != nil && !strings.Contains(err.Error(), "process already finished") { + failures = append(failures, fmt.Sprintf("%d: %v", pid, err)) + } + } + if len(failures) > 0 { + return fmt.Errorf("%s", strings.Join(failures, "; ")) + } + return nil +} + +func codexAppMatchingProcessIDs() []int { + if codexAppGOOS == "windows" { + return codexAppWindowsMatchingProcessIDs() + } + + out, err := exec.Command("ps", "-axo", "pid=,command=").Output() + if err != nil { + return nil + } + var pids []int + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + pid, err := strconv.Atoi(fields[0]) + if err != nil || pid == os.Getpid() { + continue + } + command := strings.TrimSpace(strings.TrimPrefix(line, fields[0])) + if codexAppProcessMatches(command) { + pids = append(pids, pid) + } + } + return pids +} + +func codexAppWindowsMatchingProcessIDs() []int { + script := fmt.Sprintf(`$current = %d; Get-CimInstance Win32_Process -Filter "Name = 'Codex.exe' OR Name = 'codex.exe'" | Where-Object { $_.ProcessId -ne $current -and ((($_.Name -ieq 'Codex.exe') -and (($null -eq $_.CommandLine) -or ($_.CommandLine -notlike '* --type=*'))) -or (($_.Name -ieq 'codex.exe') -and ($_.CommandLine -like '*app-server*'))) } | Select-Object -ExpandProperty ProcessId`, os.Getpid()) + out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output() + if err != nil { + return nil + } + + var pids []int + for _, line := range strings.Split(string(out), "\n") { + pid, err := strconv.Atoi(strings.TrimSpace(line)) + if err == nil && pid != os.Getpid() { + pids = append(pids, pid) + } + } + return pids +} + +func defaultCodexAppRunningAppPath() string { + if codexAppGOOS != "windows" { + return "" + } + script := `(Get-Process Codex -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 -and $_.Path } | Select-Object -First 1 -ExpandProperty Path)` + out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func defaultCodexAppStartAppID() string { + if codexAppGOOS != "windows" { + return "" + } + script := `(Get-StartApps Codex | Where-Object { $_.Name -eq 'Codex' -or $_.Name -like 'Codex*' } | Select-Object -First 1 -ExpandProperty AppID)` + out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func codexAppProcessMatches(command string) bool { + if strings.Contains(command, `\Codex.exe`) && strings.Contains(command, " --type=") { + return false + } + for _, pattern := range codexAppProcessPatterns() { + if strings.Contains(command, pattern) { + return true + } + } + return false +} + +func codexAppProcessPatterns() []string { + return []string{ + "Codex.app/Contents/MacOS/Codex", + "Codex.app/Contents/Resources/codex app-server", + `\Codex.exe`, + `resources\codex.exe app-server`, + `resources\codex.exe" app-server`, + `resources\codex.exe" "app-server`, + } +} + +func codexNormalizeURL(raw string) string { + return strings.TrimRight(strings.TrimSpace(raw), "/") +} + +type codexAppRestoreState struct { + HadProfile bool `json:"had_profile"` + Profile string `json:"profile,omitempty"` + HadModel bool `json:"had_model"` + Model string `json:"model,omitempty"` + HadModelProvider bool `json:"had_model_provider"` + ModelProvider string `json:"model_provider,omitempty"` + HadModelCatalogJSON bool `json:"had_model_catalog_json"` + ModelCatalogJSON string `json:"model_catalog_json,omitempty"` +} + +func saveCodexAppRestoreState(configPath string) error { + statePath := codexAppRestoreStatePath() + if stateData, err := os.ReadFile(statePath); err == nil { + if codexAppRestoreStateHasRootConfig(stateData) { + return nil + } + configData, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var existing codexAppRestoreState + _ = json.Unmarshal(stateData, &existing) + upgraded := codexAppRestoreStateFromText(string(configData)) + upgraded.HadProfile = existing.HadProfile + upgraded.Profile = existing.Profile + return writeCodexAppRestoreState(upgraded) + } else if !os.IsNotExist(err) { + return err + } + + data, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + return writeCodexAppRestoreState(codexAppRestoreState{}) + } + return err + } + + return writeCodexAppRestoreState(codexAppRestoreStateFromText(string(data))) +} + +func codexAppRestoreStateHasRootConfig(data []byte) bool { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return true + } + _, hasModel := raw["had_model"] + _, hasModelProvider := raw["had_model_provider"] + _, hasModelCatalogJSON := raw["had_model_catalog_json"] + return hasModel && hasModelProvider && hasModelCatalogJSON +} + +func codexAppRestoreStateFromText(text string) codexAppRestoreState { + profile, hadProfile := codexRootStringValueOK(text, "profile") + model, hadModel := codexRootStringValueOK(text, "model") + modelProvider, hadModelProvider := codexRootStringValueOK(text, "model_provider") + modelCatalogJSON, hadModelCatalogJSON := codexRootStringValueOK(text, "model_catalog_json") + return codexAppRestoreState{ + HadProfile: hadProfile, + Profile: profile, + HadModel: hadModel, + Model: model, + HadModelProvider: hadModelProvider, + ModelProvider: modelProvider, + HadModelCatalogJSON: hadModelCatalogJSON, + ModelCatalogJSON: modelCatalogJSON, + } +} + +func codexRestoreRootStringValue(text, key string, hadValue bool, value string) string { + if hadValue { + return codexSetRootStringValue(text, key, value) + } + return codexRemoveRootValue(text, key) +} + +func writeCodexAppRestoreState(state codexAppRestoreState) error { + path := codexAppRestoreStatePath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return fileutil.WriteWithBackup(path, data) +} + +func loadCodexAppRestoreState() (codexAppRestoreState, error) { + data, err := os.ReadFile(codexAppRestoreStatePath()) + if err != nil { + return codexAppRestoreState{}, err + } + var state codexAppRestoreState + if err := json.Unmarshal(data, &state); err != nil { + return codexAppRestoreState{}, err + } + return state, nil +} + +func codexAppRestoreStatePath() string { + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(os.TempDir(), "ollama-codex-app-restore.json") + } + return filepath.Join(home, ".ollama", "launch", "codex-app-restore.json") +} diff --git a/cmd/launch/codex_app_test.go b/cmd/launch/codex_app_test.go new file mode 100644 index 000000000..5d5aed2f8 --- /dev/null +++ b/cmd/launch/codex_app_test.go @@ -0,0 +1,573 @@ +package launch + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func withCodexAppPlatform(t *testing.T, goos string) { + t.Helper() + old := codexAppGOOS + codexAppGOOS = goos + t.Cleanup(func() { + codexAppGOOS = old + }) +} + +func withCodexAppProcessHooks(t *testing.T, isRunning func() bool, quit func() error, open func() error) { + t.Helper() + oldIsRunning := codexAppIsRunning + oldQuit := codexAppQuitApp + oldOpen := codexAppOpenApp + oldOpenPath := codexAppOpenPath + oldOpenStart := codexAppOpenStart + oldRunPath := codexAppRunPath + oldStartID := codexAppStartID + codexAppIsRunning = isRunning + codexAppQuitApp = quit + codexAppOpenApp = open + t.Cleanup(func() { + codexAppIsRunning = oldIsRunning + codexAppQuitApp = oldQuit + codexAppOpenApp = oldOpen + codexAppOpenPath = oldOpenPath + codexAppOpenStart = oldOpenStart + codexAppRunPath = oldRunPath + codexAppStartID = oldStartID + }) +} + +func TestCodexAppIntegration(t *testing.T) { + c := &CodexApp{} + + t.Run("implements runner", func(t *testing.T) { + var _ Runner = c + }) + t.Run("implements supported integration", func(t *testing.T) { + var _ SupportedIntegration = c + }) + t.Run("implements managed single model", func(t *testing.T) { + var _ ManagedSingleModel = c + }) + t.Run("receives model list", func(t *testing.T) { + var _ ManagedModelListConfigurer = c + }) + t.Run("onboarding is noninteractive", func(t *testing.T) { + var _ ManagedInteractiveOnboarding = c + if c.RequiresInteractiveOnboarding() { + t.Fatal("Codex App onboarding should only mark launch config") + } + }) + t.Run("implements restore", func(t *testing.T) { + var _ RestorableIntegration = c + var _ RestoreHintIntegration = c + var _ ConfigurationSuccessIntegration = c + var _ RestoreSuccessIntegration = c + }) +} + +func TestCodexAppSupportedPlatforms(t *testing.T) { + for _, goos := range []string{"darwin", "windows"} { + t.Run(goos, func(t *testing.T) { + withCodexAppPlatform(t, goos) + if err := codexAppSupported(); err != nil { + t.Fatalf("codexAppSupported returned error: %v", err) + } + }) + } + + t.Run("linux unsupported", func(t *testing.T) { + withCodexAppPlatform(t, "linux") + err := codexAppSupported() + if err == nil || !strings.Contains(err.Error(), "macOS and Windows") { + t.Fatalf("codexAppSupported error = %v, want platform message", err) + } + }) +} + +func TestCodexAppWindowsAppPathCandidates(t *testing.T) { + withCodexAppPlatform(t, "windows") + local := filepath.Join(t.TempDir(), "LocalAppData") + t.Setenv("LOCALAPPDATA", local) + + exe := filepath.Join(local, "Codex", "app-26.429.30905", "Codex.exe") + if err := os.MkdirAll(filepath.Dir(exe), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(exe, []byte{}, 0o644); err != nil { + t.Fatal(err) + } + + if got := codexAppAppPath(); got != exe { + t.Fatalf("codexAppAppPath = %q, want %q", got, exe) + } +} + +func TestCodexAppInstalledUsesWindowsStartMenuFallback(t *testing.T) { + withCodexAppPlatform(t, "windows") + t.Setenv("LOCALAPPDATA", filepath.Join(t.TempDir(), "LocalAppData")) + + oldStartID := codexAppStartID + oldIsRunning := codexAppIsRunning + codexAppStartID = func() string { return "OpenAI.Codex_12345!App" } + codexAppIsRunning = func() bool { return false } + t.Cleanup(func() { + codexAppStartID = oldStartID + codexAppIsRunning = oldIsRunning + }) + + if !codexAppInstalled() { + t.Fatal("expected Windows Start menu app id to count as installed") + } +} + +func TestCodexAppConfigureActivatesOllamaProfile(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999") + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + existing := "" + + "profile = \"default\"\n" + + "model = \"gpt-5.5\"\n\n" + + "[profiles.default]\n" + + "model = \"gpt-5.5\"\n" + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + c := &CodexApp{} + if err := c.ConfigureWithModels("llama3.2", []string{"llama3.2", "qwen3:8b"}); err != nil { + t.Fatalf("ConfigureWithModels returned error: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + content := string(data) + catalogPath, err := codexAppModelCatalogPath() + if err != nil { + t.Fatal(err) + } + + for _, want := range []string{ + `profile = "ollama-launch"`, + `model = "llama3.2"`, + `model_provider = "ollama-launch"`, + fmt.Sprintf(`model_catalog_json = %q`, catalogPath), + `[profiles.ollama-launch]`, + `model = "llama3.2"`, + `openai_base_url = "http://127.0.0.1:9999/v1/"`, + `model_provider = "ollama-launch"`, + `model_catalog_json = "`, + `[model_providers.ollama-launch]`, + `name = "Ollama"`, + `base_url = "http://127.0.0.1:9999/v1/"`, + `wire_api = "responses"`, + `[profiles.default]`, + } { + if !strings.Contains(content, want) { + t.Fatalf("expected config to contain %q, got:\n%s", want, content) + } + } + if got := c.CurrentModel(); got != "llama3.2" { + t.Fatalf("CurrentModel = %q, want llama3.2", got) + } + + restoreData, err := os.ReadFile(codexAppRestoreStatePath()) + if err != nil { + t.Fatalf("expected restore state: %v", err) + } + if !strings.Contains(string(restoreData), `"profile": "default"`) { + t.Fatalf("expected restore state to remember default profile, got %s", restoreData) + } + catalogData, err := os.ReadFile(catalogPath) + if err != nil { + t.Fatalf("expected model catalog: %v", err) + } + var catalog struct { + Models []map[string]any `json:"models"` + } + if err := json.Unmarshal(catalogData, &catalog); err != nil { + t.Fatalf("catalog should be valid JSON: %v", err) + } + if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "llama3.2,qwen3:8b" { + t.Fatalf("catalog slugs = %v, want fallback models", got) + } +} + +func TestCodexAppCurrentModelRequiresManagedActiveProfile(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + content := "" + + "profile = \"default\"\n\n" + + "[profiles.ollama-launch]\n" + + "model = \"llama3.2\"\n" + + "model_provider = \"ollama-launch\"\n\n" + + "[model_providers.ollama-launch]\n" + + "base_url = \"http://127.0.0.1:11434/v1/\"\n" + if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + if got := (&CodexApp{}).CurrentModel(); got != "" { + t.Fatalf("CurrentModel = %q, want empty when active profile is not managed", got) + } +} + +func TestCodexAppCurrentModelReadsManagedRootConfig(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + content := "" + + `model = "qwen3:8b"` + "\n" + + `model_provider = "ollama-launch"` + "\n\n" + + "[model_providers.ollama-launch]\n" + + `base_url = "http://127.0.0.1:11434/v1/"` + "\n" + if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + if got := (&CodexApp{}).CurrentModel(); got != "qwen3:8b" { + t.Fatalf("CurrentModel = %q, want qwen3:8b", got) + } +} + +func TestCodexAppConfigurePopulatesCatalogFromTagsAndShow(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + showCalls := make(map[string]int) + 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:8b"},{"name":"llama3.2"}]}`) + case "/api/show": + var req struct { + Model string `json:"model"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode show request: %v", err) + } + showCalls[req.Model]++ + fmt.Fprintf(w, `{"model_info":{"general.context_length":%d}}`, 65536+len(req.Model)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + if err := (&CodexApp{}).ConfigureWithModels("gemma4", []string{"fallback"}); err != nil { + t.Fatalf("ConfigureWithModels returned error: %v", err) + } + + catalogPath, err := codexAppModelCatalogPath() + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(catalogPath) + if err != nil { + t.Fatal(err) + } + var catalog struct { + Models []map[string]any `json:"models"` + } + if err := json.Unmarshal(data, &catalog); err != nil { + t.Fatalf("catalog should be valid JSON: %v", err) + } + + if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "gemma4,qwen3:8b,llama3.2" { + t.Fatalf("catalog slugs = %v, want /api/tags models", got) + } + for _, model := range catalog.Models { + slug, _ := model["slug"].(string) + if model["display_name"] != slug { + t.Fatalf("display_name should match slug for %q: %v", slug, model["display_name"]) + } + if model["visibility"] != "list" { + t.Fatalf("visibility for %q = %v, want list", slug, model["visibility"]) + } + if model["default_reasoning_level"] != nil { + t.Fatalf("default_reasoning_level for %q = %v, want nil", slug, model["default_reasoning_level"]) + } + levels, ok := model["supported_reasoning_levels"].([]any) + if !ok || len(levels) != 0 { + t.Fatalf("supported_reasoning_levels for %q = %v, want empty list", slug, model["supported_reasoning_levels"]) + } + if model["context_window"] != float64(65536+len(slug)) { + t.Fatalf("context_window for %q = %v", slug, model["context_window"]) + } + if showCalls[slug] != 1 { + t.Fatalf("show calls for %q = %d, want 1", slug, showCalls[slug]) + } + } +} + +func TestCodexAppConfigureUpgradesLegacyRestoreState(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999") + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + existing := "" + + `model = "gpt-5.5"` + "\n" + + `model_provider = "odc-resp-dev"` + "\n\n" + + "[model_providers.odc-resp-dev]\n" + + `base_url = "https://example.invalid/v1/"` + "\n" + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(codexAppRestoreStatePath(), []byte(`{"had_profile":false}`), 0o644); err != nil { + t.Fatal(err) + } + + if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil { + t.Fatalf("ConfigureWithModels returned error: %v", err) + } + + state, err := loadCodexAppRestoreState() + if err != nil { + t.Fatal(err) + } + if state.HadProfile { + t.Fatalf("HadProfile = true, want legacy false") + } + if !state.HadModel || state.Model != "gpt-5.5" { + t.Fatalf("model restore state = (%v, %q), want previous root model", state.HadModel, state.Model) + } + if !state.HadModelProvider || state.ModelProvider != "odc-resp-dev" { + t.Fatalf("model provider restore state = (%v, %q), want previous root provider", state.HadModelProvider, state.ModelProvider) + } +} + +func TestCodexAppRestoreRestoresPreviousProfile(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withCodexAppPlatform(t, "darwin") + + var openCalls int + withCodexAppProcessHooks(t, + func() bool { return false }, + func() error { return nil }, + func() error { + openCalls++ + return nil + }, + ) + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + existing := "" + + "profile = \"default\"\n" + + "model = \"gpt-5.5\"\n" + + "model_provider = \"openai\"\n" + + "model_catalog_json = \"/tmp/original-catalog.json\"\n\n" + + "[profiles.default]\n" + + "model = \"gpt-5.5\"\n" + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + c := &CodexApp{} + if err := c.ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil { + t.Fatalf("ConfigureWithModels returned error: %v", err) + } + if err := c.Restore(); err != nil { + t.Fatalf("Restore returned error: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `profile = "default"`) || strings.Contains(string(data), `profile = "ollama-launch"`) { + t.Fatalf("restore should restore previous active profile, got:\n%s", data) + } + restored := string(data) + for key, want := range map[string]string{ + "profile": "default", + "model": "gpt-5.5", + "model_provider": "openai", + "model_catalog_json": "/tmp/original-catalog.json", + } { + if got := codexRootStringValue(restored, key); got != want { + t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, restored) + } + } + if openCalls != 1 { + t.Fatalf("open calls = %d, want 1", openCalls) + } + if _, err := os.Stat(codexAppRestoreStatePath()); !os.IsNotExist(err) { + t.Fatalf("restore state should be removed, got err=%v", err) + } +} + +func TestCodexAppRunRestartsRunningAppWhenConfirmed(t *testing.T) { + withCodexAppPlatform(t, "darwin") + restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) + defer restoreConfirm() + + running := true + var quitCalls, openCalls int + withCodexAppProcessHooks(t, + func() bool { return running }, + func() error { + quitCalls++ + running = false + return nil + }, + func() error { + openCalls++ + return nil + }, + ) + + if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if quitCalls != 1 || openCalls != 1 { + t.Fatalf("quit/open calls = %d/%d, want 1/1", quitCalls, openCalls) + } +} + +func TestCodexAppRunOpensOnWindowsWhenNotRunning(t *testing.T) { + withCodexAppPlatform(t, "windows") + + var openCalls int + withCodexAppProcessHooks(t, + func() bool { return false }, + func() error { return nil }, + func() error { + openCalls++ + return nil + }, + ) + + if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if openCalls != 1 { + t.Fatalf("open calls = %d, want 1", openCalls) + } +} + +func TestCodexAppRunRestartsWindowsStartAppID(t *testing.T) { + withCodexAppPlatform(t, "windows") + restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) + defer restoreConfirm() + + running := true + var quitCalls int + withCodexAppProcessHooks(t, + func() bool { return running }, + func() error { + quitCalls++ + running = false + return nil + }, + func() error { + t.Fatal("open app fallback should not be used") + return nil + }, + ) + + codexAppStartID = func() string { return "OpenAI.Codex_2p2nqsd0c76g0!App" } + codexAppRunPath = func() string { + return `C:\Program Files\WindowsApps\OpenAI.Codex_26.429.8261.0_x64__2p2nqsd0c76g0\app\Codex.exe` + } + var openedStartID, openedPath string + codexAppOpenStart = func(appID string) error { + openedStartID = appID + return nil + } + codexAppOpenPath = func(path string) error { + openedPath = path + return nil + } + + if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if quitCalls != 1 { + t.Fatalf("quit calls = %d, want 1", quitCalls) + } + if openedStartID != "OpenAI.Codex_2p2nqsd0c76g0!App" { + t.Fatalf("opened Start AppID = %q", openedStartID) + } + if openedPath != "" { + t.Fatalf("opened path = %q, want Start AppID path only", openedPath) + } +} + +func TestCodexAppRunRejectsExtraArgs(t *testing.T) { + withCodexAppPlatform(t, "darwin") + err := (&CodexApp{}).Run("qwen3.5", []string{"--foo"}) + if err == nil || !strings.Contains(err.Error(), "does not accept extra arguments") { + t.Fatalf("Run error = %v, want extra args rejection", err) + } +} + +func TestCodexAppProcessMatchesMainAndAppServer(t *testing.T) { + for _, command := range []string{ + "/Applications/Codex.app/Contents/MacOS/Codex", + "/Applications/Codex.app/Contents/Resources/codex app-server --analytics-default-enabled", + `C:\Users\parth\AppData\Local\Programs\Codex\Codex.exe`, + `"C:\Users\parth\AppData\Local\Codex\app-26.429.30905\resources\codex.exe" app-server --analytics-default-enabled`, + `"C:\Users\parth\AppData\Local\openai-codex-electron\resources\codex.exe" "app-server"`, + } { + if !codexAppProcessMatches(command) { + t.Fatalf("expected command to match Codex App process: %s", command) + } + } + + for _, command := range []string{ + "/Applications/Codex.app/Contents/Frameworks/Codex Helper.app/Contents/MacOS/Codex Helper", + "/Applications/Codex.app/Contents/Frameworks/Electron Framework.framework/Helpers/chrome_crashpad_handler", + `"C:\Program Files\WindowsApps\OpenAI.Codex_26.429.8261.0_x64__2p2nqsd0c76g0\app\Codex.exe" --type=renderer --user-data-dir="C:\Users\parth\AppData\Roaming\Codex"`, + `"C:\Program Files\WindowsApps\OpenAI.Codex_26.429.8261.0_x64__2p2nqsd0c76g0\app\Codex.exe" --type=crashpad-handler`, + } { + if codexAppProcessMatches(command) { + t.Fatalf("expected helper command not to match Codex App process: %s", command) + } + } +} + +func catalogSlugs(models []map[string]any) []string { + slugs := make([]string, 0, len(models)) + for _, model := range models { + if slug, _ := model["slug"].(string); slug != "" { + slugs = append(slugs, slug) + } + } + return slugs +} diff --git a/cmd/launch/codex_test.go b/cmd/launch/codex_test.go index c19e24d1e..824e15bbd 100644 --- a/cmd/launch/codex_test.go +++ b/cmd/launch/codex_test.go @@ -69,6 +69,9 @@ func TestWriteCodexProfile(t *testing.T) { if !strings.Contains(content, `name = "Ollama"`) { t.Error("missing model provider name") } + if err := codexValidateConfigText(content); err != nil { + t.Fatalf("generated config should be valid TOML: %v\n%s", err, content) + } }) t.Run("appends profile to existing file without profile", func(t *testing.T) { @@ -114,6 +117,103 @@ func TestWriteCodexProfile(t *testing.T) { if strings.Count(content, "[model_providers.ollama-launch]") != 1 { t.Errorf("expected exactly one [model_providers.ollama-launch] section, got %d", strings.Count(content, "[model_providers.ollama-launch]")) } + if err := codexValidateConfigText(content); err != nil { + t.Fatalf("generated config should be valid TOML: %v\n%s", err, content) + } + }) + + t.Run("replaces equivalent quoted profile table", func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.toml") + existing := "" + + `profile = "default"` + "\n\n" + + `[profiles."ollama-launch"]` + "\n" + + `openai_base_url = "http://old:1234/v1/"` + "\n\n" + + `[model_providers."ollama-launch"]` + "\n" + + `name = "Old"` + "\n" + + `base_url = "http://old:1234/v1/"` + "\n\n" + + `[profiles.default]` + "\n" + + `model = "gpt-5.5"` + "\n" + os.WriteFile(configPath, []byte(existing), 0o644) + + if err := writeCodexProfile(configPath); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(configPath) + content := string(data) + + if strings.Contains(content, `profiles."ollama-launch"`) { + t.Fatalf("quoted profile table should be replaced, got:\n%s", content) + } + if strings.Contains(content, "old:1234") { + t.Fatalf("old URL was not replaced, got:\n%s", content) + } + if got := codexSectionStringValue(content, codexProfileHeader(), "model_provider"); got != codexProfileName { + t.Fatalf("profile model_provider = %q, want %q", got, codexProfileName) + } + if got := codexSectionStringValue(content, codexProviderHeader(), "base_url"); !strings.Contains(got, "/v1/") { + t.Fatalf("provider base_url = %q, want /v1/ URL", got) + } + if err := codexValidateConfigText(content); err != nil { + t.Fatalf("generated config should be valid TOML: %v\n%s", err, content) + } + }) + + t.Run("rejects invalid existing toml without writing", func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.toml") + existing := "profile = \n" + os.WriteFile(configPath, []byte(existing), 0o644) + + err := writeCodexProfile(configPath) + if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") { + t.Fatalf("writeCodexProfile error = %v, want invalid TOML", err) + } + + data, _ := os.ReadFile(configPath) + if string(data) != existing { + t.Fatalf("invalid config should be left untouched, got:\n%s", data) + } + }) + + t.Run("updates equivalent quoted root keys", func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.toml") + existing := "" + + `"profile" = "default"` + "\n" + + `"model" = "gpt-5.5"` + "\n" + + `"model_provider" = "openai"` + "\n\n" + + `[profiles.default]` + "\n" + + `model = "gpt-5.5"` + "\n" + os.WriteFile(configPath, []byte(existing), 0o644) + + err := writeCodexLaunchProfile(configPath, codexLaunchProfileOptions{ + activate: true, + setRootModelConfig: true, + model: "llama3.2", + }) + if err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(configPath) + content := string(data) + for key, want := range map[string]string{ + "profile": codexProfileName, + "model": "llama3.2", + "model_provider": codexProfileName, + } { + if got := codexRootStringValue(content, key); got != want { + t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, content) + } + } + if strings.Contains(content, `"profile"`) || strings.Contains(content, `"model_provider"`) { + t.Fatalf("quoted root keys should be rewritten once, got:\n%s", content) + } + if err := codexValidateConfigText(content); err != nil { + t.Fatalf("generated config should be valid TOML: %v\n%s", err, content) + } }) t.Run("replaces profile while preserving following sections", func(t *testing.T) { diff --git a/cmd/launch/integrations_test.go b/cmd/launch/integrations_test.go index da406b4c5..5b0a9dcfb 100644 --- a/cmd/launch/integrations_test.go +++ b/cmd/launch/integrations_test.go @@ -58,6 +58,9 @@ func TestIntegrationLookup(t *testing.T) { {"claude desktop", "claude-desktop", true, "Claude Desktop"}, {"claude desktop alias", "claude-app", true, "Claude Desktop"}, {"codex", "codex", true, "Codex"}, + {"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"}, {"kimi", "kimi", true, "Kimi Code CLI"}, {"droid", "droid", true, "Droid"}, {"opencode", "opencode", true, "OpenCode"}, @@ -80,7 +83,7 @@ func TestIntegrationLookup(t *testing.T) { } func TestIntegrationRegistry(t *testing.T) { - expectedIntegrations := []string{"claude", "claude-desktop", "codex", "kimi", "droid", "opencode", "hermes", "pool"} + expectedIntegrations := []string{"claude", "claude-desktop", "codex", "codex-app", "kimi", "droid", "opencode", "hermes", "pool"} for _, name := range expectedIntegrations { t.Run(name, func(t *testing.T) { r, ok := integrations[name] @@ -1813,6 +1816,16 @@ func TestListIntegrationInfos(t *testing.T) { } want = filtered } + if codexAppSupported() != nil { + filtered := make([]string, 0, len(want)) + for _, name := range want { + if name != "codex-app" { + filtered = append(filtered, name) + } + } + want = filtered + } + if diff := compareStrings(got, want); diff != "" { t.Fatalf("launcher integration order mismatch: %s", diff) } @@ -1831,6 +1844,9 @@ func TestListIntegrationInfos(t *testing.T) { t.Run("includes known integrations", func(t *testing.T) { known := map[string]bool{"claude": false, "codex": false, "opencode": false} + if codexAppSupported() == nil { + known["codex-app"] = false + } if poolsideGOOS != "windows" { known["pool"] = false } diff --git a/cmd/launch/launch.go b/cmd/launch/launch.go index 39ce0c4ce..ade6174a3 100644 --- a/cmd/launch/launch.go +++ b/cmd/launch/launch.go @@ -287,6 +287,7 @@ Supported integrations: claude Claude Code cline Cline codex Codex + codex-app Codex App (aliases: codex-desktop, codex-gui) copilot Copilot CLI (aliases: copilot-cli) droid Droid hermes Hermes Agent @@ -301,6 +302,8 @@ Examples: ollama launch ollama launch claude ollama launch claude --model + ollama launch codex-app + ollama launch codex-app --restore ollama launch hermes ollama launch droid --config (does not auto-launch) ollama launch codex -- -p myprofile (pass extra args to integration) @@ -769,7 +772,7 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam return nil } - if needsConfigure || req.ModelOverride != "" || (current != "" && target != current) || !savedMatchesModels(saved, []string{target}) { + if needsConfigure || req.ModelOverride != "" || target != current || !savedMatchesModels(saved, []string{target}) { configureModels, err := c.managedSingleConfigureModels(ctx, managed, target) if err != nil { return err @@ -941,7 +944,7 @@ func (c *launcherClient) resolveSingleIntegrationTarget(ctx context.Context, run } } - if needsConfigure { + if needsConfigure && req.ModelOverride == "" { selected, err := c.selectSingleModelWithSelectorReady(ctx, fmt.Sprintf("Select model for %s:", runner), target, DefaultSingleSelector, !skipReadiness) if err != nil { return "", false, err diff --git a/cmd/launch/launch_test.go b/cmd/launch/launch_test.go index 8333c89ca..3d0a8ba2e 100644 --- a/cmd/launch/launch_test.go +++ b/cmd/launch/launch_test.go @@ -480,6 +480,38 @@ func TestLaunchIntegration_ManagedSingleIntegrationConfigOnlySkipsFinalRun(t *te } } +func TestLaunchIntegration_ManagedSingleIntegrationForceConfigureUsesModelOverride(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, true) + withLauncherHooks(t) + + runner := &launcherManagedRunner{ + paths: nil, + } + withIntegrationOverride(t, "stubmanaged", runner) + + DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) { + return "", fmt.Errorf("selector should not run with an explicit model override") + } + + if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ + Name: "stubmanaged", + ModelOverride: "gemma4", + ForceConfigure: true, + ConfigureOnly: true, + }); 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.ranModel != "" { + t.Fatalf("expected configure-only flow to skip final launch, got %q", runner.ranModel) + } +} + func TestLaunchIntegration_ManagedSingleIntegrationSkipsRewriteWhenSavedMatches(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) @@ -505,7 +537,9 @@ func TestLaunchIntegration_ManagedSingleIntegrationSkipsRewriteWhenSavedMatches( t.Fatalf("failed to save managed integration config: %v", err) } - runner := &launcherManagedRunner{} + runner := &launcherManagedRunner{ + currentModel: "gemma4", + } withIntegrationOverride(t, "stubmanaged", runner) DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) { @@ -532,6 +566,53 @@ func TestLaunchIntegration_ManagedSingleIntegrationSkipsRewriteWhenSavedMatches( } } +func TestLaunchIntegration_ManagedSingleIntegrationRewritesWhenSavedMatchesButLiveConfigMissing(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 []SelectionItem, current string) (string, error) { + t.Fatal("selector should not be called when saved model is usable") + 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 Configure to rewrite missing live config: %s", diff) + } + if runner.refreshCalls != 1 { + t.Fatalf("expected runtime refresh once after rewrite, got %d", runner.refreshCalls) + } + if runner.ranModel != "gemma4" { + t.Fatalf("expected launch to run saved model, got %q", runner.ranModel) + } +} + func TestLaunchIntegration_ManagedSingleIntegrationRewritesWhenSavedDiffers(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) diff --git a/cmd/launch/registry.go b/cmd/launch/registry.go index 0d9407e4d..666479b6f 100644 --- a/cmd/launch/registry.go +++ b/cmd/launch/registry.go @@ -33,7 +33,7 @@ type IntegrationInfo struct { Description string } -var launcherIntegrationOrder = []string{"claude", "openclaw", "hermes", "opencode", "codex", "copilot", "droid", "pi", "pool"} +var launcherIntegrationOrder = []string{"claude", "openclaw", "hermes", "codex-app", "opencode", "codex", "copilot", "droid", "pi", "pool"} var integrationSpecs = []*IntegrationSpec{ { @@ -87,6 +87,18 @@ var integrationSpecs = []*IntegrationSpec{ Command: []string{"npm", "install", "-g", "@openai/codex"}, }, }, + { + Name: "codex-app", + Runner: &CodexApp{}, + Aliases: []string{"codex-desktop", "codex-gui"}, + Description: "OpenAI's desktop coding agent", + Install: IntegrationInstallSpec{ + CheckInstalled: func() bool { + return codexAppInstalled() + }, + URL: "https://developers.openai.com/codex/app/overview/", + }, + }, { Name: "kimi", Runner: &Kimi{}, diff --git a/docs/docs.json b/docs/docs.json index 79db465d6..32eb68120 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -124,6 +124,7 @@ "pages": [ "/integrations/claude-code", "/integrations/codex", + "/integrations/codex-app", "/integrations/copilot-cli", "/integrations/opencode", "/integrations/droid", diff --git a/docs/integrations/codex-app.mdx b/docs/integrations/codex-app.mdx new file mode 100644 index 000000000..dc7372309 --- /dev/null +++ b/docs/integrations/codex-app.mdx @@ -0,0 +1,41 @@ +--- +title: Codex App +--- + +## Install + +Install the [Codex App](https://developers.openai.com/codex/app/overview/) for macOS or Windows. + +## Usage with Ollama + +Codex works best with a larger context window. It is recommended to use a context window of at least 64k tokens. + +### Quick setup + +```shell +ollama launch codex-app +``` + +To choose a model: + +```shell +ollama launch codex-app --model gpt-oss:120b +``` + +To configure without launching: + +```shell +ollama launch codex-app --config +``` + +To restore your usual Codex profile: + +```shell +ollama launch codex-app --restore +``` + +## How it works + +`ollama launch codex-app` writes an `ollama-launch` profile to `~/.codex/config.toml`, sets it as the active Codex profile, and points Codex App at Ollama's OpenAI-compatible `/v1` endpoint. On Windows, `~` resolves to your user profile directory. + +It also writes `~/.codex/ollama-launch-models.json` from the models returned by Ollama's `/api/tags` endpoint, then sets `model_catalog_json` on both the top-level app config and the managed profile so Codex App can show the Ollama model names in its picker. diff --git a/docs/integrations/codex.mdx b/docs/integrations/codex.mdx index 1888809b5..3bde7aa7f 100644 --- a/docs/integrations/codex.mdx +++ b/docs/integrations/codex.mdx @@ -1,11 +1,11 @@ --- -title: Codex +title: Codex CLI --- ## Install -Install the [Codex CLI](https://developers.openai.com/codex/cli/): +Install the [Codex CLI](https://developers.openai.com/codex/cli/). For the desktop app, see [Codex App](/integrations/codex-app). ``` npm install -g @openai/codex diff --git a/docs/integrations/index.mdx b/docs/integrations/index.mdx index 83f2417d0..00a550643 100644 --- a/docs/integrations/index.mdx +++ b/docs/integrations/index.mdx @@ -9,7 +9,8 @@ Ollama integrates with a wide range of tools. Coding assistants that can read, modify, and execute code in your projects. - [Claude Code](/integrations/claude-code) -- [Codex](/integrations/codex) +- [Codex CLI](/integrations/codex) +- [Codex App](/integrations/codex-app) - [Copilot CLI](/integrations/copilot-cli) - [OpenCode](/integrations/opencode) - [Droid](/integrations/droid) diff --git a/go.mod b/go.mod index ce433674b..d5dd28ed1 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/mattn/go-runewidth v0.0.16 github.com/nlpodyssey/gopickle v0.3.0 github.com/pdevine/tensor v0.0.0-20240510204454-f88f4562727c + github.com/pelletier/go-toml/v2 v2.2.2 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/tkrajina/typescriptify-golang-structs v0.2.0 github.com/tree-sitter/go-tree-sitter v0.25.0 @@ -95,7 +96,6 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect From c45fef913fdb977b8bbe1dfc2e647b73e4fddced Mon Sep 17 00:00:00 2001 From: ParthSareen Date: Thu, 7 May 2026 15:22:14 -0700 Subject: [PATCH 2/4] fix(cmd): harden codex app config writes --- cmd/launch/codex.go | 115 +++++++++++-- cmd/launch/codex_app.go | 186 +++++++++++++++----- cmd/launch/codex_app_test.go | 322 +++++++++++++++++++++++++++++++++-- cmd/launch/codex_test.go | 67 ++++++++ 4 files changed, 622 insertions(+), 68 deletions(-) diff --git a/cmd/launch/codex.go b/cmd/launch/codex.go index 114159177..28b9b5ac2 100644 --- a/cmd/launch/codex.go +++ b/cmd/launch/codex.go @@ -81,14 +81,19 @@ func writeCodexProfile(configPath string) error { type codexLaunchProfileOptions struct { activate bool + profileName string forceAPIAuth bool setRootModelConfig bool model string modelCatalogPath string + backupIntegration string } func writeCodexLaunchProfile(configPath string, opts codexLaunchProfileOptions) error { baseURL := codexBaseURL() + profileName := codexLaunchProfileName(opts) + profileHeader := codexProfileHeaderFor(profileName) + providerHeader := codexProviderHeaderFor(profileName) content, readErr := os.ReadFile(configPath) text := "" @@ -103,11 +108,11 @@ func writeCodexLaunchProfile(configPath string, opts codexLaunchProfileOptions) model := strings.TrimSpace(opts.model) if model == "" { - model = codexSectionStringValue(text, codexProfileHeader(), "model") + model = codexSectionStringValue(text, profileHeader, "model") } modelCatalogPath := strings.TrimSpace(opts.modelCatalogPath) if modelCatalogPath == "" { - modelCatalogPath = codexSectionStringValue(text, codexProfileHeader(), "model_catalog_json") + modelCatalogPath = codexSectionStringValue(text, profileHeader, "model_catalog_json") } profileLines := []string{} @@ -116,7 +121,7 @@ func writeCodexLaunchProfile(configPath string, opts codexLaunchProfileOptions) } profileLines = append(profileLines, fmt.Sprintf("openai_base_url = %q", baseURL), - fmt.Sprintf("model_provider = %q", codexProfileName), + fmt.Sprintf("model_provider = %q", profileName), ) if opts.forceAPIAuth { profileLines = append(profileLines, `forced_login_method = "api"`) @@ -130,11 +135,11 @@ func writeCodexLaunchProfile(configPath string, opts codexLaunchProfileOptions) lines []string }{ { - header: codexProfileHeader(), + header: profileHeader, lines: profileLines, }, { - header: codexProviderHeader(), + header: providerHeader, lines: []string{ fmt.Sprintf("name = %q", codexProviderName), fmt.Sprintf("base_url = %q", baseURL), @@ -144,13 +149,13 @@ func writeCodexLaunchProfile(configPath string, opts codexLaunchProfileOptions) } if opts.activate { - text = codexSetRootStringValue(text, "profile", codexProfileName) + text = codexSetRootStringValue(text, "profile", profileName) } if opts.setRootModelConfig { if model != "" { text = codexSetRootStringValue(text, "model", model) } - text = codexSetRootStringValue(text, "model_provider", codexProfileName) + text = codexSetRootStringValue(text, "model_provider", profileName) if modelCatalogPath != "" { text = codexSetRootStringValue(text, "model_catalog_json", modelCatalogPath) } @@ -162,11 +167,21 @@ func writeCodexLaunchProfile(configPath string, opts codexLaunchProfileOptions) if err := codexValidateConfigText(text); err != nil { return err } + if err := codexValidateLaunchProfileText(text, profileName, opts, model, modelCatalogPath, baseURL); err != nil { + return err + } if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { return err } - return fileutil.WriteWithBackup(configPath, []byte(text)) + return codexWriteWithBackup(configPath, []byte(text), opts.backupIntegration) +} + +func codexLaunchProfileName(opts codexLaunchProfileOptions) string { + if name := strings.TrimSpace(opts.profileName); name != "" { + return name + } + return codexProfileName } func codexBaseURL() string { @@ -174,11 +189,79 @@ func codexBaseURL() string { } func codexProfileHeader() string { - return fmt.Sprintf("[profiles.%s]", codexProfileName) + return codexProfileHeaderFor(codexProfileName) } func codexProviderHeader() string { - return fmt.Sprintf("[model_providers.%s]", codexProfileName) + return codexProviderHeaderFor(codexProfileName) +} + +func codexProfileHeaderFor(profileName string) string { + return fmt.Sprintf("[profiles.%s]", profileName) +} + +func codexProviderHeaderFor(profileName string) string { + return fmt.Sprintf("[model_providers.%s]", profileName) +} + +func codexValidateLaunchProfileText(text, profileName string, opts codexLaunchProfileOptions, model, modelCatalogPath, baseURL string) error { + for _, check := range []struct { + path []string + want string + }{ + {[]string{"profiles", profileName, "openai_base_url"}, baseURL}, + {[]string{"profiles", profileName, "model_provider"}, profileName}, + {[]string{"model_providers", profileName, "name"}, codexProviderName}, + {[]string{"model_providers", profileName, "base_url"}, baseURL}, + {[]string{"model_providers", profileName, "wire_api"}, "responses"}, + } { + if got, ok := codexStringValue(text, check.path...); !ok || got != check.want { + return fmt.Errorf("generated Codex config missing %s = %q", strings.Join(check.path, "."), check.want) + } + } + if opts.forceAPIAuth { + if got, ok := codexStringValue(text, "profiles", profileName, "forced_login_method"); !ok || got != "api" { + return fmt.Errorf("generated Codex config missing profiles.%s.forced_login_method = %q", profileName, "api") + } + } + if model != "" { + if got, ok := codexStringValue(text, "profiles", profileName, "model"); !ok || got != model { + return fmt.Errorf("generated Codex config missing profiles.%s.model = %q", profileName, model) + } + } + if modelCatalogPath != "" { + if got, ok := codexStringValue(text, "profiles", profileName, "model_catalog_json"); !ok || got != modelCatalogPath { + return fmt.Errorf("generated Codex config missing profiles.%s.model_catalog_json = %q", profileName, modelCatalogPath) + } + } + if opts.activate { + if got := codexRootStringValue(text, "profile"); got != profileName { + return fmt.Errorf("generated Codex config missing profile = %q", profileName) + } + } + if opts.setRootModelConfig { + if model != "" { + if got := codexRootStringValue(text, "model"); got != model { + return fmt.Errorf("generated Codex config missing model = %q", model) + } + } + if got := codexRootStringValue(text, "model_provider"); got != profileName { + return fmt.Errorf("generated Codex config missing model_provider = %q", profileName) + } + if modelCatalogPath != "" { + if got := codexRootStringValue(text, "model_catalog_json"); got != modelCatalogPath { + return fmt.Errorf("generated Codex config missing model_catalog_json = %q", modelCatalogPath) + } + } + } + return nil +} + +func codexWriteWithBackup(path string, data []byte, integration string) error { + if strings.TrimSpace(integration) != "" { + return fileutil.WriteWithBackup(path, data, integration) + } + return fileutil.WriteWithBackup(path, data) } func codexUpsertSection(text, header string, lines []string) string { @@ -199,6 +282,18 @@ func codexUpsertSection(text, header string, lines []string) string { return text + block } +func codexRemoveSection(text, header string) string { + targetPath, ok := codexTableHeaderPath(header) + if !ok { + return text + } + start, end, found := codexSectionRange(text, targetPath) + if !found { + return text + } + return text[:start] + text[end:] +} + func codexRootStringValue(text, key string) string { value, _ := codexStringValue(text, key) return value diff --git a/cmd/launch/codex_app.go b/cmd/launch/codex_app.go index c4f377fc0..d08ca7e3c 100644 --- a/cmd/launch/codex_app.go +++ b/cmd/launch/codex_app.go @@ -16,12 +16,12 @@ import ( "github.com/ollama/ollama/api" "github.com/ollama/ollama/cmd/config" - "github.com/ollama/ollama/cmd/internal/fileutil" "github.com/ollama/ollama/envconfig" ) const ( codexAppIntegrationName = "codex-app" + codexAppProfileName = "ollama-launch-codex-app" codexAppBundleID = "com.openai.codex" codexAppModelCatalogFilename = "ollama-launch-models.json" codexAppRestoreHint = "To restore your usual Codex profile, run: ollama launch codex-app --restore" @@ -85,9 +85,11 @@ func (c *CodexApp) ConfigureWithModels(primary string, models []string) error { } return writeCodexLaunchProfile(configPath, codexLaunchProfileOptions{ activate: true, + profileName: codexAppProfileName, setRootModelConfig: true, model: primary, modelCatalogPath: catalogPath, + backupIntegration: codexAppIntegrationName, }) } @@ -101,23 +103,44 @@ func (c *CodexApp) CurrentModel() string { return "" } text := string(data) - if codexRootStringValue(text, "model_provider") == codexProfileName { - baseURL := codexSectionStringValue(text, codexProviderHeader(), "base_url") - if codexNormalizeURL(baseURL) == codexNormalizeURL(codexBaseURL()) { - return strings.TrimSpace(codexRootStringValue(text, "model")) + for _, profileName := range codexAppManagedProfileNames() { + if codexRootStringValue(text, "model_provider") == profileName { + baseURL := codexSectionStringValue(text, codexProviderHeaderFor(profileName), "base_url") + if codexNormalizeURL(baseURL) == codexNormalizeURL(codexBaseURL()) { + return strings.TrimSpace(codexRootStringValue(text, "model")) + } } } - if codexRootStringValue(text, "profile") != codexProfileName { + + profileName := codexRootStringValue(text, "profile") + if !codexAppIsManagedProfileName(profileName) { return "" } - if codexSectionStringValue(text, codexProfileHeader(), "model_provider") != codexProfileName { + if codexSectionStringValue(text, codexProfileHeaderFor(profileName), "model_provider") != profileName { return "" } - baseURL := codexSectionStringValue(text, codexProviderHeader(), "base_url") + baseURL := codexSectionStringValue(text, codexProviderHeaderFor(profileName), "base_url") if codexNormalizeURL(baseURL) != codexNormalizeURL(codexBaseURL()) { return "" } - return strings.TrimSpace(codexSectionStringValue(text, codexProfileHeader(), "model")) + return strings.TrimSpace(codexSectionStringValue(text, codexProfileHeaderFor(profileName), "model")) +} + +func codexAppManagedProfileNames() []string { + return []string{codexAppProfileName, codexProfileName} +} + +func codexAppIsManagedProfileName(profileName string) bool { + for _, candidate := range codexAppManagedProfileNames() { + if profileName == candidate { + return true + } + } + return false +} + +func codexAppIsOwnedProfileName(profileName string) bool { + return profileName == codexAppProfileName } func (c *CodexApp) Onboard() error { @@ -173,26 +196,23 @@ func (c *CodexApp) Restore() error { state, stateErr := loadCodexAppRestoreState() if stateErr == nil { - text = codexRestoreRootStringValue(text, "profile", state.HadProfile, state.Profile) - text = codexRestoreRootStringValue(text, "model", state.HadModel, state.Model) - text = codexRestoreRootStringValue(text, "model_provider", state.HadModelProvider, state.ModelProvider) - text = codexRestoreRootStringValue(text, "model_catalog_json", state.HadModelCatalogJSON, state.ModelCatalogJSON) - } else if codexRootStringValue(text, "profile") == codexProfileName { - text = codexRemoveRootValue(text, "profile") - if codexRootStringValue(text, "model_provider") == codexProfileName { - text = codexRemoveRootValue(text, "model_provider") - } - if catalogPath, err := codexAppModelCatalogPath(); err == nil && codexRootStringValue(text, "model_catalog_json") == catalogPath { - text = codexRemoveRootValue(text, "model_catalog_json") - } + text = codexAppRestoreRootValues(text, state) + } else if os.IsNotExist(stateErr) { + text = codexAppRemoveOwnedRootValues(text) + } else { + return stateErr + } + if !codexAppRootReferencesOwnedConfig(text) { + text = codexAppRemoveOwnedSections(text) } if err := codexValidateConfigText(text); err != nil { return err } - if err := fileutil.WriteWithBackup(configPath, []byte(text)); err != nil { + if err := codexWriteWithBackup(configPath, []byte(text), codexAppIntegrationName); err != nil { return err } + codexAppRemoveOwnedCatalogIfUnused(text) _ = os.Remove(codexAppRestoreStatePath()) return codexAppLaunchOrRestart("Restart Codex to use your usual profile?") } @@ -246,7 +266,7 @@ func writeCodexAppModelCatalog(path string, models []string) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } - return fileutil.WriteWithBackup(path, append(data, '\n')) + return codexWriteWithBackup(path, append(data, '\n'), codexAppIntegrationName) } func codexAppCatalogModelNames(primary string, fallback []string) []string { @@ -711,6 +731,69 @@ func codexNormalizeURL(raw string) string { return strings.TrimRight(strings.TrimSpace(raw), "/") } +func codexAppRootStillManaged(text string) bool { + if codexAppIsOwnedProfileName(codexRootStringValue(text, "profile")) { + return true + } + if codexAppIsOwnedProfileName(codexRootStringValue(text, "model_provider")) { + return true + } + return false +} + +func codexAppRootReferencesOwnedConfig(text string) bool { + return codexRootStringValue(text, "profile") == codexAppProfileName || + codexRootStringValue(text, "model_provider") == codexAppProfileName +} + +func codexAppRootReferencesCatalog(text string) bool { + catalogPath, err := codexAppModelCatalogPath() + if err != nil { + return false + } + return codexRootStringValue(text, "model_catalog_json") == catalogPath +} + +func codexAppRemoveOwnedSections(text string) string { + text = codexRemoveSection(text, codexProfileHeaderFor(codexAppProfileName)) + text = codexRemoveSection(text, codexProviderHeaderFor(codexAppProfileName)) + return text +} + +func codexAppRemoveOwnedCatalogIfUnused(text string) { + if codexAppRootReferencesCatalog(text) { + return + } + if catalogPath, err := codexAppModelCatalogPath(); err == nil { + _ = os.Remove(catalogPath) + } +} + +func codexAppRemoveOwnedRootValues(text string) string { + if !codexAppRootStillManaged(text) { + return text + } + text = codexRemoveRootValue(text, "profile") + if codexAppIsOwnedProfileName(codexRootStringValue(text, "model_provider")) { + text = codexRemoveRootValue(text, "model_provider") + } + if catalogPath, err := codexAppModelCatalogPath(); err == nil && codexRootStringValue(text, "model_catalog_json") == catalogPath { + text = codexRemoveRootValue(text, "model_catalog_json") + } + return text +} + +func codexAppRestoreRootValues(text string, state codexAppRestoreState) string { + if !codexAppRootStillManaged(text) { + return text + } + text = codexRestoreRootStringValue(text, "profile", state.HadProfile, state.Profile) + text = codexRestoreRootStringValue(text, "model", state.HadModel, state.Model) + text = codexRestoreRootStringValue(text, "model_provider", state.HadModelProvider, state.ModelProvider) + text = codexRestoreRootStringValue(text, "model_catalog_json", state.HadModelCatalogJSON, state.ModelCatalogJSON) + return text +} + type codexAppRestoreState struct { HadProfile bool `json:"had_profile"` Profile string `json:"profile,omitempty"` @@ -723,21 +806,35 @@ type codexAppRestoreState struct { } func saveCodexAppRestoreState(configPath string) error { - statePath := codexAppRestoreStatePath() - if stateData, err := os.ReadFile(statePath); err == nil { - if codexAppRestoreStateHasRootConfig(stateData) { - return nil - } - configData, err := os.ReadFile(configPath) - if err != nil { - if os.IsNotExist(err) { - return nil - } + configText := "" + configExists := false + if configData, err := os.ReadFile(configPath); err == nil { + configText = string(configData) + if err := codexValidateConfigText(configText); err != nil { return err } + configExists = true + } else if !os.IsNotExist(err) { + return err + } + + statePath := codexAppRestoreStatePath() + if stateData, err := os.ReadFile(statePath); err == nil { + hasRootConfig, err := codexAppRestoreStateHasRootConfig(stateData) + if err != nil { + return err + } + if hasRootConfig { + return nil + } + if !configExists { + return nil + } var existing codexAppRestoreState - _ = json.Unmarshal(stateData, &existing) - upgraded := codexAppRestoreStateFromText(string(configData)) + if err := json.Unmarshal(stateData, &existing); err != nil { + return err + } + upgraded := codexAppRestoreStateFromText(configText) upgraded.HadProfile = existing.HadProfile upgraded.Profile = existing.Profile return writeCodexAppRestoreState(upgraded) @@ -745,26 +842,21 @@ func saveCodexAppRestoreState(configPath string) error { return err } - data, err := os.ReadFile(configPath) - if err != nil { - if os.IsNotExist(err) { - return writeCodexAppRestoreState(codexAppRestoreState{}) - } - return err + if !configExists { + return writeCodexAppRestoreState(codexAppRestoreState{}) } - - return writeCodexAppRestoreState(codexAppRestoreStateFromText(string(data))) + return writeCodexAppRestoreState(codexAppRestoreStateFromText(configText)) } -func codexAppRestoreStateHasRootConfig(data []byte) bool { +func codexAppRestoreStateHasRootConfig(data []byte) (bool, error) { var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { - return true + return false, err } _, hasModel := raw["had_model"] _, hasModelProvider := raw["had_model_provider"] _, hasModelCatalogJSON := raw["had_model_catalog_json"] - return hasModel && hasModelProvider && hasModelCatalogJSON + return hasModel && hasModelProvider && hasModelCatalogJSON, nil } func codexAppRestoreStateFromText(text string) codexAppRestoreState { @@ -800,7 +892,7 @@ func writeCodexAppRestoreState(state codexAppRestoreState) error { if err != nil { return err } - return fileutil.WriteWithBackup(path, data) + return codexWriteWithBackup(path, data, codexAppIntegrationName) } func loadCodexAppRestoreState() (codexAppRestoreState, error) { diff --git a/cmd/launch/codex_app_test.go b/cmd/launch/codex_app_test.go index 5d5aed2f8..302e02c6f 100644 --- a/cmd/launch/codex_app_test.go +++ b/cmd/launch/codex_app_test.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/ollama/ollama/cmd/internal/fileutil" ) func withCodexAppPlatform(t *testing.T, goos string) { @@ -161,16 +163,16 @@ func TestCodexAppConfigureActivatesOllamaProfile(t *testing.T) { } for _, want := range []string{ - `profile = "ollama-launch"`, + fmt.Sprintf(`profile = %q`, codexAppProfileName), `model = "llama3.2"`, - `model_provider = "ollama-launch"`, + fmt.Sprintf(`model_provider = %q`, codexAppProfileName), fmt.Sprintf(`model_catalog_json = %q`, catalogPath), - `[profiles.ollama-launch]`, + codexProfileHeaderFor(codexAppProfileName), `model = "llama3.2"`, `openai_base_url = "http://127.0.0.1:9999/v1/"`, - `model_provider = "ollama-launch"`, + fmt.Sprintf(`model_provider = %q`, codexAppProfileName), `model_catalog_json = "`, - `[model_providers.ollama-launch]`, + codexProviderHeaderFor(codexAppProfileName), `name = "Ollama"`, `base_url = "http://127.0.0.1:9999/v1/"`, `wire_api = "responses"`, @@ -206,6 +208,134 @@ func TestCodexAppConfigureActivatesOllamaProfile(t *testing.T) { } } +func TestCodexAppConfigureUsesAppSpecificProfileWithoutTouchingCLIProfile(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999") + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + existing := "" + + `profile = "default"` + "\n\n" + + "[profiles.ollama-launch]\n" + + `model = "cli-model"` + "\n" + + `openai_base_url = "http://cli.invalid/v1/"` + "\n" + + `model_provider = "ollama-launch"` + "\n\n" + + "[model_providers.ollama-launch]\n" + + `name = "CLI Ollama"` + "\n" + + `base_url = "http://cli.invalid/v1/"` + "\n" + + `wire_api = "responses"` + "\n\n" + + "[profiles.default]\n" + + `model = "gpt-5.5"` + "\n" + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil { + t.Fatalf("ConfigureWithModels returned error: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + content := string(data) + if got := codexRootStringValue(content, "profile"); got != codexAppProfileName { + t.Fatalf("root profile = %q, want %q", got, codexAppProfileName) + } + if got := codexSectionStringValue(content, codexProfileHeader(), "openai_base_url"); got != "http://cli.invalid/v1/" { + t.Fatalf("CLI profile base URL = %q, want preserved CLI URL in:\n%s", got, content) + } + if got := codexSectionStringValue(content, codexProviderHeader(), "name"); got != "CLI Ollama" { + t.Fatalf("CLI provider name = %q, want preserved CLI provider in:\n%s", got, content) + } + if got := codexSectionStringValue(content, codexProfileHeaderFor(codexAppProfileName), "model"); got != "llama3.2" { + t.Fatalf("app profile model = %q, want llama3.2", got) + } + if got := codexSectionStringValue(content, codexProviderHeaderFor(codexAppProfileName), "base_url"); got != "http://127.0.0.1:9999/v1/" { + t.Fatalf("app provider base URL = %q", got) + } + assertBackupContains(t, filepath.Join(fileutil.BackupDir(), codexAppIntegrationName, "config.toml.*"), `profile = "default"`) +} + +func TestCodexAppConfigureRejectsMalformedTomlBeforeSideEffects(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + existing := "profile = \n" + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}) + if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") { + t.Fatalf("ConfigureWithModels error = %v, want invalid TOML", err) + } + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if string(data) != existing { + t.Fatalf("malformed config should be left untouched, got:\n%s", data) + } + if _, err := os.Stat(codexAppRestoreStatePath()); !os.IsNotExist(err) { + t.Fatalf("restore state should not be written before config validation, err=%v", err) + } + catalogPath, err := codexAppModelCatalogPath() + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(catalogPath); !os.IsNotExist(err) { + t.Fatalf("model catalog should not be written before config validation, err=%v", err) + } +} + +func TestCodexAppConfigureRejectsMalformedTomlEvenWithExistingRestoreState(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + existing := "[profiles.ollama-launch\nmodel = \"llama3.2\"\n" + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil { + t.Fatal(err) + } + restoreState := `{"had_profile":true,"profile":"default","had_model":true,"model":"gpt-5.5","had_model_provider":true,"model_provider":"openai","had_model_catalog_json":false}` + if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil { + t.Fatal(err) + } + + err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}) + if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") { + t.Fatalf("ConfigureWithModels error = %v, want invalid TOML", err) + } + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if string(data) != existing { + t.Fatalf("malformed config should be left untouched, got:\n%s", data) + } + stateData, err := os.ReadFile(codexAppRestoreStatePath()) + if err != nil { + t.Fatal(err) + } + if string(stateData) != restoreState { + t.Fatalf("restore state should be left untouched, got:\n%s", stateData) + } +} + func TestCodexAppCurrentModelRequiresManagedActiveProfile(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) @@ -217,10 +347,10 @@ func TestCodexAppCurrentModelRequiresManagedActiveProfile(t *testing.T) { } content := "" + "profile = \"default\"\n\n" + - "[profiles.ollama-launch]\n" + + codexProfileHeaderFor(codexAppProfileName) + "\n" + "model = \"llama3.2\"\n" + - "model_provider = \"ollama-launch\"\n\n" + - "[model_providers.ollama-launch]\n" + + fmt.Sprintf("model_provider = %q\n\n", codexAppProfileName) + + codexProviderHeaderFor(codexAppProfileName) + "\n" + "base_url = \"http://127.0.0.1:11434/v1/\"\n" if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { t.Fatal(err) @@ -242,8 +372,8 @@ func TestCodexAppCurrentModelReadsManagedRootConfig(t *testing.T) { } content := "" + `model = "qwen3:8b"` + "\n" + - `model_provider = "ollama-launch"` + "\n\n" + - "[model_providers.ollama-launch]\n" + + fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n\n" + + codexProviderHeaderFor(codexAppProfileName) + "\n" + `base_url = "http://127.0.0.1:11434/v1/"` + "\n" if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { t.Fatal(err) @@ -410,10 +540,13 @@ func TestCodexAppRestoreRestoresPreviousProfile(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(string(data), `profile = "default"`) || strings.Contains(string(data), `profile = "ollama-launch"`) { + if !strings.Contains(string(data), `profile = "default"`) || strings.Contains(string(data), fmt.Sprintf(`profile = %q`, codexAppProfileName)) { t.Fatalf("restore should restore previous active profile, got:\n%s", data) } restored := string(data) + if strings.Contains(restored, codexProfileHeaderFor(codexAppProfileName)) || strings.Contains(restored, codexProviderHeaderFor(codexAppProfileName)) { + t.Fatalf("restore should remove owned app sections, got:\n%s", restored) + } for key, want := range map[string]string{ "profile": "default", "model": "gpt-5.5", @@ -432,6 +565,173 @@ func TestCodexAppRestoreRestoresPreviousProfile(t *testing.T) { } } +func TestCodexAppRestoreRejectsMalformedTomlWithoutWriting(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withCodexAppPlatform(t, "darwin") + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + existing := "model = \"unterminated\n" + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(codexAppRestoreStatePath(), []byte(`{"had_profile":false,"had_model":false,"had_model_provider":false,"had_model_catalog_json":false}`), 0o644); err != nil { + t.Fatal(err) + } + + err := (&CodexApp{}).Restore() + if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") { + t.Fatalf("Restore error = %v, want invalid TOML", err) + } + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if string(data) != existing { + t.Fatalf("malformed config should be left untouched, got:\n%s", data) + } + if _, err := os.Stat(codexAppRestoreStatePath()); err != nil { + t.Fatalf("restore state should remain after failed restore: %v", err) + } +} + +func TestCodexAppRestoreDoesNotStompUserChangedRootConfig(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withCodexAppPlatform(t, "darwin") + + var openCalls int + withCodexAppProcessHooks(t, + func() bool { return false }, + func() error { return nil }, + func() error { + openCalls++ + return nil + }, + ) + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + catalogPath, err := codexAppModelCatalogPath() + if err != nil { + t.Fatal(err) + } + existing := "" + + `profile = "manual"` + "\n" + + `model = "gpt-5.5"` + "\n" + + `model_provider = "openai"` + "\n\n" + + codexProfileHeaderFor(codexAppProfileName) + "\n" + + `model = "llama3.2"` + "\n" + + fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n\n" + + codexProviderHeaderFor(codexAppProfileName) + "\n" + + `base_url = "http://127.0.0.1:11434/v1/"` + "\n\n" + + "[profiles.manual]\n" + + `model = "gpt-5.5"` + "\n" + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(catalogPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(catalogPath, []byte(`{"models":[]}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil { + t.Fatal(err) + } + restoreState := `{"had_profile":true,"profile":"default","had_model":true,"model":"old","had_model_provider":true,"model_provider":"old-provider","had_model_catalog_json":false}` + if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil { + t.Fatal(err) + } + + if err := (&CodexApp{}).Restore(); err != nil { + t.Fatalf("Restore returned error: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + content := string(data) + for key, want := range map[string]string{ + "profile": "manual", + "model": "gpt-5.5", + "model_provider": "openai", + } { + if got := codexRootStringValue(content, key); got != want { + t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, content) + } + } + if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) || strings.Contains(content, codexProviderHeaderFor(codexAppProfileName)) { + t.Fatalf("owned app sections should be removed when no longer active, got:\n%s", content) + } + if _, err := os.Stat(catalogPath); !os.IsNotExist(err) { + t.Fatalf("owned catalog should be removed when unused, err=%v", err) + } + if openCalls != 1 { + t.Fatalf("open calls = %d, want 1", openCalls) + } +} + +func TestCodexAppRestoreDoesNotTreatCLIProfileAsOwned(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withCodexAppPlatform(t, "darwin") + + withCodexAppProcessHooks(t, + func() bool { return false }, + func() error { return nil }, + func() error { return nil }, + ) + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + existing := "" + + `profile = "ollama-launch"` + "\n" + + `model = "cli-model"` + "\n" + + `model_provider = "ollama-launch"` + "\n\n" + + "[profiles.ollama-launch]\n" + + `model = "cli-model"` + "\n" + + `openai_base_url = "http://cli.invalid/v1/"` + "\n" + + `model_provider = "ollama-launch"` + "\n\n" + + "[model_providers.ollama-launch]\n" + + `name = "CLI Ollama"` + "\n" + + `base_url = "http://cli.invalid/v1/"` + "\n" + + `wire_api = "responses"` + "\n" + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil { + t.Fatal(err) + } + restoreState := `{"had_profile":true,"profile":"default","had_model":true,"model":"gpt-5.5","had_model_provider":true,"model_provider":"openai","had_model_catalog_json":false}` + if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil { + t.Fatal(err) + } + + if err := (&CodexApp{}).Restore(); err != nil { + t.Fatalf("Restore returned error: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if string(data) != existing { + t.Fatalf("CLI Codex profile should be left untouched, got:\n%s", data) + } +} + func TestCodexAppRunRestartsRunningAppWhenConfirmed(t *testing.T) { withCodexAppPlatform(t, "darwin") restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) diff --git a/cmd/launch/codex_test.go b/cmd/launch/codex_test.go index 824e15bbd..6b8173568 100644 --- a/cmd/launch/codex_test.go +++ b/cmd/launch/codex_test.go @@ -6,6 +6,8 @@ import ( "slices" "strings" "testing" + + "github.com/ollama/ollama/cmd/internal/fileutil" ) func TestCodexArgs(t *testing.T) { @@ -177,6 +179,53 @@ func TestWriteCodexProfile(t *testing.T) { } }) + t.Run("rejects malformed existing toml variants without writing", func(t *testing.T) { + tests := map[string]string{ + "duplicate root key": "profile = \"default\"\nprofile = \"other\"\n", + "unterminated string": "model = \"gpt-5.5\n", + "bad table": "[profiles.ollama-launch\nmodel = \"llama3.2\"\n", + "duplicate table key": "[profiles.ollama-launch]\nmodel = \"a\"\nmodel = \"b\"\n", + } + for name, existing := range tests { + t.Run(name, func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.toml") + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + err := writeCodexProfile(configPath) + if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") { + t.Fatalf("writeCodexProfile error = %v, want invalid TOML", err) + } + + data, _ := os.ReadFile(configPath) + if string(data) != existing { + t.Fatalf("invalid config should be left untouched, got:\n%s", data) + } + }) + } + }) + + t.Run("backs up previous config before overwrite", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + existing := "# original-codex-backup-marker\n[profiles.default]\nmodel = \"gpt-5.5\"\n" + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + if err := writeCodexProfile(configPath); err != nil { + t.Fatal(err) + } + + assertBackupContains(t, filepath.Join(fileutil.BackupDir(), "config.toml.*"), "original-codex-backup-marker") + }) + t.Run("updates equivalent quoted root keys", func(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.toml") @@ -327,3 +376,21 @@ func TestEnsureCodexConfig(t *testing.T) { } }) } + +func assertBackupContains(t *testing.T, pattern, marker string) { + t.Helper() + backups, err := filepath.Glob(pattern) + if err != nil { + t.Fatal(err) + } + for _, backupPath := range backups { + data, err := os.ReadFile(backupPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), marker) { + return + } + } + t.Fatalf("backup matching %q with marker %q not found", pattern, marker) +} From 66734ab1ba6de8416a58b6598f90fa19726e9608 Mon Sep 17 00:00:00 2001 From: ParthSareen Date: Thu, 7 May 2026 16:02:30 -0700 Subject: [PATCH 3/4] windows fixes --- cmd/launch/codex.go | 2 +- cmd/launch/codex_app.go | 4 ++-- cmd/launch/codex_app_test.go | 29 +++++++++++++++++++++++++++++ cmd/launch/codex_test.go | 20 ++++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/cmd/launch/codex.go b/cmd/launch/codex.go index 28b9b5ac2..19d63fa68 100644 --- a/cmd/launch/codex.go +++ b/cmd/launch/codex.go @@ -185,7 +185,7 @@ func codexLaunchProfileName(opts codexLaunchProfileOptions) string { } func codexBaseURL() string { - return strings.TrimRight(envconfig.Host().String(), "/") + "/v1/" + return strings.TrimRight(envconfig.ConnectableHost().String(), "/") + "/v1/" } func codexProfileHeader() string { diff --git a/cmd/launch/codex_app.go b/cmd/launch/codex_app.go index d08ca7e3c..932d3744f 100644 --- a/cmd/launch/codex_app.go +++ b/cmd/launch/codex_app.go @@ -248,7 +248,7 @@ func writeCodexAppModelCatalog(path string, models []string) error { if len(models) == 0 { return fmt.Errorf("codex-app model catalog cannot be empty") } - client := api.NewClient(envconfig.Host(), http.DefaultClient) + client := api.NewClient(envconfig.ConnectableHost(), http.DefaultClient) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -278,7 +278,7 @@ func codexAppCatalogModelNames(primary string, fallback []string) []string { } func codexAppTagModelNames() []string { - client := api.NewClient(envconfig.Host(), http.DefaultClient) + client := api.NewClient(envconfig.ConnectableHost(), http.DefaultClient) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/cmd/launch/codex_app_test.go b/cmd/launch/codex_app_test.go index 302e02c6f..ac25b6776 100644 --- a/cmd/launch/codex_app_test.go +++ b/cmd/launch/codex_app_test.go @@ -260,6 +260,35 @@ func TestCodexAppConfigureUsesAppSpecificProfileWithoutTouchingCLIProfile(t *tes assertBackupContains(t, filepath.Join(fileutil.BackupDir(), codexAppIntegrationName, "config.toml.*"), `profile = "default"`) } +func TestCodexAppConfigureUsesConnectableHostForUnspecifiedBindAddress(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("OLLAMA_HOST", "http://0.0.0.0:11434") + + if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil { + t.Fatalf("ConfigureWithModels returned error: %v", err) + } + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + content := string(data) + if strings.Contains(content, "0.0.0.0") { + t.Fatalf("config should not write bind-only host, got:\n%s", content) + } + if got := codexSectionStringValue(content, codexProfileHeaderFor(codexAppProfileName), "openai_base_url"); got != "http://127.0.0.1:11434/v1/" { + t.Fatalf("app profile openai_base_url = %q, want connectable loopback URL", got) + } + if got := codexSectionStringValue(content, codexProviderHeaderFor(codexAppProfileName), "base_url"); got != "http://127.0.0.1:11434/v1/" { + t.Fatalf("app provider base_url = %q, want connectable loopback URL", got) + } + if got := codexRootStringValue(content, "model_provider"); got != codexAppProfileName { + t.Fatalf("root model_provider = %q, want %q", got, codexAppProfileName) + } +} + func TestCodexAppConfigureRejectsMalformedTomlBeforeSideEffects(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) diff --git a/cmd/launch/codex_test.go b/cmd/launch/codex_test.go index 6b8173568..5c1cc573b 100644 --- a/cmd/launch/codex_test.go +++ b/cmd/launch/codex_test.go @@ -327,6 +327,26 @@ func TestWriteCodexProfile(t *testing.T) { t.Errorf("expected custom host in URL, got:\n%s", content) } }) + + t.Run("uses connectable host for unspecified bind address", func(t *testing.T) { + t.Setenv("OLLAMA_HOST", "http://0.0.0.0:11434") + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.toml") + + if err := writeCodexProfile(configPath); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(configPath) + content := string(data) + + if strings.Contains(content, "0.0.0.0") { + t.Fatalf("config should not write bind-only host, got:\n%s", content) + } + if !strings.Contains(content, "127.0.0.1:11434/v1/") { + t.Fatalf("expected connectable loopback URL, got:\n%s", content) + } + }) } func TestEnsureCodexConfig(t *testing.T) { From ececc61d2194e193c3183d2f361d0de04793775c Mon Sep 17 00:00:00 2001 From: ParthSareen Date: Tue, 12 May 2026 14:32:34 -0700 Subject: [PATCH 4/4] feat(launch): add Codex App integration --- app/store/database.go | 19 +- app/store/store_test.go | 15 + app/ui/app/public/launch-icons/codex-app.png | Bin 0 -> 41615 bytes app/ui/app/src/components/LaunchCommands.tsx | 18 +- cmd/launch/codex.go | 136 +++--- cmd/launch/codex_app.go | 266 +++++++----- cmd/launch/codex_app_test.go | 411 ++++++++++++++++++- cmd/launch/integrations_test.go | 17 + cmd/launch/launch.go | 25 +- cmd/launch/launch_test.go | 106 ++++- cmd/launch/registry.go | 2 +- cmd/tui/tui_test.go | 22 +- docs/integrations/codex-app.mdx | 6 +- 13 files changed, 849 insertions(+), 194 deletions(-) create mode 100644 app/ui/app/public/launch-icons/codex-app.png diff --git a/app/store/database.go b/app/store/database.go index bb81bdad6..6c6767c52 100644 --- a/app/store/database.go +++ b/app/store/database.go @@ -1201,15 +1201,16 @@ func (db *database) getSettings() (Settings, error) { func (db *database) setSettings(s Settings) error { lastHomeView := strings.ToLower(strings.TrimSpace(s.LastHomeView)) validLaunchView := map[string]struct{}{ - "launch": {}, - "openclaw": {}, - "claude": {}, - "hermes": {}, - "codex": {}, - "copilot": {}, - "opencode": {}, - "droid": {}, - "pi": {}, + "launch": {}, + "openclaw": {}, + "claude": {}, + "hermes": {}, + "codex": {}, + "codex-app": {}, + "copilot": {}, + "opencode": {}, + "droid": {}, + "pi": {}, } if lastHomeView != "chat" { if _, ok := validLaunchView[lastHomeView]; !ok { diff --git a/app/store/store_test.go b/app/store/store_test.go index 90bfda0dd..d4a08a8ad 100644 --- a/app/store/store_test.go +++ b/app/store/store_test.go @@ -122,6 +122,21 @@ func TestStore(t *testing.T) { } }) + t.Run("settings codex app home view is accepted", func(t *testing.T) { + if err := s.SetSettings(Settings{LastHomeView: "codex-app"}); err != nil { + t.Fatal(err) + } + + loaded, err := s.Settings() + if err != nil { + t.Fatal(err) + } + + if loaded.LastHomeView != "codex-app" { + t.Fatalf("expected codex-app LastHomeView to be preserved, got %q", loaded.LastHomeView) + } + }) + t.Run("window size", func(t *testing.T) { if err := s.SetWindowSize(1024, 768); err != nil { t.Fatal(err) diff --git a/app/ui/app/public/launch-icons/codex-app.png b/app/ui/app/public/launch-icons/codex-app.png new file mode 100644 index 0000000000000000000000000000000000000000..72e9baf7608cb565dfe8e11e43fc869ff9ea8efa GIT binary patch literal 41615 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 L4IiN&+DH8#JiG3F literal 0 HcmV?d00001 diff --git a/app/ui/app/src/components/LaunchCommands.tsx b/app/ui/app/src/components/LaunchCommands.tsx index b3f0e438c..f2b50cf5e 100644 --- a/app/ui/app/src/components/LaunchCommands.tsx +++ b/app/ui/app/src/components/LaunchCommands.tsx @@ -22,11 +22,12 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [ iconClassName: "h-7 w-7", }, { - id: "openclaw", - name: "OpenClaw", - command: "ollama launch openclaw", - description: "Personal AI with 100+ skills", - icon: "/launch-icons/openclaw.svg", + id: "codex-app", + name: "Codex App", + command: "ollama launch codex-app", + description: "OpenAI's desktop coding agent", + icon: "/launch-icons/codex-app.png", + iconClassName: "h-full w-full", }, { id: "hermes", @@ -36,6 +37,13 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [ icon: "/launch-icons/hermes-agent.svg", iconClassName: "h-7 w-7", }, + { + id: "openclaw", + name: "OpenClaw", + command: "ollama launch openclaw", + description: "Personal AI with 100+ skills", + icon: "/launch-icons/openclaw.svg", + }, { id: "opencode", name: "OpenCode", diff --git a/cmd/launch/codex.go b/cmd/launch/codex.go index 19d63fa68..f33d46e83 100644 --- a/cmd/launch/codex.go +++ b/cmd/launch/codex.go @@ -18,8 +18,15 @@ type Codex struct{} func (c *Codex) String() string { return "Codex" } -const codexProfileName = "ollama-launch" -const codexProviderName = "Ollama" +const ( + codexProfileName = "ollama-launch" + codexProviderName = "Ollama" + + codexRootProfileKey = "profile" + codexRootModelKey = "model" + codexRootModelProviderKey = "model_provider" + codexRootModelCatalogJSONKey = "model_catalog_json" +) func (c *Codex) args(model string, extra []string) []string { args := []string{"--profile", codexProfileName} @@ -102,32 +109,33 @@ func writeCodexLaunchProfile(configPath string, opts codexLaunchProfileOptions) } else if !os.IsNotExist(readErr) { return readErr } - if err := codexValidateConfigText(text); err != nil { + parsed, err := codexParseConfig(text) + if err != nil { return err } model := strings.TrimSpace(opts.model) if model == "" { - model = codexSectionStringValue(text, profileHeader, "model") + model = parsed.ProfileString(profileName, codexRootModelKey) } modelCatalogPath := strings.TrimSpace(opts.modelCatalogPath) if modelCatalogPath == "" { - modelCatalogPath = codexSectionStringValue(text, profileHeader, "model_catalog_json") + modelCatalogPath = parsed.ProfileString(profileName, codexRootModelCatalogJSONKey) } profileLines := []string{} if model != "" { - profileLines = append(profileLines, fmt.Sprintf("model = %q", model)) + profileLines = append(profileLines, fmt.Sprintf("%s = %q", codexRootModelKey, model)) } profileLines = append(profileLines, fmt.Sprintf("openai_base_url = %q", baseURL), - fmt.Sprintf("model_provider = %q", profileName), + fmt.Sprintf("%s = %q", codexRootModelProviderKey, profileName), ) if opts.forceAPIAuth { profileLines = append(profileLines, `forced_login_method = "api"`) } if modelCatalogPath != "" { - profileLines = append(profileLines, fmt.Sprintf("model_catalog_json = %q", modelCatalogPath)) + profileLines = append(profileLines, fmt.Sprintf("%s = %q", codexRootModelCatalogJSONKey, modelCatalogPath)) } sections := []struct { @@ -149,32 +157,33 @@ func writeCodexLaunchProfile(configPath string, opts codexLaunchProfileOptions) } if opts.activate { - text = codexSetRootStringValue(text, "profile", profileName) + text = codexSetRootStringValue(text, codexRootProfileKey, profileName) } if opts.setRootModelConfig { if model != "" { - text = codexSetRootStringValue(text, "model", model) + text = codexSetRootStringValue(text, codexRootModelKey, model) } - text = codexSetRootStringValue(text, "model_provider", profileName) + text = codexSetRootStringValue(text, codexRootModelProviderKey, profileName) if modelCatalogPath != "" { - text = codexSetRootStringValue(text, "model_catalog_json", modelCatalogPath) + text = codexSetRootStringValue(text, codexRootModelCatalogJSONKey, modelCatalogPath) } } for _, s := range sections { text = codexUpsertSection(text, s.header, s.lines) } - if err := codexValidateConfigText(text); err != nil { + parsed, err = codexParseConfig(text) + if err != nil { return err } - if err := codexValidateLaunchProfileText(text, profileName, opts, model, modelCatalogPath, baseURL); err != nil { + if err := codexValidateLaunchProfileText(parsed, profileName, opts, model, modelCatalogPath, baseURL); err != nil { return err } if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { return err } - return codexWriteWithBackup(configPath, []byte(text), opts.backupIntegration) + return fileutil.WriteWithBackup(configPath, []byte(text), opts.backupIntegration) } func codexLaunchProfileName(opts codexLaunchProfileOptions) string { @@ -204,52 +213,52 @@ func codexProviderHeaderFor(profileName string) string { return fmt.Sprintf("[model_providers.%s]", profileName) } -func codexValidateLaunchProfileText(text, profileName string, opts codexLaunchProfileOptions, model, modelCatalogPath, baseURL string) error { +func codexValidateLaunchProfileText(config codexParsedConfig, profileName string, opts codexLaunchProfileOptions, model, modelCatalogPath, baseURL string) error { for _, check := range []struct { path []string want string }{ {[]string{"profiles", profileName, "openai_base_url"}, baseURL}, - {[]string{"profiles", profileName, "model_provider"}, profileName}, + {[]string{"profiles", profileName, codexRootModelProviderKey}, profileName}, {[]string{"model_providers", profileName, "name"}, codexProviderName}, {[]string{"model_providers", profileName, "base_url"}, baseURL}, {[]string{"model_providers", profileName, "wire_api"}, "responses"}, } { - if got, ok := codexStringValue(text, check.path...); !ok || got != check.want { + if got, ok := config.String(check.path...); !ok || got != check.want { return fmt.Errorf("generated Codex config missing %s = %q", strings.Join(check.path, "."), check.want) } } if opts.forceAPIAuth { - if got, ok := codexStringValue(text, "profiles", profileName, "forced_login_method"); !ok || got != "api" { + if got, ok := config.String("profiles", profileName, "forced_login_method"); !ok || got != "api" { return fmt.Errorf("generated Codex config missing profiles.%s.forced_login_method = %q", profileName, "api") } } if model != "" { - if got, ok := codexStringValue(text, "profiles", profileName, "model"); !ok || got != model { + if got, ok := config.String("profiles", profileName, codexRootModelKey); !ok || got != model { return fmt.Errorf("generated Codex config missing profiles.%s.model = %q", profileName, model) } } if modelCatalogPath != "" { - if got, ok := codexStringValue(text, "profiles", profileName, "model_catalog_json"); !ok || got != modelCatalogPath { + if got, ok := config.String("profiles", profileName, codexRootModelCatalogJSONKey); !ok || got != modelCatalogPath { return fmt.Errorf("generated Codex config missing profiles.%s.model_catalog_json = %q", profileName, modelCatalogPath) } } if opts.activate { - if got := codexRootStringValue(text, "profile"); got != profileName { + if got := config.RootString(codexRootProfileKey); got != profileName { return fmt.Errorf("generated Codex config missing profile = %q", profileName) } } if opts.setRootModelConfig { if model != "" { - if got := codexRootStringValue(text, "model"); got != model { + if got := config.RootString(codexRootModelKey); got != model { return fmt.Errorf("generated Codex config missing model = %q", model) } } - if got := codexRootStringValue(text, "model_provider"); got != profileName { + if got := config.RootString(codexRootModelProviderKey); got != profileName { return fmt.Errorf("generated Codex config missing model_provider = %q", profileName) } if modelCatalogPath != "" { - if got := codexRootStringValue(text, "model_catalog_json"); got != modelCatalogPath { + if got := config.RootString(codexRootModelCatalogJSONKey); got != modelCatalogPath { return fmt.Errorf("generated Codex config missing model_catalog_json = %q", modelCatalogPath) } } @@ -257,13 +266,6 @@ func codexValidateLaunchProfileText(text, profileName string, opts codexLaunchPr return nil } -func codexWriteWithBackup(path string, data []byte, integration string) error { - if strings.TrimSpace(integration) != "" { - return fileutil.WriteWithBackup(path, data, integration) - } - return fileutil.WriteWithBackup(path, data) -} - func codexUpsertSection(text, header string, lines []string) string { block := strings.Join(append([]string{header}, lines...), "\n") + "\n" @@ -294,24 +296,15 @@ func codexRemoveSection(text, header string) string { return text[:start] + text[end:] } -func codexRootStringValue(text, key string) string { - value, _ := codexStringValue(text, key) - return value +type codexParsedConfig struct { + values map[string]any } -func codexRootStringValueOK(text, key string) (string, bool) { - return codexStringValue(text, key) -} - -func codexStringValue(text string, path ...string) (string, bool) { +func (c codexParsedConfig) String(path ...string) (string, bool) { if len(path) == 0 { return "", false } - cfg, err := codexParseConfigText(text) - if err != nil { - return "", false - } - var current any = cfg + var current any = c.values for _, part := range path { table, ok := current.(map[string]any) if !ok { @@ -329,6 +322,49 @@ func codexStringValue(text string, path ...string) (string, bool) { return value, true } +func (c codexParsedConfig) RootString(key string) string { + value, _ := c.RootStringOK(key) + return value +} + +func (c codexParsedConfig) RootStringOK(key string) (string, bool) { + return c.String(key) +} + +func (c codexParsedConfig) ProfileString(profileName, key string) string { + value, _ := c.String("profiles", profileName, key) + return value +} + +func (c codexParsedConfig) ProviderString(profileName, key string) string { + value, _ := c.String("model_providers", profileName, key) + return value +} + +func codexRootStringValue(text, key string) string { + config, err := codexParseConfig(text) + if err != nil { + return "" + } + return config.RootString(key) +} + +func codexRootStringValueOK(text, key string) (string, bool) { + config, err := codexParseConfig(text) + if err != nil { + return "", false + } + return config.RootStringOK(key) +} + +func codexStringValue(text string, path ...string) (string, bool) { + config, err := codexParseConfig(text) + if err != nil { + return "", false + } + return config.String(path...) +} + func codexSectionStringValue(text, header, key string) string { path, ok := codexTableHeaderPath(header) if !ok { @@ -338,6 +374,14 @@ func codexSectionStringValue(text, header, key string) string { return value } +func codexParseConfig(text string) (codexParsedConfig, error) { + values, err := codexParseConfigText(text) + if err != nil { + return codexParsedConfig{}, err + } + return codexParsedConfig{values: values}, nil +} + func codexParseConfigText(text string) (map[string]any, error) { cfg := map[string]any{} if strings.TrimSpace(text) == "" { @@ -350,7 +394,7 @@ func codexParseConfigText(text string) (map[string]any, error) { } func codexValidateConfigText(text string) error { - _, err := codexParseConfigText(text) + _, err := codexParseConfig(text) return err } diff --git a/cmd/launch/codex_app.go b/cmd/launch/codex_app.go index 932d3744f..b8606ae73 100644 --- a/cmd/launch/codex_app.go +++ b/cmd/launch/codex_app.go @@ -9,14 +9,16 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" "strconv" "strings" - "syscall" "time" "github.com/ollama/ollama/api" "github.com/ollama/ollama/cmd/config" + "github.com/ollama/ollama/cmd/internal/fileutil" "github.com/ollama/ollama/envconfig" + modelpkg "github.com/ollama/ollama/types/model" ) const ( @@ -40,6 +42,7 @@ var ( codexAppIsRunning = defaultCodexAppIsRunning codexAppRunPath = defaultCodexAppRunningAppPath codexAppStartID = defaultCodexAppStartAppID + codexAppCanOpenID = defaultCodexAppCanOpenBundleID codexAppSleep = time.Sleep ) @@ -80,7 +83,7 @@ func (c *CodexApp) ConfigureWithModels(primary string, models []string) error { if err != nil { return err } - if err := writeCodexAppModelCatalog(catalogPath, codexAppCatalogModelNames(primary, models)); err != nil { + if err := writeCodexAppModelCatalog(catalogPath, primary, codexAppCatalogModelNames(primary, models)); err != nil { return err } return writeCodexLaunchProfile(configPath, codexLaunchProfileOptions{ @@ -103,27 +106,34 @@ func (c *CodexApp) CurrentModel() string { return "" } text := string(data) + parsed, err := codexParseConfig(text) + if err != nil { + return "" + } for _, profileName := range codexAppManagedProfileNames() { - if codexRootStringValue(text, "model_provider") == profileName { - baseURL := codexSectionStringValue(text, codexProviderHeaderFor(profileName), "base_url") - if codexNormalizeURL(baseURL) == codexNormalizeURL(codexBaseURL()) { - return strings.TrimSpace(codexRootStringValue(text, "model")) + if parsed.RootString(codexRootModelProviderKey) == profileName { + baseURL := parsed.ProviderString(profileName, "base_url") + if codexNormalizeURL(baseURL) == codexNormalizeURL(codexBaseURL()) && codexAppCatalogHealthy(parsed, profileName) { + return strings.TrimSpace(parsed.RootString(codexRootModelKey)) } } } - profileName := codexRootStringValue(text, "profile") + profileName := parsed.RootString(codexRootProfileKey) if !codexAppIsManagedProfileName(profileName) { return "" } - if codexSectionStringValue(text, codexProfileHeaderFor(profileName), "model_provider") != profileName { + if parsed.ProfileString(profileName, codexRootModelProviderKey) != profileName { return "" } - baseURL := codexSectionStringValue(text, codexProviderHeaderFor(profileName), "base_url") + baseURL := parsed.ProviderString(profileName, "base_url") if codexNormalizeURL(baseURL) != codexNormalizeURL(codexBaseURL()) { return "" } - return strings.TrimSpace(codexSectionStringValue(text, codexProfileHeaderFor(profileName), "model")) + if !codexAppCatalogHealthy(parsed, profileName) { + return "" + } + return strings.TrimSpace(parsed.ProfileString(profileName, codexRootModelKey)) } func codexAppManagedProfileNames() []string { @@ -143,6 +153,30 @@ func codexAppIsOwnedProfileName(profileName string) bool { return profileName == codexAppProfileName } +func codexAppCatalogHealthy(config codexParsedConfig, profileName string) bool { + catalogPath, err := codexAppModelCatalogPath() + if err != nil { + return false + } + if config.RootString(codexRootModelCatalogJSONKey) != catalogPath { + return false + } + if config.ProfileString(profileName, codexRootModelCatalogJSONKey) != catalogPath { + return false + } + data, err := os.ReadFile(catalogPath) + if err != nil { + return false + } + var catalog struct { + Models []json.RawMessage `json:"models"` + } + if err := json.Unmarshal(data, &catalog); err != nil { + return false + } + return len(catalog.Models) > 0 +} + func (c *CodexApp) Onboard() error { return config.MarkIntegrationOnboarded(codexAppIntegrationName) } @@ -185,6 +219,9 @@ func (c *CodexApp) Restore() error { data, err := os.ReadFile(configPath) if err != nil { if os.IsNotExist(err) { + if err := removeCodexAppRestoreState(); err != nil { + return err + } return codexAppLaunchOrRestart("Restart Codex to use your usual profile?") } return err @@ -209,7 +246,7 @@ func (c *CodexApp) Restore() error { if err := codexValidateConfigText(text); err != nil { return err } - if err := codexWriteWithBackup(configPath, []byte(text), codexAppIntegrationName); err != nil { + if err := fileutil.WriteWithBackup(configPath, []byte(text), codexAppIntegrationName); err != nil { return err } codexAppRemoveOwnedCatalogIfUnused(text) @@ -230,10 +267,14 @@ func codexAppInstalled() bool { if codexAppAppPath() != "" { return true } - if codexAppGOOS != "windows" { + switch codexAppGOOS { + case "darwin": + return codexAppCanOpenID() + case "windows": + return codexAppIsRunning() || codexAppStartID() != "" + default: return false } - return codexAppIsRunning() || codexAppStartID() != "" } func codexAppModelCatalogPath() (string, error) { @@ -244,7 +285,7 @@ func codexAppModelCatalogPath() (string, error) { return filepath.Join(filepath.Dir(configPath), codexAppModelCatalogFilename), nil } -func writeCodexAppModelCatalog(path string, models []string) error { +func writeCodexAppModelCatalog(path, primary string, models []string) error { if len(models) == 0 { return fmt.Errorf("codex-app model catalog cannot be empty") } @@ -253,10 +294,14 @@ func writeCodexAppModelCatalog(path string, models []string) error { defer cancel() baseInstructions := codexAppBaseInstructions() + primaryMetadata := codexAppSelectedModelMetadata(ctx, client, primary) entries := make([]map[string]any, 0, len(models)) for i, model := range models { - contextWindow := codexAppModelContextWindow(ctx, client, model) - entries = append(entries, codexAppCatalogEntry(model, contextWindow, i, baseInstructions)) + metadata := codexAppDefaultModelMetadata() + if model == primary { + metadata = primaryMetadata + } + entries = append(entries, codexAppCatalogEntry(model, metadata, i, baseInstructions)) } data, err := json.MarshalIndent(map[string]any{"models": entries}, "", " ") @@ -266,7 +311,7 @@ func writeCodexAppModelCatalog(path string, models []string) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } - return codexWriteWithBackup(path, append(data, '\n'), codexAppIntegrationName) + return fileutil.WriteWithBackup(path, append(data, '\n'), codexAppIntegrationName) } func codexAppCatalogModelNames(primary string, fallback []string) []string { @@ -297,18 +342,34 @@ func codexAppTagModelNames() []string { return models } -func codexAppModelContextWindow(ctx context.Context, client *api.Client, model string) int { - resp, err := client.Show(ctx, &api.ShowRequest{Model: model}) - if err != nil { - return 272_000 - } - if n, ok := modelInfoContextLength(resp.ModelInfo); ok { - return n - } - return 272_000 +type codexAppModelMetadata struct { + contextWindow int + inputModalities []string } -func codexAppCatalogEntry(model string, contextWindow, priority int, baseInstructions string) map[string]any { +func codexAppDefaultModelMetadata() codexAppModelMetadata { + return codexAppModelMetadata{ + contextWindow: 272_000, + inputModalities: []string{"text"}, + } +} + +func codexAppSelectedModelMetadata(ctx context.Context, client *api.Client, model string) codexAppModelMetadata { + metadata := codexAppDefaultModelMetadata() + resp, err := client.Show(ctx, &api.ShowRequest{Model: model}) + if err != nil { + return metadata + } + if n, ok := modelInfoContextLength(resp.ModelInfo); ok { + metadata.contextWindow = n + } + if slices.Contains(resp.Capabilities, modelpkg.CapabilityVision) { + metadata.inputModalities = []string{"text", "image"} + } + return metadata +} + +func codexAppCatalogEntry(model string, metadata codexAppModelMetadata, priority int, baseInstructions string) map[string]any { return map[string]any{ "slug": model, "display_name": model, @@ -333,12 +394,12 @@ func codexAppCatalogEntry(model string, contextWindow, priority int, baseInstruc "truncation_policy": map[string]any{"mode": "bytes", "limit": 10_000}, "supports_parallel_tool_calls": false, "supports_image_detail_original": false, - "context_window": contextWindow, - "max_context_window": contextWindow, + "context_window": metadata.contextWindow, + "max_context_window": metadata.contextWindow, "auto_compact_token_limit": nil, "effective_context_window_percent": 95, "experimental_supported_tools": []any{}, - "input_modalities": []string{"text", "image"}, + "input_modalities": metadata.inputModalities, "supports_search_tool": false, } } @@ -561,29 +622,14 @@ func defaultCodexAppOpenStartAppID(appID string) error { func defaultCodexAppQuitApp() error { if codexAppGOOS == "windows" { script := `Get-Process Codex -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | ForEach-Object { [void]$_.CloseMainWindow() }` - scriptErr := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Run() - codexAppSleep(500 * time.Millisecond) - if err := defaultCodexAppTerminateProcesses(); err != nil { - if scriptErr != nil { - return fmt.Errorf("quit script failed: %v; terminate failed: %w", scriptErr, err) - } - return err - } - return nil + return exec.Command("powershell.exe", "-NoProfile", "-Command", script).Run() } scriptErr := exec.Command("osascript", "-e", `tell application "Codex" to quit`).Run() if scriptErr != nil { scriptErr = exec.Command("osascript", "-e", `tell application id "`+codexAppBundleID+`" to quit`).Run() } - codexAppSleep(500 * time.Millisecond) - if err := defaultCodexAppTerminateProcesses(); err != nil { - if scriptErr != nil { - return fmt.Errorf("quit script failed: %v; terminate failed: %w", scriptErr, err) - } - return err - } - return nil + return scriptErr } func defaultCodexAppIsRunning() bool { @@ -601,37 +647,6 @@ func defaultCodexAppIsRunning() bool { } } -func defaultCodexAppTerminateProcesses() error { - pids := codexAppMatchingProcessIDs() - if codexAppGOOS == "windows" { - if len(pids) == 0 { - return nil - } - ids := make([]string, 0, len(pids)) - for _, pid := range pids { - ids = append(ids, strconv.Itoa(pid)) - } - script := "Stop-Process -Id " + strings.Join(ids, ",") + " -ErrorAction SilentlyContinue" - return exec.Command("powershell.exe", "-NoProfile", "-Command", script).Run() - } - - var failures []string - for _, pid := range pids { - process, err := os.FindProcess(pid) - if err != nil { - failures = append(failures, fmt.Sprintf("%d: %v", pid, err)) - continue - } - if err := process.Signal(syscall.SIGTERM); err != nil && !strings.Contains(err.Error(), "process already finished") { - failures = append(failures, fmt.Sprintf("%d: %v", pid, err)) - } - } - if len(failures) > 0 { - return fmt.Errorf("%s", strings.Join(failures, "; ")) - } - return nil -} - func codexAppMatchingProcessIDs() []int { if codexAppGOOS == "windows" { return codexAppWindowsMatchingProcessIDs() @@ -704,6 +719,15 @@ func defaultCodexAppStartAppID() string { return strings.TrimSpace(string(out)) } +func defaultCodexAppCanOpenBundleID() bool { + if codexAppGOOS != "darwin" { + return false + } + query := fmt.Sprintf("kMDItemCFBundleIdentifier == %q", codexAppBundleID) + out, err := exec.Command("mdfind", query).Output() + return err == nil && strings.TrimSpace(string(out)) != "" +} + func codexAppProcessMatches(command string) bool { if strings.Contains(command, `\Codex.exe`) && strings.Contains(command, " --type=") { return false @@ -732,18 +756,21 @@ func codexNormalizeURL(raw string) string { } func codexAppRootStillManaged(text string) bool { - if codexAppIsOwnedProfileName(codexRootStringValue(text, "profile")) { - return true + config, err := codexParseConfig(text) + if err != nil { + return false } - if codexAppIsOwnedProfileName(codexRootStringValue(text, "model_provider")) { - return true - } - return false + return codexAppIsOwnedProfileName(config.RootString(codexRootProfileKey)) || + codexAppIsOwnedProfileName(config.RootString(codexRootModelProviderKey)) } func codexAppRootReferencesOwnedConfig(text string) bool { - return codexRootStringValue(text, "profile") == codexAppProfileName || - codexRootStringValue(text, "model_provider") == codexAppProfileName + config, err := codexParseConfig(text) + if err != nil { + return false + } + return config.RootString(codexRootProfileKey) == codexAppProfileName || + config.RootString(codexRootModelProviderKey) == codexAppProfileName } func codexAppRootReferencesCatalog(text string) bool { @@ -751,7 +778,11 @@ func codexAppRootReferencesCatalog(text string) bool { if err != nil { return false } - return codexRootStringValue(text, "model_catalog_json") == catalogPath + config, err := codexParseConfig(text) + if err != nil { + return false + } + return config.RootString(codexRootModelCatalogJSONKey) == catalogPath } func codexAppRemoveOwnedSections(text string) string { @@ -770,15 +801,22 @@ func codexAppRemoveOwnedCatalogIfUnused(text string) { } func codexAppRemoveOwnedRootValues(text string) string { - if !codexAppRootStillManaged(text) { + config, err := codexParseConfig(text) + if err != nil { return text } - text = codexRemoveRootValue(text, "profile") - if codexAppIsOwnedProfileName(codexRootStringValue(text, "model_provider")) { - text = codexRemoveRootValue(text, "model_provider") + modelProvider := config.RootString(codexRootModelProviderKey) + modelCatalogJSON := config.RootString(codexRootModelCatalogJSONKey) + if !codexAppIsOwnedProfileName(config.RootString(codexRootProfileKey)) && !codexAppIsOwnedProfileName(modelProvider) { + return text } - if catalogPath, err := codexAppModelCatalogPath(); err == nil && codexRootStringValue(text, "model_catalog_json") == catalogPath { - text = codexRemoveRootValue(text, "model_catalog_json") + text = codexRemoveRootValue(text, codexRootProfileKey) + text = codexRemoveRootValue(text, codexRootModelKey) + if codexAppIsOwnedProfileName(modelProvider) { + text = codexRemoveRootValue(text, codexRootModelProviderKey) + } + if catalogPath, err := codexAppModelCatalogPath(); err == nil && modelCatalogJSON == catalogPath { + text = codexRemoveRootValue(text, codexRootModelCatalogJSONKey) } return text } @@ -787,10 +825,10 @@ func codexAppRestoreRootValues(text string, state codexAppRestoreState) string { if !codexAppRootStillManaged(text) { return text } - text = codexRestoreRootStringValue(text, "profile", state.HadProfile, state.Profile) - text = codexRestoreRootStringValue(text, "model", state.HadModel, state.Model) - text = codexRestoreRootStringValue(text, "model_provider", state.HadModelProvider, state.ModelProvider) - text = codexRestoreRootStringValue(text, "model_catalog_json", state.HadModelCatalogJSON, state.ModelCatalogJSON) + text = codexRestoreRootStringValue(text, codexRootProfileKey, state.HadProfile, state.Profile) + text = codexRestoreRootStringValue(text, codexRootModelKey, state.HadModel, state.Model) + text = codexRestoreRootStringValue(text, codexRootModelProviderKey, state.HadModelProvider, state.ModelProvider) + text = codexRestoreRootStringValue(text, codexRootModelCatalogJSONKey, state.HadModelCatalogJSON, state.ModelCatalogJSON) return text } @@ -818,6 +856,10 @@ func saveCodexAppRestoreState(configPath string) error { return err } + if !configExists { + return writeCodexAppRestoreState(codexAppRestoreState{}) + } + statePath := codexAppRestoreStatePath() if stateData, err := os.ReadFile(statePath); err == nil { hasRootConfig, err := codexAppRestoreStateHasRootConfig(stateData) @@ -825,9 +867,9 @@ func saveCodexAppRestoreState(configPath string) error { return err } if hasRootConfig { - return nil - } - if !configExists { + if configExists && !codexAppRootStillManaged(configText) { + return writeCodexAppRestoreState(codexAppRestoreStateFromText(configText)) + } return nil } var existing codexAppRestoreState @@ -842,9 +884,6 @@ func saveCodexAppRestoreState(configPath string) error { return err } - if !configExists { - return writeCodexAppRestoreState(codexAppRestoreState{}) - } return writeCodexAppRestoreState(codexAppRestoreStateFromText(configText)) } @@ -860,10 +899,14 @@ func codexAppRestoreStateHasRootConfig(data []byte) (bool, error) { } func codexAppRestoreStateFromText(text string) codexAppRestoreState { - profile, hadProfile := codexRootStringValueOK(text, "profile") - model, hadModel := codexRootStringValueOK(text, "model") - modelProvider, hadModelProvider := codexRootStringValueOK(text, "model_provider") - modelCatalogJSON, hadModelCatalogJSON := codexRootStringValueOK(text, "model_catalog_json") + config, err := codexParseConfig(text) + if err != nil { + return codexAppRestoreState{} + } + profile, hadProfile := config.RootStringOK(codexRootProfileKey) + model, hadModel := config.RootStringOK(codexRootModelKey) + modelProvider, hadModelProvider := config.RootStringOK(codexRootModelProviderKey) + modelCatalogJSON, hadModelCatalogJSON := config.RootStringOK(codexRootModelCatalogJSONKey) return codexAppRestoreState{ HadProfile: hadProfile, Profile: profile, @@ -892,7 +935,7 @@ func writeCodexAppRestoreState(state codexAppRestoreState) error { if err != nil { return err } - return codexWriteWithBackup(path, data, codexAppIntegrationName) + return fileutil.WriteWithBackup(path, data, codexAppIntegrationName) } func loadCodexAppRestoreState() (codexAppRestoreState, error) { @@ -907,6 +950,13 @@ func loadCodexAppRestoreState() (codexAppRestoreState, error) { return state, nil } +func removeCodexAppRestoreState() error { + if err := os.Remove(codexAppRestoreStatePath()); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + func codexAppRestoreStatePath() string { home, err := os.UserHomeDir() if err != nil { diff --git a/cmd/launch/codex_app_test.go b/cmd/launch/codex_app_test.go index ac25b6776..4fb68374a 100644 --- a/cmd/launch/codex_app_test.go +++ b/cmd/launch/codex_app_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/ollama/ollama/cmd/internal/fileutil" ) @@ -31,6 +32,7 @@ func withCodexAppProcessHooks(t *testing.T, isRunning func() bool, quit func() e oldOpenStart := codexAppOpenStart oldRunPath := codexAppRunPath oldStartID := codexAppStartID + oldCanOpenID := codexAppCanOpenID codexAppIsRunning = isRunning codexAppQuitApp = quit codexAppOpenApp = open @@ -42,6 +44,7 @@ func withCodexAppProcessHooks(t *testing.T, isRunning func() bool, quit func() e codexAppOpenStart = oldOpenStart codexAppRunPath = oldRunPath codexAppStartID = oldStartID + codexAppCanOpenID = oldCanOpenID }) } @@ -129,6 +132,23 @@ func TestCodexAppInstalledUsesWindowsStartMenuFallback(t *testing.T) { } } +func TestCodexAppInstalledUsesMacBundleIDFallback(t *testing.T) { + withCodexAppPlatform(t, "darwin") + + oldCanOpenID := codexAppCanOpenID + oldStat := codexAppStat + codexAppCanOpenID = func() bool { return true } + codexAppStat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist } + t.Cleanup(func() { + codexAppCanOpenID = oldCanOpenID + codexAppStat = oldStat + }) + + if !codexAppInstalled() { + t.Fatal("expected macOS LaunchServices bundle id fallback to count as installed") + } +} + func TestCodexAppConfigureActivatesOllamaProfile(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) @@ -402,6 +422,10 @@ func TestCodexAppCurrentModelReadsManagedRootConfig(t *testing.T) { content := "" + `model = "qwen3:8b"` + "\n" + fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n\n" + + fmt.Sprintf(`model_catalog_json = %q`, mustWriteCodexAppTestCatalog(t, "qwen3:8b")) + "\n\n" + + codexProfileHeaderFor(codexAppProfileName) + "\n" + + fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" + + fmt.Sprintf(`model_catalog_json = %q`, mustCodexAppModelCatalogPath(t)) + "\n\n" + codexProviderHeaderFor(codexAppProfileName) + "\n" + `base_url = "http://127.0.0.1:11434/v1/"` + "\n" if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { @@ -413,6 +437,92 @@ func TestCodexAppCurrentModelReadsManagedRootConfig(t *testing.T) { } } +func TestCodexAppCurrentModelRequiresHealthyCatalog(t *testing.T) { + for _, tt := range []struct { + name string + rootCatalog bool + profileCatalog bool + writeCatalog bool + catalogData string + }{ + { + name: "missing catalog reference", + rootCatalog: false, + profileCatalog: true, + writeCatalog: true, + catalogData: `{"models":[{"slug":"llama3.2"}]}`, + }, + { + name: "deleted catalog file", + rootCatalog: true, + profileCatalog: true, + writeCatalog: false, + catalogData: `{"models":[{"slug":"llama3.2"}]}`, + }, + { + name: "missing profile catalog reference", + rootCatalog: true, + profileCatalog: false, + writeCatalog: true, + catalogData: `{"models":[{"slug":"llama3.2"}]}`, + }, + { + name: "corrupt catalog file", + rootCatalog: true, + profileCatalog: true, + writeCatalog: true, + catalogData: `{"models":`, + }, + { + name: "empty catalog", + rootCatalog: true, + profileCatalog: true, + writeCatalog: true, + catalogData: `{"models":[]}`, + }, + } { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + catalogPath := mustCodexAppModelCatalogPath(t) + if tt.writeCatalog { + if err := os.WriteFile(catalogPath, []byte(tt.catalogData), 0o644); err != nil { + t.Fatal(err) + } + } + var rootCatalogLine, profileCatalogLine string + if tt.rootCatalog { + rootCatalogLine = fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n" + } + if tt.profileCatalog { + profileCatalogLine = fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n" + } + content := "" + + `model = "llama3.2"` + "\n" + + fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" + + rootCatalogLine + "\n" + + codexProfileHeaderFor(codexAppProfileName) + "\n" + + fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" + + profileCatalogLine + "\n" + + codexProviderHeaderFor(codexAppProfileName) + "\n" + + `base_url = "http://127.0.0.1:11434/v1/"` + "\n" + if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + if got := (&CodexApp{}).CurrentModel(); got != "" { + t.Fatalf("CurrentModel = %q, want empty when catalog is unhealthy", got) + } + }) + } +} + func TestCodexAppConfigurePopulatesCatalogFromTagsAndShow(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) @@ -430,7 +540,7 @@ func TestCodexAppConfigurePopulatesCatalogFromTagsAndShow(t *testing.T) { t.Fatalf("decode show request: %v", err) } showCalls[req.Model]++ - fmt.Fprintf(w, `{"model_info":{"general.context_length":%d}}`, 65536+len(req.Model)) + fmt.Fprintf(w, `{"model_info":{"general.context_length":%d},"capabilities":["vision"]}`, 65536+len(req.Model)) default: http.NotFound(w, r) } @@ -475,11 +585,22 @@ func TestCodexAppConfigurePopulatesCatalogFromTagsAndShow(t *testing.T) { if !ok || len(levels) != 0 { t.Fatalf("supported_reasoning_levels for %q = %v, want empty list", slug, model["supported_reasoning_levels"]) } - if model["context_window"] != float64(65536+len(slug)) { - t.Fatalf("context_window for %q = %v", slug, model["context_window"]) + wantContext := float64(272000) + wantModalities := []string{"text"} + wantShowCalls := 0 + if slug == "gemma4" { + wantContext = float64(65536 + len(slug)) + wantModalities = []string{"text", "image"} + wantShowCalls = 1 } - if showCalls[slug] != 1 { - t.Fatalf("show calls for %q = %d, want 1", slug, showCalls[slug]) + if model["context_window"] != wantContext { + t.Fatalf("context_window for %q = %v, want %v", slug, model["context_window"], wantContext) + } + if got := catalogInputModalities(model); strings.Join(got, ",") != strings.Join(wantModalities, ",") { + t.Fatalf("input_modalities for %q = %v, want %v", slug, got, wantModalities) + } + if showCalls[slug] != wantShowCalls { + t.Fatalf("show calls for %q = %d, want %d", slug, showCalls[slug], wantShowCalls) } } } @@ -594,6 +715,137 @@ func TestCodexAppRestoreRestoresPreviousProfile(t *testing.T) { } } +func TestCodexAppRestoreMissingConfigRemovesRestoreState(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withCodexAppPlatform(t, "darwin") + + var openCalls int + withCodexAppProcessHooks(t, + func() bool { return false }, + func() error { return nil }, + func() error { + openCalls++ + return nil + }, + ) + + if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil { + t.Fatal(err) + } + restoreState := `{"had_profile":true,"profile":"stale","had_model":true,"model":"old","had_model_provider":true,"model_provider":"openai","had_model_catalog_json":false}` + if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil { + t.Fatal(err) + } + + if err := (&CodexApp{}).Restore(); err != nil { + t.Fatalf("Restore returned error: %v", err) + } + + if _, err := os.Stat(codexAppRestoreStatePath()); !os.IsNotExist(err) { + t.Fatalf("restore state should be removed when config is missing, got err=%v", err) + } + if openCalls != 1 { + t.Fatalf("open calls = %d, want 1", openCalls) + } +} + +func TestCodexAppConfigureMissingConfigReplacesStaleRestoreState(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999") + + if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil { + t.Fatal(err) + } + restoreState := `{"had_profile":true,"profile":"stale","had_model":true,"model":"old","had_model_provider":true,"model_provider":"openai","had_model_catalog_json":false}` + if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil { + t.Fatal(err) + } + + if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil { + t.Fatalf("ConfigureWithModels returned error: %v", err) + } + + state, err := loadCodexAppRestoreState() + if err != nil { + t.Fatal(err) + } + if state.HadProfile || state.HadModel || state.HadModelProvider || state.HadModelCatalogJSON { + t.Fatalf("restore state = %+v, want empty snapshot when config was missing", state) + } +} + +func TestCodexAppConfigureRefreshesRestoreStateAfterManualProfileSwitch(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999") + withCodexAppPlatform(t, "darwin") + + var openCalls int + withCodexAppProcessHooks(t, + func() bool { return false }, + func() error { return nil }, + func() error { + openCalls++ + return nil + }, + ) + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + initial := "" + + `profile = "default"` + "\n" + + `model = "gpt-5.5"` + "\n" + + `model_provider = "openai"` + "\n\n" + + "[profiles.default]\n" + + `model = "gpt-5.5"` + "\n" + if err := os.WriteFile(configPath, []byte(initial), 0o644); err != nil { + t.Fatal(err) + } + + if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil { + t.Fatalf("first ConfigureWithModels returned error: %v", err) + } + + manual := "" + + `profile = "manual"` + "\n" + + `model = "manual-model"` + "\n" + + `model_provider = "openai"` + "\n\n" + + "[profiles.manual]\n" + + `model = "manual-model"` + "\n" + if err := os.WriteFile(configPath, []byte(manual), 0o644); err != nil { + t.Fatal(err) + } + + if err := (&CodexApp{}).ConfigureWithModels("qwen3:8b", []string{"qwen3:8b"}); err != nil { + t.Fatalf("second ConfigureWithModels returned error: %v", err) + } + if err := (&CodexApp{}).Restore(); err != nil { + t.Fatalf("Restore returned error: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + restored := string(data) + for key, want := range map[string]string{ + "profile": "manual", + "model": "manual-model", + "model_provider": "openai", + } { + if got := codexRootStringValue(restored, key); got != want { + t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, restored) + } + } + if openCalls != 1 { + t.Fatalf("open calls = %d, want 1", openCalls) + } +} + func TestCodexAppRestoreRejectsMalformedTomlWithoutWriting(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) @@ -630,6 +882,74 @@ func TestCodexAppRestoreRejectsMalformedTomlWithoutWriting(t *testing.T) { } } +func TestCodexAppRestoreWithoutStateRemovesManagedRootModel(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withCodexAppPlatform(t, "darwin") + + var openCalls int + withCodexAppProcessHooks(t, + func() bool { return false }, + func() error { return nil }, + func() error { + openCalls++ + return nil + }, + ) + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + catalogPath, err := codexAppModelCatalogPath() + if err != nil { + t.Fatal(err) + } + existing := "" + + fmt.Sprintf(`profile = %q`, codexAppProfileName) + "\n" + + `model = "llama3.2"` + "\n" + + fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" + + fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n\n" + + codexProfileHeaderFor(codexAppProfileName) + "\n" + + `model = "llama3.2"` + "\n" + + fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n\n" + + codexProviderHeaderFor(codexAppProfileName) + "\n" + + `base_url = "http://127.0.0.1:11434/v1/"` + "\n" + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(catalogPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(catalogPath, []byte(`{"models":[]}`), 0o644); err != nil { + t.Fatal(err) + } + + if err := (&CodexApp{}).Restore(); err != nil { + t.Fatalf("Restore returned error: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + content := string(data) + for _, key := range []string{"profile", "model", "model_provider", "model_catalog_json"} { + if got, ok := codexRootStringValueOK(content, key); ok { + t.Fatalf("root %s should be removed, got %q in:\n%s", key, got, content) + } + } + if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) || strings.Contains(content, codexProviderHeaderFor(codexAppProfileName)) { + t.Fatalf("owned app sections should be removed, got:\n%s", content) + } + if _, err := os.Stat(catalogPath); !os.IsNotExist(err) { + t.Fatalf("owned catalog should be removed when unused, err=%v", err) + } + if openCalls != 1 { + t.Fatalf("open calls = %d, want 1", openCalls) + } +} + func TestCodexAppRestoreDoesNotStompUserChangedRootConfig(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) @@ -789,6 +1109,47 @@ func TestCodexAppRunRestartsRunningAppWhenConfirmed(t *testing.T) { } } +func TestCodexAppRunWaitsForGracefulExitBeforeReopening(t *testing.T) { + withCodexAppPlatform(t, "darwin") + restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) + defer restoreConfirm() + + oldSleep := codexAppSleep + t.Cleanup(func() { + codexAppSleep = oldSleep + }) + + running := true + var quitCalls, openCalls, sleepCalls int + codexAppSleep = func(time.Duration) { + sleepCalls++ + if sleepCalls == 2 { + running = false + } + } + withCodexAppProcessHooks(t, + func() bool { return running }, + func() error { + quitCalls++ + return nil + }, + func() error { + openCalls++ + return nil + }, + ) + + if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if quitCalls != 1 || openCalls != 1 { + t.Fatalf("quit/open calls = %d/%d, want 1/1", quitCalls, openCalls) + } + if sleepCalls == 0 { + t.Fatal("expected restart to wait for Codex to exit before reopening") + } +} + func TestCodexAppRunOpensOnWindowsWhenNotRunning(t *testing.T) { withCodexAppPlatform(t, "windows") @@ -900,3 +1261,43 @@ func catalogSlugs(models []map[string]any) []string { } return slugs } + +func catalogInputModalities(entry map[string]any) []string { + raw, _ := entry["input_modalities"].([]any) + modalities := make([]string, 0, len(raw)) + for _, item := range raw { + if modality, _ := item.(string); modality != "" { + modalities = append(modalities, modality) + } + } + return modalities +} + +func mustCodexAppModelCatalogPath(t *testing.T) string { + t.Helper() + catalogPath, err := codexAppModelCatalogPath() + if err != nil { + t.Fatal(err) + } + return catalogPath +} + +func mustWriteCodexAppTestCatalog(t *testing.T, slugs ...string) string { + t.Helper() + catalogPath := mustCodexAppModelCatalogPath(t) + if err := os.MkdirAll(filepath.Dir(catalogPath), 0o755); err != nil { + t.Fatal(err) + } + models := make([]map[string]string, 0, len(slugs)) + for _, slug := range slugs { + models = append(models, map[string]string{"slug": slug}) + } + data, err := json.Marshal(map[string]any{"models": models}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(catalogPath, data, 0o644); err != nil { + t.Fatal(err) + } + return catalogPath +} diff --git a/cmd/launch/integrations_test.go b/cmd/launch/integrations_test.go index 5b0a9dcfb..8a842eea5 100644 --- a/cmd/launch/integrations_test.go +++ b/cmd/launch/integrations_test.go @@ -1831,6 +1831,23 @@ func TestListIntegrationInfos(t *testing.T) { } }) + t.Run("prioritizes primary launcher integrations", func(t *testing.T) { + got := make([]string, 0, len(infos)) + for _, info := range infos { + got = append(got, info.Name) + } + wantPrefix := []string{"claude", "codex-app", "hermes", "openclaw"} + if codexAppSupported() != nil { + wantPrefix = []string{"claude", "hermes", "openclaw", "opencode"} + } + if len(got) < len(wantPrefix) { + t.Fatalf("expected at least %d integrations, got %v", len(wantPrefix), got) + } + if diff := compareStrings(got[:len(wantPrefix)], wantPrefix); diff != "" { + t.Fatalf("unexpected primary launcher order: %s", diff) + } + }) + t.Run("all fields populated", func(t *testing.T) { for _, info := range infos { if info.Name == "" { diff --git a/cmd/launch/launch.go b/cmd/launch/launch.go index ade6174a3..0cb81c14e 100644 --- a/cmd/launch/launch.go +++ b/cmd/launch/launch.go @@ -285,17 +285,17 @@ Flags and extra arguments require an integration name. Supported integrations: claude Claude Code - cline Cline - codex Codex codex-app Codex App (aliases: codex-desktop, codex-gui) + hermes Hermes Agent + openclaw OpenClaw (aliases: clawdbot, moltbot) + opencode OpenCode + codex Codex copilot Copilot CLI (aliases: copilot-cli) droid Droid - hermes Hermes Agent kimi Kimi Code CLI - opencode OpenCode - openclaw OpenClaw (aliases: clawdbot, moltbot) pi Pi pool Pool + cline Cline vscode VS Code (aliases: code) Examples: @@ -772,7 +772,13 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam return nil } - if needsConfigure || req.ModelOverride != "" || target != current || !savedMatchesModels(saved, []string{target}) { + // current is the live managed app config; target may come from saved launch + // state. Rewrite when the live config is missing or has drifted so the app + // config converges with the model which launch is about to use. + liveConfigMissing := current == "" + liveConfigDrifted := current != "" && target != current + configured := false + if needsConfigure || req.ModelOverride != "" || liveConfigMissing || liveConfigDrifted || !savedMatchesModels(saved, []string{target}) { configureModels, err := c.managedSingleConfigureModels(ctx, managed, target) if err != nil { return err @@ -785,6 +791,7 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam return err } } + configured = true } if !managedIntegrationOnboarded(saved, managed) { @@ -796,6 +803,12 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam } } + if configured { + if !printConfigurationSuccess(managed) { + printRestoreHint(managed) + } + } + if req.ConfigureOnly { return nil } diff --git a/cmd/launch/launch_test.go b/cmd/launch/launch_test.go index 3d0a8ba2e..12dadaa9b 100644 --- a/cmd/launch/launch_test.go +++ b/cmd/launch/launch_test.go @@ -68,15 +68,18 @@ func (r *launcherRestorableRunner) RestoreSuccessMessage() string { } type launcherManagedRunner struct { - paths []string - currentModel string - configured []string - ranModel string - onboarded bool - onboardCalls int - onboardingComplete bool - refreshCalls int - refreshErr error + paths []string + currentModel string + configured []string + ranModel string + onboarded bool + onboardCalls int + onboardingComplete bool + refreshCalls int + refreshErr error + restoreHint string + configSuccessMessage string + skipModelReadiness bool } func (r *launcherManagedRunner) Run(model string, args []string) error { @@ -110,6 +113,14 @@ func (r *launcherManagedRunner) RefreshRuntimeAfterConfigure() error { return r.refreshErr } +func (r *launcherManagedRunner) RestoreHint() string { return r.restoreHint } + +func (r *launcherManagedRunner) ConfigurationSuccessMessage() string { + return r.configSuccessMessage +} + +func (r *launcherManagedRunner) SkipModelReadiness() bool { return r.skipModelReadiness } + type launcherHeadlessManagedRunner struct { launcherManagedRunner } @@ -480,6 +491,83 @@ func TestLaunchIntegration_ManagedSingleIntegrationConfigOnlySkipsFinalRun(t *te } } +func TestLaunchIntegration_ManagedSingleIntegrationPrintsConfigurationSuccessAfterConfigure(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, true) + withLauncherHooks(t) + + runner := &launcherManagedRunner{ + configSuccessMessage: "configured successfully\nrestore via success message", + restoreHint: "run restore command", + skipModelReadiness: true, + } + withIntegrationOverride(t, "stubmanaged", runner) + + stderr := captureStderr(t, func() { + if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ + Name: "stubmanaged", + ModelOverride: "gemma4", + ForceConfigure: true, + ConfigureOnly: true, + }); 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 !strings.Contains(stderr, "configured successfully") { + t.Fatalf("expected configuration success in stderr, got %q", stderr) + } + if !strings.Contains(stderr, "restore via success message") { + t.Fatalf("expected restore guidance in configuration success, got %q", stderr) + } + if strings.Contains(stderr, "run restore command") { + t.Fatalf("restore hint should not print separately after configure, got %q", stderr) + } +} + +func TestLaunchIntegration_ManagedSingleIntegrationDoesNotPrintRestoreHintWhenUnchanged(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withInteractiveSession(t, true) + withLauncherHooks(t) + + runner := &launcherManagedRunner{ + currentModel: "gemma4", + onboardingComplete: true, + configSuccessMessage: "configured successfully", + restoreHint: "run restore command", + skipModelReadiness: true, + } + 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 integration onboarded: %v", err) + } + + stderr := captureStderr(t, func() { + if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil { + t.Fatalf("LaunchIntegration returned error: %v", err) + } + }) + + if len(runner.configured) != 0 { + t.Fatalf("expected Configure to be skipped when saved matches, got %v", runner.configured) + } + if strings.Contains(stderr, "configured successfully") { + t.Fatalf("configuration success should not print when config is unchanged, got %q", stderr) + } + if strings.Contains(stderr, "run restore command") { + t.Fatalf("restore hint should not print when config is unchanged, got %q", stderr) + } +} + func TestLaunchIntegration_ManagedSingleIntegrationForceConfigureUsesModelOverride(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) diff --git a/cmd/launch/registry.go b/cmd/launch/registry.go index 666479b6f..968d7b4bf 100644 --- a/cmd/launch/registry.go +++ b/cmd/launch/registry.go @@ -33,7 +33,7 @@ type IntegrationInfo struct { Description string } -var launcherIntegrationOrder = []string{"claude", "openclaw", "hermes", "codex-app", "opencode", "codex", "copilot", "droid", "pi", "pool"} +var launcherIntegrationOrder = []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "codex", "copilot", "droid", "pi", "pool"} var integrationSpecs = []*IntegrationSpec{ { diff --git a/cmd/tui/tui_test.go b/cmd/tui/tui_test.go index b385fe741..7f2504616 100644 --- a/cmd/tui/tui_test.go +++ b/cmd/tui/tui_test.go @@ -29,6 +29,13 @@ func launcherTestState() *launch.LauncherState { Selectable: true, Changeable: true, }, + "codex-app": { + Name: "codex-app", + DisplayName: "Codex App", + Description: "OpenAI's desktop coding agent", + Selectable: true, + Changeable: true, + }, "openclaw": { Name: "openclaw", DisplayName: "OpenClaw", @@ -122,12 +129,25 @@ func expectedExpandedSequence(state *launch.LauncherState) []string { func TestMenuRendersPinnedItemsAndMore(t *testing.T) { state := launcherTestState() menu := newModel(state) + wantPrefix := []string{"run", "claude", "codex-app", "hermes", "openclaw"} + if findMenuCursorByIntegration(menu.items, "codex-app") == -1 { + wantPrefix = []string{"run", "claude", "hermes", "openclaw", "opencode"} + } + if got := integrationSequence(menu.items); len(got) < len(wantPrefix) { + t.Fatalf("expected at least %d menu items, got %v", len(wantPrefix), got) + } else if diff := compareStrings(got[:len(wantPrefix)], wantPrefix); diff != "" { + t.Fatalf("unexpected primary TUI order: %s", diff) + } + view := menu.View() - for _, want := range []string{"Chat with a model", "Launch Claude Code", "Launch OpenClaw", "Launch Hermes Agent", "More..."} { + for _, want := range []string{"Chat with a model", "Launch Claude Code", "Launch Hermes Agent", "Launch OpenClaw", "More..."} { if !strings.Contains(view, want) { t.Fatalf("expected menu view to contain %q\n%s", want, view) } } + if findMenuCursorByIntegration(menu.items, "codex-app") != -1 && !strings.Contains(view, "Launch Codex App") { + t.Fatalf("expected menu view to contain Codex App\n%s", view) + } if strings.Contains(view, "Launch Claude Desktop") { t.Fatalf("expected hidden Claude Desktop to be absent\n%s", view) } diff --git a/docs/integrations/codex-app.mdx b/docs/integrations/codex-app.mdx index dc7372309..f93bdbadc 100644 --- a/docs/integrations/codex-app.mdx +++ b/docs/integrations/codex-app.mdx @@ -34,8 +34,6 @@ To restore your usual Codex profile: ollama launch codex-app --restore ``` -## How it works +## Backups -`ollama launch codex-app` writes an `ollama-launch` profile to `~/.codex/config.toml`, sets it as the active Codex profile, and points Codex App at Ollama's OpenAI-compatible `/v1` endpoint. On Windows, `~` resolves to your user profile directory. - -It also writes `~/.codex/ollama-launch-models.json` from the models returned by Ollama's `/api/tags` endpoint, then sets `model_catalog_json` on both the top-level app config and the managed profile so Codex App can show the Ollama model names in its picker. +Before overwriting Codex App config files, Ollama Launch saves backups under `~/.ollama/backup/codex-app/`. On Windows, `~` resolves to your user profile directory.