From 6badc79d4dab0c0108e072b4deefdc36e79afcfa Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 08:17:58 +0200 Subject: [PATCH 1/2] docs(agents): enforce enhancement branch naming format --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1caefab..9cceb6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,8 @@ Nommer la branche de manière explicite, par exemple : - `issue-8-update-drivers` - `docs-readme-installation` +Quand une branche est créée pour répondre à une issue et que cette issue porte le label `enhancement`, nommer la branche au format `feat/`. + Éviter de développer directement sur `main` quand le changement mérite une PR ou une validation fonctionnelle. ## Pull Requests From c70b3ac12b8d507a9231ea6bb292d5927133f450 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 08:24:24 +0200 Subject: [PATCH 2/2] feat: add optional CLI bootstrap package --- README.md | 52 ++++++-- bootstrap/bootstrap.go | 236 ++++++++++++++++++++++++++++++++++++ bootstrap/bootstrap_test.go | 182 +++++++++++++++++++++++++++ 3 files changed, 463 insertions(+), 7 deletions(-) create mode 100644 bootstrap/bootstrap.go create mode 100644 bootstrap/bootstrap_test.go diff --git a/README.md b/README.md index e8bcc12..3ffcb74 100644 --- a/README.md +++ b/README.md @@ -19,6 +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. - `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`. @@ -29,12 +30,50 @@ go get gitea.lclr.dev/AI/mcp-framework Le flux typique côté application est : -1. Résoudre le profil actif avec `cli`. -2. Charger la config versionnée avec `config`. -3. Lire les secrets avec `secretstore`. -4. Charger `mcp.toml` avec `manifest`. -5. Exécuter l'auto-update avec `update` si nécessaire. -6. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. +1. Déclarer les sous-commandes communes via `bootstrap` (optionnel). +2. Résoudre le profil actif avec `cli`. +3. Charger la config versionnée avec `config`. +4. Lire les secrets avec `secretstore`. +5. Charger `mcp.toml` avec `manifest`. +6. Exécuter l'auto-update avec `update` si nécessaire. +7. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. + +## Bootstrap CLI + +Le package `bootstrap` reste optionnel : une application peut l'utiliser pour +uniformiser le parsing CLI et l'aide, sans imposer de runtime monolithique. + +Exemple minimal : + +```go +func main() { + err := bootstrap.Run(context.Background(), bootstrap.Options{ + BinaryName: "my-mcp", + Description: "Client MCP", + Version: version, + Hooks: bootstrap.Hooks{ + Setup: func(ctx context.Context, inv bootstrap.Invocation) error { + return runSetup(ctx, inv.Args) + }, + 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) + }, + Update: func(ctx context.Context, inv bootstrap.Invocation) error { + return runUpdate(ctx, inv.Args) + }, + }, + }) + if err != nil { + log.Fatal(err) + } +} +``` + +Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche +automatiquement `Options.Version`. ## Manifeste `mcp.toml` @@ -371,5 +410,4 @@ func run(ctx context.Context, flagProfile string) error { ## Limites Actuelles - le manifeste gère uniquement la section `[update]` -- le framework ne fournit pas encore d'interface unique de bootstrap - l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go new file mode 100644 index 0000000..7e4854d --- /dev/null +++ b/bootstrap/bootstrap.go @@ -0,0 +1,236 @@ +package bootstrap + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" +) + +const ( + CommandSetup = "setup" + CommandMCP = "mcp" + CommandConfig = "config" + CommandUpdate = "update" + CommandVersion = "version" +) + +var ( + ErrBinaryNameRequired = errors.New("binary name is required") + ErrUnknownCommand = errors.New("unknown command") + ErrCommandNotConfigured = errors.New("command not configured") + ErrVersionRequired = errors.New("version is required when no version hook is configured") +) + +type Handler func(context.Context, Invocation) error + +type Hooks struct { + Setup Handler + MCP Handler + Config Handler + Update Handler + Version Handler +} + +type Options struct { + BinaryName string + Description string + Version string + Args []string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Hooks Hooks +} + +type Invocation struct { + Command string + Args []string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +type commandDef struct { + Name string + Description string + Handler func(Hooks) Handler +} + +var commands = []commandDef{ + { + Name: CommandSetup, + Description: "Initialiser ou mettre a jour la configuration locale.", + Handler: func(h Hooks) Handler { + return h.Setup + }, + }, + { + Name: CommandMCP, + Description: "Executer la logique MCP principale du binaire.", + Handler: func(h Hooks) Handler { + return h.MCP + }, + }, + { + Name: CommandConfig, + Description: "Inspecter ou modifier la configuration.", + Handler: func(h Hooks) Handler { + return h.Config + }, + }, + { + Name: CommandUpdate, + Description: "Declencher le flux d'auto-update.", + Handler: func(h Hooks) Handler { + return h.Update + }, + }, + { + Name: CommandVersion, + Description: "Afficher la version du binaire.", + Handler: func(h Hooks) Handler { + return h.Version + }, + }, +} + +func Run(ctx context.Context, opts Options) error { + normalized := normalize(opts) + + if strings.TrimSpace(normalized.BinaryName) == "" { + return ErrBinaryNameRequired + } + + command, commandArgs, showHelp := parseArgs(normalized.Args) + if showHelp { + return printHelp(normalized, command) + } + + if command == "" { + return printHelp(normalized, "") + } + + handler, known := resolveHandler(command, normalized.Hooks) + if !known { + return fmt.Errorf("%w: %s", ErrUnknownCommand, command) + } + + if handler == nil { + if command == CommandVersion { + if strings.TrimSpace(normalized.Version) == "" { + return ErrVersionRequired + } + _, err := fmt.Fprintln(normalized.Stdout, normalized.Version) + return err + } + return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command) + } + + return handler(ctx, Invocation{ + Command: command, + Args: commandArgs, + Stdin: normalized.Stdin, + Stdout: normalized.Stdout, + Stderr: normalized.Stderr, + }) +} + +func normalize(opts Options) Options { + if opts.Stdin == nil { + opts.Stdin = os.Stdin + } + if opts.Stdout == nil { + opts.Stdout = os.Stdout + } + if opts.Stderr == nil { + opts.Stderr = os.Stderr + } + if opts.Args == nil { + opts.Args = os.Args[1:] + } + return opts +} + +func parseArgs(args []string) (command string, commandArgs []string, showHelp bool) { + if len(args) == 0 { + return "", nil, false + } + + first := strings.TrimSpace(args[0]) + switch first { + case "help", "-h", "--help": + if len(args) > 1 { + return strings.TrimSpace(args[1]), nil, 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 + } + } + + return command, commandArgs, false +} + +func resolveHandler(command string, hooks Hooks) (Handler, bool) { + for _, def := range commands { + if def.Name == command { + return def.Handler(hooks), true + } + } + return nil, false +} + +func printHelp(opts Options, command string) error { + if command == "" { + return printGlobalHelp(opts) + } + + for _, def := range commands { + if def.Name != command { + continue + } + _, err := fmt.Fprintf( + opts.Stdout, + "Usage:\n %s %s [args]\n\n%s\n", + opts.BinaryName, + def.Name, + def.Description, + ) + return err + } + + return fmt.Errorf("%w: %s", ErrUnknownCommand, command) +} + +func printGlobalHelp(opts Options) error { + if strings.TrimSpace(opts.Description) != "" { + if _, err := fmt.Fprintf(opts.Stdout, "%s\n\n", opts.Description); err != nil { + return err + } + } + + if _, err := fmt.Fprintf(opts.Stdout, "Usage:\n %s [args]\n\n", opts.BinaryName); err != nil { + return err + } + if _, err := fmt.Fprintln(opts.Stdout, "Commandes communes:"); err != nil { + return err + } + + for _, def := range commands { + if _, err := fmt.Fprintf(opts.Stdout, " %-7s %s\n", def.Name, def.Description); err != nil { + return err + } + } + + _, err := fmt.Fprintf(opts.Stdout, "\nAide detaillee: %s help \n", opts.BinaryName) + return err +} diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go new file mode 100644 index 0000000..b0c6b4d --- /dev/null +++ b/bootstrap/bootstrap_test.go @@ -0,0 +1,182 @@ +package bootstrap + +import ( + "bytes" + "context" + "errors" + "slices" + "strings" + "testing" +) + +func TestRunRoutesSetupHook(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + var got Invocation + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Version: "v1.2.3", + Args: []string{"setup", "--profile", "prod"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + Setup: func(_ context.Context, inv Invocation) error { + got = inv + return nil + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + if got.Command != CommandSetup { + t.Fatalf("invocation command = %q, want %q", got.Command, CommandSetup) + } + wantArgs := []string{"--profile", "prod"} + if !slices.Equal(got.Args, wantArgs) { + t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) + } +} + +func TestRunReturnsUnknownCommand(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"boom"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if !errors.Is(err, ErrUnknownCommand) { + t.Fatalf("Run error = %v, want ErrUnknownCommand", err) + } +} + +func TestRunReturnsCommandNotConfigured(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"config"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if !errors.Is(err, ErrCommandNotConfigured) { + t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err) + } +} + +func TestRunPrintsVersionByDefault(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Version: "v1.2.3", + Args: []string{"version"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + if stdout.String() != "v1.2.3\n" { + t.Fatalf("stdout = %q, want %q", stdout.String(), "v1.2.3\n") + } +} + +func TestRunVersionHookOverridesDefault(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"version"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + Version: func(_ context.Context, inv Invocation) error { + _, err := inv.Stdout.Write([]byte("custom-version\n")) + return err + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + if stdout.String() != "custom-version\n" { + t.Fatalf("stdout = %q, want %q", stdout.String(), "custom-version\n") + } +} + +func TestRunRequiresVersionWithoutVersionHook(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"version"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if !errors.Is(err, ErrVersionRequired) { + t.Fatalf("Run error = %v, want ErrVersionRequired", err) + } +} + +func TestRunPrintsGlobalHelp(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{"help"}, + 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 + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"help", "update"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + if !strings.Contains(text, "my-mcp update [args]") { + t.Fatalf("command help output = %q", text) + } + if !strings.Contains(text, "auto-update") { + t.Fatalf("command help output missing update description: %q", text) + } +}