Commands are now hidden from help and return ErrUnknownCommand when invoked if their hook is nil (and for version, if Version string is also empty). No explicit DisabledCommands needed for MCPs that don't use login/setup/config/etc. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
630 lines
15 KiB
Go
630 lines
15 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
|
|
DisabledCommands []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: 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 isCommandDisabled(command, normalized.DisabledCommands) {
|
|
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
|
|
}
|
|
|
|
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)
|
|
for _, cmd := range autoDisabledCommands(opts) {
|
|
if !isCommandDisabled(cmd, opts.DisabledCommands) {
|
|
opts.DisabledCommands = append(opts.DisabledCommands, cmd)
|
|
}
|
|
}
|
|
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 isCommandDisabled(command, opts.DisabledCommands) {
|
|
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
|
|
}
|
|
|
|
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 isCommandDisabled(def.Name, opts.DisabledCommands) {
|
|
continue
|
|
}
|
|
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 autoDisabledCommands(opts Options) []string {
|
|
h := opts.Hooks
|
|
var disabled []string
|
|
if h.Setup == nil {
|
|
disabled = append(disabled, CommandSetup)
|
|
}
|
|
if h.Login == nil {
|
|
disabled = append(disabled, CommandLogin)
|
|
}
|
|
if h.MCP == nil {
|
|
disabled = append(disabled, CommandMCP)
|
|
}
|
|
if h.Config == nil && h.ConfigShow == nil && h.ConfigTest == nil && h.ConfigDelete == nil {
|
|
disabled = append(disabled, CommandConfig)
|
|
}
|
|
if h.Update == nil {
|
|
disabled = append(disabled, CommandUpdate)
|
|
}
|
|
if h.Version == nil && strings.TrimSpace(opts.Version) == "" {
|
|
disabled = append(disabled, CommandVersion)
|
|
}
|
|
return disabled
|
|
}
|
|
|
|
func isCommandDisabled(command string, disabled []string) bool {
|
|
for _, d := range disabled {
|
|
if d == command {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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)
|
|
}
|