diff --git a/.dotstate-profiles.toml b/.dotstate-profiles.toml index 6776633..7998702 100644 --- a/.dotstate-profiles.toml +++ b/.dotstate-profiles.toml @@ -24,6 +24,7 @@ synced_files = [ ".editorconfig", ".gitconfig", ".gitconfig-work", + ".local/bin/dots", ".pi/agent/settings.json", ".zprofile", ".zshenv", diff --git a/.gitignore b/.gitignore index 8e1ae01..7c4e5c7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ !/linux/ !/install.sh !/test.sh +!/dots !/AGENTS.md !/README.md diff --git a/README.md b/README.md index c01b9c7..aaa8a68 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,28 @@ in the way are moved to `~/.dotstate-backups//` with their paths kept. Re-running is safe and links paths added to the manifest since. Run `./test.sh` to validate configs and installer behavior. +### Installing parts with `dots` + +`dots` installs, checks and updates one part of the setup at a time. Run it as +`./dots` from a fresh clone; once linked it is on `PATH` as +`~/.local/bin/dots`. + +```sh +dots list # components, in install order +dots install skills # install only the missing agent skills +dots install brew shell # several components, run in install order +dots install --all --no-gui # everything, same as ./install.sh --no-gui +dots status # which components look installed +dots update brew herdr # update components (default: all) +dots link # link dotfiles with dotstate +dots doctor # also: sync, add, remove (passed to dotstate) +``` + +Components are `brew`, `gh`, `shell`, `tmux`, `tools` (uv, OpenCode, +ccstatusline), `link` (dotstate and the links), `herdr` and `skills`. `dots +install` checks what a component needs from earlier ones and stops with a hint +such as `npx not found; run: dots install brew` instead of installing it. + To stop managing a path, run `dotstate remove --yes` before deleting it from the repository, or `dotstate doctor` keeps reporting its dangling link. diff --git a/common/.local/bin/dots b/common/.local/bin/dots new file mode 120000 index 0000000..d3746ce --- /dev/null +++ b/common/.local/bin/dots @@ -0,0 +1 @@ +../../../dots \ No newline at end of file diff --git a/dots b/dots new file mode 100755 index 0000000..f00c7c8 --- /dev/null +++ b/dots @@ -0,0 +1,343 @@ +#!/usr/bin/env bash + +self="${BASH_SOURCE[0]}" +while [ -L "$self" ]; do + self_dir="$(cd "$(dirname "$self")" && pwd)" + self="$(readlink "$self")" + case "$self" in + /*) ;; + *) self="$self_dir/$self" ;; + esac +done +# shellcheck source=install.sh +source "$(cd "$(dirname "$self")" && pwd)/install.sh" + +show_dots_help() { + echo "Usage: dots [args]" + echo "" + echo "Commands:" + echo " install [--no-gui] ... Install the given components" + echo " install --all [--no-gui] Install everything (same as ./install.sh)" + echo " list List components in install order" + echo " status [--no-gui] [component]... Show which components are installed" + echo " update [component]... Update components (default: all)" + echo " link Link dotfiles with dotstate" + echo " doctor|sync|add|remove [args] Run the dotstate command" + echo " help Show this help message" + echo "" + echo "Components: ${COMPONENTS[*]}" +} + +# Split args into DOTS_ARGS (component names) and DOTS_ALL; parse_args reads +# --no-gui +split_dots_args() { + local arg + DOTS_ARGS=() + DOTS_ALL=false + for arg in "$@"; do + case "$arg" in + --all) DOTS_ALL=true ;; + --no-gui) ;; + -*) + print_error "Unknown option: $arg" + return 1 + ;; + *) DOTS_ARGS+=("$arg") ;; + esac + done +} + +dots_init() { + parse_args "$@" + setup_codespaces_environment + detect_os + load_brew_env +} + +# Fail unless $1 is installed, naming the component that provides it +require_cmd() { + if ! command_exists "$1"; then + print_error "$1 not found; run: dots install $2" + return 1 + fi +} + +# Check what a component needs from components before it +component_requires() { + case "$1" in + gh) require_cmd gh brew ;; + shell) require_cmd zsh brew && require_cmd git brew ;; + tmux) require_cmd git brew ;; + tools) require_cmd curl brew && require_cmd npm brew ;; + link) [ "$OS" != macos ] || require_cmd dotstate brew ;; + herdr) require_cmd herdr brew && require_cmd jq brew ;; + skills) + require_cmd npx brew && require_cmd jq brew || return 1 + if [ ! -f "$HOME/.agents/.skill-lock.json" ]; then + print_error "No skills lockfile at ~/.agents/.skill-lock.json; run: dots install link" + return 1 + fi + ;; + esac +} + +# Check each component just before it runs, so earlier ones in the same call +# can provide what it needs +before_component() { + load_brew_env + component_requires "$1" +} + +fail() { + echo "$*" + return 1 +} + +# Fail listing the names in "$@", if any +fail_missing() { + [ $# -eq 0 ] || fail "missing: $*" +} + +# Per-component checks: print what is missing and fail, or succeed silently +status_brew() { + command_exists brew || fail "brew not installed" || return + local casks="" + if [ "$OS" = linux ] || [ "$NO_GUI" = true ]; then + casks="$(brewfile_casks 2>/dev/null)" || fail "could not read casks from the Brewfile" || return + fi + HOMEBREW_BUNDLE_CASK_SKIP="$casks" brew bundle check --file="$BREWFILE" >/dev/null 2>&1 || + fail "Brewfile packages missing" +} + +status_gh() { + command_exists gh || fail "gh not installed" || return + local extension missing=() + while IFS= read -r extension; do + gh_extension_installed "$extension" || missing+=("$extension") + done < <(gh_extensions_wanted) + fail_missing "${missing[@]}" +} + +status_shell() { + [ "$(basename "$SHELL")" = zsh ] || fail "default shell is $SHELL" || return + [ -d "$HOME/.oh-my-zsh" ] || fail "oh-my-zsh not installed" || return + local plugins_dir plugin missing=() + plugins_dir="$(zsh_plugins_dir)" + for plugin in "${ZSH_GITHUB_PLUGINS[@]}"; do + [ -d "$plugins_dir/${plugin#*/}" ] || missing+=("${plugin#*/}") + done + fail_missing "${missing[@]}" +} + +status_tmux() { + [ -d "$HOME/.tmux/plugins/tpm" ] || fail "tpm not installed" +} + +status_tools() { + local tool missing=() + for tool in uv opencode ccstatusline; do + command_exists "$tool" || missing+=("$tool") + done + fail_missing "${missing[@]}" +} + +status_link() { + command_exists dotstate || fail "dotstate not installed" || return + dotstate_uses_this_repo || fail "dotstate links another repository, not $DOTFILES" || return + dotstate doctor --no-colors >/dev/null 2>&1 || fail "dotstate doctor reports issues; run: dots doctor" +} + +status_herdr() { + command_exists herdr || fail "herdr not installed" || return + command_exists jq || fail "jq not installed" || return + local installed_json entry missing=() + installed_json="$(herdr plugin list --json /dev/null)" || fail "could not list plugins" || return + for entry in "${HERDR_PLUGINS[@]}"; do + herdr_plugin_installed "$installed_json" "${entry%%:*}" || missing+=("${entry%%:*}") + done + fail_missing "${missing[@]}" +} + +status_skills() { + local lock="$HOME/.agents/.skill-lock.json" names name missing=() + [ -f "$lock" ] || fail "no skills lockfile" || return + command_exists jq || fail "jq not installed" || return + names="$(jq -r '.skills | keys[]' "$lock" 2>/dev/null)" || fail "unreadable lockfile" || return + while IFS= read -r name; do + [ -z "$name" ] || [ -d "$HOME/.agents/skills/$name" ] || missing+=("$name") + done <<<"$names" + fail_missing "${missing[@]}" +} + +# Per-component updates; components without one are skipped +update_brew() { + require_cmd brew brew || return 1 + # Upgrade only what is installed, so a --no-gui machine gets no casks + brew update + brew upgrade +} + +update_gh() { + require_cmd gh brew || return 1 + gh extension upgrade --all +} + +update_shell() { + [ -d "$HOME/.oh-my-zsh" ] || { + print_error "oh-my-zsh not installed; run: dots install shell" + return 1 + } + ZSH="$HOME/.oh-my-zsh" zsh -f "$HOME/.oh-my-zsh/tools/upgrade.sh" + local plugins_dir plugin + plugins_dir="$(zsh_plugins_dir)" + for plugin in "${ZSH_GITHUB_PLUGINS[@]}"; do + if [ -d "$plugins_dir/${plugin#*/}" ]; then + print_info "Updating zsh plugin ${plugin#*/}" + git -C "$plugins_dir/${plugin#*/}" pull --ff-only --recurse-submodules + fi + done +} + +update_tmux() { + [ -x "$HOME/.tmux/plugins/tpm/bin/update_plugins" ] || { + print_error "tpm not installed; run: dots install tmux" + return 1 + } + "$HOME/.tmux/plugins/tpm/bin/update_plugins" all +} + +# uv and OpenCode come from their own installers, ccstatusline from npm +update_tools() { + if command_exists uv; then + uv self update || print_warning "uv self update failed; if a package manager installed uv, update it there" + fi + if command_exists opencode; then + opencode upgrade /dev/null + for name in "${SELECTED_COMPONENTS[@]}"; do + if detail="$("status_$name")"; then + print_success "$name" + else + print_error "$name: $detail" + failed=$((failed + 1)) + fi + done + [ $failed -eq 0 ] +} + +cmd_update() { + local name + split_dots_args "$@" || return 1 + [ ${#DOTS_ARGS[@]} -gt 0 ] || DOTS_ARGS=("${COMPONENTS[@]}") + select_components "${DOTS_ARGS[@]}" || return 1 + dots_init "$@" + for name in "${SELECTED_COMPONENTS[@]}"; do + if declare -F "update_$name" >/dev/null; then + print_header "Updating $name..." + "update_$name" + else + print_info "Nothing to update for $name" + fi + done +} + +dots_main() { + local cmd="${1:-help}" + [ $# -eq 0 ] || shift + case "$cmd" in + install) cmd_install "$@" ;; + list) cmd_list ;; + status) cmd_status "$@" ;; + update) cmd_update "$@" ;; + link) + dots_init + link_dotfiles + ;; + doctor | sync | add | remove) + load_brew_env + require_cmd dotstate link || return 1 + dotstate "$cmd" "$@" + ;; + help | --help | -h) show_dots_help ;; + *) + print_error "Unknown command: $cmd" + show_dots_help + return 1 + ;; + esac +} + +# Run dots_main (skip when this script is sourced, e.g. by test.sh) +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + dots_main "$@" +fi diff --git a/install.sh b/install.sh index b09eccb..b380e90 100755 --- a/install.sh +++ b/install.sh @@ -61,6 +61,8 @@ show_help() { echo "Example:" echo " $0 # Install everything" echo " $0 --no-gui # Install only CLI tools" + echo "" + echo "Install single components with: $DOTFILES/dots install " } # Helper functions @@ -128,6 +130,7 @@ setup_codespaces_environment() { if [ -d "$PERSISTED_DOTFILES" ]; then DOTFILES="$PERSISTED_DOTFILES" BREWFILE="$DOTFILES/common/.config/brew/Brewfile" + GH_EXTENSIONS_FILE="$DOTFILES/common/.config/gh/extensions" print_success "Using dotfiles at: $DOTFILES" else print_warning "Persisted dotfiles not found at $PERSISTED_DOTFILES" @@ -180,18 +183,23 @@ install_linux_homebrew_dependencies() { print_success "Homebrew dependencies installed" } +# Put an installed brew on PATH; it may be missing in a fresh shell on Apple +# Silicon or Linux +load_brew_env() { + command_exists brew && return + + local brew_bin + for brew_bin in /opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew; do + if [ -x "$brew_bin" ]; then + eval "$("$brew_bin" shellenv)" + return + fi + done +} + # Install Homebrew install_homebrew() { - # brew may be installed but not on PATH yet (fresh shell on Apple Silicon or Linux) - if ! command_exists brew; then - local brew_bin - for brew_bin in /opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew; do - if [ -x "$brew_bin" ]; then - eval "$("$brew_bin" shellenv)" - break - fi - done - fi + load_brew_env if command_exists brew; then print_success "Homebrew already installed" @@ -351,13 +359,18 @@ ZSH_GITHUB_PLUGINS=( jeffreytse/zsh-vi-mode ) +zsh_plugins_dir() { + echo "${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/plugins" +} + install_zsh_plugins() { if [ ! -d "$HOME/.oh-my-zsh" ]; then print_warning "oh-my-zsh not installed, skipping zsh plugins" return fi - local plugins_dir="${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/plugins" + local plugins_dir + plugins_dir="$(zsh_plugins_dir)" local plugin name mkdir -p "$plugins_dir" @@ -411,6 +424,14 @@ set_zsh_default() { fi } +# Print the Brewfile's casks in the form HOMEBREW_BUNDLE_CASK_SKIP takes: +# space-separated names, not a boolean +brewfile_casks() { + local casks + casks="$(brew bundle list --cask --file="$BREWFILE")" || return 1 + echo "${casks//$'\n'/ }" +} + # Install packages from Brewfile install_brew_packages() { if [ ! -f "$BREWFILE" ]; then @@ -428,19 +449,18 @@ install_brew_packages() { elif [ "$NO_GUI" = true ]; then print_header "Installing CLI packages only (skipping GUI applications)..." local casks - # HOMEBREW_BUNDLE_CASK_SKIP takes space-separated cask names, not a boolean - if ! casks="$(brew bundle list --cask --file="$BREWFILE")"; then + if ! casks="$(brewfile_casks)"; then print_error "Could not read casks from Brewfile: $BREWFILE" return 1 fi - if ! HOMEBREW_DOWNLOAD_CONCURRENCY=1 HOMEBREW_NO_INSTALL_CLEANUP=1 HOMEBREW_BUNDLE_CASK_SKIP="${casks//$'\n'/ }" brew bundle --jobs=1 --file="$BREWFILE"; then + if ! HOMEBREW_DOWNLOAD_CONCURRENCY=1 HOMEBREW_NO_INSTALL_CLEANUP=1 HOMEBREW_BUNDLE_CASK_SKIP="$casks" brew bundle --jobs=1 --verbose --file="$BREWFILE"; then print_error "Homebrew failed to install CLI packages from: $BREWFILE" return 1 fi print_success "CLI packages installed" else print_header "Installing all packages from Brewfile..." - if ! HOMEBREW_DOWNLOAD_CONCURRENCY=1 HOMEBREW_NO_INSTALL_CLEANUP=1 brew bundle --jobs=1 --file="$BREWFILE"; then + if ! HOMEBREW_DOWNLOAD_CONCURRENCY=1 HOMEBREW_NO_INSTALL_CLEANUP=1 brew bundle --jobs=1 --verbose --file="$BREWFILE"; then print_error "Homebrew failed to install packages from: $BREWFILE" return 1 fi @@ -526,6 +546,23 @@ install_ccstatusline() { fi } +GH_EXTENSIONS_FILE="$DOTFILES/common/.config/gh/extensions" + +# Print the extensions listed in GH_EXTENSIONS_FILE, trimmed, without blank +# lines and comments +gh_extensions_wanted() { + [ -f "$GH_EXTENSIONS_FILE" ] || return 0 + local extension + while IFS= read -r extension || [ -n "$extension" ]; do + [[ -z "$extension" || "$extension" =~ ^# ]] && continue + echo "$extension" | xargs + done < "$GH_EXTENSIONS_FILE" +} + +gh_extension_installed() { + gh extension list 2>/dev/null | grep -F -- "$1" >/dev/null +} + # Install GitHub CLI extensions install_gh_extensions() { if ! command_exists gh; then @@ -545,10 +582,8 @@ install_gh_extensions() { return fi - local extensions_file="$DOTFILES/common/.config/gh/extensions" - - if [ ! -f "$extensions_file" ]; then - print_info "No gh extensions file found at $extensions_file" + if [ ! -f "$GH_EXTENSIONS_FILE" ]; then + print_info "No gh extensions file found at $GH_EXTENSIONS_FILE" return fi @@ -557,16 +592,8 @@ install_gh_extensions() { local installed=0 local skipped=0 - # Read extensions file line by line - while IFS= read -r extension || [ -n "$extension" ]; do - # Skip empty lines and comments - [[ -z "$extension" || "$extension" =~ ^# ]] && continue - - # Trim whitespace - extension=$(echo "$extension" | xargs) - - # Check if already installed - if gh extension list 2>/dev/null | grep -F -- "$extension" >/dev/null; then + while IFS= read -r extension; do + if gh_extension_installed "$extension"; then print_success "$extension already installed" skipped=$((skipped + 1)) else @@ -578,7 +605,7 @@ install_gh_extensions() { print_warning "Failed to install $extension" fi fi - done < "$extensions_file" + done < <(gh_extensions_wanted) if [ $installed -eq 0 ] && [ $skipped -eq 0 ]; then print_info "No extensions to install" @@ -642,69 +669,13 @@ install_dotstate() { print_success "dotstate installed to ~/.local/bin" } -# Move untracked state left at pre-dotstate paths (e.g. .config/zsh/secrets.zsh, -# herdr sessions, tmux plugins) into the common/ or macos/ directory that is now -# linked in its place. Apps the manifest does not link (no such directory under -# common/ or a profile) stay put. -migrate_legacy_state() { - [ -d "$DOTFILES/.config" ] || return 0 - - local app dir - for app in "$DOTFILES"/.config/*/; do - app="$(basename "$app")" - for dir in common macos linux; do - if [ -d "$DOTFILES/$dir/.config/$app" ]; then - merge_legacy_dir "$DOTFILES/.config/$app" "$DOTFILES/$dir/.config/$app" ".config/$app" - break - fi - done - done - - find "$DOTFILES/.config" -depth -type d -empty -delete -} - -# Move every entry of $1 into $2 without replacing anything already there. -# Usage: merge_legacy_dir