mcp-framework/scaffold/scaffold.go

2137 lines
51 KiB
Go
Raw Permalink Normal View History

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 <<JSON
{
"mcpServers": {
"${SERVER_NAME}": {
"command": "${COMMAND_PATH}",
"args": ["mcp"],
"env": {
"${PROFILE_ENV}": "${PROFILE_VALUE}"
}
}
}
}
JSON
}
print_header() {
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 "%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"
2026-05-05 10:23:14 +00:00
"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 laide 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://<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).
- 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 denvironnement connues si besoin.
`