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 : 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`) - 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` - wiring initial `bootstrap + config + secretstore + update`
- `README.md` de démarrage - `README.md` de démarrage

View file

@ -340,19 +340,67 @@ MODULE_PATH="{{.ModulePath}}"
DEFAULT_PROFILE="{{.DefaultProfile}}" DEFAULT_PROFILE="{{.DefaultProfile}}"
PROFILE_ENV="{{.ProfileEnv}}" 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() { prompt() {
local label="$1" local label="$1"
local default_value="$2" local default_value="$2"
local answer="" local answer=""
if [ -n "$default_value" ]; then 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 else
printf "%s: " "$label" >&2 printf "%b%s%b: " "$C_BOLD" "$label" "$C_RESET" >&2
fi fi
if [ -r /dev/tty ]; then if [ -t 2 ] && [ -r /dev/tty ]; then
IFS= read -r answer < /dev/tty || answer="" if ! IFS= read -r answer < /dev/tty 2>/dev/null; then
IFS= read -r answer || answer=""
fi
else else
IFS= read -r answer || answer="" IFS= read -r answer || answer=""
fi fi
@ -365,6 +413,20 @@ prompt() {
printf "%s" "$answer" 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() { go_bin_dir() {
local gobin local gobin
gobin="$(go env GOBIN 2>/dev/null || true)" gobin="$(go env GOBIN 2>/dev/null || true)"
@ -394,25 +456,43 @@ resolve_binary_path() {
printf "%s\n" "$HOME/.local/bin/$BINARY_NAME" printf "%s\n" "$HOME/.local/bin/$BINARY_NAME"
} }
install_binary() { ensure_cli() {
if command -v "$BINARY_NAME" >/dev/null 2>&1; then local cli_name="$1"
printf "Binaire détecté: %s\n" "$(command -v "$BINARY_NAME")" if command -v "$cli_name" >/dev/null 2>&1; then
return return
fi 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 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 exit 1
fi 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" go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest"
local bin_dir local bin_dir
bin_dir="$(go_bin_dir)" bin_dir="$(go_bin_dir)"
if [ -n "$bin_dir" ]; then if [ -n "$bin_dir" ]; then
printf "Binaire installé dans %s\n" "$bin_dir" ui_success "Binaire installe dans $bin_dir"
printf "Ajoute ce dossier à ton PATH si nécessaire.\n" ui_info "Ajoute ce dossier au PATH si necessaire."
fi fi
} }
@ -420,34 +500,161 @@ run_setup_wizard() {
install_binary install_binary
local profile local profile
profile="$(prompt "Profil à configurer (${PROFILE_ENV})" "$DEFAULT_PROFILE")" profile="$(prompt "Profil a configurer (${PROFILE_ENV})" "$DEFAULT_PROFILE")"
local binary_path local binary_path
binary_path="$(resolve_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 if [ -r /dev/tty ] && [ -w /dev/tty ]; then
env "${PROFILE_ENV}=${profile}" "$binary_path" setup < /dev/tty > /dev/tty env "${PROFILE_ENV}=${profile}" "$binary_path" setup < /dev/tty > /dev/tty
else else
env "${PROFILE_ENV}=${profile}" "$binary_path" setup env "${PROFILE_ENV}=${profile}" "$binary_path" setup
fi 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() { print_mcp_json() {
local profile collect_server_inputs
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")"
cat <<JSON cat <<JSON
{ {
"mcpServers": { "mcpServers": {
"${BINARY_NAME}": { "${SERVER_NAME}": {
"command": "${command_path}", "command": "${COMMAND_PATH}",
"args": ["mcp"], "args": ["mcp"],
"env": { "env": {
"${PROFILE_ENV}": "${profile}" "${PROFILE_ENV}": "${PROFILE_VALUE}"
} }
} }
} }
@ -456,17 +663,16 @@ JSON
} }
print_header() { print_header() {
cat <<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
Installateur MCP pour ${BINARY_NAME} printf "%bFramework module:%b %s\n" "$C_DIM" "$C_RESET" "$MODULE_PATH" >&2
=========================================================== ui_line
Choisis une action : printf "Choisis une action:\n" >&2
1) Installer le binaire + lancer le setup printf " 1) Installer/mettre a jour le binaire + setup\n" >&2
2) Générer un JSON de config MCP (Codex) printf " 2) Configurer Claude Code (apply direct)\n" >&2
3) Générer un JSON de config MCP (Claude Desktop) printf " 3) Configurer Codex (apply direct)\n" >&2
4) Générer un JSON de config MCP (autre client) printf " 4) Generer JSON MCP manuel\n" >&2
5) Quitter printf " 5) Quitter\n" >&2
TXT
} }
main() { main() {
@ -474,25 +680,32 @@ main() {
print_header print_header
local choice local choice
choice="$(prompt "Choix" "1")" choice="$(prompt "Choix" "1")"
printf "\n" printf "\n" >&2
case "$choice" in case "$choice" in
1) 1)
run_setup_wizard run_setup_wizard
printf "\nInstallation terminée.\n"
return return
;; ;;
2|3|4) 2)
printf "Copie ce JSON dans la config MCP du client ciblé.\n\n" apply_claude_mcp
return
;;
3)
apply_codex_mcp
return
;;
4)
ui_info "JSON MCP genere sur stdout."
print_mcp_json print_mcp_json
return return
;; ;;
5) 5)
printf "Annulé.\n" ui_warn "Annule."
return return
;; ;;
*) *)
printf "Choix invalide: %s\n\n" "$choice" >&2 ui_warn "Choix invalide: $choice"
;; ;;
esac esac
done done
@ -909,6 +1122,8 @@ go run ./cmd/{{.BinaryName}} config test
curl -fsSL https://<forge>/<org>/<repo>/raw/branch/main/install.sh | bash 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 ## Points à adapter
- Remplacer les valeurs de ` + "`mcp.toml`" + ` (forge, repository, URL docs). - 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", "#!/usr/bin/env bash",
`MODULE_PATH="example.com/acme/my-mcp"`, `MODULE_PATH="example.com/acme/my-mcp"`,
`go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest"`, `go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest"`,
`printf "%s [%s]: " "$label" "$default_value" >&2`, "MCP Install Wizard",
"config MCP (Codex)", `claude mcp add \`,
"config MCP (Claude Desktop)", `--transport stdio \`,
`"${PROFILE_ENV}=${profile}"`, `--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) { if !strings.Contains(string(installScript), snippet) {
t.Fatalf("install.sh missing snippet %q", snippet) t.Fatalf("install.sh missing snippet %q", snippet)