feat(scaffold): add TUI install wizard for Claude and Codex

This commit is contained in:
thibaud-lclr 2026-04-15 10:33:13 +02:00
parent b2eebf413e
commit 9d862c876f
3 changed files with 264 additions and 45 deletions

View file

@ -191,7 +191,7 @@ _ = scaffoldInfo
Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP :
- arborescence recommandée (`cmd/<binary>/main.go`, `internal/app/app.go`, `mcp.toml`)
- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard setup/JSON MCP
- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard TUI (setup, apply Claude/Codex, JSON MCP)
- wiring initial `bootstrap + config + secretstore + update`
- `README.md` de démarrage

View file

@ -340,19 +340,67 @@ MODULE_PATH="{{.ModulePath}}"
DEFAULT_PROFILE="{{.DefaultProfile}}"
PROFILE_ENV="{{.ProfileEnv}}"
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"
local default_value="$2"
local answer=""
if [ -n "$default_value" ]; then
printf "%s [%s]: " "$label" "$default_value" >&2
printf "%b%s%b [%s]: " "$C_BOLD" "$label" "$C_RESET" "$default_value" >&2
else
printf "%s: " "$label" >&2
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
@ -365,6 +413,20 @@ prompt() {
printf "%s" "$answer"
}
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"
}
toml_escape() {
printf "%s" "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
go_bin_dir() {
local gobin
gobin="$(go env GOBIN 2>/dev/null || true)"
@ -394,25 +456,43 @@ resolve_binary_path() {
printf "%s\n" "$HOME/.local/bin/$BINARY_NAME"
}
install_binary() {
if command -v "$BINARY_NAME" >/dev/null 2>&1; then
printf "Binaire détecté: %s\n" "$(command -v "$BINARY_NAME")"
ensure_cli() {
local cli_name="$1"
if command -v "$cli_name" >/dev/null 2>&1; then
return
fi
ui_error "Commande introuvable: $cli_name"
exit 1
}
install_binary() {
if command -v "$BINARY_NAME" >/dev/null 2>&1; then
ui_success "Binaire detecte: $(command -v "$BINARY_NAME")"
local reinstall
reinstall="$(prompt "Reinstaller via go install ? (y/N)" "N")"
case "$reinstall" in
y|Y|yes|YES)
;;
*)
return
;;
esac
fi
if ! command -v go >/dev/null 2>&1; then
printf "Go n'est pas installé. Installe Go ou utilise l'option JSON.\n" >&2
ui_error "Go n'est pas installe. Installe Go ou choisis une configuration manuelle."
exit 1
fi
printf "Installation du binaire via go install...\n"
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
printf "Binaire installé dans %s\n" "$bin_dir"
printf "Ajoute ce dossier à ton PATH si nécessaire.\n"
ui_success "Binaire installe dans $bin_dir"
ui_info "Ajoute ce dossier au PATH si necessaire."
fi
}
@ -420,34 +500,161 @@ 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}"
}
}
}
@ -456,17 +663,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() {
@ -474,25 +680,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
@ -909,6 +1122,8 @@ go run ./cmd/{{.BinaryName}} config test
curl -fsSL https://<forge>/<org>/<repo>/raw/branch/main/install.sh | bash
` + "```" + `
Le wizard permet ensuite d'appliquer directement la configuration MCP pour Claude Code ou Codex (scope global/projet), ou de générer un JSON manuel.
## Points à adapter
- Remplacer les valeurs de ` + "`mcp.toml`" + ` (forge, repository, URL docs).

View file

@ -120,10 +120,14 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
"#!/usr/bin/env bash",
`MODULE_PATH="example.com/acme/my-mcp"`,
`go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest"`,
`printf "%s [%s]: " "$label" "$default_value" >&2`,
"config MCP (Codex)",
"config MCP (Claude Desktop)",
`"${PROFILE_ENV}=${profile}"`,
"MCP Install Wizard",
`claude mcp add \`,
`--transport stdio \`,
`--scope "$claude_scope" \`,
`codex mcp add \`,
`Dossier projet cible pour .codex/config.toml`,
`[mcp_servers.%s]`,
`"${PROFILE_ENV}=${PROFILE_VALUE}"`,
} {
if !strings.Contains(string(installScript), snippet) {
t.Fatalf("install.sh missing snippet %q", snippet)