From 89246d1581a13477f5b9fd105a92f16bebb84e89 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 10:52:36 +0200 Subject: [PATCH] feat(bootstrap): standardize config show/test command structure --- README.md | 12 ++- bootstrap/bootstrap.go | 164 +++++++++++++++++++++++++++++++++--- bootstrap/bootstrap_test.go | 140 +++++++++++++++++++++++++++++- 3 files changed, 298 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index a935a50..501a9d2 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ go get gitea.lclr.dev/AI/mcp-framework ## Packages -- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config`, `update`, `version`) et hooks métier explicites. +- `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 et conversion vers `update.ReleaseSource`. @@ -58,8 +58,11 @@ func main() { MCP: func(ctx context.Context, inv bootstrap.Invocation) error { return runMCP(ctx, inv.Args) }, - Config: func(ctx context.Context, inv bootstrap.Invocation) error { - return runConfig(ctx, inv.Args) + ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error { + return runConfigShow(ctx, inv.Args) + }, + ConfigTest: func(ctx context.Context, inv bootstrap.Invocation) error { + return runConfigTest(ctx, inv.Args) }, Update: func(ctx context.Context, inv bootstrap.Invocation) error { return runUpdate(ctx, inv.Args) @@ -75,6 +78,9 @@ func main() { Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automatiquement `Options.Version`. +Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`). +La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`). + ## Manifeste `mcp.toml` Le package `manifest` cherche automatiquement `mcp.toml` dans le répertoire diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 7e4854d..7108942 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -15,23 +15,32 @@ const ( CommandConfig = "config" CommandUpdate = "update" CommandVersion = "version" + + ConfigSubcommandShow = "show" + ConfigSubcommandTest = "test" + ConfigSubcommandDelete = "delete" ) var ( ErrBinaryNameRequired = errors.New("binary name is required") ErrUnknownCommand = errors.New("unknown command") + ErrUnknownSubcommand = errors.New("unknown subcommand") ErrCommandNotConfigured = errors.New("command not configured") ErrVersionRequired = errors.New("version is required when no version hook is configured") + ErrSubcommandRequired = errors.New("subcommand is required") ) type Handler func(context.Context, Invocation) error type Hooks struct { - Setup Handler - MCP Handler - Config Handler - Update Handler - Version Handler + Setup Handler + MCP Handler + Config Handler + ConfigShow Handler + ConfigTest Handler + ConfigDelete Handler + Update Handler + Version Handler } type Options struct { @@ -106,13 +115,17 @@ func Run(ctx context.Context, opts Options) error { command, commandArgs, showHelp := parseArgs(normalized.Args) if showHelp { - return printHelp(normalized, command) + return printHelp(normalized, command, commandArgs) } if command == "" { return printHelp(normalized, "") } + if command == CommandConfig { + return runConfigCommand(ctx, normalized, commandArgs) + } + handler, known := resolveHandler(command, normalized.Hooks) if !known { return fmt.Errorf("%w: %s", ErrUnknownCommand, command) @@ -163,23 +176,96 @@ func parseArgs(args []string) (command string, commandArgs []string, showHelp bo switch first { case "help", "-h", "--help": if len(args) > 1 { - return strings.TrimSpace(args[1]), nil, true + return strings.TrimSpace(args[1]), trimArgs(args[2:]), true } return "", nil, true } command = first - commandArgs = args[1:] - if len(commandArgs) == 1 { - helpArg := strings.TrimSpace(commandArgs[0]) - if helpArg == "-h" || helpArg == "--help" { - return command, nil, true + commandArgs = trimArgs(args[1:]) + if len(commandArgs) > 0 { + last := strings.TrimSpace(commandArgs[len(commandArgs)-1]) + if last == "-h" || last == "--help" { + return command, commandArgs[:len(commandArgs)-1], true } } return command, commandArgs, false } +func trimArgs(args []string) []string { + if len(args) == 0 { + return nil + } + + result := make([]string, 0, len(args)) + for _, arg := range args { + trimmed := strings.TrimSpace(arg) + if trimmed == "" { + continue + } + result = append(result, trimmed) + } + return result +} + +func runConfigCommand(ctx context.Context, opts Options, args []string) error { + if len(args) == 0 { + return fmt.Errorf("%w: %s requires one of: %s, %s", ErrSubcommandRequired, CommandConfig, ConfigSubcommandShow, ConfigSubcommandTest) + } + + subcommand := strings.TrimSpace(args[0]) + subcommandArgs := args[1:] + + handler, known := resolveConfigHandler(opts.Hooks, subcommand) + if !known { + if opts.Hooks.Config != nil { + return opts.Hooks.Config(ctx, Invocation{ + Command: CommandConfig, + Args: args, + Stdin: opts.Stdin, + Stdout: opts.Stdout, + Stderr: opts.Stderr, + }) + } + return fmt.Errorf("%w: %s %s", ErrUnknownSubcommand, CommandConfig, subcommand) + } + + if handler == nil { + if opts.Hooks.Config != nil { + return opts.Hooks.Config(ctx, Invocation{ + Command: fmt.Sprintf("%s %s", CommandConfig, subcommand), + Args: subcommandArgs, + Stdin: opts.Stdin, + Stdout: opts.Stdout, + Stderr: opts.Stderr, + }) + } + return fmt.Errorf("%w: %s %s", ErrCommandNotConfigured, CommandConfig, subcommand) + } + + return handler(ctx, Invocation{ + Command: fmt.Sprintf("%s %s", CommandConfig, subcommand), + Args: subcommandArgs, + Stdin: opts.Stdin, + Stdout: opts.Stdout, + Stderr: opts.Stderr, + }) +} + +func resolveConfigHandler(hooks Hooks, subcommand string) (Handler, bool) { + switch subcommand { + case ConfigSubcommandShow: + return hooks.ConfigShow, true + case ConfigSubcommandTest: + return hooks.ConfigTest, true + case ConfigSubcommandDelete: + return hooks.ConfigDelete, true + default: + return nil, false + } +} + func resolveHandler(command string, hooks Hooks) (Handler, bool) { for _, def := range commands { if def.Name == command { @@ -189,11 +275,20 @@ func resolveHandler(command string, hooks Hooks) (Handler, bool) { return nil, false } -func printHelp(opts Options, command string) error { +func printHelp(opts Options, command string, args ...[]string) error { + var commandArgs []string + if len(args) > 0 { + commandArgs = args[0] + } + if command == "" { return printGlobalHelp(opts) } + if command == CommandConfig { + return printConfigHelp(opts, commandArgs) + } + for _, def := range commands { if def.Name != command { continue @@ -211,6 +306,49 @@ func printHelp(opts Options, command string) error { return fmt.Errorf("%w: %s", ErrUnknownCommand, command) } +func printConfigHelp(opts Options, args []string) error { + if len(args) == 0 { + _, err := fmt.Fprintf( + opts.Stdout, + "Usage:\n %s config [args]\n\nSubcommandes:\n %-7s Afficher la configuration résolue et la provenance des valeurs.\n %-7s Vérifier la configuration et la connectivité.\n %-7s Supprimer un profil local (optionnel).\n", + opts.BinaryName, + ConfigSubcommandShow, + ConfigSubcommandTest, + ConfigSubcommandDelete, + ) + return err + } + + switch args[0] { + case ConfigSubcommandShow: + _, err := fmt.Fprintf( + opts.Stdout, + "Usage:\n %s config %s [args]\n\nAfficher la configuration résolue et l'origine des valeurs.\n", + opts.BinaryName, + ConfigSubcommandShow, + ) + return err + case ConfigSubcommandTest: + _, err := fmt.Fprintf( + opts.Stdout, + "Usage:\n %s config %s [args]\n\nTester la configuration résolue et la connectivité associée.\n", + opts.BinaryName, + ConfigSubcommandTest, + ) + return err + case ConfigSubcommandDelete: + _, err := fmt.Fprintf( + opts.Stdout, + "Usage:\n %s config %s [args]\n\nSupprimer un profil local de configuration.\n", + opts.BinaryName, + ConfigSubcommandDelete, + ) + return err + default: + return fmt.Errorf("%w: %s %s", ErrUnknownSubcommand, CommandConfig, args[0]) + } +} + func printGlobalHelp(opts Options) error { if strings.TrimSpace(opts.Description) != "" { if _, err := fmt.Fprintf(opts.Stdout, "%s\n\n", opts.Description); err != nil { diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index b0c6b4d..29031ab 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -65,8 +65,8 @@ func TestRunReturnsCommandNotConfigured(t *testing.T) { Stdout: &stdout, Stderr: &stderr, }) - if !errors.Is(err, ErrCommandNotConfigured) { - t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err) + if !errors.Is(err, ErrSubcommandRequired) { + t.Fatalf("Run error = %v, want ErrSubcommandRequired", err) } } @@ -158,6 +158,36 @@ func TestRunPrintsGlobalHelp(t *testing.T) { } } +func TestRunPrintsGlobalHelpWhenNoArgs(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Description: "Binaire MCP de test.", + Args: []string{}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + for _, snippet := range []string{ + "Usage:", + "setup", + "mcp", + "config", + "update", + "version", + } { + if !strings.Contains(text, snippet) { + t.Fatalf("help output missing %q: %s", snippet, text) + } + } +} + func TestRunPrintsCommandHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -180,3 +210,109 @@ func TestRunPrintsCommandHelp(t *testing.T) { t.Fatalf("command help output missing update description: %q", text) } } + +func TestRunPrintsConfigHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"help", "config"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + for _, snippet := range []string{ + "my-mcp config ", + "show", + "test", + } { + if !strings.Contains(text, snippet) { + t.Fatalf("config help output missing %q: %q", snippet, text) + } + } +} + +func TestRunPrintsConfigSubcommandHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"help", "config", "show"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + if !strings.Contains(text, "my-mcp config show [args]") { + t.Fatalf("config subcommand help output = %q", text) + } +} + +func TestRunRoutesConfigShowHook(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + var got Invocation + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"config", "show", "--profile", "prod"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + ConfigShow: func(_ context.Context, inv Invocation) error { + got = inv + return nil + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + if got.Command != "config show" { + t.Fatalf("invocation command = %q, want %q", got.Command, "config show") + } + wantArgs := []string{"--profile", "prod"} + if !slices.Equal(got.Args, wantArgs) { + t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) + } +} + +func TestRunConfigShowReturnsCommandNotConfigured(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"config", "show"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if !errors.Is(err, ErrCommandNotConfigured) { + t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err) + } +} + +func TestRunConfigReturnsUnknownSubcommand(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"config", "sync"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if !errors.Is(err, ErrUnknownSubcommand) { + t.Fatalf("Run error = %v, want ErrUnknownSubcommand", err) + } +}