Files
Fabric/internal/core/plugin_registry.go
Kayvan Sylvan 9f79877524 User Experience: implement automated first-time setup and improved configuration validation
### CHANGES

- Add automated first-time setup for patterns and strategies.
- Implement configuration validation to warn about missing required components.
- Update setup menu to group plugins into required and optional.
- Provide helpful guidance when no patterns are found in listing.
- Expand localization support for setup and error messaging across languages.
- Enhance strategy manager to reload and count installed strategies.
- Improve pattern error handling with specific guidance for empty directories.
2025-12-18 14:48:50 -08:00

578 lines
18 KiB
Go

package core
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/danielmiessler/fabric/internal/i18n"
debuglog "github.com/danielmiessler/fabric/internal/log"
"github.com/danielmiessler/fabric/internal/plugins/ai/anthropic"
"github.com/danielmiessler/fabric/internal/plugins/ai/azure"
"github.com/danielmiessler/fabric/internal/plugins/ai/bedrock"
"github.com/danielmiessler/fabric/internal/plugins/ai/dryrun"
"github.com/danielmiessler/fabric/internal/plugins/ai/exolab"
"github.com/danielmiessler/fabric/internal/plugins/ai/gemini"
"github.com/danielmiessler/fabric/internal/plugins/ai/lmstudio"
"github.com/danielmiessler/fabric/internal/plugins/ai/ollama"
"github.com/danielmiessler/fabric/internal/plugins/ai/openai"
"github.com/danielmiessler/fabric/internal/plugins/ai/openai_compatible"
"github.com/danielmiessler/fabric/internal/plugins/ai/perplexity"
"github.com/danielmiessler/fabric/internal/plugins/strategy"
"github.com/samber/lo"
"github.com/danielmiessler/fabric/internal/plugins"
"github.com/danielmiessler/fabric/internal/plugins/ai"
"github.com/danielmiessler/fabric/internal/plugins/db/fsdb"
"github.com/danielmiessler/fabric/internal/plugins/template"
"github.com/danielmiessler/fabric/internal/tools"
"github.com/danielmiessler/fabric/internal/tools/custom_patterns"
"github.com/danielmiessler/fabric/internal/tools/jina"
"github.com/danielmiessler/fabric/internal/tools/lang"
"github.com/danielmiessler/fabric/internal/tools/youtube"
"github.com/danielmiessler/fabric/internal/util"
)
// hasAWSCredentials checks if Bedrock is properly configured by ensuring both
// AWS credentials and BEDROCK_AWS_REGION are present. This prevents the Bedrock
// client from being initialized when AWS credentials exist for other purposes.
func hasAWSCredentials() bool {
// First check if BEDROCK_AWS_REGION is set - this is required for Bedrock
if os.Getenv("BEDROCK_AWS_REGION") == "" {
return false
}
// Then check if AWS credentials are available
if os.Getenv("AWS_PROFILE") != "" ||
os.Getenv("AWS_ROLE_SESSION_NAME") != "" ||
(os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "") {
return true
}
credFile := os.Getenv("AWS_SHARED_CREDENTIALS_FILE")
if credFile == "" {
if home, err := os.UserHomeDir(); err == nil {
credFile = filepath.Join(home, ".aws", "credentials")
}
}
if credFile != "" {
if _, err := os.Stat(credFile); err == nil {
return true
}
}
return false
}
func NewPluginRegistry(db *fsdb.Db) (ret *PluginRegistry, err error) {
ret = &PluginRegistry{
Db: db,
VendorManager: ai.NewVendorsManager(),
VendorsAll: ai.NewVendorsManager(),
PatternsLoader: tools.NewPatternsLoader(db.Patterns),
CustomPatterns: custom_patterns.NewCustomPatterns(),
YouTube: youtube.NewYouTube(),
Language: lang.NewLanguage(),
Jina: jina.NewClient(),
Strategies: strategy.NewStrategiesManager(),
}
var homedir string
if homedir, err = os.UserHomeDir(); err != nil {
return
}
ret.TemplateExtensions = template.NewExtensionManager(filepath.Join(homedir, ".config/fabric"))
ret.Defaults = tools.NeeDefaults(ret.GetModels)
// Create a vendors slice to hold all vendors (order doesn't matter initially)
vendors := []ai.Vendor{}
// Add non-OpenAI compatible clients
vendors = append(vendors,
openai.NewClient(),
ollama.NewClient(),
azure.NewClient(),
gemini.NewClient(),
anthropic.NewClient(),
lmstudio.NewClient(),
exolab.NewClient(),
perplexity.NewClient(), // Added Perplexity client
)
if hasAWSCredentials() {
vendors = append(vendors, bedrock.NewClient())
}
// Add all OpenAI-compatible providers
for providerName := range openai_compatible.ProviderMap {
provider, _ := openai_compatible.GetProviderByName(providerName)
vendors = append(vendors, openai_compatible.NewClient(provider))
}
// Sort vendors by name for consistent ordering (case-insensitive)
sort.Slice(vendors, func(i, j int) bool {
return strings.ToLower(vendors[i].GetName()) < strings.ToLower(vendors[j].GetName())
})
// Add all sorted vendors to VendorsAll
ret.VendorsAll.AddVendors(vendors...)
_ = ret.Configure()
return
}
func (o *PluginRegistry) ListVendors(out io.Writer) error {
vendors := lo.Map(o.VendorsAll.Vendors, func(vendor ai.Vendor, _ int) string {
return vendor.GetName()
})
fmt.Fprintf(out, "%s\n\n", i18n.T("available_vendors_header"))
for _, vendor := range vendors {
fmt.Fprintf(out, "%s\n", vendor)
}
return nil
}
type PluginRegistry struct {
Db *fsdb.Db
VendorManager *ai.VendorsManager
VendorsAll *ai.VendorsManager
Defaults *tools.Defaults
PatternsLoader *tools.PatternsLoader
CustomPatterns *custom_patterns.CustomPatterns
YouTube *youtube.YouTube
Language *lang.Language
Jina *jina.Client
TemplateExtensions *template.ExtensionManager
Strategies *strategy.StrategiesManager
}
func (o *PluginRegistry) SaveEnvFile() (err error) {
// Now create the .env with all configured VendorsController info
var envFileContent bytes.Buffer
o.Defaults.Settings.FillEnvFileContent(&envFileContent)
o.PatternsLoader.SetupFillEnvFileContent(&envFileContent)
o.CustomPatterns.SetupFillEnvFileContent(&envFileContent)
o.Strategies.SetupFillEnvFileContent(&envFileContent)
for _, vendor := range o.VendorManager.Vendors {
vendor.SetupFillEnvFileContent(&envFileContent)
}
o.YouTube.SetupFillEnvFileContent(&envFileContent)
o.Jina.SetupFillEnvFileContent(&envFileContent)
o.Language.SetupFillEnvFileContent(&envFileContent)
err = o.Db.SaveEnv(envFileContent.String())
return
}
func (o *PluginRegistry) Setup() (err error) {
// Check if this is a first-time setup
isFirstRun := o.isFirstTimeSetup()
if isFirstRun {
err = o.runFirstTimeSetup()
} else {
err = o.runInteractiveSetup()
}
if err != nil {
return
}
// Validate setup after completion
o.validateSetup()
return
}
// isFirstTimeSetup checks if this is a first-time setup
func (o *PluginRegistry) isFirstTimeSetup() bool {
// Check if patterns and strategies are not configured
patternsConfigured := o.PatternsLoader.IsConfigured()
strategiesConfigured := o.Strategies.IsConfigured()
hasVendor := len(o.VendorManager.Vendors) > 0
return !patternsConfigured || !strategiesConfigured || !hasVendor
}
// runFirstTimeSetup handles first-time setup with automatic pattern/strategy download
func (o *PluginRegistry) runFirstTimeSetup() (err error) {
fmt.Println("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Println(i18n.T("setup_welcome_header"))
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
// Step 1: Download patterns (required, automatic)
if !o.PatternsLoader.IsConfigured() {
fmt.Printf("\n%s\n", i18n.T("setup_step_downloading_patterns"))
if err = o.PatternsLoader.Setup(); err != nil {
return fmt.Errorf(i18n.T("setup_failed_download_patterns"), err)
}
if err = o.SaveEnvFile(); err != nil {
return
}
}
// Step 2: Download strategies (required, automatic)
if !o.Strategies.IsConfigured() {
fmt.Printf("\n%s\n", i18n.T("setup_step_downloading_strategies"))
if err = o.Strategies.Setup(); err != nil {
return fmt.Errorf(i18n.T("setup_failed_download_strategies"), err)
}
if err = o.SaveEnvFile(); err != nil {
return
}
}
// Step 3: Configure AI vendor (interactive)
if len(o.VendorManager.Vendors) == 0 {
fmt.Printf("\n%s\n", i18n.T("setup_step_configure_ai_provider"))
fmt.Printf(" %s\n", i18n.T("setup_ai_provider_required"))
fmt.Printf(" %s\n", i18n.T("setup_add_more_providers_later"))
fmt.Println()
if err = o.runVendorSetup(); err != nil {
return
}
}
// Step 4: Set default vendor and model
if !o.Defaults.IsConfigured() {
fmt.Printf("\n%s\n", i18n.T("setup_step_setting_defaults"))
if err = o.Defaults.Setup(); err != nil {
return fmt.Errorf(i18n.T("setup_failed_set_defaults"), err)
}
if err = o.SaveEnvFile(); err != nil {
return
}
}
fmt.Println("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Println(i18n.T("setup_complete_header"))
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Printf("\n%s\n", i18n.T("setup_next_steps"))
fmt.Printf(" %s\n", i18n.T("setup_list_patterns"))
fmt.Printf(" %s\n", i18n.T("setup_try_pattern"))
fmt.Printf(" %s\n", i18n.T("setup_configure_more"))
fmt.Println()
return
}
// runVendorSetup helps user select and configure their first AI vendor
func (o *PluginRegistry) runVendorSetup() (err error) {
setupQuestion := plugins.NewSetupQuestion("Enter the number of the AI provider to configure")
groupsPlugins := util.NewGroupsItemsSelector(i18n.T("setup_available_ai_providers"),
func(plugin plugins.Plugin) string {
return plugin.GetSetupDescription()
})
groupsPlugins.AddGroupItems("", lo.Map(o.VendorsAll.Vendors,
func(vendor ai.Vendor, _ int) plugins.Plugin {
return vendor
})...)
groupsPlugins.Print(false)
if answerErr := setupQuestion.Ask(i18n.T("setup_enter_ai_provider_number")); answerErr != nil {
return answerErr
}
if setupQuestion.Value == "" {
return fmt.Errorf("%s", i18n.T("setup_no_ai_provider_selected"))
}
number, parseErr := strconv.Atoi(setupQuestion.Value)
if parseErr != nil {
return fmt.Errorf(i18n.T("setup_invalid_selection"), setupQuestion.Value)
}
var plugin plugins.Plugin
if _, plugin, err = groupsPlugins.GetGroupAndItemByItemNumber(number); err != nil {
return
}
if pluginSetupErr := plugin.Setup(); pluginSetupErr != nil {
return pluginSetupErr
}
if err = o.SaveEnvFile(); err != nil {
return
}
if o.VendorManager.FindByName(plugin.GetName()) == nil {
if vendor, ok := plugin.(ai.Vendor); ok {
o.VendorManager.AddVendors(vendor)
}
}
return
}
// runInteractiveSetup runs the standard interactive setup menu
func (o *PluginRegistry) runInteractiveSetup() (err error) {
setupQuestion := plugins.NewSetupQuestion("Enter the number of the plugin to setup")
groupsPlugins := util.NewGroupsItemsSelector(i18n.T("setup_available_plugins"),
func(plugin plugins.Plugin) string {
var configuredLabel string
if plugin.IsConfigured() {
configuredLabel = i18n.T("plugin_configured")
} else {
configuredLabel = i18n.T("plugin_not_configured")
}
return fmt.Sprintf("%v%v", plugin.GetSetupDescription(), configuredLabel)
})
// Add vendors first under REQUIRED section
groupsPlugins.AddGroupItems(i18n.T("setup_required_configuration_header"), lo.Map(o.VendorsAll.Vendors,
func(vendor ai.Vendor, _ int) plugins.Plugin {
return vendor
})...)
// Add required tools
groupsPlugins.AddGroupItems(i18n.T("setup_required_tools"), o.Defaults, o.PatternsLoader, o.Strategies)
// Add optional tools
groupsPlugins.AddGroupItems(i18n.T("setup_optional_configuration_header"), o.CustomPatterns, o.Jina, o.Language, o.YouTube)
for {
groupsPlugins.Print(false)
if answerErr := setupQuestion.Ask(i18n.T("setup_plugin_number")); answerErr != nil {
break
}
if setupQuestion.Value == "" {
break
}
number, parseErr := strconv.Atoi(setupQuestion.Value)
setupQuestion.Value = ""
if parseErr == nil {
var plugin plugins.Plugin
if _, plugin, err = groupsPlugins.GetGroupAndItemByItemNumber(number); err != nil {
return
}
if pluginSetupErr := plugin.Setup(); pluginSetupErr != nil {
println(pluginSetupErr.Error())
} else {
if err = o.SaveEnvFile(); err != nil {
break
}
}
if o.VendorManager.FindByName(plugin.GetName()) == nil {
if vendor, ok := plugin.(ai.Vendor); ok {
o.VendorManager.AddVendors(vendor)
}
}
} else {
break
}
}
err = o.SaveEnvFile()
return
}
// validateSetup checks if required components are configured and warns user
func (o *PluginRegistry) validateSetup() {
fmt.Println("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Println(i18n.T("setup_validation_header"))
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
missingRequired := false
// Check AI vendor
if len(o.VendorManager.Vendors) > 0 {
fmt.Printf(" %s\n", i18n.T("setup_validation_ai_provider_configured"))
} else {
fmt.Printf(" %s\n", i18n.T("setup_validation_ai_provider_missing"))
missingRequired = true
}
// Check default model
if o.Defaults.IsConfigured() {
fmt.Printf(" %s\n", fmt.Sprintf(i18n.T("setup_validation_defaults_configured"), o.Defaults.Vendor.Value, o.Defaults.Model.Value))
} else {
fmt.Printf(" %s\n", i18n.T("setup_validation_defaults_missing"))
missingRequired = true
}
// Check patterns
if o.PatternsLoader.IsConfigured() {
fmt.Printf(" %s\n", i18n.T("setup_validation_patterns_configured"))
} else {
fmt.Printf(" %s\n", i18n.T("setup_validation_patterns_missing"))
missingRequired = true
}
// Check strategies
if o.Strategies.IsConfigured() {
fmt.Printf(" %s\n", i18n.T("setup_validation_strategies_configured"))
} else {
fmt.Printf(" %s\n", i18n.T("setup_validation_strategies_missing"))
missingRequired = true
}
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
if missingRequired {
fmt.Printf("\n%s\n", i18n.T("setup_validation_incomplete_warning"))
fmt.Printf(" %s\n", i18n.T("setup_validation_incomplete_help"))
fmt.Println()
} else {
fmt.Printf("\n%s\n", i18n.T("setup_validation_complete"))
fmt.Println()
}
}
func (o *PluginRegistry) SetupVendor(vendorName string) (err error) {
if err = o.VendorsAll.SetupVendor(vendorName, o.VendorManager.VendorsByName); err != nil {
return
}
err = o.SaveEnvFile()
return
}
func (o *PluginRegistry) ConfigureVendors() {
o.VendorManager.Clear()
for _, vendor := range o.VendorsAll.Vendors {
if vendorErr := vendor.Configure(); vendorErr == nil && vendor.IsConfigured() {
o.VendorManager.AddVendors(vendor)
}
}
}
func (o *PluginRegistry) GetModels() (ret *ai.VendorsModels, err error) {
o.ConfigureVendors()
ret, err = o.VendorManager.GetModels()
return
}
// Configure buildClient VendorsController based on the environment variables
func (o *PluginRegistry) Configure() (err error) {
o.ConfigureVendors()
_ = o.Defaults.Configure()
if err := o.CustomPatterns.Configure(); err != nil {
return fmt.Errorf("error configuring CustomPatterns: %w", err)
}
_ = o.PatternsLoader.Configure()
// Refresh the database custom patterns directory after custom patterns plugin is configured
customPatternsDir := os.Getenv("CUSTOM_PATTERNS_DIRECTORY")
if customPatternsDir != "" {
// Expand home directory if needed
if strings.HasPrefix(customPatternsDir, "~/") {
if homeDir, err := os.UserHomeDir(); err == nil {
customPatternsDir = filepath.Join(homeDir, customPatternsDir[2:])
}
}
o.Db.Patterns.CustomPatternsDir = customPatternsDir
o.PatternsLoader.Patterns.CustomPatternsDir = customPatternsDir
}
//YouTube and Jina are not mandatory, so ignore not configured error
_ = o.YouTube.Configure()
_ = o.Jina.Configure()
_ = o.Language.Configure()
return
}
func (o *PluginRegistry) GetChatter(model string, modelContextLength int, vendorName string, strategy string, stream bool, dryRun bool) (ret *Chatter, err error) {
ret = &Chatter{
db: o.Db,
Stream: stream,
DryRun: dryRun,
}
defaultModel := o.Defaults.Model.Value
defaultModelContextLength, err := strconv.Atoi(o.Defaults.ModelContextLength.Value)
defaultVendor := o.Defaults.Vendor.Value
vendorManager := o.VendorManager
if err != nil {
defaultModelContextLength = 0
err = nil
}
ret.modelContextLength = modelContextLength
if ret.modelContextLength == 0 {
ret.modelContextLength = defaultModelContextLength
}
if dryRun {
ret.vendor = dryrun.NewClient()
ret.model = model
if ret.model == "" {
ret.model = defaultModel
}
} else if model == "" {
if vendorName != "" {
ret.vendor = vendorManager.FindByName(vendorName)
} else {
ret.vendor = vendorManager.FindByName(defaultVendor)
}
ret.model = defaultModel
} else {
var models *ai.VendorsModels
if models, err = vendorManager.GetModels(); err != nil {
return
}
// Normalize model name to match actual available model (case-insensitive)
// This must be done BEFORE checking vendor availability
actualModelName := models.FindModelNameCaseInsensitive(model)
if actualModelName != "" {
model = actualModelName // Use normalized name for all subsequent checks
}
if vendorName != "" {
// ensure vendor exists and provides model
ret.vendor = vendorManager.FindByName(vendorName)
availableVendors := models.FindGroupsByItem(model)
vendorAvailable := lo.ContainsBy(availableVendors, func(name string) bool {
return strings.EqualFold(name, vendorName)
})
if ret.vendor == nil || !vendorAvailable {
err = fmt.Errorf("model %s not available for vendor %s", model, vendorName)
return
}
} else {
availableVendors := models.FindGroupsByItem(model)
if len(availableVendors) > 1 {
debuglog.Log("Warning: multiple vendors provide model %s: %s. Using %s. Specify --vendor to select a vendor.\n", model, strings.Join(availableVendors, ", "), availableVendors[0])
}
ret.vendor = vendorManager.FindByName(models.FindGroupsByItemFirst(model))
}
ret.model = model
}
if ret.vendor == nil {
var errMsg string
if defaultModel == "" || defaultVendor == "" {
errMsg = "Please run, fabric --setup, and select default model and vendor."
} else {
errMsg = "could not find vendor."
}
err = fmt.Errorf(
" Requested Model = %s\n Default Model = %s\n Default Vendor = %s.\n\n%s",
model, defaultModel, defaultVendor, errMsg)
return
}
ret.strategy = strategy
return
}