mirror of
https://github.com/ollama/ollama.git
synced 2026-05-13 14:27:00 +00:00
Merge ececc61d21 into 3af1a008e2
This commit is contained in:
commit
80c9245129
20 changed files with 3268 additions and 80 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
BIN
app/ui/app/public/launch-icons/codex-app.png
Normal file
BIN
app/ui/app/public/launch-icons/codex-app.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 := 0; i < rootEnd; i++ {
|
||||
line := lines[i]
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
if codexRootLineHasKey(trimmed, key) {
|
||||
if strings.HasSuffix(line, "\n") {
|
||||
lines[i] = assignment + "\n"
|
||||
} else {
|
||||
lines[i] = assignment
|
||||
}
|
||||
return strings.Join(lines, "")
|
||||
}
|
||||
}
|
||||
|
||||
insert := assignment + "\n"
|
||||
root := strings.Join(lines[:rootEnd], "")
|
||||
rest := strings.Join(lines[rootEnd:], "")
|
||||
if root != "" && !strings.HasSuffix(root, "\n") {
|
||||
root += "\n"
|
||||
}
|
||||
if rest != "" && !strings.HasSuffix(insert, "\n\n") {
|
||||
insert += "\n"
|
||||
}
|
||||
return root + insert + rest
|
||||
}
|
||||
|
||||
func codexRemoveRootValue(text, key string) string {
|
||||
lines := strings.SplitAfter(text, "\n")
|
||||
rootEnd := len(lines)
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "[") {
|
||||
rootEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(lines))
|
||||
for i, line := range lines {
|
||||
if i < rootEnd {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "#") && codexRootLineHasKey(trimmed, key) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return strings.Join(out, "")
|
||||
}
|
||||
|
||||
func codexRootLineHasKey(line, key string) bool {
|
||||
cfg := map[string]any{}
|
||||
if err := toml.Unmarshal([]byte(line+"\n"), &cfg); err != nil {
|
||||
return false
|
||||
}
|
||||
_, ok := cfg[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func checkCodexVersion() error {
|
||||
|
|
|
|||
966
cmd/launch/codex_app.go
Normal file
966
cmd/launch/codex_app.go
Normal file
|
|
@ -0,0 +1,966 @@
|
|||
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 err
|
||||
}
|
||||
return codexAppLaunchOrRestart("Restart Codex to use your usual profile?")
|
||||
}
|
||||
return err
|
||||
}
|
||||
text := string(data)
|
||||
if err := codexValidateConfigText(text); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
state, stateErr := loadCodexAppRestoreState()
|
||||
if stateErr == nil {
|
||||
text = codexAppRestoreRootValues(text, state)
|
||||
} else if os.IsNotExist(stateErr) {
|
||||
text = codexAppRemoveOwnedRootValues(text)
|
||||
} else {
|
||||
return stateErr
|
||||
}
|
||||
if !codexAppRootReferencesOwnedConfig(text) {
|
||||
text = codexAppRemoveOwnedSections(text)
|
||||
}
|
||||
|
||||
if err := codexValidateConfigText(text); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fileutil.WriteWithBackup(configPath, []byte(text), codexAppIntegrationName); err != nil {
|
||||
return err
|
||||
}
|
||||
codexAppRemoveOwnedCatalogIfUnused(text)
|
||||
_ = os.Remove(codexAppRestoreStatePath())
|
||||
return codexAppLaunchOrRestart("Restart Codex to use your usual profile?")
|
||||
}
|
||||
|
||||
func codexAppSupported() error {
|
||||
switch codexAppGOOS {
|
||||
case "darwin", "windows":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("Codex App launch is only supported on macOS and Windows")
|
||||
}
|
||||
}
|
||||
|
||||
func codexAppInstalled() bool {
|
||||
if codexAppAppPath() != "" {
|
||||
return true
|
||||
}
|
||||
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 filepath.Join(filepath.Dir(configPath), codexAppModelCatalogFilename), nil
|
||||
}
|
||||
|
||||
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) {
|
||||
if codexAppRootReferencesCatalog(text) {
|
||||
return
|
||||
}
|
||||
if catalogPath, err := codexAppModelCatalogPath(); err == nil {
|
||||
_ = os.Remove(catalogPath)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
1303
cmd/launch/codex_app_test.go
Normal file
1303
cmd/launch/codex_app_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <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
|
||||
|
|
|
|||
|
|
@ -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,115 @@ 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,
|
||||
}
|
||||
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 +625,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 +654,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)
|
||||
|
|
|
|||
|
|
@ -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{},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@
|
|||
"pages": [
|
||||
"/integrations/claude-code",
|
||||
"/integrations/codex",
|
||||
"/integrations/codex-app",
|
||||
"/integrations/copilot-cli",
|
||||
"/integrations/opencode",
|
||||
"/integrations/droid",
|
||||
|
|
|
|||
39
docs/integrations/codex-app.mdx
Normal file
39
docs/integrations/codex-app.mdx
Normal file
|
|
@ -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
|
||||
|
||||
<Note>Codex works best with a larger context window. It is recommended to use a context window of at least 64k tokens.</Note>
|
||||
|
||||
### 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
2
go.mod
2
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue