feat(bootstrap): standardize config show/test command structure
This commit is contained in:
parent
1c40546f3f
commit
89246d1581
3 changed files with 298 additions and 18 deletions
12
README.md
12
README.md
|
|
@ -19,7 +19,7 @@ go get gitea.lclr.dev/AI/mcp-framework
|
||||||
|
|
||||||
## Packages
|
## 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`.
|
- `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()`.
|
- `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`.
|
- `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 {
|
MCP: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||||
return runMCP(ctx, inv.Args)
|
return runMCP(ctx, inv.Args)
|
||||||
},
|
},
|
||||||
Config: func(ctx context.Context, inv bootstrap.Invocation) error {
|
ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||||
return runConfig(ctx, inv.Args)
|
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 {
|
Update: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||||
return runUpdate(ctx, inv.Args)
|
return runUpdate(ctx, inv.Args)
|
||||||
|
|
@ -75,6 +78,9 @@ func main() {
|
||||||
Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche
|
Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche
|
||||||
automatiquement `Options.Version`.
|
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`
|
## Manifeste `mcp.toml`
|
||||||
|
|
||||||
Le package `manifest` cherche automatiquement `mcp.toml` dans le répertoire
|
Le package `manifest` cherche automatiquement `mcp.toml` dans le répertoire
|
||||||
|
|
|
||||||
|
|
@ -15,23 +15,32 @@ const (
|
||||||
CommandConfig = "config"
|
CommandConfig = "config"
|
||||||
CommandUpdate = "update"
|
CommandUpdate = "update"
|
||||||
CommandVersion = "version"
|
CommandVersion = "version"
|
||||||
|
|
||||||
|
ConfigSubcommandShow = "show"
|
||||||
|
ConfigSubcommandTest = "test"
|
||||||
|
ConfigSubcommandDelete = "delete"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrBinaryNameRequired = errors.New("binary name is required")
|
ErrBinaryNameRequired = errors.New("binary name is required")
|
||||||
ErrUnknownCommand = errors.New("unknown command")
|
ErrUnknownCommand = errors.New("unknown command")
|
||||||
|
ErrUnknownSubcommand = errors.New("unknown subcommand")
|
||||||
ErrCommandNotConfigured = errors.New("command not configured")
|
ErrCommandNotConfigured = errors.New("command not configured")
|
||||||
ErrVersionRequired = errors.New("version is required when no version hook is 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 Handler func(context.Context, Invocation) error
|
||||||
|
|
||||||
type Hooks struct {
|
type Hooks struct {
|
||||||
Setup Handler
|
Setup Handler
|
||||||
MCP Handler
|
MCP Handler
|
||||||
Config Handler
|
Config Handler
|
||||||
Update Handler
|
ConfigShow Handler
|
||||||
Version Handler
|
ConfigTest Handler
|
||||||
|
ConfigDelete Handler
|
||||||
|
Update Handler
|
||||||
|
Version Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
|
|
@ -106,13 +115,17 @@ func Run(ctx context.Context, opts Options) error {
|
||||||
|
|
||||||
command, commandArgs, showHelp := parseArgs(normalized.Args)
|
command, commandArgs, showHelp := parseArgs(normalized.Args)
|
||||||
if showHelp {
|
if showHelp {
|
||||||
return printHelp(normalized, command)
|
return printHelp(normalized, command, commandArgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if command == "" {
|
if command == "" {
|
||||||
return printHelp(normalized, "")
|
return printHelp(normalized, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if command == CommandConfig {
|
||||||
|
return runConfigCommand(ctx, normalized, commandArgs)
|
||||||
|
}
|
||||||
|
|
||||||
handler, known := resolveHandler(command, normalized.Hooks)
|
handler, known := resolveHandler(command, normalized.Hooks)
|
||||||
if !known {
|
if !known {
|
||||||
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
|
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
|
||||||
|
|
@ -163,23 +176,96 @@ func parseArgs(args []string) (command string, commandArgs []string, showHelp bo
|
||||||
switch first {
|
switch first {
|
||||||
case "help", "-h", "--help":
|
case "help", "-h", "--help":
|
||||||
if len(args) > 1 {
|
if len(args) > 1 {
|
||||||
return strings.TrimSpace(args[1]), nil, true
|
return strings.TrimSpace(args[1]), trimArgs(args[2:]), true
|
||||||
}
|
}
|
||||||
return "", nil, true
|
return "", nil, true
|
||||||
}
|
}
|
||||||
|
|
||||||
command = first
|
command = first
|
||||||
commandArgs = args[1:]
|
commandArgs = trimArgs(args[1:])
|
||||||
if len(commandArgs) == 1 {
|
if len(commandArgs) > 0 {
|
||||||
helpArg := strings.TrimSpace(commandArgs[0])
|
last := strings.TrimSpace(commandArgs[len(commandArgs)-1])
|
||||||
if helpArg == "-h" || helpArg == "--help" {
|
if last == "-h" || last == "--help" {
|
||||||
return command, nil, true
|
return command, commandArgs[:len(commandArgs)-1], true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return command, commandArgs, false
|
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) {
|
func resolveHandler(command string, hooks Hooks) (Handler, bool) {
|
||||||
for _, def := range commands {
|
for _, def := range commands {
|
||||||
if def.Name == command {
|
if def.Name == command {
|
||||||
|
|
@ -189,11 +275,20 @@ func resolveHandler(command string, hooks Hooks) (Handler, bool) {
|
||||||
return nil, false
|
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 == "" {
|
if command == "" {
|
||||||
return printGlobalHelp(opts)
|
return printGlobalHelp(opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if command == CommandConfig {
|
||||||
|
return printConfigHelp(opts, commandArgs)
|
||||||
|
}
|
||||||
|
|
||||||
for _, def := range commands {
|
for _, def := range commands {
|
||||||
if def.Name != command {
|
if def.Name != command {
|
||||||
continue
|
continue
|
||||||
|
|
@ -211,6 +306,49 @@ func printHelp(opts Options, command string) error {
|
||||||
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
|
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 <subcommand> [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 {
|
func printGlobalHelp(opts Options) error {
|
||||||
if strings.TrimSpace(opts.Description) != "" {
|
if strings.TrimSpace(opts.Description) != "" {
|
||||||
if _, err := fmt.Fprintf(opts.Stdout, "%s\n\n", opts.Description); err != nil {
|
if _, err := fmt.Fprintf(opts.Stdout, "%s\n\n", opts.Description); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,8 @@ func TestRunReturnsCommandNotConfigured(t *testing.T) {
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
})
|
})
|
||||||
if !errors.Is(err, ErrCommandNotConfigured) {
|
if !errors.Is(err, ErrSubcommandRequired) {
|
||||||
t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err)
|
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) {
|
func TestRunPrintsCommandHelp(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr 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)
|
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 <subcommand>",
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue