From ec3774138e3156a5efffb795f7aabc380db1977a Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 10:35:41 +0200 Subject: [PATCH] feat(install): add TUI wizard with Claude and Codex apply flows --- install.sh | 394 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 246 insertions(+), 148 deletions(-) diff --git a/install.sh b/install.sh index 3dc7699..11eab47 100755 --- a/install.sh +++ b/install.sh @@ -2,10 +2,55 @@ set -euo pipefail BINARY_NAME="email-mcp" +MODULE_PATH="email-mcp" DEFAULT_PROFILE="default" PROFILE_ENV="EMAIL_MCP_PROFILE" -RELEASE_BASE_URL="https://gitea.lclr.dev" -RELEASE_REPOSITORY="AI/email-mcp" + +if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then + C_RESET="$(printf '\033[0m')" + C_BOLD="$(printf '\033[1m')" + C_DIM="$(printf '\033[2m')" + C_RED="$(printf '\033[31m')" + C_GREEN="$(printf '\033[32m')" + C_YELLOW="$(printf '\033[33m')" + C_BLUE="$(printf '\033[34m')" + C_MAGENTA="$(printf '\033[35m')" + C_CYAN="$(printf '\033[36m')" +else + C_RESET="" + C_BOLD="" + C_DIM="" + C_RED="" + C_GREEN="" + C_YELLOW="" + C_BLUE="" + C_MAGENTA="" + C_CYAN="" +fi + +ui_line() { + printf "%b%s%b\n" "$C_DIM" "------------------------------------------------------------" "$C_RESET" >&2 +} + +ui_title() { + printf "\n%b%s%b\n" "$C_BOLD$C_CYAN" "$1" "$C_RESET" >&2 +} + +ui_info() { + printf "%b[info]%b %s\n" "$C_BLUE" "$C_RESET" "$1" >&2 +} + +ui_success() { + printf "%b[ok]%b %s\n" "$C_GREEN" "$C_RESET" "$1" >&2 +} + +ui_warn() { + printf "%b[warn]%b %s\n" "$C_YELLOW" "$C_RESET" "$1" >&2 +} + +ui_error() { + printf "%b[error]%b %s\n" "$C_RED" "$C_RESET" "$1" >&2 +} prompt() { local label="$1" @@ -13,13 +58,15 @@ prompt() { local answer="" if [ -n "$default_value" ]; then - printf "%s [%s]: " "$label" "$default_value" + printf "%b%s%b [%s]: " "$C_BOLD" "$label" "$C_RESET" "$default_value" >&2 else - printf "%s: " "$label" + printf "%b%s%b: " "$C_BOLD" "$label" "$C_RESET" >&2 fi - if [ -r /dev/tty ]; then - IFS= read -r answer < /dev/tty || answer="" + if [ -t 2 ] && [ -r /dev/tty ]; then + if ! IFS= read -r answer < /dev/tty 2>/dev/null; then + IFS= read -r answer || answer="" + fi else IFS= read -r answer || answer="" fi @@ -32,42 +79,18 @@ prompt() { printf "%s" "$answer" } -detect_os() { - case "$(uname -s | tr '[:upper:]' '[:lower:]')" in - linux) printf "linux" ;; - darwin) printf "darwin" ;; - *) - printf "OS non supporté: %s\n" "$(uname -s)" >&2 - exit 1 - ;; - esac +sanitize_server_name() { + local raw="$1" + local sanitized + sanitized="$(printf "%s" "$raw" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/-/g; s/--*/-/g; s/^-*//; s/-*$//')" + if [ -z "$sanitized" ]; then + sanitized="$BINARY_NAME" + fi + printf "%s" "$sanitized" } -detect_arch() { - case "$(uname -m)" in - x86_64|amd64) printf "amd64" ;; - aarch64|arm64) printf "arm64" ;; - *) - printf "Architecture non supportée: %s\n" "$(uname -m)" >&2 - exit 1 - ;; - esac -} - -release_api_url() { - printf "%s/api/v1/repos/%s/releases/latest\n" "${RELEASE_BASE_URL%/}" "$RELEASE_REPOSITORY" -} - -extract_asset_url() { - local json="$1" - local asset_name="$2" - - printf "%s\n" "$json" \ - | grep -o '"browser_download_url":"[^"]*"' \ - | sed 's/"browser_download_url":"//;s/"$//' \ - | sed 's#\\/#/#g' \ - | grep "/${asset_name}$" \ - | head -n 1 +toml_escape() { + printf "%s" "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' } go_bin_dir() { @@ -99,136 +122,205 @@ resolve_binary_path() { printf "%s\n" "$HOME/.local/bin/$BINARY_NAME" } -verify_checksum() { - local binary_path="$1" - local checksum_path="$2" - - if command -v sha256sum >/dev/null 2>&1; then - ( cd "$(dirname "$binary_path")" && sha256sum -c "$(basename "$checksum_path")" ) +ensure_cli() { + local cli_name="$1" + if command -v "$cli_name" >/dev/null 2>&1; then return fi - if command -v shasum >/dev/null 2>&1; then - local expected - local actual - expected="$(awk '{print $1}' "$checksum_path")" - actual="$(shasum -a 256 "$binary_path" | awk '{print $1}')" - if [ "$expected" != "$actual" ]; then - printf "Checksum invalide pour %s\n" "$binary_path" >&2 - exit 1 - fi - return - fi - - printf "Aucun utilitaire de checksum trouvé (sha256sum/shasum), vérification ignorée.\n" >&2 -} - -download_latest_release_binary() { - if ! command -v curl >/dev/null 2>&1; then - printf "curl est requis pour télécharger la release.\n" >&2 - exit 1 - fi - - local os_name - local arch_name - local ext="" - os_name="$(detect_os)" - arch_name="$(detect_arch)" - if [ "$os_name" = "windows" ]; then - ext=".exe" - fi - - local asset_name - local checksum_name - asset_name="${BINARY_NAME}-${os_name}-${arch_name}${ext}" - checksum_name="${asset_name}.sha256" - - printf "Récupération de la dernière release...\n" - local release_json - release_json="$(curl -fsSL "$(release_api_url)")" - - local asset_url - asset_url="$(extract_asset_url "$release_json" "$asset_name")" - if [ -z "$asset_url" ]; then - printf "Asset introuvable dans la dernière release: %s\n" "$asset_name" >&2 - exit 1 - fi - - local checksum_url - checksum_url="$(extract_asset_url "$release_json" "$checksum_name" || true)" - - local tmp_dir - tmp_dir="$(mktemp -d)" - trap 'rm -rf "$tmp_dir"' EXIT INT TERM - - curl -fsSL "$asset_url" -o "$tmp_dir/$asset_name" - chmod +x "$tmp_dir/$asset_name" - - if [ -n "$checksum_url" ]; then - curl -fsSL "$checksum_url" -o "$tmp_dir/$checksum_name" - verify_checksum "$tmp_dir/$asset_name" "$tmp_dir/$checksum_name" - fi - - local target_dir - target_dir="$(prompt "Répertoire d'installation" "$HOME/.local/bin")" - mkdir -p "$target_dir" - install -m 0755 "$tmp_dir/$asset_name" "$target_dir/$BINARY_NAME" - - printf "Binaire installé: %s/%s\n" "$target_dir" "$BINARY_NAME" - printf "Ajoute ce dossier au PATH si nécessaire.\n" + ui_error "Commande introuvable: $cli_name" + exit 1 } install_binary() { if command -v "$BINARY_NAME" >/dev/null 2>&1; then - printf "Binaire détecté: %s\n" "$(command -v "$BINARY_NAME")" + ui_success "Binaire detecte: $(command -v "$BINARY_NAME")" local reinstall - reinstall="$(prompt "Forcer une réinstallation depuis la dernière release ? (y/N)" "N")" + reinstall="$(prompt "Reinstaller via go install ? (y/N)" "N")" case "$reinstall" in y|Y|yes|YES) - download_latest_release_binary ;; *) return ;; esac - return fi - download_latest_release_binary + if ! command -v go >/dev/null 2>&1; then + ui_error "Go n'est pas installe. Installe Go ou choisis une configuration manuelle." + exit 1 + fi + + ui_info "Installation du binaire via go install..." + go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest" + + local bin_dir + bin_dir="$(go_bin_dir)" + if [ -n "$bin_dir" ]; then + ui_success "Binaire installe dans $bin_dir" + ui_info "Ajoute ce dossier au PATH si necessaire." + fi } run_setup_wizard() { install_binary local profile - profile="$(prompt "Profil à configurer (${PROFILE_ENV})" "$DEFAULT_PROFILE")" + profile="$(prompt "Profil a configurer (${PROFILE_ENV})" "$DEFAULT_PROFILE")" local binary_path binary_path="$(resolve_binary_path)" - printf "Lancement de %s setup...\n\n" "$BINARY_NAME" + ui_info "Lancement de $BINARY_NAME setup" if [ -r /dev/tty ] && [ -w /dev/tty ]; then env "${PROFILE_ENV}=${profile}" "$binary_path" setup < /dev/tty > /dev/tty else env "${PROFILE_ENV}=${profile}" "$binary_path" setup fi + ui_success "Setup termine pour le profil \"$profile\"." +} + +collect_server_inputs() { + local default_name + default_name="$(sanitize_server_name "$BINARY_NAME")" + SERVER_NAME="$(prompt "Nom du serveur MCP" "$default_name")" + SERVER_NAME="$(sanitize_server_name "$SERVER_NAME")" + + PROFILE_VALUE="$(prompt "Valeur de ${PROFILE_ENV}" "$DEFAULT_PROFILE")" + + local default_command + default_command="$(resolve_binary_path)" + COMMAND_PATH="$(prompt "Chemin du binaire serveur MCP" "$default_command")" +} + +choose_scope() { + local selected + while true; do + ui_title "Scope de configuration" + printf " 1) global (user)\n" >&2 + printf " 2) project (projet courant)\n" >&2 + selected="$(prompt "Choix" "1")" + case "$selected" in + 1) + printf "global" + return + ;; + 2) + printf "project" + return + ;; + *) + ui_warn "Choix invalide: $selected" + ;; + esac + done +} + +apply_claude_mcp() { + ensure_cli "claude" + collect_server_inputs + + local scope_choice + scope_choice="$(choose_scope)" + local claude_scope + if [ "$scope_choice" = "global" ]; then + claude_scope="user" + else + claude_scope="project" + fi + + ui_info "Application de la configuration Claude ($claude_scope)..." + claude mcp remove --scope "$claude_scope" "$SERVER_NAME" >/dev/null 2>&1 || true + claude mcp add \ + --transport stdio \ + --scope "$claude_scope" \ + -e "${PROFILE_ENV}=${PROFILE_VALUE}" \ + "$SERVER_NAME" -- "$COMMAND_PATH" mcp + + ui_success "Serveur \"$SERVER_NAME\" configure dans Claude ($claude_scope)." +} + +rewrite_codex_project_config() { + local project_dir="$1" + local config_file="$project_dir/.codex/config.toml" + local section_prefix="[mcp_servers.${SERVER_NAME}" + + mkdir -p "$project_dir/.codex" + touch "$config_file" + + local tmp_file + tmp_file="$(mktemp)" + + awk -v prefix="$section_prefix" ' + function is_target(line, p, next_char) { + if (index(line, p) != 1) { + return 0 + } + next_char = substr(line, length(p) + 1, 1) + return next_char == "]" || next_char == "." + } + /^\[.*\]$/ { + if (is_target($0, prefix)) { + skip = 1 + next + } + if (skip == 1) { + skip = 0 + } + } + { + if (skip != 1) { + print $0 + } + } + ' "$config_file" > "$tmp_file" + + mv "$tmp_file" "$config_file" + + { + printf "\n[mcp_servers.%s]\n" "$SERVER_NAME" + printf "command = \"%s\"\n" "$(toml_escape "$COMMAND_PATH")" + printf "args = [\"mcp\"]\n\n" + printf "[mcp_servers.%s.env]\n" "$SERVER_NAME" + printf "%s = \"%s\"\n" "$PROFILE_ENV" "$(toml_escape "$PROFILE_VALUE")" + } >> "$config_file" +} + +apply_codex_mcp() { + ensure_cli "codex" + collect_server_inputs + + local scope_choice + scope_choice="$(choose_scope)" + + if [ "$scope_choice" = "global" ]; then + ui_info "Application via codex mcp add (scope global)..." + codex mcp remove "$SERVER_NAME" >/dev/null 2>&1 || true + codex mcp add \ + "$SERVER_NAME" \ + --env "${PROFILE_ENV}=${PROFILE_VALUE}" \ + -- "$COMMAND_PATH" mcp + ui_success "Serveur \"$SERVER_NAME\" configure dans le scope global Codex." + return + fi + + local default_project_dir + default_project_dir="$(pwd)" + local project_dir + project_dir="$(prompt "Dossier projet cible pour .codex/config.toml" "$default_project_dir")" + rewrite_codex_project_config "$project_dir" + ui_success "Configuration projet ecrite dans $project_dir/.codex/config.toml" } print_mcp_json() { - local profile - profile="$(prompt "Profil à exposer dans la config MCP (${PROFILE_ENV})" "$DEFAULT_PROFILE")" - local default_command - default_command="$(resolve_binary_path)" - local command_path - command_path="$(prompt "Commande du serveur MCP" "$default_command")" - + collect_server_inputs cat <&2 + printf "%bFramework module:%b %s\n" "$C_DIM" "$C_RESET" "$MODULE_PATH" >&2 + ui_line + printf "Choisis une action:\n" >&2 + printf " 1) Installer/mettre a jour le binaire + setup\n" >&2 + printf " 2) Configurer Claude Code (apply direct)\n" >&2 + printf " 3) Configurer Codex (apply direct)\n" >&2 + printf " 4) Generer JSON MCP manuel\n" >&2 + printf " 5) Quitter\n" >&2 } main() { @@ -255,25 +346,32 @@ main() { print_header local choice choice="$(prompt "Choix" "1")" - printf "\n" + printf "\n" >&2 case "$choice" in 1) run_setup_wizard - printf "\nInstallation terminée.\n" return ;; - 2|3|4) - printf "Copie ce JSON dans la config MCP du client ciblé.\n\n" + 2) + apply_claude_mcp + return + ;; + 3) + apply_codex_mcp + return + ;; + 4) + ui_info "JSON MCP genere sur stdout." print_mcp_json return ;; 5) - printf "Annulé.\n" + ui_warn "Annule." return ;; *) - printf "Choix invalide: %s\n\n" "$choice" >&2 + ui_warn "Choix invalide: $choice" ;; esac done