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 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 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", Content: renderTemplate(gitignoreTemplate, normalized)}, {Path: "go.mod", Content: renderTemplate(goModTemplate, normalized)}, {Path: "README.md", Content: renderTemplate(readmeTemplate, normalized)}, {Path: "mcp.toml", Content: renderTemplate(manifestTemplate, normalized)}, {Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Content: renderTemplate(mainTemplate, normalized)}, {Path: filepath.Join("internal", "app", "app.go"), Content: renderTemplate(appTemplate, normalized)}, } written := make([]string, 0, len(files)) for _, file := range files { fullPath := filepath.Join(normalized.TargetDir, file.Path) if err := writeFile(fullPath, file.Content, 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 Content string } func writeFile(path, content string, 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 err := os.WriteFile(path, []byte(content), 0o644); err != nil { return fmt.Errorf("write scaffold file %q: %w", path, err) } return nil } func renderTemplate(src string, data normalizedOptions) string { tpl := template.Must(template.New("scaffold").Parse(src)) var builder strings.Builder if err := tpl.Execute(&builder, data); err != nil { panic(err) } return builder.String() } 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" } 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, 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 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" "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" ) type Profile struct { BaseURL string } type Runtime struct { ConfigStore config.Store[Profile] Manifest manifest.File 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) { manifestFile, _, err := manifest.LoadDefault(".") if err != nil { return Runtime{}, err } 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, 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, 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, ManifestDir: ".", }) 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) { return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{ ServiceName: r.BinaryName, 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 "" } ` 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 = false 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 ├── 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 ` + "```" + ` ## Points à adapter - Remplacer les valeurs de ` + "`mcp.toml`" + ` (forge, repository, URL docs). - Compléter la logique métier dans ` + "`internal/app/app.go`" + ` (` + "`runMCP`" + `). - Ajuster les variables d’environnement connues si besoin. `