From 9264d3a61aa73c506438729c9bf14c1b4b21ea49 Mon Sep 17 00:00:00 2001 From: Jacques Marais <88091427+Jacques2Marais@users.noreply.github.com> Date: Wed, 27 May 2026 15:02:00 +0200 Subject: [PATCH] feat: support shared profile groups --- CHANGELOG.md | 4 ++ README.md | 35 +++++++++++-- bin/codex-profile | 103 +++++++++++++++++++++++++++++++++++-- test/codex-profile-test.sh | 86 +++++++++++++++++++++++++++++++ 4 files changed, 221 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b38a6ed..490268f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,15 @@ and this project follows semantic versioning for tagged releases. ### Added +- `init --share-with ` 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 diff --git a/README.md b/README.md index a8dacf1..a3768b3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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 @@ -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 @@ -338,7 +361,7 @@ alias codex-app-work='codex-profile app work' codex-profile app [workspace] codex-profile cli [codex-args...] codex-profile login [codex-login-args...] -codex-profile init +codex-profile init [--share-with ] codex-profile remove [--yes] codex-profile status [profile] codex-profile status --json [profile] @@ -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. diff --git a/bin/codex-profile b/bin/codex-profile index 852b0c3..8c7becc 100755 --- a/bin/codex-profile +++ b/bin/codex-profile @@ -20,7 +20,7 @@ Usage: $PROGRAM app [workspace] $PROGRAM cli [codex-args...] $PROGRAM login [codex-login-args...] - $PROGRAM init + $PROGRAM init [--share-with ] $PROGRAM remove [--yes] $PROGRAM status [profile] $PROGRAM status --json [profile] @@ -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 @@ -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 @@ -246,9 +320,32 @@ command_login() { command_init() { local profile="${1:-}" - [[ -n "$profile" ]] || die "Usage: $PROGRAM init " + [[ -n "$profile" ]] || die "Usage: $PROGRAM init [--share-with ]" shift || true - [[ "$#" -eq 0 ]] || die "Usage: $PROGRAM init " + local share_with="" + + while [[ "$#" -gt 0 ]]; do + case "$1" in + --share-with) + [[ -n "${2:-}" ]] || die "Usage: $PROGRAM init [--share-with ]" + share_with="$2" + shift + ;; + --share-with=*) + share_with="${1#--share-with=}" + [[ -n "$share_with" ]] || die "Usage: $PROGRAM init [--share-with ]" + ;; + *) + die "Usage: $PROGRAM init [--share-with ]" + ;; + 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")" diff --git a/test/codex-profile-test.sh b/test/codex-profile-test.sh index 577394d..65a2ccd 100644 --- a/test/codex-profile-test.sh +++ b/test/codex-profile-test.sh @@ -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" @@ -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)" @@ -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