From d42a790bc07c414baf6384c5e4ca1a1598e335cd Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 15:59:18 +0200 Subject: [PATCH] feat(cli): add scaffold init command --- README.md | 21 ++++ cmd/mcp-framework/main.go | 200 +++++++++++++++++++++++++++++++++ cmd/mcp-framework/main_test.go | 101 +++++++++++++++++ 3 files changed, 322 insertions(+) create mode 100644 cmd/mcp-framework/main.go create mode 100644 cmd/mcp-framework/main_test.go diff --git a/README.md b/README.md index 72270a8..b3fb9b3 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,27 @@ 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. 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) + } +}