mcp-framework/bootstrap/bootstrap.go
thibaud-lclr cd0740c75f feat: default login handler in bootstrap
When Hooks.Login is nil, Run() now handles the login command directly
using LoginBitwarden with BinaryName as the service name, removing
the need for glue code in each consumer binary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 11:37:35 +02:00

580 lines
14 KiB
Go

package bootstrap
import (
"context"
"errors"
"fmt"
"io"
"os"
"sort"
"strings"
"forge.lclr.dev/AI/mcp-framework/secretstore"
)
const (
CommandSetup = "setup"
CommandLogin = "login"
CommandMCP = "mcp"
CommandConfig = "config"
CommandUpdate = "update"
CommandVersion = "version"
CommandDoctor = "doctor"
bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG"
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
Login Handler
MCP Handler
Config Handler
ConfigShow Handler
ConfigTest Handler
ConfigDelete Handler
Update Handler
Version Handler
}
type Options struct {
BinaryName string
Description string
Version string
Aliases map[string][]string
AliasDescriptions map[string]string
EnableDoctorAlias bool
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: CommandLogin,
Description: "Authentifier et deverrouiller Bitwarden pour persister BW_SESSION.",
Handler: func(h Hooks) Handler {
return h.Login
},
},
{
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
}
resolvedArgs := expandAliases(normalized.Args, normalized.Aliases)
resolvedArgs, debugEnabled := extractGlobalDebugFlag(resolvedArgs)
if debugEnabled {
_ = os.Setenv(bitwardenDebugEnvName, "1")
}
command, commandArgs, showHelp := parseArgs(resolvedArgs)
if showHelp {
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)
}
if handler == nil {
switch command {
case CommandVersion:
if strings.TrimSpace(normalized.Version) == "" {
return ErrVersionRequired
}
_, err := fmt.Fprintln(normalized.Stdout, normalized.Version)
return err
case CommandLogin:
_, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{
ServiceName: normalized.BinaryName,
Stdin: normalized.Stdin,
Stdout: normalized.Stdout,
Stderr: normalized.Stderr,
})
return err
default:
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:]
}
opts.Aliases = normalizeAliases(opts.Aliases, opts.EnableDoctorAlias)
opts.AliasDescriptions = normalizeAliasDescriptions(opts.AliasDescriptions, opts.Aliases, opts.EnableDoctorAlias)
return opts
}
func normalizeAliases(aliases map[string][]string, enableDoctorAlias bool) map[string][]string {
normalized := make(map[string][]string, len(aliases)+1)
for name, target := range aliases {
trimmedName := strings.TrimSpace(name)
trimmedTarget := trimArgs(target)
if trimmedName == "" || len(trimmedTarget) == 0 {
continue
}
normalized[trimmedName] = trimmedTarget
}
if enableDoctorAlias {
if _, ok := normalized[CommandDoctor]; !ok {
normalized[CommandDoctor] = []string{CommandConfig, ConfigSubcommandTest}
}
}
if len(normalized) == 0 {
return nil
}
return normalized
}
func normalizeAliasDescriptions(descriptions map[string]string, aliases map[string][]string, enableDoctorAlias bool) map[string]string {
normalized := make(map[string]string, len(descriptions)+1)
for name, description := range descriptions {
trimmedName := strings.TrimSpace(name)
trimmedDescription := strings.TrimSpace(description)
if trimmedName == "" || trimmedDescription == "" {
continue
}
if _, ok := aliases[trimmedName]; !ok {
continue
}
normalized[trimmedName] = trimmedDescription
}
if enableDoctorAlias {
if _, ok := aliases[CommandDoctor]; ok {
if _, defined := normalized[CommandDoctor]; !defined {
normalized[CommandDoctor] = "Diagnostiquer la configuration locale."
}
}
}
if len(normalized) == 0 {
return nil
}
return normalized
}
func parseArgs(args []string) (command string, commandArgs []string, showHelp bool) {
args = trimArgs(args)
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]), trimArgs(args[2:]), true
}
return "", nil, true
}
command = first
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 extractGlobalDebugFlag(args []string) ([]string, bool) {
args = trimArgs(args)
if len(args) == 0 {
return nil, false
}
filtered := make([]string, 0, len(args))
debugEnabled := false
for _, arg := range args {
if strings.TrimSpace(arg) == "--debug" {
debugEnabled = true
continue
}
filtered = append(filtered, arg)
}
return filtered, debugEnabled
}
func expandAliases(args []string, aliases map[string][]string) []string {
args = trimArgs(args)
if len(args) == 0 || len(aliases) == 0 {
return args
}
if args[0] == "help" && len(args) > 1 {
expanded, ok := resolveAlias(aliases, args[1])
if !ok {
return args
}
withHelp := make([]string, 0, 1+len(expanded)+len(args[2:]))
withHelp = append(withHelp, "help")
withHelp = append(withHelp, expanded...)
withHelp = append(withHelp, args[2:]...)
return withHelp
}
expanded, ok := resolveAlias(aliases, args[0])
if !ok {
return args
}
withCommand := make([]string, 0, len(expanded)+len(args[1:]))
withCommand = append(withCommand, expanded...)
withCommand = append(withCommand, args[1:]...)
if len(withCommand) == 0 {
return args
}
last := strings.TrimSpace(withCommand[len(withCommand)-1])
if last != "-h" && last != "--help" {
return withCommand
}
helpArgs := make([]string, 0, len(withCommand))
helpArgs = append(helpArgs, "help")
helpArgs = append(helpArgs, withCommand[:len(withCommand)-1]...)
return helpArgs
}
func resolveAlias(aliases map[string][]string, command string) ([]string, bool) {
target, ok := aliases[strings.TrimSpace(command)]
if !ok {
return nil, false
}
expanded := trimArgs(target)
if len(expanded) == 0 {
return nil, false
}
return expanded, true
}
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, %s",
ErrSubcommandRequired,
CommandConfig,
ConfigSubcommandShow,
ConfigSubcommandTest,
ConfigSubcommandDelete,
)
}
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 {
return def.Handler(hooks), true
}
}
return nil, false
}
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
}
_, 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 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 {
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
}
}
if len(opts.Aliases) > 0 {
if _, err := fmt.Fprintln(opts.Stdout, "\nAlias:"); err != nil {
return err
}
names := make([]string, 0, len(opts.Aliases))
for name := range opts.Aliases {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
target := strings.Join(opts.Aliases[name], " ")
description := aliasDescription(opts.AliasDescriptions, name, target)
if _, err := fmt.Fprintf(opts.Stdout, " %-7s %s\n", name, description); err != nil {
return err
}
}
}
if _, err := fmt.Fprintln(opts.Stdout, "\nOptions globales:"); err != nil {
return err
}
if _, err := fmt.Fprintln(opts.Stdout, " --debug Active le debug des appels Bitwarden."); err != nil {
return err
}
_, err := fmt.Fprintf(opts.Stdout, "\nAide detaillee: %s help <command>\n", opts.BinaryName)
return err
}
func aliasDescription(descriptions map[string]string, name, target string) string {
description := strings.TrimSpace(descriptions[name])
if description == "" {
return fmt.Sprintf("Alias de %q.", target)
}
return fmt.Sprintf("%s (alias de %q).", description, target)
}