diff --git a/AGENTS.md b/AGENTS.md index 5a09bcc..3182d56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,14 @@ Three platforms are supported. All changes must account for all three: When adding a new tool installation, provide install commands for all three platforms. +### Shell configuration + +- When persisting `PATH` or env activation to a shell rc file, write to the rc file of the user's **actual login shell** (`$SHELL`) — never hardcode `~/.zshrc`. Bash → `~/.bashrc`, zsh → `~/.zshrc`. Fall back to the OS default shell when `$SHELL` is unset or unrecognized (macOS → zsh, Ubuntu/Arch → bash). +- Use the `login_shell_rc` helper in `lib/common.sh` to resolve the target rc path (it does the `$SHELL` + `$OS`-based selection). See `lib/mise_setup.sh`, `lib/packages_setup.sh`, or `lib/claude_code_setup.sh` for usage. +- On Ubuntu/Arch, login bash sources `~/.bashrc` via `~/.profile` / `~/.bash_profile`, so `~/.bashrc` is the correct target there. +- Writing only to `~/.zshrc` silently fails for bash users on Ubuntu/Omarchy. Resolve the rc via `login_shell_rc` rather than hardcoding `~/.zshrc`. +- Always make rc-file edits idempotent: guard the append with a `grep -qF` for a stable marker. + ### Scope boundaries This repo installs **tool managers, CLI tools, and infrastructure dependencies**: diff --git a/doctor.sh b/doctor.sh index 97b349d..e5a9835 100755 --- a/doctor.sh +++ b/doctor.sh @@ -139,6 +139,9 @@ source "$SCRIPT_DIR/lib/render_doctor.sh" # shellcheck source=lib/registries_doctor.sh source "$SCRIPT_DIR/lib/registries_doctor.sh" +# shellcheck source=lib/claude_code_doctor.sh +source "$SCRIPT_DIR/lib/claude_code_doctor.sh" + # shellcheck source=lib/migrate_doctor.sh source "$SCRIPT_DIR/lib/migrate_doctor.sh" diff --git a/lib/build_setup.sh b/lib/build_setup.sh index de6e79f..df3aa7f 100644 --- a/lib/build_setup.sh +++ b/lib/build_setup.sh @@ -17,6 +17,14 @@ case "$OS" in echo " NOTE: A dialog may have opened. Complete the installation and re-run this script." fi + # rust is required for building Ruby from source + if brew list rust > /dev/null 2>&1; then + fmt_ok "rust already installed" + else + fmt_install "rust" + brew install rust + fi + # libyaml is required for building Ruby from source if brew list libyaml > /dev/null 2>&1; then fmt_ok "libyaml already installed" @@ -32,6 +40,14 @@ case "$OS" in fmt_install "build-essential" sudo apt-get install -y -qq build-essential libssl-dev libreadline-dev zlib1g-dev libyaml-dev fi + + # rust is required for building Ruby from source + if dpkg -s rustc > /dev/null 2>&1; then + fmt_ok "rust already installed" + else + fmt_install "rust" + sudo apt-get install -y -qq rustc cargo + fi ;; arch) if pacman -Qi base-devel > /dev/null 2>&1; then @@ -41,5 +57,12 @@ case "$OS" in sudo pacman -S --noconfirm --needed base-devel fi + # rust is required for building Ruby from source + if pacman -Qi rust > /dev/null 2>&1; then + fmt_ok "rust already installed" + else + fmt_install "rust" + sudo pacman -S --noconfirm --needed rust + fi ;; esac diff --git a/lib/circleci_setup.sh b/lib/circleci_setup.sh index a6bed70..ca6d62b 100644 --- a/lib/circleci_setup.sh +++ b/lib/circleci_setup.sh @@ -31,26 +31,33 @@ fi fmt_header "CircleCI Authentication" -# Capture diagnostic output into a variable to avoid pipe + pipefail issues. -# With pipefail, a failing left-hand side poisons the whole pipeline even when -# grep succeeds, which causes false negatives. -circleci_diag="$(circleci diagnostic 2>&1 || true)" - -if echo "$circleci_diag" | grep -q "OK, got a token"; then - fmt_ok "CircleCI CLI: already authenticated" +# Allow skipping authentication when SKIP_CIRCLECI_AUTH is exactly "1". +# Only the auth step is skipped; the CLI install above always runs and the +# rest of setup.sh continues normally. +if [ "${SKIP_CIRCLECI_AUTH:-}" = "1" ]; then + fmt_ok "CircleCI CLI: authentication skipped (SKIP_CIRCLECI_AUTH=1)" else - echo " CircleCI CLI needs to be configured." - echo " You will need a personal API token from:" - echo " https://app.circleci.com/settings/user/tokens" - echo "" - circleci setup - + # Capture diagnostic output into a variable to avoid pipe + pipefail issues. + # With pipefail, a failing left-hand side poisons the whole pipeline even when + # grep succeeds, which causes false negatives. circleci_diag="$(circleci diagnostic 2>&1 || true)" - if ! echo "$circleci_diag" | grep -q "OK, got a token"; then + + if echo "$circleci_diag" | grep -q "OK, got a token"; then + fmt_ok "CircleCI CLI: already authenticated" + else + echo " CircleCI CLI needs to be configured." + echo " You will need a personal API token from:" + echo " https://app.circleci.com/settings/user/tokens" echo "" - echo "ERROR: CircleCI CLI authentication failed or was cancelled." - echo "Setup cannot continue without CircleCI access." - exit 1 + circleci setup + + circleci_diag="$(circleci diagnostic 2>&1 || true)" + if ! echo "$circleci_diag" | grep -q "OK, got a token"; then + echo "" + echo "ERROR: CircleCI CLI authentication failed or was cancelled." + echo "Setup cannot continue without CircleCI access." + exit 1 + fi + fmt_ok "CircleCI CLI: authenticated" fi - fmt_ok "CircleCI CLI: authenticated" fi diff --git a/lib/claude_code_doctor.sh b/lib/claude_code_doctor.sh new file mode 100644 index 0000000..b70265f --- /dev/null +++ b/lib/claude_code_doctor.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# +# Doctor check: Claude Code CLI (opt-in). +# Sourced by doctor.sh — do not execute directly. +# Requires: lib/common.sh, doctor helpers (check_pass, check_fail, check_cmd) +# +# This component is OPT-IN. It is only checked when the OPT_IN_CLAUDE_CODE +# environment variable is set to exactly "1". Otherwise it is skipped silently +# so machines that never opted in don't report a spurious failure. + +# Opt-in gate. Keep this check here (not in doctor.sh) so the component owns +# its own enablement logic. Only the literal value "1" enables the check. +if [ "${OPT_IN_CLAUDE_CODE:-}" = "1" ]; then + fmt_header "Claude Code (opt-in)" + + # The native installer installs to ~/.local/bin, which may not be on PATH in + # a non-interactive shell. Add it so the check below can find claude. + # Read-only — no files are modified. + export PATH="$HOME/.local/bin:$PATH" + + check_cmd "claude" "claude" + + if cmd_exists claude; then + version_output="$(claude --version 2>&1 | head -1)" + check_pass "claude reports version: $version_output" + fi +fi diff --git a/lib/claude_code_setup.sh b/lib/claude_code_setup.sh new file mode 100644 index 0000000..a8b4c1d --- /dev/null +++ b/lib/claude_code_setup.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# +# Claude Code CLI setup (opt-in). +# Sourced by setup.sh — do not execute directly. +# Requires: lib/common.sh +# +# This component is OPT-IN. It is only installed when the OPT_IN_CLAUDE_CODE +# environment variable is set to exactly "1". Any other value (including +# unset, "0", "true", "yes", etc.) is treated as "do not install". + +fmt_header "Claude Code (opt-in)" + +# Opt-in gate. Keep this check here (not in setup.sh) so the component owns +# its own enablement logic. Only the literal value "1" enables installation. +if [ "${OPT_IN_CLAUDE_CODE:-}" != "1" ]; then + fmt_ok "Claude Code: skipped (set OPT_IN_CLAUDE_CODE=1 to install)" +else + # The native installer puts the claude binary in ~/.local/bin, which is not + # on PATH by default. Persist it to the rc file of the user's actual login + # shell so claude is available in future shells. Idempotent. + claude_rc="$(login_shell_rc)" + + if [ ! -f "$claude_rc" ]; then + touch "$claude_rc" + fi + + if ! grep -qF '.local/bin' "$claude_rc" 2>/dev/null; then + { + echo "" + echo "# Local user binaries (Claude Code, etc.)" + # shellcheck disable=SC2016 # Intentionally single-quoted: written literally to RC file + echo 'export PATH="$HOME/.local/bin:$PATH"' + } >> "$claude_rc" + echo " Added ~/.local/bin to PATH in $claude_rc" + fi + + # Also add it for this session so the cmd_exists checks below work. + export PATH="$HOME/.local/bin:$PATH" + + if cmd_exists claude; then + fmt_ok "claude already installed ($(claude --version 2>/dev/null | head -1))" + else + fmt_install "Claude Code CLI" + # Official cross-platform native installer — installs to ~/.local/bin. + # Used on every OS (never Homebrew). + curl -fsSL https://claude.ai/install.sh | bash + + if cmd_exists claude; then + fmt_ok "Claude Code installed ($(claude --version 2>/dev/null | head -1))" + else + echo " WARNING: Claude Code was installed but is not yet on PATH." + echo " It will be available after restarting your shell." + fi + fi +fi diff --git a/lib/common.sh b/lib/common.sh index a2a92cf..68cb92c 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -84,3 +84,25 @@ if [ "$OS" = "unsupported" ]; then echo "Supported: macOS, Ubuntu/Debian, Arch Linux (including Omarchy)." exit 1 fi + +# --------------------------------------------------------------------------- +# Shell configuration +# --------------------------------------------------------------------------- + +# Path to the rc file of the user's actual login shell. Used when persisting +# PATH/env so it lands where the login shell will source it (bash -> ~/.bashrc, +# zsh -> ~/.zshrc). Falls back to the OS default shell when $SHELL is unset or +# unrecognized (macOS -> zsh, Linux -> bash). On Ubuntu/Arch login bash sources +# ~/.bashrc via ~/.profile / ~/.bash_profile, so ~/.bashrc is the right target. +login_shell_rc() { + case "$(basename "${SHELL:-}")" in + bash) echo "$HOME/.bashrc" ;; + zsh) echo "$HOME/.zshrc" ;; + *) + case "$OS" in + macos) echo "$HOME/.zshrc" ;; + *) echo "$HOME/.bashrc" ;; + esac + ;; + esac +} diff --git a/lib/git_doctor.sh b/lib/git_doctor.sh index 6c75ff2..fefa30b 100644 --- a/lib/git_doctor.sh +++ b/lib/git_doctor.sh @@ -37,3 +37,33 @@ if cmd_exists gh; then check_fail "gh --version returned unexpected output: $version_output" fi fi + +# --------------------------------------------------------------------------- +# git credential helper +# --------------------------------------------------------------------------- + +fmt_header "git credential helper" + +if cmd_exists git && cmd_exists gh; then + if git config --get-regexp '^credential\..*github\.com.*\.helper$' 2>/dev/null | grep -q 'gh'; then + check_pass "git is configured to authenticate GitHub HTTPS via gh" + else + check_fail "git credential helper for github.com not set — run 'gh auth setup-git'" + fi +fi + +# --------------------------------------------------------------------------- +# git identity +# --------------------------------------------------------------------------- + +fmt_header "git identity" + +if cmd_exists git; then + git_name="$(git config --global user.name || true)" + git_email="$(git config --global user.email || true)" + if [ -n "$git_name" ] && [ -n "$git_email" ]; then + check_pass "git identity set ($git_name <$git_email>)" + else + check_fail "git identity incomplete — set user.name and user.email globally" + fi +fi diff --git a/lib/git_setup.sh b/lib/git_setup.sh index 40bbaa7..7a62d55 100644 --- a/lib/git_setup.sh +++ b/lib/git_setup.sh @@ -99,3 +99,53 @@ else fi fmt_ok "Authenticated with GitHub" fi + +# --------------------------------------------------------------------------- +# git credential helper (use gh for HTTPS GitHub auth) +# --------------------------------------------------------------------------- +# +# Without this, git over HTTPS (e.g. `git pull` on a repo cloned via +# `gh repo clone`) prompts for a username/password even when `gh` is +# authenticated. `gh auth setup-git` wires git's credential helper to +# `gh auth git-credential` for github.com. Idempotent. + +fmt_header "git credential helper" + +gh auth setup-git +fmt_ok "Configured git to authenticate via gh" + +# --------------------------------------------------------------------------- +# git identity (global user.name / user.email) +# --------------------------------------------------------------------------- +# +# Derive the identity from the authenticated GitHub account. Only fill in +# values that are not already set globally — never clobber an identity the +# user configured deliberately. + +fmt_header "git identity" + +git_name="$(git config --global user.name || true)" +git_email="$(git config --global user.email || true)" + +if [ -n "$git_name" ] && [ -n "$git_email" ]; then + fmt_ok "git identity already set ($git_name <$git_email>)" +else + gh_login="$(gh api user --jq '.login')" + gh_id="$(gh api user --jq '.id')" + + if [ -z "$git_name" ]; then + git_name="$(gh api user --jq '.name // ""')" + # Display name is optional on GitHub -> fall back to the login. + [ -n "$git_name" ] || git_name="$gh_login" + git config --global user.name "$git_name" + fi + + if [ -z "$git_email" ]; then + git_email="$(gh api user --jq '.email // ""')" + # Public email is often hidden -> fall back to the noreply address. + [ -n "$git_email" ] || git_email="${gh_id}+${gh_login}@users.noreply.github.com" + git config --global user.email "$git_email" + fi + + fmt_ok "git identity set ($git_name <$git_email>)" +fi diff --git a/lib/mise_setup.sh b/lib/mise_setup.sh index 2596074..94c499c 100644 --- a/lib/mise_setup.sh +++ b/lib/mise_setup.sh @@ -33,19 +33,18 @@ else esac fi -# Ensure mise is activated in ~/.zshrc -if [ ! -f "$HOME/.zshrc" ]; then - touch "$HOME/.zshrc" -fi +# Ensure mise is activated in the user's login shell rc +mise_rc="$(login_shell_rc)" +[ -f "$mise_rc" ] || touch "$mise_rc" -if ! grep -qF "mise activate" "$HOME/.zshrc" 2>/dev/null; then +if ! grep -qF "mise activate" "$mise_rc" 2>/dev/null; then { echo "" echo "# mise version manager" # shellcheck disable=SC2016 # Intentionally single-quoted: written literally to RC file echo 'eval "$(mise activate)"' - } >> "$HOME/.zshrc" - echo " Added mise activation to ~/.zshrc" + } >> "$mise_rc" + echo " Added mise activation to $mise_rc" fi # Activate mise for this session @@ -60,6 +59,9 @@ fmt_header "Ruby (via mise)" if mise which ruby > /dev/null 2>&1; then fmt_ok "Ruby already available via mise" else + echo 'setting mise ruby.compile=false' + mise settings ruby.compile=false + fmt_install "Ruby (latest stable via mise)" mise use --global ruby@latest fmt_ok "Ruby installed: $(mise exec -- ruby --version)" diff --git a/lib/packages_setup.sh b/lib/packages_setup.sh index 4b425d1..7c6260b 100644 --- a/lib/packages_setup.sh +++ b/lib/packages_setup.sh @@ -32,20 +32,19 @@ case "$OS" in fi fi - # Ensure Homebrew activation is persisted in ~/.zshrc + # Ensure Homebrew activation is persisted in the user's login shell rc if cmd_exists brew; then - if [ ! -f "$HOME/.zshrc" ]; then - touch "$HOME/.zshrc" - fi + brew_rc="$(login_shell_rc)" + [ -f "$brew_rc" ] || touch "$brew_rc" - if ! grep -qF "brew shellenv" "$HOME/.zshrc"; then + if ! grep -qF "brew shellenv" "$brew_rc"; then { echo "" echo "# Homebrew" # shellcheck disable=SC2016 # Intentionally single-quoted: written literally to RC file echo 'eval "$(brew shellenv)"' - } >> "$HOME/.zshrc" - echo " Added Homebrew activation to ~/.zshrc" + } >> "$brew_rc" + echo " Added Homebrew activation to $brew_rc" fi fi ;; diff --git a/setup.sh b/setup.sh index afb8a1a..0c90b2e 100755 --- a/setup.sh +++ b/setup.sh @@ -152,6 +152,9 @@ source "$SCRIPT_DIR/lib/render_setup.sh" # shellcheck source=lib/registries_setup.sh source "$SCRIPT_DIR/lib/registries_setup.sh" +# shellcheck source=lib/claude_code_setup.sh +source "$SCRIPT_DIR/lib/claude_code_setup.sh" + # --------------------------------------------------------------------------- # Migrations # --------------------------------------------------------------------------- @@ -177,7 +180,31 @@ echo "" echo "${COLOR_GREEN}Setup finished successfully.${COLOR_RESET}" echo "" echo "${COLOR_YELLOW}Next steps:${COLOR_RESET}" -echo " 1. Open a new terminal (or run: source ~/.zshrc)" -echo " 2. Clone a project: cd ~/Work && gh repo clone trusted/" -echo " 3. Run project setup: cd && bin/setup" +echo " 1. Clone a project: cd ~/Work && gh repo clone trusted/" +echo " 2. Run project setup: cd && bin/setup" +echo "" + +# --------------------------------------------------------------------------- +# Hand off a fresh login shell +# --------------------------------------------------------------------------- +# +# Setup writes shell config (PATH/env) and, on Linux, adds the user to the +# docker group. Re-exec a fresh login shell so both take effect immediately, +# without the user having to log out / re-ssh. +# +# Using `$SHELL -l` re-reads the user's startup files for whichever shell +# they use (login bash reads ~/.bash_profile; login zsh reads +# ~/.zprofile/~/.zshrc). On Linux we wrap it in `sg docker` so the new docker +# group membership is active too — `sg` needs no password since the user is +# already a member (unlike `su - $USER`, which would prompt). + +login_shell="${SHELL:-/bin/bash}" + +echo "${COLOR_YELLOW}Starting a fresh login shell so the changes above take effect...${COLOR_RESET}" echo "" + +if [ "$OS" != "macos" ] && getent group docker 2>/dev/null | grep -qw "$USER"; then + exec sg docker -c "exec $login_shell -l" +else + exec "$login_shell" -l +fi