Commands listed in DisabledCommands are excluded from global help output and return ErrUnknownCommand when invoked or help is requested for them. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
601 lines
14 KiB
Go
601 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
|
|
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)
|
|
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)
|
|
}
|
|
|
|
if isCommandDisabled(command, opts.DisabledCommands) {
|
|
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
|
|
}
|
|
|
|
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 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)
|
|
}
|