feat(cli): add scaffold init command

This commit is contained in:
thibaud-lclr 2026-04-14 15:59:18 +02:00
parent c4c461105f
commit d42a790bc0
3 changed files with 322 additions and 0 deletions

View file

@ -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.

200
cmd/mcp-framework/main.go Normal file
View file

@ -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 <command> [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 <dir> [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
}

View file

@ -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 <command>") {
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)
}
}