Merge pull request 'feat: ajouter une couche bootstrap CLI optionnelle' (#15) from feat/bootstrap-mcp-binary into release/v1.2
Reviewed-on: https://gitea.lclr.dev/AI/mcp-framework/pulls/15
This commit is contained in:
commit
c6c38d6019
4 changed files with 465 additions and 7 deletions
|
|
@ -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/<issue_short_name>`.
|
||||
|
||||
Éviter de développer directement sur `main` quand le changement mérite une PR ou une validation fonctionnelle.
|
||||
|
||||
## Pull Requests
|
||||
|
|
|
|||
52
README.md
52
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
|
||||
|
|
|
|||
236
bootstrap/bootstrap.go
Normal file
236
bootstrap/bootstrap.go
Normal file
|
|
@ -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 <command> [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 <command>\n", opts.BinaryName)
|
||||
return err
|
||||
}
|
||||
182
bootstrap/bootstrap_test.go
Normal file
182
bootstrap/bootstrap_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue