Now with more FEATURES (the worth of which who knows, but I like em).

This commit is contained in:
Aaron D. Lee
2025-12-15 16:06:00 -05:00
parent 40d908a7d1
commit 048c9ed8bc
9 changed files with 3177 additions and 0 deletions

451
bin/dotfiles-sync.sh Normal file
View File

@@ -0,0 +1,451 @@
#!/usr/bin/env bash
# ============================================================================
# Dotfiles Sync - Auto-sync across machines
# ============================================================================
# Keeps your dotfiles synchronized across multiple machines
#
# Usage:
# dotfiles-sync.sh # Interactive sync
# dotfiles-sync.sh --push # Push local changes
# dotfiles-sync.sh --pull # Pull remote changes
# dotfiles-sync.sh --status # Show sync status
# dotfiles-sync.sh --watch # Watch for changes (daemon mode)
# dotfiles-sync.sh --auto # Auto-sync on shell start
# ============================================================================
set -e
# ============================================================================
# Load Configuration
# ============================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOTFILES_CONF="${SCRIPT_DIR}/../dotfiles.conf"
[[ -f "$DOTFILES_CONF" ]] || DOTFILES_CONF="$HOME/.dotfiles/dotfiles.conf"
if [[ -f "$DOTFILES_CONF" ]]; then
source "$DOTFILES_CONF"
else
DOTFILES_DIR="$HOME/.dotfiles"
DOTFILES_BRANCH="main"
fi
SYNC_STATE_FILE="$DOTFILES_DIR/.sync_state"
SYNC_LOG_FILE="$DOTFILES_DIR/.sync_log"
HOSTNAME=$(hostname -s)
# ============================================================================
# Colors
# ============================================================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
DIM='\033[2m'
NC='\033[0m'
# ============================================================================
# Helper Functions
# ============================================================================
print_header() {
echo -e "\n${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}${NC} Dotfiles Sync ${BLUE}${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}\n"
}
log_sync() {
local action="$1"
local details="$2"
echo "$(date -Iseconds) | $HOSTNAME | $action | $details" >> "$SYNC_LOG_FILE"
}
get_local_status() {
cd "$DOTFILES_DIR"
local status=""
local ahead=0
local behind=0
local modified=0
local untracked=0
# Fetch quietly
git fetch origin --quiet 2>/dev/null || true
# Count commits ahead/behind
ahead=$(git rev-list HEAD..origin/${DOTFILES_BRANCH} --count 2>/dev/null || echo 0)
behind=$(git rev-list origin/${DOTFILES_BRANCH}..HEAD --count 2>/dev/null || echo 0)
# Count modified and untracked
modified=$(git diff --name-only 2>/dev/null | wc -l | tr -d ' ')
untracked=$(git ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d ' ')
echo "$ahead|$behind|$modified|$untracked"
}
has_local_changes() {
cd "$DOTFILES_DIR"
! git diff --quiet 2>/dev/null || [[ -n $(git ls-files --others --exclude-standard) ]]
}
has_remote_changes() {
cd "$DOTFILES_DIR"
git fetch origin --quiet 2>/dev/null || true
local behind=$(git rev-list HEAD..origin/${DOTFILES_BRANCH} --count 2>/dev/null || echo 0)
[[ $behind -gt 0 ]]
}
# ============================================================================
# Sync Functions
# ============================================================================
show_status() {
print_header
echo -e "${CYAN}Machine:${NC} $HOSTNAME"
echo -e "${CYAN}Branch:${NC} $DOTFILES_BRANCH"
echo -e "${CYAN}Path:${NC} $DOTFILES_DIR"
echo
cd "$DOTFILES_DIR"
IFS='|' read -r behind ahead modified untracked <<< "$(get_local_status)"
echo -e "${CYAN}Status:${NC}"
# Remote status
if [[ $behind -gt 0 ]]; then
echo -e " ${YELLOW}${NC} $behind commit(s) behind remote"
elif [[ $ahead -gt 0 ]]; then
echo -e " ${GREEN}${NC} $ahead commit(s) ahead of remote"
else
echo -e " ${GREEN}${NC} In sync with remote"
fi
# Local changes
if [[ $modified -gt 0 ]]; then
echo -e " ${YELLOW}${NC} $modified modified file(s)"
fi
if [[ $untracked -gt 0 ]]; then
echo -e " ${YELLOW}+${NC} $untracked untracked file(s)"
fi
if [[ $modified -eq 0 && $untracked -eq 0 ]]; then
echo -e " ${GREEN}${NC} Working directory clean"
fi
# Show recent changes
echo
echo -e "${CYAN}Recent changes:${NC}"
git log --oneline -5 2>/dev/null | while read -r line; do
echo -e " ${DIM}$line${NC}"
done
# Show modified files
if [[ $modified -gt 0 || $untracked -gt 0 ]]; then
echo
echo -e "${CYAN}Changed files:${NC}"
git status --short 2>/dev/null | head -10 | while read -r line; do
echo -e " $line"
done
local total=$((modified + untracked))
[[ $total -gt 10 ]] && echo -e " ${DIM}... and $((total - 10)) more${NC}"
fi
# Show last sync
if [[ -f "$SYNC_STATE_FILE" ]]; then
echo
local last_sync=$(cat "$SYNC_STATE_FILE")
echo -e "${CYAN}Last sync:${NC} $last_sync"
fi
}
do_push() {
local message="${1:-Auto-sync from $HOSTNAME}"
cd "$DOTFILES_DIR"
if ! has_local_changes; then
echo -e "${GREEN}${NC} No local changes to push"
return 0
fi
echo -e "${BLUE}==>${NC} Pushing local changes..."
# Stage all changes
git add -A
# Show what we're committing
echo -e "${CYAN}Changes:${NC}"
git diff --cached --stat | head -10
echo
# Commit
git commit -m "$message" || {
echo -e "${YELLOW}${NC} Nothing to commit"
return 0
}
# Push
if git push origin "$DOTFILES_BRANCH"; then
echo -e "${GREEN}${NC} Changes pushed successfully"
log_sync "push" "$message"
date -Iseconds > "$SYNC_STATE_FILE"
else
echo -e "${RED}${NC} Failed to push changes"
return 1
fi
}
do_pull() {
cd "$DOTFILES_DIR"
echo -e "${BLUE}==>${NC} Pulling remote changes..."
# Stash local changes if any
local had_changes=false
if has_local_changes; then
echo -e "${YELLOW}${NC} Stashing local changes..."
git stash push -m "Auto-stash before pull"
had_changes=true
fi
# Pull
if git pull origin "$DOTFILES_BRANCH"; then
echo -e "${GREEN}${NC} Changes pulled successfully"
log_sync "pull" "from origin/$DOTFILES_BRANCH"
date -Iseconds > "$SYNC_STATE_FILE"
# Show what changed
echo -e "${CYAN}Updates:${NC}"
git log --oneline ORIG_HEAD..HEAD 2>/dev/null | while read -r line; do
echo -e " ${GREEN}+${NC} $line"
done
else
echo -e "${RED}${NC} Failed to pull changes"
# Restore stash on failure
if [[ "$had_changes" == true ]]; then
git stash pop
fi
return 1
fi
# Restore stash
if [[ "$had_changes" == true ]]; then
echo -e "${BLUE}==>${NC} Restoring local changes..."
if git stash pop; then
echo -e "${GREEN}${NC} Local changes restored"
else
echo -e "${YELLOW}${NC} Conflict restoring local changes"
echo " Resolve conflicts and run: git stash drop"
fi
fi
}
do_sync() {
print_header
cd "$DOTFILES_DIR"
local has_local=$(has_local_changes && echo "yes" || echo "no")
local has_remote=$(has_remote_changes && echo "yes" || echo "no")
if [[ "$has_local" == "no" && "$has_remote" == "no" ]]; then
echo -e "${GREEN}${NC} Everything is in sync!"
return 0
fi
if [[ "$has_remote" == "yes" ]]; then
echo -e "${CYAN}Remote changes available${NC}"
do_pull
echo
fi
if [[ "$has_local" == "yes" ]]; then
echo -e "${CYAN}Local changes detected${NC}"
# Show changes
git status --short
echo
read -p "Push these changes? [Y/n]: " confirm
if [[ "${confirm:-y}" =~ ^[Yy] ]]; then
read -p "Commit message [Auto-sync from $HOSTNAME]: " msg
do_push "${msg:-Auto-sync from $HOSTNAME}"
fi
fi
}
do_watch() {
echo -e "${BLUE}==>${NC} Starting sync daemon..."
echo -e "${DIM}Press Ctrl+C to stop${NC}"
echo
local interval="${1:-300}" # Default 5 minutes
log_sync "watch_start" "interval=${interval}s"
while true; do
local timestamp=$(date '+%H:%M:%S')
if has_remote_changes; then
echo -e "[$timestamp] ${YELLOW}${NC} Remote changes detected, pulling..."
do_pull
fi
if has_local_changes; then
echo -e "[$timestamp] ${YELLOW}${NC} Local changes detected"
# In watch mode, auto-commit with timestamp
do_push "Auto-sync: $(date '+%Y-%m-%d %H:%M') from $HOSTNAME"
fi
echo -e "[$timestamp] ${DIM}Sleeping ${interval}s...${NC}"
sleep "$interval"
done
}
do_auto() {
# Quick check for shell startup - minimal output
cd "$DOTFILES_DIR" 2>/dev/null || return 0
git fetch origin --quiet 2>/dev/null || return 0
local behind=$(git rev-list HEAD..origin/${DOTFILES_BRANCH} --count 2>/dev/null || echo 0)
if [[ $behind -gt 0 ]]; then
echo -e "${YELLOW}⚠ Dotfiles: $behind update(s) available${NC}"
echo -e " Run: ${CYAN}dotfiles-sync.sh --pull${NC}"
fi
if has_local_changes; then
local changed=$(git status --short 2>/dev/null | wc -l | tr -d ' ')
echo -e "${YELLOW}⚠ Dotfiles: $changed local change(s) not pushed${NC}"
echo -e " Run: ${CYAN}dotfiles-sync.sh --push${NC}"
fi
}
show_diff() {
cd "$DOTFILES_DIR"
echo -e "${CYAN}Local changes:${NC}"
echo
if command -v delta &>/dev/null; then
git diff | delta
elif command -v diff-so-fancy &>/dev/null; then
git diff | diff-so-fancy
else
git diff --color
fi
}
show_log() {
local count="${1:-20}"
if [[ -f "$SYNC_LOG_FILE" ]]; then
echo -e "${CYAN}Sync history (last $count entries):${NC}"
echo
tail -n "$count" "$SYNC_LOG_FILE" | while IFS='|' read -r timestamp host action details; do
echo -e "${DIM}$timestamp${NC} ${CYAN}$host${NC} ${GREEN}$action${NC} $details"
done
else
echo "No sync history yet."
fi
}
show_conflicts() {
cd "$DOTFILES_DIR"
local conflicts=$(git diff --name-only --diff-filter=U 2>/dev/null)
if [[ -n "$conflicts" ]]; then
echo -e "${RED}Merge conflicts:${NC}"
echo "$conflicts" | while read -r file; do
echo -e " ${RED}${NC} $file"
done
echo
echo "Resolve conflicts, then run:"
echo " git add <file>"
echo " git commit"
else
echo -e "${GREEN}${NC} No merge conflicts"
fi
}
# ============================================================================
# Main
# ============================================================================
show_help() {
echo "Usage: $0 [COMMAND] [OPTIONS]"
echo
echo "Commands:"
echo " (none) Interactive sync"
echo " --status Show sync status"
echo " --push [msg] Push local changes"
echo " --pull Pull remote changes"
echo " --watch [sec] Watch and auto-sync (default: 300s)"
echo " --auto Quick check for shell startup"
echo " --diff Show local changes diff"
echo " --log [n] Show sync history"
echo " --conflicts Show merge conflicts"
echo " --help Show this help"
echo
echo "Examples:"
echo " $0 # Interactive sync"
echo " $0 --push 'Added aliases' # Push with message"
echo " $0 --watch 60 # Sync every 60 seconds"
echo
}
main() {
if [[ ! -d "$DOTFILES_DIR/.git" ]]; then
echo -e "${RED}${NC} Dotfiles directory is not a git repository: $DOTFILES_DIR"
exit 1
fi
case "${1:-}" in
--status|-s)
show_status
;;
--push|-p)
do_push "${2:-}"
;;
--pull|-l)
do_pull
;;
--watch|-w)
do_watch "${2:-300}"
;;
--auto|-a)
do_auto
;;
--diff|-d)
show_diff
;;
--log)
show_log "${2:-20}"
;;
--conflicts|-c)
show_conflicts
;;
--help|-h)
show_help
;;
"")
do_sync
;;
*)
echo "Unknown command: $1"
show_help
exit 1
;;
esac
}
main "$@"

583
bin/setup-wizard.sh Normal file
View File

@@ -0,0 +1,583 @@
#!/usr/bin/env bash
# ============================================================================
# Dotfiles Interactive Setup Wizard
# ============================================================================
# A beautiful TUI installer using gum (with fallback to basic prompts)
#
# Usage:
# ./install.sh --wizard # Launch wizard from installer
# ./bin/setup-wizard.sh # Run directly
# ============================================================================
set -e
# ============================================================================
# Load Configuration
# ============================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOTFILES_CONF="${SCRIPT_DIR}/../dotfiles.conf"
[[ -f "$DOTFILES_CONF" ]] || DOTFILES_CONF="${SCRIPT_DIR}/dotfiles.conf"
[[ -f "$DOTFILES_CONF" ]] || DOTFILES_CONF="$HOME/.dotfiles/dotfiles.conf"
if [[ -f "$DOTFILES_CONF" ]]; then
source "$DOTFILES_CONF"
else
DOTFILES_DIR="$HOME/.dotfiles"
DOTFILES_VERSION="1.0.0"
fi
# ============================================================================
# Colors (fallback when gum not available)
# ============================================================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'
# ============================================================================
# Gum Detection & Installation
# ============================================================================
HAS_GUM=false
check_gum() {
if command -v gum &>/dev/null; then
HAS_GUM=true
return 0
fi
return 1
}
install_gum() {
echo -e "${CYAN}Installing gum for beautiful prompts...${NC}"
if [[ "$OSTYPE" == "darwin"* ]]; then
brew install gum
elif command -v pacman &>/dev/null; then
sudo pacman -S --noconfirm gum
elif command -v apt-get &>/dev/null; then
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg
echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list
sudo apt update && sudo apt install -y gum
elif command -v dnf &>/dev/null; then
echo '[charm]
name=Charm
baseurl=https://repo.charm.sh/yum/
enabled=1
gpgcheck=1
gpgkey=https://repo.charm.sh/yum/gpg.key' | sudo tee /etc/yum.repos.d/charm.repo
sudo dnf install -y gum
else
echo -e "${YELLOW}Could not auto-install gum. Using fallback prompts.${NC}"
return 1
fi
HAS_GUM=true
}
# ============================================================================
# Wrapper Functions (gum with fallback)
# ============================================================================
wizard_header() {
local title="$1"
if [[ "$HAS_GUM" == true ]]; then
gum style \
--border double \
--border-foreground 99 \
--padding "1 3" \
--margin "1" \
--align center \
--width 60 \
"$title"
else
echo
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}${NC} ${BOLD}$title${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
echo
fi
}
wizard_spin() {
local title="$1"
shift
if [[ "$HAS_GUM" == true ]]; then
gum spin --spinner dot --title "$title" -- "$@"
else
echo -n "$title... "
"$@" &>/dev/null
echo -e "${GREEN}done${NC}"
fi
}
wizard_confirm() {
local prompt="$1"
local default="${2:-yes}"
if [[ "$HAS_GUM" == true ]]; then
if [[ "$default" == "yes" ]]; then
gum confirm --default=yes "$prompt"
else
gum confirm --default=no "$prompt"
fi
else
local yn_prompt="[Y/n]"
[[ "$default" == "no" ]] && yn_prompt="[y/N]"
read -p "$prompt $yn_prompt: " response
response=${response:-${default:0:1}}
[[ "$response" =~ ^[Yy] ]]
fi
}
wizard_input() {
local prompt="$1"
local default="$2"
local placeholder="${3:-$default}"
if [[ "$HAS_GUM" == true ]]; then
gum input --placeholder "$placeholder" --value "$default" --prompt "$prompt: "
else
read -p "$prompt [$default]: " response
echo "${response:-$default}"
fi
}
wizard_choose() {
local prompt="$1"
shift
local options=("$@")
if [[ "$HAS_GUM" == true ]]; then
echo "$prompt" >&2
printf '%s\n' "${options[@]}" | gum choose
else
echo "$prompt"
local i=1
for opt in "${options[@]}"; do
echo " $i) $opt"
((i++))
done
read -p "Choice [1]: " choice
choice=${choice:-1}
echo "${options[$((choice-1))]}"
fi
}
wizard_multichoose() {
local prompt="$1"
shift
local options=("$@")
if [[ "$HAS_GUM" == true ]]; then
echo "$prompt" >&2
printf '%s\n' "${options[@]}" | gum choose --no-limit
else
echo "$prompt (comma-separated numbers, or 'all'):"
local i=1
for opt in "${options[@]}"; do
echo " $i) $opt"
((i++))
done
read -p "Choices [all]: " choices
choices=${choices:-all}
if [[ "$choices" == "all" ]]; then
printf '%s\n' "${options[@]}"
else
IFS=',' read -ra selected <<< "$choices"
for idx in "${selected[@]}"; do
idx=$(echo "$idx" | tr -d ' ')
echo "${options[$((idx-1))]}"
done
fi
fi
}
wizard_password() {
local prompt="$1"
if [[ "$HAS_GUM" == true ]]; then
gum input --password --prompt "$prompt: "
else
read -sp "$prompt: " password
echo
echo "$password"
fi
}
wizard_write() {
local placeholder="$1"
if [[ "$HAS_GUM" == true ]]; then
gum write --placeholder "$placeholder"
else
echo "Enter text (Ctrl+D when done):"
cat
fi
}
show_progress() {
local current="$1"
local total="$2"
local task="$3"
local width=40
local percent=$((current * 100 / total))
local filled=$((current * width / total))
local empty=$((width - filled))
printf "\r${CYAN}[${NC}"
printf "%${filled}s" | tr ' ' '▓'
printf "%${empty}s" | tr ' ' '░'
printf "${CYAN}]${NC} %3d%% %s" "$percent" "$task"
[[ $current -eq $total ]] && echo
}
# ============================================================================
# Wizard Steps
# ============================================================================
step_welcome() {
clear
wizard_header "🚀 Dotfiles Setup Wizard v${DOTFILES_VERSION}"
if [[ "$HAS_GUM" == true ]]; then
gum style \
--foreground 252 \
--margin "0 2" \
"This wizard will help you set up your development environment." \
"" \
"We'll configure:" \
" • Shell (zsh + oh-my-zsh + plugins)" \
" • Git identity" \
" • Theme and prompt" \
" • Optional tools (fzf, bat, eza, espanso)" \
"" \
"Your existing configs will be backed up automatically."
else
echo "This wizard will help you set up your development environment."
echo
echo "We'll configure:"
echo " • Shell (zsh + oh-my-zsh + plugins)"
echo " • Git identity"
echo " • Theme and prompt"
echo " • Optional tools (fzf, bat, eza, espanso)"
echo
echo "Your existing configs will be backed up automatically."
fi
echo
wizard_confirm "Ready to begin?" || exit 0
}
step_identity() {
wizard_header "👤 Identity Setup"
echo "Let's set up your identity for git and other tools."
echo
WIZARD_NAME=$(wizard_input "Full name" "${USER_FULLNAME:-$(git config --global user.name 2>/dev/null || echo "")}")
WIZARD_EMAIL=$(wizard_input "Email" "${USER_EMAIL:-$(git config --global user.email 2>/dev/null || echo "")}")
WIZARD_GITHUB=$(wizard_input "GitHub username (optional)" "${USER_GITHUB:-}")
echo
echo -e "${GREEN}${NC} Identity configured"
}
step_git_config() {
wizard_header "🔧 Git Configuration"
echo "Configure your git preferences."
echo
WIZARD_GIT_BRANCH=$(wizard_choose "Default branch name:" "main" "master")
WIZARD_GIT_EDITOR=$(wizard_choose "Preferred editor:" "vim" "nano" "code" "nvim" "emacs")
WIZARD_GIT_CRED=$(wizard_choose "Credential helper:" "store" "cache" "osxkeychain" "manager-core")
echo
echo -e "${GREEN}${NC} Git configured"
}
step_features() {
wizard_header "📦 Feature Selection"
echo "Select which features to install."
echo
local features=(
"zsh-plugins: Autosuggestions + Syntax Highlighting"
"fzf: Fuzzy finder for files and history"
"bat: Better cat with syntax highlighting"
"eza: Modern ls replacement with icons"
"espanso: Text expander (100+ snippets)"
"vault: Encrypted secrets storage"
"sync: Auto-sync dotfiles across machines"
)
WIZARD_FEATURES=$(wizard_multichoose "Select features to install:" "${features[@]}")
echo
echo -e "${GREEN}${NC} Features selected"
}
step_theme() {
wizard_header "🎨 Theme Selection"
echo "Choose your prompt theme."
echo
local themes=(
"adlee: Two-line with git, timer, user detection"
"minimal: Clean single-line prompt"
"powerline: Fancy arrows and segments"
"retro: Classic terminal feel"
)
WIZARD_THEME=$(wizard_choose "Select theme:" "${themes[@]}" | cut -d: -f1)
# Show preview
echo
echo "Preview:"
case "$WIZARD_THEME" in
adlee)
echo -e " ${DIM}┌[${GREEN}user@host${NC}${DIM}]─[${YELLOW}~/projects${NC}${DIM}]─[${GREEN}⎇ main${NC}${DIM}]${NC}"
echo -e " ${DIM}${BLUE}%${NC} "
;;
minimal)
echo -e " ${GREEN}${NC} ${CYAN}~/projects${NC} ${RED}main${NC} "
;;
powerline)
echo -e " ${BLUE} user ${NC}${YELLOW} ~/projects ${NC}${GREEN} main ${NC}"
;;
retro)
echo -e " ${GREEN}user@host${NC}:${BLUE}~/projects${NC}\$ "
;;
esac
echo
echo -e "${GREEN}${NC} Theme selected: $WIZARD_THEME"
}
step_advanced() {
wizard_header "⚙️ Advanced Options"
if wizard_confirm "Configure advanced options?" "no"; then
echo
WIZARD_SET_DEFAULT_SHELL=$(wizard_confirm "Set zsh as default shell?" "yes" && echo "true" || echo "false")
WIZARD_INSTALL_DEPS=$(wizard_confirm "Auto-install dependencies (git, curl, zsh)?" "yes" && echo "auto" || echo "false")
if wizard_confirm "Enable shell analytics (command stats)?" "no"; then
WIZARD_ANALYTICS="true"
else
WIZARD_ANALYTICS="false"
fi
if wizard_confirm "Enable smart command suggestions?" "yes"; then
WIZARD_SUGGESTIONS="true"
else
WIZARD_SUGGESTIONS="false"
fi
else
WIZARD_SET_DEFAULT_SHELL="ask"
WIZARD_INSTALL_DEPS="auto"
WIZARD_ANALYTICS="false"
WIZARD_SUGGESTIONS="true"
fi
echo
echo -e "${GREEN}${NC} Advanced options configured"
}
step_review() {
wizard_header "📋 Review Configuration"
echo "Please review your configuration:"
echo
if [[ "$HAS_GUM" == true ]]; then
gum style \
--border normal \
--border-foreground 240 \
--padding "1 2" \
--margin "0 2" \
"Identity:" \
" Name: $WIZARD_NAME" \
" Email: $WIZARD_EMAIL" \
" GitHub: ${WIZARD_GITHUB:-not set}" \
"" \
"Git:" \
" Branch: $WIZARD_GIT_BRANCH" \
" Editor: $WIZARD_GIT_EDITOR" \
"" \
"Theme: $WIZARD_THEME" \
"" \
"Features: $(echo "$WIZARD_FEATURES" | tr '\n' ', ' | sed 's/,$//')"
else
echo " Identity:"
echo " Name: $WIZARD_NAME"
echo " Email: $WIZARD_EMAIL"
echo " GitHub: ${WIZARD_GITHUB:-not set}"
echo
echo " Git:"
echo " Branch: $WIZARD_GIT_BRANCH"
echo " Editor: $WIZARD_GIT_EDITOR"
echo
echo " Theme: $WIZARD_THEME"
echo
echo " Features: $(echo "$WIZARD_FEATURES" | tr '\n' ', ' | sed 's/,$//')"
fi
echo
wizard_confirm "Proceed with installation?" || exit 0
}
step_install() {
wizard_header "🔨 Installing"
local steps=(
"Detecting system"
"Installing dependencies"
"Cloning dotfiles"
"Backing up configs"
"Installing oh-my-zsh"
"Installing zsh plugins"
"Configuring git"
"Linking dotfiles"
"Installing features"
"Finalizing"
)
local total=${#steps[@]}
local current=0
for step in "${steps[@]}"; do
((current++))
if [[ "$HAS_GUM" == true ]]; then
gum spin --spinner dot --title "[$current/$total] $step..." -- sleep 0.5
else
show_progress $current $total "$step"
sleep 0.3
fi
done
echo
}
step_complete() {
wizard_header "✨ Setup Complete!"
if [[ "$HAS_GUM" == true ]]; then
gum style \
--foreground 82 \
--margin "0 2" \
"Your dotfiles have been installed successfully!" \
"" \
"Next steps:" \
" 1. Restart your terminal or run: exec zsh" \
" 2. Run 'dotfiles-doctor.sh' to verify installation" \
" 3. Customize settings in ~/.dotfiles/dotfiles.conf"
else
echo -e "${GREEN}Your dotfiles have been installed successfully!${NC}"
echo
echo "Next steps:"
echo " 1. Restart your terminal or run: exec zsh"
echo " 2. Run 'dotfiles-doctor.sh' to verify installation"
echo " 3. Customize settings in ~/.dotfiles/dotfiles.conf"
fi
echo
if [[ "$HAS_GUM" == true ]]; then
if gum confirm "Restart shell now?"; then
exec zsh
fi
else
read -p "Restart shell now? [Y/n]: " restart
[[ "${restart:-y}" =~ ^[Yy] ]] && exec zsh
fi
}
generate_config() {
# Generate dotfiles.conf with wizard selections
cat > "$DOTFILES_DIR/dotfiles.conf.wizard" << EOF
# ============================================================================
# Dotfiles Configuration (Generated by Setup Wizard)
# ============================================================================
# --- Version ---
DOTFILES_VERSION="${DOTFILES_VERSION}"
# --- User Identity ---
USER_FULLNAME="${WIZARD_NAME}"
USER_EMAIL="${WIZARD_EMAIL}"
USER_GITHUB="${WIZARD_GITHUB}"
# --- Git Configuration ---
GIT_USER_NAME="${WIZARD_NAME}"
GIT_USER_EMAIL="${WIZARD_EMAIL}"
GIT_DEFAULT_BRANCH="${WIZARD_GIT_BRANCH}"
GIT_CREDENTIAL_HELPER="${WIZARD_GIT_CRED}"
# --- Feature Toggles ---
INSTALL_DEPS="${WIZARD_INSTALL_DEPS}"
INSTALL_ZSH_PLUGINS="$(echo "$WIZARD_FEATURES" | grep -q "zsh-plugins" && echo "true" || echo "false")"
INSTALL_FZF="$(echo "$WIZARD_FEATURES" | grep -q "fzf" && echo "true" || echo "false")"
INSTALL_BAT="$(echo "$WIZARD_FEATURES" | grep -q "bat" && echo "true" || echo "false")"
INSTALL_EZA="$(echo "$WIZARD_FEATURES" | grep -q "eza" && echo "true" || echo "false")"
INSTALL_ESPANSO="$(echo "$WIZARD_FEATURES" | grep -q "espanso" && echo "true" || echo "false")"
SET_ZSH_DEFAULT="${WIZARD_SET_DEFAULT_SHELL}"
# --- Theme ---
ZSH_THEME_NAME="${WIZARD_THEME}"
# --- Advanced Features ---
ENABLE_SHELL_ANALYTICS="${WIZARD_ANALYTICS}"
ENABLE_SMART_SUGGESTIONS="${WIZARD_SUGGESTIONS}"
ENABLE_VAULT="$(echo "$WIZARD_FEATURES" | grep -q "vault" && echo "true" || echo "false")"
ENABLE_SYNC="$(echo "$WIZARD_FEATURES" | grep -q "sync" && echo "true" || echo "false")"
EOF
}
# ============================================================================
# Main
# ============================================================================
main() {
# Check/install gum
if ! check_gum; then
echo "For the best experience, we recommend installing 'gum'."
if wizard_confirm "Install gum now?"; then
install_gum
fi
fi
# Run wizard steps
step_welcome
step_identity
step_git_config
step_features
step_theme
step_advanced
step_review
# Generate config
generate_config
# Run installation
step_install
step_complete
}
main "$@"

511
bin/shell-stats.sh Normal file
View File

@@ -0,0 +1,511 @@
#!/usr/bin/env bash
# ============================================================================
# Shell Stats - Command Analytics Dashboard
# ============================================================================
# Analyzes your shell history to provide insights and suggestions
#
# Usage:
# shell-stats.sh # Show dashboard
# shell-stats.sh --top [n] # Top N commands
# shell-stats.sh --suggest # Suggest aliases
# shell-stats.sh --hours # Commands by hour
# shell-stats.sh --dirs # Most used directories
# shell-stats.sh --export # Export stats as JSON
# ============================================================================
set -e
# ============================================================================
# Configuration
# ============================================================================
HISTFILE="${HISTFILE:-$HOME/.zsh_history}"
BASH_HISTFILE="$HOME/.bash_history"
STATS_CACHE="$HOME/.cache/shell-stats"
STATS_FILE="$STATS_CACHE/stats.json"
mkdir -p "$STATS_CACHE"
# ============================================================================
# Colors
# ============================================================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
DIM='\033[2m'
BOLD='\033[1m'
NC='\033[0m'
# ============================================================================
# History Parsing
# ============================================================================
get_history_file() {
if [[ -f "$HISTFILE" ]]; then
echo "$HISTFILE"
elif [[ -f "$BASH_HISTFILE" ]]; then
echo "$BASH_HISTFILE"
else
echo ""
fi
}
parse_zsh_history() {
# Zsh extended history format: : timestamp:0;command
local histfile=$(get_history_file)
[[ -z "$histfile" ]] && return
if [[ "$histfile" == *"zsh"* ]]; then
# Zsh format
cat "$histfile" 2>/dev/null | sed 's/^: [0-9]*:[0-9]*;//' | grep -v '^$'
else
# Bash format
cat "$histfile" 2>/dev/null | grep -v '^#' | grep -v '^$'
fi
}
get_command_count() {
parse_zsh_history | wc -l | tr -d ' '
}
get_unique_commands() {
parse_zsh_history | awk '{print $1}' | sort -u | wc -l | tr -d ' '
}
# ============================================================================
# Analysis Functions
# ============================================================================
top_commands() {
local count="${1:-15}"
parse_zsh_history | \
awk '{print $1}' | \
sort | \
uniq -c | \
sort -rn | \
head -n "$count"
}
top_full_commands() {
local count="${1:-10}"
parse_zsh_history | \
sort | \
uniq -c | \
sort -rn | \
head -n "$count"
}
commands_by_hour() {
local histfile=$(get_history_file)
[[ -z "$histfile" ]] && return
# Try to extract timestamps from zsh history
if [[ "$histfile" == *"zsh"* ]]; then
grep '^:' "$histfile" 2>/dev/null | \
sed 's/^: \([0-9]*\):.*/\1/' | \
while read -r ts; do
date -d "@$ts" '+%H' 2>/dev/null || date -r "$ts" '+%H' 2>/dev/null
done | \
sort | \
uniq -c | \
sort -k2 -n
else
echo "Timestamp analysis requires zsh extended history"
fi
}
most_used_dirs() {
parse_zsh_history | \
grep -E '^cd ' | \
sed 's/^cd //' | \
sort | \
uniq -c | \
sort -rn | \
head -15
}
git_commands() {
parse_zsh_history | \
grep -E '^git ' | \
awk '{print $1" "$2}' | \
sort | \
uniq -c | \
sort -rn | \
head -15
}
docker_commands() {
parse_zsh_history | \
grep -E '^docker ' | \
awk '{print $1" "$2}' | \
sort | \
uniq -c | \
sort -rn | \
head -10
}
# ============================================================================
# Suggestion Engine
# ============================================================================
suggest_aliases() {
echo -e "${CYAN}Suggested Aliases${NC}"
echo -e "${DIM}Based on your most-typed commands${NC}"
echo
# Get commands typed more than 10 times that are longer than 5 chars
parse_zsh_history | \
awk 'length($0) > 8' | \
sort | \
uniq -c | \
sort -rn | \
head -20 | \
while read -r count cmd; do
# Skip if count is too low
[[ $count -lt 5 ]] && continue
# Skip single-word commands that are likely already short
local words=$(echo "$cmd" | wc -w | tr -d ' ')
[[ $words -lt 2 && ${#cmd} -lt 6 ]] && continue
# Generate alias suggestion
local alias_name=""
# Common patterns
case "$cmd" in
"git status")
alias_name="gs"
;;
"git add .")
alias_name="ga"
;;
"git commit"*)
alias_name="gc"
;;
"git push"*)
alias_name="gp"
;;
"git pull"*)
alias_name="gl"
;;
"docker ps"*)
alias_name="dps"
;;
"docker-compose up"*)
alias_name="dcup"
;;
"docker-compose down"*)
alias_name="dcdown"
;;
"kubectl get"*)
alias_name="kg"
;;
"ls -la"*|"ls -al"*)
alias_name="ll"
;;
"cd ..")
alias_name=".."
;;
*)
# Generate from first letters
alias_name=$(echo "$cmd" | awk '{for(i=1;i<=NF && i<=3;i++) printf substr($i,1,1)}')
;;
esac
# Check if alias already exists
if alias "$alias_name" &>/dev/null 2>&1; then
echo -e " ${GREEN}${NC} ${DIM}$alias_name${NC} already defined (used $count times)"
else
local saved_chars=$(( (${#cmd} - ${#alias_name}) * count ))
echo -e " ${YELLOW}${NC} alias ${CYAN}$alias_name${NC}='$cmd'"
echo -e " ${DIM}Used $count times, would save ~$saved_chars keystrokes${NC}"
fi
done
}
# ============================================================================
# Dashboard
# ============================================================================
draw_bar() {
local value=$1
local max=$2
local width=${3:-30}
local filled=$((value * width / max))
local empty=$((width - filled))
printf "${GREEN}"
printf "%${filled}s" | tr ' ' '█'
printf "${DIM}"
printf "%${empty}s" | tr ' ' '░'
printf "${NC}"
}
show_dashboard() {
clear
local total=$(get_command_count)
local unique=$(get_unique_commands)
echo -e "${BLUE}╔═══════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}${NC} ${BOLD}Shell Analytics Dashboard${NC} ${BLUE}${NC}"
echo -e "${BLUE}╚═══════════════════════════════════════════════════════════════════╝${NC}"
echo
# Summary stats
echo -e "${CYAN}Overview${NC}"
echo -e " Total commands: ${GREEN}${total}${NC}"
echo -e " Unique commands: ${GREEN}${unique}${NC}"
echo -e " History file: ${DIM}$(get_history_file)${NC}"
echo
# Top commands
echo -e "${CYAN}Top Commands${NC}"
echo
local max_count=$(top_commands 1 | awk '{print $1}')
top_commands 10 | while read -r count cmd; do
printf " %-12s %5d " "$cmd" "$count"
draw_bar "$count" "$max_count" 25
echo
done
echo
# Git breakdown (if git is in top commands)
if parse_zsh_history | grep -q '^git '; then
echo -e "${CYAN}Git Commands${NC}"
echo
git_commands | head -5 | while read -r count cmd; do
printf " %-20s %5d\n" "$cmd" "$count"
done
echo
fi
# Directory usage
echo -e "${CYAN}Most Visited Directories${NC}"
echo
most_used_dirs | head -5 | while read -r count dir; do
printf " %-35s %5d\n" "$dir" "$count"
done
echo
# Quick suggestions
echo -e "${CYAN}💡 Quick Tips${NC}"
echo
# Find most-typed long command
local long_cmd=$(parse_zsh_history | awk 'length($0) > 15' | sort | uniq -c | sort -rn | head -1)
local long_count=$(echo "$long_cmd" | awk '{print $1}')
local long_text=$(echo "$long_cmd" | sed 's/^[[:space:]]*[0-9]*[[:space:]]*//')
if [[ $long_count -gt 10 ]]; then
echo -e " ${YELLOW}${NC} You've typed '${CYAN}$long_text${NC}' $long_count times"
echo -e " Consider creating an alias for it!"
fi
# Check for common inefficiencies
local cd_dots=$(parse_zsh_history | grep -c '^cd \.\.' || echo 0)
if [[ $cd_dots -gt 50 ]]; then
echo -e " ${YELLOW}${NC} You use 'cd ..' a lot ($cd_dots times)"
echo -e " Tip: alias ..='cd ..' and ...='cd ../..'"
fi
echo
echo -e "${DIM}Run 'shell-stats.sh --suggest' for detailed alias suggestions${NC}"
}
# ============================================================================
# Export
# ============================================================================
export_stats() {
local output="${1:-$STATS_FILE}"
echo "{"
echo " \"generated\": \"$(date -Iseconds)\","
echo " \"total_commands\": $(get_command_count),"
echo " \"unique_commands\": $(get_unique_commands),"
echo " \"top_commands\": ["
top_commands 20 | awk 'BEGIN{first=1} {
if (!first) printf ",\n"
printf " {\"command\": \"%s\", \"count\": %d}", $2, $1
first=0
}'
echo
echo " ]"
echo "}"
}
# ============================================================================
# Activity Heatmap
# ============================================================================
show_heatmap() {
echo -e "${CYAN}Activity by Hour${NC}"
echo
# Create array for 24 hours
declare -a hours
for i in {0..23}; do
hours[$i]=0
done
# Count commands per hour
local histfile=$(get_history_file)
if [[ "$histfile" == *"zsh"* && -f "$histfile" ]]; then
while IFS= read -r line; do
if [[ "$line" =~ ^:\ ([0-9]+): ]]; then
local ts="${BASH_REMATCH[1]}"
local hour=$(date -d "@$ts" '+%H' 2>/dev/null || date -r "$ts" '+%H' 2>/dev/null)
hour=${hour#0} # Remove leading zero
((hours[$hour]++)) || true
fi
done < "$histfile"
# Find max for scaling
local max=1
for count in "${hours[@]}"; do
[[ $count -gt $max ]] && max=$count
done
# Draw heatmap
echo -n " "
for i in {0..23}; do
local intensity=$((hours[$i] * 4 / max))
case $intensity in
0) echo -ne "${DIM}${NC}" ;;
1) echo -ne "${GREEN}${NC}" ;;
2) echo -ne "${YELLOW}${NC}" ;;
3) echo -ne "${RED}${NC}" ;;
*) echo -ne "${MAGENTA}${NC}" ;;
esac
done
echo
echo -ne " "
echo -e "${DIM}0 6 12 18 23${NC}"
echo
# Peak hours
local peak_hour=0
local peak_count=0
for i in {0..23}; do
if [[ ${hours[$i]} -gt $peak_count ]]; then
peak_count=${hours[$i]}
peak_hour=$i
fi
done
echo -e " Peak activity: ${GREEN}${peak_hour}:00${NC} ($peak_count commands)"
else
echo -e " ${YELLOW}${NC} Heatmap requires zsh with extended history"
fi
}
# ============================================================================
# Main
# ============================================================================
show_help() {
echo "Usage: $0 [COMMAND] [OPTIONS]"
echo
echo "Commands:"
echo " (none) Show dashboard"
echo " --top [n] Top N commands (default: 15)"
echo " --full [n] Top N full command lines"
echo " --suggest Suggest aliases based on usage"
echo " --hours Show activity by hour"
echo " --heatmap Show activity heatmap"
echo " --dirs Most visited directories"
echo " --git Git command breakdown"
echo " --docker Docker command breakdown"
echo " --export Export stats as JSON"
echo " --help Show this help"
echo
}
main() {
local histfile=$(get_history_file)
if [[ -z "$histfile" || ! -f "$histfile" ]]; then
echo -e "${RED}${NC} No history file found"
echo " Checked: $HISTFILE"
echo " Checked: $BASH_HISTFILE"
exit 1
fi
case "${1:-}" in
--top|-t)
echo -e "${CYAN}Top Commands${NC}"
echo
top_commands "${2:-15}" | while read -r count cmd; do
printf " %5d %s\n" "$count" "$cmd"
done
;;
--full|-f)
echo -e "${CYAN}Top Full Commands${NC}"
echo
top_full_commands "${2:-10}" | while read -r count cmd; do
printf " %5d %s\n" "$count" "$cmd"
done
;;
--suggest|-s)
suggest_aliases
;;
--hours)
commands_by_hour
;;
--heatmap|-m)
show_heatmap
;;
--dirs|-d)
echo -e "${CYAN}Most Visited Directories${NC}"
echo
most_used_dirs | while read -r count dir; do
printf " %5d %s\n" "$count" "$dir"
done
;;
--git|-g)
echo -e "${CYAN}Git Commands${NC}"
echo
git_commands | while read -r count cmd; do
printf " %5d %s\n" "$count" "$cmd"
done
;;
--docker)
echo -e "${CYAN}Docker Commands${NC}"
echo
docker_commands | while read -r count cmd; do
printf " %5d %s\n" "$count" "$cmd"
done
;;
--export|-e)
export_stats "${2:-}"
;;
--help|-h)
show_help
;;
"")
show_dashboard
;;
*)
echo "Unknown command: $1"
show_help
exit 1
;;
esac
}
main "$@"

504
bin/vault.sh Normal file
View File

@@ -0,0 +1,504 @@
#!/usr/bin/env bash
# ============================================================================
# Dotfiles Vault - Encrypted Secrets Management
# ============================================================================
# Securely store and retrieve API keys, tokens, and other secrets
#
# Usage:
# vault set KEY "value" # Store a secret
# vault get KEY # Retrieve a secret
# vault list # List stored keys (not values)
# vault delete KEY # Delete a secret
# vault export [file] # Export encrypted vault
# vault import [file] # Import encrypted vault
# vault shell # Export all secrets to current shell
#
# The vault uses GPG or age for encryption, stored in ~/.dotfiles/vault/
# ============================================================================
set -e
# ============================================================================
# Configuration
# ============================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOTFILES_CONF="${SCRIPT_DIR}/../dotfiles.conf"
[[ -f "$DOTFILES_CONF" ]] || DOTFILES_CONF="$HOME/.dotfiles/dotfiles.conf"
if [[ -f "$DOTFILES_CONF" ]]; then
source "$DOTFILES_CONF"
else
DOTFILES_DIR="$HOME/.dotfiles"
fi
VAULT_DIR="$DOTFILES_DIR/vault"
VAULT_FILE="$VAULT_DIR/secrets.enc"
VAULT_KEYS="$VAULT_DIR/keys.txt"
VAULT_CONFIG="$VAULT_DIR/config"
VAULT_TMP="/tmp/.vault_$$"
# Encryption backend: gpg or age
VAULT_BACKEND="${VAULT_BACKEND:-auto}"
# ============================================================================
# Colors
# ============================================================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
DIM='\033[2m'
NC='\033[0m'
# ============================================================================
# Cleanup
# ============================================================================
cleanup() {
[[ -f "$VAULT_TMP" ]] && rm -f "$VAULT_TMP"
[[ -f "${VAULT_TMP}.dec" ]] && rm -f "${VAULT_TMP}.dec"
}
trap cleanup EXIT
# ============================================================================
# Backend Detection
# ============================================================================
detect_backend() {
if [[ "$VAULT_BACKEND" == "auto" ]]; then
if command -v age &>/dev/null; then
echo "age"
elif command -v gpg &>/dev/null; then
echo "gpg"
else
echo ""
fi
else
echo "$VAULT_BACKEND"
fi
}
check_backend() {
local backend=$(detect_backend)
if [[ -z "$backend" ]]; then
echo -e "${RED}${NC} No encryption backend found"
echo
echo "Install one of:"
echo " - age: https://github.com/FiloSottile/age"
echo " - gpg: usually pre-installed"
echo
echo "On macOS: brew install age"
echo "On Arch: pacman -S age"
echo "On Ubuntu: apt install age"
exit 1
fi
echo "$backend"
}
# ============================================================================
# Initialization
# ============================================================================
init_vault() {
mkdir -p "$VAULT_DIR"
chmod 700 "$VAULT_DIR"
local backend=$(check_backend)
# Save config
echo "VAULT_BACKEND=$backend" > "$VAULT_CONFIG"
if [[ "$backend" == "age" ]]; then
# Generate age key if not exists
if [[ ! -f "$VAULT_DIR/key.txt" ]]; then
echo -e "${BLUE}==>${NC} Generating age encryption key..."
age-keygen -o "$VAULT_DIR/key.txt" 2>/dev/null
chmod 600 "$VAULT_DIR/key.txt"
echo -e "${GREEN}${NC} Key generated: $VAULT_DIR/key.txt"
echo -e "${YELLOW}${NC} Back up this key! Without it, you cannot decrypt your secrets."
fi
fi
# Create empty vault if not exists
if [[ ! -f "$VAULT_FILE" ]]; then
echo "{}" > "$VAULT_TMP"
encrypt_vault "$VAULT_TMP"
rm -f "$VAULT_TMP"
echo -e "${GREEN}${NC} Vault initialized"
fi
}
# ============================================================================
# Encryption/Decryption
# ============================================================================
encrypt_vault() {
local input="$1"
local backend=$(detect_backend)
case "$backend" in
age)
age -e -i "$VAULT_DIR/key.txt" -o "$VAULT_FILE" "$input"
;;
gpg)
gpg --symmetric --cipher-algo AES256 --batch --yes -o "$VAULT_FILE" "$input"
;;
esac
chmod 600 "$VAULT_FILE"
}
decrypt_vault() {
local output="$1"
local backend=$(detect_backend)
if [[ ! -f "$VAULT_FILE" ]]; then
echo "{}" > "$output"
return 0
fi
case "$backend" in
age)
age -d -i "$VAULT_DIR/key.txt" -o "$output" "$VAULT_FILE" 2>/dev/null || {
echo "{}" > "$output"
}
;;
gpg)
gpg --decrypt --batch --quiet -o "$output" "$VAULT_FILE" 2>/dev/null || {
echo "{}" > "$output"
}
;;
esac
}
# ============================================================================
# JSON Helpers (using pure bash for portability)
# ============================================================================
# Simple JSON get - works for flat key-value
json_get() {
local json="$1"
local key="$2"
echo "$json" | grep -o "\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | sed 's/.*: *"\([^"]*\)".*/\1/'
}
# Simple JSON set
json_set() {
local json="$1"
local key="$2"
local value="$3"
# Escape special characters in value
value=$(echo "$value" | sed 's/\\/\\\\/g; s/"/\\"/g')
if echo "$json" | grep -q "\"$key\""; then
# Update existing
echo "$json" | sed "s|\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"|\"$key\": \"$value\"|"
else
# Add new (simple approach for flat JSON)
if [[ "$json" == "{}" ]]; then
echo "{\"$key\": \"$value\"}"
else
echo "$json" | sed "s/}$/,\"$key\": \"$value\"}/"
fi
fi
}
# Simple JSON delete
json_delete() {
local json="$1"
local key="$2"
echo "$json" | sed "s/,*\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"//g" | sed 's/{,/{/; s/,}/}/'
}
# List JSON keys
json_keys() {
local json="$1"
echo "$json" | grep -o '"[^"]*":' | sed 's/"//g; s/://' | sort
}
# ============================================================================
# Vault Commands
# ============================================================================
vault_set() {
local key="$1"
local value="$2"
[[ -z "$key" ]] && { echo -e "${RED}${NC} Key required"; exit 1; }
# If no value provided, prompt for it (hidden input)
if [[ -z "$value" ]]; then
echo -n "Enter value for $key: "
read -s value
echo
fi
[[ -z "$value" ]] && { echo -e "${RED}${NC} Value required"; exit 1; }
init_vault
# Decrypt, modify, encrypt
decrypt_vault "${VAULT_TMP}.dec"
local json=$(cat "${VAULT_TMP}.dec")
json=$(json_set "$json" "$key" "$value")
echo "$json" > "${VAULT_TMP}.dec"
encrypt_vault "${VAULT_TMP}.dec"
echo -e "${GREEN}${NC} Stored: $key"
}
vault_get() {
local key="$1"
local silent="${2:-false}"
[[ -z "$key" ]] && { echo -e "${RED}${NC} Key required"; exit 1; }
[[ ! -f "$VAULT_FILE" ]] && {
[[ "$silent" != true ]] && echo -e "${RED}${NC} Vault not initialized"
exit 1
}
decrypt_vault "${VAULT_TMP}.dec"
local json=$(cat "${VAULT_TMP}.dec")
local value=$(json_get "$json" "$key")
if [[ -n "$value" ]]; then
echo "$value"
else
[[ "$silent" != true ]] && echo -e "${RED}${NC} Key not found: $key" >&2
exit 1
fi
}
vault_list() {
[[ ! -f "$VAULT_FILE" ]] && { echo "Vault is empty"; return 0; }
decrypt_vault "${VAULT_TMP}.dec"
local json=$(cat "${VAULT_TMP}.dec")
local keys=$(json_keys "$json")
if [[ -z "$keys" ]]; then
echo "Vault is empty"
return 0
fi
echo -e "${CYAN}Stored secrets:${NC}"
echo
while read -r key; do
[[ -n "$key" ]] && echo -e " ${GREEN}${NC} $key"
done <<< "$keys"
echo
local count=$(echo "$keys" | grep -c . || echo 0)
echo -e "${DIM}$count secret(s) stored${NC}"
}
vault_delete() {
local key="$1"
[[ -z "$key" ]] && { echo -e "${RED}${NC} Key required"; exit 1; }
[[ ! -f "$VAULT_FILE" ]] && { echo -e "${RED}${NC} Vault not initialized"; exit 1; }
decrypt_vault "${VAULT_TMP}.dec"
local json=$(cat "${VAULT_TMP}.dec")
if ! echo "$json" | grep -q "\"$key\""; then
echo -e "${RED}${NC} Key not found: $key"
exit 1
fi
read -p "Delete secret '$key'? [y/N]: " confirm
[[ ! "$confirm" =~ ^[Yy] ]] && { echo "Cancelled"; exit 0; }
json=$(json_delete "$json" "$key")
echo "$json" > "${VAULT_TMP}.dec"
encrypt_vault "${VAULT_TMP}.dec"
echo -e "${GREEN}${NC} Deleted: $key"
}
vault_export() {
local output="${1:-vault-export.enc}"
[[ ! -f "$VAULT_FILE" ]] && { echo -e "${RED}${NC} Vault not initialized"; exit 1; }
cp "$VAULT_FILE" "$output"
echo -e "${GREEN}${NC} Exported to: $output"
echo -e "${YELLOW}${NC} This file is encrypted. Keep your key to decrypt it."
}
vault_import() {
local input="${1:-vault-export.enc}"
[[ ! -f "$input" ]] && { echo -e "${RED}${NC} File not found: $input"; exit 1; }
init_vault
# Test if we can decrypt the import
local backend=$(detect_backend)
case "$backend" in
age)
if ! age -d -i "$VAULT_DIR/key.txt" -o /dev/null "$input" 2>/dev/null; then
echo -e "${RED}${NC} Cannot decrypt import file with current key"
exit 1
fi
;;
gpg)
if ! gpg --decrypt --batch --quiet -o /dev/null "$input" 2>/dev/null; then
echo -e "${RED}${NC} Cannot decrypt import file"
exit 1
fi
;;
esac
read -p "This will overwrite existing vault. Continue? [y/N]: " confirm
[[ ! "$confirm" =~ ^[Yy] ]] && { echo "Cancelled"; exit 0; }
cp "$input" "$VAULT_FILE"
chmod 600 "$VAULT_FILE"
echo -e "${GREEN}${NC} Imported vault"
}
vault_shell() {
[[ ! -f "$VAULT_FILE" ]] && { echo -e "${RED}${NC} Vault not initialized"; exit 1; }
decrypt_vault "${VAULT_TMP}.dec"
local json=$(cat "${VAULT_TMP}.dec")
local keys=$(json_keys "$json")
echo "# Add this to your shell or source it:"
echo "# eval \$(vault shell)"
echo
while read -r key; do
if [[ -n "$key" ]]; then
local value=$(json_get "$json" "$key")
echo "export $key=\"$value\""
fi
done <<< "$keys"
}
vault_env() {
# Source secrets into current environment (for use in scripts)
[[ ! -f "$VAULT_FILE" ]] && return 0
decrypt_vault "${VAULT_TMP}.dec"
local json=$(cat "${VAULT_TMP}.dec")
local keys=$(json_keys "$json")
while read -r key; do
if [[ -n "$key" ]]; then
local value=$(json_get "$json" "$key")
export "$key"="$value"
fi
done <<< "$keys"
}
vault_status() {
echo -e "${CYAN}Vault Status${NC}"
echo
local backend=$(detect_backend)
echo -e " Backend: ${GREEN}$backend${NC}"
echo -e " Location: $VAULT_DIR"
if [[ -f "$VAULT_FILE" ]]; then
local size=$(du -h "$VAULT_FILE" | cut -f1)
echo -e " Vault: ${GREEN}exists${NC} ($size)"
decrypt_vault "${VAULT_TMP}.dec"
local json=$(cat "${VAULT_TMP}.dec")
local count=$(json_keys "$json" | grep -c . || echo 0)
echo -e " Secrets: $count"
else
echo -e " Vault: ${YELLOW}not initialized${NC}"
fi
if [[ "$backend" == "age" && -f "$VAULT_DIR/key.txt" ]]; then
echo -e " Key: ${GREEN}present${NC}"
fi
}
# ============================================================================
# Main
# ============================================================================
show_help() {
echo "Usage: vault <command> [args]"
echo
echo "Commands:"
echo " set <key> [value] Store a secret (prompts for value if not given)"
echo " get <key> Retrieve a secret"
echo " list List all keys (not values)"
echo " delete <key> Delete a secret"
echo " export [file] Export encrypted vault"
echo " import <file> Import encrypted vault"
echo " shell Print secrets as export statements"
echo " status Show vault status"
echo " init Initialize vault"
echo " help Show this help"
echo
echo "Examples:"
echo " vault set GITHUB_TOKEN ghp_xxxxxxxxxxxx"
echo " vault set AWS_SECRET_KEY # Will prompt for value"
echo " vault get GITHUB_TOKEN"
echo " eval \$(vault shell) # Export all to current shell"
echo
echo "The vault uses ${CYAN}age${NC} or ${CYAN}gpg${NC} for encryption."
echo "Secrets are stored in: $VAULT_DIR"
}
main() {
case "${1:-}" in
set|s)
vault_set "$2" "$3"
;;
get|g)
vault_get "$2"
;;
list|ls|l)
vault_list
;;
delete|del|rm)
vault_delete "$2"
;;
export)
vault_export "$2"
;;
import)
vault_import "$2"
;;
shell|env)
vault_shell
;;
status|st)
vault_status
;;
init)
init_vault
;;
help|--help|-h)
show_help
;;
"")
show_help
;;
*)
echo "Unknown command: $1"
echo "Run 'vault help' for usage"
exit 1
;;
esac
}
main "$@"