feat(install): add TUI wizard with Claude and Codex apply flows

This commit is contained in:
thibaud-lclr 2026-04-15 10:35:41 +02:00
parent 647a03a10c
commit ec3774138e

View file

@ -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
ui_error "Commande introuvable: $cli_name"
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"
}
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 <<JSON
{
"mcpServers": {
"${BINARY_NAME}": {
"command": "${command_path}",
"${SERVER_NAME}": {
"command": "${COMMAND_PATH}",
"args": ["mcp"],
"env": {
"${PROFILE_ENV}": "${profile}"
"${PROFILE_ENV}": "${PROFILE_VALUE}"
}
}
}
@ -237,17 +329,16 @@ JSON
}
print_header() {
cat <<TXT
===========================================================
Installateur MCP pour ${BINARY_NAME}
===========================================================
Choisis une action :
1) Installer le binaire + lancer le setup
2) Générer un JSON de config MCP (Codex)
3) Générer un JSON de config MCP (Claude Desktop)
4) Générer un JSON de config MCP (autre client)
5) Quitter
TXT
ui_line
printf "%bMCP Install Wizard%b for %b%s%b\n" "$C_BOLD$C_MAGENTA" "$C_RESET" "$C_BOLD" "$BINARY_NAME" "$C_RESET" >&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