From 6eb0f020b217ec95662681063eda9af677238aaa Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 11 Nov 2024 21:54:32 -0500 Subject: [PATCH] feat: add editor flag to generate (#16) --- .lycheeignore | 5 ++- README.md | 32 +++++++++++++-- src/cmd/common/file_operations.go | 68 +++++++++++++++++++++++++++++++ src/cmd/copy.go | 12 +----- src/cmd/delete.go | 10 +---- src/cmd/edit.go | 60 +++++++-------------------- src/cmd/generate.go | 53 ++++++++++++++++++------ src/config/config.go | 10 +++-- 8 files changed, 166 insertions(+), 84 deletions(-) create mode 100644 src/cmd/common/file_operations.go diff --git a/.lycheeignore b/.lycheeignore index a95071c..11bb28b 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -1,2 +1,5 @@ github\.com/private-org/.* -file.*/issues \ No newline at end of file +file.*/issues +localhost:\d+/.* +http://localhost:\d+/.* +https://localhost:\d+/.* \ No newline at end of file diff --git a/README.md b/README.md index 3fe069b..f46a265 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,14 @@ A prompt engineer's ***Grimoire*** to assist with getting the best results from To install Grimoire, follow these steps: 1. Clone the repository and navigate to the directory: + ```sh git clone https://github.com/gphorvath/grimoire.git cd grimoire ``` -2. Build and install using Make: +1. Build and install using Make: + ```sh # Build the binary make build @@ -31,6 +33,7 @@ make install ``` This will: + - Build the Grimoire binary - Create the ~/.grimoire directory - Copy default prompts to ~/.grimoire/prompts @@ -50,14 +53,37 @@ grimoire copy # Create a new prompt grimoire new -# Edit an existing prompt +# Edit an existing prompt in default editor grimoire edit # Use echo prompt to verify prompt loading # Requires Ollama running locally with a configured model (default: llama3) -grimoire generate -p echo "Testing generation functionality" +grimoire generate --prompt echo "Testing generation functionality" + +# Modify prompt in default editor before generation (doesn't require arg) +grimoire generate --prompt echo --edit ``` +## Environment Variables + +GRIMOIRE environment variables control the configuration of the application. + +### Ollama Configuration + +- `GRIMOIRE_OLLAMA_MODEL` (default: "llama3") + The model to be used with Ollama for AI inference. + +- `GRIMOIRE_OLLAMA_URL` (default: "") + The URL endpoint for the Ollama API server. + +- `GRIMOIRE_OLLAMA_STREAM` (default: true) + Controls whether to stream responses from Ollama. Set to "true" to enable streaming, "false" to disable. + +### Editor Configuration + +- `GRIMOIRE_EDITOR` (default: "vim") + The text editor to use when editing prompts or configuration files. + ## License This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. diff --git a/src/cmd/common/file_operations.go b/src/cmd/common/file_operations.go new file mode 100644 index 0000000..b14392b --- /dev/null +++ b/src/cmd/common/file_operations.go @@ -0,0 +1,68 @@ +package common + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + + "github.com/gphorvath/grimoire/src/config" +) + +// FileExists reports if a file exists +func FileExists(path string) bool { + _, err := os.Stat(path) + return !os.IsNotExist(err) +} + +// CreateIfNotExists creates a file if it does not exist +// and returns an error if it fails to create the file +func CreateIfNotExists(filePath string) error { + if !FileExists(filePath) { + file, err := os.Create(filePath) + if err != nil { + return err + } + defer file.Close() + } + return nil +} + +// FindAndJoin walks the directory tree rooted at baseDir +// and returns the path of the first file with the given filename +// and an error if the file is not found +func FindAndJoin(baseDir, filename string) (string, error) { + var foundPath string + err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && info.Name() == filename { + foundPath = path + return filepath.SkipDir + } + return nil + }) + if err != nil { + return "", err + } + if foundPath == "" { + return "", os.ErrNotExist + } + return foundPath, nil +} + +// OpenInEditor opens a file in the configured editor +// and returns an error if it fails to open the file +func OpenInEditor(path string) error { + if !FileExists(path) { + return errors.New("file does not exist") + } + + cmd := exec.Command(config.Editor, path) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + return cmd.Run() +} diff --git a/src/cmd/copy.go b/src/cmd/copy.go index 8010489..156bf89 100644 --- a/src/cmd/copy.go +++ b/src/cmd/copy.go @@ -3,8 +3,8 @@ package cmd import ( "fmt" "os" - "path/filepath" + "github.com/gphorvath/grimoire/src/cmd/common" "github.com/gphorvath/grimoire/src/config" "github.com/spf13/cobra" "golang.design/x/clipboard" @@ -24,20 +24,12 @@ func init() { func runCopyCmd(cmd *cobra.Command, args []string) { baseDir := config.GetPromptDir() filename := args[0] + ".md" - - dir, err := findFileDir(baseDir, filename) + filePath, err := common.FindAndJoin(baseDir, filename) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - if dir == "" { - fmt.Fprintf(os.Stderr, "Error: prompt not found\n") - os.Exit(1) - } - - filePath := filepath.Join(dir, filename) - // Init returns an error if the package is not ready for use. err = clipboard.Init() if err != nil { diff --git a/src/cmd/delete.go b/src/cmd/delete.go index 5a6e652..b15a59e 100644 --- a/src/cmd/delete.go +++ b/src/cmd/delete.go @@ -3,8 +3,8 @@ package cmd import ( "fmt" "os" - "path/filepath" + "github.com/gphorvath/grimoire/src/cmd/common" "github.com/gphorvath/grimoire/src/config" "github.com/spf13/cobra" ) @@ -24,18 +24,12 @@ func runDeleteCmd(cmd *cobra.Command, args []string) { baseDir := config.GetPromptDir() filename := args[0] + ".md" - dir, err := findFileDir(baseDir, filename) + filePath, err := common.FindAndJoin(baseDir, filename) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - if dir == "" { - fmt.Fprintf(os.Stderr, "Error: prompt not found\n") - os.Exit(1) - } - - filePath := filepath.Join(dir, filename) if err := os.Remove(filePath); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) diff --git a/src/cmd/edit.go b/src/cmd/edit.go index e9a00c4..ae6eb00 100644 --- a/src/cmd/edit.go +++ b/src/cmd/edit.go @@ -3,9 +3,9 @@ package cmd import ( "fmt" "os" - "os/exec" "path/filepath" + "github.com/gphorvath/grimoire/src/cmd/common" "github.com/gphorvath/grimoire/src/config" "github.com/spf13/cobra" ) @@ -25,62 +25,30 @@ func runEditCmd(cmd *cobra.Command, args []string) { baseDir := config.GetPromptDir() filename := args[0] + ".md" - dir, err := findFileDir(baseDir, filename) - if err != nil { + // Try to find existing file + filePath, err := common.FindAndJoin(baseDir, filename) + if err != nil && !os.IsNotExist(err) { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - if dir == "" { - dir = baseDir - filePath := filepath.Join(dir, filename) - if err := createNewFile(filePath); err != nil { + // If file doesn't exist, create it in the base directory + if filePath == "" { + filePath = filepath.Join(baseDir, filename) + if err := common.CreateIfNotExists(filePath); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + // Write example prompt to the new file + if err := os.WriteFile(filePath, []byte(config.ExamplePrompt), 0644); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } fmt.Printf("Created new prompt file %s\n", filePath) } - filePath := filepath.Join(dir, filename) - if err := openEditor(filePath); err != nil { + if err := common.OpenInEditor(filePath); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } - -func findFileDir(baseDir, filename string) (string, error) { - var dir string - err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.IsDir() && info.Name() == filename { - dir = filepath.Dir(path) - return filepath.SkipDir - } - return nil - }) - return dir, err -} - -func createNewFile(filePath string) error { - file, err := os.Create(filePath) - if err != nil { - return err - } - defer file.Close() - - _, err = file.WriteString(config.ExamplePrompt) - return err -} - -func openEditor(filePath string) error { - editor := config.Editor - - editCmd := exec.Command(editor, filePath) - editCmd.Stdin = os.Stdin - editCmd.Stdout = os.Stdout - editCmd.Stderr = os.Stderr - - return editCmd.Run() -} diff --git a/src/cmd/generate.go b/src/cmd/generate.go index 9082c37..383c9f6 100644 --- a/src/cmd/generate.go +++ b/src/cmd/generate.go @@ -7,9 +7,9 @@ import ( "io" "net/http" "os" - "path/filepath" "strings" + "github.com/gphorvath/grimoire/src/cmd/common" "github.com/gphorvath/grimoire/src/config" "github.com/spf13/cobra" ) @@ -25,22 +25,29 @@ type OllamaResponse struct { } var ( - promptFile string + promptFile string + editBeforeGenerate bool generateCmd = &cobra.Command{ Use: "generate [flags] [input...]", Short: "Get generation from Ollama", Long: "Requests generation from Ollama while optionally prepending a prompt", - Args: cobra.MinimumNArgs(1), - RunE: runGenerate, + Args: func(cmd *cobra.Command, args []string) error { + editFlag, _ := cmd.Flags().GetBool("edit") + if !editFlag && len(args) < 1 { + return fmt.Errorf("requires at least 1 arg when not using edit flag") + } + return nil + }, + RunE: runGenerate, } ) func init() { rootCmd.AddCommand(generateCmd) generateCmd.Flags().StringVarP(&promptFile, "prompt", "p", "", "Prompt file to prepend to input") + generateCmd.Flags().BoolVarP(&editBeforeGenerate, "edit", "e", false, "Edit input before generating") } - func runGenerate(cmd *cobra.Command, args []string) error { // Join all arguments as the input text input := strings.Join(args, " ") @@ -51,17 +58,12 @@ func runGenerate(cmd *cobra.Command, args []string) error { baseDir := config.GetPromptDir() filename := promptFile + ".md" - dir, err := findFileDir(baseDir, filename) + filepath, err := common.FindAndJoin(baseDir, filename) if err != nil { return err } - if dir == "" { - return fmt.Errorf("prompt not found") - } - - filePath := filepath.Join(dir, filename) - content, err := os.ReadFile(filePath) + content, err := os.ReadFile(filepath) if err != nil { return err } @@ -71,6 +73,33 @@ func runGenerate(cmd *cobra.Command, args []string) error { finalPrompt = input } + if editBeforeGenerate { + // Create temporary file for editing + tmpFile, err := os.CreateTemp("", "grimoire-*.txt") + if err != nil { + return err + } + defer os.Remove(tmpFile.Name()) + + // Write prompt to temp file + if _, err := tmpFile.WriteString(finalPrompt); err != nil { + return err + } + tmpFile.Close() + + // Open in editor + if err := common.OpenInEditor(tmpFile.Name()); err != nil { + return err + } + + // Read back edited content + content, err := os.ReadFile(tmpFile.Name()) + if err != nil { + return err + } + finalPrompt = string(content) + } + // Create request body reqBody := OllamaRequest{ Model: config.OllamaModel, diff --git a/src/config/config.go b/src/config/config.go index db92545..98deae8 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -39,11 +39,13 @@ tags: --- This is an example prompt.` +const EnvPrefix string = "GRIMOIRE" + var ( - OllamaModel = getEnv("OLLAMA_MODEL", "llama3") - OllamaURL = getEnv("OLLAMA_URL", "http://localhost:11434/api") - OllamaStream = getEnvAsBool("OLLAMA_STREAM", true) - Editor = getEnv("EDITOR", "vim") + OllamaModel string = getEnv(EnvPrefix+"_OLLAMA_MODEL", "llama3") + OllamaURL string = getEnv(EnvPrefix+"_OLLAMA_URL", "http://localhost:11434/api") + OllamaStream bool = getEnvAsBool(EnvPrefix+"_OLLAMA_STREAM", true) + Editor string = getEnv(EnvPrefix+"_EDITOR", "vim") ) func getEnv(key, defaultValue string) string {