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 000000000..72e9baf76 Binary files /dev/null and b/app/ui/app/public/launch-icons/codex-app.png differ 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/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..7bd03e54c 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" ) @@ -16,7 +18,15 @@ type Codex struct{} func (c *Codex) String() string { return "Codex" } -const codexProfileName = "ollama-launch" +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} @@ -49,76 +59,483 @@ 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 + 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 := "" + if readErr == nil { + text = string(content) + } else if !os.IsNotExist(readErr) { + return readErr + } + parsed, err := codexParseConfig(text) + if err != nil { + return err + } + + model := strings.TrimSpace(opts.model) + if model == "" { + model = parsed.ProfileString(profileName, codexRootModelKey) + } + modelCatalogPath := strings.TrimSpace(opts.modelCatalogPath) + if modelCatalogPath == "" { + modelCatalogPath = parsed.ProfileString(profileName, codexRootModelCatalogJSONKey) + } + + profileLines := []string{} + if model != "" { + profileLines = append(profileLines, fmt.Sprintf("%s = %q", codexRootModelKey, model)) + } + profileLines = append(profileLines, + fmt.Sprintf("openai_base_url = %q", baseURL), + fmt.Sprintf("%s = %q", codexRootModelProviderKey, profileName), + ) + if opts.forceAPIAuth { + profileLines = append(profileLines, `forced_login_method = "api"`) + } + if modelCatalogPath != "" { + profileLines = append(profileLines, fmt.Sprintf("%s = %q", codexRootModelCatalogJSONKey, 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: profileHeader, + lines: profileLines, }, { - header: fmt.Sprintf("[model_providers.%s]", codexProfileName), + header: providerHeader, 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, codexRootProfileKey, profileName) } - - 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, codexRootModelKey, model) + } + text = codexSetRootStringValue(text, codexRootModelProviderKey, profileName) + if modelCatalogPath != "" { + text = codexSetRootStringValue(text, codexRootModelCatalogJSONKey, modelCatalogPath) } } - return os.WriteFile(configPath, []byte(text), 0o644) + for _, s := range sections { + text = codexUpsertSection(text, s.header, s.lines) + } + parsed, err = codexParseConfig(text) + if err != nil { + return err + } + 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 fileutil.WriteWithBackup(configPath, []byte(text), opts.backupIntegration) +} + +func codexLaunchProfileName(opts codexLaunchProfileOptions) string { + if name := strings.TrimSpace(opts.profileName); name != "" { + return name + } + return codexProfileName +} + +func codexBaseURL() string { + return strings.TrimRight(envconfig.ConnectableHost().String(), "/") + "/v1/" +} + +func codexProfileHeader() string { + return codexProfileHeaderFor(codexProfileName) +} + +func codexProviderHeader() string { + 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(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, 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 := 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 := 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 := 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 := 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 := config.RootString(codexRootProfileKey); got != profileName { + return fmt.Errorf("generated Codex config missing profile = %q", profileName) + } + } + if opts.setRootModelConfig { + if model != "" { + if got := config.RootString(codexRootModelKey); got != model { + return fmt.Errorf("generated Codex config missing model = %q", model) + } + } + if got := config.RootString(codexRootModelProviderKey); got != profileName { + return fmt.Errorf("generated Codex config missing model_provider = %q", profileName) + } + if modelCatalogPath != "" { + if got := config.RootString(codexRootModelCatalogJSONKey); got != modelCatalogPath { + return fmt.Errorf("generated Codex config missing model_catalog_json = %q", modelCatalogPath) + } + } + } + return nil +} + +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 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:] +} + +type codexParsedConfig struct { + values map[string]any +} + +func (c codexParsedConfig) String(path ...string) (string, bool) { + if len(path) == 0 { + return "", false + } + var current any = c.values + 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 (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 { + return "" + } + value, _ := codexStringValue(text, append(path, key)...) + 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) == "" { + 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 := codexParseConfig(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 := range rootEnd { + 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..454fe90d6 --- /dev/null +++ b/cmd/launch/codex_app.go @@ -0,0 +1,989 @@ +package launch + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "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 ( + 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" + 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 + codexAppCanOpenID = defaultCodexAppCanOpenBundleID + 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, primary, codexAppCatalogModelNames(primary, models)); err != nil { + return err + } + return writeCodexLaunchProfile(configPath, codexLaunchProfileOptions{ + activate: true, + profileName: codexAppProfileName, + setRootModelConfig: true, + model: primary, + modelCatalogPath: catalogPath, + backupIntegration: codexAppIntegrationName, + }) +} + +func (c *CodexApp) CurrentModel() string { + configPath, err := codexConfigPath() + if err != nil { + return "" + } + data, err := os.ReadFile(configPath) + if err != nil { + return "" + } + text := string(data) + parsed, err := codexParseConfig(text) + if err != nil { + return "" + } + for _, profileName := range codexAppManagedProfileNames() { + 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 := parsed.RootString(codexRootProfileKey) + if !codexAppIsManagedProfileName(profileName) { + return "" + } + if parsed.ProfileString(profileName, codexRootModelProviderKey) != profileName { + return "" + } + baseURL := parsed.ProviderString(profileName, "base_url") + if codexNormalizeURL(baseURL) != codexNormalizeURL(codexBaseURL()) { + return "" + } + if !codexAppCatalogHealthy(parsed, profileName) { + return "" + } + return strings.TrimSpace(parsed.ProfileString(profileName, codexRootModelKey)) +} + +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 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) +} + +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) { + if err := removeCodexAppRestoreState(); err != nil { + return codexAppRestoreFailure(configPath, err) + } + return codexAppLaunchOrRestart("Restart Codex to use your usual profile?") + } + return codexAppRestoreFailure(configPath, err) + } + text := string(data) + if err := codexValidateConfigText(text); err != nil { + return codexAppRestoreFailure(configPath, err) + } + + state, stateErr := loadCodexAppRestoreState() + if stateErr == nil { + text = codexAppRestoreRootValues(text, state) + } else if os.IsNotExist(stateErr) { + text = codexAppRemoveOwnedRootValues(text) + } else { + return codexAppRestoreFailure(configPath, stateErr) + } + if !codexAppRootReferencesOwnedConfig(text) { + text = codexAppRemoveOwnedSections(text) + } + + if err := codexValidateConfigText(text); err != nil { + return codexAppRestoreFailure(configPath, err) + } + if err := fileutil.WriteWithBackup(configPath, []byte(text), codexAppIntegrationName); err != nil { + return codexAppRestoreFailure(configPath, err) + } + if err := codexAppRemoveOwnedCatalogIfUnused(text); err != nil { + return codexAppRestoreFailure(configPath, err) + } + if err := removeCodexAppRestoreState(); err != nil { + return codexAppRestoreFailure(configPath, err) + } + return codexAppLaunchOrRestart("Restart Codex to use your usual profile?") +} + +func codexAppRestoreFailure(configPath string, err error) error { + return fmt.Errorf("restore Codex App config: %w\n\nRestore did not complete. Check these files before retrying:\n Codex config: %s\n Restore state: %s\n Model catalog: %s\n Backups: %s", + err, + configPath, + codexAppRestoreStatePath(), + codexAppModelCatalogPathForConfig(configPath), + filepath.Join(fileutil.BackupDir(), codexAppIntegrationName), + ) +} + +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 + } + switch codexAppGOOS { + case "darwin": + return codexAppCanOpenID() + case "windows": + return codexAppIsRunning() || codexAppStartID() != "" + default: + return false + } +} + +func codexAppModelCatalogPath() (string, error) { + configPath, err := codexConfigPath() + if err != nil { + return "", err + } + return codexAppModelCatalogPathForConfig(configPath), nil +} + +func codexAppModelCatalogPathForConfig(configPath string) string { + return filepath.Join(filepath.Dir(configPath), codexAppModelCatalogFilename) +} + +func writeCodexAppModelCatalog(path, primary string, models []string) error { + if len(models) == 0 { + return fmt.Errorf("codex-app model catalog cannot be empty") + } + client := api.NewClient(envconfig.ConnectableHost(), http.DefaultClient) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + baseInstructions := codexAppBaseInstructions() + primaryMetadata := codexAppSelectedModelMetadata(ctx, client, primary) + entries := make([]map[string]any, 0, len(models)) + for i, model := range models { + metadata := codexAppDefaultModelMetadata() + if model == primary { + metadata = primaryMetadata + } + entries = append(entries, codexAppCatalogEntry(model, metadata, 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'), codexAppIntegrationName) +} + +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.ConnectableHost(), 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 +} + +type codexAppModelMetadata struct { + contextWindow int + inputModalities []string +} + +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, + "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": metadata.contextWindow, + "max_context_window": metadata.contextWindow, + "auto_compact_token_limit": nil, + "effective_context_window_percent": 95, + "experimental_supported_tools": []any{}, + "input_modalities": metadata.inputModalities, + "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() }` + 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() + } + return scriptErr +} + +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 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 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 + } + 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), "/") +} + +func codexAppRootStillManaged(text string) bool { + config, err := codexParseConfig(text) + if err != nil { + return false + } + return codexAppIsOwnedProfileName(config.RootString(codexRootProfileKey)) || + codexAppIsOwnedProfileName(config.RootString(codexRootModelProviderKey)) +} + +func codexAppRootReferencesOwnedConfig(text string) bool { + config, err := codexParseConfig(text) + if err != nil { + return false + } + return config.RootString(codexRootProfileKey) == codexAppProfileName || + config.RootString(codexRootModelProviderKey) == codexAppProfileName +} + +func codexAppRootReferencesCatalog(text string) bool { + catalogPath, err := codexAppModelCatalogPath() + if err != nil { + return false + } + config, err := codexParseConfig(text) + if err != nil { + return false + } + return config.RootString(codexRootModelCatalogJSONKey) == catalogPath +} + +func codexAppRemoveOwnedSections(text string) string { + text = codexRemoveSection(text, codexProfileHeaderFor(codexAppProfileName)) + text = codexRemoveSection(text, codexProviderHeaderFor(codexAppProfileName)) + return text +} + +func codexAppRemoveOwnedCatalogIfUnused(text string) error { + if codexAppRootReferencesCatalog(text) { + return nil + } + if catalogPath, err := codexAppModelCatalogPath(); err == nil { + if err := os.Remove(catalogPath); err != nil && !os.IsNotExist(err) { + return err + } + } else { + return err + } + return nil +} + +func codexAppRemoveOwnedRootValues(text string) string { + config, err := codexParseConfig(text) + if err != nil { + return text + } + modelProvider := config.RootString(codexRootModelProviderKey) + modelCatalogJSON := config.RootString(codexRootModelCatalogJSONKey) + if !codexAppIsOwnedProfileName(config.RootString(codexRootProfileKey)) && !codexAppIsOwnedProfileName(modelProvider) { + return text + } + 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 +} + +func codexAppRestoreRootValues(text string, state codexAppRestoreState) string { + if !codexAppRootStillManaged(text) { + return text + } + 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 +} + +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 { + 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 + } + + if !configExists { + return writeCodexAppRestoreState(codexAppRestoreState{}) + } + + statePath := codexAppRestoreStatePath() + if stateData, err := os.ReadFile(statePath); err == nil { + hasRootConfig, err := codexAppRestoreStateHasRootConfig(stateData) + if err != nil { + return err + } + if hasRootConfig { + if configExists && !codexAppRootStillManaged(configText) { + return writeCodexAppRestoreState(codexAppRestoreStateFromText(configText)) + } + return nil + } + var existing codexAppRestoreState + if err := json.Unmarshal(stateData, &existing); err != nil { + return err + } + upgraded := codexAppRestoreStateFromText(configText) + upgraded.HadProfile = existing.HadProfile + upgraded.Profile = existing.Profile + return writeCodexAppRestoreState(upgraded) + } else if !os.IsNotExist(err) { + return err + } + + return writeCodexAppRestoreState(codexAppRestoreStateFromText(configText)) +} + +func codexAppRestoreStateHasRootConfig(data []byte) (bool, error) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return false, err + } + _, hasModel := raw["had_model"] + _, hasModelProvider := raw["had_model_provider"] + _, hasModelCatalogJSON := raw["had_model_catalog_json"] + return hasModel && hasModelProvider && hasModelCatalogJSON, nil +} + +func codexAppRestoreStateFromText(text string) codexAppRestoreState { + 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, + 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, codexAppIntegrationName) +} + +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 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 { + 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..d1f1f85f4 --- /dev/null +++ b/cmd/launch/codex_app_test.go @@ -0,0 +1,1318 @@ +package launch + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ollama/ollama/cmd/internal/fileutil" +) + +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 + oldCanOpenID := codexAppCanOpenID + codexAppIsRunning = isRunning + codexAppQuitApp = quit + codexAppOpenApp = open + t.Cleanup(func() { + codexAppIsRunning = oldIsRunning + codexAppQuitApp = oldQuit + codexAppOpenApp = oldOpen + codexAppOpenPath = oldOpenPath + codexAppOpenStart = oldOpenStart + codexAppRunPath = oldRunPath + codexAppStartID = oldStartID + codexAppCanOpenID = oldCanOpenID + }) +} + +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 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) + 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{ + fmt.Sprintf(`profile = %q`, codexAppProfileName), + `model = "llama3.2"`, + fmt.Sprintf(`model_provider = %q`, codexAppProfileName), + fmt.Sprintf(`model_catalog_json = %q`, catalogPath), + codexProfileHeaderFor(codexAppProfileName), + `model = "llama3.2"`, + `openai_base_url = "http://127.0.0.1:9999/v1/"`, + fmt.Sprintf(`model_provider = %q`, codexAppProfileName), + `model_catalog_json = "`, + codexProviderHeaderFor(codexAppProfileName), + `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 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 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) + + 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) + 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" + + codexProfileHeaderFor(codexAppProfileName) + "\n" + + "model = \"llama3.2\"\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) + } + + 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" + + 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 { + t.Fatal(err) + } + + if got := (&CodexApp{}).CurrentModel(); got != "qwen3:8b" { + t.Fatalf("CurrentModel = %q, want qwen3:8b", got) + } +} + +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) + + 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},"capabilities":["vision"]}`, 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"]) + } + wantContext := float64(272000) + wantModalities := []string{"text"} + wantShowCalls := 0 + if slug == "gemma4" { + wantContext = float64(65536 + len(slug)) + wantModalities = []string{"text", "image"} + wantShowCalls = 1 + } + 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) + } + } +} + +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), 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", + "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 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) + 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) + } + catalogPath, pathErr := codexAppModelCatalogPath() + if pathErr != nil { + t.Fatal(pathErr) + } + for _, want := range []string{ + "Restore did not complete", + "Codex config: " + configPath, + "Restore state: " + codexAppRestoreStatePath(), + "Model catalog: " + catalogPath, + "Backups: " + filepath.Join(fileutil.BackupDir(), codexAppIntegrationName), + } { + if !strings.Contains(err.Error(), want) { + t.Fatalf("Restore error missing %q:\n%v", want, 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 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) + 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}) + 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 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") + + 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 +} + +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/codex_test.go b/cmd/launch/codex_test.go index c19e24d1e..5c1cc573b 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) { @@ -69,6 +71,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 +119,150 @@ 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("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") + 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) { @@ -178,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) { @@ -227,3 +396,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) +} diff --git a/cmd/launch/integrations_test.go b/cmd/launch/integrations_test.go index da406b4c5..8a842eea5 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,11 +1816,38 @@ 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) } }) + 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 == "" { @@ -1831,6 +1861,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..0cb81c14e 100644 --- a/cmd/launch/launch.go +++ b/cmd/launch/launch.go @@ -285,22 +285,25 @@ Flags and extra arguments require an integration name. Supported integrations: claude Claude Code - cline Cline + 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: 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,13 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam return nil } - if needsConfigure || req.ModelOverride != "" || (current != "" && 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 @@ -782,6 +791,7 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam return err } } + configured = true } if !managedIntegrationOnboarded(saved, managed) { @@ -793,6 +803,12 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam } } + if configured { + if !printConfigurationSuccess(managed) { + printRestoreHint(managed) + } + } + if req.ConfigureOnly { return nil } @@ -941,7 +957,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..c0903aaf9 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,116 @@ 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) + withInteractiveSession(t, true) + withLauncherHooks(t) + + runner := &launcherManagedRunner{ + paths: nil, + skipModelReadiness: true, + } + 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 +626,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 +655,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..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", "opencode", "codex", "copilot", "droid", "pi", "pool"} +var launcherIntegrationOrder = []string{"claude", "codex-app", "hermes", "openclaw", "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/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/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..f93bdbadc --- /dev/null +++ b/docs/integrations/codex-app.mdx @@ -0,0 +1,39 @@ +--- +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 +``` + +## Backups + +Before overwriting Codex App config files, Ollama Launch saves backups under `~/.ollama/backup/codex-app/`. On Windows, `~` resolves to your user profile directory. 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