diff --git a/cli/cli.go b/cli/cli.go index 4fdcae67..95313065 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -2,12 +2,13 @@ package cli import ( "fmt" - "github.com/danielmiessler/fabric/plugins/tools/youtube" "os" "path/filepath" "strconv" "strings" + "github.com/danielmiessler/fabric/plugins/tools/youtube" + "github.com/danielmiessler/fabric/common" "github.com/danielmiessler/fabric/core" "github.com/danielmiessler/fabric/plugins/ai" @@ -42,7 +43,10 @@ func Cli(version string) (err error) { } } - registry := core.NewPluginRegistry(fabricDb) + var registry *core.PluginRegistry + if registry, err = core.NewPluginRegistry(fabricDb); err != nil { + return + } // if the setup flag is set, run the setup function if currentFlags.Setup { @@ -129,6 +133,23 @@ func Cli(version string) (err error) { } } +if currentFlags.ListExtensions { + err = registry.TemplateExtensions.ListExtensions() + return +} + +if currentFlags.AddExtension != "" { + err = registry.TemplateExtensions.RegisterExtension(currentFlags.AddExtension) + return +} + +if currentFlags.RemoveExtension != "" { + err = registry.TemplateExtensions.RemoveExtension(currentFlags.RemoveExtension) + return +} + + + // if the interactive flag is set, run the interactive function // if currentFlags.Interactive { // interactive.Interactive() diff --git a/cli/flags.go b/cli/flags.go index 701ee730..7bafc7dc 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -59,6 +59,10 @@ type Flags struct { Serve bool `long:"serve" description:"Serve the Fabric Rest API"` ServeAddress string `long:"address" description:"The address to bind the REST API" default:":8080"` Version bool `long:"version" description:"Print current version"` + ListExtensions bool `long:"listextensions" description:"List all registered extensions"` + AddExtension string `long:"addextension" description:"Register a new extension from config file path"` + RemoveExtension string `long:"rmextension" description:"Remove a registered extension by name"` + } // Init Initialize flags. returns a Flags struct and an error diff --git a/core/plugin_registry.go b/core/plugin_registry.go index dc62a168..3dc76bbd 100644 --- a/core/plugin_registry.go +++ b/core/plugin_registry.go @@ -3,6 +3,8 @@ package core import ( "bytes" "fmt" + "os" + "path/filepath" "strconv" "github.com/samber/lo" @@ -21,13 +23,14 @@ import ( "github.com/danielmiessler/fabric/plugins/ai/openrouter" "github.com/danielmiessler/fabric/plugins/ai/siliconcloud" "github.com/danielmiessler/fabric/plugins/db/fsdb" + "github.com/danielmiessler/fabric/plugins/template" "github.com/danielmiessler/fabric/plugins/tools" "github.com/danielmiessler/fabric/plugins/tools/jina" "github.com/danielmiessler/fabric/plugins/tools/lang" "github.com/danielmiessler/fabric/plugins/tools/youtube" ) -func NewPluginRegistry(db *fsdb.Db) (ret *PluginRegistry) { +func NewPluginRegistry(db *fsdb.Db) (ret *PluginRegistry, err error) { ret = &PluginRegistry{ Db: db, VendorManager: ai.NewVendorsManager(), @@ -37,6 +40,12 @@ func NewPluginRegistry(db *fsdb.Db) (ret *PluginRegistry) { Language: lang.NewLanguage(), Jina: jina.NewClient(), } + + 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) @@ -60,6 +69,7 @@ type PluginRegistry struct { YouTube *youtube.YouTube Language *lang.Language Jina *jina.Client + TemplateExtensions *template.ExtensionManager } func (o *PluginRegistry) SaveEnvFile() (err error) { diff --git a/plugins/template/extension_executor.go b/plugins/template/extension_executor.go new file mode 100644 index 00000000..9edc0789 --- /dev/null +++ b/plugins/template/extension_executor.go @@ -0,0 +1,196 @@ +package template + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// ExtensionExecutor handles the secure execution of extensions +// It uses the registry to verify extensions before running them +type ExtensionExecutor struct { + registry *ExtensionRegistry +} + +// NewExtensionExecutor creates a new executor instance +// It requires a registry to verify extensions +func NewExtensionExecutor(registry *ExtensionRegistry) *ExtensionExecutor { + return &ExtensionExecutor{ + registry: registry, + } +} + +// Execute runs an extension with the given operation and value string +// name: the registered name of the extension +// operation: the operation to perform +// value: the input value(s) for the operation +// In extension_executor.go +func (e *ExtensionExecutor) Execute(name, operation, value string) (string, error) { + // Get and verify extension from registry + ext, err := e.registry.GetExtension(name) + if err != nil { + return "", fmt.Errorf("failed to get extension: %w", err) + } + + // Format the command using our template system + cmdStr, err := e.formatCommand(ext, operation, value) + if err != nil { + return "", fmt.Errorf("failed to format command: %w", err) + } + + // Split the command string into command and arguments + cmdParts := strings.Fields(cmdStr) + if len(cmdParts) < 1 { + return "", fmt.Errorf("empty command after formatting") + } + + // Create command with the Executable and formatted arguments + cmd := exec.Command("sh", "-c", cmdStr) + //cmd := exec.Command(cmdParts[0], cmdParts[1:]...) + + // Set up environment if specified + if len(ext.Env) > 0 { + cmd.Env = append(os.Environ(), ext.Env...) + } + + // Execute based on output method + outputMethod := ext.GetOutputMethod() + if outputMethod == "file" { + return e.executeWithFile(cmd, ext) + } + return e.executeStdout(cmd, ext) +} + +// formatCommand uses fabric's template system to format the command +// It creates a variables map for the template system using the input values +func (e *ExtensionExecutor) formatCommand(ext *ExtensionDefinition, operation string, value string) (string, error) { + // Get operation config + opConfig, exists := ext.Operations[operation] + if !exists { + return "", fmt.Errorf("operation %s not found for extension %s", operation, ext.Name) + } + + vars := make(map[string]string) + vars["executable"] = ext.Executable + vars["operation"] = operation + vars["value"] = value + + // Split on pipe for numbered variables + values := strings.Split(value, "|") + for i, val := range values { + vars[fmt.Sprintf("%d", i+1)] = val + } + + return ApplyTemplate(opConfig.CmdTemplate, vars, "") +} + +// executeStdout runs the command and captures its stdout +func (e *ExtensionExecutor) executeStdout(cmd *exec.Cmd, ext *ExtensionDefinition) (string, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + //debug output + fmt.Printf("Executing command: %s\n", cmd.String()) + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("execution failed: %w\nstderr: %s", err, stderr.String()) + } + + return stdout.String(), nil +} + +// executeWithFile runs the command and handles file-based output +func (e *ExtensionExecutor) executeWithFile(cmd *exec.Cmd, ext *ExtensionDefinition) (string, error) { + // Parse timeout - this is now a first-class field + timeout, err := time.ParseDuration(ext.Timeout) + if err != nil { + return "", fmt.Errorf("invalid timeout format: %w", err) + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...) + cmd.Env = cmd.Env + + fileConfig := ext.GetFileConfig() + if fileConfig == nil { + return "", fmt.Errorf("no file configuration found") + } + + // Handle path from stdout case + if pathFromStdout, ok := fileConfig["path_from_stdout"].(bool); ok && pathFromStdout { + return e.handlePathFromStdout(cmd, ext) + } + + // Handle fixed file case + workDir, _ := fileConfig["work_dir"].(string) + outputFile, _ := fileConfig["output_file"].(string) + + if outputFile == "" { + return "", fmt.Errorf("no output file specified in configuration") + } + + // Set working directory if specified + if workDir != "" { + cmd.Dir = workDir + } + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("execution timed out after %v", timeout) + } + return "", fmt.Errorf("execution failed: %w\nerr: %s", err, stderr.String()) + } + + // Construct full file path + outputPath := outputFile + if workDir != "" { + outputPath = filepath.Join(workDir, outputFile) + } + + content, err := os.ReadFile(outputPath) + if err != nil { + return "", fmt.Errorf("failed to read output file: %w", err) + } + + // Handle cleanup if enabled + if ext.IsCleanupEnabled() { + defer os.Remove(outputPath) + } + + return string(content), nil +} + +// Helper method to handle path from stdout case +func (e *ExtensionExecutor) handlePathFromStdout(cmd *exec.Cmd, ext *ExtensionDefinition) (string, error) { + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("failed to get output path: %w\nerr: %s", err, stderr.String()) + } + + outputPath := strings.TrimSpace(stdout.String()) + content, err := os.ReadFile(outputPath) + if err != nil { + return "", fmt.Errorf("failed to read output file: %w", err) + } + + if ext.IsCleanupEnabled() { + defer os.Remove(outputPath) + } + + return string(content), nil +} \ No newline at end of file diff --git a/plugins/template/extension_manager.go b/plugins/template/extension_manager.go new file mode 100644 index 00000000..7d0ed115 --- /dev/null +++ b/plugins/template/extension_manager.go @@ -0,0 +1,84 @@ +package template + +import ( + "fmt" + "path/filepath" +) + +// ExtensionManager handles the high-level operations of the extension system +type ExtensionManager struct { + registry *ExtensionRegistry + executor *ExtensionExecutor + configDir string +} + +// NewExtensionManager creates a new extension manager instance +func NewExtensionManager(configDir string) *ExtensionManager { + registry := NewExtensionRegistry(configDir) + return &ExtensionManager{ + registry: registry, + executor: NewExtensionExecutor(registry), + configDir: configDir, + } +} + +// ListExtensions handles the listextensions flag action +func (em *ExtensionManager) ListExtensions() error { + extensions, err := em.registry.ListExtensions() + if err != nil { + return fmt.Errorf("failed to list extensions: %w", err) + } + + for _, ext := range extensions { + fmt.Printf("Name: %s\n", ext.Name) + fmt.Printf(" Executable: %s\n", ext.Executable) + fmt.Printf(" Type: %s\n", ext.Type) + fmt.Printf(" Timeout: %s\n", ext.Timeout) + fmt.Printf(" Description: %s\n", ext.Description) + fmt.Printf(" Version: %s\n", ext.Version) + + fmt.Printf(" Operations:\n") + for opName, opConfig := range ext.Operations { + fmt.Printf(" %s:\n", opName) + fmt.Printf(" Command Template: %s\n", opConfig.CmdTemplate) + } + + if fileConfig := ext.GetFileConfig(); fileConfig != nil { + fmt.Printf(" File Configuration:\n") + for k, v := range fileConfig { + fmt.Printf(" %s: %v\n", k, v) + } + } + fmt.Printf("\n") + } + + return nil +} + +// RegisterExtension handles the addextension flag action +func (em *ExtensionManager) RegisterExtension(configPath string) error { + absPath, err := filepath.Abs(configPath) + if err != nil { + return fmt.Errorf("invalid config path: %w", err) + } + + if err := em.registry.Register(absPath); err != nil { + return fmt.Errorf("failed to register extension: %w", err) + } + + return nil +} + +// RemoveExtension handles the rmextension flag action +func (em *ExtensionManager) RemoveExtension(name string) error { + if err := em.registry.Remove(name); err != nil { + return fmt.Errorf("failed to remove extension: %w", err) + } + + return nil +} + +// ProcessExtension handles template processing for extension directives +func (em *ExtensionManager) ProcessExtension(name, operation, value string) (string, error) { + return em.executor.Execute(name, operation, value) +} \ No newline at end of file diff --git a/plugins/template/extension_registry.go b/plugins/template/extension_registry.go new file mode 100644 index 00000000..6da115ae --- /dev/null +++ b/plugins/template/extension_registry.go @@ -0,0 +1,229 @@ +package template + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" + // Add this import +) + +// ExtensionDefinition represents a single extension configuration +type ExtensionDefinition struct { + // Global properties + Name string `yaml:"name"` + Executable string `yaml:"executable"` + Type string `yaml:"type"` + Timeout string `yaml:"timeout"` + Description string `yaml:"description"` + Version string `yaml:"version"` + Env []string `yaml:"env"` + + // Operation-specific commands + Operations map[string]OperationConfig `yaml:"operations"` + + // Additional config + Config map[string]interface{} `yaml:"config"` +} + +type OperationConfig struct { + CmdTemplate string `yaml:"cmd_template"` +} + +type ExtensionRegistry struct { + configDir string + registry struct { + Extensions map[string]*ExtensionDefinition + ConfigHashes map[string]string + ExecutableHashes map[string]string + } +} + + +// Helper methods for Config access +func (e *ExtensionDefinition) GetOutputMethod() string { + if output, ok := e.Config["output"].(map[string]interface{}); ok { + if method, ok := output["method"].(string); ok { + return method + } + } + return "stdout" // default to stdout if not specified +} + +func (e *ExtensionDefinition) GetFileConfig() map[string]interface{} { + if output, ok := e.Config["output"].(map[string]interface{}); ok { + if fileConfig, ok := output["file_config"].(map[string]interface{}); ok { + return fileConfig + } + } + return nil +} + +func (e *ExtensionDefinition) IsCleanupEnabled() bool { + if fc := e.GetFileConfig(); fc != nil { + if cleanup, ok := fc["cleanup"].(bool); ok { + return cleanup + } + } + return false // default to no cleanup +} + + +func NewExtensionRegistry(configDir string) *ExtensionRegistry { + r := &ExtensionRegistry{ + configDir: configDir, + } + r.registry.Extensions = make(map[string]*ExtensionDefinition) + r.registry.ConfigHashes = make(map[string]string) + r.registry.ExecutableHashes = make(map[string]string) + + // Ensure extensions directory exists + r.ensureConfigDir() + + // Load existing registry if it exists + if err := r.loadRegistry(); err != nil { + // Since we're in a constructor, we can't return error + // Log it if we have logging, but continue with empty registry + if Debug { + fmt.Printf("Warning: could not load extension registry: %v\n", err) + } + } + + return r +} + +func (r *ExtensionRegistry) ensureConfigDir() error { + extDir := filepath.Join(r.configDir, "extensions") + return os.MkdirAll(extDir, 0755) +} + +func (r *ExtensionRegistry) Register(configPath string) error { + // Read and parse the extension definition + data, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + var ext ExtensionDefinition + if err := yaml.Unmarshal(data, &ext); err != nil { + return fmt.Errorf("failed to parse config file: %w", err) + } + + // Verify Executable exists + if _, err := os.Stat(ext.Executable); err != nil { + return fmt.Errorf("Executable not found: %w", err) + } + + // Calculate hashes using template package functions + configHash := ComputeStringHash(string(data)) + ExecutableHash, err := ComputeHash(ext.Executable) + if err != nil { + return fmt.Errorf("failed to hash Executable: %w", err) + } + + // Store extension and hashes + r.registry.Extensions[ext.Name] = &ext + r.registry.ConfigHashes[ext.Name] = configHash + r.registry.ExecutableHashes[ext.Name] = ExecutableHash + + return r.saveRegistry() +} + +func (r *ExtensionRegistry) Remove(name string) error { + if _, exists := r.registry.Extensions[name]; !exists { + return fmt.Errorf("extension %s not found", name) + } + + delete(r.registry.Extensions, name) + delete(r.registry.ConfigHashes, name) + delete(r.registry.ExecutableHashes, name) + + return r.saveRegistry() +} + +func (r *ExtensionRegistry) Verify(name string) error { + ext, exists := r.registry.Extensions[name] + if !exists { + return fmt.Errorf("extension %s not found", name) + } + + // Verify Executable hash using template package function + currentExecutableHash, err := ComputeHash(ext.Executable) + if err != nil { + return fmt.Errorf("failed to verify Executable: %w", err) + } + + if currentExecutableHash != r.registry.ExecutableHashes[name] { + return fmt.Errorf("Executable hash mismatch for %s", name) + } + + return nil +} + +func (r *ExtensionRegistry) GetExtension(name string) (*ExtensionDefinition, error) { + ext, exists := r.registry.Extensions[name] + if !exists { + return nil, fmt.Errorf("extension %s not found", name) + } + + if err := r.Verify(name); err != nil { + return nil, err + } + + return ext, nil +} + +func (r *ExtensionRegistry) ListExtensions() ([]*ExtensionDefinition, error) { + exts := make([]*ExtensionDefinition, 0, len(r.registry.Extensions)) + for _, ext := range r.registry.Extensions { + exts = append(exts, ext) + } + return exts, nil +} + +func (r *ExtensionRegistry) calculateFileHash(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + + return hex.EncodeToString(h.Sum(nil)), nil +} + +func (r *ExtensionRegistry) saveRegistry() error { + data, err := yaml.Marshal(r.registry) + if err != nil { + return fmt.Errorf("failed to marshal extension registry: %w", err) + } + + registryPath := filepath.Join(r.configDir, "extensions", "extensions.yaml") + return os.WriteFile(registryPath, data, 0644) +} + +func (r *ExtensionRegistry) loadRegistry() error { + registryPath := filepath.Join(r.configDir, "extensions", "extensions.yaml") + data, err := os.ReadFile(registryPath) + if err != nil { + if os.IsNotExist(err) { + return nil // New registry + } + return fmt.Errorf("failed to read extension registry: %w", err) + } + + // Need to unmarshal the data into our registry + if err := yaml.Unmarshal(data, &r.registry); err != nil { + return fmt.Errorf("failed to parse extension registry: %w", err) + } + + return nil +} \ No newline at end of file diff --git a/plugins/template/hash.go b/plugins/template/hash.go new file mode 100644 index 00000000..38f7dde8 --- /dev/null +++ b/plugins/template/hash.go @@ -0,0 +1,33 @@ +package template + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" +) + +// ComputeHash computes SHA-256 hash of a file at given path. +// Returns the hex-encoded hash string or an error if the operation fails. +func ComputeHash(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("open file: %w", err) + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", fmt.Errorf("read file: %w", err) + } + + return hex.EncodeToString(h.Sum(nil)), nil +} + +// ComputeStringHash returns hex-encoded SHA-256 hash of the given string +func ComputeStringHash(s string) string { + h := sha256.New() + h.Write([]byte(s)) + return hex.EncodeToString(h.Sum(nil)) +} \ No newline at end of file diff --git a/plugins/template/hash_test.go b/plugins/template/hash_test.go new file mode 100644 index 00000000..a7b12421 --- /dev/null +++ b/plugins/template/hash_test.go @@ -0,0 +1,119 @@ +// template/hash_test.go +package template + +import ( + "os" + "path/filepath" + "testing" +) + +func TestComputeHash(t *testing.T) { + // Create a temporary test file + content := []byte("test content for hashing") + tmpfile, err := os.CreateTemp("", "hashtest") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer os.Remove(tmpfile.Name()) + + if _, err := tmpfile.Write(content); err != nil { + t.Fatalf("failed to write to temp file: %v", err) + } + if err := tmpfile.Close(); err != nil { + t.Fatalf("failed to close temp file: %v", err) + } + + tests := []struct { + name string + path string + want string // known hash for test content + wantErr bool + }{ + { + name: "valid file", + path: tmpfile.Name(), + want: "e25dd806d495b413931f4eea50b677a7a5c02d00460924661283f211a37f7e7f", // pre-computed hash of "test content for hashing" + wantErr: false, + }, + { + name: "nonexistent file", + path: filepath.Join(os.TempDir(), "nonexistent"), + want: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ComputeHash(tt.path) + if (err != nil) != tt.wantErr { + t.Errorf("ComputeHash() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want && !tt.wantErr { + t.Errorf("ComputeHash() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestComputeStringHash(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "empty string", + input: "", + want: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, + { + name: "simple string", + input: "test", + want: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + }, + { + name: "longer string with spaces", + input: "this is a test string", + want: "f6774519d1c7a3389ef327e9c04766b999db8cdfb85d1346c471ee86d65885bc", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ComputeStringHash(tt.input); got != tt.want { + t.Errorf("ComputeStringHash() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestHashConsistency ensures both hash functions produce same results for same content +func TestHashConsistency(t *testing.T) { + content := "test content for consistency check" + + // Create a file with the test content + tmpfile, err := os.CreateTemp("", "hashconsistency") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer os.Remove(tmpfile.Name()) + + if err := os.WriteFile(tmpfile.Name(), []byte(content), 0644); err != nil { + t.Fatalf("failed to write to temp file: %v", err) + } + + // Get hashes using both methods + fileHash, err := ComputeHash(tmpfile.Name()) + if err != nil { + t.Fatalf("ComputeHash failed: %v", err) + } + + stringHash := ComputeStringHash(content) + + // Compare results + if fileHash != stringHash { + t.Errorf("Hash inconsistency: file hash %v != string hash %v", fileHash, stringHash) + } +} \ No newline at end of file diff --git a/plugins/template/template.go b/plugins/template/template.go index 6ff7cd4e..382053e0 100644 --- a/plugins/template/template.go +++ b/plugins/template/template.go @@ -2,6 +2,8 @@ package template import ( "fmt" + "os" + "path/filepath" "regexp" "strings" ) @@ -11,11 +13,24 @@ var ( datetimePlugin = &DateTimePlugin{} filePlugin = &FilePlugin{} fetchPlugin = &FetchPlugin{} - sysPlugin = &SysPlugin{} - Debug = false // Debug flag + sysPlugin = &SysPlugin{} + extensionManager *ExtensionManager + Debug = true // Debug flag ) + +func init() { + homedir, err := os.UserHomeDir() + if err != nil { + // We should probably handle this error appropriately + return + } + configDir := filepath.Join(homedir, ".config/fabric") + extensionManager = NewExtensionManager(configDir) +} + var pluginPattern = regexp.MustCompile(`\{\{plugin:([^:]+):([^:]+)(?::([^}]+))?\}\}`) +var extensionPattern = regexp.MustCompile(`\{\{ext:([^:]+):([^:]+)(?::([^}]+))?\}\}`) func debugf(format string, a ...interface{}) { if Debug { @@ -91,6 +106,31 @@ func ApplyTemplate(content string, variables map[string]string, input string) (s } } + if pluginMatches := extensionPattern.FindStringSubmatch(fullMatch); len(pluginMatches) >= 3 { + name := pluginMatches[1] + operation := pluginMatches[2] + value := "" + if len(pluginMatches) == 4 { + value = pluginMatches[3] + } + + debugf("\nExtension call:\n") + debugf(" Name: %s\n", name) + debugf(" Operation: %s\n", operation) + debugf(" Value: %s\n", value) + + result, err := extensionManager.ProcessExtension(name, operation, value) + if err != nil { + return "", fmt.Errorf("extension %s error: %v", name, err) + } + + content = strings.ReplaceAll(content, fullMatch, result) + replaced = true + continue + } + + + // Handle regular variables and input debugf("Processing variable: %s\n", varName) if varName == "input" { diff --git a/plugins/template/utils.go b/plugins/template/utils.go new file mode 100644 index 00000000..1db7c891 --- /dev/null +++ b/plugins/template/utils.go @@ -0,0 +1,41 @@ +// utils.go in template package for now +package template + +import ( + "fmt" + "os" + "os/user" + "path/filepath" + "strings" +) + +// ExpandPath expands the ~ to user's home directory and returns absolute path +// It also checks if the path exists +// Returns expanded absolute path or error if: +// - cannot determine user home directory +// - cannot convert to absolute path +// - path doesn't exist +func ExpandPath(path string) (string, error) { + // If path starts with ~ + if strings.HasPrefix(path, "~/") { + usr, err := user.Current() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + // Replace ~/ with actual home directory + path = filepath.Join(usr.HomeDir, path[2:]) + } + + // Convert to absolute path + absPath, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("failed to get absolute path: %w", err) + } + + // Check if path exists + if _, err := os.Stat(absPath); err != nil { + return "", fmt.Errorf("path does not exist: %w", err) + } + + return absPath, nil +}