package scaffold import ( "errors" "fmt" "os" "path/filepath" "slices" "sort" "strings" "text/template" "unicode" ) var ( ErrTargetDirRequired = errors.New("target directory is required") ErrFileExists = errors.New("target file already exists") ) type Options struct { TargetDir string ModulePath string BinaryName string Description string DocsURL string DefaultProfile string Profiles []string KnownEnvironmentVariables []string SecretStorePolicy string ReleaseDriver string ReleaseBaseURL string ReleaseRepository string ReleaseTokenEnv string ReleasePublicKeyEnv string Overwrite bool } type Result struct { Root string Files []string } type normalizedOptions struct { TargetDir string ModulePath string BinaryName string Description string DocsURL string DefaultProfile string Profiles []string KnownEnvironmentVariables []string ProfileEnv string BaseURLEnv string TokenEnv string SecretStorePolicy string ReleaseDriver string ReleaseBaseURL string ReleaseRepository string ReleaseTokenEnv string ReleasePublicKeyEnv string Overwrite bool } func Generate(options Options) (Result, error) { normalized, err := normalizeOptions(options) if err != nil { return Result{}, err } if err := os.MkdirAll(normalized.TargetDir, 0o755); err != nil { return Result{}, fmt.Errorf("create scaffold target %q: %w", normalized.TargetDir, err) } files := []generatedFile{ {Path: ".gitignore", Template: gitignoreTemplate, Mode: 0o644}, {Path: "go.mod", Template: goModTemplate, Mode: 0o644}, {Path: "README.md", Template: readmeTemplate, Mode: 0o644}, {Path: "install.sh", Template: installTemplate, Mode: 0o755}, {Path: "mcp.toml", Template: manifestTemplate, Mode: 0o644}, {Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Template: mainTemplate, Mode: 0o644}, {Path: filepath.Join("internal", "app", "app.go"), Template: appTemplate, Mode: 0o644}, } written := make([]string, 0, len(files)) for _, file := range files { content, err := renderTemplate(file.Template, normalized) if err != nil { return Result{}, fmt.Errorf("render scaffold file %q: %w", file.Path, err) } fullPath := filepath.Join(normalized.TargetDir, file.Path) if err := writeFile(fullPath, content, file.Mode, normalized.Overwrite); err != nil { return Result{}, err } written = append(written, file.Path) } sort.Strings(written) return Result{ Root: normalized.TargetDir, Files: written, }, nil } type generatedFile struct { Path string Template string Mode os.FileMode } func writeFile(path, content string, mode os.FileMode, overwrite bool) error { if !overwrite { if _, err := os.Stat(path); err == nil { return fmt.Errorf("%w: %s", ErrFileExists, path) } else if !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("stat scaffold file %q: %w", path, err) } } dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("create scaffold directory %q: %w", dir, err) } if mode == 0 { mode = 0o644 } if err := os.WriteFile(path, []byte(content), mode); err != nil { return fmt.Errorf("write scaffold file %q: %w", path, err) } return nil } func renderTemplate(src string, data normalizedOptions) (string, error) { tpl, err := template.New("scaffold").Parse(src) if err != nil { return "", fmt.Errorf("parse template: %w", err) } var builder strings.Builder if err := tpl.Execute(&builder, data); err != nil { return "", fmt.Errorf("execute template: %w", err) } return builder.String(), nil } func normalizeOptions(options Options) (normalizedOptions, error) { targetDir := strings.TrimSpace(options.TargetDir) if targetDir == "" { return normalizedOptions{}, ErrTargetDirRequired } resolvedTarget, err := filepath.Abs(targetDir) if err != nil { return normalizedOptions{}, fmt.Errorf("resolve scaffold target %q: %w", targetDir, err) } binaryName := strings.TrimSpace(options.BinaryName) if binaryName == "" { binaryName = sanitizeSlug(filepath.Base(resolvedTarget)) } if binaryName == "" { binaryName = "my-mcp" } if strings.ContainsRune(binaryName, os.PathSeparator) { return normalizedOptions{}, fmt.Errorf("binary name %q must not contain path separators", binaryName) } modulePath := strings.TrimSpace(options.ModulePath) if modulePath == "" { modulePath = fmt.Sprintf("example.com/%s", sanitizeModuleSegment(binaryName)) } description := strings.TrimSpace(options.Description) if description == "" { description = fmt.Sprintf("Binaire MCP %s.", binaryName) } docsURL := strings.TrimSpace(options.DocsURL) if docsURL == "" { docsURL = fmt.Sprintf("https://docs.example.com/%s", binaryName) } defaultProfile := strings.TrimSpace(options.DefaultProfile) if defaultProfile == "" { defaultProfile = "default" } profiles := normalizeValues(options.Profiles) if !slices.Contains(profiles, defaultProfile) { profiles = append([]string{defaultProfile}, profiles...) } if len(profiles) == 0 { profiles = []string{defaultProfile} } envPrefix := environmentPrefix(binaryName) profileEnv := envPrefix + "_PROFILE" baseURLEnv := envPrefix + "_BASE_URL" tokenEnv := envPrefix + "_API_TOKEN" knownEnvironmentVariables := []string{profileEnv, baseURLEnv, tokenEnv} for _, name := range normalizeValues(options.KnownEnvironmentVariables) { if !slices.Contains(knownEnvironmentVariables, name) { knownEnvironmentVariables = append(knownEnvironmentVariables, name) } } secretStorePolicy := strings.TrimSpace(options.SecretStorePolicy) if secretStorePolicy == "" { secretStorePolicy = "auto" } releaseDriver := strings.TrimSpace(options.ReleaseDriver) if releaseDriver == "" { releaseDriver = "gitea" } releaseBaseURL := strings.TrimSpace(options.ReleaseBaseURL) if releaseBaseURL == "" { releaseBaseURL = "https://gitea.example.com" } releaseRepository := strings.Trim(strings.TrimSpace(options.ReleaseRepository), "/") if releaseRepository == "" { releaseRepository = fmt.Sprintf("org/%s", binaryName) } releaseTokenEnv := strings.TrimSpace(options.ReleaseTokenEnv) if releaseTokenEnv == "" { releaseTokenEnv = envPrefix + "_RELEASE_TOKEN" } releasePublicKeyEnv := strings.TrimSpace(options.ReleasePublicKeyEnv) if releasePublicKeyEnv == "" { releasePublicKeyEnv = envPrefix + "_RELEASE_ED25519_PUBLIC_KEY" } if !slices.Contains(knownEnvironmentVariables, releasePublicKeyEnv) { knownEnvironmentVariables = append(knownEnvironmentVariables, releasePublicKeyEnv) } return normalizedOptions{ TargetDir: resolvedTarget, ModulePath: modulePath, BinaryName: binaryName, Description: description, DocsURL: docsURL, DefaultProfile: defaultProfile, Profiles: profiles, KnownEnvironmentVariables: knownEnvironmentVariables, ProfileEnv: profileEnv, BaseURLEnv: baseURLEnv, TokenEnv: tokenEnv, SecretStorePolicy: secretStorePolicy, ReleaseDriver: releaseDriver, ReleaseBaseURL: releaseBaseURL, ReleaseRepository: releaseRepository, ReleaseTokenEnv: releaseTokenEnv, ReleasePublicKeyEnv: releasePublicKeyEnv, Overwrite: options.Overwrite, }, nil } func normalizeValues(values []string) []string { normalized := make([]string, 0, len(values)) for _, value := range values { trimmed := strings.TrimSpace(value) if trimmed == "" { continue } normalized = append(normalized, trimmed) } return normalized } func sanitizeSlug(value string) string { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { return "" } var builder strings.Builder lastDash := false for _, r := range value { switch { case unicode.IsLetter(r) || unicode.IsDigit(r): builder.WriteRune(r) lastDash = false case r == '-' || r == '_' || r == ' ' || r == '.': if !lastDash && builder.Len() > 0 { builder.WriteRune('-') lastDash = true } } } result := strings.Trim(builder.String(), "-") if result == "" { return "my-mcp" } return result } func sanitizeModuleSegment(binaryName string) string { segment := sanitizeSlug(binaryName) if segment == "" { return "my-mcp" } return segment } func environmentPrefix(binaryName string) string { name := strings.ToUpper(strings.TrimSpace(binaryName)) if name == "" { return "MCP" } var builder strings.Builder lastUnderscore := false for _, r := range name { switch { case unicode.IsLetter(r) || unicode.IsDigit(r): builder.WriteRune(r) lastUnderscore = false default: if !lastUnderscore { builder.WriteRune('_') lastUnderscore = true } } } result := strings.Trim(builder.String(), "_") if result == "" { return "MCP" } return result } const gitignoreTemplate = `bin/ dist/ *.log ` const goModTemplate = `module {{.ModulePath}} go 1.25.0 ` const installTemplate = `#!/usr/bin/env bash set -euo pipefail BINARY_NAME="{{.BinaryName}}" MODULE_PATH="{{.ModulePath}}" DEFAULT_PROFILE="{{.DefaultProfile}}" PROFILE_ENV="{{.ProfileEnv}}" DEFAULT_RELEASE_DRIVER="{{.ReleaseDriver}}" DEFAULT_RELEASE_BASE_URL="{{.ReleaseBaseURL}}" DEFAULT_RELEASE_REPOSITORY="{{.ReleaseRepository}}" DEFAULT_ASSET_NAME_TEMPLATE="{binary}-{os}-{arch}{ext}" DEFAULT_CHECKSUM_ASSET_NAME="{asset}.sha256" DEFAULT_CHECKSUM_REQUIRED="true" DEFAULT_TOKEN_HEADER="Authorization" DEFAULT_TOKEN_PREFIX="token" DEFAULT_TOKEN_ENV_NAME="{{.ReleaseTokenEnv}}" RELEASE_DRIVER="$DEFAULT_RELEASE_DRIVER" RELEASE_BASE_URL="$DEFAULT_RELEASE_BASE_URL" RELEASE_REPOSITORY="$DEFAULT_RELEASE_REPOSITORY" LATEST_RELEASE_URL="" ASSET_NAME_TEMPLATE="$DEFAULT_ASSET_NAME_TEMPLATE" CHECKSUM_ASSET_NAME="$DEFAULT_CHECKSUM_ASSET_NAME" CHECKSUM_REQUIRED="$DEFAULT_CHECKSUM_REQUIRED" TOKEN_HEADER="$DEFAULT_TOKEN_HEADER" TOKEN_PREFIX="$DEFAULT_TOKEN_PREFIX" AUTH_HEADER_NAME="" AUTH_HEADER_VALUE="" TOKEN_ENV_NAMES=() if [ -n "$DEFAULT_TOKEN_ENV_NAME" ]; then TOKEN_ENV_NAMES=("$DEFAULT_TOKEN_ENV_NAME") fi PREFILL_SERVER_NAME="" PREFILL_PROFILE_VALUE="" PREFILL_COMMAND_PATH="" 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 "%b%s%b [%s]: " "$C_BOLD" "$label" "$C_RESET" "$default_value" >&2 else printf "%b%s%b: " "$C_BOLD" "$label" "$C_RESET" >&2 fi 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 if [ -z "$answer" ]; then printf "%s" "$default_value" return fi printf "%s" "$answer" } tty_prompt_available() { [ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ] } menu_select() { local title="$1" shift local options=("$@") local count="${#options[@]}" local index=0 local key="" local i=0 local rows=$((count + 3)) local rendered=0 if [ "$count" -eq 0 ]; then return 1 fi if ! tty_prompt_available; then ui_title "$title" i=0 while [ "$i" -lt "$count" ]; do printf " %d) %s\n" "$((i + 1))" "${options[$i]}" >&2 i=$((i + 1)) done while true; do local raw_choice raw_choice="$(prompt "Choix" "1")" case "$raw_choice" in ''|*[!0-9]*) ui_warn "Choix invalide: $raw_choice" ;; *) if [ "$raw_choice" -ge 1 ] && [ "$raw_choice" -le "$count" ]; then printf "%s" "${options[$((raw_choice - 1))]}" return 0 fi ui_warn "Choix invalide: $raw_choice" ;; esac done fi while true; do if [ "$rendered" -eq 1 ]; then printf "\033[%dA\033[J" "$rows" >&2 2>/dev/null || true fi ui_title "$title" i=0 while [ "$i" -lt "$count" ]; do if [ "$i" -eq "$index" ]; then printf " %b› %s%b\n" "$C_BOLD$C_CYAN" "${options[$i]}" "$C_RESET" >&2 else printf " %s\n" "${options[$i]}" >&2 fi i=$((i + 1)) done printf "%bUtilise ↑/↓ puis Entrée.%b\n" "$C_DIM" "$C_RESET" >&2 rendered=1 if ! IFS= read -rsn1 key < /dev/tty; then continue fi case "$key" in "") printf "%s" "${options[$index]}" return 0 ;; $'\x1b') if IFS= read -rsn2 key < /dev/tty; then case "$key" in "[A") if [ "$index" -eq 0 ]; then index=$((count - 1)) else index=$((index - 1)) fi ;; "[B") if [ "$index" -eq $((count - 1)) ]; then index=0 else index=$((index + 1)) fi ;; esac fi ;; esac done } 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' } trim_whitespace() { printf "%s" "$1" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' } unquote_toml() { local value value="$(trim_whitespace "$1")" if [ "${#value}" -ge 2 ]; then case "$value" in \"*\") value="${value#\"}" value="${value%\"}" ;; \'*\') value="${value#\'}" value="${value%\'}" ;; esac fi printf "%s" "$value" } normalize_bool() { local value value="$(printf "%s" "$1" | tr '[:upper:]' '[:lower:]')" value="$(trim_whitespace "$value")" case "$value" in true|1|yes|y) printf "true" ;; false|0|no|n) printf "false" ;; *) printf "%s" "$2" ;; esac } toml_read_update_value() { local key="$1" awk -v key="$key" ' BEGIN { in_update = 0 } { line = $0 sub(/[[:space:]]*#.*/, "", line) if (line ~ /^[[:space:]]*\[/) { if (line ~ /^[[:space:]]*\[update\][[:space:]]*$/) { in_update = 1 } else { in_update = 0 } } if (in_update == 1 && line ~ "^[[:space:]]*" key "[[:space:]]*=") { sub("^[[:space:]]*" key "[[:space:]]*=[[:space:]]*", "", line) print line exit } } ' mcp.toml } toml_read_update_array() { local key="$1" local raw raw="$(toml_read_update_value "$key")" raw="$(trim_whitespace "$raw")" if [ -z "$raw" ]; then return fi raw="${raw#\[}" raw="${raw%\]}" local items=() local item="" IFS=',' read -r -a items <<< "$raw" for item in "${items[@]}"; do item="$(unquote_toml "$item")" item="$(trim_whitespace "$item")" if [ -n "$item" ]; then printf "%s\n" "$item" fi done } normalize_release_config() { RELEASE_DRIVER="$(printf "%s" "$RELEASE_DRIVER" | tr '[:upper:]' '[:lower:]')" RELEASE_DRIVER="$(trim_whitespace "$RELEASE_DRIVER")" RELEASE_REPOSITORY="$(trim_whitespace "$RELEASE_REPOSITORY")" RELEASE_REPOSITORY="${RELEASE_REPOSITORY#/}" RELEASE_REPOSITORY="${RELEASE_REPOSITORY%/}" RELEASE_BASE_URL="$(trim_whitespace "$RELEASE_BASE_URL")" RELEASE_BASE_URL="${RELEASE_BASE_URL%/}" LATEST_RELEASE_URL="$(trim_whitespace "$LATEST_RELEASE_URL")" ASSET_NAME_TEMPLATE="$(trim_whitespace "$ASSET_NAME_TEMPLATE")" CHECKSUM_ASSET_NAME="$(trim_whitespace "$CHECKSUM_ASSET_NAME")" TOKEN_HEADER="$(trim_whitespace "$TOKEN_HEADER")" TOKEN_PREFIX="$(trim_whitespace "$TOKEN_PREFIX")" if [ -z "$ASSET_NAME_TEMPLATE" ]; then ASSET_NAME_TEMPLATE="$DEFAULT_ASSET_NAME_TEMPLATE" fi if [ -z "$CHECKSUM_ASSET_NAME" ]; then CHECKSUM_ASSET_NAME="$DEFAULT_CHECKSUM_ASSET_NAME" fi local filtered_env_names=() local env_name="" for env_name in "${TOKEN_ENV_NAMES[@]}"; do env_name="$(trim_whitespace "$env_name")" if [ -n "$env_name" ]; then filtered_env_names+=("$env_name") fi done TOKEN_ENV_NAMES=("${filtered_env_names[@]}") case "$RELEASE_DRIVER" in gitea) if [ -z "$TOKEN_HEADER" ]; then TOKEN_HEADER="Authorization" fi if [ -z "$TOKEN_PREFIX" ]; then TOKEN_PREFIX="token" fi if [ "${#TOKEN_ENV_NAMES[@]}" -eq 0 ]; then TOKEN_ENV_NAMES=("GITEA_TOKEN") fi ;; gitlab) if [ -z "$RELEASE_BASE_URL" ]; then RELEASE_BASE_URL="https://gitlab.com" fi if [ -z "$TOKEN_HEADER" ]; then TOKEN_HEADER="PRIVATE-TOKEN" fi if [ "${#TOKEN_ENV_NAMES[@]}" -eq 0 ]; then TOKEN_ENV_NAMES=("GITLAB_TOKEN" "GITLAB_PRIVATE_TOKEN") fi ;; github) if [ -z "$RELEASE_BASE_URL" ]; then RELEASE_BASE_URL="https://api.github.com" fi if [ -z "$TOKEN_HEADER" ]; then TOKEN_HEADER="Authorization" fi if [ -z "$TOKEN_PREFIX" ]; then TOKEN_PREFIX="Bearer" fi if [ "${#TOKEN_ENV_NAMES[@]}" -eq 0 ]; then TOKEN_ENV_NAMES=("GITHUB_TOKEN") fi ;; esac } load_release_config_from_manifest() { if [ ! -f "mcp.toml" ]; then ui_warn "mcp.toml introuvable: utilisation de la configuration release integree au script." normalize_release_config return fi local value value="$(toml_read_update_value "driver")" if [ -n "$value" ]; then RELEASE_DRIVER="$(printf "%s" "$(unquote_toml "$value")" | tr '[:upper:]' '[:lower:]')" fi value="$(toml_read_update_value "repository")" if [ -n "$value" ]; then RELEASE_REPOSITORY="$(unquote_toml "$value")" fi value="$(toml_read_update_value "base_url")" if [ -n "$value" ]; then RELEASE_BASE_URL="$(unquote_toml "$value")" fi value="$(toml_read_update_value "latest_release_url")" if [ -n "$value" ]; then LATEST_RELEASE_URL="$(unquote_toml "$value")" fi value="$(toml_read_update_value "asset_name_template")" if [ -n "$value" ]; then ASSET_NAME_TEMPLATE="$(unquote_toml "$value")" fi value="$(toml_read_update_value "checksum_asset_name")" if [ -n "$value" ]; then CHECKSUM_ASSET_NAME="$(unquote_toml "$value")" fi value="$(toml_read_update_value "checksum_required")" if [ -n "$value" ]; then CHECKSUM_REQUIRED="$(normalize_bool "$(unquote_toml "$value")" "$CHECKSUM_REQUIRED")" fi value="$(toml_read_update_value "token_header")" if [ -n "$value" ]; then TOKEN_HEADER="$(unquote_toml "$value")" fi value="$(toml_read_update_value "token_prefix")" if [ -n "$value" ]; then TOKEN_PREFIX="$(unquote_toml "$value")" fi local parsed_token_env_names=() local parsed_env_name="" while IFS= read -r parsed_env_name; do if [ -n "$parsed_env_name" ]; then parsed_token_env_names+=("$parsed_env_name") fi done < <(toml_read_update_array "token_env_names") if [ "${#parsed_token_env_names[@]}" -gt 0 ]; then TOKEN_ENV_NAMES=("${parsed_token_env_names[@]}") fi normalize_release_config } resolve_goos() { case "$(uname -s)" in Linux) printf "linux" ;; Darwin) printf "darwin" ;; FreeBSD) printf "freebsd" ;; OpenBSD) printf "openbsd" ;; NetBSD) printf "netbsd" ;; CYGWIN*|MINGW*|MSYS*) printf "windows" ;; *) ui_error "OS non supporte: $(uname -s)" exit 1 ;; esac } resolve_goarch() { case "$(uname -m)" in x86_64|amd64) printf "amd64" ;; aarch64|arm64) printf "arm64" ;; armv7*|armv6*|armhf) printf "arm" ;; i386|i686) printf "386" ;; ppc64le) printf "ppc64le" ;; s390x) printf "s390x" ;; riscv64) printf "riscv64" ;; *) ui_error "Architecture non supportee: $(uname -m)" exit 1 ;; esac } resolve_asset_name() { local goos="$1" local goarch="$2" local ext="" if [ "$goos" = "windows" ]; then ext=".exe" fi local template="$ASSET_NAME_TEMPLATE" if [ -z "$template" ]; then template="$DEFAULT_ASSET_NAME_TEMPLATE" fi local asset_name="$template" asset_name="${asset_name//\{binary\}/$BINARY_NAME}" asset_name="${asset_name//\{os\}/$goos}" asset_name="${asset_name//\{arch\}/$goarch}" asset_name="${asset_name//\{ext\}/$ext}" asset_name="$(trim_whitespace "$asset_name")" if [ -z "$asset_name" ]; then ui_error "Le template d'asset resolve vers une valeur vide." exit 1 fi case "$asset_name" in */*|*\\*) ui_error "Nom d'asset invalide (separateur de chemin): $asset_name" exit 1 ;; esac printf "%s" "$asset_name" } resolve_latest_release_url() { if [ -n "$LATEST_RELEASE_URL" ]; then printf "%s" "$LATEST_RELEASE_URL" return fi if [ -z "$RELEASE_DRIVER" ]; then ui_error "Configuration release incomplete: definir [update].driver ou [update].latest_release_url." exit 1 fi if [ -z "$RELEASE_REPOSITORY" ]; then ui_error "Configuration release incomplete: definir [update].repository." exit 1 fi case "$RELEASE_DRIVER" in gitea) if [ -z "$RELEASE_BASE_URL" ]; then ui_error "Configuration release incomplete: [update].base_url requis pour driver gitea." exit 1 fi printf "%s/api/v1/repos/%s/releases/latest" "$RELEASE_BASE_URL" "$RELEASE_REPOSITORY" ;; gitlab) local encoded_repository encoded_repository="$(jq -nr --arg value "$RELEASE_REPOSITORY" '$value|@uri')" printf "%s/api/v4/projects/%s/releases/permalink/latest" "$RELEASE_BASE_URL" "$encoded_repository" ;; github) printf "%s/repos/%s/releases/latest" "$RELEASE_BASE_URL" "$RELEASE_REPOSITORY" ;; *) ui_error "Driver release non supporte: $RELEASE_DRIVER (attendu: gitea, gitlab ou github)." exit 1 ;; esac } resolve_auth_header() { AUTH_HEADER_NAME="" AUTH_HEADER_VALUE="" local token="" local env_name for env_name in "${TOKEN_ENV_NAMES[@]}"; do if [ -z "$env_name" ]; then continue fi if [ -n "${!env_name:-}" ]; then token="$(trim_whitespace "${!env_name}")" if [ -n "$token" ]; then break fi fi done if [ -z "$token" ]; then return fi local header_name header_name="$(trim_whitespace "$TOKEN_HEADER")" if [ -z "$header_name" ]; then return fi local prefix prefix="$(trim_whitespace "$TOKEN_PREFIX")" if [ -n "$prefix" ]; then local lower_token local lower_prefix lower_token="$(printf "%s" "$token" | tr '[:upper:]' '[:lower:]')" lower_prefix="$(printf "%s" "$prefix" | tr '[:upper:]' '[:lower:]')" if [[ "$lower_token" != "$lower_prefix"* ]]; then token="$prefix $token" fi fi AUTH_HEADER_NAME="$header_name" AUTH_HEADER_VALUE="$token" } curl_download() { local url="$1" local output_path="$2" local mode="${3:-}" local curl_args=( -fsSL -H "User-Agent: mcp install wizard" ) if [ "$mode" = "json" ]; then curl_args+=(-H "Accept: application/json") fi if [ -n "$AUTH_HEADER_NAME" ] && [ -n "$AUTH_HEADER_VALUE" ]; then curl_args+=(-H "${AUTH_HEADER_NAME}: ${AUTH_HEADER_VALUE}") fi curl "${curl_args[@]}" "$url" -o "$output_path" } resolve_url_reference() { local value="$1" local base="$2" if printf "%s" "$value" | grep -Eq '^https?://'; then printf "%s" "$value" return fi local origin origin="$(printf "%s" "$base" | sed -E 's#^(https?://[^/]+).*$#\1#')" if [ -z "$origin" ]; then printf "%s" "$value" return fi if [ "${value#/}" != "$value" ]; then printf "%s%s" "$origin" "$value" return fi local base_no_query base_no_query="$(printf "%s" "$base" | sed 's/[?#].*$//')" local base_dir="${base_no_query%/*}" printf "%s/%s" "$base_dir" "$value" } resolve_asset_url_from_release() { local release_json_path="$1" local asset_name="$2" jq -r --arg asset "$asset_name" ' ( .assets.links[]? | select(.name == $asset) | (.direct_asset_url // .browser_download_url // .url // empty) ), ( .assets[]? | select(.name == $asset) | (.direct_asset_url // .browser_download_url // .url // empty) ) | select(. != null and . != "") ' "$release_json_path" | head -n 1 } release_assets_preview() { local release_json_path="$1" jq -r ' [ (.assets.links[]?.name), (.assets[]?.name) ] | map(select(. != null and . != "")) | unique | .[:8] | join(", ") ' "$release_json_path" } parse_checksum_for_asset() { local checksum_file="$1" local asset_name="$2" awk -v asset="$asset_name" ' function is_hex(value) { return value ~ /^[0-9A-Fa-f]{64}$/ } { line = $0 sub(/#.*/, "", line) gsub(/^[[:space:]]+|[[:space:]]+$/, "", line) if (line == "") { next } n = split(line, fields, /[[:space:]]+/) if (n == 1 && is_hex(fields[1]) && fallback == "") { fallback = fields[1] next } if (n >= 2 && is_hex(fields[1])) { candidate = fields[2] sub(/^\*/, "", candidate) sub(/^\.\/+/, "", candidate) if (candidate == asset) { print fields[1] exit } } if (n >= 2 && is_hex(fields[n])) { candidate = fields[1] sub(/^\*/, "", candidate) sub(/^\.\/+/, "", candidate) if (candidate == asset) { print fields[n] exit } } } END { if (fallback != "") { print fallback } } ' "$checksum_file" } sha256_file() { local file_path="$1" if command -v sha256sum >/dev/null 2>&1; then sha256sum "$file_path" | awk '{print $1}' return fi if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$file_path" | awk '{print $1}' return fi ui_error "Aucun outil SHA-256 detecte (sha256sum ou shasum requis)." exit 1 } resolve_binary_path() { if command -v "$BINARY_NAME" >/dev/null 2>&1; then command -v "$BINARY_NAME" return fi if [ -x "$HOME/.local/bin/$BINARY_NAME" ]; then printf "%s\n" "$HOME/.local/bin/$BINARY_NAME" return fi printf "%s\n" "$HOME/.local/bin/$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() { ensure_cli "curl" ensure_cli "jq" load_release_config_from_manifest resolve_auth_header local target_path="$HOME/.local/bin/$BINARY_NAME" if command -v "$BINARY_NAME" >/dev/null 2>&1; then target_path="$(command -v "$BINARY_NAME")" ui_success "Binaire detecte: $target_path" local reinstall reinstall="$(prompt "Reinstaller depuis la derniere release ? (y/N)" "N")" case "$reinstall" in y|Y|yes|YES) ;; *) return ;; esac fi if [ ! -w "$(dirname "$target_path")" ]; then ui_warn "Pas de droit d'ecriture dans $(dirname "$target_path"), installation dans $HOME/.local/bin." target_path="$HOME/.local/bin/$BINARY_NAME" fi local goos goos="$(resolve_goos)" local goarch goarch="$(resolve_goarch)" local asset_name asset_name="$(resolve_asset_name "$goos" "$goarch")" local release_url release_url="$(resolve_latest_release_url)" ui_info "Recherche de la derniere release pour $RELEASE_REPOSITORY ($goos/$goarch)..." local release_json release_json="$(mktemp)" if ! curl_download "$release_url" "$release_json" "json"; then rm -f "$release_json" ui_error "Impossible de recuperer la release latest depuis $release_url." if [ "${#TOKEN_ENV_NAMES[@]}" -gt 0 ]; then ui_info "Si le repo est prive, configure un token via: ${TOKEN_ENV_NAMES[*]}" fi exit 1 fi local release_tag release_tag="$(jq -r '.tag_name // empty' "$release_json")" if [ -z "$release_tag" ]; then rm -f "$release_json" ui_error "Reponse release invalide: tag_name manquant." exit 1 fi local asset_url asset_url="$(resolve_asset_url_from_release "$release_json" "$asset_name")" if [ -z "$asset_url" ]; then local preview preview="$(release_assets_preview "$release_json")" rm -f "$release_json" ui_error "Aucun asset \"$asset_name\" dans la release $release_tag." if [ -n "$preview" ]; then ui_info "Assets disponibles: $preview" fi exit 1 fi asset_url="$(resolve_url_reference "$asset_url" "$release_url")" ui_info "Telechargement de l'asset $asset_name ($release_tag)..." local download_path download_path="$(mktemp)" if ! curl_download "$asset_url" "$download_path"; then rm -f "$release_json" "$download_path" ui_error "Echec du telechargement de l'asset: $asset_url" exit 1 fi local checksum_asset_name checksum_asset_name="${CHECKSUM_ASSET_NAME//\{asset\}/$asset_name}" if [ -z "$checksum_asset_name" ]; then checksum_asset_name="${asset_name}.sha256" fi local checksum_url checksum_url="$(resolve_asset_url_from_release "$release_json" "$checksum_asset_name")" if [ -n "$checksum_url" ]; then checksum_url="$(resolve_url_reference "$checksum_url" "$release_url")" local checksum_path checksum_path="$(mktemp)" if ! curl_download "$checksum_url" "$checksum_path"; then rm -f "$release_json" "$download_path" "$checksum_path" ui_error "Echec du telechargement du checksum: $checksum_url" exit 1 fi local expected_checksum expected_checksum="$(parse_checksum_for_asset "$checksum_path" "$asset_name")" if [ -z "$expected_checksum" ]; then rm -f "$release_json" "$download_path" "$checksum_path" ui_error "Checksum introuvable dans $checksum_asset_name pour l'asset $asset_name." exit 1 fi local actual_checksum actual_checksum="$(sha256_file "$download_path")" local actual_checksum_lc actual_checksum_lc="$(printf "%s" "$actual_checksum" | tr '[:upper:]' '[:lower:]')" local expected_checksum_lc expected_checksum_lc="$(printf "%s" "$expected_checksum" | tr '[:upper:]' '[:lower:]')" if [ "$actual_checksum_lc" != "$expected_checksum_lc" ]; then rm -f "$release_json" "$download_path" "$checksum_path" ui_error "Checksum invalide pour $asset_name." exit 1 fi rm -f "$checksum_path" ui_success "Checksum verifie pour $asset_name." else if [ "$CHECKSUM_REQUIRED" = "true" ]; then rm -f "$release_json" "$download_path" ui_error "Checksum requis mais asset \"$checksum_asset_name\" introuvable." exit 1 fi ui_warn "Checksum non disponible pour $asset_name (verification ignoree)." fi chmod +x "$download_path" mkdir -p "$(dirname "$target_path")" mv "$download_path" "$target_path" rm -f "$release_json" ui_success "Binaire installe: $target_path" if ! printf ":%s:" "$PATH" | grep -Fq ":$(dirname "$target_path"):"; then ui_info "Ajoute $(dirname "$target_path") au PATH si necessaire." fi } run_setup_wizard() { install_binary local profile profile="$(prompt "Profil a configurer (${PROFILE_ENV})" "$DEFAULT_PROFILE")" local binary_path binary_path="$(resolve_binary_path)" ui_info "Lancement de $BINARY_NAME setup" if [ -t 2 ] && [ -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\"." PREFILL_SERVER_NAME="$(sanitize_server_name "$BINARY_NAME")" PREFILL_PROFILE_VALUE="$profile" PREFILL_COMMAND_PATH="$binary_path" } collect_server_inputs() { local default_name default_name="$(sanitize_server_name "${PREFILL_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}" "${PREFILL_PROFILE_VALUE:-$DEFAULT_PROFILE}")" local default_command if [ -n "${PREFILL_COMMAND_PATH:-}" ]; then default_command="$PREFILL_COMMAND_PATH" else default_command="$(resolve_binary_path)" fi COMMAND_PATH="$(prompt "Chemin du binaire serveur MCP" "$default_command")" } choose_scope() { local selected selected="$(menu_select "Scope de configuration" "global (user)" "project (projet courant)")" case "$selected" in "global (user)") printf "global" ;; *) printf "project" ;; esac } 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" \ "$SERVER_NAME" \ --env "${PROFILE_ENV}=${PROFILE_VALUE}" \ -- "$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() { collect_server_inputs cat <&2 printf "%bFramework module:%b %s\n" "$C_DIM" "$C_RESET" "$MODULE_PATH" >&2 ui_line printf "%bSelectionne une action dans le menu interactif.%b\n" "$C_DIM" "$C_RESET" >&2 } post_setup_configure_mcp() { ui_title "Configuration MCP apres setup" local next_action next_action="$(menu_select \ "Configurer le MCP maintenant ?" \ "Configurer Claude Code (apply direct)" \ "Configurer Codex (apply direct)" \ "Generer JSON MCP manuel" \ "Terminer sans config MCP")" printf "\n" >&2 case "$next_action" in "Configurer Claude Code (apply direct)") apply_claude_mcp ;; "Configurer Codex (apply direct)") apply_codex_mcp ;; "Generer JSON MCP manuel") ui_info "JSON MCP genere sur stdout." print_mcp_json ;; *) ui_info "Setup termine sans configuration MCP additionnelle." ;; esac } main() { print_header local action action="$(menu_select \ "Choisis une action" \ "Installer/mettre a jour le binaire + setup" \ "Configurer Claude Code (apply direct)" \ "Configurer Codex (apply direct)" \ "Generer JSON MCP manuel" \ "Quitter")" printf "\n" >&2 case "$action" in "Installer/mettre a jour le binaire + setup") run_setup_wizard post_setup_configure_mcp ;; "Configurer Claude Code (apply direct)") apply_claude_mcp ;; "Configurer Codex (apply direct)") apply_codex_mcp ;; "Generer JSON MCP manuel") ui_info "JSON MCP genere sur stdout." print_mcp_json ;; *) ui_warn "Annule." ;; esac } main "$@" ` const mainTemplate = `package main import ( "context" "log" "os" "{{.ModulePath}}/internal/app" ) var version = "dev" func main() { if err := app.Run(context.Background(), os.Args[1:], version); err != nil { log.Fatal(err) } } ` const appTemplate = `package app import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "sync" "forge.lclr.dev/AI/mcp-framework/bootstrap" "forge.lclr.dev/AI/mcp-framework/cli" "forge.lclr.dev/AI/mcp-framework/config" "forge.lclr.dev/AI/mcp-framework/manifest" "forge.lclr.dev/AI/mcp-framework/secretstore" "forge.lclr.dev/AI/mcp-framework/update" ) var embeddedManifest = ` + "`" + `binary_name = "{{.BinaryName}}" docs_url = "{{.DocsURL}}" [update] source_name = "Release endpoint" driver = "{{.ReleaseDriver}}" repository = "{{.ReleaseRepository}}" base_url = "{{.ReleaseBaseURL}}" asset_name_template = "{binary}-{os}-{arch}{ext}" checksum_asset_name = "{asset}.sha256" checksum_required = true signature_asset_name = "{asset}.sig" signature_required = false signature_public_key_env_names = ["{{.ReleasePublicKeyEnv}}"] token_header = "Authorization" token_prefix = "token" token_env_names = ["{{.ReleaseTokenEnv}}"] [environment] known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] [secret_store] backend_policy = "{{.SecretStorePolicy}}" [profiles] default = "{{.DefaultProfile}}" known = [{{- range $index, $value := .Profiles}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] [bootstrap] description = "{{.Description}}" ` + "`" + ` type Profile struct { BaseURL string } type Runtime struct { ConfigStore config.Store[Profile] Manifest manifest.File ManifestSource string BinaryName string Description string Version string DefaultProfile string ProfileEnv string TokenEnv string SecretName string } const ( ansiRedColor = "\033[31m" ansiResetColor = "\033[0m" ) func Run(ctx context.Context, args []string, version string) error { runtime, err := NewRuntime(version) if err != nil { return err } return runtime.Run(ctx, args) } func NewRuntime(version string) (Runtime, error) { manifestStartDir := "." if executablePath, err := os.Executable(); err == nil { if dir := strings.TrimSpace(filepath.Dir(executablePath)); dir != "" { manifestStartDir = dir } } manifestFile, manifestSource, err := manifest.LoadDefaultOrEmbedded(manifestStartDir, embeddedManifest) if err != nil { if !errors.Is(err, os.ErrNotExist) { return Runtime{}, err } manifestFile = manifest.File{} manifestSource = "" } bootstrapInfo := manifestFile.BootstrapInfo() scaffoldInfo := manifestFile.ScaffoldInfo() binaryName := firstNonEmpty(bootstrapInfo.BinaryName, "{{.BinaryName}}") description := firstNonEmpty(bootstrapInfo.Description, "{{.Description}}") defaultProfile := firstNonEmpty(scaffoldInfo.DefaultProfile, "{{.DefaultProfile}}") profileEnv := "{{.ProfileEnv}}" tokenEnv := "{{.TokenEnv}}" if len(scaffoldInfo.KnownEnvironmentVariables) > 0 { profileEnv = firstNonEmpty(scaffoldInfo.KnownEnvironmentVariables[0], profileEnv) } if len(scaffoldInfo.KnownEnvironmentVariables) > 2 { tokenEnv = firstNonEmpty(scaffoldInfo.KnownEnvironmentVariables[2], tokenEnv) } return Runtime{ ConfigStore: config.NewStore[Profile](binaryName), Manifest: manifestFile, ManifestSource: manifestSource, BinaryName: binaryName, Description: description, Version: firstNonEmpty(strings.TrimSpace(version), "dev"), DefaultProfile: defaultProfile, ProfileEnv: profileEnv, TokenEnv: tokenEnv, SecretName: binaryName + "-api-token", }, nil } func (r Runtime) Run(ctx context.Context, args []string) error { return bootstrap.Run(ctx, bootstrap.Options{ BinaryName: r.BinaryName, Description: r.Description, Version: r.Version, Args: args, Hooks: bootstrap.Hooks{ Setup: r.runSetup, Login: r.runLogin, MCP: r.runMCP, ConfigShow: r.runConfigShow, ConfigTest: r.runConfigTest, Update: r.runUpdate, }, }) } func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error { stdin, ok := inv.Stdin.(*os.File) if !ok || stdin == nil { stdin = os.Stdin } stdout := inv.Stdout if stdout == nil { stdout = os.Stdout } if _, err := r.openSecretStore(); err != nil { return fmt.Errorf("secret backend is not ready: %w", err) } cfg, _, err := r.ConfigStore.LoadDefault() if err != nil { return err } profileName := r.resolveProfileName(cfg.CurrentProfile) profile := cfg.Profiles[profileName] storedToken, err := r.readToken() switch { case err == nil: case errors.Is(err, secretstore.ErrNotFound): storedToken = "" default: return err } result, err := cli.RunSetup(cli.SetupOptions{ Stdin: stdin, Stdout: stdout, Fields: []cli.SetupField{ { Name: "base_url", Label: "Base URL", Type: cli.SetupFieldURL, Required: true, Default: profile.BaseURL, }, { Name: "api_token", Label: "API token", Type: cli.SetupFieldSecret, Required: true, ExistingSecret: storedToken, }, }, }) if err != nil { return err } baseURLValue, _ := result.Get("base_url") tokenValue, _ := result.Get("api_token") profile.BaseURL = strings.TrimSpace(baseURLValue.String) cfg.CurrentProfile = profileName cfg.Profiles[profileName] = profile if _, err := r.ConfigStore.SaveDefault(cfg); err != nil { return err } store, err := r.openSecretStore() if err != nil { return err } if err := cli.WriteSetupSecretVerified(cli.SetupSecretWriteOptions{ Store: store, SecretName: r.SecretName, SecretLabel: "API token", TokenEnv: r.TokenEnv, Value: tokenValue, }); err != nil { return err } _, err = fmt.Fprintf(stdout, "Configuration saved for profile %q. Secret readability confirmed.\n", profileName) return err } func (r Runtime) runLogin(_ context.Context, inv bootstrap.Invocation) error { if r.activeBackendPolicy() != secretstore.BackendBitwardenCLI { return fmt.Errorf( "commande login disponible uniquement avec secret_store.backend_policy=%q", secretstore.BackendBitwardenCLI, ) } stdin := inv.Stdin if stdin == nil { stdin = os.Stdin } stdout := inv.Stdout if stdout == nil { stdout = os.Stdout } stderr := inv.Stderr if stderr == nil { stderr = os.Stderr } if _, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{ ServiceName: r.BinaryName, Stdin: stdin, Stdout: stdout, Stderr: stderr, }); err != nil { return err } _, err := fmt.Fprintf(stdout, "Session Bitwarden persistée pour %q.\n", r.BinaryName) return err } func (r Runtime) runMCP(_ context.Context, inv bootstrap.Invocation) error { stdout := inv.Stdout if stdout == nil { stdout = os.Stdout } cfg, _, err := r.ConfigStore.LoadDefault() if err != nil { return err } profileName := r.resolveProfileName(cfg.CurrentProfile) profile, ok := cfg.Profiles[profileName] if !ok { return fmt.Errorf("profil %q absent, lance %s setup", profileName, r.BinaryName) } token, err := r.readToken() if err != nil { if errors.Is(err, secretstore.ErrNotFound) { return fmt.Errorf("secret %q introuvable, lance %s setup", r.SecretName, r.BinaryName) } return err } fmt.Fprintf(stdout, "MCP prêt sur %s (profil %s).\n", profile.BaseURL, profileName) fmt.Fprintf(stdout, "Token chargé (%d caractères).\n", len(strings.TrimSpace(token))) fmt.Fprintln(stdout, "Ajoute ici ta logique métier MCP.") return nil } func (r Runtime) runConfigShow(_ context.Context, inv bootstrap.Invocation) error { stdout := inv.Stdout if stdout == nil { stdout = os.Stdout } cfg, path, err := r.ConfigStore.LoadDefault() if err != nil { return err } payload, err := json.MarshalIndent(cfg, "", " ") if err != nil { return fmt.Errorf("encode config JSON: %w", err) } if _, err := fmt.Fprintf(stdout, "Config: %s\n", path); err != nil { return err } if _, err := fmt.Fprintf(stdout, "Manifest source: %s\n", r.manifestSourceLabel()); err != nil { return err } if _, err := fmt.Fprintf(stdout, "Secret backend policy (active): %s\n", r.activeBackendPolicy()); err != nil { return err } _, err = fmt.Fprintf(stdout, "%s\n", payload) return err } func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) error { stdout := inv.Stdout if stdout == nil { stdout = os.Stdout } secretStoreFactory := memoizeSecretStoreFactory(r.openSecretStore) report := cli.RunDoctor(ctx, cli.DoctorOptions{ ConfigCheck: cli.NewConfigCheck(r.ConfigStore), SecretStoreCheck: cli.SecretStoreAvailabilityCheck(secretStoreFactory), SecretBackendPolicy: r.activeBackendPolicy(), RequiredSecrets: []cli.DoctorSecret{ {Name: r.SecretName, Label: "API token"}, }, SecretStoreFactory: secretStoreFactory, ManifestCheck: r.manifestDoctorCheck(), BitwardenOptions: cli.BitwardenDoctorOptions{ LookupEnv: os.LookupEnv, }, }) if err := cli.RenderDoctorReport(stdout, report); err != nil { return err } if report.HasFailures() { return errors.New("doctor checks failed") } return nil } func memoizeSecretStoreFactory(factory func() (secretstore.Store, error)) func() (secretstore.Store, error) { if factory == nil { return nil } var ( once sync.Once store secretstore.Store err error ) return func() (secretstore.Store, error) { once.Do(func() { store, err = factory() }) return store, err } } func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error { stdout := inv.Stdout if stdout == nil { stdout = os.Stdout } return update.Run(ctx, update.Options{ CurrentVersion: r.Version, BinaryName: r.BinaryName, ReleaseSource: r.Manifest.Update.ReleaseSource(), Stdout: stdout, }) } func (r Runtime) openSecretStore() (secretstore.Store, error) { policy := r.activeBackendPolicy() if policy == secretstore.BackendBitwardenCLI { if err := r.ensureBitwardenSession(); err != nil { return nil, err } } return secretstore.Open(secretstore.Options{ ServiceName: r.BinaryName, BackendPolicy: policy, LookupEnv: func(name string) (string, bool) { if name == r.SecretName { return os.LookupEnv(r.TokenEnv) } return os.LookupEnv(name) }, }) } func (r Runtime) ensureBitwardenSession() error { if hasBitwardenSessionInEnv() { return nil } loaded, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{ ServiceName: r.BinaryName, }) if err != nil { return err } if loaded || hasBitwardenSessionInEnv() { return nil } return errors.New(colorizeRed(fmt.Sprintf( "Session Bitwarden introuvable. Lance %s login puis relance la commande.", r.BinaryName, ))) } func hasBitwardenSessionInEnv() bool { session, ok := os.LookupEnv("BW_SESSION") return ok && strings.TrimSpace(session) != "" } func colorizeRed(message string) string { return ansiRedColor + strings.TrimSpace(message) + ansiResetColor } func (r Runtime) activeBackendPolicy() secretstore.BackendPolicy { policy := secretstore.BackendPolicy(strings.TrimSpace(r.Manifest.SecretStore.BackendPolicy)) if policy == "" { return secretstore.BackendAuto } return policy } func (r Runtime) manifestSourceLabel() string { source := strings.TrimSpace(r.ManifestSource) if source == "" { return "embedded defaults" } return source } func (r Runtime) readToken() (string, error) { store, err := r.openSecretStore() if err != nil { return "", err } return store.GetSecret(r.SecretName) } func (r Runtime) resolveProfileName(currentProfile string) string { resolved := cli.ResolveProfileName("", os.Getenv(r.ProfileEnv), currentProfile) if strings.TrimSpace(resolved) != "" { return resolved } return r.DefaultProfile } func firstNonEmpty(values ...string) string { for _, value := range values { trimmed := strings.TrimSpace(value) if trimmed != "" { return trimmed } } return "" } func (r Runtime) manifestDoctorCheck() cli.DoctorCheck { return func(context.Context) cli.DoctorResult { if strings.TrimSpace(r.ManifestSource) == "" { return cli.DoctorResult{ Name: "manifest", Status: cli.DoctorStatusWarn, Summary: "manifest is missing, using built-in defaults", Detail: fmt.Sprintf("source=%s policy=%s", r.manifestSourceLabel(), r.activeBackendPolicy()), } } return cli.DoctorResult{ Name: "manifest", Status: cli.DoctorStatusOK, Summary: "manifest is valid", Detail: fmt.Sprintf("source=%s policy=%s", r.manifestSourceLabel(), r.activeBackendPolicy()), } } } ` const manifestTemplate = `binary_name = "{{.BinaryName}}" docs_url = "{{.DocsURL}}" [update] source_name = "Release endpoint" driver = "{{.ReleaseDriver}}" repository = "{{.ReleaseRepository}}" base_url = "{{.ReleaseBaseURL}}" asset_name_template = "{binary}-{os}-{arch}{ext}" checksum_asset_name = "{asset}.sha256" checksum_required = true signature_asset_name = "{asset}.sig" signature_required = false signature_public_key_env_names = ["{{.ReleasePublicKeyEnv}}"] token_header = "Authorization" token_prefix = "token" token_env_names = ["{{.ReleaseTokenEnv}}"] [environment] known = [{{- range $index, $value := .KnownEnvironmentVariables}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] [secret_store] backend_policy = "{{.SecretStorePolicy}}" [profiles] default = "{{.DefaultProfile}}" known = [{{- range $index, $value := .Profiles}}{{if $index}}, {{end}}"{{$value}}"{{- end}}] [bootstrap] description = "{{.Description}}" ` const readmeTemplate = `# {{.BinaryName}} Binaire MCP généré depuis ` + "`mcp-framework`" + `. ## Arborescence générée ` + "```text" + ` . ├── cmd/ │ └── {{.BinaryName}}/ │ └── main.go ├── internal/ │ └── app/ │ └── app.go ├── .gitignore ├── go.mod ├── install.sh ├── mcp.toml └── README.md ` + "```" + ` ## Démarrage rapide 1. Installer les dépendances : ` + "```bash" + ` go mod tidy ` + "```" + ` 2. Vérifier l’aide CLI bootstrap : ` + "```bash" + ` go run ./cmd/{{.BinaryName}} help ` + "```" + ` 3. Initialiser la configuration locale : ` + "```bash" + ` go run ./cmd/{{.BinaryName}} setup ` + "```" + ` 4. Lancer le flux MCP (placeholder) : ` + "```bash" + ` go run ./cmd/{{.BinaryName}} mcp ` + "```" + ` 5. Vérifier la configuration et le manifeste : ` + "```bash" + ` go run ./cmd/{{.BinaryName}} config test ` + "```" + ` 6. Publier un install wizard consommable via ` + "`curl | bash`" + ` : ` + "```bash" + ` curl -fsSL https://///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). - Adapter l'URL ` + "`curl .../install.sh`" + ` à votre forge/répertoire. - Compléter la logique métier dans ` + "`internal/app/app.go`" + ` (` + "`runMCP`" + `). - Ajuster les variables d’environnement connues si besoin. `