mcp-framework/scaffold/scaffold.go

2102 lines
50 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
"gitea.lclr.dev/AI/mcp-framework/bootstrap"
"gitea.lclr.dev/AI/mcp-framework/cli"
"gitea.lclr.dev/AI/mcp-framework/config"
"gitea.lclr.dev/AI/mcp-framework/manifest"
"gitea.lclr.dev/AI/mcp-framework/secretstore"
"gitea.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
}
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 := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{
ServiceName: r.BinaryName,
}); 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) 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.
`