feat(scaffold): add MCP binary scaffold generator

This commit is contained in:
thibaud-lclr 2026-04-14 15:39:21 +02:00
parent 46f48cb1f6
commit c4c461105f
3 changed files with 948 additions and 0 deletions

View file

@ -23,6 +23,7 @@ go get gitea.lclr.dev/AI/mcp-framework
- `cli` : helpers pour résoudre un profil, valider une URL, demander des valeurs à l'utilisateur et exécuter un `doctor`.
- `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`.
- `manifest` : lecture de `mcp.toml` à la racine du projet, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding.
- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, wiring de base et README de démarrage).
- `secretstore` : lecture/écriture de secrets dans le wallet natif.
- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release.
@ -159,6 +160,32 @@ _ = bootstrapInfo
_ = scaffoldInfo
```
## Scaffolding
Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP :
- arborescence recommandée (`cmd/<binary>/main.go`, `internal/app/app.go`, `mcp.toml`)
- wiring initial `bootstrap + config + secretstore + update`
- `README.md` de démarrage
Exemple :
```go
result, err := scaffold.Generate(scaffold.Options{
TargetDir: "./my-mcp",
ModulePath: "gitea.lclr.dev/AI/my-mcp",
BinaryName: "my-mcp",
Description: "Client MCP interne",
DefaultProfile: "prod",
Profiles: []string{"dev", "prod"},
})
if err != nil {
return err
}
fmt.Printf("Scaffold generated in %s (%d files)\n", result.Root, len(result.Files))
```
## Config JSON
Le package `config` stocke une structure générique par profil dans un JSON privé

738
scaffold/scaffold.go Normal file
View file

@ -0,0 +1,738 @@
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
SecretStorePolicy 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",
SecretStorePolicy: firstNonEmpty(scaffoldInfo.SecretStorePolicy, "{{.SecretStorePolicy}}"),
}, 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.Open(secretstore.Options{
ServiceName: r.BinaryName,
BackendPolicy: secretstore.BackendPolicy(r.SecretStorePolicy),
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 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
` + "```" + `
## 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 denvironnement connues si besoin.
`

183
scaffold/scaffold_test.go Normal file
View file

@ -0,0 +1,183 @@
package scaffold
import (
"errors"
"go/parser"
"go/token"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
target := filepath.Join(t.TempDir(), "my-mcp")
result, err := Generate(Options{
TargetDir: target,
ModulePath: "example.com/acme/my-mcp",
BinaryName: "my-mcp",
Description: "Client MCP interne",
DefaultProfile: "prod",
Profiles: []string{"dev", "prod"},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if result.Root != target {
t.Fatalf("result root = %q, want %q", result.Root, target)
}
wantFiles := []string{
".gitignore",
"README.md",
"cmd/my-mcp/main.go",
"go.mod",
"internal/app/app.go",
"mcp.toml",
}
if !slices.Equal(result.Files, wantFiles) {
t.Fatalf("result files = %v, want %v", result.Files, wantFiles)
}
for _, path := range wantFiles {
if _, err := os.Stat(filepath.Join(target, filepath.FromSlash(path))); err != nil {
t.Fatalf("generated file %q missing: %v", path, err)
}
}
mainGo, err := os.ReadFile(filepath.Join(target, "cmd", "my-mcp", "main.go"))
if err != nil {
t.Fatalf("ReadFile main.go: %v", err)
}
if !strings.Contains(string(mainGo), "\"example.com/acme/my-mcp/internal/app\"") {
t.Fatalf("main.go does not import internal app package")
}
if _, err := parser.ParseFile(token.NewFileSet(), "main.go", mainGo, parser.AllErrors); err != nil {
t.Fatalf("generated main.go is invalid Go: %v", err)
}
appGo, err := os.ReadFile(filepath.Join(target, "internal", "app", "app.go"))
if err != nil {
t.Fatalf("ReadFile app.go: %v", err)
}
for _, snippet := range []string{
"config.NewStore[Profile]",
"secretstore.Open",
"update.Run",
"manifest.LoadDefault",
"bootstrap.Run",
} {
if !strings.Contains(string(appGo), snippet) {
t.Fatalf("app.go missing snippet %q", snippet)
}
}
if _, err := parser.ParseFile(token.NewFileSet(), "app.go", appGo, parser.AllErrors); err != nil {
t.Fatalf("generated app.go is invalid Go: %v", err)
}
manifestContent, err := os.ReadFile(filepath.Join(target, "mcp.toml"))
if err != nil {
t.Fatalf("ReadFile mcp.toml: %v", err)
}
for _, snippet := range []string{
"binary_name = \"my-mcp\"",
"[update]",
"[secret_store]",
"[environment]",
"[profiles]",
"backend_policy = \"auto\"",
} {
if !strings.Contains(string(manifestContent), snippet) {
t.Fatalf("mcp.toml missing snippet %q", snippet)
}
}
readme, err := os.ReadFile(filepath.Join(target, "README.md"))
if err != nil {
t.Fatalf("ReadFile README.md: %v", err)
}
for _, snippet := range []string{
"Arborescence générée",
"go run ./cmd/my-mcp setup",
"internal/app/app.go",
} {
if !strings.Contains(string(readme), snippet) {
t.Fatalf("README missing snippet %q", snippet)
}
}
}
func TestGenerateUsesDefaultsFromTargetDirectory(t *testing.T) {
target := filepath.Join(t.TempDir(), "super-agent-mcp")
_, err := Generate(Options{TargetDir: target})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
goModContent, err := os.ReadFile(filepath.Join(target, "go.mod"))
if err != nil {
t.Fatalf("ReadFile go.mod: %v", err)
}
if !strings.Contains(string(goModContent), "module example.com/super-agent-mcp") {
t.Fatalf("go.mod should contain default module path")
}
manifestContent, err := os.ReadFile(filepath.Join(target, "mcp.toml"))
if err != nil {
t.Fatalf("ReadFile mcp.toml: %v", err)
}
for _, snippet := range []string{
"binary_name = \"super-agent-mcp\"",
"SUPER_AGENT_MCP_PROFILE",
"SUPER_AGENT_MCP_API_TOKEN",
} {
if !strings.Contains(string(manifestContent), snippet) {
t.Fatalf("mcp.toml missing snippet %q", snippet)
}
}
}
func TestGenerateFailsWhenFileAlreadyExistsWithoutOverwrite(t *testing.T) {
target := t.TempDir()
readmePath := filepath.Join(target, "README.md")
if err := os.WriteFile(readmePath, []byte("pre-existing"), 0o644); err != nil {
t.Fatalf("WriteFile README.md: %v", err)
}
_, err := Generate(Options{TargetDir: target})
if !errors.Is(err, ErrFileExists) {
t.Fatalf("Generate error = %v, want ErrFileExists", err)
}
}
func TestGenerateOverwritesExistingFilesWhenRequested(t *testing.T) {
target := t.TempDir()
readmePath := filepath.Join(target, "README.md")
if err := os.WriteFile(readmePath, []byte("pre-existing"), 0o644); err != nil {
t.Fatalf("WriteFile README.md: %v", err)
}
_, err := Generate(Options{TargetDir: target, Overwrite: true})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
readmeContent, err := os.ReadFile(readmePath)
if err != nil {
t.Fatalf("ReadFile README.md: %v", err)
}
if !strings.Contains(string(readmeContent), "Démarrage rapide") {
t.Fatalf("README should be overwritten with scaffold content")
}
}
func TestGenerateRequiresTargetDirectory(t *testing.T) {
_, err := Generate(Options{})
if !errors.Is(err, ErrTargetDirRequired) {
t.Fatalf("Generate error = %v, want ErrTargetDirRequired", err)
}
}