Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ daemon 不会在后台自动弹升级提示;升级只通过这条手动入口
- **Codex API Profile**:独立设置 base URL、API key、模型、推理强度等
- **Claude Profile**:独立设置认证方式、base URL、模型、推理强度等

这些 profile 保存在 codex-remote 自己的 `config.json` 里,不会改写 `~/.codex` 或 `~/.claude` 的原有配置,可以随时切换、并行使用。
这些 profile 保存在 codex-remote 自己的 `config.json` 里,不会改写 `$CODEX_HOME`(默认 `~/.codex`)或 `~/.claude` 的原有配置,可以随时切换、并行使用。

在飞书里切换:

Expand Down
31 changes: 24 additions & 7 deletions docs/general/user-guide.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# 使用说明书

> Type: `general`
> Updated: `2026-08-05`
> Summary: 更新安装方式与首次配置流程,并补充模型后端说明:默认使用机器现有 Codex / Claude Code 配置,可通过独立 Profile 并行使用多套模型参数。
> Updated: `2026-08-21`
> Summary: 补充非默认 `CODEX_HOME` 的安装与自动启动合同,并同步 Codex 配置目录说明。

## 1. 这是什么

Expand Down Expand Up @@ -132,7 +132,24 @@
- VS Code Remote SSH 使用,就装在那台 SSH 目标机器上
- 如果两边都会各自运行 Codex,两边都可以各装一套,但它们是两套独立环境

### 5.2 原生安装器(Windows / macOS)
### 5.2 使用非默认 CODEX_HOME

如果本机 Codex 通过 `CODEX_HOME` 使用非默认配置与会话目录,请在运行安装命令前导出该变量,并确保目录已经存在:

```bash
export CODEX_HOME="/path/to/codex-home"
```

安装时显式设置的 `CODEX_HOME` 会同时用于:

- headless Codex 的配置、认证和会话
- `/list`、`/use`、`/useall` 使用的本地会话索引
- turn patch 使用的 sessions 与状态目录
- Linux systemd 和 macOS launchd 自动启动

未设置时继续使用 Codex 默认目录 `~/.codex`。如果要切换到另一个 Codex Home,请先确保新目录有效,再带着新的 `CODEX_HOME` 重新运行安装命令,使安装状态和自动启动定义一起更新。

### 5.3 原生安装器(Windows / macOS)

如果你更习惯桌面安装器,可以直接从 [GitHub Releases](https://github.com/kxn/codex-remote-feishu/releases) 下载对应平台的 native installer。

Expand All @@ -156,7 +173,7 @@ codex-remote-feishu_<version>_darwin_universal_installer.dmg

运行 DMG 里的 **Install Codex Remote.app**。首次安装可以选择安装目录;完成后在结果页打开 WebSetup。已经安装过时再次运行,会按 repair / 升级处理。

### 5.3 一条命令安装
### 5.4 一条命令安装

macOS / Linux:

Expand Down Expand Up @@ -198,7 +215,7 @@ curl -fsSL https://raw.githubusercontent.com/kxn/codex-remote-feishu/master/inst
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/kxn/codex-remote-feishu/master/install-release.ps1))) -Version <version>
```

### 5.4 手动安装 release 包
### 5.5 手动安装 release 包

macOS / Linux:

Expand Down Expand Up @@ -423,7 +440,7 @@ Windows PowerShell:

两个 headless 后端默认都直接使用机器上已经配好的环境:

- `codex`:使用本机 `~/.codex` 已有的登录状态、模型和配置
- `codex`:使用 `$CODEX_HOME` 已有的登录状态、模型和配置;未设置时默认使用 `~/.codex`
- `claude`:使用本机 `~/.claude` 已有的 Claude Code 登录状态和配置

默认不需要额外配置任何模型参数,开箱就用机器现有环境。
Expand All @@ -433,7 +450,7 @@ Windows PowerShell:
- **Codex API Profile**:独立设置 base URL、API key、模型、review model、推理强度等
- **Claude Profile**:独立设置认证方式、base URL、auth token、模型、small model、推理强度等

这些 profile 保存在 codex-remote 自己的 `config.json` 里,不会写回 `~/.codex` 或 `~/.claude` 的原有配置。Claude 自定义 profile 通过临时 `--settings` overlay 注入,不改动用户自己的 Claude 配置。
这些 profile 保存在 codex-remote 自己的 `config.json` 里,不会写回 `$CODEX_HOME`(默认 `~/.codex`)或 `~/.claude` 的原有配置。Claude 自定义 profile 通过临时 `--settings` overlay 注入,不改动用户自己的 Claude 配置。

在飞书里查看或切换:

Expand Down
16 changes: 12 additions & 4 deletions internal/app/install/darwin_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,16 @@ func renderLaunchdUserPlist(state InstallState) (string, error) {
` <string>` + xmlEscape(dataHome) + `</string>`,
` <key>XDG_STATE_HOME</key>`,
` <string>` + xmlEscape(stateHome) + `</string>`,
}
if codexHome := normalizeServicePathValue(state.CodexHome); codexHome != "" {
lines = append(lines,
` <key>CODEX_HOME</key>`,
` <string>`+xmlEscape(codexHome)+`</string>`,
)
}
lines = append(lines,
` <key>PATH</key>`,
` <string>` + xmlEscape(systemdUserServicePATH()) + `</string>`,
` <string>`+xmlEscape(systemdUserServicePATH())+`</string>`,
` </dict>`,
` <key>RunAtLoad</key>`,
` <true/>`,
Expand All @@ -128,13 +136,13 @@ func renderLaunchdUserPlist(state InstallState) (string, error) {
` <false/>`,
` </dict>`,
` <key>StandardOutPath</key>`,
` <string>` + xmlEscape(logPath) + `</string>`,
` <string>`+xmlEscape(logPath)+`</string>`,
` <key>StandardErrorPath</key>`,
` <string>` + xmlEscape(logPath) + `</string>`,
` <string>`+xmlEscape(logPath)+`</string>`,
`</dict>`,
`</plist>`,
``,
}
)
return strings.Join(lines, "\n"), nil
}

Expand Down
35 changes: 35 additions & 0 deletions internal/app/install/darwin_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,41 @@ func TestRenderLaunchdUserPlistContainsKeyElements(t *testing.T) {
}
}

func TestBootstrapPreservesCodexHomeInLaunchdUserPlist(t *testing.T) {
defer withDarwinGOOS(t)()
baseDir := t.TempDir()
stubServiceUserHome(t, baseDir)
codexHome := filepath.Join(baseDir, "custom-codex-home")
if err := os.MkdirAll(codexHome, 0o755); err != nil {
t.Fatalf("MkdirAll CODEX_HOME: %v", err)
}
t.Setenv("CODEX_HOME", codexHome)
t.Setenv("PATH", "/usr/bin:/bin")

service := NewService()
state, err := service.Bootstrap(Options{
BaseDir: baseDir,
BinaryPath: seedBinary(t, filepath.Join(baseDir, "source-bin", "codex-remote"), "binary"),
ServiceManager: ServiceManagerLaunchdUser,
CurrentVersion: "dev",
RelayServerURL: "ws://127.0.0.1:9500/ws/agent",
CodexHome: codexHome,
})
if err != nil {
t.Fatalf("Bootstrap: %v", err)
}
t.Setenv("CODEX_HOME", "")

plist, err := renderLaunchdUserPlist(state)
if err != nil {
t.Fatalf("renderLaunchdUserPlist: %v", err)
}
want := "<key>CODEX_HOME</key>\n <string>" + xmlEscape(codexHome) + "</string>"
if !strings.Contains(plist, want) {
t.Fatalf("launchd plist missing preserved CODEX_HOME %q:\n%s", want, plist)
}
}

func TestRenderLaunchdUserPlistEscapesXMLSpecialChars(t *testing.T) {
defer withDarwinGOOS(t)()
baseDir := t.TempDir()
Expand Down
9 changes: 9 additions & 0 deletions internal/app/install/entry.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"
"time"

"github.com/kxn/codex-remote-feishu/internal/config"
"github.com/kxn/codex-remote-feishu/internal/execlaunch"
"github.com/kxn/codex-remote-feishu/internal/pathcompare"
"github.com/kxn/codex-remote-feishu/internal/xutil"
Expand Down Expand Up @@ -85,6 +86,10 @@ func RunMain(args []string, stdin io.Reader, stdout, stderr io.Writer, version s
resolvedBaseDir := selection.BaseDir
resolvedInstallBinDir := resolveTargetInstallBinDir(selection, *installBinDir)
preInteractiveInstallBinDir := resolvedInstallBinDir
codexHome, err := config.ResolveExplicitCodexHomeDir(os.Environ())
if err != nil {
return err
}

service := NewService()
opts := Options{
Expand All @@ -102,6 +107,7 @@ func RunMain(args []string, stdin io.Reader, stdout, stderr io.Writer, version s
RelaydBinary: *legacyRelaydBinary,
RelayServerURL: *relayURL,
CodexRealBinary: *codexBinary,
CodexHome: codexHome,
VSCodeSettingsPath: *settingsPath,
BundleEntrypoint: *bundleEntrypoint,
FeishuGatewayID: *feishuGatewayID,
Expand Down Expand Up @@ -211,6 +217,9 @@ func preserveInstallOptionsFromExistingState(flagSet *flag.FlagSet, statePath st
if !flagWasProvided(flagSet, "bundle-entrypoint") && strings.TrimSpace(existing.BundleEntrypoint) != "" {
opts.BundleEntrypoint = existing.BundleEntrypoint
}
if strings.TrimSpace(opts.CodexHome) == "" {
opts.CodexHome = existing.CodexHome
}
}

func defaultBinaryPath(goos string) string {
Expand Down
37 changes: 37 additions & 0 deletions internal/app/install/entry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,45 @@ func TestRunMainBootstrapOnlyPreservesExistingRelayURLWhenFlagOmitted(t *testing
}
}

func TestRunMainBootstrapOnlyPersistsExplicitCodexHome(t *testing.T) {
t.Setenv(repoRootEnvVar, t.TempDir())
baseDir := t.TempDir()
codexHome := filepath.Join(baseDir, "custom-codex-home")
if err := os.MkdirAll(codexHome, 0o755); err != nil {
t.Fatalf("MkdirAll CODEX_HOME: %v", err)
}
t.Setenv("CODEX_HOME", codexHome)
binaryPath := seedBinary(t, filepath.Join(baseDir, "bin", "codex-remote"), "binary")

originalValidator := sourceBinaryValidator
sourceBinaryValidator = func(string) error { return nil }
defer func() { sourceBinaryValidator = originalValidator }()

if err := RunMain([]string{
"-bootstrap-only",
"-base-dir", baseDir,
"-binary", binaryPath,
}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}, "vtest"); err != nil {
t.Fatalf("RunMain bootstrap-only: %v", err)
}

state, err := LoadState(defaultInstallStatePath(baseDir))
if err != nil {
t.Fatalf("LoadState: %v", err)
}
if state.CodexHome != codexHome {
t.Fatalf("CodexHome = %q, want %q", state.CodexHome, codexHome)
}
}

func TestRunMainBootstrapOnlyPreservesExistingInstallMetadataWhenFlagsOmitted(t *testing.T) {
t.Setenv(repoRootEnvVar, t.TempDir())
t.Setenv("CODEX_HOME", "")
baseDir := t.TempDir()
installBinDir := filepath.Join(baseDir, "installed-bin")
statePath := defaultInstallStatePathForInstance(baseDir, defaultInstanceID)
existingBinary := seedBinary(t, filepath.Join(installBinDir, xutil.ExecutableName("linux")), "old-binary")
existingCodexHome := filepath.Join(baseDir, "existing-codex-home")
if err := WriteState(statePath, InstallState{
InstanceID: defaultInstanceID,
BaseDir: baseDir,
Expand All @@ -93,6 +126,7 @@ func TestRunMainBootstrapOnlyPreservesExistingInstallMetadataWhenFlagsOmitted(t
CurrentSlot: "v1.4.0-beta.1",
VSCodeSettingsPath: filepath.Join(baseDir, "vscode", "settings.json"),
BundleEntrypoint: filepath.Join(baseDir, "bundle", "codex"),
CodexHome: existingCodexHome,
}); err != nil {
t.Fatalf("WriteState: %v", err)
}
Expand Down Expand Up @@ -142,6 +176,9 @@ func TestRunMainBootstrapOnlyPreservesExistingInstallMetadataWhenFlagsOmitted(t
if updated.BundleEntrypoint != filepath.Join(baseDir, "bundle", "codex") {
t.Fatalf("BundleEntrypoint = %q, want preserved value", updated.BundleEntrypoint)
}
if updated.CodexHome != existingCodexHome {
t.Fatalf("CodexHome = %q, want preserved %q", updated.CodexHome, existingCodexHome)
}
}

func TestRunMainDefaultsBinaryToCurrentExecutable(t *testing.T) {
Expand Down
11 changes: 8 additions & 3 deletions internal/app/install/linux_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,15 +134,20 @@ func renderSystemdUserUnit(state InstallState) (string, error) {
"Environment=XDG_CONFIG_HOME=" + systemdEscapeValue(configHome),
"Environment=XDG_DATA_HOME=" + systemdEscapeValue(dataHome),
"Environment=XDG_STATE_HOME=" + systemdEscapeValue(stateHome),
}
if codexHome := normalizeServicePathValue(state.CodexHome); codexHome != "" {
lines = append(lines, "Environment=CODEX_HOME="+systemdEscapeValue(codexHome))
}
lines = append(lines,
"Restart=on-failure",
"RestartSec=2s",
"StandardOutput=append:" + serviceLogPath,
"StandardError=append:" + serviceLogPath,
"StandardOutput=append:"+serviceLogPath,
"StandardError=append:"+serviceLogPath,
"",
"[Install]",
"WantedBy=default.target",
"",
}
)
return strings.Join(lines, "\n"), nil
}

Expand Down
9 changes: 9 additions & 0 deletions internal/app/install/packaged_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"strings"
"time"

"github.com/kxn/codex-remote-feishu/internal/config"
"github.com/kxn/codex-remote-feishu/internal/xutil"
)

Expand Down Expand Up @@ -59,6 +60,7 @@ type packagedInstallOptions struct {
CurrentTrack ReleaseTrack
VersionsRoot string
CurrentSlot string
CodexHome string
OutputFormat string
ResultFilePath string
GOOS string
Expand Down Expand Up @@ -129,6 +131,10 @@ func RunPackagedInstall(args []string, _ io.Reader, stdout, _ io.Writer, version
if err != nil {
return err
}
codexHome, err := config.ResolveExplicitCodexHomeDir(os.Environ())
if err != nil {
return err
}
opts := packagedInstallOptions{
Selection: selection,
StatePath: resolvedStatePath,
Expand All @@ -139,6 +145,7 @@ func RunPackagedInstall(args []string, _ io.Reader, stdout, _ io.Writer, version
CurrentTrack: ParseReleaseTrack(*currentTrack),
VersionsRoot: strings.TrimSpace(*versionsRoot),
CurrentSlot: requestedSlot,
CodexHome: codexHome,
OutputFormat: outputFormat,
ResultFilePath: strings.TrimSpace(*resultFile),
GOOS: defaults.GOOS,
Expand Down Expand Up @@ -207,6 +214,7 @@ func runPackagedFirstInstall(ctx context.Context, opts packagedInstallOptions) (
CurrentTrack: opts.CurrentTrack,
VersionsRoot: versionsRoot,
CurrentSlot: targetSlot,
CodexHome: opts.CodexHome,
BootstrapOnly: true,
})
result := packagedInstallResultForState(packagedInstallModeFirstInstall, state)
Expand Down Expand Up @@ -270,6 +278,7 @@ func runPackagedRepair(ctx context.Context, flagSet *flag.FlagSet, opts packaged
}
state.CurrentBinaryPath = liveBinaryPath
state.VersionsRoot = xutil.FirstNonEmpty(strings.TrimSpace(opts.VersionsRoot), strings.TrimSpace(state.VersionsRoot), defaultVersionsRootForStatePath(state.StatePath))
state.CodexHome = choosePreservedValue(opts.CodexHome, state.CodexHome)

// Migrate version-scoped legacy live binary to canonical instance bin dir.
if canonicalDir, needsMigration := canonicalInstallBinDirForMigration(opts.GOOS, state); needsMigration {
Expand Down
7 changes: 7 additions & 0 deletions internal/app/install/packaged_install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@ func TestRunPackagedInstallRepairOverwritesLiveBinaryAndClearsUpgradeState(t *te
liveBinary := seedBinary(t, filepath.Join(baseDir, "installed-bin", xutil.ExecutableName(runtime.GOOS)), "old-binary")
sourceBinary := seedBinary(t, filepath.Join(baseDir, "pkg", xutil.ExecutableName(runtime.GOOS)), "new-binary")
versionsRoot := filepath.Join(baseDir, "releases")
existingCodexHome := t.TempDir()
requestedCodexHome := t.TempDir()
t.Setenv("CODEX_HOME", requestedCodexHome)
if err := WriteState(statePath, InstallState{
InstanceID: defaultInstanceID,
BaseDir: baseDir,
Expand All @@ -224,6 +227,7 @@ func TestRunPackagedInstallRepairOverwritesLiveBinaryAndClearsUpgradeState(t *te
CurrentTrack: ReleaseTrackProduction,
CurrentVersion: "v1.0.0",
CurrentBinaryPath: liveBinary,
CodexHome: existingCodexHome,
VersionsRoot: versionsRoot,
CurrentSlot: "v1.0.0",
PendingUpgrade: &PendingUpgrade{
Expand Down Expand Up @@ -310,6 +314,9 @@ func TestRunPackagedInstallRepairOverwritesLiveBinaryAndClearsUpgradeState(t *te
if updated.CurrentVersion != "v1.2.0-beta.1" {
t.Fatalf("CurrentVersion = %q, want new version", updated.CurrentVersion)
}
if updated.CodexHome != requestedCodexHome {
t.Fatalf("CodexHome = %q, want %q", updated.CodexHome, requestedCodexHome)
}

raw, err := os.ReadFile(liveBinary)
if err != nil {
Expand Down
6 changes: 5 additions & 1 deletion internal/app/install/runtime_paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,14 @@ func RuntimeEnvForState(state InstallState) []string {
if configPath == "" {
configPath = filepath.Join(layout.ConfigDir, "config.json")
}
return []string{
env := []string{
"CODEX_REMOTE_CONFIG=" + configPath,
"XDG_CONFIG_HOME=" + layout.ConfigHome,
"XDG_DATA_HOME=" + layout.DataHome,
"XDG_STATE_HOME=" + layout.StateHome,
}
if codexHome := strings.TrimSpace(state.CodexHome); codexHome != "" {
env = append(env, "CODEX_HOME="+codexHome)
}
return env
}
20 changes: 20 additions & 0 deletions internal/app/install/runtime_paths_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package install

import (
"path/filepath"
"slices"
"testing"
)

func TestRuntimeEnvForStateIncludesCodexHome(t *testing.T) {
baseDir := t.TempDir()
codexHome := filepath.Join(baseDir, "codex-home")
env := RuntimeEnvForState(InstallState{
BaseDir: baseDir,
CodexHome: codexHome,
})

if !slices.Contains(env, "CODEX_HOME="+codexHome) {
t.Fatalf("RuntimeEnvForState() = %#v, want CODEX_HOME=%s", env, codexHome)
}
}
Loading