diff --git a/README.md b/README.md index 506cd5c..8effd2d 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,14 @@ go install github.com/SilkageNet/codex-switch/cmd/codex-switch@latest Release archives for macOS, Linux, and Windows are published on GitHub. Adding accounts and querying live usage require an official Codex CLI that this -process can launch. On Windows, install the +process can launch. On macOS, `codex-switch` checks `PATH` first and then the +official Codex and ChatGPT application bundles in `/Applications` and +`~/Applications`, including the nested CLI layout used by current ChatGPT.app +releases. On Windows, install the [standalone Codex CLI](https://learn.chatgpt.com/docs/codex/cli); the executable bundled inside the Codex desktop app's WindowsApps package cannot be launched by external processes. Verify the installation with `codex --version` before using -those commands. +those commands. `CODEX_BINARY` and `--codex-binary` remain explicit overrides. WSL2 is supported by the Linux archive. It uses the Windows user's DPAPI protection through the built-in `powershell.exe`; a Linux desktop Secret Service diff --git a/docs/compatibility.md b/docs/compatibility.md index 23a19bc..4076347 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -58,6 +58,11 @@ searching `PATH` and returns an actionable error when no standalone CLI is available. A standalone CLI can also be selected with `CODEX_BINARY` or `--codex-binary`. +On macOS, discovery checks `PATH`, `/Applications`, and `~/Applications`. Both +the retained `Codex.app/Contents/Resources/codex` compatibility path and the +nested `ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex` +layout are supported, along with their direct/nested bundle variants. + Usage querying initializes `codex app-server` and calls: - `account/read` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 68a0478..ec1637d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -99,6 +99,13 @@ codex --version codex-switch account usage ``` +On macOS, a standalone `codex` command is optional when the official Codex or +ChatGPT desktop app is installed in `/Applications` or `~/Applications`. +`codex-switch` automatically discovers both the retained Codex.app compatibility +path and the current nested ChatGPT.app CLI. Run `codex-switch doctor` to see the +exact executable path selected. Nonstandard application locations still require +`--codex-binary ` or `CODEX_BINARY`. + On Windows, a path under `C:\Program Files\WindowsApps\OpenAI.Codex_*\app\resources\codex.exe` belongs to the desktop app package. Windows does not allow `codex-switch` to launch that diff --git a/internal/codexlogin/login.go b/internal/codexlogin/login.go index 6e027b8..5da9446 100644 --- a/internal/codexlogin/login.go +++ b/internal/codexlogin/login.go @@ -35,10 +35,46 @@ func FindBinary(override string) (string, error) { } return validateBinary(path) } - if path := "/Applications/ChatGPT.app/Contents/Resources/codex"; fileExists(path) { - return path, nil + if runtime.GOOS == "darwin" { + if path := findMacOSBundledBinary(macOSApplicationDirectories()); path != "" { + return path, nil + } } - return "", fmt.Errorf("codex executable not found; install Codex CLI or set CODEX_BINARY") + return "", fmt.Errorf("codex executable not found on PATH or in a supported app bundle; install Codex CLI or set CODEX_BINARY") +} + +func macOSApplicationDirectories() []string { + directories := []string{"/Applications"} + if home, err := os.UserHomeDir(); err == nil && home != "" { + directories = append(directories, filepath.Join(home, "Applications")) + } + return directories +} + +func findMacOSBundledBinary(applicationDirectories []string) string { + relativePaths := []string{ + filepath.Join("Codex.app", "Contents", "Resources", "codex"), + filepath.Join("Codex.app", "Contents", "Resources", "codex-cli", "CodexCLI.app", "Contents", "MacOS", "codex"), + filepath.Join("ChatGPT.app", "Contents", "Resources", "codex-cli", "CodexCLI.app", "Contents", "MacOS", "codex"), + filepath.Join("ChatGPT.app", "Contents", "Resources", "codex"), + } + for _, directory := range applicationDirectories { + for _, relative := range relativePaths { + path := filepath.Join(directory, relative) + if executableFileExists(path) { + return path + } + } + } + return "" +} + +func executableFileExists(path string) bool { + info, err := os.Stat(path) + if err != nil || info.IsDir() || info.Mode()&0o111 == 0 { + return false + } + return true } func validateBinary(path string) (string, error) { @@ -123,8 +159,3 @@ func withEnvironment(environment []string, key, value string) []string { } return append(result, prefix+value) } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/internal/codexlogin/login_test.go b/internal/codexlogin/login_test.go index c398af5..de78322 100644 --- a/internal/codexlogin/login_test.go +++ b/internal/codexlogin/login_test.go @@ -42,6 +42,40 @@ func TestFindBinaryAcceptsExplicitStandaloneBinary(t *testing.T) { } } +func TestFindMacOSBundledBinarySupportsCurrentChatGPTLayout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("executable mode bits are not available on Windows") + } + applications := t.TempDir() + want := filepath.Join(applications, "ChatGPT.app", "Contents", "Resources", "codex-cli", "CodexCLI.app", "Contents", "MacOS", "codex") + if err := os.MkdirAll(filepath.Dir(want), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(want, []byte("test"), 0o755); err != nil { + t.Fatal(err) + } + if got := findMacOSBundledBinary([]string{applications}); got != want { + t.Fatalf("findMacOSBundledBinary() = %q, want %q", got, want) + } +} + +func TestFindMacOSBundledBinaryIgnoresNonExecutableFiles(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("executable mode bits are not available on Windows") + } + applications := t.TempDir() + path := filepath.Join(applications, "ChatGPT.app", "Contents", "Resources", "codex") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + if got := findMacOSBundledBinary([]string{applications}); got != "" { + t.Fatalf("findMacOSBundledBinary() returned non-executable file %q", got) + } +} + func TestFindBinarySkipsWindowsDesktopBundleOnPath(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows package paths are only rejected on Windows") diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 20a9cb3..dbc8528 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -103,7 +103,7 @@ func Run(home codexhome.Home, paths appconfig.Paths, manager *vault.Manager, cod if version, err := runner.Version(); err != nil { add("codex_cli", "error", err.Error()) } else { - add("codex_cli", "ok", version) + add("codex_cli", "ok", fmt.Sprintf("%s (%s)", version, codexBinary)) } }