Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ and this project follows semantic versioning for tagged releases.

### Added

- `init --share-with <profile>` for creating a profile that keeps a separate
`auth.json` while symlinking shared top-level `CODEX_HOME` entries from an
existing profile.
- npm package metadata and public install documentation for the published
`codex-profile` package.

### Tests

- Added coverage for shared-profile initialization via `init --share-with`.
- Added npm package installation coverage.

## 0.2.0 - 2026-05-21
Expand Down
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ Switch Codex CLI and Desktop accounts with isolated `CODEX_HOME` profiles
instead of copying `auth.json` token files around.

`codex-profiles` is a small Bash wrapper around Codex's `CODEX_HOME` support.
Each profile gets its own Codex home directory, so auth, settings, sessions,
connectors, plugins, caches, logs, and local state stay separated while the
wrapper launches Codex CLI or Codex Desktop with the selected profile.
Each profile gets its own Codex home directory, or can share another profile's
config and state with a separate `auth.json`, while the wrapper launches Codex
CLI or Codex Desktop with the selected profile.

```sh
codex-profile cli personal
Expand Down Expand Up @@ -68,6 +68,7 @@ where local Codex state should not bleed between contexts.
- Read-only `list`, `status`, and `doctor` commands for diagnostics.
- JSON output for automation.
- Profile lifecycle commands: `init` and confirmed `remove`.
- Shared-profile setup with separate auth via `init --share-with`.
- Profile-local desktop logs with private permissions.
- Safe config cloning for known non-secret config files.
- Bash, Zsh, and Fish completion generators.
Expand Down Expand Up @@ -127,6 +128,14 @@ codex-profile login personal
codex-profile login work
```

Or create a second profile that shares an existing profile's config, rules,
plugins, and other top-level state while keeping its own auth file:

```sh
codex-profile init personal-2 --share-with personal-1
codex-profile login personal-2
```

Run Codex CLI with a profile:

```sh
Expand Down Expand Up @@ -186,6 +195,20 @@ Create a profile home without launching Codex:
codex-profile init client-a
```

Create a profile that shares another profile's top-level `CODEX_HOME` entries
except `auth.json`:

```sh
codex-profile init client-b --share-with client-a
```

This creates `~/.codex-client-b` as its own directory, keeps
`~/.codex-client-b/auth.json` separate, and symlinks shared entries such as
`config.toml`, `AGENTS.md`, `AGENTS.override.md`, `rules/`, `plugins/`,
`sessions/`, and `logs/` back to `~/.codex-client-a`.

Today, `sessions/` and `logs/` are shared in this mode too.

Remove a profile home interactively:

```sh
Expand Down Expand Up @@ -338,7 +361,7 @@ alias codex-app-work='codex-profile app work'
codex-profile app <profile> [workspace]
codex-profile cli <profile> [codex-args...]
codex-profile login <profile> [codex-login-args...]
codex-profile init <profile>
codex-profile init <profile> [--share-with <profile>]
codex-profile remove <profile> [--yes]
codex-profile status [profile]
codex-profile status --json [profile]
Expand Down Expand Up @@ -398,6 +421,10 @@ instead of Dock or Spotlight.
`codex-profile` does one security-sensitive thing: it sets `CODEX_HOME` before
running Codex. It does not read, copy, print, parse, or migrate auth tokens.

`init --share-with` also avoids reading or copying auth tokens. It creates
symlinks for shared top-level entries and leaves `auth.json` to be created and
managed separately by Codex in the target profile.

`clone-config` uses a small allowlist and refuses sensitive-looking config
files. It does not inspect or rewrite Codex auth files.

Expand Down
103 changes: 100 additions & 3 deletions bin/codex-profile
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Usage:
$PROGRAM app <profile> [workspace]
$PROGRAM cli <profile> [codex-args...]
$PROGRAM login <profile> [codex-login-args...]
$PROGRAM init <profile>
$PROGRAM init <profile> [--share-with <profile>]
$PROGRAM remove <profile> [--yes]
$PROGRAM status [profile]
$PROGRAM status --json [profile]
Expand All @@ -36,6 +36,7 @@ Usage:
Examples:
$PROGRAM login personal
$PROGRAM init work
$PROGRAM init personal-2 --share-with personal-1
$PROGRAM app personal ~/Dev/my-project
$PROGRAM cli personal exec "review this repo"
$PROGRAM status
Expand Down Expand Up @@ -127,6 +128,79 @@ ensure_home() {
chmod 700 "$codex_home" || die "Cannot set private permissions on: $codex_home"
}

directory_has_entries() {
local dir="$1"

[[ -d "$dir" ]] || return 1
find "$dir" -mindepth 1 -maxdepth 1 -print -quit | grep -q .
}

shared_profile_entry_is_excluded() {
local entry="$1"

[[ "$entry" == "auth.json" ]]
}

shared_profile_link_candidate_entries() {
local source_home="$1"
local path name
local -A seen=()
local defaults=(
"config.toml"
"AGENTS.md"
"AGENTS.override.md"
"instructions.md"
"custom-instructions.md"
"rules"
"plugins"
"sessions"
"logs"
)

for name in "${defaults[@]}"; do
seen["$name"]=1
printf '%s\n' "$name"
done

for path in "$source_home"/* "$source_home"/.[!.]* "$source_home"/..?*; do
[[ -e "$path" || -L "$path" ]] || continue
name="${path##*/}"
if [[ -n "${seen[$name]:-}" ]]; then
continue
fi
seen["$name"]=1
printf '%s\n' "$name"
done
}

initialize_shared_profile() {
local target_profile="$1"
local source_profile="$2"
local target_home source_home entry

[[ "$target_profile" != "$source_profile" ]] || die "Shared profile source must be different from the target profile."

target_home="$(codex_home_for_profile "$target_profile")"
source_home="$(codex_home_for_profile "$source_profile")"

[[ -d "$source_home" ]] || die "Shared profile source is not initialized: $source_profile ($source_home)"

if [[ -e "$target_home" && -d "$target_home" ]] && directory_has_entries "$target_home"; then
die "Target profile already contains files: $target_profile ($target_home)"
fi

ensure_home "$target_home"

while IFS= read -r entry; do
[[ -n "$entry" ]] || continue
shared_profile_entry_is_excluded "$entry" && continue
ln -s "$source_home/$entry" "$target_home/$entry" || die "Cannot link shared entry $entry into $target_home"
done < <(shared_profile_link_candidate_entries "$source_home")

note "Initialized $target_profile ($target_home)"
note "Sharing with $source_profile ($source_home)"
}

codex_desktop_running() {
pgrep -x Codex > /dev/null 2>&1 && return 0
pgrep -f "$CODEX_BUNDLED_CLI app-server" > /dev/null 2>&1 && return 0
Expand Down Expand Up @@ -246,9 +320,32 @@ command_login() {

command_init() {
local profile="${1:-}"
[[ -n "$profile" ]] || die "Usage: $PROGRAM init <profile>"
[[ -n "$profile" ]] || die "Usage: $PROGRAM init <profile> [--share-with <profile>]"
shift || true
[[ "$#" -eq 0 ]] || die "Usage: $PROGRAM init <profile>"
local share_with=""

while [[ "$#" -gt 0 ]]; do
case "$1" in
--share-with)
[[ -n "${2:-}" ]] || die "Usage: $PROGRAM init <profile> [--share-with <profile>]"
share_with="$2"
shift
;;
--share-with=*)
share_with="${1#--share-with=}"
[[ -n "$share_with" ]] || die "Usage: $PROGRAM init <profile> [--share-with <profile>]"
;;
*)
die "Usage: $PROGRAM init <profile> [--share-with <profile>]"
;;
esac
shift
done

if [[ -n "$share_with" ]]; then
initialize_shared_profile "$profile" "$share_with"
return 0
fi

local codex_home existed
codex_home="$(codex_home_for_profile "$profile")"
Expand Down
86 changes: 86 additions & 0 deletions test/codex-profile-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ assert_equals() {
fi
}

assert_symlink_target() {
local path="$1"
local expected="$2"
local actual

[[ -L "$path" ]] || fail "expected symlink: $path"
actual="$(readlink "$path")"
[[ "$actual" == "$expected" ]] || fail "expected $path -> $expected, got $actual"
}

mode_of() {
if stat -f '%Lp' "$1" > /dev/null 2>&1; then
stat -f '%Lp' "$1"
Expand Down Expand Up @@ -447,6 +457,78 @@ test_init_creates_private_profile_home_without_codex() {
rm -rf "$tmp"
}

test_init_share_with_links_existing_profile_except_auth_file() {
local tmp source_home target_home
tmp="$(mktemp -d)"
source_home="$tmp/home/.codex-personal-1"
target_home="$tmp/home/.codex-personal-2"
mkdir -p "$source_home/rules" "$source_home/sessions"
printf 'model = "gpt-5"\n' > "$source_home/config.toml"
printf '# Shared instructions\n' > "$source_home/AGENTS.md"
printf 'follow the rules\n' > "$source_home/rules/base.md"
printf 'shared session\n' > "$source_home/sessions/one.json"
printf '{"token":"secret"}\n' > "$source_home/auth.json"

run_cmd env HOME="$tmp/home" CODEX_CLI=/no/such/codex "$SCRIPT" init personal-2 --share-with personal-1

assert_status 0
assert_contains "Initialized personal-2 ($target_home)"
assert_contains "Sharing with personal-1 ($source_home)"
[[ -d "$target_home" ]] || fail "shared init did not create target home"
[[ "$(mode_of "$target_home")" == "700" ]] || fail "shared profile home is not private"
assert_symlink_target "$target_home/config.toml" "$source_home/config.toml"
assert_symlink_target "$target_home/AGENTS.md" "$source_home/AGENTS.md"
assert_symlink_target "$target_home/rules" "$source_home/rules"
assert_symlink_target "$target_home/sessions" "$source_home/sessions"
[[ ! -e "$target_home/auth.json" ]] || fail "shared init should not link auth.json"

rm -rf "$tmp"
}

test_init_share_with_refuses_missing_source_profile() {
local tmp
tmp="$(mktemp -d)"

run_cmd env HOME="$tmp/home" CODEX_CLI=/no/such/codex "$SCRIPT" init personal-2 --share-with personal-1

assert_status 1
assert_contains "Shared profile source is not initialized"

rm -rf "$tmp"
}

test_init_share_with_refuses_same_profile() {
local tmp source_home
tmp="$(mktemp -d)"
source_home="$tmp/home/.codex-personal"
mkdir -p "$source_home"

run_cmd env HOME="$tmp/home" CODEX_CLI=/no/such/codex "$SCRIPT" init personal --share-with personal

assert_status 1
assert_contains "Shared profile source must be different from the target profile"

rm -rf "$tmp"
}

test_init_share_with_refuses_existing_nonempty_target() {
local tmp source_home target_home
tmp="$(mktemp -d)"
source_home="$tmp/home/.codex-personal-1"
target_home="$tmp/home/.codex-personal-2"
mkdir -p "$source_home" "$target_home"
printf 'model = "gpt-5"\n' > "$source_home/config.toml"
printf 'keep me\n' > "$target_home/local.txt"

run_cmd env HOME="$tmp/home" CODEX_CLI=/no/such/codex "$SCRIPT" init personal-2 --share-with personal-1

assert_status 1
assert_contains "Target profile already contains files"
[[ "$(cat "$target_home/local.txt")" == "keep me" ]] || fail "shared init modified an existing target"

rm -rf "$tmp"
}

test_remove_aborts_when_confirmation_does_not_match() {
local tmp profile_home
tmp="$(mktemp -d)"
Expand Down Expand Up @@ -908,6 +990,10 @@ test_app_logs_stay_under_profile_home
test_app_refuses_to_launch_when_app_server_is_still_running
test_doctor_skips_status_when_cli_missing
test_init_creates_private_profile_home_without_codex
test_init_share_with_links_existing_profile_except_auth_file
test_init_share_with_refuses_missing_source_profile
test_init_share_with_refuses_same_profile
test_init_share_with_refuses_existing_nonempty_target
test_remove_aborts_when_confirmation_does_not_match
test_remove_yes_deletes_profile_home
test_remove_yes_deletes_profiles_named_like_common_aliases
Expand Down