2026-04-14 13:39:21 +00:00
|
|
|
|
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
|
2026-04-15 10:20:06 +00:00
|
|
|
|
ReleasePublicKeyEnv string
|
2026-04-14 13:39:21 +00:00
|
|
|
|
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
|
2026-04-15 10:20:06 +00:00
|
|
|
|
ReleasePublicKeyEnv string
|
2026-04-14 13:39:21 +00:00
|
|
|
|
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{
|
2026-04-15 10:13:41 +00:00
|
|
|
|
{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},
|
2026-04-14 13:39:21 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
written := make([]string, 0, len(files))
|
|
|
|
|
|
for _, file := range files {
|
2026-04-15 10:13:41 +00:00
|
|
|
|
content, err := renderTemplate(file.Template, normalized)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return Result{}, fmt.Errorf("render scaffold file %q: %w", file.Path, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 13:39:21 +00:00
|
|
|
|
fullPath := filepath.Join(normalized.TargetDir, file.Path)
|
2026-04-15 10:13:41 +00:00
|
|
|
|
if err := writeFile(fullPath, content, file.Mode, normalized.Overwrite); err != nil {
|
2026-04-14 13:39:21 +00:00
|
|
|
|
return Result{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
written = append(written, file.Path)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sort.Strings(written)
|
|
|
|
|
|
return Result{
|
|
|
|
|
|
Root: normalized.TargetDir,
|
|
|
|
|
|
Files: written,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type generatedFile struct {
|
2026-04-15 10:13:41 +00:00
|
|
|
|
Path string
|
|
|
|
|
|
Template string
|
|
|
|
|
|
Mode os.FileMode
|
2026-04-14 13:39:21 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 08:13:45 +00:00
|
|
|
|
func writeFile(path, content string, mode os.FileMode, overwrite bool) error {
|
2026-04-14 13:39:21 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 08:13:45 +00:00
|
|
|
|
if mode == 0 {
|
|
|
|
|
|
mode = 0o644
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
2026-04-14 13:39:21 +00:00
|
|
|
|
return fmt.Errorf("write scaffold file %q: %w", path, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 10:13:41 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-04-14 13:39:21 +00:00
|
|
|
|
|
|
|
|
|
|
var builder strings.Builder
|
|
|
|
|
|
if err := tpl.Execute(&builder, data); err != nil {
|
2026-04-15 10:13:41 +00:00
|
|
|
|
return "", fmt.Errorf("execute template: %w", err)
|
2026-04-14 13:39:21 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 10:13:41 +00:00
|
|
|
|
return builder.String(), nil
|
2026-04-14 13:39:21 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
}
|
2026-04-15 10:20:06 +00:00
|
|
|
|
releasePublicKeyEnv := strings.TrimSpace(options.ReleasePublicKeyEnv)
|
|
|
|
|
|
if releasePublicKeyEnv == "" {
|
|
|
|
|
|
releasePublicKeyEnv = envPrefix + "_RELEASE_ED25519_PUBLIC_KEY"
|
|
|
|
|
|
}
|
|
|
|
|
|
if !slices.Contains(knownEnvironmentVariables, releasePublicKeyEnv) {
|
|
|
|
|
|
knownEnvironmentVariables = append(knownEnvironmentVariables, releasePublicKeyEnv)
|
|
|
|
|
|
}
|
2026-04-14 13:39:21 +00:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-04-15 10:20:06 +00:00
|
|
|
|
ReleasePublicKeyEnv: releasePublicKeyEnv,
|
2026-04-14 13:39:21 +00:00
|
|
|
|
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
|
|
|
|
|
|
`
|
|
|
|
|
|
|
2026-04-15 08:13:45 +00:00
|
|
|
|
const installTemplate = `#!/usr/bin/env bash
|
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
|
|
BINARY_NAME="{{.BinaryName}}"
|
|
|
|
|
|
MODULE_PATH="{{.ModulePath}}"
|
|
|
|
|
|
DEFAULT_PROFILE="{{.DefaultProfile}}"
|
|
|
|
|
|
PROFILE_ENV="{{.ProfileEnv}}"
|
2026-04-15 09:29:45 +00:00
|
|
|
|
PREFILL_SERVER_NAME=""
|
|
|
|
|
|
PREFILL_PROFILE_VALUE=""
|
|
|
|
|
|
PREFILL_COMMAND_PATH=""
|
2026-04-15 08:13:45 +00:00
|
|
|
|
|
2026-04-15 08:33:13 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 08:13:45 +00:00
|
|
|
|
prompt() {
|
|
|
|
|
|
local label="$1"
|
|
|
|
|
|
local default_value="$2"
|
|
|
|
|
|
local answer=""
|
|
|
|
|
|
|
|
|
|
|
|
if [ -n "$default_value" ]; then
|
2026-04-15 08:33:13 +00:00
|
|
|
|
printf "%b%s%b [%s]: " "$C_BOLD" "$label" "$C_RESET" "$default_value" >&2
|
2026-04-15 08:13:45 +00:00
|
|
|
|
else
|
2026-04-15 08:33:13 +00:00
|
|
|
|
printf "%b%s%b: " "$C_BOLD" "$label" "$C_RESET" >&2
|
2026-04-15 08:13:45 +00:00
|
|
|
|
fi
|
|
|
|
|
|
|
2026-04-15 08:33:13 +00:00
|
|
|
|
if [ -t 2 ] && [ -r /dev/tty ]; then
|
|
|
|
|
|
if ! IFS= read -r answer < /dev/tty 2>/dev/null; then
|
|
|
|
|
|
IFS= read -r answer || answer=""
|
|
|
|
|
|
fi
|
2026-04-15 08:13:45 +00:00
|
|
|
|
else
|
|
|
|
|
|
IFS= read -r answer || answer=""
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
if [ -z "$answer" ]; then
|
|
|
|
|
|
printf "%s" "$default_value"
|
|
|
|
|
|
return
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
printf "%s" "$answer"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 09:29:45 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 08:33:13 +00:00
|
|
|
|
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'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 08:13:45 +00:00
|
|
|
|
go_bin_dir() {
|
|
|
|
|
|
local gobin
|
|
|
|
|
|
gobin="$(go env GOBIN 2>/dev/null || true)"
|
|
|
|
|
|
if [ -n "$gobin" ]; then
|
|
|
|
|
|
printf "%s\n" "$gobin"
|
|
|
|
|
|
return
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
go env GOPATH 2>/dev/null | awk '{print $1 "/bin"}'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
resolve_binary_path() {
|
|
|
|
|
|
if command -v "$BINARY_NAME" >/dev/null 2>&1; then
|
|
|
|
|
|
command -v "$BINARY_NAME"
|
|
|
|
|
|
return
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
if command -v go >/dev/null 2>&1; then
|
|
|
|
|
|
local bin_dir
|
|
|
|
|
|
bin_dir="$(go_bin_dir)"
|
|
|
|
|
|
if [ -n "$bin_dir" ] && [ -x "$bin_dir/$BINARY_NAME" ]; then
|
|
|
|
|
|
printf "%s\n" "$bin_dir/$BINARY_NAME"
|
|
|
|
|
|
return
|
|
|
|
|
|
fi
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
printf "%s\n" "$HOME/.local/bin/$BINARY_NAME"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 08:33:13 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 08:13:45 +00:00
|
|
|
|
install_binary() {
|
|
|
|
|
|
if command -v "$BINARY_NAME" >/dev/null 2>&1; then
|
2026-04-15 08:33:13 +00:00
|
|
|
|
ui_success "Binaire detecte: $(command -v "$BINARY_NAME")"
|
|
|
|
|
|
local reinstall
|
|
|
|
|
|
reinstall="$(prompt "Reinstaller via go install ? (y/N)" "N")"
|
|
|
|
|
|
case "$reinstall" in
|
|
|
|
|
|
y|Y|yes|YES)
|
|
|
|
|
|
;;
|
|
|
|
|
|
*)
|
|
|
|
|
|
return
|
|
|
|
|
|
;;
|
|
|
|
|
|
esac
|
2026-04-15 08:13:45 +00:00
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
if ! command -v go >/dev/null 2>&1; then
|
2026-04-15 08:33:13 +00:00
|
|
|
|
ui_error "Go n'est pas installe. Installe Go ou choisis une configuration manuelle."
|
2026-04-15 08:13:45 +00:00
|
|
|
|
exit 1
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
2026-04-15 08:33:13 +00:00
|
|
|
|
ui_info "Installation du binaire via go install..."
|
2026-04-15 08:13:45 +00:00
|
|
|
|
go install "${MODULE_PATH}/cmd/${BINARY_NAME}@latest"
|
|
|
|
|
|
|
|
|
|
|
|
local bin_dir
|
|
|
|
|
|
bin_dir="$(go_bin_dir)"
|
|
|
|
|
|
if [ -n "$bin_dir" ]; then
|
2026-04-15 08:33:13 +00:00
|
|
|
|
ui_success "Binaire installe dans $bin_dir"
|
|
|
|
|
|
ui_info "Ajoute ce dossier au PATH si necessaire."
|
2026-04-15 08:13:45 +00:00
|
|
|
|
fi
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
run_setup_wizard() {
|
|
|
|
|
|
install_binary
|
|
|
|
|
|
|
|
|
|
|
|
local profile
|
2026-04-15 08:33:13 +00:00
|
|
|
|
profile="$(prompt "Profil a configurer (${PROFILE_ENV})" "$DEFAULT_PROFILE")"
|
2026-04-15 08:13:45 +00:00
|
|
|
|
local binary_path
|
|
|
|
|
|
binary_path="$(resolve_binary_path)"
|
|
|
|
|
|
|
2026-04-15 08:33:13 +00:00
|
|
|
|
ui_info "Lancement de $BINARY_NAME setup"
|
2026-04-15 09:29:45 +00:00
|
|
|
|
if [ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then
|
2026-04-15 08:13:45 +00:00
|
|
|
|
env "${PROFILE_ENV}=${profile}" "$binary_path" setup < /dev/tty > /dev/tty
|
|
|
|
|
|
else
|
|
|
|
|
|
env "${PROFILE_ENV}=${profile}" "$binary_path" setup
|
|
|
|
|
|
fi
|
2026-04-15 08:33:13 +00:00
|
|
|
|
ui_success "Setup termine pour le profil \"$profile\"."
|
2026-04-15 09:29:45 +00:00
|
|
|
|
|
|
|
|
|
|
PREFILL_SERVER_NAME="$(sanitize_server_name "$BINARY_NAME")"
|
|
|
|
|
|
PREFILL_PROFILE_VALUE="$profile"
|
|
|
|
|
|
PREFILL_COMMAND_PATH="$binary_path"
|
2026-04-15 08:13:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 08:33:13 +00:00
|
|
|
|
collect_server_inputs() {
|
|
|
|
|
|
local default_name
|
2026-04-15 09:29:45 +00:00
|
|
|
|
default_name="$(sanitize_server_name "${PREFILL_SERVER_NAME:-$BINARY_NAME}")"
|
2026-04-15 08:33:13 +00:00
|
|
|
|
SERVER_NAME="$(prompt "Nom du serveur MCP" "$default_name")"
|
|
|
|
|
|
SERVER_NAME="$(sanitize_server_name "$SERVER_NAME")"
|
|
|
|
|
|
|
2026-04-15 09:29:45 +00:00
|
|
|
|
PROFILE_VALUE="$(prompt "Valeur de ${PROFILE_ENV}" "${PREFILL_PROFILE_VALUE:-$DEFAULT_PROFILE}")"
|
2026-04-15 08:33:13 +00:00
|
|
|
|
|
2026-04-15 08:13:45 +00:00
|
|
|
|
local default_command
|
2026-04-15 09:29:45 +00:00
|
|
|
|
if [ -n "${PREFILL_COMMAND_PATH:-}" ]; then
|
|
|
|
|
|
default_command="$PREFILL_COMMAND_PATH"
|
|
|
|
|
|
else
|
|
|
|
|
|
default_command="$(resolve_binary_path)"
|
|
|
|
|
|
fi
|
2026-04-15 08:33:13 +00:00
|
|
|
|
COMMAND_PATH="$(prompt "Chemin du binaire serveur MCP" "$default_command")"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
choose_scope() {
|
|
|
|
|
|
local selected
|
2026-04-15 09:29:45 +00:00
|
|
|
|
selected="$(menu_select "Scope de configuration" "global (user)" "project (projet courant)")"
|
|
|
|
|
|
case "$selected" in
|
|
|
|
|
|
"global (user)")
|
|
|
|
|
|
printf "global"
|
|
|
|
|
|
;;
|
|
|
|
|
|
*)
|
|
|
|
|
|
printf "project"
|
|
|
|
|
|
;;
|
|
|
|
|
|
esac
|
2026-04-15 08:33:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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" \
|
2026-04-15 09:29:45 +00:00
|
|
|
|
"$SERVER_NAME" \
|
|
|
|
|
|
--env "${PROFILE_ENV}=${PROFILE_VALUE}" \
|
|
|
|
|
|
-- "$COMMAND_PATH" mcp
|
2026-04-15 08:33:13 +00:00
|
|
|
|
|
|
|
|
|
|
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}"
|
2026-04-15 08:13:45 +00:00
|
|
|
|
|
2026-04-15 08:33:13 +00:00
|
|
|
|
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
|
2026-04-15 08:13:45 +00:00
|
|
|
|
cat <<JSON
|
|
|
|
|
|
{
|
|
|
|
|
|
"mcpServers": {
|
2026-04-15 08:33:13 +00:00
|
|
|
|
"${SERVER_NAME}": {
|
|
|
|
|
|
"command": "${COMMAND_PATH}",
|
2026-04-15 08:13:45 +00:00
|
|
|
|
"args": ["mcp"],
|
|
|
|
|
|
"env": {
|
2026-04-15 08:33:13 +00:00
|
|
|
|
"${PROFILE_ENV}": "${PROFILE_VALUE}"
|
2026-04-15 08:13:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
JSON
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
print_header() {
|
2026-04-15 08:33:13 +00:00
|
|
|
|
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
|
2026-04-15 09:29:45 +00:00
|
|
|
|
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
|
2026-04-15 08:13:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
main() {
|
2026-04-15 09:29:45 +00:00
|
|
|
|
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
|
2026-04-15 08:13:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
main "$@"
|
|
|
|
|
|
`
|
|
|
|
|
|
|
2026-04-14 13:39:21 +00:00
|
|
|
|
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"
|
2026-04-15 10:13:41 +00:00
|
|
|
|
"path/filepath"
|
2026-04-14 13:39:21 +00:00
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
|
|
"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"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-16 14:56:00 +00:00
|
|
|
|
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}}"
|
|
|
|
|
|
` + "`" + `
|
|
|
|
|
|
|
2026-04-14 13:39:21 +00:00
|
|
|
|
type Profile struct {
|
|
|
|
|
|
BaseURL string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type Runtime struct {
|
|
|
|
|
|
ConfigStore config.Store[Profile]
|
|
|
|
|
|
Manifest manifest.File
|
2026-04-16 14:56:00 +00:00
|
|
|
|
ManifestSource string
|
2026-04-14 13:39:21 +00:00
|
|
|
|
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) {
|
2026-04-15 10:13:41 +00:00
|
|
|
|
manifestStartDir := "."
|
|
|
|
|
|
if executablePath, err := os.Executable(); err == nil {
|
|
|
|
|
|
if dir := strings.TrimSpace(filepath.Dir(executablePath)); dir != "" {
|
|
|
|
|
|
manifestStartDir = dir
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 14:56:00 +00:00
|
|
|
|
manifestFile, manifestSource, err := manifest.LoadDefaultOrEmbedded(manifestStartDir, embeddedManifest)
|
2026-04-14 13:39:21 +00:00
|
|
|
|
if err != nil {
|
2026-04-15 10:13:41 +00:00
|
|
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
|
|
|
|
return Runtime{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
manifestFile = manifest.File{}
|
2026-04-16 14:56:00 +00:00
|
|
|
|
manifestSource = ""
|
2026-04-14 13:39:21 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 14:56:00 +00:00
|
|
|
|
return Runtime{
|
|
|
|
|
|
ConfigStore: config.NewStore[Profile](binaryName),
|
|
|
|
|
|
Manifest: manifestFile,
|
|
|
|
|
|
ManifestSource: manifestSource,
|
|
|
|
|
|
BinaryName: binaryName,
|
|
|
|
|
|
Description: description,
|
|
|
|
|
|
Version: firstNonEmpty(strings.TrimSpace(version), "dev"),
|
|
|
|
|
|
DefaultProfile: defaultProfile,
|
2026-04-14 13:39:21 +00:00
|
|
|
|
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,
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cfg, _, err := r.ConfigStore.LoadDefault()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
profileName := r.resolveProfileName(cfg.CurrentProfile)
|
|
|
|
|
|
profile := cfg.Profiles[profileName]
|
|
|
|
|
|
storedToken, _ := r.readToken()
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if !tokenValue.KeptStoredSecret {
|
|
|
|
|
|
store, err := r.openSecretStore()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := store.SetSecret(r.SecretName, "API token", tokenValue.String); err != nil {
|
|
|
|
|
|
if errors.Is(err, secretstore.ErrReadOnly) {
|
|
|
|
|
|
fmt.Fprintf(stdout, "Secret store en lecture seule, exporte %s pour fournir le token.\n", r.TokenEnv)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_, err = fmt.Fprintf(stdout, "Configuration sauvegardée pour le profil %q.\n", profileName)
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
_, 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
report := cli.RunDoctor(ctx, cli.DoctorOptions{
|
|
|
|
|
|
ConfigCheck: cli.NewConfigCheck(r.ConfigStore),
|
|
|
|
|
|
SecretStoreCheck: cli.SecretStoreAvailabilityCheck(r.openSecretStore),
|
|
|
|
|
|
RequiredSecrets: []cli.DoctorSecret{
|
|
|
|
|
|
{Name: r.SecretName, Label: "API token"},
|
|
|
|
|
|
},
|
|
|
|
|
|
SecretStoreFactory: r.openSecretStore,
|
2026-04-16 14:56:00 +00:00
|
|
|
|
ManifestCheck: r.manifestDoctorCheck(),
|
2026-04-14 13:39:21 +00:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if err := cli.RenderDoctorReport(stdout, report); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if report.HasFailures() {
|
|
|
|
|
|
return errors.New("doctor checks failed")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-04-16 14:56:00 +00:00
|
|
|
|
backendPolicy := secretstore.BackendPolicy(strings.TrimSpace(r.Manifest.SecretStore.BackendPolicy))
|
|
|
|
|
|
if backendPolicy == "" {
|
|
|
|
|
|
backendPolicy = secretstore.BackendAuto
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return secretstore.Open(secretstore.Options{
|
2026-04-14 14:40:50 +00:00
|
|
|
|
ServiceName: r.BinaryName,
|
2026-04-16 14:56:00 +00:00
|
|
|
|
BackendPolicy: backendPolicy,
|
2026-04-14 13:39:21 +00:00
|
|
|
|
LookupEnv: func(name string) (string, bool) {
|
|
|
|
|
|
if name == r.SecretName {
|
|
|
|
|
|
return os.LookupEnv(r.TokenEnv)
|
|
|
|
|
|
}
|
|
|
|
|
|
return os.LookupEnv(name)
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 ""
|
|
|
|
|
|
}
|
2026-04-16 14:56:00 +00:00
|
|
|
|
|
|
|
|
|
|
func (r Runtime) manifestDoctorCheck() cli.DoctorCheck {
|
|
|
|
|
|
return func(context.Context) cli.DoctorResult {
|
|
|
|
|
|
source := strings.TrimSpace(r.ManifestSource)
|
|
|
|
|
|
if source == "" {
|
|
|
|
|
|
return cli.DoctorResult{
|
|
|
|
|
|
Name: "manifest",
|
|
|
|
|
|
Status: cli.DoctorStatusWarn,
|
|
|
|
|
|
Summary: "manifest is missing, using built-in defaults",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return cli.DoctorResult{
|
|
|
|
|
|
Name: "manifest",
|
|
|
|
|
|
Status: cli.DoctorStatusOK,
|
|
|
|
|
|
Summary: "manifest is valid",
|
|
|
|
|
|
Detail: source,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-14 13:39:21 +00:00
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
2026-04-15 10:13:41 +00:00
|
|
|
|
checksum_required = true
|
2026-04-15 10:20:06 +00:00
|
|
|
|
signature_asset_name = "{asset}.sig"
|
|
|
|
|
|
signature_required = false
|
|
|
|
|
|
signature_public_key_env_names = ["{{.ReleasePublicKeyEnv}}"]
|
2026-04-14 13:39:21 +00:00
|
|
|
|
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
|
2026-04-15 08:13:45 +00:00
|
|
|
|
├── install.sh
|
2026-04-14 13:39:21 +00:00
|
|
|
|
├── mcp.toml
|
|
|
|
|
|
└── README.md
|
|
|
|
|
|
` + "```" + `
|
|
|
|
|
|
|
|
|
|
|
|
## Démarrage rapide
|
|
|
|
|
|
|
|
|
|
|
|
1. Installer les dépendances :
|
|
|
|
|
|
|
|
|
|
|
|
` + "```bash" + `
|
|
|
|
|
|
go mod tidy
|
|
|
|
|
|
` + "```" + `
|
|
|
|
|
|
|
|
|
|
|
|
2. Vérifier l’aide CLI bootstrap :
|
|
|
|
|
|
|
|
|
|
|
|
` + "```bash" + `
|
|
|
|
|
|
go run ./cmd/{{.BinaryName}} help
|
|
|
|
|
|
` + "```" + `
|
|
|
|
|
|
|
|
|
|
|
|
3. Initialiser la configuration locale :
|
|
|
|
|
|
|
|
|
|
|
|
` + "```bash" + `
|
|
|
|
|
|
go run ./cmd/{{.BinaryName}} setup
|
|
|
|
|
|
` + "```" + `
|
|
|
|
|
|
|
|
|
|
|
|
4. Lancer le flux MCP (placeholder) :
|
|
|
|
|
|
|
|
|
|
|
|
` + "```bash" + `
|
|
|
|
|
|
go run ./cmd/{{.BinaryName}} mcp
|
|
|
|
|
|
` + "```" + `
|
|
|
|
|
|
|
|
|
|
|
|
5. Vérifier la configuration et le manifeste :
|
|
|
|
|
|
|
|
|
|
|
|
` + "```bash" + `
|
|
|
|
|
|
go run ./cmd/{{.BinaryName}} config test
|
|
|
|
|
|
` + "```" + `
|
|
|
|
|
|
|
2026-04-15 08:13:45 +00:00
|
|
|
|
6. Publier un install wizard consommable via ` + "`curl | bash`" + ` :
|
|
|
|
|
|
|
|
|
|
|
|
` + "```bash" + `
|
|
|
|
|
|
curl -fsSL https://<forge>/<org>/<repo>/raw/branch/main/install.sh | bash
|
|
|
|
|
|
` + "```" + `
|
|
|
|
|
|
|
2026-04-15 08:33:13 +00:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-04-14 13:39:21 +00:00
|
|
|
|
## Points à adapter
|
|
|
|
|
|
|
|
|
|
|
|
- Remplacer les valeurs de ` + "`mcp.toml`" + ` (forge, repository, URL docs).
|
2026-04-15 08:13:45 +00:00
|
|
|
|
- Adapter l'URL ` + "`curl .../install.sh`" + ` à votre forge/répertoire.
|
2026-04-14 13:39:21 +00:00
|
|
|
|
- Compléter la logique métier dans ` + "`internal/app/app.go`" + ` (` + "`runMCP`" + `).
|
|
|
|
|
|
- Ajuster les variables d’environnement connues si besoin.
|
|
|
|
|
|
`
|