diff --git a/README.md b/README.md index ce2a5be..b3fb9b3 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,34 @@ pas une application MCP complète. go get gitea.lclr.dev/AI/mcp-framework ``` +## CLI de scaffold + +Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go : + +```bash +go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest +mcp-framework scaffold init \ + --target ./my-mcp \ + --module example.com/my-mcp \ + --binary my-mcp \ + --profiles dev,prod +``` + +Puis dans le projet généré : + +```bash +cd my-mcp +go mod tidy +go run ./cmd/my-mcp help +``` + ## Packages - `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. - `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 +181,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//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é diff --git a/cmd/mcp-framework/main.go b/cmd/mcp-framework/main.go new file mode 100644 index 0000000..d6f98b9 --- /dev/null +++ b/cmd/mcp-framework/main.go @@ -0,0 +1,200 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + + scaffoldpkg "gitea.lclr.dev/AI/mcp-framework/scaffold" +) + +const toolName = "mcp-framework" + +func main() { + if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func run(args []string, stdout, stderr io.Writer) error { + if stdout == nil { + stdout = io.Discard + } + if stderr == nil { + stderr = io.Discard + } + + if len(args) == 0 || isHelpArg(args[0]) { + printGlobalHelp(stdout) + return nil + } + + switch args[0] { + case "scaffold": + return runScaffold(args[1:], stdout, stderr) + default: + return fmt.Errorf("unknown command %q", args[0]) + } +} + +func runScaffold(args []string, stdout, stderr io.Writer) error { + if len(args) == 0 || isHelpArg(args[0]) { + printScaffoldHelp(stdout) + return nil + } + + switch args[0] { + case "init": + return runScaffoldInit(args[1:], stdout, stderr) + default: + return fmt.Errorf("unknown scaffold subcommand %q", args[0]) + } +} + +func runScaffoldInit(args []string, stdout, stderr io.Writer) error { + if shouldShowHelp(args) { + printScaffoldInitHelp(stdout) + return nil + } + + fs := flag.NewFlagSet("scaffold init", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + var target string + var modulePath string + var binaryName string + var description string + var docsURL string + var defaultProfile string + var profiles string + var knownEnv string + var secretStorePolicy string + var releaseDriver string + var releaseBaseURL string + var releaseRepository string + var releaseTokenEnv string + var overwrite bool + + fs.StringVar(&target, "target", "", "Répertoire cible du nouveau projet (requis)") + fs.StringVar(&modulePath, "module", "", "Chemin de module Go du projet généré") + fs.StringVar(&binaryName, "binary", "", "Nom du binaire généré") + fs.StringVar(&description, "description", "", "Description bootstrap du binaire") + fs.StringVar(&docsURL, "docs-url", "", "URL de documentation du projet") + fs.StringVar(&defaultProfile, "default-profile", "", "Profil par défaut") + fs.StringVar(&profiles, "profiles", "", "Liste CSV de profils connus") + fs.StringVar(&knownEnv, "known-env", "", "Liste CSV de variables d'environnement connues") + fs.StringVar(&secretStorePolicy, "secret-store-policy", "", "Politique secret store (auto, keyring-any, kwallet-only, env-only)") + fs.StringVar(&releaseDriver, "release-driver", "", "Driver de release (gitea, gitlab, github)") + fs.StringVar(&releaseBaseURL, "release-base-url", "", "Base URL de la forge release") + fs.StringVar(&releaseRepository, "release-repository", "", "Repository release (org/repo)") + fs.StringVar(&releaseTokenEnv, "release-token-env", "", "Nom de variable d'environnement pour le token release") + fs.BoolVar(&overwrite, "overwrite", false, "Autorise l'écrasement des fichiers existants") + + if err := fs.Parse(args); err != nil { + _ = stderr + return fmt.Errorf("parse scaffold init flags: %w", err) + } + + if fs.NArg() > 0 { + return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", ")) + } + + if strings.TrimSpace(target) == "" { + return errors.New("--target is required") + } + + result, err := scaffoldpkg.Generate(scaffoldpkg.Options{ + TargetDir: target, + ModulePath: modulePath, + BinaryName: binaryName, + Description: description, + DocsURL: docsURL, + DefaultProfile: defaultProfile, + Profiles: parseCSV(profiles), + KnownEnvironmentVariables: parseCSV(knownEnv), + SecretStorePolicy: secretStorePolicy, + ReleaseDriver: releaseDriver, + ReleaseBaseURL: releaseBaseURL, + ReleaseRepository: releaseRepository, + ReleaseTokenEnv: releaseTokenEnv, + Overwrite: overwrite, + }) + if err != nil { + return err + } + + if _, err := fmt.Fprintf(stdout, "Scaffold generated in %s\n", result.Root); err != nil { + return err + } + for _, file := range result.Files { + if _, err := fmt.Fprintf(stdout, "- %s\n", file); err != nil { + return err + } + } + + return nil +} + +func printGlobalHelp(w io.Writer) { + fmt.Fprintf( + w, + "Usage:\n %s [options]\n\nCommands:\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n", + toolName, + toolName, + ) +} + +func printScaffoldHelp(w io.Writer) { + fmt.Fprintf( + w, + "Usage:\n %s scaffold init [flags]\n\nSubcommands:\n init Génère un nouveau squelette MCP\n", + toolName, + ) +} + +func printScaffoldInitHelp(w io.Writer) { + fmt.Fprintf( + w, + "Usage:\n %s scaffold init --target [flags]\n\nFlags:\n --target Répertoire cible (requis)\n --module Module Go (ex: example.com/my-mcp)\n --binary Nom du binaire\n --description Description bootstrap\n --docs-url URL de documentation\n --default-profile Profil par défaut\n --profiles CSV des profils connus\n --known-env CSV des variables d'environnement connues\n --secret-store-policy auto|keyring-any|kwallet-only|env-only\n --release-driver gitea|gitlab|github\n --release-base-url URL de base de la forge\n --release-repository Dépôt release (org/repo)\n --release-token-env Variable token release\n --overwrite Écraser les fichiers existants\n", + toolName, + ) +} + +func shouldShowHelp(args []string) bool { + for _, arg := range args { + if isHelpArg(arg) { + return true + } + } + return false +} + +func isHelpArg(arg string) bool { + switch strings.TrimSpace(arg) { + case "-h", "--help", "help": + return true + default: + return false + } +} + +func parseCSV(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + continue + } + result = append(result, trimmed) + } + return result +} diff --git a/cmd/mcp-framework/main_test.go b/cmd/mcp-framework/main_test.go new file mode 100644 index 0000000..6f32c6f --- /dev/null +++ b/cmd/mcp-framework/main_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunPrintsGlobalHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + if err := run(nil, &stdout, &stderr); err != nil { + t.Fatalf("run returned error: %v", err) + } + + output := stdout.String() + if !strings.Contains(output, "mcp-framework ") { + t.Fatalf("global help should mention command usage: %q", output) + } + if !strings.Contains(output, "scaffold init") { + t.Fatalf("global help should mention scaffold init: %q", output) + } +} + +func TestRunScaffoldInitCreatesProject(t *testing.T) { + target := filepath.Join(t.TempDir(), "demo-mcp") + args := []string{ + "scaffold", "init", + "--target", target, + "--module", "example.com/demo-mcp", + "--binary", "demo-mcp", + "--profiles", "dev,prod", + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + if err := run(args, &stdout, &stderr); err != nil { + t.Fatalf("run returned error: %v", err) + } + + if _, err := os.Stat(filepath.Join(target, "cmd", "demo-mcp", "main.go")); err != nil { + t.Fatalf("generated main.go missing: %v", err) + } + if _, err := os.Stat(filepath.Join(target, "internal", "app", "app.go")); err != nil { + t.Fatalf("generated app.go missing: %v", err) + } + if _, err := os.Stat(filepath.Join(target, "mcp.toml")); err != nil { + t.Fatalf("generated mcp.toml missing: %v", err) + } + + if !strings.Contains(stdout.String(), "Scaffold generated in") { + t.Fatalf("stdout should include generation summary: %q", stdout.String()) + } +} + +func TestRunScaffoldInitRequiresTarget(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := run([]string{"scaffold", "init"}, &stdout, &stderr) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "--target is required") { + t.Fatalf("error = %v", err) + } +} + +func TestRunUnknownCommandReturnsError(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := run([]string{"boom"}, &stdout, &stderr) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "unknown command") { + t.Fatalf("error = %v", err) + } +} + +func TestScaffoldInitHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + if err := run([]string{"scaffold", "init", "--help"}, &stdout, &stderr); err != nil { + t.Fatalf("run returned error: %v", err) + } + + output := stdout.String() + if !strings.Contains(output, "--target") { + t.Fatalf("init help should mention --target: %q", output) + } + if !strings.Contains(output, "--overwrite") { + t.Fatalf("init help should mention --overwrite: %q", output) + } +} diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go new file mode 100644 index 0000000..06b5da4 --- /dev/null +++ b/scaffold/scaffold.go @@ -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 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. +` diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go new file mode 100644 index 0000000..3e65462 --- /dev/null +++ b/scaffold/scaffold_test.go @@ -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) + } +}