From 75158bb970730f784875786db9d2b444200faaa2 Mon Sep 17 00:00:00 2001 From: aguil Date: Sat, 11 Jul 2026 13:24:02 -0600 Subject: [PATCH 1/2] fix(gh): export profile GH_CONFIG_DIR for subprocess callers Regression from repo-scoped gh routing (4361c73): auth lived only in the shell gh() wrapper, so command gh and tools like agents code-review hit stale ~/.config/gh and returned HTTP 401. Export GH_CONFIG_DIR from profile.d for personal and work profiles, keep gh-routes.d overrides in the wrapper, and add unit tests. --- README.md | 16 ++++--- docs/chezmoi-profiles.md | 3 ++ .../skills/dotfiles-github-cli/SKILL.md | 11 ++++- dot_bash_profile.tmpl | 39 ++++++++--------- .../profile.d/01-gh-default-config.sh.tmpl | 15 +++++++ dot_zshrc.tmpl | 39 ++++++++--------- tests/shell/unit/gh_default_config.bats | 42 +++++++++++++++++++ 7 files changed, 116 insertions(+), 49 deletions(-) create mode 100644 dot_config/chezmoi/profile.d/01-gh-default-config.sh.tmpl create mode 100644 tests/shell/unit/gh_default_config.bats diff --git a/README.md b/README.md index 1396e3e..bbca1be 100644 --- a/README.md +++ b/README.md @@ -330,17 +330,23 @@ you pass): ## Repo-scoped GitHub auth for chezmoi -The shell config includes a `gh` wrapper that automatically sets: +`~/.config/chezmoi/profile.d/01-gh-default-config.sh` exports a profile default +`GH_CONFIG_DIR` so subprocess tools (`command gh`, `agents code-review`, +scripts) use the same authenticated config as your shell: -- `GH_CONFIG_DIR=$HOME/.config/gh-personal` +- **personal** profile: `~/.config/gh-personal` +- **work** profile: `~/.config/gh` -...when your current directory is either: +The shell `gh` wrapper still applies `gh-routes.d` and built-in repo-path +overrides on top of that default when you run `gh` interactively. + +Automatic routing applies when your current directory is either: - under `$HOME/dev/repos/github.com/aguil`, or - inside your chezmoi source path (`chezmoi source-path`). -This lets you keep your default global `gh` account while always using your -personal profile for those trees. +This lets you keep a separate work `gh` profile while personal dotfiles work +uses `gh-personal` by default, including non-interactive subprocess callers. Overlay profiles can extend the wrapper without replacing it by installing route files in: diff --git a/docs/chezmoi-profiles.md b/docs/chezmoi-profiles.md index 5499628..6ba6464 100644 --- a/docs/chezmoi-profiles.md +++ b/docs/chezmoi-profiles.md @@ -84,6 +84,9 @@ takes precedence. `.chezmoi.toml.tmpl` data. - Tooling intent metadata in `.chezmoi.toml.tmpl` (`work`: IntelliJ IDEA + Cursor, `personal`: OpenCode + VSCode). +- GitHub CLI default config per profile via + `dot_config/chezmoi/profile.d/01-gh-default-config.sh.tmpl` (`GH_CONFIG_DIR` + for subprocess `gh`; shell `gh()` adds per-directory overrides). - Windows-only `komorebi`/`whkd` assets via `.chezmoiignore.tmpl`. ## Optional gh auto-login via 1Password diff --git a/dot_agents/skills/dotfiles-github-cli/SKILL.md b/dot_agents/skills/dotfiles-github-cli/SKILL.md index 7464682..7e28a4c 100644 --- a/dot_agents/skills/dotfiles-github-cli/SKILL.md +++ b/dot_agents/skills/dotfiles-github-cli/SKILL.md @@ -21,8 +21,15 @@ source tree** that applies into it, use **`gh`** for GitHub. ## Why prefer local `gh` It uses your login, git hosts (for example your personal GitHub SSH host alias), -and optional **`CHEZMOI_GH_HOST`** / 1Password flows from your dotfiles -docs—same context as your terminal. +profile-default `GH_CONFIG_DIR` from chezmoi `profile.d`, and optional +**`CHEZMOI_GH_HOST`** / 1Password flows from your dotfiles docs—same context as +your terminal. + +Subprocess tools invoke `command gh` (not the shell `gh()` function). Chezmoi +exports `GH_CONFIG_DIR` per profile in +`~/.config/chezmoi/profile.d/01-gh-default-config.sh` so agents and scripts do +not fall back to an unauthenticated `~/.config/gh` when your interactive shell +is routed correctly. ## Multiple GitHub.com logins on one machine diff --git a/dot_bash_profile.tmpl b/dot_bash_profile.tmpl index 1f4c4c0..f3cd4bd 100644 --- a/dot_bash_profile.tmpl +++ b/dot_bash_profile.tmpl @@ -78,7 +78,6 @@ export DART_FLAGS="--checked --load_deferred_eagerly" export DARTIUM_EXPIRATION_TIME=1577836800 export GOPATH=$HOME/code/gopath export CHEZMOI_GH_HOST='{{ if hasKey . "gh" }}{{ .gh.host }}{{ else if ne (env "CHEZMOI_GH_HOST") "" }}{{ env "CHEZMOI_GH_HOST" }}{{ else }}github.com{{ end }}' -export CHEZMOI_GH_CONFIG_DIR="${CHEZMOI_GH_CONFIG_DIR:-$HOME/.config/gh-personal}" export AI_TERM_CMD="{{ if eq .profile "work" }}agent{{ else }}opencode{{ end }}" path_append "$GOPATH/bin" path_append "/usr/local/bin/nimrod/bin" @@ -223,27 +222,25 @@ gh () { local route_config_dir local gh_personal_repo_root="$HOME/dev/repos/github.com/aguil" - if [ -z "${GH_CONFIG_DIR:-}" ]; then - if declare -F _chez_gh_route_config_dir >/dev/null 2>&1 \ - && route_config_dir="$(_chez_gh_route_config_dir)"; then - GH_CONFIG_DIR="$route_config_dir" command gh "$@" + if declare -F _chez_gh_route_config_dir >/dev/null 2>&1 \ + && route_config_dir="$(_chez_gh_route_config_dir)"; then + GH_CONFIG_DIR="$route_config_dir" command gh "$@" + return $? + fi + case "$PWD/" in + "$gh_personal_repo_root"/*|"$gh_personal_repo_root"/) + GH_CONFIG_DIR="$CHEZMOI_GH_CONFIG_DIR" command gh "$@" return $? - fi - case "$PWD/" in - "$gh_personal_repo_root"/*|"$gh_personal_repo_root"/) - GH_CONFIG_DIR="$CHEZMOI_GH_CONFIG_DIR" command gh "$@" - return $? - ;; - esac - if command -v chezmoi >/dev/null 2>&1; then - if chezmoi_source="$(chezmoi source-path 2>/dev/null)"; then - case "$PWD/" in - "$chezmoi_source"/*|"$chezmoi_source"/) - GH_CONFIG_DIR="$CHEZMOI_GH_CONFIG_DIR" command gh "$@" - return $? - ;; - esac - fi + ;; + esac + if command -v chezmoi >/dev/null 2>&1; then + if chezmoi_source="$(chezmoi source-path 2>/dev/null)"; then + case "$PWD/" in + "$chezmoi_source"/*|"$chezmoi_source"/) + GH_CONFIG_DIR="$CHEZMOI_GH_CONFIG_DIR" command gh "$@" + return $? + ;; + esac fi fi diff --git a/dot_config/chezmoi/profile.d/01-gh-default-config.sh.tmpl b/dot_config/chezmoi/profile.d/01-gh-default-config.sh.tmpl new file mode 100644 index 0000000..a0a1e0a --- /dev/null +++ b/dot_config/chezmoi/profile.d/01-gh-default-config.sh.tmpl @@ -0,0 +1,15 @@ +# Default GitHub CLI config for this chezmoi profile. +# +# Subprocess tools (agents code-review, scripts, mise tasks) invoke `command gh` +# and do not run the shell gh() wrapper. Export GH_CONFIG_DIR here so they use +# the same authenticated profile as interactive shells. +# +# The gh() wrapper in dot_zshrc / dot_bash_profile still applies gh-routes.d and +# built-in repo-path overrides on top of this default. + +{{- if eq .profile "work" }} +export CHEZMOI_GH_CONFIG_DIR="${CHEZMOI_GH_CONFIG_DIR:-$HOME/.config/gh}" +{{- else }} +export CHEZMOI_GH_CONFIG_DIR="${CHEZMOI_GH_CONFIG_DIR:-$HOME/.config/gh-personal}" +{{- end }} +export GH_CONFIG_DIR="${GH_CONFIG_DIR:-$CHEZMOI_GH_CONFIG_DIR}" diff --git a/dot_zshrc.tmpl b/dot_zshrc.tmpl index 34d04dd..e817bd6 100644 --- a/dot_zshrc.tmpl +++ b/dot_zshrc.tmpl @@ -4,7 +4,6 @@ export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" {{- end }} export CHEZMOI_GH_HOST='{{ if hasKey . "gh" }}{{ .gh.host }}{{ else if ne (env "CHEZMOI_GH_HOST") "" }}{{ env "CHEZMOI_GH_HOST" }}{{ else }}github.com{{ end }}' -export CHEZMOI_GH_CONFIG_DIR="${CHEZMOI_GH_CONFIG_DIR:-$HOME/.config/gh-personal}" export AI_TERM_CMD="{{ if eq .profile "work" }}agent{{ else }}opencode{{ end }}" {{- if eq .profile "personal" }} @@ -114,27 +113,25 @@ gh () { local route_config_dir local gh_personal_repo_root="$HOME/dev/repos/github.com/aguil" - if [ -z "${GH_CONFIG_DIR:-}" ]; then - if whence -w _chez_gh_route_config_dir >/dev/null 2>&1 \ - && route_config_dir="$(_chez_gh_route_config_dir)"; then - GH_CONFIG_DIR="$route_config_dir" command gh "$@" + if whence -w _chez_gh_route_config_dir >/dev/null 2>&1 \ + && route_config_dir="$(_chez_gh_route_config_dir)"; then + GH_CONFIG_DIR="$route_config_dir" command gh "$@" + return $? + fi + case "$PWD/" in + "$gh_personal_repo_root"/*|"$gh_personal_repo_root"/) + GH_CONFIG_DIR="$CHEZMOI_GH_CONFIG_DIR" command gh "$@" return $? - fi - case "$PWD/" in - "$gh_personal_repo_root"/*|"$gh_personal_repo_root"/) - GH_CONFIG_DIR="$CHEZMOI_GH_CONFIG_DIR" command gh "$@" - return $? - ;; - esac - if command -v chezmoi >/dev/null 2>&1; then - if chezmoi_source="$(chezmoi source-path 2>/dev/null)"; then - case "$PWD/" in - "$chezmoi_source"/*|"$chezmoi_source"/) - GH_CONFIG_DIR="$CHEZMOI_GH_CONFIG_DIR" command gh "$@" - return $? - ;; - esac - fi + ;; + esac + if command -v chezmoi >/dev/null 2>&1; then + if chezmoi_source="$(chezmoi source-path 2>/dev/null)"; then + case "$PWD/" in + "$chezmoi_source"/*|"$chezmoi_source"/) + GH_CONFIG_DIR="$CHEZMOI_GH_CONFIG_DIR" command gh "$@" + return $? + ;; + esac fi fi diff --git a/tests/shell/unit/gh_default_config.bats b/tests/shell/unit/gh_default_config.bats new file mode 100644 index 0000000..b25a048 --- /dev/null +++ b/tests/shell/unit/gh_default_config.bats @@ -0,0 +1,42 @@ +#!/usr/bin/env bats + +setup() { + REPO_ROOT="$(cd "${BATS_TEST_DIRNAME}/../../.." && pwd)" + RENDERED="$(mktemp)" +} + +teardown() { + rm -f "$RENDERED" +} + +render_profile_snippet() { + local profile="$1" + CHEZMOI_PROFILE="$profile" chezmoi execute-template \ + <"$REPO_ROOT/dot_config/chezmoi/profile.d/01-gh-default-config.sh.tmpl" \ + >"$RENDERED" +} + +@test "personal profile exports gh-personal for subprocess gh" { + command -v chezmoi >/dev/null 2>&1 || skip "chezmoi not installed" + render_profile_snippet personal + + run bash -c "source \"$RENDERED\"; printf '%s' \"\$GH_CONFIG_DIR\"" + [ "$status" -eq 0 ] + [[ "$output" == *"/.config/gh-personal" ]] +} + +@test "work profile template branch targets default gh config dir" { + grep -Fq 'eq .profile "work"' \ + "$REPO_ROOT/dot_config/chezmoi/profile.d/01-gh-default-config.sh.tmpl" + grep -Fq '$HOME/.config/gh}' \ + "$REPO_ROOT/dot_config/chezmoi/profile.d/01-gh-default-config.sh.tmpl" +} + +@test "subprocess command gh inherits exported GH_CONFIG_DIR" { + command -v chezmoi >/dev/null 2>&1 || skip "chezmoi not installed" + render_profile_snippet personal + + run bash -c "source \"$RENDERED\"; env | rg '^GH_CONFIG_DIR='" + [ "$status" -eq 0 ] + [[ "$output" == GH_CONFIG_DIR=*gh-personal ]] +} From b028b4acc26833948a11787b0f290e2b76890e78 Mon Sep 17 00:00:00 2001 From: aguil Date: Sat, 11 Jul 2026 13:36:22 -0600 Subject: [PATCH 2/2] fix(shell): harden agent session detection for rehydrated shells Agent shells that rehydrate from export -p can lose helper functions while keeping CURSOR_AGENT/CLAUDECODE env markers. Fall back to env checks in zoxide-init so smart-cd does not replace cd in agent sessions, and export _chez_agent_shell_active for bash like the gh route helper. --- dot_bash_profile.tmpl | 2 +- .../chezmoi/profile.d/00-agent-shell.sh | 12 +++++ .../chezmoi/profile.d/zoxide-init.sh.tmpl | 5 +- tests/shell/unit/agent_shell_detection.bats | 49 +++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/shell/unit/agent_shell_detection.bats diff --git a/dot_bash_profile.tmpl b/dot_bash_profile.tmpl index f3cd4bd..085b604 100644 --- a/dot_bash_profile.tmpl +++ b/dot_bash_profile.tmpl @@ -249,7 +249,7 @@ gh () { # Agent subprocesses (Claude Code, Cursor) rehydrate shell state from export -p # and inherit BASH_FUNC_gh without private helpers. Export the helper too. -export -f _chez_gh_route_config_dir gh 2>/dev/null || true +export -f _chez_gh_route_config_dir _chez_agent_shell_active _is_agent_shell gh 2>/dev/null || true tmuxdev () { local chezmoi_source diff --git a/dot_config/chezmoi/profile.d/00-agent-shell.sh b/dot_config/chezmoi/profile.d/00-agent-shell.sh index b08f5b9..ceaec79 100644 --- a/dot_config/chezmoi/profile.d/00-agent-shell.sh +++ b/dot_config/chezmoi/profile.d/00-agent-shell.sh @@ -11,3 +11,15 @@ _is_agent_shell() { [ -n "${CUSTOM_AGENT_SHELL:-}" ] && return 0 return 1 } + +# Agent shells rehydrated from export -p keep env markers but may drop helper +# functions. Use this at call sites (zoxide-init) instead of _is_agent_shell alone. +_chez_agent_shell_active() { + if type _is_agent_shell >/dev/null 2>&1 && _is_agent_shell; then + return 0 + fi + [ -n "${CURSOR_AGENT:-}" ] && return 0 + [ "${CLAUDECODE:-}" = "1" ] && return 0 + [ -n "${CUSTOM_AGENT_SHELL:-}" ] && return 0 + return 1 +} diff --git a/dot_config/chezmoi/profile.d/zoxide-init.sh.tmpl b/dot_config/chezmoi/profile.d/zoxide-init.sh.tmpl index 931d119..45a7c8e 100644 --- a/dot_config/chezmoi/profile.d/zoxide-init.sh.tmpl +++ b/dot_config/chezmoi/profile.d/zoxide-init.sh.tmpl @@ -7,7 +7,10 @@ chez_init_zoxide_cd() { if [ -n "${INTELLIJ_ENVIRONMENT_READER:-}" ] || ! command -v zoxide >/dev/null 2>&1; then return 0 fi - if type _is_agent_shell >/dev/null 2>&1 && _is_agent_shell; then + if type _chez_agent_shell_active >/dev/null 2>&1 && _chez_agent_shell_active; then + return 0 + fi + if [ -n "${CURSOR_AGENT:-}" ] || [ "${CLAUDECODE:-}" = "1" ] || [ -n "${CUSTOM_AGENT_SHELL:-}" ]; then return 0 fi diff --git a/tests/shell/unit/agent_shell_detection.bats b/tests/shell/unit/agent_shell_detection.bats new file mode 100644 index 0000000..d87dad2 --- /dev/null +++ b/tests/shell/unit/agent_shell_detection.bats @@ -0,0 +1,49 @@ +#!/usr/bin/env bats + +setup() { + REPO_ROOT="$(cd "${BATS_TEST_DIRNAME}/../../.." && pwd)" + PROFILE_D="$REPO_ROOT/dot_config/chezmoi/profile.d" + RENDERED_ZOXIDE="$(mktemp)" + command -v chezmoi >/dev/null 2>&1 || return 0 + chezmoi execute-template \ + <"$PROFILE_D/zoxide-init.sh.tmpl" >"$RENDERED_ZOXIDE" +} + +teardown() { + rm -f "${RENDERED_ZOXIDE:-}" +} + +@test "agent env markers skip zoxide when helper function is missing" { + command -v zoxide >/dev/null 2>&1 || skip "zoxide not installed" + command -v chezmoi >/dev/null 2>&1 || skip "chezmoi not installed" + + run bash -c ' + set -euo pipefail + source "$1/00-agent-shell.sh" + # shellcheck source=/dev/null + source "$2" + unset -f _is_agent_shell _chez_agent_shell_active 2>/dev/null || true + export CURSOR_AGENT=1 + chez_init_zoxide_cd + declare -f cd 2>/dev/null | grep -q __zoxide && exit 1 || exit 0 + ' bash "$PROFILE_D" "$RENDERED_ZOXIDE" + + [ "$status" -eq 0 ] +} + +@test "human shell without agent markers may init zoxide cd" { + command -v zoxide >/dev/null 2>&1 || skip "zoxide not installed" + command -v chezmoi >/dev/null 2>&1 || skip "chezmoi not installed" + + run bash -c ' + set -euo pipefail + source "$1/00-agent-shell.sh" + # shellcheck source=/dev/null + source "$2" + unset CURSOR_AGENT CLAUDECODE CUSTOM_AGENT_SHELL + chez_init_zoxide_cd + declare -f cd 2>/dev/null | grep -q __zoxide + ' bash "$PROFILE_D" "$RENDERED_ZOXIDE" + + [ "$status" -eq 0 ] +}