#!/usr/bin/env bash set -euo pipefail # POS: ai alias — manage AI agent aliases # POS_SUBCMDS: create edit remove list show source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh" # ── Paths & constants ────────────────────────────────────────── ENV_FILE="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}/ai-aliases.env" SH_FILE="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}/ai-aliases.sh" # Provider discovery — same pattern as bin/pos-ai (lines 17-18) PROVIDER_DIR="$(dirname "$0")/../lib/ai-providers" [ -d "$PROVIDER_DIR" ] || PROVIDER_DIR="$(dirname "$0")/ai-providers" # ── Core helpers ─────────────────────────────────────────────── _alias_load() { _ALIAS_NAMES=(); _ALIAS_PROVIDERS=(); _ALIAS_SESSIONS=(); _ALIAS_PROMPTS=() [ -f "$ENV_FILE" ] || return 0 while IFS='|' read -r name provider session prompt _rest; do [[ "$name" =~ ^[[:space:]]*# ]] && continue [[ -z "${name// /}" ]] && continue name="${name## }"; name="${name%% }" [[ "$name" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]] || continue provider="${provider## }"; provider="${provider%% }" session="${session## }"; session="${session%% }" _ALIAS_NAMES+=("$name") _ALIAS_PROVIDERS+=("$provider") _ALIAS_SESSIONS+=("$session") _ALIAS_PROMPTS+=("$prompt") done < <(grep -v '^[[:space:]]*#' "$ENV_FILE" | grep -v '^[[:space:]]*$' || true) } _alias_save() { mkdir -p "$(dirname "$ENV_FILE")" { printf '%s\n' "# AI aliases — managed by pos ai alias (do not hand-edit)" printf '%s\n' "# Format: alias_name|provider|session_name|system_prompt" printf '%s\n' "#" local i for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do printf '%s|%s|%s|%s\n' "${_ALIAS_NAMES[$i]}" "${_ALIAS_PROVIDERS[$i]}" \ "${_ALIAS_SESSIONS[$i]}" "${_ALIAS_PROMPTS[$i]}" done } >"$ENV_FILE" chmod 600 "$ENV_FILE" } # Build the pos-ai command for an alias with the prompt safely quoted as # ONE shell word (printf %q) — shared by regen (stored form) and show # (copy-pasteable display form). Empty prompt → no --system fragment. _alias_quote_cmd() { local provider="$1" session="$2" prompt="${3:-}" q_prompt printf -v q_prompt '%q' "$prompt" printf 'pos ai %s ask --session %s' "$provider" "$session" [ -n "$prompt" ] && printf ' --system %s' "$q_prompt" return 0 } # ── Activation artifacts (Option B) ──────────────────────────── # ENV stays the single source of truth; each alias is materialized as an # executable wrapper script at ~/.local/bin/. Every invocation re-reads # current bytes, so a stale snapshot (the old sourced-alias failure mode) is # impossible by construction. No shell sourcing of any kind. _wrapper_path() { printf '%s/.local/bin/%s' "$HOME" "$1" } # Ownership test: line 2 must carry our generator marker. Files failing this # test are NEVER overwritten or deleted. _alias_owned() { [ -f "$1" ] && sed -n '2p' "$1" 2>/dev/null | grep -q 'Managed by pos ai alias' } # Render one wrapper to stdout (args: name provider session prompt). # The exec line reuses _alias_quote_cmd's double-%q mechanics so the prompt # lands as exactly ONE shell word; "$@" passes user args through. _wrapper_render() { local name="$1" provider="$2" session="$3" prompt="${4:-}" cat <"$tmp" if cmp -s "$tmp" "$path"; then rm -f "$tmp" return 0 fi if ! bash -n "$tmp" 2>/dev/null; then warn "Wrapper for '$1' failed syntax check — keeping previous version" >&2 rm -f "$tmp" return 1 fi mv "$tmp" "$path" chmod 755 "$path" } # rc 0 iff ~/.local/bin is on PATH. _alias_check_path() { case ":$PATH:" in *":$HOME/.local/bin:"*) return 0 ;; *) return 1 ;; esac } # Legacy ~/.config/.../ai-aliases.sh retirement: activation moved to wrapper # scripts, and a stale sourced alias would shadow them (interactive bash gives # aliases precedence over PATH lookups). Marker-guarded auto-remove only — # foreign files are warned about and left untouched. _alias_retire_legacy_sh() { [ -f "$SH_FILE" ] || return 0 if ! head -n 3 "$SH_FILE" | grep -q 'Auto-generated by pos ai alias'; then warn "$SH_FILE was not generated by pos ai alias — left untouched; review manually" return 0 fi local stale stale="$(sed -n 's/^alias \([A-Za-z_][A-Za-z0-9_-]*\)=.*/\1/p' "$SH_FILE" | tr '\n' ' ')" stale="${stale% }" rm -f "$SH_FILE" { echo "[!] Alias activation moved to executable scripts in ~/.local/bin/ — legacy file removed: $SH_FILE" [ -n "$stale" ] && echo " Stale sourced aliases shadow the new scripts until cleaned — run: unalias $stale" echo " (or simply start a new shell)" } >&2 return 0 } # Two-way reconciliation on EVERY invocation: # forward: each ENV entry → render-diff-install (first-run migration, # create/edit/remove consistency, silent heal of hand-edited wrappers) # reverse: owned wrappers whose name is not in ENV → deleted (covers remove, # manual ENV edits, and the empty-set case) # plus: legacy .sh retirement; PATH guidance when owned wrappers exist but # ~/.local/bin is absent from PATH (wrappers are written regardless). _alias_sync() { _alias_load local bin_dir="${HOME}/.local/bin" i name f base match any=0 mkdir -p "$bin_dir" for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do _wrapper_install "${_ALIAS_NAMES[$i]}" "${_ALIAS_PROVIDERS[$i]}" \ "${_ALIAS_SESSIONS[$i]}" "${_ALIAS_PROMPTS[$i]}" || : done for f in "$bin_dir"/*; do [ -f "$f" ] || continue _alias_owned "$f" || continue base="${f##*/}" match=0 for name in ${_ALIAS_NAMES[@]+"${_ALIAS_NAMES[@]}"}; do [ "$base" = "$name" ] && { match=1; break; } done [ "$match" -eq 1 ] || rm -f "$f" done _alias_retire_legacy_sh if ! _alias_check_path; then for f in "$bin_dir"/*; do [ -f "$f" ] && _alias_owned "$f" && { any=1; break; } done if [ "$any" -eq 1 ]; then warn "~/.local/bin is not on your PATH — alias scripts will not resolve by name." warn " Fix now: export PATH=\"\$HOME/.local/bin:\$PATH\"" warn " Persist it: echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.profile" fi fi return 0 } _alias_provider_pick() { local providers=() for f in "$PROVIDER_DIR"/*.sh; do [ -f "$f" ] || continue providers+=("$(basename "$f" .sh)") done if [ ${#providers[@]} -eq 0 ]; then err "No AI providers installed — run 'pos ai' setup first" fi menu_pick "Pick provider" "${providers[@]}" } _alias_find() { local name="$1" i for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do if [ "${_ALIAS_NAMES[$i]}" = "$name" ]; then echo "$i" return 0 fi done echo "-1" return 0 } _alias_name_valid() { [[ "$1" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]] } _alias_prompt_truncate() { local p="$1" if [ ${#p} -gt 42 ]; then printf '%s…' "${p:0:42}" else printf '%s' "$p" fi } # ── Non-interactive output ───────────────────────────────────── # SINGLE alias-table renderer — used by `list` (stdout) and the menu # pre-render (inside its stderr display block). One source of truth for the # grid so the two contexts can never drift or duplicate each other. _alias_table() { local count=${#_ALIAS_NAMES[@]} i [ "$count" -eq 0 ] && return 0 printf ' %-12s %-12s %-12s %s\n' "Name" "Provider" "Session" "Prompt" printf ' %-12s %-12s %-12s %s\n' "------------" "------------" "------------" \ "------------------------------------------" for ((i = 0; i < count; i++)); do printf ' %-12s %-12s %-12s %s\n' "${_ALIAS_NAMES[$i]}" "${_ALIAS_PROVIDERS[$i]}" \ "${_ALIAS_SESSIONS[$i]}" "$(_alias_prompt_truncate "${_ALIAS_PROMPTS[$i]}")" done } _alias_list() { printf 'Aliases (%d):\n' "${#_ALIAS_NAMES[@]}" _alias_table } _alias_show() { local idx idx="$(_alias_find "$1")" [ "$idx" = "-1" ] && err "Alias '$1' not found" local name="${_ALIAS_NAMES[$idx]}" provider="${_ALIAS_PROVIDERS[$idx]}" local session="${_ALIAS_SESSIONS[$idx]}" prompt="${_ALIAS_PROMPTS[$idx]}" [ -z "$session" ] && session="$name" printf ' %-12s %s\n' "Alias:" "$name" printf ' %-12s %s\n' "Provider:" "$provider" printf ' %-12s %s\n' "Session:" "$session" if _alias_check_path; then printf ' %-12s %s\n' "Wrapper:" "$(_wrapper_path "$name")" else printf ' %-12s %s\n' "Wrapper:" "(not installed — ~/.local/bin not on PATH)" fi printf ' %-12s %s\n' "Prompt:" "${prompt:-$(printf '%s' "(default)")}" # Show the resolved command (same quoting mechanism as the generated # wrapper — what users copy from here pastes into a shell verbatim) printf ' %-12s %s\n' "Command:" "$(_alias_quote_cmd "$provider" "$session" "$prompt")" } # ── Interactive: main menu ───────────────────────────────────── _alias_menu() { menu_guard || return 1 while true; do { _alias_load if [ ${#_ALIAS_NAMES[@]} -eq 0 ]; then echo "${YELLOW}[!] No aliases defined yet — create one with option 1.${RESET}" else _alias_table printf ' %d alias(es)\n' "${#_ALIAS_NAMES[@]}" fi echo >&2 } >&2 local choice choice="$(menu_run "AI Agent Aliases" "Create new alias" "Edit existing alias" \ "Remove alias" "List aliases")" || return 0 case "$choice" in 1) _alias_create ;; 2) _alias_edit ;; 3) _alias_remove ;; 4) : ;; # List aliases — the loop's pre-render above IS the current # table (single renderer, redrawn fresh every iteration); # option 4 returns to the loop for a fresh render instead # of printing a second copy (dup-table bug fix). esac done } # ── Interactive: create ──────────────────────────────────────── _alias_create() { local preset_name="${1:-}" section "Create AI Agent Alias" >&2 # Step 1: Alias name local name="$preset_name" while true; do if [ -z "$name" ]; then step 1 4 "Alias Name" >&2 name="$(menu_ask_value "Alias name" "")" || return 0 fi [ -z "$name" ] && { warn "Alias name cannot be empty" >&2; name=""; continue; } if ! _alias_name_valid "$name"; then warn "Invalid name '$name' — use letters, digits, hyphens, underscores (start with a letter)" >&2 name=""; continue fi _alias_load local existing existing="$(_alias_find "$name")" if [ "$existing" != "-1" ]; then warn "Alias '$name' already exists — use 'pos ai alias edit $name' instead" >&2 [ -n "$preset_name" ] && return 1 name=""; continue fi # Collision refusals (never clobber foreign files or real binaries): # 1. wrapper exists WITH our marker → fine, sync regenerates it # 2. file exists WITHOUT marker → refuse # 3. name resolves to another binary on PATH → refuse, naming it local wpath wpath="$(_wrapper_path "$name")" if [ -e "$wpath" ]; then _alias_owned "$wpath" || err "File '~/.local/bin/$name' already exists and was not created by pos ai alias — pick another name" elif command -v "$name" >/dev/null 2>&1; then err "'$name' already exists on PATH as $(command -v "$name") — pick another name" fi break done # Step 2: Provider step 2 4 "Provider" >&2 local pidx pidx="$(_alias_provider_pick)" || return 0 local providers=() for f in "$PROVIDER_DIR"/*.sh; do [ -f "$f" ] || continue providers+=("$(basename "$f" .sh)") done local provider="${providers[$((pidx - 1))]}" # Step 3: Session name local session="" while true; do step 3 4 "Session Name" >&2 session="$(menu_ask_value "Session name" "$name")" || return 0 if [ -n "$session" ] && ! _alias_name_valid "$session"; then warn "Invalid session '$session' — use letters, digits, hyphens, underscores" >&2 session=""; continue fi break done [ -z "$session" ] && session="$name" # Step 4: System prompt local prompt="" while true; do step 4 4 "System Prompt" >&2 prompt="$(menu_ask_value "System prompt (empty = use built-in)" "")" || return 0 if [[ "$prompt" == *'|'* ]]; then warn "System prompt must not contain '|' characters" >&2 prompt=""; continue fi if [ ${#prompt} -gt 500 ]; then warn "Prompt is ${#prompt} chars — consider keeping it concise" >&2 fi break done # Confirmation { echo "────────────────────────────────────────────" printf ' Create alias '\''%s'\''?\n' "$name" printf ' Provider: %s\n' "$provider" printf ' Session: %s\n' "$session" local dp="$prompt" [ ${#dp} -gt 50 ] && dp="${dp:0:50}…" printf ' Prompt: %s\n' "${dp:-}" echo "────────────────────────────────────────────" } >&2 if ! confirm "Create alias '$name'?" y; then log "Aborted." >&2 return 0 fi _alias_load _ALIAS_NAMES+=("$name") _ALIAS_PROVIDERS+=("$provider") _ALIAS_SESSIONS+=("$session") _ALIAS_PROMPTS+=("$prompt") _alias_save _alias_sync log "Alias '$name' created." >&2 log "Available immediately: $(_wrapper_path "$name")" >&2 } # ── Interactive: edit ────────────────────────────────────────── _alias_edit() { local preset_name="${1:-}" _alias_load if [ ${#_ALIAS_NAMES[@]} -eq 0 ]; then warn "No aliases to edit — create one first" >&2 return 0 fi local name="$preset_name" if [ -z "$name" ]; then section "Edit AI Agent Alias" >&2 local display_items=() i for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do local p="${_ALIAS_PROMPTS[$i]}" if [ ${#p} -gt 30 ]; then p="${p:0:30}…" fi display_items+=("${_ALIAS_NAMES[$i]} [${_ALIAS_PROVIDERS[$i]}] ${p}") done local picked picked="$(menu_pick "Pick alias to edit" "${display_items[@]}")" || return 0 name="${_ALIAS_NAMES[$((picked - 1))]}" fi local idx idx="$(_alias_find "$name")" if [ "$idx" = "-1" ]; then err "Alias '$name' not found" fi # Show current values { echo " Current values for '$name':" printf ' Provider: %s\n' "${_ALIAS_PROVIDERS[$idx]}" printf ' Session: %s\n' "${_ALIAS_SESSIONS[$idx]}" local cp="${_ALIAS_PROMPTS[$idx]}" [ -z "$cp" ] && cp="(default)" printf ' Prompt: %s\n' "$cp" echo >&2 } >&2 local new_provider="${_ALIAS_PROVIDERS[$idx]}" local new_session="${_ALIAS_SESSIONS[$idx]}" local new_prompt="${_ALIAS_PROMPTS[$idx]}" local changed=0 # Edit provider step 1 3 "Provider" >&2 local pidx pidx="$(_alias_provider_pick)" || return 0 local providers=() for f in "$PROVIDER_DIR"/*.sh; do [ -f "$f" ] || continue providers+=("$(basename "$f" .sh)") done local picked_provider="${providers[$((pidx - 1))]}" if [ "$picked_provider" != "$new_provider" ]; then new_provider="$picked_provider" changed=1 fi # Edit session local tmp_session="" while true; do step 2 3 "Session Name" >&2 tmp_session="$(menu_ask_value "Session name" "$new_session")" || return 0 if [ -n "$tmp_session" ] && ! _alias_name_valid "$tmp_session"; then warn "Invalid session '$tmp_session' — use letters, digits, hyphens, underscores" >&2 tmp_session=""; continue fi break done [ -n "$tmp_session" ] && new_session="$tmp_session" [ "$new_session" != "${_ALIAS_SESSIONS[$idx]}" ] && changed=1 # Edit prompt local tmp_prompt="" while true; do step 3 3 "System Prompt" >&2 local default_prompt="${_ALIAS_PROMPTS[$idx]}" [ ${#default_prompt} -gt 80 ] && default_prompt="${default_prompt:0:80}…" [ -z "$default_prompt" ] && default_prompt="" tmp_prompt="$(menu_ask_value "System prompt" "$default_prompt")" || return 0 if [[ "$tmp_prompt" == *'|'* ]]; then warn "System prompt must not contain '|' characters" >&2 tmp_prompt=""; continue fi if [ ${#tmp_prompt} -gt 500 ]; then warn "Prompt is ${#tmp_prompt} chars — consider keeping it concise" >&2 fi break done # Keep full current if user pressed Enter (tmp_prompt = default_prompt value) if [ -n "$tmp_prompt" ]; then new_prompt="$tmp_prompt" fi [ "$new_prompt" != "${_ALIAS_PROMPTS[$idx]}" ] && changed=1 # No changes? if [ "$changed" -eq 0 ]; then log "No changes — nothing to save." >&2 return 0 fi # Show diff summary { echo "────────────────────────────────────────────" printf ' Save changes to '\''%s'\''?\n' "$name" local tag_p tag_s tag_pr [ "$new_provider" = "${_ALIAS_PROVIDERS[$idx]}" ] && tag_p="(unchanged)" || tag_p="(changed)" [ "$new_session" = "${_ALIAS_SESSIONS[$idx]}" ] && tag_s="(unchanged)" || tag_s="(changed)" [ "$new_prompt" = "${_ALIAS_PROMPTS[$idx]}" ] && tag_pr="(unchanged)" || tag_pr="(changed)" printf ' Provider: %-12s %s\n' "$new_provider" "$tag_p" printf ' Session: %-12s %s\n' "$new_session" "$tag_s" local dp="$new_prompt" [ -z "$dp" ] && dp="" printf ' Prompt: %s %s\n' "${dp:0:40}" "$tag_pr" echo "────────────────────────────────────────────" } >&2 if ! confirm "Save changes to '$name'?" y; then log "Discarded." >&2 return 0 fi _alias_load _ALIAS_PROVIDERS[$idx]="$new_provider" _ALIAS_SESSIONS[$idx]="$new_session" _ALIAS_PROMPTS[$idx]="$new_prompt" _alias_save _alias_sync log "Alias '$name' updated — the change is live on next invocation." >&2 } # ── Interactive: remove ──────────────────────────────────────── _alias_remove() { local preset_name="${1:-}" _alias_load if [ ${#_ALIAS_NAMES[@]} -eq 0 ]; then warn "No aliases to remove" >&2 return 0 fi local name="$preset_name" if [ -z "$name" ]; then section "Remove AI Agent Alias" >&2 local display_items=() i for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do local p="${_ALIAS_PROMPTS[$i]}" if [ ${#p} -gt 30 ]; then p="${p:0:30}…" fi display_items+=("${_ALIAS_NAMES[$i]} [${_ALIAS_PROVIDERS[$i]}] ${p}") done local picked picked="$(menu_pick "Pick alias to remove" "${display_items[@]}")" || return 0 name="${_ALIAS_NAMES[$((picked - 1))]}" fi local idx idx="$(_alias_find "$name")" if [ "$idx" = "-1" ]; then err "Alias '$name' not found" fi # Show alias detail { echo " Alias: $name" printf ' Provider: %s\n' "${_ALIAS_PROVIDERS[$idx]}" printf ' Session: %s\n' "${_ALIAS_SESSIONS[$idx]}" printf ' Prompt: %s\n' "${_ALIAS_PROMPTS[$idx]:-}" echo >&2 } >&2 if ! confirm "Remove alias '$name'? This cannot be undone." n; then log "Cancelled." >&2 return 0 fi _alias_load local new_names=() new_providers=() new_sessions=() new_prompts=() i for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do if [ "${_ALIAS_NAMES[$i]}" != "$name" ]; then new_names+=("${_ALIAS_NAMES[$i]}") new_providers+=("${_ALIAS_PROVIDERS[$i]}") new_sessions+=("${_ALIAS_SESSIONS[$i]}") new_prompts+=("${_ALIAS_PROMPTS[$i]}") fi done _ALIAS_NAMES=("${new_names[@]+"${new_names[@]}"}") _ALIAS_PROVIDERS=("${new_providers[@]+"${new_providers[@]}"}") _ALIAS_SESSIONS=("${new_sessions[@]+"${new_sessions[@]}"}") _ALIAS_PROMPTS=("${new_prompts[@]+"${new_prompts[@]}"}") _alias_save _alias_sync log "Alias '$name' removed — script deleted from $(_wrapper_path "$name")." >&2 log "If the name still autocompletes stale in this shell, run: hash -r" >&2 } # ── show ─────────────────────────────────────────────── # (defined above as _alias_show) # ── Usage ────────────────────────────────────────────────────── usage() { cat <<'EOF' Usage: pos ai alias [subcommand] [args] Manage named AI agent aliases — create, edit, remove, list, and show configured aliases. Each alias maps a name to a provider, session, and optional system prompt. Subcommands: (no args) Interactive menu create [name] Create a new alias (interactive prompts for each field) edit [name] Edit an existing alias (interactive, Enter = keep) remove [name] Remove an alias (interactive, default = no) list List all aliases (non-interactive, machine-readable) show Show one alias's details Activation: every alias is materialized as an executable script at ~/.local/bin/, synced automatically on every invocation — no shell sourcing required. Changes are live on the next invocation, and the scripts work identically in interactive shells, scripts, cron, and non-login ssh sessions. Options: -h|--help Show this help. Examples: pos ai alias # interactive menu pos ai alias list # show all aliases pos ai alias create # interactive create pos ai alias create mybot # create 'mybot' alias pos ai alias edit mybot # edit the 'mybot' alias pos ai alias remove mybot # remove 'mybot' (with confirm) pos ai alias show mybot # show alias details EOF exit 0 } # ── Main dispatch ────────────────────────────────────────────── # Every subcommand syncs first: artifacts always equal ENV truth before any # subcommand logic runs (migration, healing, retraction — all automatic). case "${1:-}" in -h|--help) usage ;; create) shift; _alias_sync; _alias_create "${1:-}" ;; edit) shift; _alias_sync; _alias_edit "${1:-}" ;; remove) shift; _alias_sync; _alias_remove "${1:-}" ;; list) _alias_sync; _alias_list ;; show) [ -n "${2:-}" ] || err "Usage: pos ai alias show " _alias_sync _alias_show "$2" ;; "") _alias_sync; _alias_menu ;; *) err "Unknown subcommand '$1' (use -h for help)" ;; esac